sdmxlib-rest 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. sdmxlib_rest/__init__.py +19 -0
  2. sdmxlib_rest/app.py +388 -0
  3. sdmxlib_rest/availability_cache.py +80 -0
  4. sdmxlib_rest/data/__init__.py +94 -0
  5. sdmxlib_rest/data/encoding.py +153 -0
  6. sdmxlib_rest/data/paging.py +72 -0
  7. sdmxlib_rest/data/params.py +284 -0
  8. sdmxlib_rest/data/projection.py +240 -0
  9. sdmxlib_rest/data/sql.py +84 -0
  10. sdmxlib_rest/filter_shaper.py +78 -0
  11. sdmxlib_rest/localization.py +167 -0
  12. sdmxlib_rest/logging.py +28 -0
  13. sdmxlib_rest/message_annotator.py +68 -0
  14. sdmxlib_rest/metadata_store.py +81 -0
  15. sdmxlib_rest/middleware/__init__.py +6 -0
  16. sdmxlib_rest/middleware/compression.py +149 -0
  17. sdmxlib_rest/middleware/etag.py +175 -0
  18. sdmxlib_rest/middleware/head_method.py +76 -0
  19. sdmxlib_rest/middleware/lang_override.py +75 -0
  20. sdmxlib_rest/middleware/openapi_extensions.py +106 -0
  21. sdmxlib_rest/middleware/perf_log.py +140 -0
  22. sdmxlib_rest/middleware/query_param_guard.py +126 -0
  23. sdmxlib_rest/middleware/version_token.py +76 -0
  24. sdmxlib_rest/negotiation.py +146 -0
  25. sdmxlib_rest/provider.py +73 -0
  26. sdmxlib_rest/responses/__init__.py +6 -0
  27. sdmxlib_rest/responses/asof.py +49 -0
  28. sdmxlib_rest/responses/empty.py +99 -0
  29. sdmxlib_rest/responses/urn_injection.py +157 -0
  30. sdmxlib_rest/routes/__init__.py +7 -0
  31. sdmxlib_rest/routes/availability.py +793 -0
  32. sdmxlib_rest/routes/metadata.py +428 -0
  33. sdmxlib_rest/routes/structure.py +1205 -0
  34. sdmxlib_rest/runtime.py +176 -0
  35. sdmxlib_rest/splice.py +146 -0
  36. sdmxlib_rest/structure_cache.py +75 -0
  37. sdmxlib_rest-0.1.0.dist-info/METADATA +114 -0
  38. sdmxlib_rest-0.1.0.dist-info/RECORD +40 -0
  39. sdmxlib_rest-0.1.0.dist-info/WHEEL +4 -0
  40. sdmxlib_rest-0.1.0.dist-info/licenses/LICENSE +185 -0
