From c979066b5d08b0f548bcd398545234ac18b31e18 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 21 Oct 2026 17:36:55 +0200 Subject: [PATCH 1/4] test(contract): check the app against the client contract tests/contract/test_client_contract.py checks the composed app against the scenarios, strict fields and actions that dfo-spec-kit-client-contract ships. Each recorded exchange must match a route and validate against its request and response schemas. Every strict member must stay required and non-nullable, and every strict enum value must stay admitted. Every action must name an active registry code, and every problem code must carry the status the server would pick for its route. The six spec-kit pins move to 98d78be together, and the test group gains dfo-spec-kit-client-contract. CONTRIBUTING.md describes how to change what the clients read. --- CONTRIBUTING.md | 16 ++ packages/dfo-data-tools/pyproject.toml | 8 +- pyproject.toml | 13 +- tests/contract/__init__.py | 0 tests/contract/test_client_contract.py | 335 +++++++++++++++++++++++++ uv.lock | 27 +- 6 files changed, 382 insertions(+), 17 deletions(-) create mode 100644 tests/contract/__init__.py create mode 100644 tests/contract/test_client_contract.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6f628e8..12c4b631 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -202,3 +202,19 @@ surface's `prefix` at all. (A successful `/codes/{code}` lookup returns a bare either `dfo_spec_kit.registry.REGISTRY` or the merged refusal catalogue. Registry-derived fields may be null and `mappings` empty. `title` and `detail` may be null when the merged catalogue has no entry. Only the refusal goes through the envelope.) You never set or read that token yourself. + +### Changing what the clients read + +`tests/contract/test_client_contract.py` checks the app against the client contract spec-kit +ships as `dfo-spec-kit-client-contract`. Each recorded scenario's requests and responses must +validate against the app's OpenAPI document, every required member `strict-fields.json` lists +must stay required and non-nullable, every enum value it lists must stay admitted, and every +problem code a response carries must stay registered on its route at its status. When the test +fails for a wire change you intend, commit the change, record the contract against your branch's +local app with spec-kit's `tools/capture_client_contract.py` +(`openapi --dfo-apis `, then `record --base-url http://127.0.0.1:8083/api`), open the +spec-kit PR, and cut a spec-kit release. Move all six spec-kit pins to that release's commit in +the same dfo-apis PR as the change: `dfo-spec-kit` and `dfo-spec-kit-client-contract` in the root +`pyproject.toml`, and the four in `packages/dfo-data-tools/pyproject.toml`. A change that keeps +the shape and alters behaviour passes this test, and the sdk's scheduled end-to-end run catches +it after merge. diff --git a/packages/dfo-data-tools/pyproject.toml b/packages/dfo-data-tools/pyproject.toml index e8f72624..fc2d152c 100644 --- a/packages/dfo-data-tools/pyproject.toml +++ b/packages/dfo-data-tools/pyproject.toml @@ -86,10 +86,10 @@ graph-release = ["google-cloud-spanner>=3.57", "dfo-data-tools[coverage]"] [tool.uv.sources] # See the root pyproject.toml's dfo-spec-kit source comment for the # pin-consistency invariant this rev must satisfy. -dfo-spec-kit-frame-grammar = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "3616b64245c2db8af3187552e9e5de99b6d304d4", subdirectory = "python/frame-grammar" } -dfo-spec-kit-components = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "3616b64245c2db8af3187552e9e5de99b6d304d4", subdirectory = "python/dfo-spec-kit-components" } -dfo-spec-kit-provenance-docs = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "3616b64245c2db8af3187552e9e5de99b6d304d4", subdirectory = "python/provenance-docs" } -dfo-spec-kit-descriptions = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "3616b64245c2db8af3187552e9e5de99b6d304d4", subdirectory = "python/descriptions" } +dfo-spec-kit-frame-grammar = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/frame-grammar" } +dfo-spec-kit-components = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/dfo-spec-kit-components" } +dfo-spec-kit-provenance-docs = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/provenance-docs" } +dfo-spec-kit-descriptions = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/descriptions" } [build-system] requires = ["uv_build>=0.11,<0.12"] diff --git a/pyproject.toml b/pyproject.toml index 00bc3b1a..cd092a32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -135,6 +135,8 @@ test = [ # A bare `just test` selects the tests affected by a change; CI never # passes --testmon, so it stays inactive there. "pytest-testmon>=2.2.0", + # tests/contract/test_client_contract.py reads the recorded client scenarios. + "dfo-spec-kit-client-contract", ] [build-system] @@ -163,7 +165,10 @@ dfo-data-tools = { workspace = true } # uv resolves relative to frame-grammar's own pinned commit -- so this # project's own `dfo-spec-kit` source MUST resolve to that identical commit, # or `uv lock` refuses with "conflicting URLs for package dfo-spec-kit" -# (confirmed live). All five pins move together as a single rev. This rev's +# (confirmed live). All six pins move together as a single rev: these four, +# `dfo-spec-kit`, and `dfo-spec-kit-client-contract`. uv does not force the +# contract package onto this commit; tests/contract/test_client_contract.py +# checks that its rev equals `dfo-spec-kit`'s. This rev's # registry schema has no `spec_resubmit` door: a code with a raise site in # this repo maps to `dataset_resubmit` instead. `entity_set_too_large` is # mapped on `search.resolve` for the `raw_text` and `parsed` doors, which serve @@ -239,9 +244,11 @@ dfo-data-tools = { workspace = true } # The pin is a commit of spec-kit's main. It registers # slot_default_not_applied and the three source-code refusals, and retires # starved_axis_binding_restored. -# Follow-up: move all five to a real spec-kit tag once one is cut, so a +# Follow-up: move all six, `dfo-spec-kit-client-contract` included, to a real +# spec-kit tag once one is cut, so a # future force-push or GC on spec-kit's main can't silently break this lock. -dfo-spec-kit = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "3616b64245c2db8af3187552e9e5de99b6d304d4", subdirectory = "python/spec-kit" } +dfo-spec-kit = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/spec-kit" } +dfo-spec-kit-client-contract = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/client-contract" } [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/contract/__init__.py b/tests/contract/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/contract/test_client_contract.py b/tests/contract/test_client_contract.py new file mode 100644 index 00000000..25db6615 --- /dev/null +++ b/tests/contract/test_client_contract.py @@ -0,0 +1,335 @@ +"""The composed app serves every exchange the client contract records. + +`dfo_client_contract` ships the scenarios the DFO Python and TypeScript clients replay, the +response members they act on (`strict-fields.json`) and the caller's next step for each refusal +code (`actions.json`). Each check reads the app's own OpenAPI document, built in process with the +deployed surfaces (Search, Datasets, Graph and Presentation) as the contract's capture tool builds +it, and the code registry the app serves: + +- every recorded scenario's API exchange matches a route the app serves, its request body + validates against that route's request schema, and its response body against the route's + schema for that status, or the route's default response when it lists none for the status; +- every problem code a recorded response carries is active, and its status is the one the server + would serve (`dfo_platform.codes.registered_status`): the one status the registry gives the code + on the route's registry endpoint, or across every endpoint when the route's surface maps the + route to none in `Surface.registry_endpoints`; +- every strict required member stays required and admits no null, and every strict enum value + stays admitted; +- every `actions.json` code is an active registry code. + +A synthetic scenario edits a recorded one to test a client's handling of a malformed answer, and +a `url` exchange fetches from storage, so neither is checked here. +""" + +from __future__ import annotations + +import re +import tomllib +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlsplit + +import pytest +from dfo_client_contract import actions, scenarios, strict_fields +from dfo_spec_kit.registry import REGISTRY +from fastapi.routing import APIRoute +from jsonschema import Draft202012Validator +from jsonschema.exceptions import ValidationError, best_match +from jsonschema.protocols import Validator +from referencing import Registry, Resource +from referencing.jsonschema import DRAFT202012 +from starlette.routing import Match + +import dfo_apis.app as dfo_apis_app +from dfo_platform.app import Surface + +# The deployed composition's surface flags, as the capture tool sets them. +_DEPLOYED_FLAGS = { + "DFO_DATASETS_SURFACE": "1", + "DFO_GRAPH_SURFACE": "1", + "DFO_PRESENTATION_SURFACE": "1", +} +_UNDEPLOYED_FLAGS = ("DFO_SKILLS_SURFACE", "DFO_WORKER_SURFACE") +_DOCUMENT_URI = "urn:dfo-apis:openapi" +_PROBLEM_MEDIA_TYPE = "application/problem+json" +# A validation message quotes the failing instance, which can be a whole answer. +_MESSAGE_LIMIT = 300 +# Starlette keeps converters on routes, while OpenAPI strips `{id:dfo_spec_id}` to `{id}`. +_CONVERTER_SUFFIX_RE = re.compile(r"\{([^:}]+):[^}]+\}") + +_RECORDED = [s for s in scenarios() if s["origin"]["kind"] == "recorded"] +_PYPROJECT = Path(__file__).resolve().parents[2] / "pyproject.toml" + + +@dataclass(frozen=True) +class _Served: + """The composed app's OpenAPI document and the surfaces it was built from.""" + + document: dict[str, Any] + surfaces: tuple[Surface, ...] + schemas: Registry + + +@dataclass(frozen=True) +class _Route: + """The route that serves one recorded request.""" + + path: str + method: str + endpoint: str | None + operation: Mapping[str, Any] + + +@pytest.fixture(scope="session") +def served() -> _Served: + """Build the deployed composition once and read its document. + + `create_app()` composes `deployed_surfaces()`. The fixture keeps the surfaces so a request's + route and registry endpoint come from the router that serves it. + """ + with pytest.MonkeyPatch.context() as patch: + for name, value in _DEPLOYED_FLAGS.items(): + patch.setenv(name, value) + for name in _UNDEPLOYED_FLAGS: + patch.delenv(name, raising=False) + surfaces = tuple(dfo_apis_app.deployed_surfaces()) + patch.setattr(dfo_apis_app, "deployed_surfaces", lambda: list(surfaces)) + document = dfo_apis_app.create_app().openapi() + schemas = Registry().with_resource( + _DOCUMENT_URI, Resource.from_contents(document, default_specification=DRAFT202012) + ) + return _Served(document=document, surfaces=surfaces, schemas=schemas) + + +def _pointer(*tokens: str) -> str: + return "".join("/" + token.replace("~", "~0").replace("/", "~1") for token in tokens) + + +def _resolve(document: Mapping[str, Any], pointer: str) -> Any: + node: Any = document + for token in pointer.removeprefix("/").split("/"): + node = node[token.replace("~1", "/").replace("~0", "~")] + return node + + +def _validator(served: _Served, pointer: str) -> Validator: + """A validator for the schema at `pointer` in the app's document.""" + return Draft202012Validator({"$ref": f"{_DOCUMENT_URI}#{pointer}"}, registry=served.schemas) + + +def _causes(error: ValidationError) -> Iterator[ValidationError]: + """The most specific cause of `error` in each `oneOf` or `anyOf` branch it tried.""" + if not error.context: + yield error + return + branches: dict[str | int, list[ValidationError]] = {} + for cause in error.context: + branches.setdefault(cause.relative_schema_path[0], []).append(cause) + for causes in branches.values(): + if (cause := best_match(causes)) is not None: + yield from _causes(cause) + + +def _errors(served: _Served, pointer: str, instance: object) -> list[str]: + return [ + f"{cause.json_path}: {cause.message[:_MESSAGE_LIMIT]}" + for error in _validator(served, pointer).iter_errors(instance) + for cause in _causes(error) + ] + + +def _request_path(base_url: str, request: Mapping[str, Any]) -> str | None: + """The request's path on the app, or `None` for a storage fetch. + + A `path` resolves under the base URL's path and an `origin_path` against its origin.""" + if "path" in request: + raw = urlsplit(base_url).path.rstrip("/") + request["path"] + elif "origin_path" in request: + raw = request["origin_path"] + else: + return None + return unquote(urlsplit(raw).path) + + +def _route(served: _Served, method: str, path: str) -> _Route | None: + """The route that serves `method path`, as the app's router picks it.""" + scope = {"type": "http", "method": method, "path": path, "root_path": ""} + for surface in served.surfaces: + for route in surface.router.routes: + if not isinstance(route, APIRoute) or route.matches(scope)[0] is not Match.FULL: + continue + schema_path = _CONVERTER_SUFFIX_RE.sub(r"{\1}", route.path) + operation = served.document["paths"].get(schema_path, {}).get(method.lower()) + if not route.include_in_schema or operation is None: + return None + return _Route( + path=schema_path, + method=method.lower(), + endpoint=surface.registry_endpoints.get(route.path), + operation=operation, + ) + return None + + +def _api_exchanges( + served: _Served, scenario: Mapping[str, Any] +) -> Iterator[tuple[int, Mapping[str, Any], Mapping[str, Any], _Route | None]]: + """Each API exchange with its index, request, response and the route that serves it.""" + base_url = scenario["origin"]["base_url"] + for index, exchange in enumerate(scenario["exchanges"]): + request = exchange["request"] + path = _request_path(base_url, request) + if path is None: + continue + yield index, request, exchange["response"], _route(served, request["method"], path) + + +def _media_type(response: Mapping[str, Any]) -> str: + content_type = response.get("headers", {}).get("content-type", "application/json") + return content_type.split(";", 1)[0].strip() + + +def _problem_statuses(code: str, endpoint: str | None) -> frozenset[int]: + """The statuses the registry gives `code`'s problem details on `endpoint`, or on every + endpoint when `endpoint` is `None`, as `dfo_platform.codes.registered_status` reads them. + + The server answers with the status only when this set holds exactly one.""" + statuses = ( + mapping.http_status + for mapping in REGISTRY[code].mappings + if mapping.channel == "problem_details" + and mapping.http_status is not None + and (endpoint is None or mapping.endpoint == endpoint) + ) + return frozenset(statuses) + + +def _is_active(code: str) -> bool: + return code in REGISTRY and REGISTRY[code].lifecycle == "active" + + +def test_the_contract_records_scenarios(served: _Served): + assert _RECORDED, "the client contract installs no recorded scenario" + problems = [ + response + for scenario in _RECORDED + for _, _, response, _ in _api_exchanges(served, scenario) + if _media_type(response) == _PROBLEM_MEDIA_TYPE + ] + assert problems, "no recorded API response is a problem document, so no code is checked" + + +@pytest.mark.parametrize("scenario", _RECORDED, ids=[s["name"] for s in _RECORDED]) +def test_recorded_exchanges_match_the_served_document(served: _Served, scenario: Mapping[str, Any]): + """Each API exchange names a served route, and its bodies validate against that route's + request schema and its response schema for the recorded status.""" + problems: list[str] = [] + checked = 0 + for index, request, response, route in _api_exchanges(served, scenario): + checked += 1 + where = f"exchange {index} {request['method']}" + if route is None: + problems.append(f"{where}: no route in the served document serves the request") + continue + operation_pointer = ("paths", route.path, route.method) + + if "json" in request: + content = route.operation.get("requestBody", {}).get("content", {}) + if "application/json" not in content: + problems.append(f"{where} {route.path}: the route takes no JSON body") + else: + pointer = _pointer( + *operation_pointer, "requestBody", "content", "application/json", "schema" + ) + problems.extend( + f"{where} {route.path} request {error}" + for error in _errors(served, pointer, request["json"]) + ) + + responses = route.operation["responses"] + status = str(response["status"]) + key = status if status in responses else "default" + if key not in responses: + problems.append(f"{where} {route.path}: no response for status {status}") + continue + if "json" not in response: + continue + media_type = _media_type(response) + if media_type not in responses[key].get("content", {}): + problems.append(f"{where} {route.path}: status {key} serves no {media_type}") + continue + pointer = _pointer(*operation_pointer, "responses", key, "content", media_type, "schema") + problems.extend( + f"{where} {route.path} response {status} {error}" + for error in _errors(served, pointer, response["json"]) + ) + + assert checked, "the scenario records no API exchange" + assert not problems, "\n".join(problems) + + +@pytest.mark.parametrize("scenario", _RECORDED, ids=[s["name"] for s in _RECORDED]) +def test_recorded_problem_codes_are_registered_on_their_route( + served: _Served, scenario: Mapping[str, Any] +): + """Each problem code a response carries is active and answers with the one status the + registry gives it on the route's endpoint, or across every endpoint when the route maps to + no endpoint.""" + problems: list[str] = [] + for index, _request, response, route in _api_exchanges(served, scenario): + if _media_type(response) != _PROBLEM_MEDIA_TYPE or "json" not in response: + continue + code = response["json"].get("code") + path = route.path if route is not None else None + endpoint = route.endpoint if route is not None else None + where = f"exchange {index} {path}" + if code is None or not _is_active(code): + problems.append(f"{where}: {code!r} is not an active registry code") + continue + statuses = sorted(_problem_statuses(code, endpoint)) + scope = f"on {endpoint!r}" if endpoint is not None else "across every endpoint" + if len(statuses) != 1: + problems.append( + f"{where}: the registry gives {code!r} the statuses {statuses} {scope}, so the " + "server cannot pick one; declare the route's endpoint in its surface's " + "`registry_endpoints`" + ) + elif response["status"] != statuses[0]: + problems.append( + f"{where}: the registry gives {code!r} status {statuses[0]} {scope}, " + f"not {response['status']}" + ) + assert not problems, "\n".join(problems) + + +_REQUIRED = [entry["pointer"] for entry in strict_fields()["required"]] +_ENUMS = [(entry["pointer"], entry["values"]) for entry in strict_fields()["enums"]] + + +@pytest.mark.parametrize("pointer", _REQUIRED) +def test_strict_member_stays_required_and_non_nullable(served: _Served, pointer: str): + parent, separator, member = pointer.rpartition("/properties/") + assert separator, f"{pointer} names no property" + assert member in _resolve(served.document, parent).get("required", []) + assert not _validator(served, pointer).is_valid(None), f"{pointer} admits null" + + +@pytest.mark.parametrize(("pointer", "values"), _ENUMS, ids=[p for p, _ in _ENUMS]) +def test_strict_enum_keeps_every_value(served: _Served, pointer: str, values: list[str]): + validator = _validator(served, pointer) + assert [value for value in values if not validator.is_valid(value)] == [] + + +def test_every_action_names_an_active_registry_code(): + codes = [row["code"] for row in actions()["codes"]] + assert codes + assert [code for code in codes if not _is_active(code)] == [] + + +def test_the_contract_package_pins_spec_kits_commit(): + """uv forces dfo-data-tools's spec-kit pins onto `dfo-spec-kit`'s commit and nothing forces + the contract package's, so this test does.""" + sources = tomllib.loads(_PYPROJECT.read_text())["tool"]["uv"]["sources"] + assert sources["dfo-spec-kit-client-contract"]["rev"] == sources["dfo-spec-kit"]["rev"] diff --git a/uv.lock b/uv.lock index 68e4c0bd..1805a43e 100644 --- a/uv.lock +++ b/uv.lock @@ -720,10 +720,10 @@ requires-dist = [ { name = "dfo-data-tools", extras = ["generation"], marker = "extra == 'resolve'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["sdmx"], marker = "extra == 'coverage'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["sdmx"], marker = "extra == 'formats'", editable = "packages/dfo-data-tools" }, - { name = "dfo-spec-kit-components", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=3616b64245c2db8af3187552e9e5de99b6d304d4" }, - { name = "dfo-spec-kit-descriptions", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=3616b64245c2db8af3187552e9e5de99b6d304d4" }, - { name = "dfo-spec-kit-frame-grammar", extras = ["arrow"], git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=3616b64245c2db8af3187552e9e5de99b6d304d4" }, - { name = "dfo-spec-kit-provenance-docs", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=3616b64245c2db8af3187552e9e5de99b6d304d4" }, + { name = "dfo-spec-kit-components", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, + { name = "dfo-spec-kit-descriptions", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, + { name = "dfo-spec-kit-frame-grammar", extras = ["arrow"], git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, + { name = "dfo-spec-kit-provenance-docs", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, { name = "fastapi", marker = "extra == 'surface'", specifier = ">=0.138" }, { name = "google-auth", extras = ["requests"], marker = "extra == 'generation'", specifier = ">=2.55" }, { name = "google-cloud-bigquery", marker = "extra == 'coverage'", specifier = ">=3.27" }, @@ -748,15 +748,20 @@ provides-extras = ["sdmx", "formats", "surface", "resolve", "generation", "cover [[package]] name = "dfo-spec-kit" version = "0.2.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=3616b64245c2db8af3187552e9e5de99b6d304d4#3616b64245c2db8af3187552e9e5de99b6d304d4" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } dependencies = [ { name = "rfc8785" }, ] +[[package]] +name = "dfo-spec-kit-client-contract" +version = "0.1.0" +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fclient-contract&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } + [[package]] name = "dfo-spec-kit-components" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=3616b64245c2db8af3187552e9e5de99b6d304d4#3616b64245c2db8af3187552e9e5de99b6d304d4" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } dependencies = [ { name = "dfo-spec-kit-descriptions" }, ] @@ -764,7 +769,7 @@ dependencies = [ [[package]] name = "dfo-spec-kit-descriptions" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=3616b64245c2db8af3187552e9e5de99b6d304d4#3616b64245c2db8af3187552e9e5de99b6d304d4" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } dependencies = [ { name = "rfc8785" }, ] @@ -772,7 +777,7 @@ dependencies = [ [[package]] name = "dfo-spec-kit-frame-grammar" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=3616b64245c2db8af3187552e9e5de99b6d304d4#3616b64245c2db8af3187552e9e5de99b6d304d4" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } dependencies = [ { name = "dfo-spec-kit" }, { name = "pyarrow" }, @@ -782,7 +787,7 @@ dependencies = [ [[package]] name = "dfo-spec-kit-provenance-docs" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=3616b64245c2db8af3187552e9e5de99b6d304d4#3616b64245c2db8af3187552e9e5de99b6d304d4" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } dependencies = [ { name = "markdown-it-py" }, ] @@ -2906,6 +2911,7 @@ lint = [ { name = "ruff" }, ] test = [ + { name = "dfo-spec-kit-client-contract" }, { name = "frictionless" }, { name = "pytest" }, { name = "pytest-split" }, @@ -2927,7 +2933,7 @@ requires-dist = [ { name = "dfo-data-tools", extras = ["resolve", "sdmx", "coverage"], marker = "extra == 'graph'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["sdmx"], marker = "extra == 'sdmx'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["surface", "resolve", "coverage", "sdmx"], marker = "extra == 'engine'", editable = "packages/dfo-data-tools" }, - { name = "dfo-spec-kit", marker = "extra == 'engine'", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=3616b64245c2db8af3187552e9e5de99b6d304d4" }, + { name = "dfo-spec-kit", marker = "extra == 'engine'", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.138" }, { name = "google-auth", marker = "extra == 'datasets'", specifier = ">=2.34" }, { name = "google-auth", extras = ["requests"], marker = "extra == 'eval'", specifier = ">=2.34" }, @@ -2958,6 +2964,7 @@ lint = [ { name = "ruff", specifier = ">=0.15" }, ] test = [ + { name = "dfo-spec-kit-client-contract", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fclient-contract&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, { name = "frictionless", specifier = ">=5.19.0" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-split", specifier = ">=0.10.0" }, From 8ef7c003b8b97bf1b8fecc9828d8890aa4ae6435 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 21 Oct 2026 17:57:57 +0200 Subject: [PATCH 2/4] fix(search): bind the Search routes to their code registry endpoints The Search surface declared no registry_endpoints, so registered_status read a Search code's status across every endpoint, and found two for facet_binding_unsupported: 501 on Search and 422 elsewhere. /api/search/resolve binds search.resolve and /api/search/options binds search.options, as the Graph and Datasets routes bind theirs. Search's classifier sets each status itself, so no response changes. tests/engine/test_refusal_registry.py checks that every search.* endpoint the registry maps is bound to a route, and that each Search exception exemplar goes out at the status the registry gives its code on each Search endpoint that maps it. --- src/search/engine/app.py | 4 ++ tests/engine/test_refusal_registry.py | 76 +++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/engine/test_refusal_registry.py diff --git a/src/search/engine/app.py b/src/search/engine/app.py index 35c1690d..2270dc16 100644 --- a/src/search/engine/app.py +++ b/src/search/engine/app.py @@ -1234,6 +1234,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: UpstreamVersionChangedError("exemplar rate table changed version during its load"), ), lifespan=lifespan, + registry_endpoints={ + "/api/search/resolve": "search.resolve", + "/api/search/options": "search.options", + }, request_scope=lambda: _request_scope(catalogue, release, conversions, variable_breadth), ) diff --git a/tests/engine/test_refusal_registry.py b/tests/engine/test_refusal_registry.py new file mode 100644 index 00000000..8e7ab66c --- /dev/null +++ b/tests/engine/test_refusal_registry.py @@ -0,0 +1,76 @@ +"""Every Search route that refuses binds the code registry endpoint it serves, and every exemplar +the Search surface declares goes out at the status the registry gives its code on each Search +endpoint that maps it. +""" + +from __future__ import annotations + +from dfo_spec_kit.registry import REGISTRY + +from dfo_platform.codes import bound_registry_endpoint +from search.engine.app import search_surface + +# Routes that serve no registry endpoint: they refuse only with platform codes. +_UNBOUND_PATHS = frozenset( + { + "/api/search/healthz", + "/api/search/schema", + "/api/search/capabilities", + "/api/search/catalogue", + } +) + + +def _statuses(code: str, endpoint: str) -> set[int]: + """The statuses the registry gives `code`'s problem details on `endpoint`.""" + entry = REGISTRY[code] if code in REGISTRY else None # noqa: SIM401 -- CodeRegistry has no .get() + return { + mapping.http_status + for mapping in (entry.mappings if entry is not None else ()) + if mapping.channel == "problem_details" + and mapping.http_status is not None + and mapping.endpoint == endpoint + } + + +def test_every_search_route_binds_the_registry_endpoint_it_serves(): + """The surface binds every Search endpoint the registry maps, each to a route it serves, and + leaves only the routes in `_UNBOUND_PATHS` unbound.""" + surface = search_surface() + registered = { + mapping.endpoint + for entry in REGISTRY.entries + for mapping in entry.mappings + if mapping.endpoint.startswith("search.") + } + routed = {getattr(route, "path", None) for route in surface.router.routes} + + assert set(surface.registry_endpoints.values()) == registered + assert routed - set(surface.registry_endpoints) == _UNBOUND_PATHS + + +def test_every_search_exemplar_goes_out_at_its_registered_status(): + """Each exemplar, classified with each Search endpoint bound, goes out at the one status the + registry gives its code there. Its code is mapped on at least one Search endpoint.""" + surface = search_surface() + endpoints = sorted(set(surface.registry_endpoints.values())) + assert endpoints + disagreements: set[tuple[str, str, str, int]] = set() + unmapped: set[str] = set() + for exemplar in surface.exception_exemplars: + mapped = False + for endpoint in endpoints: + with bound_registry_endpoint(endpoint): + spec = surface.classify(exemplar) + if spec is None: + continue + statuses = _statuses(spec.code, endpoint) + if not statuses: + continue + mapped = True + if statuses != {spec.status}: + disagreements.add((type(exemplar).__qualname__, endpoint, spec.code, spec.status)) + if not mapped: + unmapped.add(type(exemplar).__qualname__) + assert sorted(disagreements) == [] + assert sorted(unmapped) == [] From 53b940e47a854ed8096bd5b8afd36035b9e6c498 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Fri, 23 Oct 2026 13:30:38 +0200 Subject: [PATCH 3/4] chore(contract): pin spec-kit at the client contract's dev merge Move all six spec-kit pins to 95a15cf, where spec-kit#11 landed on dev, so the gate checks the 118 scenarios the SDK clients replay. --- packages/dfo-data-tools/pyproject.toml | 8 ++++---- pyproject.toml | 4 ++-- uv.lock | 24 ++++++++++++------------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/dfo-data-tools/pyproject.toml b/packages/dfo-data-tools/pyproject.toml index fc2d152c..2a447980 100644 --- a/packages/dfo-data-tools/pyproject.toml +++ b/packages/dfo-data-tools/pyproject.toml @@ -86,10 +86,10 @@ graph-release = ["google-cloud-spanner>=3.57", "dfo-data-tools[coverage]"] [tool.uv.sources] # See the root pyproject.toml's dfo-spec-kit source comment for the # pin-consistency invariant this rev must satisfy. -dfo-spec-kit-frame-grammar = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/frame-grammar" } -dfo-spec-kit-components = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/dfo-spec-kit-components" } -dfo-spec-kit-provenance-docs = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/provenance-docs" } -dfo-spec-kit-descriptions = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/descriptions" } +dfo-spec-kit-frame-grammar = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/frame-grammar" } +dfo-spec-kit-components = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/dfo-spec-kit-components" } +dfo-spec-kit-provenance-docs = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/provenance-docs" } +dfo-spec-kit-descriptions = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/descriptions" } [build-system] requires = ["uv_build>=0.11,<0.12"] diff --git a/pyproject.toml b/pyproject.toml index cd092a32..54c8c027 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -247,8 +247,8 @@ dfo-data-tools = { workspace = true } # Follow-up: move all six, `dfo-spec-kit-client-contract` included, to a real # spec-kit tag once one is cut, so a # future force-push or GC on spec-kit's main can't silently break this lock. -dfo-spec-kit = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/spec-kit" } -dfo-spec-kit-client-contract = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "98d78be4ebaad0575ce56c8e9fb400c0848c7247", subdirectory = "python/client-contract" } +dfo-spec-kit = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/spec-kit" } +dfo-spec-kit-client-contract = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/client-contract" } [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/uv.lock b/uv.lock index 1805a43e..f6ef0717 100644 --- a/uv.lock +++ b/uv.lock @@ -720,10 +720,10 @@ requires-dist = [ { name = "dfo-data-tools", extras = ["generation"], marker = "extra == 'resolve'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["sdmx"], marker = "extra == 'coverage'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["sdmx"], marker = "extra == 'formats'", editable = "packages/dfo-data-tools" }, - { name = "dfo-spec-kit-components", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, - { name = "dfo-spec-kit-descriptions", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, - { name = "dfo-spec-kit-frame-grammar", extras = ["arrow"], git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, - { name = "dfo-spec-kit-provenance-docs", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, + { name = "dfo-spec-kit-components", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, + { name = "dfo-spec-kit-descriptions", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, + { name = "dfo-spec-kit-frame-grammar", extras = ["arrow"], git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, + { name = "dfo-spec-kit-provenance-docs", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, { name = "fastapi", marker = "extra == 'surface'", specifier = ">=0.138" }, { name = "google-auth", extras = ["requests"], marker = "extra == 'generation'", specifier = ">=2.55" }, { name = "google-cloud-bigquery", marker = "extra == 'coverage'", specifier = ">=3.27" }, @@ -748,7 +748,7 @@ provides-extras = ["sdmx", "formats", "surface", "resolve", "generation", "cover [[package]] name = "dfo-spec-kit" version = "0.2.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } dependencies = [ { name = "rfc8785" }, ] @@ -756,12 +756,12 @@ dependencies = [ [[package]] name = "dfo-spec-kit-client-contract" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fclient-contract&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fclient-contract&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } [[package]] name = "dfo-spec-kit-components" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } dependencies = [ { name = "dfo-spec-kit-descriptions" }, ] @@ -769,7 +769,7 @@ dependencies = [ [[package]] name = "dfo-spec-kit-descriptions" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } dependencies = [ { name = "rfc8785" }, ] @@ -777,7 +777,7 @@ dependencies = [ [[package]] name = "dfo-spec-kit-frame-grammar" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } dependencies = [ { name = "dfo-spec-kit" }, { name = "pyarrow" }, @@ -787,7 +787,7 @@ dependencies = [ [[package]] name = "dfo-spec-kit-provenance-docs" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247#98d78be4ebaad0575ce56c8e9fb400c0848c7247" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } dependencies = [ { name = "markdown-it-py" }, ] @@ -2933,7 +2933,7 @@ requires-dist = [ { name = "dfo-data-tools", extras = ["resolve", "sdmx", "coverage"], marker = "extra == 'graph'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["sdmx"], marker = "extra == 'sdmx'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["surface", "resolve", "coverage", "sdmx"], marker = "extra == 'engine'", editable = "packages/dfo-data-tools" }, - { name = "dfo-spec-kit", marker = "extra == 'engine'", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, + { name = "dfo-spec-kit", marker = "extra == 'engine'", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.138" }, { name = "google-auth", marker = "extra == 'datasets'", specifier = ">=2.34" }, { name = "google-auth", extras = ["requests"], marker = "extra == 'eval'", specifier = ">=2.34" }, @@ -2964,7 +2964,7 @@ lint = [ { name = "ruff", specifier = ">=0.15" }, ] test = [ - { name = "dfo-spec-kit-client-contract", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fclient-contract&rev=98d78be4ebaad0575ce56c8e9fb400c0848c7247" }, + { name = "dfo-spec-kit-client-contract", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fclient-contract&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, { name = "frictionless", specifier = ">=5.19.0" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-split", specifier = ">=0.10.0" }, From 48605f164fbf5b4eba561927b50822ca77b52ca4 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Fri, 23 Oct 2026 20:59:11 +0200 Subject: [PATCH 4/4] build(deps): pin spec-kit at 4ef22b7, the client contract recorded at 035d29cd0 The client contract's served document and scenarios are captured at dfo-apis 035d29cd0, so recorded versions pages carry `enrichment_stamp`, and a client reads the newest plain build of a spec. --- packages/dfo-data-tools/pyproject.toml | 8 ++++---- pyproject.toml | 6 +++--- uv.lock | 24 ++++++++++++------------ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/dfo-data-tools/pyproject.toml b/packages/dfo-data-tools/pyproject.toml index 2a447980..bc1a884c 100644 --- a/packages/dfo-data-tools/pyproject.toml +++ b/packages/dfo-data-tools/pyproject.toml @@ -86,10 +86,10 @@ graph-release = ["google-cloud-spanner>=3.57", "dfo-data-tools[coverage]"] [tool.uv.sources] # See the root pyproject.toml's dfo-spec-kit source comment for the # pin-consistency invariant this rev must satisfy. -dfo-spec-kit-frame-grammar = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/frame-grammar" } -dfo-spec-kit-components = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/dfo-spec-kit-components" } -dfo-spec-kit-provenance-docs = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/provenance-docs" } -dfo-spec-kit-descriptions = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/descriptions" } +dfo-spec-kit-frame-grammar = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "4ef22b70d0f11af87ed9a78afda841ab66f6230c", subdirectory = "python/frame-grammar" } +dfo-spec-kit-components = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "4ef22b70d0f11af87ed9a78afda841ab66f6230c", subdirectory = "python/dfo-spec-kit-components" } +dfo-spec-kit-provenance-docs = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "4ef22b70d0f11af87ed9a78afda841ab66f6230c", subdirectory = "python/provenance-docs" } +dfo-spec-kit-descriptions = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "4ef22b70d0f11af87ed9a78afda841ab66f6230c", subdirectory = "python/descriptions" } [build-system] requires = ["uv_build>=0.11,<0.12"] diff --git a/pyproject.toml b/pyproject.toml index e369cfe9..fae28d3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -241,14 +241,14 @@ dfo-data-tools = { workspace = true } # The registry refuses a part's `source_code` on search.resolve with # `source_code_not_held`, `source_code_ambiguous`, and # `source_code_shares_variable`. -# The pin is 95a15cf on spec-kit's dev branch. It registers +# The pin is 4ef22b7 on spec-kit's dev branch. It registers # slot_default_not_applied, the three source-code refusals and # whole_total_available, retires starved_axis_binding_restored, and holds the # client contract `dfo-spec-kit-client-contract` reads. # Follow-up: move all six to a real spec-kit tag once one is cut, so a future # force-push or GC on spec-kit's dev can't silently break this lock. -dfo-spec-kit = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/spec-kit" } -dfo-spec-kit-client-contract = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/client-contract" } +dfo-spec-kit = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "4ef22b70d0f11af87ed9a78afda841ab66f6230c", subdirectory = "python/spec-kit" } +dfo-spec-kit-client-contract = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git", rev = "4ef22b70d0f11af87ed9a78afda841ab66f6230c", subdirectory = "python/client-contract" } [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/uv.lock b/uv.lock index f6ef0717..25072881 100644 --- a/uv.lock +++ b/uv.lock @@ -720,10 +720,10 @@ requires-dist = [ { name = "dfo-data-tools", extras = ["generation"], marker = "extra == 'resolve'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["sdmx"], marker = "extra == 'coverage'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["sdmx"], marker = "extra == 'formats'", editable = "packages/dfo-data-tools" }, - { name = "dfo-spec-kit-components", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, - { name = "dfo-spec-kit-descriptions", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, - { name = "dfo-spec-kit-frame-grammar", extras = ["arrow"], git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, - { name = "dfo-spec-kit-provenance-docs", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, + { name = "dfo-spec-kit-components", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c" }, + { name = "dfo-spec-kit-descriptions", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c" }, + { name = "dfo-spec-kit-frame-grammar", extras = ["arrow"], git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c" }, + { name = "dfo-spec-kit-provenance-docs", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c" }, { name = "fastapi", marker = "extra == 'surface'", specifier = ">=0.138" }, { name = "google-auth", extras = ["requests"], marker = "extra == 'generation'", specifier = ">=2.55" }, { name = "google-cloud-bigquery", marker = "extra == 'coverage'", specifier = ">=3.27" }, @@ -748,7 +748,7 @@ provides-extras = ["sdmx", "formats", "surface", "resolve", "generation", "cover [[package]] name = "dfo-spec-kit" version = "0.2.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c#4ef22b70d0f11af87ed9a78afda841ab66f6230c" } dependencies = [ { name = "rfc8785" }, ] @@ -756,12 +756,12 @@ dependencies = [ [[package]] name = "dfo-spec-kit-client-contract" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fclient-contract&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fclient-contract&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c#4ef22b70d0f11af87ed9a78afda841ab66f6230c" } [[package]] name = "dfo-spec-kit-components" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdfo-spec-kit-components&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c#4ef22b70d0f11af87ed9a78afda841ab66f6230c" } dependencies = [ { name = "dfo-spec-kit-descriptions" }, ] @@ -769,7 +769,7 @@ dependencies = [ [[package]] name = "dfo-spec-kit-descriptions" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c#4ef22b70d0f11af87ed9a78afda841ab66f6230c" } dependencies = [ { name = "rfc8785" }, ] @@ -777,7 +777,7 @@ dependencies = [ [[package]] name = "dfo-spec-kit-frame-grammar" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fframe-grammar&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c#4ef22b70d0f11af87ed9a78afda841ab66f6230c" } dependencies = [ { name = "dfo-spec-kit" }, { name = "pyarrow" }, @@ -787,7 +787,7 @@ dependencies = [ [[package]] name = "dfo-spec-kit-provenance-docs" version = "0.1.0" -source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c#4ef22b70d0f11af87ed9a78afda841ab66f6230c" } dependencies = [ { name = "markdown-it-py" }, ] @@ -2933,7 +2933,7 @@ requires-dist = [ { name = "dfo-data-tools", extras = ["resolve", "sdmx", "coverage"], marker = "extra == 'graph'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["sdmx"], marker = "extra == 'sdmx'", editable = "packages/dfo-data-tools" }, { name = "dfo-data-tools", extras = ["surface", "resolve", "coverage", "sdmx"], marker = "extra == 'engine'", editable = "packages/dfo-data-tools" }, - { name = "dfo-spec-kit", marker = "extra == 'engine'", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, + { name = "dfo-spec-kit", marker = "extra == 'engine'", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c" }, { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.138" }, { name = "google-auth", marker = "extra == 'datasets'", specifier = ">=2.34" }, { name = "google-auth", extras = ["requests"], marker = "extra == 'eval'", specifier = ">=2.34" }, @@ -2964,7 +2964,7 @@ lint = [ { name = "ruff", specifier = ">=0.15" }, ] test = [ - { name = "dfo-spec-kit-client-contract", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fclient-contract&rev=95a15cf0af7f4d34c113a1b811712a93e1baa2cb" }, + { name = "dfo-spec-kit-client-contract", git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fclient-contract&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c" }, { name = "frictionless", specifier = ">=5.19.0" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-split", specifier = ">=0.10.0" },