sdmxlib 0.62.2__tar.gz → 0.64.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 (135) hide show
  1. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/PKG-INFO +1 -1
  2. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/pyproject.toml +2 -2
  3. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/pyproject.toml.orig +11 -2
  4. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/__init__.py +1 -0
  5. {sdmxlib-0.62.2/src/sdmxlib/storage → sdmxlib-0.64.0/src/sdmxlib}/_time.py +12 -1
  6. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/api/_freshness.py +1 -1
  7. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/api/client.py +1 -1
  8. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/api/federated.py +3 -3
  9. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/api/registry.py +3 -3
  10. sdmxlib-0.64.0/src/sdmxlib/formats/sdmx_csv/reader.py +321 -0
  11. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_csv/writer.py +35 -6
  12. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/_data_common.py +72 -60
  13. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/data_v1.py +2 -3
  14. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/data_v2.py +2 -3
  15. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/local/registry.py +9 -6
  16. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/__init__.py +1 -0
  17. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/_items.py +92 -2
  18. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/category.py +17 -0
  19. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/codelist.py +43 -4
  20. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/concept.py +8 -11
  21. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/dataset.py +23 -0
  22. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/datastructure.py +25 -0
  23. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/in_memory_registry.py +2 -2
  24. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/metadatastructure.py +17 -0
  25. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/organisation.py +50 -0
  26. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/ref.py +19 -0
  27. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/urn.py +5 -3
  28. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/validation.py +36 -0
  29. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/polars.py +65 -9
  30. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/_compile.py +24 -16
  31. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/_expr.py +85 -3
  32. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/query.py +1 -1
  33. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/session.py +37 -31
  34. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/storage/lazy.py +36 -5
  35. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/storage/readers.py +1 -1
  36. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/storage/resolve.py +50 -14
  37. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/storage/schema.py +1 -1
  38. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/storage/writers.py +3 -3
  39. sdmxlib-0.62.2/src/sdmxlib/formats/sdmx_csv/reader.py +0 -162
  40. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/README.md +0 -0
  41. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/_duckdb.py +0 -0
  42. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/_singleflight.py +0 -0
  43. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/api/__init__.py +0 -0
  44. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/api/filters.py +0 -0
  45. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/api/policy.py +0 -0
  46. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/api/providers.py +0 -0
  47. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/api/query.py +0 -0
  48. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/api/session.py +0 -0
  49. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/builders/__init__.py +0 -0
  50. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/builders/contentconstraint.py +0 -0
  51. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/builders/metadata_annotation.py +0 -0
  52. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/data_store.py +0 -0
  53. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/__init__.py +0 -0
  54. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/_coerce.py +0 -0
  55. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/_index.py +0 -0
  56. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/data_format.py +0 -0
  57. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/gaps.py +0 -0
  58. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  59. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  60. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/_dto.py +0 -0
  61. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/_resolve.py +0 -0
  62. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/_structure_dto.py +0 -0
  63. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/_structure_ingest.py +0 -0
  64. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  65. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  66. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/structure_values.py +0 -0
  67. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  68. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
  69. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -0
  70. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
  71. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml/_common.py +0 -0
  72. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml/_v3_reader.py +0 -0
  73. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml/_v3_writer.py +0 -0
  74. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  75. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml21/_synthetic.py +0 -0
  76. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  77. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  78. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  79. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  80. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  81. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  82. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  83. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  84. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
  85. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
  86. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml31/reader.py +0 -0
  87. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml31/writer.py +0 -0
  88. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/local/__init__.py +0 -0
  89. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/local/data_store.py +0 -0
  90. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/local/sql.py +0 -0
  91. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/_interning.py +0 -0
  92. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/_label_match.py +0 -0
  93. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/_refshape.py +0 -0
  94. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/annotations.py +0 -0
  95. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/base.py +0 -0
  96. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/binding.py +0 -0
  97. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/collections.py +0 -0
  98. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/constraint.py +0 -0
  99. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/convert.py +0 -0
  100. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/dataflow.py +0 -0
  101. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/errors.py +0 -0
  102. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/expr.py +0 -0
  103. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/hierarchy.py +0 -0
  104. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/istring.py +0 -0
  105. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/mapping.py +0 -0
  106. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/message.py +0 -0
  107. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/metadata_provision.py +0 -0
  108. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/metadataflow.py +0 -0
  109. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/metadataset.py +0 -0
  110. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/provision.py +0 -0
  111. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/registry.py +0 -0
  112. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/representation.py +0 -0
  113. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/rest_api.py +0 -0
  114. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/rollup.py +0 -0
  115. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/model/version.py +0 -0
  116. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/py.typed +0 -0
  117. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/__init__.py +0 -0
  118. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/_closure.py +0 -0
  119. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/_common.py +0 -0
  120. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/_edges.py +0 -0
  121. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/_execute.py +0 -0
  122. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/_joins.py +0 -0
  123. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/_plan.py +0 -0
  124. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/_pushdown.py +0 -0
  125. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/_rebase.py +0 -0
  126. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/_rest.py +0 -0
  127. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/accessors.py +0 -0
  128. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/observations.py +0 -0
  129. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/raw.py +0 -0
  130. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/query/schema.py +0 -0
  131. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/rest.py +0 -0
  132. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/sql.py +0 -0
  133. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/storage/__init__.py +0 -0
  134. {sdmxlib-0.62.2 → sdmxlib-0.64.0}/src/sdmxlib/storage/_kinds.py +0 -0
  135. {sdmxlib-0.62.2 → sdmxlib-0.64.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.62.2
3
+ Version: 0.64.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.62.2"
3
+ version = "0.64.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.62.2"
3
+ version = "0.64.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",
@@ -11,6 +11,7 @@ from sdmxlib.api.client import ResponseTooLargeError
11
11
  from sdmxlib.api.registry import BearerToken, FmrRegistry, RestRegistry
12
12
  from sdmxlib.data_store import DataStore, WriteSource
13
13
  from sdmxlib.model.datastructure import ComponentRole
14
+ from sdmxlib.model.ref import short_urn as short_urn
14
15
  from sdmxlib.model.ref import SupportsUrn
15
16
  from sdmxlib.model.rest_api import ApiVersion
16
17
  from sdmxlib.model.rollup import AncestorMap
@@ -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)
@@ -32,7 +32,7 @@ Example:
32
32
 
