sdmxlib 0.63.0__tar.gz → 0.65.0__tar.gz

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 (134) hide show
  1. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/PKG-INFO +1 -1
  2. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/pyproject.toml +2 -2
  3. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/pyproject.toml.orig +11 -2
  4. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/__init__.py +24 -1
  5. {sdmxlib-0.63.0/src/sdmxlib/storage → sdmxlib-0.65.0/src/sdmxlib}/_time.py +12 -1
  6. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/_freshness.py +1 -1
  7. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/client.py +1 -1
  8. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_csv/writer.py +6 -5
  9. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/_data_common.py +78 -62
  10. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/data_v1.py +2 -3
  11. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/data_v2.py +2 -3
  12. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/local/sql.py +69 -2
  13. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/codelist.py +26 -4
  14. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/dataset.py +14 -2
  15. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/polars.py +4 -0
  16. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_compile.py +56 -21
  17. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_expr.py +97 -9
  18. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/observations.py +21 -3
  19. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/query.py +66 -5
  20. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/session.py +37 -31
  21. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/lazy.py +13 -1
  22. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/readers.py +1 -1
  23. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/resolve.py +50 -14
  24. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/schema.py +1 -1
  25. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/writers.py +3 -3
  26. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/README.md +0 -0
  27. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/_duckdb.py +0 -0
  28. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/_singleflight.py +0 -0
  29. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/__init__.py +0 -0
  30. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/federated.py +0 -0
  31. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/filters.py +0 -0
  32. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/policy.py +0 -0
  33. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/providers.py +0 -0
  34. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/query.py +0 -0
  35. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/registry.py +0 -0
  36. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/session.py +0 -0
  37. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/builders/__init__.py +0 -0
  38. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/builders/contentconstraint.py +0 -0
  39. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/builders/metadata_annotation.py +0 -0
  40. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/data_store.py +0 -0
  41. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/__init__.py +0 -0
  42. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/_coerce.py +0 -0
  43. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/_index.py +0 -0
  44. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/data_format.py +0 -0
  45. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/gaps.py +0 -0
  46. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  47. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  48. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  49. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/_dto.py +0 -0
  50. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/_resolve.py +0 -0
  51. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/_structure_dto.py +0 -0
  52. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/_structure_ingest.py +0 -0
  53. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  54. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  55. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/structure_values.py +0 -0
  56. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  57. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
  58. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -0
  59. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
  60. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml/_common.py +0 -0
  61. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml/_v3_reader.py +0 -0
  62. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml/_v3_writer.py +0 -0
  63. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  64. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml21/_synthetic.py +0 -0
  65. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  66. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  67. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  68. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  69. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  70. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  71. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  72. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  73. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
  74. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
  75. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml31/reader.py +0 -0
  76. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml31/writer.py +0 -0
  77. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/local/__init__.py +0 -0
  78. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/local/data_store.py +0 -0
  79. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/local/registry.py +0 -0
  80. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/__init__.py +0 -0
  81. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/_interning.py +0 -0
  82. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/_items.py +0 -0
  83. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/_label_match.py +0 -0
  84. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/_refshape.py +0 -0
  85. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/annotations.py +0 -0
  86. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/base.py +0 -0
  87. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/binding.py +0 -0
  88. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/category.py +0 -0
  89. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/collections.py +0 -0
  90. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/concept.py +0 -0
  91. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/constraint.py +0 -0
  92. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/convert.py +0 -0
  93. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/dataflow.py +0 -0
  94. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/datastructure.py +0 -0
  95. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/errors.py +0 -0
  96. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/expr.py +0 -0
  97. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/hierarchy.py +0 -0
  98. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/in_memory_registry.py +0 -0
  99. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/istring.py +0 -0
  100. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/mapping.py +0 -0
  101. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/message.py +0 -0
  102. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/metadata_provision.py +0 -0
  103. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/metadataflow.py +0 -0
  104. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/metadataset.py +0 -0
  105. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/metadatastructure.py +0 -0
  106. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/organisation.py +0 -0
  107. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/provision.py +0 -0
  108. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/ref.py +0 -0
  109. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/registry.py +0 -0
  110. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/representation.py +0 -0
  111. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/rest_api.py +0 -0
  112. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/rollup.py +0 -0
  113. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/urn.py +0 -0
  114. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/validation.py +0 -0
  115. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/version.py +0 -0
  116. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/py.typed +0 -0
  117. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/__init__.py +0 -0
  118. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_closure.py +0 -0
  119. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_common.py +0 -0
  120. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_edges.py +0 -0
  121. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_execute.py +0 -0
  122. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_joins.py +0 -0
  123. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_plan.py +0 -0
  124. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_pushdown.py +0 -0
  125. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_rebase.py +0 -0
  126. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_rest.py +0 -0
  127. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/accessors.py +0 -0
  128. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/raw.py +0 -0
  129. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/schema.py +0 -0
  130. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/rest.py +0 -0
  131. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/sql.py +0 -0
  132. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/__init__.py +0 -0
  133. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/_kinds.py +0 -0
  134. {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/_projections.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.63.0
3
+ Version: 0.65.0
4
4
  Summary: SDMX structural metadata library for Python
5
5
  Keywords: sdmx,statistics,metadata,datastructure
6
6
  Author: gabrielgellner
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.63.0"
3
+ version = "0.65.0"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -140,7 +140,7 @@ mutate_only_covered_lines = false
140
140
 
141
141
  [dependency-groups]
142
142
  dev = [
143
- "basedpyright>=1.39.9,<1.40",
143
+ "basedpyright==1.39.9",
144
144
  "catppuccin[pygments]>=2.5.0",
145
145
  "types-lxml>=2025.3.30",
146
146
  "mkdocs-material>=9.7.6",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.63.0"
3
+ version = "0.65.0"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -220,7 +220,16 @@ dev = [
220
220
  # ruff on `select = ["ALL"]` that is not hypothetical: ruff 0.16.0
221
221
  # stabilised 12 preview rules and would have turned CI red with no commit
222
222
  # of ours in between. Bump these deliberately, in their own MR.
223
- "basedpyright>=1.39.9,<1.40",
223
+ #
224
+ # basedpyright is pinned *exactly*, because the bound above was not enough:
225
+ # 1.39.10 is a patch release built on pyright 1.1.412, which stopped solving
226
+ # a generic class passed as a `Callable[[], R]` factory from the declared
227
+ # type — `x: Box[int] = make(Box)` is an error there and fine on 1.1.411.
228
+ # Every `field(factory=ItemList, ...)` in the model is that shape, so a fresh
229
+ # environment failed `just check` with 44 errors and no commit of ours in
230
+ # between. Still broken on pyright 1.1.414 / basedpyright 1.40.2. The pin
231
+ # goes with basedpyright itself in the pyrefly-only migration.
232
+ "basedpyright==1.39.9",
224
233
  "catppuccin[pygments]>=2.5.0",
225
234
  "types-lxml>=2025.3.30",
226
235
  "mkdocs-material>=9.7.6",
@@ -19,7 +19,7 @@ from sdmxlib.session import Session, SessionHook, ValueSet
19
19
  from sdmxlib.query import RawSql, RawSqlError, schema
20
20
  from sdmxlib.query.accessors import ArtefactRow, CodeRow, HCodeRow, ReportedAttributeRow, q
21
21
  from sdmxlib.query._compile import CompiledQuery
22
- from sdmxlib.query._expr import Agg, AggPred, AnyPred, Col, Cols, Cursor, Pred, RowKind, Win, WinFn, WinPred
22
+ from sdmxlib.query._expr import Agg, AggPred, AnyPred, Col, Cols, Cursor, Pred, RowKind, Untyped, Win, WinFn, WinPred
23
23
  from sdmxlib.query.observations import ObsQuery, ObsRow
24
24
  from sdmxlib.query.query import Frame, Grouped, Page, Query, union
25
25
  from sdmxlib.sql import SqlRelation
@@ -136,6 +136,27 @@ from sdmxlib.model import (
136
136
  artefact_urn,
137
137
  )
138
138
 
139
+
140
+ def len[R: RowKind]() -> Agg[R]: # noqa: A001 - polars' `pl.len()`; nothing in this module calls the builtin
141
+ """The number of rows, as an aggregate — `count(*)`, named for polars' `pl.len()`.
142
+
143
+ For `Query.agg`, which folds a whole query into one row, so the row count
144
+ can come out of the same scan as the other aggregates (#414):
145
+
146
+ obs.agg(SEX=obs.col("SEX").unique(), n=sl.len())
147
+
148
+ A function rather than a column method because it counts *rows*, not the
149
+ values of any column — the same reason `group_by(...).len()` is a method on
150
+ the grouping rather than on a column. The row kind is taken from where it is
151
+ used, so it fits any query's `agg`.
152
+
153
+ Shadows the builtin only as `sl.len`; this module never calls `len()`
154
+ itself, which is why it is defined here rather than re-exported from a
155
+ module that would need a second suppression.
156
+ """
157
+ return Agg[R]("count", ())
158
+
159
+
139
160
  __all__ = [
140
161
  "Agency",
141
162
  "AgencyScheme",
@@ -278,6 +299,7 @@ __all__ = [
278
299
  "SupportsUrn",
279
300
  "TimeDimension",
280
301
  "UnresolvedRef",
302
+ "Untyped",
281
303
  "ValidationError",
282
304
  "ValidationIssue",
283
305
  "ValueMap",
@@ -290,6 +312,7 @@ __all__ = [
290
312
  "dim",
291
313
  "from_dict",
292
314
  "item",
315
+ "len",
293
316
  "parse",
294
317
  "parse_components",
295
318
  "q",
@@ -35,12 +35,19 @@ breaking.
35
35
  from __future__ import annotations
36
36
 
37
37
  from datetime import UTC
38
- from typing import TYPE_CHECKING
38
+ from typing import TYPE_CHECKING, overload
39
39
 
40
40
  if TYPE_CHECKING:
41
41
  from datetime import datetime
42
42
 
43
43
 
44
+ # Overloaded so a value known to be present stays `datetime` on the way out —
45
+ # a query literal (#415) is never None, and widening it to `datetime | None`
46
+ # would push a pointless check onto every caller.
47
+ @overload
48
+ def to_storage(value: datetime) -> datetime: ...
49
+ @overload
50
+ def to_storage(value: None) -> None: ...
44
51
  def to_storage(value: datetime | None) -> datetime | None:
45
52
  """A datetime as this schema stores it: UTC, with `tzinfo` stripped.
46
53
 
@@ -60,6 +67,10 @@ def to_storage(value: datetime | None) -> datetime | None:
60
67
  return value.astimezone(UTC).replace(tzinfo=None)
61
68
 
62
69
 
70
+ @overload
71
+ def from_storage(value: datetime) -> datetime: ...
72
+ @overload
73
+ def from_storage(value: None) -> None: ...
63
74
  def from_storage(value: datetime | None) -> datetime | None:
64
75
  """A stored datetime as the model sees it: the same instant, tagged UTC.
65
76
 
@@ -56,7 +56,7 @@ class Freshness:
56
56
  "has the pointer moved?", which validators answer without one.
57
57
  """
58
58
 
59
- #: When the response was received. Naive UTC, per `storage._time`'s
59
+ #: When the response was received. Naive UTC, per `sdmxlib._time`'s
60
60
  #: convention, so comparisons cannot shift with a reader's zone.
61
61
  stored_at: datetime
62
62
  #: `ETag`, for `If-None-Match`. The cheapest revalidation there is.
@@ -223,7 +223,7 @@ def _raise_for_status(response: httpx.Response, *, url: str) -> None:
223
223
  def _now() -> datetime:
224
224
  """Now, as naive UTC.
225
225
 
226
- `storage._time`'s convention, so a stored instant means the same thing to
226
+ `sdmxlib._time`'s convention, so a stored instant means the same thing to
227
227
  every reader regardless of their machine's zone.
228
228
  """
229
229
  return datetime.now(UTC).replace(tzinfo=None)
@@ -23,7 +23,7 @@ column maps in their dict-insertion order, which matches the order in
23
23
  which they were declared on the structure.
24
24
 
25
25
  Performance: rows are serialised via Polars' Rust CSV writer
26
- (``pl.from_arrow(batch).write_csv``) rather than Python's `csv`,
26
+ (``pl.DataFrame(batch).write_csv``) rather than Python's `csv`,
27
27
  which makes the writer fast enough that bulk SDMX-CSV downloads are
28
28
  network-bound rather than writer-bound (#61).
29
29
 
@@ -43,7 +43,7 @@ Example:
43
43
  from __future__ import annotations
44
44
 
45
45
  import io
46
- from typing import TYPE_CHECKING, cast
46
+ from typing import TYPE_CHECKING
47
47
 
48
48
  import polars as pl
49
49
 
@@ -143,10 +143,11 @@ def write_sdmx_csv(
143
143
  )
144
144
  raise KeyError(msg)
145
145
 
146
- # pl.from_arrow returns DataFrame | Series; a RecordBatch always
147
- # gives back a DataFrame, but the union is sticky in the stubs.
146
+ # The constructor rather than `pl.from_arrow`, which is typed
147
+ # `DataFrame | Series` and since polars 2.0 really does return a Series
148
+ # for stream-only inputs — a `cast` here would have hidden that.
148
149
  df = (
149
- cast("pl.DataFrame", pl.from_arrow(batch))
150
+ pl.DataFrame(batch)
150
151
  .select(data_cols)
151
152
  .with_columns(
152
153
  pl.lit(structure).alias("STRUCTURE"),
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  import calendar
15
15
  import re
16
16
  from datetime import datetime, timedelta
17
- from typing import TYPE_CHECKING, Literal, NamedTuple, TypedDict, cast
17
+ from typing import TYPE_CHECKING, Literal, NamedTuple, TypedDict
18
18
 
19
19
  import msgspec
20
20
  import polars as pl
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
23
23
  import pyarrow as pa
24
24
 
25
25
  from sdmxlib.model.annotations import Annotations
26
- from sdmxlib.model.codelist import Codelist, CodelistLike
26
+ from sdmxlib.model.codelist import Code, CodelistLike
27
27
  from sdmxlib.model.dataflow import Dataflow
28
28
  from sdmxlib.model.datastructure import Component, DataStructure, Dimension
29
29
  from sdmxlib.model.istring import InternationalString
@@ -194,28 +194,16 @@ def _frequency_dimension(dsd: DataStructure) -> Dimension | None:
194
194
  return None
195
195
 
196
196
 
197
- def frequency_durations(dsd: DataStructure, lang: str) -> tuple[str | None, dict[str, _Duration]]:
198
- """Find the frequency dimension and its codes' annotation-derived durations.
197
+ def frequency_dimension_id(dsd: DataStructure) -> str | None:
198
+ """The id of the DSD's frequency dimension, or None if it has none.
199
199
 
200
- Returns ``(freq_dim_id, overrides)``. ``overrides`` only carries codes that
201
- declare an explicit ``duration`` annotation — the standard/``A{n}``
202
- convention is applied lazily at lookup time, so codes outside the codelist
203
- (or an unresolved codelist) still resolve by convention.
200
+ Only the id. The FREQ codes' ``duration`` annotations are read by
201
+ `ComponentTable` from the codes a message actually observed, in the same
202
+ batched lookup that labels them — this used to materialise the whole FREQ
203
+ codelist up front, one of the per-message full reads #410 removed.
204
204
  """
205
205
  freq = _frequency_dimension(dsd)
206
- if freq is None:
207
- return None, {}
208
- overrides: dict[str, _Duration] = {}
209
- codelist: CodelistLike | None = getattr(freq, "resolved_codelist", None)
210
- if codelist is not None:
211
- # `materialize()` because this reads each code's *annotations*, which
212
- # is past what `CodelistLike` offers; free when the codelist is already
213
- # eager, and a frequency codelist is a handful of codes either way.
214
- for code in codelist.materialize().codes:
215
- duration = _annotation_duration(code.annotations, lang)
216
- if duration is not None:
217
- overrides[code.id] = duration
218
- return freq.id, overrides
206
+ return freq.id if freq is not None else None
219
207
 
220
208
 
221
209
  def _add_duration(start: datetime, duration: _Duration) -> datetime:
@@ -316,6 +304,16 @@ class ComponentTable:
316
304
  SDMX-JSON spec explicitly allows this) rather than the full
317
305
  enumeration. The StatCan census geography codelist (579k codes)
318
306
  would otherwise blow up a ``limit=2`` data response to >30 MB.
307
+
308
+ **The cost follows the response too, not just its size.** A value's
309
+ name, ``parent`` and annotations are filled in at `to_json`, by one
310
+ `CodelistLike.codes_for` per component over exactly the ids observed.
311
+ Calling `materialize` here instead read every code of every enumerated
312
+ component on every message once the DSD came from a `LocalRegistry` —
313
+ 1.4 s to write one observation against a 200k-code geography, where the
314
+ same DSD with eager codelists took 3 ms (#410). Looking each code up as it
315
+ arrived would be one round trip per distinct value, which is the same
316
+ mistake at the other end of the scale.
319
317
  """
320
318
 
321
319
  def __init__(
@@ -328,7 +326,6 @@ class ComponentTable:
328
326
  annotations: AnnotationPool,
329
327
  time_dim_id: str | None = None,
330
328
  freq_dim_id: str | None = None,
331
- freq_overrides: dict[str, _Duration] | None = None,
332
329
  ) -> None:
333
330
  self._ids = component_ids
334
331
  # Shared with the sibling table — see `AnnotationPool`. Required rather
@@ -342,11 +339,12 @@ class ComponentTable:
342
339
  self._kind = kind
343
340
  self._time_dim_id = time_dim_id
344
341
  # FREQ-driven period bounds (issue #149): the frequency dimension id,
345
- # its codes' annotation-derived duration overrides, and the
346
- # accumulated TIME_PERIOD id → set of co-occurring FREQ ids seen while
347
- # streaming. Bounds are resolved at to_json() time from this.
342
+ # its observed codes' annotation-derived duration overrides (filled
343
+ # by `_resolve_codes`, alongside their labels), and the accumulated
344
+ # TIME_PERIOD id → set of co-occurring FREQ ids seen while streaming.
345
+ # Bounds are resolved at to_json() time from these.
348
346
  self._freq_dim_id = freq_dim_id
349
- self._freq_overrides = freq_overrides or {}
347
+ self._freq_overrides: dict[str, _Duration] = {}
350
348
  self._time_freqs: dict[str, set[str]] = {}
351
349
  # ``dsd.dimensions`` is ItemList[Dimension] and does NOT include
352
350
  # the time dimension (which lives at ``dsd.time_dimension``).
@@ -370,14 +368,12 @@ class ComponentTable:
370
368
  # so position N maps to the Nth-inserted entry. ``to_json``
371
369
  # walks this directly.
372
370
  self._codes: dict[str, dict[str, dict[str, object]]] = {cid: {} for cid in component_ids}
373
- # Per-component resolved codelists, stored by reference. Labels are
374
- # resolved on-demand in ``position()`` via ``ItemList.get`` (O(1));
375
- # we deliberately do NOT walk the full codelist here. For census-scale
376
- # codelists (CL_GEOGRAPHY: 579k codes) the eager walk dominated wall
377
- # time on small data responses — a single-row /data query spent
378
- # multiple seconds populating a label cache it would never read more
379
- # than once from.
380
- self._codelists: dict[str, Codelist] = {}
371
+ # Per-component resolved codelists, held as they came — eager or lazy
372
+ # — and never walked: `_resolve_codes` asks each for the observed ids
373
+ # only. For census-scale codelists (CL_GEOGRAPHY: 579k codes) the walk
374
+ # dominated wall time on small data responses, first as a label cache
375
+ # and again as a per-message `materialize()` (#410).
376
+ self._codelists: dict[str, CodelistLike] = {}
381
377
 
382
378
  for cid in component_ids:
383
379
  comp = components.get(cid)
@@ -395,10 +391,7 @@ class ComponentTable:
395
391
  # getattr returns Any; the annotation kills it on this line.
396
392
  cl: CodelistLike | None = getattr(comp, "resolved_codelist", None)
397
393
  if cl is not None:
398
- # Materialised because `value_entry` looks codes up by id and
399
- # reads name, parent_id and annotations off each — the full
400
- # `Code`, not the id-and-label surface `CodelistLike` shares.
401
- self._codelists[cid] = cl.materialize()
394
+ self._codelists[cid] = cl
402
395
  comp_anns = self._annotations.register(comp.annotations)
403
396
  if comp_anns:
404
397
  self._component_annotations[cid] = comp_anns
@@ -412,22 +405,39 @@ class ComponentTable:
412
405
  pos = len(table)
413
406
  table[code_id] = pos
414
407
  self._position_str[component_id][code_id] = str(pos)
415
- # Both SDMX-JSON 1.0 and 2.0 schemas require every value entry
416
- # to carry id + name. Look the localized name up on-demand from
417
- # the resolved codelist's id-indexed ItemList (O(1)); fall back
418
- # to the id when no codelist is registered or the code is
419
- # unknown to it (e.g. observed values outside the enumerated set).
420
- label = code_id
421
- parent_id: str | None = None
422
- value_anns: list[int] = []
423
- cl = self._codelists.get(component_id)
424
- if cl is not None:
425
- code = cl.codes.get(code_id)
426
- if code is not None:
427
- label = code.name.get(self._lang) or code_id
428
- parent_id = code.parent_id
429
- value_anns = self._annotations.register(code.annotations)
430
- entry: dict[str, object] = {"id": code_id, "name": label}
408
+ # Both SDMX-JSON 1.0 and 2.0 schemas require every value entry to carry
409
+ # id + name. The id stands in for the name until `_resolve_codes`
410
+ # replaces it, and stays for a value the codelist does not hold (or a
411
+ # component with no codelist).
412
+ self._codes[component_id][code_id] = {"id": code_id, "name": code_id}
413
+ return pos
414
+
415
+ def _resolve_codes(self) -> None:
416
+ """Fill each observed value's name, ``parent`` and annotations.
417
+
418
+ One `CodelistLike.codes_for` per enumerated component, over the ids
419
+ this message observed — the whole point of deferring it here (#410).
420
+ Annotations are registered in position order, so the pool's indices
421
+ are deterministic for a given input.
422
+ """
423
+ for component_id, cl in self._codelists.items():
424
+ entries = self._codes[component_id]
425
+ if not entries:
426
+ continue
427
+ found = cl.codes_for(entries.keys())
428
+ for code_id, entry in entries.items():
429
+ code = found.get(code_id)
430
+ if code is None:
431
+ continue
432
+ if component_id == self._freq_dim_id:
433
+ duration = _annotation_duration(code.annotations, self._lang)
434
+ if duration is not None:
435
+ self._freq_overrides[code_id] = duration
436
+ self._fill_entry(entry, code)
437
+
438
+ def _fill_entry(self, entry: dict[str, object], code: Code) -> None:
439
+ """Write one resolved *code*'s name, ``parent`` and annotations into *entry*."""
440
+ entry["name"] = code.name.get(self._lang) or code.id
431
441
  # A value's hierarchy edge (#365). `parent` is a first-class property of
432
442
  # this object in the 1.0, 2.0 and 2.1 data schemas, and omitting it made
433
443
  # every hierarchical codelist arrive flat: the .Stat Data Explorer groups
@@ -446,20 +456,17 @@ class ComponentTable:
446
456
  # `IDType`, so an id it rejects was already invalid SDMX upstream. Adding
447
457
  # a filter here would turn someone else's malformed codelist into a
448
458
  # clean-looking document with a silently missing hierarchy.
449
- if parent_id is not None:
450
- entry["parent"] = parent_id
459
+ if code.parent_id is not None:
460
+ entry["parent"] = code.parent_id
451
461
  # The code's annotations, as indices into the shared pool (#366). This
452
462
  # is how a consumer gets a value's ORDER, NOT_DISPLAYED and the rest —
453
463
  # the *type* vocabulary is the annotation creator's, not SDMX's ("the
454
464
  # types are not enumerated, as these can be specified by the user or
455
465
  # creator", per the AnnotationType documentation in every SDMX-ML
456
466
  # version), so the writer emits all of them and filters none.
467
+ value_anns = self._annotations.register(code.annotations)
457
468
  if value_anns:
458
469
  entry["annotations"] = value_anns
459
- # TIME_PERIOD start/end is resolved in to_json() once the full set of
460
- # co-occurring FREQ values is known (see _resolve_time_bounds).
461
- self._codes[component_id][code_id] = entry
462
- return pos
463
470
 
464
471
  def tracks_freq(self) -> bool:
465
472
  """True if both a time dimension and a frequency dimension are known."""
@@ -532,7 +539,12 @@ class ComponentTable:
532
539
  return {cid: pos_str[cid] for cid in code_ids}
533
540
 
534
541
  def to_json(self) -> list[dict[str, object]]:
535
- """Render the per-position metadata array for the structures block."""
542
+ """Render the per-position metadata array for the structures block.
543
+
544
+ Call once, after the last batch: this is where observed values are
545
+ resolved against their codelists.
546
+ """
547
+ self._resolve_codes()
536
548
  out: list[dict[str, object]] = []
537
549
  for key_pos, cid in enumerate(self._ids):
538
550
  code_entries = self._codes[cid]
@@ -628,7 +640,11 @@ def encode_batch(
628
640
  )
629
641
  raise KeyError(msg)
630
642
 
631
- df = cast("pl.DataFrame", pl.from_arrow(batch))
643
+ # The constructor, not `pl.from_arrow`: the same frame for a RecordBatch, but
644
+ # typed `DataFrame` rather than `DataFrame | Series` — and polars 2.0 made
645
+ # `from_arrow` return a Series for stream-only inputs, which a `cast` would
646
+ # have passed through unchecked.
647
+ df = pl.DataFrame(batch)
632
648
  n = df.height
633
649
  if n == 0:
634
650
  return b""
@@ -49,7 +49,7 @@ from sdmxlib.formats.sdmx_json._data_common import (
49
49
  decode_observations,
50
50
  display_names,
51
51
  encode_batch,
52
- frequency_durations,
52
+ frequency_dimension_id,
53
53
  resolve_columns,
54
54
  rows_to_lazyframe,
55
55
  )
@@ -121,7 +121,7 @@ def write_sdmx_json_data(
121
121
  dim_cols, dim_phys, attr_cols, attr_phys, _measure_id, measure_col = resolve_columns(dsd)
122
122
 
123
123
  time_dim_id = dsd.time_dimension.id if dsd.time_dimension is not None else "TIME_PERIOD"
124
- freq_dim_id, freq_overrides = frequency_durations(dsd, lang)
124
+ freq_dim_id = frequency_dimension_id(dsd)
125
125
  # One pool for the whole structure block — the two tables emit integer
126
126
  # indices into it, and an index is meaningless without knowing which array
127
127
  # it indexes (#366).
@@ -134,7 +134,6 @@ def write_sdmx_json_data(
134
134
  annotations=annotations,
135
135
  time_dim_id=time_dim_id,
136
136
  freq_dim_id=freq_dim_id,
137
- freq_overrides=freq_overrides,
138
137
  )
139
138
  attr_lookup = ComponentTable(attr_cols, dsd, kind="attribute", lang=lang, annotations=annotations)
140
139
 
@@ -63,7 +63,7 @@ from sdmxlib.formats.sdmx_json._data_common import (
63
63
  decode_observations,
64
64
  display_names,
65
65
  encode_batch,
66
- frequency_durations,
66
+ frequency_dimension_id,
67
67
  resolve_columns,
68
68
  rows_to_lazyframe,
69
69
  )
@@ -142,7 +142,7 @@ def write_sdmx_json_data(
142
142
  dim_cols, dim_phys, attr_cols, attr_phys, _measure_id, measure_col = resolve_columns(dsd)
143
143
 
144
144
  time_dim_id = dsd.time_dimension.id if dsd.time_dimension is not None else "TIME_PERIOD"
145
- freq_dim_id, freq_overrides = frequency_durations(dsd, lang)
145
+ freq_dim_id = frequency_dimension_id(dsd)
146
146
  # One pool for the whole structure block — the two tables emit integer
147
147
  # indices into it, and an index is meaningless without knowing which array
148
148
  # it indexes (#366).
@@ -155,7 +155,6 @@ def write_sdmx_json_data(
155
155
  annotations=annotations,
156
156
  time_dim_id=time_dim_id,
157
157
  freq_dim_id=freq_dim_id,
158
- freq_overrides=freq_overrides,
159
158
  )
160
159
  attr_lookup = ComponentTable(attr_cols, dsd, kind="attribute", lang=lang, annotations=annotations)
161
160
 
@@ -59,6 +59,20 @@ if TYPE_CHECKING:
59
59
  from sdmxlib.model.binding import DataflowBinding
60
60
 
61
61
 
62
+ class SidecarNotUniqueError(ValueError):
63
+ """A series-attributes sidecar holds more than one row per join key (#412).
64
+
65
+ The sidecar is joined as `LEFT JOIN … USING (<keys>)`, so every extra row
66
+ per key multiplies each observation it matches. Nothing errors downstream:
67
+ `count()`, `collect()` and every aggregate over a sidecar column come back
68
+ inflated by the fan-out factor, and every row looks valid.
69
+
70
+ A `ValueError` because it is the same kind of refusal `plan` already makes
71
+ for a sidecar sharing no columns — the binding names a table that cannot
72
+ serve as its series attributes — so a caller catching that catches this.
73
+ """
74
+
75
+
62
76
  @frozen(kw_only=True)
63
77
  class QueryPlan:
64
78
  """The resolved ``FROM`` side of a `DataflowBinding`'s query.
@@ -109,6 +123,10 @@ class SchemaInspector:
109
123
  def __init__(self, conn: duckdb.DuckDBPyConnection) -> None:
110
124
  self._conn = conn
111
125
  self._columns_cache: dict[str, tuple[str, ...]] = {}
126
+ #: (table, keys) -> (rows, distinct keys), for `key_counts`. Shares
127
+ #: `_columns_cache`'s lock and lifecycle: both describe a table as it
128
+ #: stands, and `invalidate` is the one signal that it changed.
129
+ self._key_counts_cache: dict[tuple[str, tuple[str, ...]], tuple[int, int]] = {}
112
130
  self._lock = threading.Lock()
113
131
 
114
132
  def columns(self, table: str) -> tuple[str, ...]:
@@ -139,15 +157,50 @@ class SchemaInspector:
139
157
  cols_b = set(self.columns(table_b))
140
158
  return tuple(c for c in self.columns(table_a) if c in cols_b)
141
159
 
160
+ def key_counts(self, table: str, keys: tuple[str, ...]) -> tuple[int, int]:
161
+ """``(rows, distinct key combinations)`` of ``table`` over ``keys`` (cached).
162
+
163
+ Rows with a NULL in any key are left out of both counts: `USING` never
164
+ matches NULL, so repeated NULL-keyed rows cannot multiply a join, and
165
+ refusing a table for them would be refusing a harmless one.
166
+
167
+ A scan of the table — ~5 ms on cenprof-2021-csip's 619-row geography
168
+ sidecar, ~37 ms cold — so memoised single-flight beside `columns`, and
169
+ dropped by `invalidate` with it.
170
+
171
+ Args:
172
+ table: The table to count, as `columns` takes it.
173
+ keys: The columns whose combinations are counted.
174
+ """
175
+ cache_key = (table, keys)
176
+ cached = self._key_counts_cache.get(cache_key)
177
+ if cached is not None:
178
+ return cached
179
+ with self._lock:
180
+ cached = self._key_counts_cache.get(cache_key)
181
+ if cached is not None:
182
+ return cached
183
+ tuple_sql = ", ".join(keys)
184
+ not_null = " AND ".join(f"{k} IS NOT NULL" for k in keys)
185
+ row = self._conn.execute(
186
+ f"SELECT count(*), count(DISTINCT ({tuple_sql})) FROM {table} WHERE {not_null}"
187
+ ).fetchone()
188
+ counts = (int(row[0]), int(row[1])) if row else (0, 0)
189
+ self._key_counts_cache[cache_key] = counts
190
+ return counts
191
+
142
192
  def invalidate(self, table: str) -> None:
143
- """Drop the cached column list for ``table`` (e.g. after a rewrite)."""
193
+ """Drop everything cached about ``table`` (e.g. after a rewrite)."""
144
194
  with self._lock:
145
195
  self._columns_cache.pop(table, None)
196
+ for cache_key in [k for k in self._key_counts_cache if k[0] == table]:
197
+ del self._key_counts_cache[cache_key]
146
198
 
147
199
  def invalidate_all(self) -> None:
148
- """Drop all cached column lists."""
200
+ """Drop everything cached about every table."""
149
201
  with self._lock:
150
202
  self._columns_cache.clear()
203
+ self._key_counts_cache.clear()
151
204
 
152
205
  def plan(self, binding: DataflowBinding) -> QueryPlan:
153
206
  """Resolve ``binding``'s main/sidecar tables into a `QueryPlan`.
@@ -163,6 +216,9 @@ class SchemaInspector:
163
216
  Raises:
164
217
  ValueError: If the binding names a sidecar that shares no columns
165
218
  with the main table (no derivable join keys).
219
+ SidecarNotUniqueError: If the sidecar holds more than one row for
220
+ some combination of those keys, which would multiply every
221
+ observation the join matches (#412).
166
222
  """
167
223
  main = f"{binding.database_role}.{binding.table_location}"
168
224
  sidecar_loc = binding.series_attributes_location
@@ -176,4 +232,15 @@ class SchemaInspector:
176
232
  f"shares no columns with {main!r}; cannot derive JOIN keys"
177
233
  )
178
234
  raise ValueError(msg)
235
+ rows, distinct = self.key_counts(sidecar, join_keys)
236
+ if rows != distinct:
237
+ keys = ", ".join(join_keys)
238
+ msg = (
239
+ f"series-attributes sidecar {sidecar!r} holds {rows:,} rows but only {distinct:,} distinct "
240
+ f"({keys}) — joined on those keys, every observation of a repeated series would be counted "
241
+ f"once per sidecar row, so counts and aggregates over its columns come back inflated (#412). "
242
+ f"The keys are the columns the two tables share; make the sidecar unique on them, or drop the "
243
+ f"shared columns that are not part of the series key."
244
+ )
245
+ raise SidecarNotUniqueError(msg)
179
246
  return QueryPlan(main=main, sidecar=sidecar, join_keys=join_keys)
@@ -1,5 +1,6 @@
1
1
  """Codelist and Code — enumerated value sets."""
2
2
 
3
+ from collections.abc import Iterable
3
4
  from datetime import datetime
4
5
  from enum import Enum
5
6
  from typing import TYPE_CHECKING, ClassVar, Protocol, runtime_checkable
@@ -186,14 +187,28 @@ class CodelistLike(Protocol):
186
187
  """``{code_id: label}`` in ``lang``, falling back to the id."""
187
188
  ...
188
189
 
190
+ def codes_for(self, ids: Iterable[str]) -> dict[str, Code]:
191
+ """``{code_id: Code}`` for exactly *ids*, each fully built.
192
+
193
+ Ids the codelist does not hold are absent from the result rather than
194
+ an error — a caller resolving observed values meets values outside the
195
+ enumeration, and the id is then its own label.
196
+
197
+ The targeted counterpart to `materialize`: one batched read on a lazy
198
+ codelist, whose cost follows ``len(ids)`` rather than the codelist. The
199
+ SDMX-JSON data writer called `materialize` per component per message
200
+ and paid for every code in a 579,951-code geography to label the
201
+ handful a response observed (#410).
202
+ """
203
+ ...
204
+
189
205
  def materialize(self) -> "Codelist":
190
206
  """This codelist with every `Code` built, for callers needing more.
191
207
 
192
208
  Free on an eager `Codelist`, which returns itself; a full read on a
193
- lazy one. Declared here so a caller that genuinely needs each code's
194
- annotations or ``parent_id`` — the SDMX-JSON data writer does — asks
195
- for that in one visible call instead of iterating something that might
196
- be a `Query`.
209
+ lazy one, whose cost is the whole codelist. A caller that needs some
210
+ codes' annotations or ``parent_id`` rather than all of them wants
211
+ `codes_for` instead — that was the SDMX-JSON data writer (#410).
197
212
  """
198
213
  ...
199
214
 
@@ -385,6 +400,13 @@ class Codelist(MaintainableArtefact):
385
400
  """
386
401
  return self.codes.ids()
387
402
 
403
+ def codes_for(self, ids: Iterable[str]) -> dict[str, Code]:
404
+ """``{code_id: Code}`` for exactly *ids*; unknown ids are absent.
405
+
406
+ The eager half of `CodelistLike.codes_for` — an `ItemList.get` per id.
407
+ """
408
+ return {cid: code for cid in ids if (code := self.codes.get(cid)) is not None}
409
+
388
410
  def materialize(self) -> "Codelist":
389
411
  """This codelist — it is already materialised.
390
412