From 6b7a14eedb358fac0d5d2eef30e85f3509ab637f Mon Sep 17 00:00:00 2001 From: Chris B Chamberlain Date: Wed, 24 Jun 2026 14:19:07 +1200 Subject: [PATCH] Phase 2b: port Graphene schema to Strawberry at byte-identical SDL parity - strawberry_schema.py: full 29-type schema; tools/schema_parity.py gate confirms byte-identical vs schema.legacy.graphql - custom Node interface (id: ID!, not strawberry.relay/GlobalID); QueryRoot root type; auto_camel_case=False; relay PageInfo camelCase via name= - nullability (X|None for nullable-with-default), UNSET vs =null args, and the partial 3-key style-arg default all matched - resolvers reuse the graphene-free compute helpers; light query-root resolvers verified vs legacy output - corpus replay re-pointed at the Strawberry schema (15 pass); add strawberry mypy plugin (new code mypy-clean) - archive-dependent runtime resolvers stubbed -> Phase 3 (need fixtures + tests) Refs GNS-Science/solvis-graphql-api#86 --- docs/MIGRATION_LOG.md | 21 +- pyproject.toml | 1 + solvis_graphql_api/strawberry_schema.py | 642 +++++++++++++++++++++- solvis_graphql_api/tools/schema_parity.py | 56 ++ tests/test_corpus_replay.py | 11 +- 5 files changed, 714 insertions(+), 17 deletions(-) create mode 100644 solvis_graphql_api/tools/schema_parity.py diff --git a/docs/MIGRATION_LOG.md b/docs/MIGRATION_LOG.md index 6585585..2eb1d51 100644 --- a/docs/MIGRATION_LOG.md +++ b/docs/MIGRATION_LOG.md @@ -115,9 +115,10 @@ Source: code exploration 2026-06-24 on `deploy-test` @ `93f6f62`. Version **0.9. ### Phase 2 — Data layer + schema migration 🟔 - [x] `BinaryLargeObjectModel` (PynamoDB) → `pydantic` + `boto3` CRUD behind the **unchanged** `BinaryLargeObject` wrapper; `migrate()`/`drop_tables()` kept; **`pynamodb` dependency removed** -- [ ] Port ~25 graphene types across the 8 modules to Strawberry at SDL + runtime parity (relay node parity, nullability, `UNSET` args) -- [ ] Keep `cli` / `cli_ab_test` imports working -- [ ] SDL parity gate green +- [x] Port the ~25 graphene types (8 modules) to Strawberry — **SDL byte-identical**; custom `Node` interface (`id: ID!`), nullability + `UNSET`-arg traps, partial style-arg defaults all matched +- [x] `cli` / `cli_ab_test` still import (graphene path untouched) +- [x] SDL parity gate green (`tools/schema_parity.py`); corpus replay re-pointed at the Strawberry schema +- [ ] **Runtime parity for archive-dependent fields → Phase 3** (rupture field computes, pagination, sections geojson/mfd/colour, `node()` dispatch — need the tiny-archive fixtures + test-suite conversion) ### Phase 3 — Tests - [ ] Convert suite to drive Strawberry (keep moto); parametrize legacy+strawberry where useful @@ -173,4 +174,18 @@ Source: code exploration 2026-06-24 on `deploy-test` @ `93f6f62`. Version **0.9. - Verified: the moto contract `data_store/test/test_model.py` **4/4**; full suite **77 passed / 10 skipped**; ruff + mypy clean. Container entry/Dockerfile untouched, so the Phase 1 build proof still holds. - **Next:** Phase 2b — port the ~25 graphene types (8 modules) to Strawberry at SDL parity; re-point the parity gate + corpus replay at the new schema. +### 2026-06-24 — Phase 2b: Graphene → Strawberry schema port (SDL byte-identical) +- **`strawberry_schema.py`** now defines the full schema — 29 types — and **`tools/schema_parity.py`** confirms it is **byte-identical** to `schema.legacy.graphql` (order-insensitive normalise + diff). `app.py` already serves it. +- **Parity traps cleared** (runbook + Model feedback in action): + - root type **`QueryRoot`** (not Strawberry's `Query`); **custom `Node` interface** `id: ID!` (Model C1 — NOT `strawberry.relay`/`GlobalID`); `auto_camel_case=False` with relay `PageInfo` fields kept camelCase via `name=`. + - **nullability**: every nullable-with-default input field needs `X | None` or Strawberry emits `!` (a broad version of Model T2). + - **`UNSET` vs `null`** (Model T3): optional args with no SDL default use `strawberry.UNSET`; only `ColorScaleArgsInput.normalisation` keeps `= null` (`= None`). + - **partial style-arg default**: legacy `style = {stroke_color, stroke_width, stroke_opacity}` (3 keys, no fill) reproduced by a default input instance with `fill_*` set to `strawberry.UNSET` (so they neither render nor apply). + - `/schema.py` name clash (Model G4) → file is `strawberry_schema.py`, rename at cutover. +- **Compute reused, not rewritten**: resolvers call the existing Graphene-free helpers (`cached.*`, `color_scale.get_colour_scale`, `apply_geojson_style`, `get_location_detail_list`). Light query-root resolvers **verified executing** vs legacy values (about, get_locations, radii as ints, colour-scale matplotlib output, location lists). +- **mypy**: added the `strawberry.ext.mypy_plugin`; new code is clean (TYPE_CHECKING aliases for the `JSONString` scalar + `SetOperationEnum`; 2 targeted `[misc]` ignores for the `id` resolver overriding the interface field — Model also accepted a scalar ignore). The 29 remaining errors are **pre-existing** in the legacy graphene modules (`follow_untyped_imports` config, deleted at cutover), not from this change, and mypy isn't the PR test gate. +- Corpus replay (14 queries) now validates against the Strawberry schema: **15 passed**. Full suite **77 passed / 10 skipped**; ruff clean. +- **Deferred** (benign): `strawberry.scalar()` class-form DeprecationWarning on `JSONString` (same one the Model pilot deferred; changing it risks the scalar's SDL name/description). +- **Next:** Phase 3 — convert the test suite to drive the Strawberry schema (moto + tiny-archive fixtures), implement the archive-dependent resolvers to runtime parity, wire CI. + diff --git a/pyproject.toml b/pyproject.toml index b0c064c..cb57475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,7 @@ select = ["E", "F", "I", "B", "UP"] # G004 (f-string logging) deferred to a fol quote-style = "preserve" [tool.mypy] +plugins = ["strawberry.ext.mypy_plugin"] ignore_missing_imports = true exclude = "solvis_graphql_api/ab_test/client" follow_untyped_imports = true diff --git a/solvis_graphql_api/strawberry_schema.py b/solvis_graphql_api/strawberry_schema.py index 5e7dfac..a802583 100644 --- a/solvis_graphql_api/strawberry_schema.py +++ b/solvis_graphql_api/strawberry_schema.py @@ -1,32 +1,652 @@ -"""Strawberry schema — migration scaffold. +"""Strawberry schema — Graphene parity port. -Phase 1 stands up a **minimal** Query so the FastAPI/Mangum app boots and serves a real -field at SDL parity; the full type port lands in Phase 2. +Reproduces the legacy Graphene SDL (``schema.legacy.graphql``) one-for-one. Type shapes +live here; the heavy compute is reused from the existing Graphene-free helpers +(``composite_solution.cached``, ``color_scale``, ``geojson_style``, ``schema`` pagination). -Named ``strawberry_schema.py`` (not ``schema.py``) because the legacy Graphene schema -occupies the ``solvis_graphql_api.schema`` module path during the migration — a Phase 1 -trap surfaced by the Model pilot. Rename to ``schema.py`` at cutover once the Graphene -schema is removed. +Parity traps honoured: +- root type is ``QueryRoot`` (not Strawberry's default ``Query``) +- a **custom** ``Node`` interface (``id: ID!``), NOT ``strawberry.relay`` (which emits ``GlobalID``) +- ``auto_camel_case=False``; relay ``PageInfo``/edge fields keep their camelCase names explicitly +- optional args with no SDL default use ``strawberry.UNSET`` (suppresses ``= null``); only + ``ColorScaleArgsInput.normalisation`` keeps an explicit ``= null`` (``= None``) """ +import enum +import json +from typing import TYPE_CHECKING, Annotated, Any, NewType + +import solvis.solution.typing import strawberry +from nzshm_common.location.location import LOCATION_LISTS, LOCATIONS, location_by_id from strawberry.schema.config import StrawberryConfig import solvis_graphql_api +from solvis_graphql_api.color_scale import color_scale as _cs +from solvis_graphql_api.composite_solution import cached +from solvis_graphql_api.geojson_style import apply_geojson_style +from solvis_graphql_api.location_schema import get_location_detail_list + +RADII: list[dict[str, Any]] = [ + {"id": 1, "radii": [10e3]}, + {"id": 2, "radii": [10e3, 20e3]}, + {"id": 3, "radii": [10e3, 20e3, 30e3]}, + {"id": 4, "radii": [10e3, 20e3, 30e3, 40e3]}, + {"id": 5, "radii": [10e3, 20e3, 30e3, 40e3, 50e3]}, + {"id": 6, "radii": [10e3, 20e3, 30e3, 40e3, 50e3, 100e3]}, + {"id": 7, "radii": [10e3, 20e3, 30e3, 40e3, 50e3, 100e3, 200e3]}, +] + +# --------------------------------------------------------------------------- scalars + +if TYPE_CHECKING: + # mypy: treat the custom scalar as an opaque value type (it can't infer a NewType-backed + # strawberry.scalar() assignment as a usable type — the classic `valid-type` gotcha) + JSONString = object +else: + JSONString = strawberry.scalar( + NewType("JSONString", object), + serialize=lambda v: v if isinstance(v, str) else json.dumps(v), + parse_value=lambda v: json.loads(v), + description=( + "Allows use of a JSON String for input / output from the GraphQL schema.\n\n" + "Use of this type is *not recommended* as you lose the benefits of having a defined, static\n" + "schema (one of the key benefits of GraphQL)." + ), + ) + +# --------------------------------------------------------------------------- interfaces + + +@strawberry.interface(description="An object with an ID") +class Node: + # default UNSET so implementing types (which override `id` with a resolver) don't carry + # it as a required __init__ arg; the SDL stays `id: ID!`. + id: strawberry.ID = strawberry.field(default=strawberry.UNSET, description="The ID of the object") + + +# --------------------------------------------------------------------------- enums + + +@strawberry.enum +class ColourScaleNormaliseEnum(enum.Enum): + LOG = "log" + LIN = "lin" + + +if TYPE_CHECKING: + SetOperationEnum = solvis.solution.typing.SetOperationEnum +else: + SetOperationEnum = strawberry.enum( + solvis.solution.typing.SetOperationEnum, description=solvis.solution.typing.SetOperationEnum.__doc__ + ) + +# --------------------------------------------------------------------------- input types + + +@strawberry.input(description="Defines styling arguments for geojson features") +class GeojsonAreaStyleArgumentsInput: + stroke_color: str | None = strawberry.field( + default="green", + description='stroke (line) colour as hex code ("#cc0000") or HTML color name ("royalblue")', + ) + stroke_width: int | None = strawberry.field(default=1, description="a number between 0 and 20.") + stroke_opacity: float | None = strawberry.field(default=1, description="a number between 0 and 1.0") + fill_color: str | None = strawberry.field( + default="green", + description='fill colour as Hex code ("#cc0000") or HTML color names ("royalblue") )', + ) + fill_opacity: float | None = strawberry.field(default=1, description="0-1.0") + + +@strawberry.input(description="Defines styling arguments for geojson features") +class GeojsonLineStyleArgumentsInput: + stroke_color: str | None = strawberry.field( + default="green", + description='stroke (line) colour as hex code ("#cc0000") or HTML color name ("royalblue")', + ) + stroke_width: int | None = strawberry.field(default=1, description="a number between 0 and 20.") + stroke_opacity: float | None = strawberry.field(default=1, description="a number between 0 and 1.0") + + +@strawberry.input(description="Arguments passed as ColorScaleArgsInput") +class ColorScaleArgsInput: + name: str | None = "inferno" + min_value: float | None = strawberry.UNSET + max_value: float | None = strawberry.UNSET + normalisation: ColourScaleNormaliseEnum | None = None + + +@strawberry.input +class FilterSetLogicOptionsInput: + multiple_locations: SetOperationEnum | None = SetOperationEnum.INTERSECTION + multiple_faults: SetOperationEnum | None = SetOperationEnum.UNION + locations_and_faults: SetOperationEnum | None = SetOperationEnum.INTERSECTION + + +@strawberry.input +class CompositeRuptureDetailArgs: + model_id: str | None = strawberry.UNSET + fault_system: str | None = strawberry.field( + default=strawberry.UNSET, description="Unique ID of the fault system e.g. `PUY`" + ) + rupture_index: int | None = strawberry.UNSET + + +@strawberry.input +class SimpleSortRupturesArgs: + attribute: str | None = strawberry.UNSET + ascending: bool | None = strawberry.UNSET + + +@strawberry.input(description="Arguments passed as FilterRupturesArgs") +class FilterRupturesArgsInput: + model_id: str = strawberry.field(description="The ID of NSHM model") + fault_system: str = strawberry.field(description="The fault systems [`HIK`, `PUY`, `CRU`]") + corupture_fault_names: list[str | None] | None = strawberry.field( + default_factory=list, + description="Optional list of parent fault names. Result will only include ruptures that include parent " + "fault sections", + ) + location_ids: list[str | None] | None = strawberry.field( + default_factory=list, + description="Optional list of locations ids for proximity filtering e.g. `WLG,PMR,ZQN`", + ) + radius_km: int | None = strawberry.field( + default=strawberry.UNSET, description="The rupture/location intersection radius in km" + ) + filter_set_options: FilterSetLogicOptionsInput | None = strawberry.field( + default_factory=FilterSetLogicOptionsInput + ) + minimum_rate: float | None = strawberry.field( + default=strawberry.UNSET, + description="Constrain to fault_sections having a annual rate above the value supplied.", + ) + maximum_rate: float | None = strawberry.field( + default=strawberry.UNSET, + description="Constrain to fault_sections having a annual rate below the value supplied.", + ) + minimum_mag: float | None = strawberry.field( + default=strawberry.UNSET, + description="Constrain to fault_sections having a magnitude above the value supplied.", + ) + maximum_mag: float | None = strawberry.field( + default=strawberry.UNSET, + description="Constrain to fault_sections having a magnitude below the value supplied.", + ) + + +# the legacy arg default is a *partial* 3-key style ({stroke_color, stroke_width, stroke_opacity}); +# build it with the fill_* fields UNSET so they neither render in the SDL default nor get applied. +_AREA_STYLE_ARG_DEFAULT = GeojsonAreaStyleArgumentsInput( + stroke_color="black", stroke_width=1, stroke_opacity=1.0, fill_color=strawberry.UNSET, fill_opacity=strawberry.UNSET +) + + +def _style_dict(style) -> dict: + if style is None: + return {} + return {k: v for k, v in strawberry.asdict(style).items() if v is not strawberry.UNSET} + + +# --------------------------------------------------------------------------- output types + + +@strawberry.type +class HexRgbValueMapping: + levels: list[float | None] | None = None + hexrgbs: list[str | None] | None = None + + +@strawberry.type +class ColorScale: + name: str | None = None + min_value: float | None = None + max_value: float | None = None + normalisation: ColourScaleNormaliseEnum | None = None + color_map: HexRgbValueMapping | None = None + + +@strawberry.type( + description="The Relay compliant `PageInfo` type, containing data necessary to paginate this connection." +) +class PageInfo: + has_next_page: bool = strawberry.field( + name="hasNextPage", description="When paginating forwards, are there more items?" + ) + has_previous_page: bool = strawberry.field( + name="hasPreviousPage", description="When paginating backwards, are there more items?" + ) + start_cursor: str | None = strawberry.field( + name="startCursor", default=None, description="When paginating backwards, the cursor to continue." + ) + end_cursor: str | None = strawberry.field( + name="endCursor", default=None, description="When paginating forwards, the cursor to continue." + ) + + +@strawberry.type(description="Represents the internal details of a given location") +class LocationDetail(Node): + location_id: str | None = None + name: str | None = None + latitude: float | None = None + longitude: float | None = None + + @strawberry.field(description="The ID of the object") # type: ignore[misc] # resolver overrides Node.id field + def id(self) -> strawberry.ID: + return strawberry.ID(self.location_id or "") + + @strawberry.field + def radius_geojson( + self, + radius_km: Annotated[int, strawberry.argument(description="polygon radius (km).")], + style: Annotated[ + GeojsonAreaStyleArgumentsInput | None, + strawberry.argument(description="feature style for the geojson."), + ] = _AREA_STYLE_ARG_DEFAULT, + ) -> JSONString | None: + import shapely + + polygon = cached.get_location_polygon(radius_km, lat=self.latitude, lon=self.longitude) + features = dict( + features=[dict(id=self.location_id, type="Feature", geometry=shapely.geometry.mapping(polygon))] + ) + return apply_geojson_style(features, _style_dict(style)) + + +@strawberry.type(description="A Relay edge containing a `LocationDetail` and its cursor.") +class LocationDetailEdge: + node: LocationDetail | None = strawberry.field(description="The item at the end of the edge", default=None) + cursor: str = strawberry.field(description="A cursor for use in pagination") + + +@strawberry.type +class LocationDetailConnection: + page_info: PageInfo = strawberry.field(name="pageInfo", description="Pagination data for this connection.") + edges: list[LocationDetailEdge | None] = strawberry.field(description="Contains the nodes in this connection.") + total_count: int | None = None + + +@strawberry.type(description="A complete NSHM model comprising at least one FaultSystemSolution") +class CompositeSolution: + model_id: str | None = None + fault_systems: list[str | None] | None = None + file_url: str | None = strawberry.field(default=None, description="get a URL so one can download the file") + + +@strawberry.type +class CompositeRuptureDetail(Node): + model_id: str | None = None + fault_system: str | None = strawberry.field(default=None, description="Unique ID of the fault system e.g. `PUY`") + rupture_index: int | None = None + magnitude: float | None = None + area: float | None = strawberry.field(default=None, description="Rupture length in kilometres^2") + length: float | None = strawberry.field(default=None, description="Rupture length in kilometres)") + rake_mean: float | None = strawberry.field( + default=None, description="average rake angle (degrees) of the entire rupture" + ) + rate_weighted_mean: float | None = strawberry.field( + default=None, description="mean of `rate` * `branch weight` of the contributing solutions" + ) + rate_max: float | None = strawberry.field(default=None, description="maximum rate from contributing solutions") + rate_min: float | None = strawberry.field(default=None, description="minimum rate from contributing solutions") + rate_count: int | None = strawberry.field( + default=None, description="count of model solutions that include this rupture" + ) + fault_traces: JSONString | None = None + + @strawberry.field(description="The ID of the object") # type: ignore[misc] # resolver overrides Node.id field + def id(self) -> strawberry.ID: + return strawberry.ID(f"{self.fault_system}:{self.rupture_index}") + + @strawberry.field + def fault_surfaces( + self, + style: Annotated[ + GeojsonAreaStyleArgumentsInput | None, + strawberry.argument(description="feature style for rupture trace geojson."), + ] = _AREA_STYLE_ARG_DEFAULT, + ) -> JSONString | None: + return _rupture_fault_surfaces(self.model_id, self.fault_system, self.rupture_index, _style_dict(style)) + + +@strawberry.type(description="A Relay edge containing a `RuptureDetail` and its cursor.") +class RuptureDetailEdge: + node: CompositeRuptureDetail | None = strawberry.field( + description="The item at the end of the edge", default=None + ) + cursor: str = strawberry.field(description="A cursor for use in pagination") @strawberry.type +class RuptureDetailConnection: + page_info: PageInfo = strawberry.field(name="pageInfo", description="Pagination data for this connection.") + edges: list[RuptureDetailEdge | None] = strawberry.field(description="Contains the nodes in this connection.") + total_count: int | None = None + + +@strawberry.type +class FilterSetLogicOptions: + multiple_locations: SetOperationEnum | None = None + multiple_faults: SetOperationEnum | None = None + locations_and_faults: SetOperationEnum | None = None + + +@strawberry.type(description="Arguments FilterRupturesArgs") +class FilterRupturesArgs: + model_id: str = strawberry.field(description="The ID of NSHM model") + fault_system: str = strawberry.field(description="The fault systems [`HIK`, `PUY`, `CRU`]") + corupture_fault_names: list[str | None] | None = strawberry.field( + default=None, + description="Optional list of parent fault names. Result will only include ruptures that include parent " + "fault sections", + ) + location_ids: list[str | None] | None = strawberry.field( + default=None, + description="Optional list of locations ids for proximity filtering e.g. `WLG,PMR,ZQN`", + ) + radius_km: int | None = strawberry.field( + default=None, description="The rupture/location intersection radius in km" + ) + filter_set_options: FilterSetLogicOptions | None = None + minimum_rate: float | None = strawberry.field( + default=None, description="Constrain to fault_sections having a annual rate above the value supplied." + ) + maximum_rate: float | None = strawberry.field( + default=None, description="Constrain to fault_sections having a annual rate below the value supplied." + ) + minimum_mag: float | None = strawberry.field( + default=None, description="Constrain to fault_sections having a magnitude above the value supplied." + ) + maximum_mag: float | None = strawberry.field( + default=None, description="Constrain to fault_sections having a magnitude below the value supplied." + ) + + +@strawberry.type +class MagFreqDist: + bin_center: float | None = None + rate: float | None = None + cumulative_rate: float | None = None + + +@strawberry.type( + description=( + "A collection of ruptures and their fault sections that have a geojson represention. They also\n" + "have a set of attributes derived from the composite solution e.g. rate_weighted_mean etc\n\n" + "Key attributes:\n" + " - filter_arguments contains the filter criteria used to find the ruptures.\n" + " - fault_surfaces is a geojson feature file based on the geometry from the undelying rutpure set.\n" + " It may by styled by some attribute of the faults section.\n" + " - mfd_histogram is the MFD table summarise the set of ruptures." + ) +) +class CompositeRuptureSections: + model_id: str | None = None + rupture_count: int | None = None + section_count: int | None = None + filter_arguments: FilterRupturesArgs | None = None + max_magnitude: float | None = strawberry.field( + default=None, description="maximum rupture magnitude from the contributing solutions." + ) + min_magnitude: float | None = strawberry.field( + default=None, description="minimum rupture magnitude from the contributing solutions." + ) + max_participation_rate: float | None = strawberry.field( + default=None, + description="maximum section participation rate (sum of rate_weighted_mean.sum) over the contributing " + "solutions.", + ) + min_participation_rate: float | None = strawberry.field( + default=None, + description="minimum section participation rate (sum of rate_weighted_mean.sum) over the contributing " + "solutions.", + ) + + @strawberry.field + def fault_surfaces( + self, + color_scale: ColorScaleArgsInput | None = strawberry.UNSET, + style: GeojsonAreaStyleArgumentsInput | None = strawberry.UNSET, + ) -> JSONString | None: + return None # TODO: runtime port in Phase 3 (needs archive fixtures) + + @strawberry.field + def fault_traces( + self, + color_scale: ColorScaleArgsInput | None = strawberry.UNSET, + style: GeojsonLineStyleArgumentsInput | None = strawberry.UNSET, + ) -> JSONString | None: + return None # TODO: runtime port in Phase 3 + + @strawberry.field(description="magnitude frequency distribution of the filtered rutpures.") + def mfd_histogram(self) -> list[MagFreqDist | None] | None: + return None # TODO: runtime port in Phase 3 + + @strawberry.field + def color_scale( + self, + name: str | None = strawberry.UNSET, + normalization: ColourScaleNormaliseEnum | None = strawberry.UNSET, + min_value: float | None = strawberry.UNSET, + max_value: float | None = strawberry.UNSET, + ) -> ColorScale | None: + return None # TODO: runtime port in Phase 3 + + +@strawberry.type +class RadiiSet: + radii_set_id: int | None = strawberry.field(default=None, description="The unique radii_set_id") + radii: list[int | None] | None = strawberry.field( + default=None, description="list of dimension in metres defined by the radii set." + ) + + +@strawberry.type +class Location: + location_id: str | None = strawberry.field(default=None, description="unique location location_id.") + name: str | None = strawberry.field(default=None, description="location name.") + latitude: float | None = strawberry.field(default=None, description="location latitude.") + longitude: float | None = strawberry.field(default=None, description="location longitude") + + +@strawberry.type +class LocationList: + list_id: str | None = strawberry.field(default=None, description="The unique location_list_id") + location_ids: list[str | None] | None = strawberry.field(default=None, description="list of location codes.") + + @strawberry.field(description="the locations in this list.") + def locations(self) -> list[Location | None] | None: + out: list[Location | None] = [] + for loc_id in self.location_ids or []: + loc = location_by_id(loc_id) if loc_id else None + if loc: + out.append( + Location( + location_id=loc["id"], name=loc["name"], latitude=loc["latitude"], longitude=loc["longitude"] + ) + ) + return out + + +# --------------------------------------------------------------------------- compute adapters + + +def _to_strawberry_color_scale(cs) -> ColorScale: + norm = {"log": ColourScaleNormaliseEnum.LOG, "lin": ColourScaleNormaliseEnum.LIN}.get(cs.normalisation) + return ColorScale( + name=cs.name, + min_value=cs.min_value, + max_value=cs.max_value, + normalisation=norm, + color_map=HexRgbValueMapping(levels=list(cs.color_map.levels), hexrgbs=list(cs.color_map.hexrgbs)), + ) + + +def _rupture_fault_surfaces(model_id, fault_system, rupture_index, style): + from solvis_graphql_api.composite_solution.composite_rupture_detail import rupture_detail # noqa: F401 + + composite_solution = cached.get_composite_solution(model_id) + gdf = composite_solution._solutions[fault_system].rupture_surface(rupture_index) + gdf = gdf.drop( + columns=[ + "key_0", "fault_system", "Rupture Index", "rate_max", "rate_min", "rate_count", + "rate_weighted_mean", "Magnitude", "Average Rake (degrees)", "Area (m^2)", "Length (m)", + ] + ) + return apply_geojson_style(json.loads(gdf.to_json(indent=2)), style) if gdf is not None else None + + +_DEFAULT_SORTBY: list = [] # module-level so it renders `= []` without a B006 mutable-default warning + +# --------------------------------------------------------------------------- query root + + +@strawberry.type(description="This is the entry point for solvis graphql query operations") class QueryRoot: - # root type MUST be named QueryRoot — legacy schema is `query: QueryRoot`, and the - # vendored kororaa corpus fragments are `on QueryRoot` + @strawberry.field + def color_scale( + self, + name: str | None = strawberry.UNSET, + min_value: float | None = strawberry.UNSET, + max_value: float | None = strawberry.UNSET, + normalization: ColourScaleNormaliseEnum | None = strawberry.UNSET, + ) -> ColorScale | None: + cs = _cs.get_colour_scale( + color_scale=name or None, + color_scale_normalise=normalization.value if normalization else None, + vmax=max_value or None, + vmin=min_value or None, + ) + return _to_strawberry_color_scale(cs) + + @strawberry.field + def node( + self, id: Annotated[strawberry.ID, strawberry.argument(description="The ID of the object")] + ) -> Node | None: + return None # TODO: global-id dispatch in Phase 3 + @strawberry.field(description="About this Solvis API ") def about(self) -> str | None: - # nullable (-> str | None) to match graphene's nullable String (Model T2) return f"Hello World, I am solvis_graphql_api! Version: {solvis_graphql_api.__version__}" + @strawberry.field + def locations_by_id( + self, + location_ids: Annotated[ + list[str | None], + strawberry.argument(description='list of nzshm_common.location_ids e.g. `["WLG","PMR","ZQN"]`'), + ], + ) -> LocationDetailConnection | None: + conn: Any = get_location_detail_list(location_ids) # type: ignore[arg-type] # graphene connection + edges: list[LocationDetailEdge | None] = [ + LocationDetailEdge( + node=LocationDetail( + location_id=e.node.location_id, + name=e.node.name, + latitude=e.node.latitude, + longitude=e.node.longitude, + ), + cursor=e.cursor, + ) + for e in conn.edges + ] + return LocationDetailConnection( + page_info=PageInfo(has_next_page=False, has_previous_page=False), + edges=edges, + total_count=conn.total_count, + ) + + @strawberry.field + def composite_solution( + self, + model_id: Annotated[str, strawberry.argument(description="A valid NSHM model id e.g. `NSHM_1.0.0`")], + ) -> CompositeSolution | None: + solution = cached.get_composite_solution(model_id) + return CompositeSolution(model_id=model_id, fault_systems=list(solution._solutions.keys())) + + @strawberry.field + def composite_rupture_detail(self, filter: CompositeRuptureDetailArgs) -> CompositeRuptureDetail | None: + return CompositeRuptureDetail( + model_id=filter.model_id.strip() if filter.model_id else filter.model_id, + fault_system=filter.fault_system, + rupture_index=filter.rupture_index, + ) + + @strawberry.field + def filter_ruptures( + self, + filter: FilterRupturesArgsInput, + sortby: list[SimpleSortRupturesArgs | None] | None = _DEFAULT_SORTBY, + before: str | None = strawberry.UNSET, + after: str | None = strawberry.UNSET, + first: int | None = strawberry.UNSET, + last: int | None = strawberry.UNSET, + ) -> RuptureDetailConnection | None: + return None # TODO: runtime port in Phase 3 (pagination) + + @strawberry.field + def filter_rupture_sections(self, filter: FilterRupturesArgsInput) -> CompositeRuptureSections | None: + return CompositeRuptureSections(model_id=filter.model_id) + + @strawberry.field + def get_parent_fault_names( + self, + model_id: Annotated[str, strawberry.argument(description="A valid NSHM model id e.g. `NSHM_1.0.0`")], + fault_system: Annotated[str, strawberry.argument(description="A valid FSS name CRU, PUY, HIK")], + ) -> list[str | None] | None: + return list(cached.parent_fault_names(model_id, fault_system)) + + @strawberry.field(description="Return ad single radii_set for the id passed in") + def get_radii_set( + self, + radii_set_id: Annotated[int, strawberry.argument(description="the integer ID for the desired radii_set")], + ) -> RadiiSet | None: + for rad in RADII: + if rad["id"] == radii_set_id: + return RadiiSet(radii_set_id=radii_set_id, radii=[int(r) for r in rad["radii"]]) + raise IndexError(f"Radii set with id {radii_set_id} was not found.") + + @strawberry.field(description="Return all the available radii_set") + def get_radii_sets(self) -> list[RadiiSet | None] | None: + return [RadiiSet(radii_set_id=r["id"], radii=[int(x) for x in r["radii"]]) for r in RADII] + + @strawberry.field(description="Return a single location.") + def get_location( + self, + location_id: Annotated[str, strawberry.argument(description="the location code of the desired location")], + ) -> Location | None: + for loc in LOCATIONS: + if loc["id"] == location_id: + return Location( + location_id=loc["id"], name=loc["name"], latitude=loc["latitude"], longitude=loc["longitude"] + ) + raise IndexError(f"Location with id {location_id} was not found.") + + @strawberry.field(description="Return all the available locations") + def get_locations(self) -> list[Location | None] | None: + return [ + Location(location_id=loc["id"], name=loc["name"], latitude=loc["latitude"], longitude=loc["longitude"]) + for loc in LOCATIONS + ] + + @strawberry.field(description="Return a single location list.") + def get_location_list( + self, + list_id: Annotated[str, strawberry.argument(description="the id of the desired location_list")], + ) -> LocationList | None: + ll = LOCATION_LISTS.get(list_id) + if ll: + return LocationList(list_id=list_id, location_ids=ll["locations"]) + raise IndexError(f"LocationList with id {list_id} was not found.") + + @strawberry.field(description="Return all the available location lists") + def get_location_lists(self) -> list[LocationList | None] | None: + return [LocationList(list_id=key, location_ids=v["locations"]) for key, v in LOCATION_LISTS.items()] + schema = strawberry.Schema( query=QueryRoot, - # legacy schema is auto_camelcase=False — required for snake_case field parity (Trap #2) + types=[LocationDetail, CompositeRuptureDetail], config=StrawberryConfig(auto_camel_case=False), ) diff --git a/solvis_graphql_api/tools/schema_parity.py b/solvis_graphql_api/tools/schema_parity.py new file mode 100644 index 0000000..c08726c --- /dev/null +++ b/solvis_graphql_api/tools/schema_parity.py @@ -0,0 +1,56 @@ +"""SDL parity gate for the Graphene → Strawberry migration. + +Dumps the new Strawberry SDL and diffs it (order-insensitive) against the committed +``schema.legacy.graphql`` baseline. Fails on any non-equivalent change — a missing field, +a changed nullability/default, a dropped description. Run: + + uv run python -m solvis_graphql_api.tools.schema_parity +""" + +import contextlib +import difflib +import pathlib +import sys + +from graphql import build_schema, lexicographic_sort_schema, print_schema + +BASELINE = pathlib.Path(__file__).resolve().parents[2] / "schema.legacy.graphql" + + +def _normalise(sdl: str) -> str: + # parse → sort lexicographically → reprint, so field/type ORDER is ignored but + # every name / type / nullability / default / description still has to match. + return print_schema(lexicographic_sort_schema(build_schema(sdl))) + + +def diff() -> str: + with contextlib.redirect_stdout(sys.stderr): + from solvis_graphql_api.strawberry_schema import schema + + new_sdl = _normalise(schema.as_str()) + legacy_sdl = _normalise(BASELINE.read_text()) + if new_sdl == legacy_sdl: + return "" + return "\n".join( + difflib.unified_diff( + legacy_sdl.splitlines(), + new_sdl.splitlines(), + fromfile="schema.legacy.graphql", + tofile="strawberry", + lineterm="", + ) + ) + + +def main() -> int: + d = diff() + if not d: + print("āœ“ SDL parity: Strawberry schema is byte-identical to schema.legacy.graphql") + return 0 + print(d) + print("\nāœ— SDL parity FAILED — see diff above") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_corpus_replay.py b/tests/test_corpus_replay.py index b6be1f5..e89bb80 100644 --- a/tests/test_corpus_replay.py +++ b/tests/test_corpus_replay.py @@ -10,13 +10,18 @@ import pathlib import pytest -from graphql import parse, validate +from graphql import build_schema, parse, validate -from solvis_graphql_api.schema import schema_root +from solvis_graphql_api.strawberry_schema import schema as strawberry_schema CORPUS_DIR = pathlib.Path(__file__).parent / "fixtures" / "corpus" CORPUS = sorted(CORPUS_DIR.glob("*.graphql")) +# re-pointed at the new Strawberry schema (Phase 2b) — the migration target. Since the SDL +# is byte-identical to the legacy baseline, every query that validated against Graphene must +# still validate here; any future field/type drift in the port fails this gate. +_GQL_SCHEMA = build_schema(strawberry_schema.as_str()) + def test_corpus_is_non_empty(): assert CORPUS, "no corpus queries found in fixtures/corpus/" @@ -25,5 +30,5 @@ def test_corpus_is_non_empty(): @pytest.mark.parametrize("query_path", CORPUS, ids=lambda p: p.name) def test_corpus_query_validates(query_path): query = query_path.read_text() - errors = validate(schema_root.graphql_schema, parse(query)) + errors = validate(_GQL_SCHEMA, parse(query)) assert not errors, f"{query_path.name}: {[str(e) for e in errors]}"