sdmxlib 0.63.0__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 (134) hide show
  1. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/PKG-INFO +1 -1
  2. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/pyproject.toml +2 -2
  3. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/pyproject.toml.orig +11 -2
  4. {sdmxlib-0.63.0/src/sdmxlib/storage → sdmxlib-0.64.0/src/sdmxlib}/_time.py +12 -1
  5. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/api/_freshness.py +1 -1
  6. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/api/client.py +1 -1
  7. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/_data_common.py +72 -60
  8. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/data_v1.py +2 -3
  9. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/data_v2.py +2 -3
  10. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/codelist.py +26 -4
  11. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/_compile.py +24 -16
  12. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/_expr.py +85 -3
  13. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/query.py +1 -1
  14. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/session.py +37 -31
  15. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/storage/lazy.py +13 -1
  16. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/storage/readers.py +1 -1
  17. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/storage/resolve.py +50 -14
  18. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/storage/schema.py +1 -1
  19. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/storage/writers.py +3 -3
  20. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/README.md +0 -0
  21. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/__init__.py +0 -0
  22. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/_duckdb.py +0 -0
  23. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/_singleflight.py +0 -0
  24. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/api/__init__.py +0 -0
  25. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/api/federated.py +0 -0
  26. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/api/filters.py +0 -0
  27. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/api/policy.py +0 -0
  28. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/api/providers.py +0 -0
  29. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/api/query.py +0 -0
  30. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/api/registry.py +0 -0
  31. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/api/session.py +0 -0
  32. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/builders/__init__.py +0 -0
  33. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/builders/contentconstraint.py +0 -0
  34. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/builders/metadata_annotation.py +0 -0
  35. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/data_store.py +0 -0
  36. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/__init__.py +0 -0
  37. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/_coerce.py +0 -0
  38. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/_index.py +0 -0
  39. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/data_format.py +0 -0
  40. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/gaps.py +0 -0
  41. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  42. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  43. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  44. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  45. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/_dto.py +0 -0
  46. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/_resolve.py +0 -0
  47. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/_structure_dto.py +0 -0
  48. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/_structure_ingest.py +0 -0
  49. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  50. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  51. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/structure_values.py +0 -0
  52. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  53. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
  54. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -0
  55. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
  56. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml/_common.py +0 -0
  57. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml/_v3_reader.py +0 -0
  58. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml/_v3_writer.py +0 -0
  59. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  60. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml21/_synthetic.py +0 -0
  61. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  62. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  63. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  64. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  65. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  66. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  67. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  68. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  69. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
  70. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
  71. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml31/reader.py +0 -0
  72. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/formats/sdmx_ml31/writer.py +0 -0
  73. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/local/__init__.py +0 -0
  74. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/local/data_store.py +0 -0
  75. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/local/registry.py +0 -0
  76. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/local/sql.py +0 -0
  77. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/__init__.py +0 -0
  78. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/_interning.py +0 -0
  79. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/_items.py +0 -0
  80. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/_label_match.py +0 -0
  81. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/_refshape.py +0 -0
  82. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/annotations.py +0 -0
  83. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/base.py +0 -0
  84. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/binding.py +0 -0
  85. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/category.py +0 -0
  86. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/collections.py +0 -0
  87. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/concept.py +0 -0
  88. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/constraint.py +0 -0
  89. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/convert.py +0 -0
  90. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/dataflow.py +0 -0
  91. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/dataset.py +0 -0
  92. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/datastructure.py +0 -0
  93. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/errors.py +0 -0
  94. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/expr.py +0 -0
  95. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/hierarchy.py +0 -0
  96. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/in_memory_registry.py +0 -0
  97. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/istring.py +0 -0
  98. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/mapping.py +0 -0
  99. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/message.py +0 -0
  100. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/metadata_provision.py +0 -0
  101. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/metadataflow.py +0 -0
  102. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/metadataset.py +0 -0
  103. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/metadatastructure.py +0 -0
  104. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/organisation.py +0 -0
  105. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/provision.py +0 -0
  106. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/ref.py +0 -0
  107. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/registry.py +0 -0
  108. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/representation.py +0 -0
  109. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/rest_api.py +0 -0
  110. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/rollup.py +0 -0
  111. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/urn.py +0 -0
  112. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/validation.py +0 -0
  113. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/model/version.py +0 -0
  114. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/polars.py +0 -0
  115. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/py.typed +0 -0
  116. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/__init__.py +0 -0
  117. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/_closure.py +0 -0
  118. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/_common.py +0 -0
  119. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/_edges.py +0 -0
  120. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/_execute.py +0 -0
  121. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/_joins.py +0 -0
  122. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/_plan.py +0 -0
  123. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/_pushdown.py +0 -0
  124. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/_rebase.py +0 -0
  125. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/_rest.py +0 -0
  126. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/accessors.py +0 -0
  127. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/observations.py +0 -0
  128. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/raw.py +0 -0
  129. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/query/schema.py +0 -0
  130. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/rest.py +0 -0
  131. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/sql.py +0 -0
  132. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/storage/__init__.py +0 -0
  133. {sdmxlib-0.63.0 → sdmxlib-0.64.0}/src/sdmxlib/storage/_kinds.py +0 -0
  134. {sdmxlib-0.63.0 → 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.63.0
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.63.0"
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.63.0"
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",
@@ -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 @@ 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]
@@ -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
 