33
33
  import functools
34
34
  import inspect
35
- from typing import TYPE_CHECKING, Any, overload
35
+ from typing import TYPE_CHECKING, overload
36
36
 
37
37
  import httpx
38
38
 
@@ -124,7 +124,7 @@ class FederatedRegistry:
124
124
  @overload
125
125
  def get[T](
126
126
  self,
127
- target: "SdmxUrn[object] | Ref[Any]",
127
+ target: "SdmxUrn[object] | Ref[object]",
128
128
  /,
129
129
  *,
130
130
  expect: "type[T]",
@@ -144,7 +144,7 @@ class FederatedRegistry:
144
144
 
145
145
  def get[T](
146
146
  self,
147
- target: "SdmxUrn[object] | Ref[Any]",
147
+ target: "SdmxUrn[object] | Ref[object]",
148
148
  /,
149
149
  *,
150
150
  expect: "type[T] | None" = None,
@@ -4,7 +4,7 @@ import tempfile
4
4
  import threading
5
5
  from collections.abc import Callable, Sequence
6
6
  from pathlib import Path
7
- from typing import TYPE_CHECKING, Any, Self, Unpack, cast, overload
7
+ from typing import TYPE_CHECKING, Self, Unpack, cast, overload
8
8
 
9
9
  import attrs
10
10
  from attrs import frozen
@@ -779,7 +779,7 @@ class RestRegistry:
779
779
  @overload
780
780
  def get[T](
781
781
  self,
782
- target: "SdmxUrn[object] | Ref[Any]",
782
+ target: "SdmxUrn[object] | Ref[object]",
783
783
  /,
784
784
  *,
785
785
  expect: "type[T]",
@@ -799,7 +799,7 @@ class RestRegistry:
799
799
 
800
800
  def get[T](
801
801
  self,
802
- target: "SdmxUrn[object] | Ref[Any]",
802
+ target: "SdmxUrn[object] | Ref[object]",
803
803
  /,
804
804
  *,
805
805
  expect: "type[T] | None" = None,
@@ -0,0 +1,321 @@
1
+ """SDMX-CSV data reader.
2
+
3
+ Parses SDMX-CSV messages into `Dataset` objects.
4
+
5
+ SDMX-CSV format::
6
+
7
+ DATAFLOW,FREQ,REF_AREA,INDICATOR,TIME_PERIOD,OBS_VALUE,OBS_STATUS
8
+ ESTAT:STS_INPR_M(1.0),M,AT,INDPRO,2024-01,98.5,A
9
+
10
+ The ``DATAFLOW`` column identifies the dataflow but is not a DSD component.
11
+ It is extracted and then dropped; the remaining columns are parsed using the
12
+ DSD schema.
13
+ """
14
+
15
+ import csv
16
+ import io
17
+ import re
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Final
20
+
21
+ import polars as pl
22
+
23
+ import sdmxlib.polars as slpl
24
+ from sdmxlib.model.dataflow import Dataflow
25
+ from sdmxlib.model.dataset import Dataset
26
+ from sdmxlib.model.datastructure import DataStructure
27
+ from sdmxlib.model.provision import ProvisionAgreement
28
+ from sdmxlib.model.ref import Ref, short_urn
29
+ from sdmxlib.model.urn import SdmxUrn
30
+
31
+ if TYPE_CHECKING:
32
+ from sdmxlib.model.registry import SupportsGet
33
+ from sdmxlib.polars import CodedAs
34
+
35
+ _DATAFLOW_COL = "DATAFLOW"
36
+ _KEY_COL = "KEY"
37
+ # SDMX-CSV 2.0.0 envelope columns — not DSD components, dropped on read.
38
+ _STRUCTURE_COL = "STRUCTURE"
39
+ _STRUCTURE_ID_COL = "STRUCTURE_ID"
40
+ _ACTION_COL = "ACTION"
41
+ _DATAFLOW_RE = re.compile(r"^([^:]+):([^(]+)\(([^)]+)\)$")
42
+
43
+
44
+ def parse_dataflow_id(dataflow_id: str) -> tuple[str, str, str]:
45
+ """Parse an SDMX-CSV DATAFLOW value into ``(agency, id, version)``.
46
+
47
+ Example:
48
+ parse_dataflow_id("ESTAT:STS_INPR_M(1.0)") # ("ESTAT", "STS_INPR_M", "1.0")
49
+ """
50
+ m = _DATAFLOW_RE.match(dataflow_id)
51
+ if m is None:
52
+ msg = f"Cannot parse DATAFLOW value {dataflow_id!r} — expected 'AGENCY:ID(VERSION)'"
53
+ raise ValueError(msg)
54
+ return m.group(1), m.group(2), m.group(3)
55
+
56
+
57
+ #: The values SDMX-CSV 2.0's ``STRUCTURE`` column may carry.
58
+ #:
59
+ #: Verbatim from the field guide: "The first column contains: ``dataflow``,
60
+ #: ``datastructure`` or ``dataprovision``". Worth stating because two of our own
61
+ #: docstrings said ``provisionagreement`` — which is the RFC 5988 *link
62
+ #: relation* SDMX-JSON uses, a different vocabulary — and a caller following
63
+ #: them wrote a non-conformant file. sdmx1's CSV reader enumerates the same
64
+ #: three (#409).
65
+ STRUCTURE_KINDS: Final[tuple[str, ...]] = ("dataflow", "datastructure", "dataprovision")
66
+
67
+
68
+ def _peek_dataflow_id(source: "str | Path | bytes") -> str | None:
69
+ """Extract the DATAFLOW value from the first data row without reading the whole file."""
70
+ if isinstance(source, (str, Path)):
71
+ with Path(source).open(newline="", encoding="utf-8") as f:
72
+ head = f.read(4096)
73
+ else:
74
+ head = source[:4096].decode("utf-8", errors="replace")
75
+
76
+ reader = csv.reader(io.StringIO(head))
77
+ try:
78
+ header = next(reader)
79
+ except StopIteration:
80
+ return None
81
+ df_idx: int | None = None
82
+ for col in (_DATAFLOW_COL, _STRUCTURE_ID_COL):
83
+ if col in header:
84
+ df_idx = header.index(col)
85
+ break
86
+ if df_idx is None:
87
+ return None
88
+ try:
89
+ first_row = next(reader)
90
+ except StopIteration:
91
+ return None
92
+ return first_row[df_idx] if df_idx < len(first_row) else None
93
+
94
+
95
+ def _peek_structure(source: "str | Path | bytes") -> "tuple[str | None, str | None]":
96
+ """``(STRUCTURE, STRUCTURE_ID)`` from the first data row, without reading on.
97
+
98
+ The kind matters: an SDMX-CSV file may name a dataflow, a data structure or
99
+ a provision agreement, and resolving all three is the difference between
100
+ honouring the column and assuming what it says (#409). SDMX-CSV 1.0 has no
101
+ ``STRUCTURE`` column at all — its ``DATAFLOW`` implies the kind — so the
102
+ first element is None there and the caller treats it as a dataflow.
103
+ """
104
+ if isinstance(source, (str, Path)):
105
+ with Path(source).open(newline="", encoding="utf-8") as f:
106
+ head = f.read(4096)
107
+ else:
108
+ head = source[:4096].decode("utf-8", errors="replace")
109
+ reader = csv.reader(io.StringIO(head))
110
+ try:
111
+ header = next(reader)
112
+ first_row = next(reader)
113
+ except StopIteration:
114
+ return None, None
115
+
116
+ def value(column: str) -> str | None:
117
+ if column not in header:
118
+ return None
119
+ index = header.index(column)
120
+ return first_row[index] if index < len(first_row) else None
121
+
122
+ kind = value(_STRUCTURE_COL)
123
+ identifier = value(_STRUCTURE_ID_COL) or value(_DATAFLOW_COL)
124
+ return kind, identifier
125
+
126
+
127
+ def _dataflow_ref(dataflow_str: str) -> "Ref[Dataflow]":
128
+ """Build an unresolved Ref[Dataflow] from an SDMX-CSV DATAFLOW column value."""
129
+ agency, id_, version = parse_dataflow_id(dataflow_str)
130
+ urn = SdmxUrn.make(
131
+ Dataflow,
132
+ package="datastructure",
133
+ artefact_class="Dataflow",
134
+ agency=agency,
135
+ id=id_,
136
+ version=version,
137
+ )
138
+ return Ref[Dataflow].unresolved(urn)
139
+
140
+
141
+ def _resolve_structure(
142
+ source: "str | Path | bytes",
143
+ registry: "SupportsGet",
144
+ ) -> "tuple[DataStructure, Ref[Dataflow] | None]":
145
+ """The `DataStructure` this file names, looked up in *registry*.
146
+
147
+ Every SDMX-CSV 2.0 row names its own structure, and this reader has always
148
+ parsed that column and then thrown it away — `scan_csv` required the DSD as
149
+ a positional argument, so the file's answer was only available after you had
150
+ supplied it. #409 is that asymmetry.
151
+
152
+ All three kinds are honoured rather than assuming `dataflow`, which is what
153
+ "only `dataflow` appears to be considered" described. A provision agreement
154
+ resolves through its dataflow, which is the same second hop a dataflow makes
155
+ to its structure.
156
+
157
+ Raises:
158
+ ValueError: If the file names no structure (no ``STRUCTURE_ID`` and no
159
+ ``DATAFLOW`` column), if its ``STRUCTURE`` value is not one of
160
+ `STRUCTURE_KINDS`, if the registry cannot resolve what it names, or
161
+ if what it resolves to has no structure to read. Four distinct
162
+ messages, because a caller's next move differs for each.
163
+ TypeError: If the URN resolves to something that is not a dataflow,
164
+ provision agreement or data structure at all.
165
+ """
166
+ kind, identifier = _peek_structure(source)
167
+ if identifier is None:
168
+ msg = (
169
+ "this file names no structure: no STRUCTURE_ID column (SDMX-CSV 2.0) "
170
+ "and no DATAFLOW column (1.0). Pass structure=<DataStructure> instead"
171
+ )
172
+ raise ValueError(msg)
173
+ if kind is not None and kind not in STRUCTURE_KINDS:
174
+ msg = f"unknown STRUCTURE value {kind!r} — SDMX-CSV allows {', '.join(STRUCTURE_KINDS)}"
175
+ raise ValueError(msg)
176
+
177
+ agency, id_, version = parse_dataflow_id(identifier)
178
+ artefact_class = {"datastructure": DataStructure, "dataprovision": ProvisionAgreement}.get(
179
+ kind or "dataflow", Dataflow
180
+ )
181
+ urn = artefact_class.urn_for(agency=agency, id=id_, version=version)
182
+ found: object = registry.get(urn)
183
+ if found is None:
184
+ msg = f"{kind or 'dataflow'} {identifier} is not in this registry — add it, or pass structure=<DataStructure>"
185
+ raise ValueError(msg)
186
+
187
+ flow_ref: Ref[Dataflow] | None = None
188
+ if isinstance(found, ProvisionAgreement):
189
+ if found.dataflow is None:
190
+ msg = f"provision agreement {identifier} has no dataflow, so its structure cannot be reached"
191
+ raise ValueError(msg)
192
+ flow_ref = found.dataflow
193
+ found = found.dataflow()
194
+ if isinstance(found, Dataflow):
195
+ flow_ref = flow_ref or _dataflow_ref(identifier)
196
+ if found.structure is None:
197
+ msg = f"dataflow {identifier} has no structure — it cannot say how to read this file"
198
+ raise ValueError(msg)
199
+ found = found.structure()
200
+ if not isinstance(found, DataStructure):
201
+ # A `TypeError`, per ruff's TRY004 and correctly: the registry answered
202
+ # with the wrong *kind* of thing, which is a different fault from the
203
+ # file naming something absent.
204
+ msg = f"{identifier} resolved to {type(found).__name__}, which is not a DataStructure"
205
+ raise TypeError(msg)
206
+ return found, flow_ref
207
+
208
+
209
+ def scan_csv(
210
+ source: "str | Path | bytes",
211
+ structure: "DataStructure | Dataflow | None" = None,
212
+ *,
213
+ registry: "SupportsGet | None" = None,
214
+ infer_schema_length: "int | None" = 0,
215
+ dataflow: "Ref[Dataflow] | None" = None,
216
+ coded_as: "CodedAs" = "enum",
217
+ ) -> Dataset:
218
+ """Parse an SDMX-CSV message into a `Dataset`.
219
+
220
+ File paths are scanned lazily via ``pl.scan_csv``; bytes (e.g. from an
221
+ HTTP response) are read eagerly and wrapped in a ``LazyFrame``. Either
222
+ way the returned ``Dataset.data`` is always a ``pl.LazyFrame``.
223
+
224
+ Args:
225
+ source: File path or raw bytes from an SDMX-CSV response.
226
+ structure: Fully resolved `DataStructure`, or the `Dataflow` that names
227
+ one — the flow is unwrapped for you, as sdmx1's reader does.
228
+ Codelists should be resolved for ``pl.Enum`` typing.
229
+ Omit it and pass ``registry`` to use the file's own
230
+ ``STRUCTURE_ID`` instead (#409).
231
+ registry: Any `Registry` to resolve the file's ``STRUCTURE_ID``
232
+ against, when ``structure`` is omitted. All three
233
+ ``STRUCTURE`` kinds are honoured — ``dataflow``,
234
+ ``datastructure`` and ``dataprovision``.
235
+ infer_schema_length: Passed to Polars. ``0`` (default) means
236
+ schema is fully DSD-driven with no row sampling,
237
+ which is required for true streaming on paths.
238
+ dataflow: Optional resolved `Dataflow` ref.
239
+ When ``None`` and a ``DATAFLOW`` column is present, an
240
+ unresolved ref is built from the column value.
241
+ coded_as: How coded columns are typed — see `sdmxlib.polars.schema`.
242
+ ``"enum"`` (default) fails fast on a value outside its
243
+ codelist; ``"string"`` lets it through so
244
+ `Dataset.validate` can report every one (#408).
245
+
246
+ Returns:
247
+ A `Dataset` with a lazy ``data`` frame.
248
+
249
+ Example:
250
+ import sdmxlib.polars as slpl
251
+
252
+ ds = slpl.scan_csv("data.csv", dsd)
253
+ df = ds.collect(with_labels=True, lang="en")
254
+
255
+ # The file says which structure it needs; the registry has it:
256
+ ds = slpl.scan_csv("data.csv", registry=reg)
257
+
258
+ # Or hand over the flow and skip the `.structure()` unwrap:
259
+ ds = slpl.scan_csv("data.csv", message.dataflows["DF"])
260
+
261
+ # A report rather than a raise, for a file you do not trust yet:
262
+ report = slpl.scan_csv("suspect.csv", dsd, coded_as="string").validate()
263
+ """
264
+ resolved_ref: Ref[Dataflow] | None = None
265
+ if isinstance(structure, Dataflow):
266
+ # sdmx1's CSV reader takes either, and for the same reason: a caller
267
+ # holding the flow has already done the lookup, and making them add
268
+ # `.structure()` is the hand-written join #409 is about.
269
+ if structure.structure is None:
270
+ msg = f"dataflow {short_urn(structure)} has no structure — it cannot say how to read this file"
271
+ raise ValueError(msg)
272
+ resolved_ref = _dataflow_ref(short_urn(structure))
273
+ structure = structure.structure()
274
+ elif structure is None:
275
+ if registry is None:
276
+ msg = (
277
+ "scan_csv needs structure=<DataStructure | Dataflow>, or "
278
+ "registry=<Registry> to resolve the file's own STRUCTURE_ID"
279
+ )
280
+ raise ValueError(msg)
281
+ structure, resolved_ref = _resolve_structure(source, registry)
282
+
283
+ # Only a `dataflow` file gets a `Dataset.dataflow`. This used to build one
284
+ # from `STRUCTURE_ID` whatever the `STRUCTURE` column said, so a
285
+ # `datastructure` file came back claiming a dataflow whose id was really a
286
+ # DSD's. Reading the kind (#409) is what makes the distinction available.
287
+ kind, identifier = _peek_structure(source)
288
+ dataflow_ref = dataflow or resolved_ref
289
+ if dataflow_ref is None and identifier is not None and kind in (None, "dataflow"):
290
+ dataflow_ref = _dataflow_ref(identifier)
291
+
292
+ schema_overrides = slpl.schema(structure, coded_as=coded_as)
293
+
294
+ if isinstance(source, (str, Path)):
295
+ lf = pl.scan_csv(
296
+ source,
297
+ schema_overrides=schema_overrides,
298
+ infer_schema_length=infer_schema_length,
299
+ )
300
+ else:
301
+ lf = pl.read_csv(
302
+ io.BytesIO(source),
303
+ schema_overrides=schema_overrides,
304
+ infer_schema_length=infer_schema_length,
305
+ ).lazy()
306
+
307
+ # Drop metadata columns that are not DSD components. Covers both
308
+ # SDMX-CSV 1.0 (DATAFLOW, KEY) and 2.0 (STRUCTURE, STRUCTURE_ID, ACTION).
309
+ col_names = lf.collect_schema().names()
310
+ envelope_cols = (
311
+ _DATAFLOW_COL,
312
+ _KEY_COL,
313
+ _STRUCTURE_COL,
314
+ _STRUCTURE_ID_COL,
315
+ _ACTION_COL,
316
+ )
317
+ to_drop = [c for c in envelope_cols if c in col_names]
318
+ if to_drop:
319
+ lf = lf.drop(to_drop)
320
+
321
+ return Dataset(structure=structure, data=lf, dataflow=dataflow_ref)
@@ -47,6 +47,21 @@ from typing import TYPE_CHECKING, cast
47
47
 
48
48
  import polars as pl
49
49
 
50
+ from sdmxlib.formats.sdmx_csv.reader import STRUCTURE_KINDS
51
+ from sdmxlib.model.dataflow import Dataflow
52
+ from sdmxlib.model.datastructure import DataStructure
53
+ from sdmxlib.model.provision import ProvisionAgreement
54
+ from sdmxlib.model.ref import short_urn
55
+
56
+ #: Artefact class -> the ``STRUCTURE`` value SDMX-CSV gives it, for the form of
57
+ #: `write_sdmx_csv` that takes the artefact instead of a pre-formatted string.
58
+ _KIND_OF: dict[type, str] = {
59
+ Dataflow: "dataflow",
60
+ DataStructure: "datastructure",
61
+ ProvisionAgreement: "dataprovision",
62
+ }
63
+
64
+
50
65
  if TYPE_CHECKING:
51
66
  from collections.abc import Iterable, Iterator
52
67
 
@@ -59,7 +74,7 @@ def write_sdmx_csv(
59
74
  batches: Iterable[pa.RecordBatch],
60
75
  dsd: DataStructure,
61
76
  *,
62
- structure_id: str,
77
+ structure_id: str | Dataflow | DataStructure | ProvisionAgreement,
63
78
  structure: str = "dataflow",
64
79
  action: str = "I",
65
80
  ) -> Iterator[bytes]:
@@ -73,11 +88,18 @@ def write_sdmx_csv(
73
88
  Determines column order in the output: dimensions, then
74
89
  measures, then attributes, each in the order the structure
75
90
  declares them.
76
- structure_id: The ``AGENCY:ID(VERSION)`` identifier emitted in
77
- the ``STRUCTURE_ID`` column on every row.
78
- structure: Value emitted in the ``STRUCTURE`` column. SDMX-CSV
79
- allows ``"dataflow"``, ``"datastructure"``, or
80
- ``"provisionagreement"``. Defaults to ``"dataflow"``.
91
+ structure_id: The ``AGENCY:ID(VERSION)`` identifier emitted in the
92
+ ``STRUCTURE_ID`` column on every row — or the artefact itself, in
93
+ which case both this and ``structure`` are read off it. Passing the
94
+ artefact is the point of #409: every caller was formatting the short
95
+ URN by hand while `parse_dataflow_id` owned reading it.
96
+ structure: Value emitted in the ``STRUCTURE`` column. SDMX-CSV allows
97
+ ``"dataflow"``, ``"datastructure"`` or ``"dataprovision"``, and the
98
+ field guide's wording is exactly those three. This said
99
+ ``"provisionagreement"`` until #409 — that is SDMX-JSON's RFC 5988
100
+ link relation, a different vocabulary, and a caller who followed it
101
+ wrote a file no conformant reader accepts. Ignored when
102
+ ``structure_id`` is an artefact.
81
103
  action: Value emitted in the ``ACTION`` column. One of ``I``
82
104
  (Insert), ``R`` (Replace), ``A`` (Append), ``D`` (Delete).
83
105
  Defaults to ``"I"``.
@@ -89,7 +111,14 @@ def write_sdmx_csv(
89
111
 
90
112
  Raises:
91
113
  KeyError: If a batch is missing a component the structure declares.
114
+ ValueError: If ``structure`` is not one of SDMX-CSV's three kinds.
92
115
  """
116
+ if not isinstance(structure_id, str):
117
+ structure = _KIND_OF.get(type(structure_id), "dataflow")
118
+ structure_id = short_urn(structure_id)
119
+ if structure not in STRUCTURE_KINDS:
120
+ msg = f"STRUCTURE must be one of {', '.join(STRUCTURE_KINDS)}, not {structure!r}"
121
+ raise ValueError(msg)
93
122
  # Component ids, in SDMX order, from the structure. This read the
94
123
  # *binding's* physical column names until an observation query began
95
124
  # projecting by component id — after which it raised a `KeyError` naming