sdmxlib 0.45.0__tar.gz → 0.46.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 (103) hide show
  1. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/PKG-INFO +2 -2
  2. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/pyproject.toml +20 -3
  3. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/__init__.py +2 -0
  4. sdmxlib-0.46.0/src/sdmxlib/_duckdb.py +22 -0
  5. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/federated.py +2 -6
  6. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/query.py +1 -2
  7. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/registry.py +10 -11
  8. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/session.py +10 -6
  9. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/__init__.py +29 -9
  10. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json/_data_common.py +21 -13
  11. sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/_dto.py +208 -0
  12. sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/_resolve.py +37 -0
  13. sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/_structure_dto.py +913 -0
  14. sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/_structure_ingest.py +836 -0
  15. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json/data_v1.py +7 -6
  16. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json/data_v2.py +7 -6
  17. sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/metadata.py +229 -0
  18. sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/reader.py +396 -0
  19. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json/structure_values.py +3 -1
  20. sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json/writer.py +121 -0
  21. sdmxlib-0.46.0/src/sdmxlib/formats/sdmx_json_1_0/writer.py +194 -0
  22. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml/_common.py +64 -29
  23. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml21/reader.py +39 -38
  24. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml21/writer.py +1 -1
  25. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml30/metadata.py +74 -88
  26. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml30/reader.py +47 -46
  27. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml30/writer.py +14 -9
  28. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml31/reader.py +47 -46
  29. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml31/writer.py +18 -11
  30. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/local/data_store.py +13 -5
  31. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/local/registry.py +48 -44
  32. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/local/sql.py +27 -31
  33. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/__init__.py +2 -0
  34. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/base.py +7 -6
  35. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/binding.py +1 -1
  36. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/code_query.py +11 -8
  37. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/collections.py +41 -15
  38. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/dataset.py +16 -50
  39. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/hierarchy.py +2 -4
  40. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/hierarchy_query.py +13 -6
  41. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/in_memory_registry.py +13 -44
  42. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/istring.py +29 -6
  43. sdmxlib-0.46.0/src/sdmxlib/model/metadata_provision.py +60 -0
  44. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/metadataset.py +26 -11
  45. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/ref.py +46 -8
  46. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/registry.py +2 -1
  47. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/validation.py +3 -3
  48. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/polars.py +92 -12
  49. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/_kinds.py +2 -0
  50. sdmxlib-0.46.0/src/sdmxlib/storage/_projections.py +58 -0
  51. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/lazy.py +8 -9
  52. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/readers.py +46 -57
  53. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/schema.py +7 -0
  54. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/writers.py +35 -8
  55. sdmxlib-0.45.0/src/sdmxlib/_duckdb.py +0 -18
  56. sdmxlib-0.45.0/src/sdmxlib/formats/sdmx_json/metadata.py +0 -200
  57. sdmxlib-0.45.0/src/sdmxlib/formats/sdmx_json/reader.py +0 -1224
  58. sdmxlib-0.45.0/src/sdmxlib/formats/sdmx_json/writer.py +0 -921
  59. sdmxlib-0.45.0/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -166
  60. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/README.md +0 -0
  61. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/__init__.py +0 -0
  62. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/client.py +0 -0
  63. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/filters.py +0 -0
  64. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/policy.py +0 -0
  65. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/api/providers.py +0 -0
  66. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/builders/__init__.py +0 -0
  67. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/builders/contentconstraint.py +0 -0
  68. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/catalog.py +0 -0
  69. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/data_store.py +0 -0
  70. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  71. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  72. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  73. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  74. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
  75. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
  76. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  77. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  78. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  79. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  80. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
  81. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
  82. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/local/__init__.py +0 -0
  83. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/annotations.py +0 -0
  84. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/category.py +0 -0
  85. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/codelist.py +0 -0
  86. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/concept.py +0 -0
  87. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/constraint.py +0 -0
  88. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/convert.py +0 -0
  89. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/dataflow.py +0 -0
  90. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/datastructure.py +0 -0
  91. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/expr.py +0 -0
  92. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/mapping.py +0 -0
  93. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/message.py +0 -0
  94. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/metadataflow.py +0 -0
  95. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/metadatastructure.py +0 -0
  96. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/organisation.py +0 -0
  97. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/provision.py +0 -0
  98. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/representation.py +0 -0
  99. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/model/urn.py +0 -0
  100. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/py.typed +0 -0
  101. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/rest.py +0 -0
  102. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/sql.py +0 -0
  103. {sdmxlib-0.45.0 → sdmxlib-0.46.0}/src/sdmxlib/storage/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.45.0