@@ -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
 
@@ -375,24 +375,32 @@ def _operator(pred: _Leaf, reference: str, lang: tuple[object, ...] = ()) -> Sql
375
375
  # `In(d, [])` is False, normalised here rather than emitted as
376
376
  # `IN ()`, which is a syntax error in most dialects.
377
377
  return Sql("1 = 0")
378
- # One placeholder per value, and it stays that way (#301).
378
+ # The whole set as *one* list parameter (#411), not a placeholder
379
+ # per value as #301 left it. Binding is linear either way, because
380
+ # DuckDB's Python client converts each value, but the list is never
381
+ # slower and usually much faster — duckdb 1.5.5, 1,000 / 50,000
382
+ # values, `IN (?, …)` against `= ANY(?)`:
379
383
  #
380
- # This is linear in the set — ~48 us per value in DuckDB's Python
381
- # client (#271) — and the obvious fix is to bind the whole list as
382
- # *one* parameter, `reference = ANY(?)`, which some consumers write
383
- # by hand. Measured, it is **not** a fix: 2489 ms against 2549 ms
384
- # for 50,000 values, 1.0x at every size from 100 up. The cost is
385
- # DuckDB marshalling each Python value, not the placeholder count,
386
- # so collapsing the placeholders moves nothing.
384
+ # pandas absent 1.1x / 1.1x
385
+ # pandas stubbed 1.5x / 2.1x (`sys.modules["pandas"] = None`)
386
+ # pandas installed 2.6x / 9.3x
387
387
  #
388
- # What *is* flat is registering an Arrow buffer — 2.8 ms for those
389
- # same 50,000 — which is `Session.value_set`, explicit because
390
- # doing it implicitly would make `to_sql()` output reference a
391
- # relation that does not exist. #301 surveyed six consumers before
392
- # leaving it explicit: none of them calls `is_in` at all, and the
393
- # ones filtering by a set use a subquery, which is already free.
394
- marks = ", ".join("?" for _ in values)
395
- return Sql(f"{reference} IN ({marks})", lang + tuple(values))
388
+ # #301 measured 1.0x and kept the placeholders. That run had pandas
389
+ # absent, where DuckDB retries `import pandas` twice for every bound
390
+ # value to check for `pandas.NA` and never remembers the failure;
391
+ # the probe swamped both shapes, so they tied. Strip it out and the
392
+ # per-placeholder overhead shows.
393
+ #
394
+ # `ANY` over a list holding NULL has `IN`'s three-valued semantics,
395
+ # so no row changes — the differential properties are what say so.
396
+ #
397
+ # Still linear, though. What is flat is registering an Arrow buffer,
398
+ # which is `Session.value_set`, explicit because doing it implicitly
399
+ # would make `to_sql()` output reference a relation that does not
400
+ # exist. #301 surveyed six consumers before leaving it explicit:
401
+ # none of them calls `is_in` at all, and the ones filtering by a
402
+ # set use a subquery, which is already free.
403
+ return Sql(f"{reference} = ANY(?)", (*lang, list(values)))
396
404
  case _: # pragma: no cover - exhaustive over the leaf kinds
