From e5ca44ed88bd6ad31d2038b101fa375cab2ca85f Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 29 Oct 2026 13:36:06 +1100 Subject: [PATCH 1/8] refactor(api): read the recorded metric bundle path and stop dropping groups Resolves the metric bundle through the reader's artifacts.bundle, using the path recorded on the execution instead of assuming diagnostic.json. The execution list now raises when a group fails to build, rather than logging and leaving it out of the page. --- backend/src/ref_backend/api/routes/executions.py | 15 ++++++--------- backend/src/ref_backend/core/file_handling.py | 5 ++++- .../tests/test_api/test_routes/test_executions.py | 12 ++++++++++++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index df6d197c..ac37505c 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -135,13 +135,10 @@ def list_recent_execution_groups( # noqa: PLR0913, PLR0917 limit=limit, include_outputs=include_outputs, ) - data = [] - for eg in groups: - try: - data.append(ExecutionGroup.build(eg, app_context, include_outputs=include_outputs)) - except Exception as e: - logger.error(f"Error building execution group ID {eg.id}: {e}") - return Collection(total_count=total_count, data=data) + return Collection( + total_count=total_count, + data=[ExecutionGroup.build(eg, app_context, include_outputs=include_outputs) for eg in groups], + ) @router.get("/{group_id}") @@ -258,10 +255,10 @@ def metric_bundle( execution = _get_execution(group_id, execution_id, app_context) file_path = resolve_artifact( - app_context.reader.artifacts.output_file, execution.output_fragment, "diagnostic.json" + app_context.reader.artifacts.bundle, execution.output_fragment, execution.path ) - if not file_path.exists(): + if file_path is None or not file_path.exists(): logger.warning(f"Metric bundle not found: {file_path}") raise HTTPException(status_code=404, detail="Metrics bundle not found") diff --git a/backend/src/ref_backend/core/file_handling.py b/backend/src/ref_backend/core/file_handling.py index ba482b16..3bcf9079 100644 --- a/backend/src/ref_backend/core/file_handling.py +++ b/backend/src/ref_backend/core/file_handling.py @@ -4,11 +4,14 @@ import zlib from collections.abc import Callable, Generator from pathlib import Path +from typing import TypeVar from fastapi import HTTPException +T = TypeVar("T", bound=Path | None) -def resolve_artifact(resolve: Callable[..., Path], *parts: str) -> Path: + +def resolve_artifact(resolve: Callable[..., T], *parts: str | None) -> T: """ Call an ``ArtifactsReader`` resolver and turn a containment failure into a 404. diff --git a/backend/tests/test_api/test_routes/test_executions.py b/backend/tests/test_api/test_routes/test_executions.py index 20bd1309..ea3928db 100644 --- a/backend/tests/test_api/test_routes/test_executions.py +++ b/backend/tests/test_api/test_routes/test_executions.py @@ -683,3 +683,15 @@ def test_execution_archive(client: TestClient, settings, monkeypatch, tmp_path) with tarfile.open(fileobj=io.BytesIO(r.content), mode="r:gz") as tar: assert sorted(tar.getnames()) == ["diagnostic.json", "plots/map.png"] assert tar.extractfile("plots/map.png").read() == b"png" * 1000 + + +def test_metric_bundle(client: TestClient, settings) -> None: + group_id = get_execution_group_id(client, settings) + r = client.get(f"{settings.API_V1_STR}/executions/{group_id}/metric_bundle") + assert r.status_code == 200 + assert "RESULTS" in r.json() + + +def test_metric_bundle_404_without_recorded_bundle(client: TestClient, settings) -> None: + r = client.get(f"{settings.API_V1_STR}/executions/37/metric_bundle?execution_id=20") + assert r.status_code == 404 From 21fb1f1cf1af45bb9e2ddabeeeecf04f9c2ab4cf Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 29 Oct 2026 13:36:25 +1100 Subject: [PATCH 2/8] docs(changelog): add 173 fragments --- changelog/173.fix.md | 1 + changelog/173.improvement.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog/173.fix.md create mode 100644 changelog/173.improvement.md diff --git a/changelog/173.fix.md b/changelog/173.fix.md new file mode 100644 index 00000000..6a36e7fc --- /dev/null +++ b/changelog/173.fix.md @@ -0,0 +1 @@ +The metric bundle endpoint now reads the bundle path recorded for each execution instead of assuming `diagnostic.json`. diff --git a/changelog/173.improvement.md b/changelog/173.improvement.md new file mode 100644 index 00000000..585e1c1e --- /dev/null +++ b/changelog/173.improvement.md @@ -0,0 +1 @@ +`GET /executions/` now returns an error when an execution group cannot be loaded, instead of leaving it out of the page. From 57e2e0c2785a1bfd32dcd0b1a532be3fb824f8f4 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 29 Oct 2026 13:45:59 +1100 Subject: [PATCH 3/8] test: cover the recorded bundle path and the failing list build --- .../test_api/test_routes/test_executions.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/backend/tests/test_api/test_routes/test_executions.py b/backend/tests/test_api/test_routes/test_executions.py index ea3928db..b2d62fa3 100644 --- a/backend/tests/test_api/test_routes/test_executions.py +++ b/backend/tests/test_api/test_routes/test_executions.py @@ -1,5 +1,7 @@ import io +import json import tarfile +from types import SimpleNamespace import pytest from fastapi.testclient import TestClient @@ -692,6 +694,41 @@ def test_metric_bundle(client: TestClient, settings) -> None: assert "RESULTS" in r.json() +def test_metric_bundle_reads_recorded_path(client: TestClient, settings, monkeypatch, tmp_path) -> None: + group_id = get_execution_group_id(client, settings) + bundle = client.get(f"{settings.API_V1_STR}/executions/{group_id}/metric_bundle").json() + custom = tmp_path / "custom.json" + custom.write_text(json.dumps(bundle)) + execution = SimpleNamespace(id=1, output_fragment="some/fragment", path="custom.json") + monkeypatch.setattr(executions_routes, "_get_execution", lambda *_: execution) + calls = [] + + def fake_resolve(resolve, *parts): + calls.append((resolve.__name__, parts)) + return custom + + monkeypatch.setattr(executions_routes, "resolve_artifact", fake_resolve) + + r = client.get(f"{settings.API_V1_STR}/executions/{group_id}/metric_bundle") + + assert r.status_code == 200 + assert r.json() == bundle + assert calls == [("bundle", ("some/fragment", "custom.json"))] + + def test_metric_bundle_404_without_recorded_bundle(client: TestClient, settings) -> None: r = client.get(f"{settings.API_V1_STR}/executions/37/metric_bundle?execution_id=20") assert r.status_code == 404 + assert r.json()["detail"] == "Metrics bundle not found" + + +def test_execution_list_fails_when_a_group_cannot_be_built(app, settings, monkeypatch) -> None: + def fail_build(*args, **kwargs): + raise RuntimeError("build failed") + + monkeypatch.setattr(executions_routes.ExecutionGroup, "build", fail_build) + + with TestClient(app, raise_server_exceptions=False) as client: + r = client.get(f"{settings.API_V1_STR}/executions/") + + assert r.status_code == 500 From fe2f53caea0aadeea178ffd128b5096f2f661abc Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 29 Oct 2026 14:05:10 +1100 Subject: [PATCH 4/8] fix(api): allow execution outputs with empty names, matching climate-ref ExecutionOutput's filename, short_name, long_name and description are nullable in climate-ref, so a null no longer fails validation. The figure components handle the missing values. --- backend/src/ref_backend/models/executions.py | 8 ++++---- changelog/173.fix.1.md | 1 + frontend/src/client/types.gen.ts | 8 ++++---- frontend/src/components/diagnostics/figure.tsx | 14 ++++++++++---- .../components/diagnostics/figureGalleryData.ts | 5 +++-- 5 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 changelog/173.fix.1.md diff --git a/backend/src/ref_backend/models/executions.py b/backend/src/ref_backend/models/executions.py index 4cfdbc97..39d2fc16 100644 --- a/backend/src/ref_backend/models/executions.py +++ b/backend/src/ref_backend/models/executions.py @@ -19,10 +19,10 @@ class ExecutionOutput(BaseModel): id: int execution_id: int output_type: ResultOutputType - filename: str - short_name: str - long_name: str - description: str + filename: str | None + short_name: str | None + long_name: str | None + description: str | None created_at: datetime updated_at: datetime url: str diff --git a/changelog/173.fix.1.md b/changelog/173.fix.1.md new file mode 100644 index 00000000..751c66d2 --- /dev/null +++ b/changelog/173.fix.1.md @@ -0,0 +1 @@ +Execution outputs with no filename, name or description no longer fail to load, matching the fields `climate-ref` allows to be empty. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index d1074d13..5091af15 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1013,19 +1013,19 @@ export type ExecutionOutput = { /** * Filename */ - filename: string; + filename: string | null; /** * Short Name */ - short_name: string; + short_name: string | null; /** * Long Name */ - long_name: string; + long_name: string | null; /** * Description */ - description: string; + description: string | null; /** * Created At */ diff --git a/frontend/src/components/diagnostics/figure.tsx b/frontend/src/components/diagnostics/figure.tsx index 9e0b13a5..d41fe0be 100644 --- a/frontend/src/components/diagnostics/figure.tsx +++ b/frontend/src/components/diagnostics/figure.tsx @@ -21,15 +21,21 @@ export function Figure({ long_name, size, }: ExecutionOutput & VariantProps) { - const hasLatex = long_name ? containsLatex(long_name) : false; const captionHtml = useMemo( - () => (hasLatex ? renderLatexToHtml(long_name) : null), - [long_name, hasLatex], + () => + long_name && containsLatex(long_name) + ? renderLatexToHtml(long_name) + : null, + [long_name], ); return (
- {long_name} + {long_name {captionHtml ? ( matchesSelectorFilters(executionGroup.selectors, selectorFilters) && (!pattern || - pattern.test(figure.description) || - pattern.test(figure.filename)), + [figure.description, figure.filename].some( + (field) => field != null && pattern.test(field), + )), ); } From 60f6994d755f0acfc57a75683288b0a1f96e8dd0 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 4 Nov 2026 02:59:46 +0000 Subject: [PATCH 5/8] refactor(api): type resolve_artifact by its resolver's signature Types resolve_artifact with a ParamSpec instead of accepting any str | None. This keeps mypy checking each call against the resolver it wraps. --- backend/src/ref_backend/core/file_handling.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/src/ref_backend/core/file_handling.py b/backend/src/ref_backend/core/file_handling.py index 3bcf9079..a2f755c7 100644 --- a/backend/src/ref_backend/core/file_handling.py +++ b/backend/src/ref_backend/core/file_handling.py @@ -4,14 +4,15 @@ import zlib from collections.abc import Callable, Generator from pathlib import Path -from typing import TypeVar +from typing import ParamSpec, TypeVar from fastapi import HTTPException +P = ParamSpec("P") T = TypeVar("T", bound=Path | None) -def resolve_artifact(resolve: Callable[..., T], *parts: str | None) -> T: +def resolve_artifact(resolve: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T: """ Call an ``ArtifactsReader`` resolver and turn a containment failure into a 404. @@ -22,11 +23,11 @@ def resolve_artifact(resolve: Callable[..., T], *parts: str | None) -> T: ---------- resolve Bound ``ArtifactsReader`` method, such as ``artifacts.log_file`` - parts + args, kwargs Arguments to pass to the resolver """ try: - return resolve(*parts) + return resolve(*args, **kwargs) except ValueError: raise HTTPException(status_code=404, detail="Execution output not found") From 8777e97baefc8a346568fb26b98b30aecc0f292f Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 4 Nov 2026 03:11:20 +0000 Subject: [PATCH 6/8] build: regenerate the OpenAPI spec for nullable execution output fields --- backend/openapi.json | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/backend/openapi.json b/backend/openapi.json index 7b6eb298..da7ad092 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -4222,19 +4222,47 @@ "$ref": "#/components/schemas/ResultOutputType" }, "filename": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Filename" }, "short_name": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Short Name" }, "long_name": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Long Name" }, "description": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Description" }, "created_at": { From 50756bd22fe92947dcc79e631182a0176e28c798 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 4 Nov 2026 03:31:38 +0000 Subject: [PATCH 7/8] fix(api): return 404 for a result with no filename Outputs can now have a null filename, but their result URL still passed it to the resolver and failed with a 500. --- backend/src/ref_backend/api/routes/results.py | 3 +++ .../test_api/test_routes/test_results.py | 21 +++++++++++++++++++ changelog/173.fix.1.md | 1 + 3 files changed, 25 insertions(+) diff --git a/backend/src/ref_backend/api/routes/results.py b/backend/src/ref_backend/api/routes/results.py index 19a5f31d..6fb40d42 100644 --- a/backend/src/ref_backend/api/routes/results.py +++ b/backend/src/ref_backend/api/routes/results.py @@ -24,6 +24,9 @@ def get_result(session: SessionDep, reader: ReaderDep, result_id: int) -> Stream # Release the connection now, so streaming the file does not hold it open session.close() + if filename is None: + raise HTTPException(status_code=404, detail="Result file not found") + file_path = resolve_artifact(reader.artifacts.output_file, output_fragment, filename) mime_type, _encoding = mimetypes.guess_type(file_path) diff --git a/backend/tests/test_api/test_routes/test_results.py b/backend/tests/test_api/test_routes/test_results.py index b3029bb4..4c6f3737 100644 --- a/backend/tests/test_api/test_routes/test_results.py +++ b/backend/tests/test_api/test_routes/test_results.py @@ -1,6 +1,8 @@ import pytest from fastapi.testclient import TestClient +from sqlalchemy import select +from climate_ref.database import Database from climate_ref.models import ExecutionOutput from ref_backend.api import deps from ref_backend.api.routes.results import get_result @@ -71,3 +73,22 @@ def test_result_releases_connection_before_streaming(): assert response.status_code == 200 assert pool.checkedout() == idle + + +def test_result_without_filename_is_not_found(writable_client: TestClient, settings): + """An output with no recorded filename is listed but has no file to serve.""" + with Database.from_config(writable_client.ref_config, run_migrations=False).session_scope() as session: # type: ignore[attr-defined] + output = session.scalars(select(ExecutionOutput).limit(1)).one() + output.filename = None # type: ignore[assignment] + result_id = output.id + group_id = output.execution.execution_group_id + session.commit() + + r = writable_client.get(f"{settings.API_V1_STR}/executions/{group_id}") + assert r.status_code == 200 + outputs = [o for e in r.json()["executions"] for o in e["outputs"]] + assert next(o for o in outputs if o["id"] == result_id)["filename"] is None + + r = writable_client.get(f"{settings.API_V1_STR}/results/{result_id}") + assert r.status_code == 404 + assert r.json()["detail"] == "Result file not found" diff --git a/changelog/173.fix.1.md b/changelog/173.fix.1.md index 751c66d2..a20ad4a7 100644 --- a/changelog/173.fix.1.md +++ b/changelog/173.fix.1.md @@ -1 +1,2 @@ Execution outputs with no filename, name or description no longer fail to load, matching the fields `climate-ref` allows to be empty. +Downloading an output with no filename now returns a 404 instead of a server error. From 59cf342aa9827afc3cbb006d0151da0d2bafbefe Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 4 Nov 2026 03:31:38 +0000 Subject: [PATCH 8/8] build: pin the project name when generating the OpenAPI spec A local .env could set PROJECT_NAME and change the committed spec title. This uses the default the deployed API serves. --- backend/openapi.json | 2 +- changelog/173.trivial.md | 1 + scripts/generate-client-sdk.sh | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 changelog/173.trivial.md diff --git a/backend/openapi.json b/backend/openapi.json index da7ad092..c75c271d 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.1.0", "info": { - "title": "Rapid Evaluation Framework", + "title": "Climate Rapid Evaluation Framework", "description": "\nAPI for querying the results from the Climate Rapid Evaluation Framework (Climate REF).\nThis is used to back the [Climate REF Explorer](https://dashboard.climate-ref.org) web application.\n\nThis API is a work in progress and will likely change in the future without warning.\nWe are interested in your feedback on how you would like to use this API.\nThis will help us prioritize features, and be aware of any API consumers before we make any changes.\n\nContact [Jared Lewis](mailto:jared.lewis@climate-resource.com) or\n[file an issue on GitHub](https://github.com/Climate-REF/ref-app/issues),\nif you have any questions or feedback.\n", "contact": { "name": "Development Lead - Jared Lewis", diff --git a/changelog/173.trivial.md b/changelog/173.trivial.md new file mode 100644 index 00000000..160f0c16 --- /dev/null +++ b/changelog/173.trivial.md @@ -0,0 +1 @@ +The OpenAPI spec now uses a fixed project name, so a local `.env` no longer changes it. diff --git a/scripts/generate-client-sdk.sh b/scripts/generate-client-sdk.sh index 054db9a3..dab2df29 100644 --- a/scripts/generate-client-sdk.sh +++ b/scripts/generate-client-sdk.sh @@ -7,7 +7,8 @@ set -x cd "$(dirname "$0")/.." pushd backend -uv run python -c "import ref_backend.main; import json; print(json.dumps(ref_backend.main.app.openapi(), indent=2))" > openapi.json +# Pinned so a local .env cannot change the committed spec. +PROJECT_NAME="Climate Rapid Evaluation Framework" uv run python -c "import ref_backend.main; import json; print(json.dumps(ref_backend.main.app.openapi(), indent=2))" > openapi.json popd # openapi-ts needs the TypeScript 5 compiler API, which TypeScript 7 does not ship.