diff --git a/docs/development.md b/docs/development.md index 554ce938..97169e11 100644 --- a/docs/development.md +++ b/docs/development.md @@ -56,6 +56,22 @@ just check # lint, type check, and the full suite, as CI runs them `just check` and CI run the normative identity vector tests (`tests/engine/test_identity_vectors.py`) from the `spec-kit` repository. `just check` and `just test` find a clone at `../dfo-spec-kit`, or at the path in `DFO_SPEC_KIT_DIR`. A git worktree needs `DFO_SPEC_KIT_DIR` set explicitly, because the sibling fallback resolves relative to the worktree's own directory, not the main checkout's. `just check` fails when no clone resolves. +### 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. + ### SDMX live tests `tests/dc_sdmx/` runs offline against recorded wire fixtures. `test_live.py` calls a real Data Commons deployment and is skipped unless `DFO_SDMX_LIVE_BASE` is set. 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 416b8ebf..fae28d3f 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 @@ -236,12 +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 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 -# 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 = "95a15cf0af7f4d34c113a1b811712a93e1baa2cb", subdirectory = "python/spec-kit" } +# 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 = "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"] @@ -285,9 +292,8 @@ python = ".venv" # literally in a docstring about averaging three ratios. "dependant" is # FastAPI's own attribute name (`APIRoute.dependant`). # heading_words_specs.json holds graph labels verbatim ("Assitance" in a UNICEF series). -# CHANEL Foundation is an OECD private donor (code 1645) named in code_index.declarations. skip = "tests/render/heading_words_specs.json" -ignore-words-list = "caf,nam,som,tha,fro,zar,nd,retuned,unparseable,uncomplete,re-use,pre-empt,pre-empting,fo,convertor,convertors,disjointness,meaned,dependant,chanel" +ignore-words-list = "caf,nam,som,tha,fro,zar,nd,retuned,unparseable,uncomplete,re-use,pre-empt,pre-empting,fo,convertor,convertors,disjointness,meaned,dependant" [tool.ruff] target-version = "py312" diff --git a/src/search/engine/app.py b/src/search/engine/app.py index 1d019e7d..af111644 100644 --- a/src/search/engine/app.py +++ b/src/search/engine/app.py @@ -1235,6 +1235,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/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..0d7e0d34 --- /dev/null +++ b/tests/contract/test_client_contract.py @@ -0,0 +1,335 @@ +"""Recorded client exchanges fit the composed app's schemas and code registry. + +`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/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) == [] diff --git a/uv.lock b/uv.lock index b1ed86a7..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,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=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fspec-kit&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c#4ef22b70d0f11af87ed9a78afda841ab66f6230c" } 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=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" }, ] @@ -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=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fdescriptions&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c#4ef22b70d0f11af87ed9a78afda841ab66f6230c" } 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=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" }, @@ -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=95a15cf0af7f4d34c113a1b811712a93e1baa2cb#95a15cf0af7f4d34c113a1b811712a93e1baa2cb" } +source = { git = "https://github.com/DevelopmentFinanceObservatory/spec-kit.git?subdirectory=python%2Fprovenance-docs&rev=4ef22b70d0f11af87ed9a78afda841ab66f6230c#4ef22b70d0f11af87ed9a78afda841ab66f6230c" } 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=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" }, @@ -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=4ef22b70d0f11af87ed9a78afda841ab66f6230c" }, { name = "frictionless", specifier = ">=5.19.0" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-split", specifier = ">=0.10.0" },