397
405
  msg = f"cannot compile {type(pred).__name__}"
398
406
  raise PathError(msg)
@@ -25,10 +25,12 @@ import builtins
25
25
  import copy
26
26
  import sys
27
27
  from collections.abc import Callable, Iterable, Mapping
28
+ from datetime import UTC, date, datetime
28
29
  from typing import NoReturn, Self, TypeIs, cast, override
29
30
 
30
31
  import attrs
31
32
 
33
+ from sdmxlib._time import to_storage
32
34
  from sdmxlib.model._label_match import Fold
33
35
  from sdmxlib.model.version import sort_key as version_sort_key
34
36
  from sdmxlib.query._common import Descending, canonical_key
@@ -1060,10 +1062,14 @@ def _check_literal(column: AnyCol, holds: str, value: object) -> None:
1060
1062
  ok = isinstance(value, int | float) and not isinstance(value, bool)
1061
1063
  if ok:
1062
1064
  return
1065
+ raise _literal_type_error(column, holds, value)
1066
+
1067
+
1068
+ def _literal_type_error(column: AnyCol, holds: str, value: object) -> TypeError:
1069
+ """The refusal `_check_literal` raises, for a column checking its own way."""
1063
1070
  field = ".".join((*column.path, column.column)) if column.path else column.column
1064
1071
  suggestion = f' Did you mean "{value}"?' if holds == "str" else ""
1065
- msg = f"{field} holds {holds}, got {type(value).__name__}.{suggestion}"
1066
- raise TypeError(msg)
1072
+ return TypeError(f"{field} holds {holds}, got {type(value).__name__}.{suggestion}")
1067
1073
 
1068
1074
 
1069
1075
  class Str[R: RowKind](Col[R]):
@@ -1850,7 +1856,7 @@ class Temporal[R: RowKind](Num[R]):
1850
1856
  The store's convention is **UTC-naive** — DuckDB's `TIMESTAMP` carries no
1851
1857
  zone, and handing it a tz-aware value converts to the *session's* local
1852
1858
  time, so the same instant read on two machines is two instants (#321,
1853
- `storage/_time.py`). The round-trip that module documents is therefore
1859
+ `sdmxlib/_time.py`). The round-trip that module documents is therefore
1854
1860
  `naive in -> aware out`: the wall time is unchanged, it is simply labelled.
1855
1861
 
1856
1862
  `readers` already honour that, so `reg.get(urn).valid_from` is tz-aware.
@@ -1863,10 +1869,86 @@ class Temporal[R: RowKind](Num[R]):
1863
1869
  A column class rather than `Num`, which is what these were declared as at
1864
1870
  first: `Num` compares and orders correctly but carries no way to tell the
1865
1871
  frame builder that a value is an instant, and that is the whole difference.