3
+ Version: 0.46.0
4
4
  Summary: SDMX structural metadata library for Python
5
5
  Keywords: sdmx,statistics,metadata,datastructure
6
6
  Author: gabrielgellner
@@ -16,7 +16,7 @@ Requires-Dist: attrs>=26.1.0
16
16
  Requires-Dist: duckdb>=1.5.3
17
17
  Requires-Dist: httpx>=0.28.1
18
18
  Requires-Dist: lxml>=6.0.2
19
- Requires-Dist: orjson>=3.9
19
+ Requires-Dist: msgspec>=0.19
20
20
  Requires-Dist: polars>=1.39.3
21
21
  Requires-Dist: pyarrow>=18.0.0
22
22
  Requires-Python: >=3.13
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.45.0"
3
+ version = "0.46.0"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -24,7 +24,7 @@ dependencies = [
24
24
  "duckdb>=1.5.3",
25
25
  "httpx>=0.28.1",
26
26
  "lxml>=6.0.2",
27
- "orjson>=3.9",
27
+ "msgspec>=0.19",
28
28
  "polars>=1.39.3",
29
29
  "pyarrow>=18.0.0",
30
30
  ]
@@ -83,11 +83,28 @@ typeCheckingMode = "standard"
83
83
  # we *don't* care about are tests.
84
84
  reportUnusedCallResult = false
85
85
 
86
+ # Strict-mode rules that ARE clean on this codebase (0 findings), turned on
87
+ # to catch regressions the flood-prone `reportUnknown*` rules would bury.
88
+ # The first two are basedpyright's Any-degradation catchers — the closest it
89
+ # gets to pyrefly's `implicit-any` family without the duckdb/pyarrow noise:
90
+ # * reportMissingTypeArgument — a bare generic (`list` not `list[str]`) → Any.
91
+ # * reportMissingParameterType — an untyped parameter → Any.
92
+ # The rest are pure hardening: dead casts (aligns with "narrow, don't cast"),
93
+ # getter/setter type drift, __init__/__new__ disagreement, mutable-default
94
+ # gotchas, and always-true/false `in` checks.
95
+ reportMissingTypeArgument = "error"
96
+ reportMissingParameterType = "error"
97
+ reportUnnecessaryCast = "error"
98
+ reportPropertyTypeMismatch = "error"
99
+ reportInconsistentConstructor = "error"
100
+ reportCallInDefaultInitializer = "error"
101
+ reportUnnecessaryContains = "error"
102
+
86
103
  [dependency-groups]