@@ -0,0 +1,19 @@
1
+ """sdmxlib-rest — generic SDMX-REST HTTP surface over a sdmxlib ``FederatedCatalog``.
2
+
3
+ This package is being extracted in place from ``cpt_sdmx_api`` (see
4
+ ``~/.claude/plans/so-as-this-project-misty-pond.md``). It owns the generic
5
+ HTTP shape of SDMX-REST — URLs, content negotiation, streaming, and
6
+ formatting delegation to sdmxlib — over an abstract catalog. Consumers
7
+ supply how the catalog is opened, what gets spliced into responses, and any
8
+ catalog-specific speedups.
9
+
10
+ During the in-place phase it ships as a second module inside the
11
+ ``cpt-sdmx-api`` distribution; Phase 5 moves it to its own sibling repo
12
+ (``gitlab.com/pinax-suite/sdmxlib-rest``). It MUST NOT import ``cpt_sdmx_api``
13
+ or ``pinax`` — that separation is the whole point of the boundary, and
14
+ ``tests/test_library_import_boundary.py`` fails the build if it slips.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ __all__: list[str] = []
sdmxlib_rest/app.py ADDED
@@ -0,0 +1,388 @@
1
+ """The generic SDMX-REST application factory.
2
+
3
+ What a consumer actually has to get right to stand up an SDMX-REST service is
4
+ not the routes — those are already library-owned — but the *assembly*: the
5
+ middleware stack has an ordering invariant that is easy to violate and whose
6
+ violations are subtle (an ETag computed over compressed bytes still looks like
7
+ an ETag), the runtime has to be shared rather than rebuilt across mounted
8
+ surfaces or each one opens its own catalog, and the multi-spec docs page has to
9
+ be hand-rolled because FastAPI's helper can't emit one.
10
+
11
+ So this module owns the assembly and leaves every catalog-shaped decision to
12
+ the caller: which routers to include and in what order, what extra middleware
13
+ to layer, which sub-apps to mount, and what to warm at startup.
14
+
15
+ See :func:`create_app`. The ordering invariant is documented at its call to
16
+ ``_install_middleware`` and is the single most important thing here — #22
17
+ tracks giving it regression coverage.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
23
+ from contextlib import asynccontextmanager
24
+ from typing import Any
25
+
26
+ from attrs import field, frozen
27
+ from fastapi import APIRouter, Depends, FastAPI
28
+ from fastapi.middleware.cors import CORSMiddleware
29
+ from fastapi.responses import HTMLResponse, JSONResponse
30
+ from loguru import logger
31
+
32
+ from sdmxlib_rest.middleware.compression import SelectiveGZipMiddleware, install_isal_zip_backend
33
+ from sdmxlib_rest.middleware.etag import ConditionalGetMiddleware
34
+ from sdmxlib_rest.middleware.head_method import HeadMethodMiddleware
35
+ from sdmxlib_rest.middleware.lang_override import LangOverrideMiddleware
36
+ from sdmxlib_rest.middleware.openapi_extensions import apply_vendor_markers
37
+ from sdmxlib_rest.middleware.perf_log import PerfLogMiddleware
38
+ from sdmxlib_rest.middleware.query_param_guard import reject_unknown_query_params
39
+ from sdmxlib_rest.middleware.version_token import SdmxVersionAdvertiseMiddleware
40
+ from sdmxlib_rest.routes import availability, metadata, structure
41
+ from sdmxlib_rest.runtime import CatalogRuntime, attach_runtime, share_runtime
42
+
43
+ # CORS response headers a browser-based SDMX client actually reads from script.
44
+ # Anything not listed here is hidden from JS on a cross-origin fetch.
45
+ _DEFAULT_EXPOSE_HEADERS: tuple[str, ...] = (
46
+ "ETag",
47
+ "Last-Modified",
48
+ "Content-Disposition",
49
+ "Vary",
50
+ "Content-Encoding",
51
+ # Range pagination — clients read `Content-Range` off the response;
52
+ # `Accept-Ranges` rides along for feature detection.
53
+ "Content-Range",
54
+ "Accept-Ranges",
55
+ )
56
+
57
+ # POST is here for the `/body` form-fallback routes: clients POST an
58
+ # `application/x-www-form-urlencoded` body carrying the SDMX key when the GET
59
+ # URL would exceed an ingress limit. Without it the browser preflight rejects
60
+ # the fallback.
61
+ _DEFAULT_ALLOW_METHODS: tuple[str, ...] = ("GET", "HEAD", "OPTIONS", "POST")
62
+
63
+
64
+ @frozen
65
+ class RestSettings:
66
+ """Configuration the generic app assembly needs — nothing catalog-specific.
67
+
68
+ A consumer's own settings object will carry far more (bundle paths, log
69
+ destinations, feature flags); map the relevant slice onto this.
70
+ """
71
+
72
+ title: str
73
+ version: str
74
+ description: str = ""
75
+ cache_control: str = "public, max-age=300, stale-while-revalidate=3600"
76
+ cors_allowed_origins: tuple[str, ...] = ("*",)
77
+ cors_max_age: int = 3600
78
+ # Level 1: SDMX-CSV is highly compressible (long format with repeated
79
+ # dimension values — ~37x measured on census data), and a low level keeps
80
+ # the CPU cost from eating the streaming throughput.
81
+ gzip_minimum_size: int = 1024
82
+ gzip_compress_level: int = 1
83
+ canonical_spec_name: str = "SDMX-REST 2.2.2 (canonical)"
84
+
85
+
86
+ @frozen
87
+ class Mount:
88
+ """A sub-app mounted under the main app.
89
+
90
+ Mounted as a sub-app rather than a prefixed router so it publishes its own
91
+ ``/openapi.json``, which is what lets the docs page offer a spec dropdown.
92
+ The mount shares the parent's runtime — one catalog open serves every
93
+ surface — and receives a copy of the parent's ``state``, because request
94
+ dependencies resolve against ``request.app``, which is the *sub*-app for a
95
+ request that lands inside a mount.
96
+
97
+ ``spec_name`` labels it in the docs dropdown; ``None`` keeps it out.
98
+ """
99
+
100
+ path: str
101
+ app: FastAPI
102
+ spec_name: str | None = None
103
+
104
+
105
+ @frozen
106
+ class MiddlewareSpec:
107
+ """A middleware class plus its keyword arguments, applied at the outer slot."""
108
+
109
+ cls: type
110
+ options: Mapping[str, Any] = field(factory=dict)
111
+
112
+
113
+ def library_routers() -> tuple[APIRouter, ...]:
114
+ """The routers this library owns: structure, metadata, availability.
115
+
116
+ Returned in a sensible default order. A consumer that interleaves its own
117
+ routers should pass an explicit list to :func:`create_app` instead — route
118
+ inclusion order is preserved verbatim and determines OpenAPI path order.
119
+ """
120
+ return (structure.router, metadata.router, availability.router)
121
+
122
+
123
+ def create_app(
124
+ runtime: CatalogRuntime,
125
+ settings: RestSettings,
126
+ *,
127
+ routers: Sequence[APIRouter] = (),
128
+ outer_middleware: Sequence[MiddlewareSpec] = (),
129
+ mounts: Sequence[Mount] = (),
130
+ state: Mapping[str, Any] | None = None,
131
+ perf_sink: Callable[..., None] | None = None,
132
+ on_startup: Callable[[FastAPI], Awaitable[None]] | None = None,
133
+ on_shutdown: Callable[[], None] | None = None,
134
+ root_payload: Mapping[str, Any] | None = None,
135
+ ) -> FastAPI:
136
+ """Assemble a FastAPI app serving SDMX-REST over ``runtime``'s catalog.
137
+
138
+ The catalog is opened once, on startup, and shared with every mount. The
139
+ returned app is ordinary FastAPI — a consumer is free to keep mounting
140
+ static files or including further routers afterwards.
141
+
142
+ Args:
143
+ runtime: Carries the provider and every seam (splices, caches, filter
144
+ shaper, annotator). Install those before calling; they are read per
145
+ request, so later mutation works but is rarely what you want.
146
+ settings: The generic slice of configuration — see :class:`RestSettings`.
147
+ routers: Routers to include, **in order**. Order is preserved because it
148
+ determines the order paths appear in the OpenAPI document. Use
149
+ :func:`library_routers` for the standard trio, or interleave your own.
150
+ outer_middleware: Extra middleware layered just inside CORS — i.e.
151
+ outside everything else, so it observes the client's original
152
+ request and the fully-encoded response. Logging taps belong here.
153
+ mounts: Sub-apps to mount; each shares this app's runtime and state.
154
+ state: Attributes to stamp on ``app.state`` and mirror onto every mount.
155
+ perf_sink: Sink for the perf-log middleware; ``None`` makes it a
156
+ zero-overhead no-op.
157
+ on_startup: Awaited after the catalog opens — the place for cache
158
+ warmers. Exceptions propagate and will fail startup, so a
159
+ best-effort warmer must swallow its own errors.
160
+ on_shutdown: Called after the app stops, instead of the default
161
+ ``runtime.close()``. Supply one only if you need to close something
162
+ more than the runtime; it takes over responsibility for doing so.
163
+ root_payload: JSON body for ``GET /``. Defaults to a service identity.
164
+ """
165
+ # Routes DEFLATE through isa-l instead of stdlib zlib — same wire format,
166
+ # ~2-3x throughput at the low compression levels we use. Idempotent.
167
+ install_isal_zip_backend()
168
+
169
+ @asynccontextmanager
170
+ async def _lifespan(app_: FastAPI) -> AsyncGenerator[None]:
171
+ logger.info("opening catalog")
172
+ runtime.ensure_open()
173
+ if on_startup is not None:
174
+ await on_startup(app_)
175
+ yield
176
+ if on_shutdown is not None:
177
+ on_shutdown()
178
+ else:
179
+ runtime.close()
180
+ logger.info("catalog closed")
181
+
182
+ app = FastAPI(
183
+ title=settings.title,
184
+ version=settings.version,
185
+ description=settings.description,
186
+ # Disabled so the multi-spec `/docs` page below can be served instead —
187
+ # FastAPI's helper only emits a single-spec page.
188
+ docs_url=None,
189
+ redoc_url=None,
190
+ lifespan=_lifespan,
191
+ # Reject unknown query params with 400 rather than silently ignoring
192
+ # them. App-wide; the dependency is one declared-set build per request.
193
+ dependencies=[Depends(reject_unknown_query_params)],
194
+ )
195
+ _apply_state(app, state)
196
+ attach_runtime(app, runtime)
197
+
198
+ _install_middleware(app, runtime, settings, perf_sink=perf_sink, outer=outer_middleware)
199
+
200
+ for router in routers:
201
+ app.include_router(router)
202
+
203
+ for mount in mounts:
204
+ _apply_state(mount.app, state)
205
+ share_runtime(app, mount.app)
206
+ app.mount(mount.path, mount.app)
207
+
208
+ # Mark vendor params/paths with `x-vendor: true` so SDK generators can
209
+ # filter them out for a spec-conformant client. Wraps each app's `openapi`
210
+ # method; the marker pass mutates FastAPI's cached dict in place.
211
+ _install_vendor_marker_hook(app)
212
+ for mount in mounts:
213
+ _install_vendor_marker_hook(mount.app)
214
+
215
+ _install_docs(app, settings, mounts)
216
+
217
+ payload = (
218
+ dict(root_payload)
219
+ if root_payload is not None
220
+ else {
221
+ "service": settings.title,
222
+ "version": settings.version,
223
+ "docs": "/docs",
224
+ }
225
+ )
226
+
227
+ @app.get("/", include_in_schema=False)
228
+ async def _root() -> JSONResponse:
229
+ return JSONResponse(payload)
230
+
231
+ return app
232
+
233
+
234
+ def _apply_state(app: FastAPI, state: Mapping[str, Any] | None) -> None:
235
+ for key, value in (state or {}).items():
236
+ setattr(app.state, key, value)
237
+
238
+
239
+ def _install_middleware(
240
+ app: FastAPI,
241
+ runtime: CatalogRuntime,
242
+ settings: RestSettings,
243
+ *,
244
+ perf_sink: Callable[..., None] | None,
245
+ outer: Sequence[MiddlewareSpec],
246
+ ) -> None:
247
+ """Build the middleware stack — the order here is an invariant.
248
+
249
+ Starlette wraps inside-out: the LAST middleware added is the OUTERMOST and
250
+ runs first on the way in, last on the way out. Reading the calls below
251
+ top-to-bottom therefore walks from the routes outward. Each adjacency
252
+ exists for a reason:
253
+
254
+ - **ConditionalGet innermost** — its ETag hashes the *uncompressed* body,
255
+ so it has to run before GZip on the way back out. Move it outward and
256
+ the ETag starts varying with compression settings.
257
+ - **LangOverride outside ConditionalGet** — ``?lang=`` rewrites the
258
+ Accept-Language header, and that rewrite must land before the ETag layer
259
+ reads the header, so ``?lang=fr`` hashes differently from the equivalent
260
+ English request on the same URL.
261
+ - **GZip outside the version-advertise rewrite** — the rewrite edits
262
+ Content-Type, which the compression decision reads.
263
+ - **HeadMethod inside the logging taps** — it rewrites HEAD to GET so the
264
+ ETag / GZip / version layers see a normal GET and the 304 short-circuit
265
+ behaves identically. The taps stay outside it so they still observe the
266
+ client's *original* method.
267
+ - **CORS outermost** — so it answers every OPTIONS preflight directly, even
268
+ for paths the app doesn't route. A preflight that falls through to a 404
269
+ reads to the browser as a CORS failure.
270
+ """
271
+ app.add_middleware(
272
+ ConditionalGetMiddleware,
273
+ runtime=runtime,
274
+ cache_control=settings.cache_control,
275
+ version=settings.version,
276
+ )
277
+ app.add_middleware(LangOverrideMiddleware)
278
+ # sdmxlib's writers emit three-part media-type versions (`;version=2.0.0`);
279
+ # SDMX-REST 2.2.2 §6.1 advertises the two-part form (`;version=2.0`), and
280
+ # strict clients compare Content-Type literally. Rewrites only the version
281
+ # segment on the way out; `ensure_acceptable` already treats both as
282
+ # equivalent on the way in, so three-part clients keep working.
283
+ app.add_middleware(SdmxVersionAdvertiseMiddleware)
284
+ app.add_middleware(
285
+ SelectiveGZipMiddleware,
286
+ minimum_size=settings.gzip_minimum_size,
287
+ compresslevel=settings.gzip_compress_level,
288
+ )
289
+ app.add_middleware(HeadMethodMiddleware)
290
+ # Outermost of the taps: `wall_ms` then covers CORS, compression, ETag,
291
+ # routing and body render. A `None` sink short-circuits at zero cost.
292
+ app.add_middleware(PerfLogMiddleware, sink=perf_sink)
293
+ for spec in outer:
294
+ app.add_middleware(spec.cls, **dict(spec.options))
295
+ app.add_middleware(
296
+ CORSMiddleware,
297
+ allow_origins=list(settings.cors_allowed_origins),
298
+ # Required by CORS to coexist with `allow_origins=["*"]`. Revisit if
299
+ # the service ever grows real auth.
300
+ allow_credentials=False,
301
+ allow_methods=list(_DEFAULT_ALLOW_METHODS),
302
+ # Wildcard rather than a curated list: real clients send Cache-Control,
303
+ # Pragma and custom X-* headers on top of the SDMX basics, and a
304
+ # curated list turns any extra header into a preflight 400 that
305
+ # browsers surface as "preflight doesn't pass access control check".
306
+ # Safe because `allow_credentials=False`.
307
+ allow_headers=["*"],
308
+ expose_headers=list(_DEFAULT_EXPOSE_HEADERS),
309
+ max_age=settings.cors_max_age,
310
+ )
311
+
312
+
313
+ def _install_vendor_marker_hook(app: FastAPI) -> None:
314
+ """Wrap ``app.openapi`` so vendor params/paths get ``x-vendor: true``.
315
+
316
+ FastAPI memoises the schema on ``app.openapi_schema``; the dict is mutated
317
+ in place once, and later calls return the already-marked cache.
318
+ """
319
+ original = app.openapi
320
+
321
+ def _openapi_with_vendor() -> dict[str, Any]:
322
+ spec = original()
323
+ if not isinstance(spec, dict):
324
+ return spec
325
+ if not spec.get("x-vendor-markers-applied"):
326
+ apply_vendor_markers(spec)
327
+ spec["x-vendor-markers-applied"] = True
328
+ return spec
329
+
330
+ app.openapi = _openapi_with_vendor # type: ignore[method-assign]
331
+
332
+
333
+ def _install_docs(app: FastAPI, settings: RestSettings, mounts: Sequence[Mount]) -> None:
334
+ """Serve a hand-rolled Swagger UI page with a spec dropdown.
335
+
336
+ FastAPI's ``get_swagger_ui_html`` always emits a single-spec ``url:`` line;
337
+ Swagger UI given both ``url`` and ``urls`` renders "No API definition
338
+ provided", so the page is built here instead. Three details are load-bearing
339
+ for the dropdown to appear at all: the standalone-preset script, the
340
+ ``StandaloneLayout`` layout, and ``SwaggerUIStandalonePreset`` in ``presets``.
341
+ """
342
+ specs = [(settings.canonical_spec_name, "/openapi.json")]
343
+ specs.extend((m.spec_name, f"{m.path}/openapi.json") for m in mounts if m.spec_name)
344
+ urls = ",\n".join(f' {{name: "{name}", url: "{url}"}}' for name, url in specs)
345
+
346
+ html = (
347
+ "<!DOCTYPE html>\n"
348
+ "<html>\n"
349
+ "<head>\n"
350
+ f" <title>{app.title} — API docs</title>\n"
351
+ ' <link rel="stylesheet" type="text/css" '
352
+ 'href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">\n'
353
+ "</head>\n"
354
+ "<body>\n"
355
+ ' <div id="swagger-ui"></div>\n'
356
+ ' <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>\n'
357
+ ' <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>\n'
358
+ " <script>\n"
359
+ " window.ui = SwaggerUIBundle({\n"
360
+ ' dom_id: "#swagger-ui",\n'
361
+ ' layout: "StandaloneLayout",\n'
362
+ " deepLinking: true,\n"
363
+ " showExtensions: true,\n"
364
+ " showCommonExtensions: true,\n"
365
+ " urls: [\n"
366
+ f"{urls}\n"
367
+ " ],\n"
368
+ f' "urls.primaryName": "{settings.canonical_spec_name}",\n'
369
+ # Drop the bottom-right validator badge — it pings validator.swagger.io,
370
+ # which can't reach a private cluster, so it always renders a spurious
371
+ # warning.
372
+ " validatorUrl: null,\n"
373
+ " presets: [\n"
374
+ " SwaggerUIBundle.presets.apis,\n"
375
+ " SwaggerUIStandalonePreset\n"
376
+ " ]\n"
377
+ " });\n"
378
+ " </script>\n"
379
+ "</body>\n"
380
+ "</html>\n"
381
+ )
382
+
383
+ @app.get("/docs", include_in_schema=False)
384
+ async def _swagger_ui() -> HTMLResponse:
385
+ return HTMLResponse(html)
386
+
387
+
388
+ __all__ = ["MiddlewareSpec", "Mount", "RestSettings", "create_app", "library_routers"]
@@ -0,0 +1,80 @@
1
+ """The availability-response cache seam.
2
+
3
+ ``/availability`` runs at minimum one ``SELECT DISTINCT`` per component against
4
+ the lake, and the result is fully determined by the request URL plus the
5
+ content-negotiation headers — the bundle is immutable per ``build_id``, so the
6
+ same key maps to the same bytes for as long as that build is served. That makes
7
+ the response an obvious cache candidate, but *whether* it is worth caching, how
8
+ many entries to hold, and where the bytes live are all consumer decisions: a
9
+ catalog whose flows are small enough to answer in milliseconds should not pay
10
+ for an LRU it never hits.
11
+
12
+ So the library only knows to ask the injected :class:`AvailabilityCache` before
13
+ doing work, and to offer the finished response back for storage. The default
14
+ :class:`NullAvailabilityCache` does neither, so a bare catalog simply always
15
+ runs the query. The cache is carried on the
16
+ :class:`~sdmxlib_rest.runtime.CatalogRuntime`, alongside
17
+ :class:`~sdmxlib_rest.structure_cache.StructureCache`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
23
+
24
+ from attrs import frozen
25
+
26
+ if TYPE_CHECKING:
27
+ from fastapi.responses import Response
28
+
29
+
30
+ @frozen
31
+ class AvailabilityQuery:
32
+ """The resolved availability request a cache keys on.
33
+
34
+ Everything that shapes the body is in the URL, so ``path`` + ``query`` (the
35
+ raw query string) identify the computation and the two headers cover
36
+ content negotiation. ``build_id`` pins the entry to the immutable bundle
37
+ build, so a re-publish invalidates every slot.
38
+
39
+ Keying on the raw path rather than the parsed flow coordinates matters: the
40
+ 1.5 ``/availableconstraint`` forwarders synthesise a per-flow stub path
41
+ precisely so two flows cannot collide in one slot.
42
+ """
43
+
44
+ build_id: str
45
+ path: str
46
+ query: str
47
+ accept: str | None
48
+ accept_language: str | None
49
+
50
+
51
+ @runtime_checkable
52
+ class AvailabilityCache(Protocol):
53
+ """Consumer-owned cache for rendered availability responses.
54
+
55
+ ``lookup`` returns a ready response to short-circuit with, or ``None`` to
56
+ proceed with the live query. ``store`` is offered the finished response for
57
+ the consumer to cache if its own gating says the query is worth it —
58
+ including the empty 204, which is as expensive to derive as a full answer.
59
+ """
60
+
61
+ def lookup(self, query: AvailabilityQuery) -> Response | None:
62
+ """Return a cached response for ``query``, or ``None`` to run it live."""
63
+ ...
64
+
65
+ def store(self, query: AvailabilityQuery, response: Response) -> None:
66
+ """Offer a freshly built response for caching (consumer decides)."""
67
+
68
+
69
+ class NullAvailabilityCache:
70
+ """Default no-op cache — never hits, never stores."""
71
+
72
+ def lookup(self, query: AvailabilityQuery) -> Response | None: # noqa: ARG002
73
+ """Never a hit."""
74
+ return None
75
+
76
+ def store(self, query: AvailabilityQuery, response: Response) -> None:
77
+ """No-op."""
78
+
79
+
80
+ __all__ = ["AvailabilityCache", "AvailabilityQuery", "NullAvailabilityCache"]
@@ -0,0 +1,94 @@
1
+ """Generic building blocks for an SDMX-REST data plane.
2
+
3
+ The library deliberately does **not** own the ``/data`` route: orchestration
4
+ is where catalog-specific caching, partition pushdown and vendor extensions
5
+ live, and every catalog shapes those differently. What it does own is the
6
+ reusable middle — query-parameter parsing, WHERE construction, projection
7
+ resolution, paging headers and the arrow→CSV/parquet streaming path.
8
+
9
+ A consumer writes its own handler and calls into these. See issue #34 for
10
+ the decision and its rationale.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from sdmxlib_rest.data.encoding import (
16
+ emit_csv_with_labels,
17
+ encode_parquet,
18
+ prepend_utf8_bom,
19
+ stream_csv_zip,
20
+ )
21
+ from sdmxlib_rest.data.paging import (
22
+ RANGE_RE,
23
+ build_range_headers,
24
+ parse_items_range,
25
+ range_status,
26
+ with_range,
27
+ )
28
+ from sdmxlib_rest.data.params import (
29
+ AGENCY_PATTERN,
30
+ C_OPERATORS,
31
+ C_PARAM,
32
+ DROP_ALL_ATTRIBUTE_KEYWORDS,
33
+ ID_PATTERN,
34
+ KEEP_ALL_ATTRIBUTE_KEYWORDS,
35
+ KEEP_ALL_MEASURE_KEYWORDS,
36
+ LIKE_OPS,
37
+ NON_EQ_OPERATOR_SQL,
38
+ VERSION_PATTERN,
39
+ build_non_eq_where,
40
+ like_pattern,
41
+ parse_bracketed,
42
+ parse_c_params,
43
+ parse_sort,
44
+ reject_unsupported_standard_params,
45
+ sql_ident,
46
+ validate_dimension_at_observation,
47
+ )
48
+ from sdmxlib_rest.data.projection import (
49
+ key_dimension_ids,
50
+ label_maps_for_dims,
51
+ materialise_dsd_for_flow,
52
+ project_arrow_batches,
53
+ project_binding,
54
+ resolve_projection_set,
55
+ )
56
+ from sdmxlib_rest.data.sql import decimals_format_replace_clause, qualify_is_active
57
+
58
+ __all__ = [
59
+ "AGENCY_PATTERN",
60
+ "C_OPERATORS",
61
+ "C_PARAM",
62
+ "DROP_ALL_ATTRIBUTE_KEYWORDS",
63
+ "ID_PATTERN",
64
+ "KEEP_ALL_ATTRIBUTE_KEYWORDS",
65
+ "KEEP_ALL_MEASURE_KEYWORDS",
66
+ "LIKE_OPS",
67
+ "NON_EQ_OPERATOR_SQL",
68
+ "RANGE_RE",
69
+ "VERSION_PATTERN",
70
+ "build_non_eq_where",
71
+ "build_range_headers",
72
+ "decimals_format_replace_clause",
73
+ "emit_csv_with_labels",
74
+ "encode_parquet",
75
+ "key_dimension_ids",
76
+ "label_maps_for_dims",
77
+ "like_pattern",
78
+ "materialise_dsd_for_flow",
79
+ "parse_bracketed",
80
+ "parse_c_params",
81
+ "parse_items_range",
82
+ "parse_sort",
83
+ "prepend_utf8_bom",
84
+ "project_arrow_batches",
85
+ "project_binding",
86
+ "qualify_is_active",
87
+ "range_status",
88
+ "reject_unsupported_standard_params",
89
+ "resolve_projection_set",
90
+ "sql_ident",
91
+ "stream_csv_zip",
92
+ "validate_dimension_at_observation",
93
+ "with_range",
94
+ ]