1872
+
1873
+ **Literals go in the way the writers put values in** (#415). Every one is
1874
+ normalised with `sdmxlib._time.to_storage` — an aware `datetime` converted
1875
+ to UTC and its zone dropped, a naive one taken as UTC already, a `date` as
1876
+ midnight UTC — before it reaches DuckDB. Without that, DuckDB converted an
1877
+ aware literal to the *session's* zone first: `is_in([aware])` matched
1878
+ nothing and `< aware` compared against an instant shifted by the local
1879
+ offset, so one query gave different rows on different machines. It went
1880
+ unseen because on a UTC session the shift is zero.
1881
+
1882
+ And the literal check is its own. `Num`'s demands an `int`, so `==`
1883
+ refused a `datetime` while `<` — which `Num` does not check — took one, and
1884
+ took an `int` just as readily.
1866
1885
  """
1867
1886
 
1868
1887
  __slots__ = ()
1869
1888
 
1889
+ def _literal(self, value: object) -> datetime:
1890
+ """*value* as the store holds it, or a `TypeError` naming the column.
1891
+
1892
+ Raises:
1893
+ TypeError: On anything but a `datetime` or `date` — `None`
1894
+ included, since `= NULL` and `< NULL` are never true.
1895
+ """
1896
+ if value is None:
1897
+ msg = (
1898
+ f"comparing {self.column} to None matches nothing — a comparison with NULL "
1899
+ "is never true in SQL. Use is_null(), or is_not_null() for the complement."
1900
+ )
1901
+ raise TypeError(msg)
1902
+ if isinstance(value, datetime):
1903
+ return to_storage(value)
1904
+ # After `datetime`, which is a `date` too.
1905
+ if isinstance(value, date):
1906
+ return to_storage(datetime(value.year, value.month, value.day, tzinfo=UTC))
1907
+ raise _literal_type_error(self, "datetime", value)
1908
+
1909
+ @override
1910
+ def __eq__(self, other: object) -> Pred[R]:
1911
+ # To `Col` directly, past `Num`, whose integer check is the defect.
1912
+ return Col.__eq__(self, self._literal(other))
1913
+
1914
+ @override
1915
+ def __ne__(self, other: object) -> Pred[R]:
1916
+ return Col.__ne__(self, self._literal(other))
1917
+
1918
+ @override
1919
+ def __lt__(self, other: object) -> Pred[R]:
1920
+ return Compare(self, "<", self._literal(other))
1921
+
1922
+ @override
1923
+ def __le__(self, other: object) -> Pred[R]:
1924
+ return Compare(self, "<=", self._literal(other))
1925
+
1926
+ @override
1927
+ def __gt__(self, other: object) -> Pred[R]:
1928
+ return Compare(self, ">", self._literal(other))
1929
+
1930
+ @override
1931
+ def __ge__(self, other: object) -> Pred[R]:
1932
+ return Compare(self, ">=", self._literal(other))
1933
+
1934
+ @override
1935
+ def __hash__(self) -> int:
1936
+ return id(self)
1937
+
1938
+ @override
1939
+ def is_in(self, values: Iterable[object] | SqlRelation) -> Pred[R]:
1940
+ """As `Col.is_in`, with every member checked and normalised.
1941
+
1942
+ Every member, not just the first as `Str.is_in` does: a mixed set is
1943
+ unlikely, but an aware member left un-normalised is a silent miss, so
1944
+ each one is converted anyway and the check comes free.
1945
+ """
1946
+ if _is_relation(values) or _is_opaque(values) or not _is_iterable(values):
1947
+ return super().is_in(values)
1948
+ # `None` stays a member, as `Str.is_in` lets it: NULL in an `IN` set
1949
+ # is inert rather than a mistake worth refusing.
1950
+ return super().is_in([None if member is None else self._literal(member) for member in values])
1951
+
1870
1952
 
1871
1953
  class AnyWinPred:
1872
1954
  """Non-generic base for windowed predicates, as `AnyPred` is for plain ones.
@@ -224,7 +224,7 @@ def _as_utc(frame: pl.DataFrame, items: Sequence[tuple[str, AnyCol | AnyAgg]]) -
224
224
  compare nor subtract.
225
225
 
226
226
  `replace_time_zone`, not `convert_time_zone`: the wall time is already
227
- UTC and must not move. That is the third row of `storage/_time.py`'s
227
+ UTC and must not move. That is the third row of `sdmxlib/_time.py`'s
228
228
  round-trip table — *same wall time, now labelled*.
229
229
  """
230
230
  temporal = [name for name, item in items if isinstance(item, Temporal)]