87
104
  dev = [
88
105
  "basedpyright>=1.38.4",
89
106
  "catppuccin[pygments]>=2.5.0",
90
- "lxml-stubs>=0.5.1",
107
+ "types-lxml>=2025.3.30",
91
108
  "mkdocs-material>=9.7.6",
92
109
  "mkdocstrings[python]>=1.0.3",
93
110
  "polars>=1.39.3",
@@ -90,6 +90,7 @@ from sdmxlib.model import (
90
90
  MeasureDimension,
91
91
  MetadataAttribute,
92
92
  MetadataMessage,
93
+ MetadataProvisionAgreement,
93
94
  MetadataSection,
94
95
  MetadataSet,
95
96
  MetadataStructure,
@@ -199,6 +200,7 @@ __all__ = [
199
200
  "MemberValue",
200
201
  "MetadataAttribute",
201
202
  "MetadataMessage",
203
+ "MetadataProvisionAgreement",
202
204
  "MetadataSection",
203
205
  "MetadataSet",
204
206
  "MetadataStructure",
@@ -0,0 +1,22 @@
1
+ """Private DuckDB helpers shared by the storage layer and model query DSLs.
2
+
3
+ Lives at the package root because both ``storage/`` and the model query DSLs
4
+ (``code_query``, ``hierarchy_query``) need it, and neither layer may import the
5
+ other.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import duckdb
11
+
12
+
13
+ def count(cursor: duckdb.DuckDBPyConnection) -> int:
14
+ """Return the integer in the first cell of the cursor's first row, or 0.
15
+
16
+ The ``__len__`` helper for ``SELECT COUNT(*)`` queries: centralises the
17
+ ``row = cur.fetchone(); row[0] if row else 0`` idiom. Generalise to a typed
18
+ ``scalar[T](cursor, *, default: T) -> T`` if a non-count scalar read ever
19
+ appears.
20
+ """
21
+ row = cursor.fetchone()
22
+ return int(row[0]) if row is not None else 0
@@ -35,6 +35,8 @@ from typing import TYPE_CHECKING
35
35
  import httpx
36
36
 
37
37
  from sdmxlib.api.policy import FailurePolicy, RegistryUnavailable
38
+ from sdmxlib.model.binding import DriftError, _validate_binding
39
+ from sdmxlib.model.urn import SdmxUrn
38
40
 
39
41
  if TYPE_CHECKING:
40
42
  from sdmxlib.model.binding import DataflowBinding, DriftIssue
@@ -140,8 +142,6 @@ class FederatedRegistry:
140
142
  Like the underlying Registry membership: never triggers a network
141
143
  round-trip. ``False`` if no source is routed for the URN's agency.
142
144
  """
143
- from sdmxlib.model.urn import SdmxUrn # noqa: PLC0415
144
-
145
145
  if not isinstance(urn, SdmxUrn):
146
146
  return False
147
147
  source = self._route(urn)
@@ -158,8 +158,6 @@ class FederatedRegistry:
158
158
  want to enumerate or display drift; use ``validate_binding()``
159
159
  when you want to raise on any drift.
160
160
  """
161
- from sdmxlib.model.binding import _validate_binding # noqa: PLC0415
162
-
163
161
  return _validate_binding(binding, self)
164
162
 
165
163
  def validate_binding(self, binding: "DataflowBinding") -> None:
@@ -171,8 +169,6 @@ class FederatedRegistry:
171
169
  DriftError: If any drift is detected. ``error.issues`` carries
172
170
  the full accumulated list.
173
171
  """
174
- from sdmxlib.model.binding import DriftError # noqa: PLC0415
175
-
176
172
  issues = self.check_binding_drift(binding)
177
173
  if issues:
178
174
  raise DriftError(issues)
@@ -1,5 +1,6 @@
1
1
  """GetQuery[T], Query[T], and DataQuery — artefact and data queries."""
2
2
 
3
+ import tempfile
3
4
  from collections.abc import Callable
4
5
  from pathlib import Path
5
6
  from typing import TYPE_CHECKING, Any, Self
@@ -396,8 +397,6 @@ class DataQuery:
396
397
  Example:
397
398
  reg.data(flow).measures(["EMP", "UNE"]).pivot_to("wide.parquet")
398
399
  """
399
- import tempfile # noqa: PLC0415
400
-
401
400
  suffix = ".csv"
402
401
  with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
403
402
  tmp_path = Path(tmp.name)
@@ -11,11 +11,12 @@ from sdmxlib.api.providers import ProviderInfo
11
11
  from sdmxlib.api.query import DataQuery, GetQuery, Query, _KeySpec
12
12
  from sdmxlib.api.session import Session
13
13
  from sdmxlib.formats import parse as _parse
14
+ from sdmxlib.model.binding import DriftError, _validate_binding
14
15
  from sdmxlib.model.dataflow import Dataflow
15
16
  from sdmxlib.model.dataset import Dataset
16
17
  from sdmxlib.model.datastructure import DataStructure
17
18
  from sdmxlib.model.message import StructureMessage
18
- from sdmxlib.model.ref import Ref
19
+ from sdmxlib.model.ref import Ref, SupportsUrn
19
20
  from sdmxlib.model.registry import InMemoryRegistry
20
21
  from sdmxlib.model.urn import SdmxUrn
21
22
 
@@ -300,12 +301,10 @@ class RestRegistry:
300
301
  if cached is None:
301
302
  return None
302
303
  # Warm the in-process state. Some artefacts (e.g. LazyCodelist) don't
303
- # satisfy HasUrn cleanly; in that case skip in-memory interning while
304
+ # satisfy SupportsUrn cleanly; skip in-memory interning for those while
304
305
  # keeping Session caching.
305
- import contextlib # noqa: PLC0415
306
-
307
306
  self._session._map[str(urn)] = cached # noqa: SLF001
308
- with contextlib.suppress(TypeError):
307
+ if isinstance(cached, SupportsUrn):
309
308
  self._registry.add(cached)
310
309
  return cached
311
310
 
@@ -671,7 +670,7 @@ class RestRegistry:
671
670
  it is streamed straight to the file, and the returned Dataset's
672
671
  ``LazyFrame`` is scanned from disk.
673
672
  """
674
- from sdmxlib.formats.sdmx_csv.reader import scan_csv # noqa: PLC0415
673
+ from sdmxlib.formats.sdmx_csv.reader import scan_csv # noqa: PLC0415 # lazy: defer CSV import
675
674
 
676
675
  dataflow_ref = Ref.to(spec.dataflow) if spec.dataflow is not None else None
677
676
  if to is None:
@@ -718,7 +717,11 @@ class RestRegistry:
718
717
  ds = reg.scan_csv("ecb_exr.csv")
719
718
  df = ds.collect(with_labels=True, lang="en")
720
719
  """
721
- from sdmxlib.formats.sdmx_csv.reader import _peek_dataflow_id, parse_dataflow_id, scan_csv # noqa: PLC0415
720
+ from sdmxlib.formats.sdmx_csv.reader import ( # noqa: PLC0415 # lazy: defer CSV import
721
+ _peek_dataflow_id,
722
+ parse_dataflow_id,
723
+ scan_csv,
724
+ )
722
725
 
723
726
  dataflow_str = _peek_dataflow_id(source)
724
727
  if dataflow_str is None:
@@ -755,8 +758,6 @@ class RestRegistry:
755
758
  when you want to enumerate or display drift; use
756
759
  ``validate_binding()`` when you want to raise on any drift.
757
760
  """
758
- from sdmxlib.model.binding import _validate_binding # noqa: PLC0415
759
-
760
761
  return _validate_binding(binding, self)
761
762
 
762
763
  def validate_binding(self, binding: "DataflowBinding") -> None:
@@ -768,8 +769,6 @@ class RestRegistry:
768
769
  DriftError: If any drift is detected. ``error.issues`` carries
769
770
  the full accumulated list.
770
771
  """
771
- from sdmxlib.model.binding import DriftError # noqa: PLC0415
772
-
773
772
  issues = self.check_binding_drift(binding)
774
773
  if issues:
775
774
  raise DriftError(issues)
@@ -1,7 +1,6 @@
1
1
  """Session — identity map and artefact construction."""
2
2
 
3
- from typing import Any
4
-
3
+ from sdmxlib.model.collections import Maintainable
5
4
  from sdmxlib.model.message import StructureMessage
6
5
  from sdmxlib.model.urn import SdmxUrn
7
6
 
@@ -37,10 +36,15 @@ class Session:
37
36
  """
38
37
 
39
38
  def __init__(self) -> None:
40
- self._map: dict[str, Any] = {}
41
- self._latest: dict[str, Any] = {}
42
- self._resolved_map: dict[str, Any] = {}
43
- self._resolved_latest: dict[str, Any] = {}
39
+ # ``_map``/``_resolved_map`` only store + return-via-get (the unsafe
40
+ # object→T narrowing is localized to ``get``), so ``object`` suffices
41
+ # and admits the registry-cache path that writes an unbounded ``T``.
42
+ # ``_latest``/``_resolved_latest`` are compared on ``.version``, so
43
+ # they carry the checked `Maintainable` type.
44
+ self._map: dict[str, object] = {}
45
+ self._latest: dict[str, Maintainable] = {}
46
+ self._resolved_map: dict[str, object] = {}
47
+ self._resolved_latest: dict[str, Maintainable] = {}
44
48
  self._class_tomsg_field: dict[str, str] = {}
45
49
 
46
50
  def ingest(self, msg: StructureMessage, *, resolved: bool = False) -> None:
@@ -126,6 +126,7 @@ def serialize(
126
126
  *,
127
127
  version: str = "2.1",
128
128
  pretty_print: bool = False,
129
+ validate: bool = False,
129
130
  ) -> bytes:
130
131
  """Serialise a Structure or Metadata message to bytes.
131
132
 
@@ -136,12 +137,22 @@ def serialize(
136
137
  - ``MetadataMessage`` → ``<mes:GenericMetadata>`` envelope (XML) or
137
138
  metadata ``$schema`` (JSON).
138
139
 
140
+ Serialisation and validation are separate concerns: this function
141
+ serialises. Pass ``validate=True`` to run `validate_message` first and
142
+ refuse to emit a structurally invalid message, or call `validate_message`
143
+ yourself. The default skips it — messages built through the typed model or
144
+ read back by the parser are already well-formed, and re-validating on every
145
+ write is wasted work.
146
+
139
147
  Args:
140
148
  msg: The message to serialise.
141
149
  version: Wire format — ``"2.1"`` (default), ``"3.0"`` (SDMX-ML XML),
142
150
  or ``"json"`` (SDMX-JSON 2.0). MetadataMessage requires ``"3.0"``
143
151
  or ``"json"`` (SDMX-ML 2.1 GenericMetadata is out of scope).
144
152
  pretty_print: If True, indent the output.
153
+ validate: If True, `validate_message` runs first and raises
154
+ ``ValidationError`` (accumulated across all artefacts) before any
155
+ bytes are produced. Default False.
145
156
  """
146
157
  if isinstance(msg, MetadataMessage):
147
158
  if version == "json":
@@ -151,9 +162,9 @@ def serialize(
151
162
  err = f"MetadataMessage cannot be serialised as {version!r} (use '3.0' or 'json')"
152
163
  raise ValueError(err)
153
164
 
154
- # Refuse to emit structurally invalid ids on the wire. Validation is
155
- # accumulated across every artefact so all problems surface at once.
156
- validate_message(msg)
165
+ if validate:
166
+ # Accumulated across every artefact so all problems surface at once.
167
+ validate_message(msg)
157
168
 
158
169
  if version == "json":
159
170
  return _write_json(msg, pretty_print=pretty_print)
@@ -167,20 +178,29 @@ def serialize(
167
178
  raise ValueError(err)
168
179
 
169
180
 
170
- def to_xml(msg: "StructureMessage | MetadataMessage", *, version: str = "2.1", pretty_print: bool = False) -> bytes:
181
+ def to_xml(
182
+ msg: "StructureMessage | MetadataMessage",
183
+ *,
184
+ version: str = "2.1",
185
+ pretty_print: bool = False,
186
+ validate: bool = False,
187
+ ) -> bytes:
171
188
  """Serialise a Structure or Metadata message to SDMX-ML XML bytes.
172
189
 
173
190
  MetadataMessage requires ``version="3.0"`` (SDMX-ML 2.1 GenericMetadata
174
- is intentionally not supported).
191
+ is intentionally not supported). See `serialize` for ``validate``.
175
192
  """
176
193
  if isinstance(msg, MetadataMessage) and version != "3.0":
177
194
  version = "3.0"
178
- return serialize(msg, version=version, pretty_print=pretty_print)
195
+ return serialize(msg, version=version, pretty_print=pretty_print, validate=validate)
179
196
 
180
197
 
181
- def to_json(msg: "StructureMessage | MetadataMessage", *, pretty_print: bool = False) -> bytes:
182
- """Serialise a Structure or Metadata message to SDMX-JSON 2.0 bytes."""
183
- return serialize(msg, version="json", pretty_print=pretty_print)
198
+ def to_json(msg: "StructureMessage | MetadataMessage", *, pretty_print: bool = False, validate: bool = False) -> bytes:
199
+ """Serialise a Structure or Metadata message to SDMX-JSON 2.0 bytes.
200
+
201
+ See `serialize` for ``validate``.
202
+ """
203
+ return serialize(msg, version="json", pretty_print=pretty_print, validate=validate)
184
204
 
185
205
 
186
206
  def _detect_version(data: bytes) -> str:
@@ -16,7 +16,6 @@ import re
16
16
  from datetime import datetime, timedelta
17
17
  from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast
18
18
 
19
- import orjson
20
19
  import polars as pl
21
20
 
22
21
  if TYPE_CHECKING:
@@ -24,11 +23,13 @@ if TYPE_CHECKING:
24
23
 
25
24
  from sdmxlib.model.annotations import Annotations
26
25
  from sdmxlib.model.binding import DataflowBinding
27
- from sdmxlib.model.datastructure import DataStructure
26
+ from sdmxlib.model.datastructure import DataStructure, Dimension
28
27
  from sdmxlib.model.istring import InternationalString
29
28
 
30
29
  from sdmxlib.model.ref import Ref
31
30
 
31
+ from ._dto import ENCODER
32
+
32
33
  ComponentKind = Literal["dimension", "attribute"]
33
34
 
34
35
 
@@ -176,7 +177,7 @@ def _annotation_duration(annotations: Annotations, lang: str) -> _Duration | Non
176
177
  return None
177
178
 
178
179
 
179
- def _frequency_dimension(dsd: DataStructure) -> Any:
180
+ def _frequency_dimension(dsd: DataStructure) -> Dimension | None:
180
181
  """The DSD's frequency dimension, by well-known id then concept role."""
181
182
  for dim in dsd.dimensions:
182
183
  if dim.id in ("FREQ", "FREQUENCY"):
@@ -357,10 +358,14 @@ class ComponentTable:
357
358
  `_resolve_time_bounds` can pick each period's duration from the
358
359
  frequencies it actually co-occurs with.
359
360
  """
360
- if not self.tracks_freq() or self._time_dim_id not in dim_cols or self._freq_dim_id not in dim_cols:
361
+ # Bind to locals so the None-guard narrows `str | None` → `str` for the
362
+ # `dim_cols.index(...)` calls below (equivalent to `tracks_freq()`, but
363
+ # the method call can't narrow the attributes).
364
+ time_id, freq_id = self._time_dim_id, self._freq_dim_id
365
+ if time_id is None or freq_id is None or time_id not in dim_cols or freq_id not in dim_cols:
361
366
  return
362
- time_col = dim_phys[dim_cols.index(self._time_dim_id)]
363
- freq_col = dim_phys[dim_cols.index(self._freq_dim_id)]
367
+ time_col = dim_phys[dim_cols.index(time_id)]
368
+ freq_col = dim_phys[dim_cols.index(freq_id)]
364
369
  pairs = df.select(
365
370
  pl.col(time_col).cast(pl.String).fill_null("").alias("__t"),
366
371
  pl.col(freq_col).cast(pl.String).fill_null("").alias("__f"),
@@ -433,7 +438,8 @@ class ComponentTable:
433
438
  # Attribute schema (both 1.0 and 2.0) requires a relationship.
434
439
  # We currently only emit observation-level attributes, so the
435
440
  # relationship is the empty observation marker.
436
- entry["relationship"] = {"observation": {}}
441
+ observation_marker: dict[str, object] = {}
442
+ entry["relationship"] = {"observation": observation_marker}
437
443
  out.append(entry)
438
444
  return out
439
445
 
@@ -479,7 +485,7 @@ def encode_batch(
479
485
 
480
486
  Hot path: column-oriented Polars work for the things that vectorise
481
487
  (key string construction, attribute position lookups) and one
482
- orjson.dumps over the whole batch's ``{key: row}`` dict. The
488
+ msgspec encode over the whole batch's ``{key: row}`` dict. The
483
489
  surrounding ``{}`` braces are stripped because the caller is
484
490
  splicing this chunk into a streamed object whose braces it owns.
485
491
 
@@ -538,11 +544,13 @@ def encode_batch(
538
544
  row.append(None if raw is None else attr_int_tables[col_idx][str(raw)])
539
545
  rows.append(row)
540
546
 
541
- # ── Step 4: one orjson.dumps over the whole batch, strip braces ──
547
+ # ── Step 4: one msgspec encode over the whole batch, strip braces ──
542
548
  # ``{key1:[...], key2:[...], ...}`` with the leading ``{`` and
543
549
  # trailing ``}`` removed becomes a valid chunk to splice into the
544
- # caller's streamed observations map.
545
- payload = orjson.dumps(dict(zip(keys, rows, strict=True)), option=orjson.OPT_SERIALIZE_NUMPY)
550
+ # caller's streamed observations map. Polars ``.to_list()`` already
551
+ # yields Python-native scalars (int/float/str/None), so no numpy
552
+ # encode hook is needed (#179).
553
+ payload = ENCODER.encode(dict(zip(keys, rows, strict=True)))
546
554
  body = payload[1:-1] # strip outer { and }
547
555
  if first_obs or not body:
548
556
  return body
@@ -551,7 +559,7 @@ def encode_batch(
551
559
 
552
560
  def dataflow_link(binding: DataflowBinding) -> bytes:
553
561
  """Serialise the dataSet ``links[]`` entry pointing at the bound dataflow."""
554
- return orjson.dumps(
562
+ return ENCODER.encode(
555
563
  {
556
564
  "urn": str(binding.dataflow.urn),
557
565
  "rel": "dataflow",
@@ -686,7 +694,7 @@ def decode_observations(
686
694
  observations = dataset_block.get("observations") or {}
687
695
  rows: list[list[Any]] = []
688
696
  for key, value_array in observations.items():
689
- positions = key.split(":") if key else []
697
+ positions: list[str] = key.split(":") if key else []
690
698
  if len(positions) != len(obs_dim_ids):
691
699
  msg = (
692
700
  f"Observation key {key!r} has {len(positions)} positions but "
@@ -0,0 +1,208 @@
1
+ """Shared SDMX-JSON 2.0 wire Structs and helpers (msgspec).
2
+
3
+ Foundation of the msgspec migration (milestone: SDMX-JSON dict+orjson →
4
+ typed msgspec Structs). Holds the cross-cutting wire types every artefact DTO
5
+ reuses — annotations, links, the message ``meta`` envelope — plus the
6
+ ``InternationalString`` ↔ ``(name, names)`` convention, the annotation
7
+ convert helpers, and the shared encoder. The per-artefact DTOs (Codelist,
8
+ DataStructure, …) live in the writer/reader modules and build on these.
9
+
10
+ Conventions for wire Structs — read before adding one
11
+ -----------------------------------------------------
12
+
13
+ - **Inherit `WireStruct`.** It carries ``omit_defaults=True`` + ``gc=False``
14
+ for every DTO (add ``rename=_camel_rename`` in the subclass when the wire
15
+ wants camelCase). ``gc=False`` opts these transient, tree-shaped structs out
16
+ of the cyclic collector — a large encode speedup — and is safe only because
17
+ wire DTOs never form reference cycles (see `WireStruct`).
18
+
19
+ - **``omit_defaults=True``** (from `WireStruct`): a field whose value equals
20
+ its default is skipped on encode, matching SDMX-JSON's "omit optional when
21
+ absent".
22
+
23
+ - **A field that must ALWAYS appear on the wire is REQUIRED — no default.**
24
+ This is the subtle one. With ``omit_defaults`` a field equal to its default
25
+ is *dropped*; if you give an artefact's ``version`` a default of ``"1.0"`` it
26
+ silently vanishes whenever the value is ``"1.0"``. So ``id`` / ``agencyID`` /
27
+ ``version`` and any other always-present field must be declared without a
28
+ default (required). ``test_dto.py`` locks this with a guard test.
29
+
30
+ - **Unknown fields are ignored on decode** (msgspec's default) — forward-
31
+ compatible with producer extensions.
32
+
33
+ - **Wire names that differ from Python** use ``rename=`` (e.g. ``agencyID``).
34
+
35
+ - **An ``InternationalString`` splits into ``name: str`` (primary /
36
+ default-language) + ``names: dict[str, str]`` (full map)** per the SDMX-JSON
37
+ 2.0 contract. Use `emit_istr` / `ingest_istr` — never hand-roll the split.
38
+ Annotations use the same pattern under ``text`` / ``texts``.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import msgspec
44
+
45
+ from sdmxlib.model.annotations import Annotation as _ModelAnnotation
46
+ from sdmxlib.model.annotations import Annotations
47
+ from sdmxlib.model.istring import InternationalString
48
+
49
+ # Sentinel language used when a producer sends a singular ``name`` with no
50
+ # ``names`` map — we can't know the real language tag, so we mirror the
51
+ # dict-reader's behaviour and key it under ``_default``.
52
+ _DEFAULT_LANG = "_default"
53
+
54
+
55
+ # ── Wire-name convention ─────────────────────────────────────────────────────
56
+ # Struct fields are snake_case; `rename=_camel_rename` maps them to the SDMX-JSON
57
+ # camelCase wire names (msgspec's equivalent of pydantic's alias_generator).
58
+
59
+
60
+ def _camel_rename(name: str) -> str:
61
+ """snake_case field → SDMX-JSON wire name.
62
+
63
+ camelCase, except ``agency_id`` → ``agencyID`` (the SDMX-IM acronym).
64
+ Matches msgspec's built-in ``"camel"`` for every other field. msgspec calls
65
+ this once per field at class definition.
66
+ """
67
+ if name == "agency_id":
68
+ return "agencyID"
69
+ head, *tail = name.split("_")
70
+ return head + "".join(w[:1].upper() + w[1:] for w in tail)
71
+
72
+
73
+ # ── InternationalString ↔ (name, names) ─────────────────────────────────────
74
+
75
+
76
+ def emit_istr(v: InternationalString | None) -> tuple[str | None, dict[str, str] | None]:
77
+ """Split an ``InternationalString`` into ``(primary, full map)`` for a Struct.
78
+
79
+ Returns ``(None, None)`` for an empty/absent istring so ``omit_defaults``
80
+ drops both the singular and plural fields. Delegates to
81
+ `InternationalString.emit_pair`, which does the empty-check + best-match +
82
+ backing-map fetch in one call (the map is shared, not copied: it feeds a
83
+ transient DTO that msgspec encodes and we then discard, so it is never
84
+ mutated).
85
+ """
86
+ return v.emit_pair() if v is not None else (None, None)
87
+
88
+
89
+ def ingest_istr(name: str | None, names: dict[str, str] | None) -> dict[str, str]:
90
+ """Reconstruct a lang→text map from a wire ``(name, names)`` pair.
91
+
92
+ Prefers the full ``names`` map. Falls back to ``{_DEFAULT_LANG: name}`` when
93
+ only the singular ``name`` is present (a producer that omitted ``names``),
94
+ mirroring the dict-reader. Empty when neither is present. The caller builds
95
+ an ``InternationalString`` from the result.
96
+ """
97
+ if names:
98
+ return names
99
+ if name:
100
+ return {_DEFAULT_LANG: name}
101
+ return {}
102
+
103
+
104
+ # ── Wire Struct base ─────────────────────────────────────────────────────────
105
+
106
+
107
+ class WireStruct(msgspec.Struct, omit_defaults=True, gc=False):
108
+ """Base for every SDMX-JSON wire DTO.
109
+
110
+ Carries the two config knobs every DTO wants:
111
+
112
+ - ``omit_defaults=True`` — a field equal to its default is skipped on encode
113
+ (matches SDMX-JSON's "omit optional when absent"). Subclasses that also
114
+ need camelCase wire names add ``rename=_camel_rename``; it composes with
115
+ this base's config down the MRO.
116
+ - ``gc=False`` — opt these structs out of CPython's *cyclic* collector.
117
+ Reference counting still frees them promptly (they are transient: built
118
+ per-serialise, encoded, discarded), so nothing leaks; skipping cyclic-GC
119
+ tracking removes the dominant cost of building millions of them.
120
+
121
+ Invariant that makes ``gc=False`` safe: **wire DTOs are trees** — parent →
122
+ children only, built fresh from the (acyclic) domain model, never holding a
123
+ back-reference to an ancestor or sibling. A reference *cycle* would leak
124
+ under ``gc=False`` (refcounting alone can't break one). Do not introduce
125
+ one.
126
+ """
127
+
128
+
129
+ class Link(WireStruct):
130
+ """A SDMX-JSON ``links[*]`` entry. Annotation URLs ride here under ``rel=self``."""
131
+
132
+ href: str = ""
133
+ rel: str = ""
134
+
135
+
136
+ class Annotation(WireStruct):
137
+ """SDMX-JSON 2.0 ``annotations[*]``. ``text``/``texts`` carry the localised body."""
138
+
139
+ id: str | None = None
140
+ title: str | None = None
141
+ type: str | None = None
142
+ text: str | None = None
143
+ texts: dict[str, str] | None = None
144
+ #: The annotation URL lives in ``links`` under ``rel="self"``; a bare ``url``
145
+ #: is tolerated on decode for non-conformant producers.
146
+ url: str | None = None
147
+ links: list[Link] | None = None
148
+
149
+
150
+ class Sender(WireStruct):
151
+ """The ``meta.sender`` object."""
152
+
153
+ id: str = ""
154
+
155
+
156
+ class Meta(WireStruct):
157
+ """The SDMX-JSON ``meta`` envelope object."""
158
+
159
+ id: str = ""
160
+ prepared: str = ""
161
+ sender: Sender | None = None
162
+
163
+
164
+ # ── Annotation ↔ model ───────────────────────────────────────────────────────
165
+
166
+
167
+ def emit_annotations(annotations: Annotations | None) -> list[Annotation] | None:
168
+ """Model ``Annotations`` → wire ``list[Annotation]``, or ``None`` when empty."""
169
+ if not annotations:
170
+ return None
171
+ out: list[Annotation] = []
172
+ for a in annotations:
173
+ text, texts = emit_istr(a.text)
174
+ links = [Link(rel="self", href=a.url)] if a.url else None
175
+ out.append(Annotation(id=a.id, title=a.title, type=a.type, text=text, texts=texts, links=links))
176
+ return out
177
+
178
+
179
+ def _annotation_url(a: Annotation) -> str | None:
180
+ """The annotation URL from its ``links`` (``rel="self"``), or the bare ``url``."""
181
+ if a.links:
182
+ for link in a.links:
183
+ if link.rel == "self" and link.href:
184
+ return link.href
185
+ return a.url
186
+
187
+
188
+ def ingest_annotations(annotations: list[Annotation] | None) -> Annotations:
189
+ """Wire ``list[Annotation]`` → model ``Annotations``."""
190
+ if not annotations:
191
+ return Annotations()
192
+ return Annotations(
193
+ [
194
+ _ModelAnnotation(
195
+ id=a.id,
196
+ title=a.title,
197
+ type=a.type,
198
+ url=_annotation_url(a),
199
+ text=ingest_istr(a.text, a.texts),
200
+ )
201
+ for a in annotations
202
+ ]
203
+ )
204
+
205
+
206
+ # ── Shared encoder ───────────────────────────────────────────────────────────
207
+ # One reusable encoder; decode is per-type via ``msgspec.json.decode(data, type=T)``.
208
+ ENCODER = msgspec.json.Encoder()