sdmxlib 0.41.0__tar.gz → 0.41.2__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 (91) hide show
  1. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/PKG-INFO +1 -1
  2. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/pyproject.toml +1 -1
  3. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/_data_common.py +231 -19
  4. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/data_v1.py +11 -1
  5. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/data_v2.py +11 -1
  6. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/README.md +0 -0
  7. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/__init__.py +0 -0
  8. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/_duckdb.py +0 -0
  9. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/__init__.py +0 -0
  10. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/client.py +0 -0
  11. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/federated.py +0 -0
  12. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/filters.py +0 -0
  13. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/policy.py +0 -0
  14. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/providers.py +0 -0
  15. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/query.py +0 -0
  16. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/registry.py +0 -0
  17. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/session.py +0 -0
  18. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/builders/__init__.py +0 -0
  19. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/builders/contentconstraint.py +0 -0
  20. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/catalog.py +0 -0
  21. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/data_store.py +0 -0
  22. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/__init__.py +0 -0
  23. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  24. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  25. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  26. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  27. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  28. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  29. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/structure_values.py +0 -0
  30. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  31. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
  32. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -0
  33. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
  34. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml/_common.py +0 -0
  35. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  36. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  37. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  38. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  39. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  40. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  41. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  42. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  43. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  44. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
  45. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
  46. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml31/reader.py +0 -0
  47. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml31/writer.py +0 -0
  48. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/local/__init__.py +0 -0
  49. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/local/data_store.py +0 -0
  50. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/local/registry.py +0 -0
  51. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/__init__.py +0 -0
  52. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/annotations.py +0 -0
  53. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/base.py +0 -0
  54. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/binding.py +0 -0
  55. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/category.py +0 -0
  56. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/code_query.py +0 -0
  57. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/codelist.py +0 -0
  58. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/collections.py +0 -0
  59. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/concept.py +0 -0
  60. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/constraint.py +0 -0
  61. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/convert.py +0 -0
  62. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/dataflow.py +0 -0
  63. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/dataset.py +0 -0
  64. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/datastructure.py +0 -0
  65. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/expr.py +0 -0
  66. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/hierarchy.py +0 -0
  67. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/hierarchy_query.py +0 -0
  68. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/in_memory_registry.py +0 -0
  69. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/istring.py +0 -0
  70. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/mapping.py +0 -0
  71. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/message.py +0 -0
  72. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/metadataflow.py +0 -0
  73. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/metadataset.py +0 -0
  74. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/metadatastructure.py +0 -0
  75. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/organisation.py +0 -0
  76. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/provision.py +0 -0
  77. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/ref.py +0 -0
  78. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/registry.py +0 -0
  79. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/representation.py +0 -0
  80. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/urn.py +0 -0
  81. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/validation.py +0 -0
  82. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/polars.py +0 -0
  83. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/py.typed +0 -0
  84. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/rest.py +0 -0
  85. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/sql.py +0 -0
  86. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/__init__.py +0 -0
  87. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/_kinds.py +0 -0
  88. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/lazy.py +0 -0
  89. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/readers.py +0 -0
  90. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/schema.py +0 -0
  91. {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/writers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.41.0
3
+ Version: 0.41.2
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.41.0"
3
+ version = "0.41.2"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -13,7 +13,8 @@ from __future__ import annotations
13
13
 
14
14
  import calendar
15
15
  import re
16
- from typing import TYPE_CHECKING, Any, Literal, cast
16
+ from datetime import datetime, timedelta
17
+ from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast
17
18
 
18
19
  import orjson
19
20
  import polars as pl
@@ -21,8 +22,10 @@ import polars as pl
21
22
  if TYPE_CHECKING:
22
23
  import pyarrow as pa
23
24
 
25
+ from sdmxlib.model.annotations import Annotations
24
26
  from sdmxlib.model.binding import DataflowBinding
25
27
  from sdmxlib.model.datastructure import DataStructure
28
+ from sdmxlib.model.istring import InternationalString
26
29
 
27
30
  from sdmxlib.model.ref import Ref
28
31
 
@@ -96,6 +99,142 @@ def _period_bounds(period_id: str) -> tuple[str, str] | None:
96
99
  return None
97
100
 
98
101
 
102
+ # ── FREQ-driven multi-period bounds (issue #149) ──────────────────────────────
103
+ #
104
+ # A bare TIME_PERIOD id like ``"2021"`` is only a *start anchor*; the period's
105
+ # length comes from the FREQ dimension's code (``A`` = 1 year, ``A5`` = 5 years,
106
+ # ``Q`` = 1 quarter, ...). ``_period_bounds`` alone treats ``"2021"`` as a single
107
+ # calendar year, which under-reports coverage for multi-year frequencies. The
108
+ # writer resolves each FREQ code to a duration — an explicit ``duration``
109
+ # annotation on the code overrides the standard/``A{n}`` convention — and uses it
110
+ # to extend the end of the period. We only ever *extend* past the id-shape bound,
111
+ # never shrink below it, so standard single-period frequencies and inconsistent
112
+ # data stay byte-identical to the id-shape default.
113
+
114
+
115
+ class _Duration(NamedTuple):
116
+ """A calendar duration decomposed into addable parts (weeks fold into days)."""
117
+
118
+ years: int = 0
119
+ months: int = 0
120
+ days: int = 0
121
+ hours: int = 0
122
+
123
+
124
+ # Implied duration of the standard single-letter SDMX frequency codes.
125
+ _FREQ_UNIT: dict[str, _Duration] = {
126
+ "A": _Duration(years=1),
127
+ "Y": _Duration(years=1),
128
+ "S": _Duration(months=6),
129
+ "Q": _Duration(months=3),
130
+ "M": _Duration(months=1),
131
+ "W": _Duration(days=7),
132
+ "D": _Duration(days=1),
133
+ "H": _Duration(hours=1),
134
+ }
135
+ # ``A5`` / ``A10`` / ``S2`` / ... — a standard unit times a positive multiple.
136
+ _FREQ_MULTIPLE = re.compile(r"^([AYSQMWDH])(\d+)$")
137
+ # ISO-8601 duration subset we accept on a ``duration`` annotation.
138
+ _ISO_DURATION = re.compile(r"^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?)?$")
139
+
140
+
141
+ def _parse_iso_duration(text: str) -> _Duration | None:
142
+ """Parse the ISO-8601 duration subset (``P5Y``, ``P1M``, ``PT1H``, ...)."""
143
+ m = _ISO_DURATION.match(text.strip())
144
+ if not m or not any(m.groups()):
145
+ return None
146
+ y, mo, w, d, h = (int(g) if g else 0 for g in m.groups())
147
+ return _Duration(years=y, months=mo, days=w * 7 + d, hours=h)
148
+
149
+
150
+ def _convention_duration(freq_id: str) -> _Duration | None:
151
+ """Duration implied by a FREQ code id via the standard/``A{n}`` convention."""
152
+ unit = _FREQ_UNIT.get(freq_id)
153
+ if unit is not None:
154
+ return unit
155
+ m = _FREQ_MULTIPLE.match(freq_id)
156
+ if m is None:
157
+ return None
158
+ base = _FREQ_UNIT[m.group(1)]
159
+ n = int(m.group(2))
160
+ return _Duration(years=base.years * n, months=base.months * n, days=base.days * n, hours=base.hours * n)
161
+
162
+
163
+ def _first_text(istr: InternationalString) -> str | None:
164
+ """First localized value of an InternationalString, or None if empty."""
165
+ first = next(iter(istr.items()), None)
166
+ return first[1] if first is not None else None
167
+
168
+
169
+ def _annotation_duration(annotations: Annotations, lang: str) -> _Duration | None:
170
+ """Duration from a ``duration`` annotation (case-insensitive type), if any."""
171
+ for ann in annotations:
172
+ if ann.type and ann.type.strip().lower() == "duration":
173
+ text = ann.text.get(lang) or _first_text(ann.text)
174
+ if text:
175
+ return _parse_iso_duration(text)
176
+ return None
177
+
178
+
179
+ def _frequency_dimension(dsd: DataStructure) -> Any:
180
+ """The DSD's frequency dimension, by well-known id then concept role."""
181
+ for dim in dsd.dimensions:
182
+ if dim.id in ("FREQ", "FREQUENCY"):
183
+ return dim
184
+ for dim in dsd.dimensions:
185
+ roles = getattr(dim, "concept_role", ()) or ()
186
+ if any(str(r).upper() in ("FREQ", "FREQUENCY") for r in roles):
187
+ return dim
188
+ return None
189
+
190
+
191
+ def frequency_durations(dsd: DataStructure, lang: str) -> tuple[str | None, dict[str, _Duration]]:
192
+ """Find the frequency dimension and its codes' annotation-derived durations.
193
+
194
+ Returns ``(freq_dim_id, overrides)``. ``overrides`` only carries codes that
195
+ declare an explicit ``duration`` annotation — the standard/``A{n}``
196
+ convention is applied lazily at lookup time, so codes outside the codelist
197
+ (or an unresolved codelist) still resolve by convention.
198
+ """
199
+ freq = _frequency_dimension(dsd)
200
+ if freq is None:
201
+ return None, {}
202
+ overrides: dict[str, _Duration] = {}
203
+ codelist = getattr(freq, "resolved_codelist", None)
204
+ if codelist is not None:
205
+ for code in codelist.codes:
206
+ duration = _annotation_duration(code.annotations, lang)
207
+ if duration is not None:
208
+ overrides[code.id] = duration
209
+ return freq.id, overrides
210
+
211
+
212
+ def _add_duration(start: datetime, duration: _Duration) -> datetime:
213
+ """Add a `_Duration` to *start*, clamping the day to the target month."""
214
+ total_months = start.year * 12 + (start.month - 1) + duration.years * 12 + duration.months
215
+ year, month0 = divmod(total_months, 12)
216
+ month = month0 + 1
217
+ day = min(start.day, calendar.monthrange(year, month)[1])
218
+ shifted = start.replace(year=year, month=month, day=day)
219
+ return shifted + timedelta(days=duration.days, hours=duration.hours)
220
+
221
+
222
+ def _bounds_from_anchor(period_id: str, duration: _Duration | None) -> tuple[str, str] | None:
223
+ """``(start, end)`` for *period_id* extended by *duration*.
224
+
225
+ ``start`` is the id's anchor (reusing `_period_bounds`); ``end`` is
226
+ ``start + duration - 1s``. Returns the plain id-shape bounds when *duration*
227
+ is None, and None when the id shape itself is unrecognised (e.g. the
228
+ ``<start>/<duration>`` form the consumer parses itself).
229
+ """
230
+ base = _period_bounds(period_id)
231
+ if base is None or duration is None:
232
+ return base
233
+ start_dt = datetime.fromisoformat(base[0])
234
+ end_dt = _add_duration(start_dt, duration) - timedelta(seconds=1)
235
+ return base[0], end_dt.strftime("%Y-%m-%dT%H:%M:%S")
236
+
237
+
99
238
  class ComponentTable:
100
239
  """Lazy id → position table for one component kind (dim or attr).
101
240
 
@@ -119,11 +258,20 @@ class ComponentTable:
119
258
  kind: ComponentKind,
120
259
  lang: str,
121
260
  time_dim_id: str | None = None,
261
+ freq_dim_id: str | None = None,
262
+ freq_overrides: dict[str, _Duration] | None = None,
122
263
  ) -> None:
123
264
  self._ids = component_ids
124
265
  self._lang = lang
125
266
  self._kind = kind
126
267
  self._time_dim_id = time_dim_id
268
+ # FREQ-driven period bounds (issue #149): the frequency dimension id,
269
+ # its codes' annotation-derived duration overrides, and the
270
+ # accumulated TIME_PERIOD id → set of co-occurring FREQ ids seen while
271
+ # streaming. Bounds are resolved at to_json() time from this.
272
+ self._freq_dim_id = freq_dim_id
273
+ self._freq_overrides = freq_overrides or {}
274
+ self._time_freqs: dict[str, set[str]] = {}
127
275
  # ``dsd.dimensions`` is ItemList[Dimension] and does NOT include
128
276
  # the time dimension (which lives at ``dsd.time_dimension``).
129
277
  # Use ``key_dimensions`` so TIME_PERIOD's concept-derived name
@@ -146,11 +294,14 @@ class ComponentTable:
146
294
  # so position N maps to the Nth-inserted entry. ``to_json``
147
295
  # walks this directly.
148
296
  self._codes: dict[str, dict[str, dict[str, object]]] = {cid: {} for cid in component_ids}
149
- # static label cache (codelist code id localized name). Read
150
- # at code-registration time to fill the ``name`` field; never
151
- # iterated for emission, so unobserved codes stay out of the
152
- # serialised values array.
153
- self._code_labels: dict[str, dict[str, str]] = {cid: {} for cid in component_ids}
297
+ # Per-component resolved codelists, stored by reference. Labels are
298
+ # resolved on-demand in ``position()`` via ``ItemList.get`` (O(1));
299
+ # we deliberately do NOT walk the full codelist here. For census-scale
300
+ # codelists (CL_GEOGRAPHY: 579k codes) the eager walk dominated wall
301
+ # time on small data responses a single-row /data query spent
302
+ # multiple seconds populating a label cache it would never read more
303
+ # than once from.
304
+ self._codelists: dict[str, Any] = {}
154
305
 
155
306
  for cid in component_ids:
156
307
  comp = components.get(cid)
@@ -164,12 +315,10 @@ class ComponentTable:
164
315
  self._names[cid] = name
165
316
  # TimeDimension carries a Facet representation, not a codelist
166
317
  # ref, so it has no ``resolved_codelist`` property — skip the
167
- # label-cache step for components without one.
318
+ # codelist registration for components without one.
168
319
  cl = getattr(comp, "resolved_codelist", None)
169
- if cl is None:
170
- continue
171
- for code in cl.codes:
172
- self._code_labels[cid][code.id] = code.name.get(lang) or code.id
320
+ if cl is not None:
321
+ self._codelists[cid] = cl
173
322
 
174
323
  def position(self, component_id: str, code_id: str) -> int:
175
324
  """Look up ``code_id`` under ``component_id``, assigning on first sight."""
@@ -181,17 +330,71 @@ class ComponentTable:
181
330
  table[code_id] = pos
182
331
  self._position_str[component_id][code_id] = str(pos)
183
332
  # Both SDMX-JSON 1.0 and 2.0 schemas require every value entry
184
- # to carry id + name. Pull the localized name from the codelist
185
- # label cache if known, otherwise fall back to the id.
186
- label = self._code_labels[component_id].get(code_id, code_id)
333
+ # to carry id + name. Look the localized name up on-demand from
334
+ # the resolved codelist's id-indexed ItemList (O(1)); fall back
335
+ # to the id when no codelist is registered or the code is
336
+ # unknown to it (e.g. observed values outside the enumerated set).
337
+ label = code_id
338
+ cl = self._codelists.get(component_id)
339
+ if cl is not None:
340
+ code = cl.codes.get(code_id)
341
+ if code is not None:
342
+ label = code.name.get(self._lang) or code_id
187
343
  entry: dict[str, object] = {"id": code_id, "name": label}
188
- if component_id == self._time_dim_id:
189
- bounds = _period_bounds(code_id)
190
- if bounds is not None:
191
- entry["start"], entry["end"] = bounds
344
+ # TIME_PERIOD start/end is resolved in to_json() once the full set of
345
+ # co-occurring FREQ values is known (see _resolve_time_bounds).
192
346
  self._codes[component_id][code_id] = entry
193
347
  return pos
194
348
 
349
+ def tracks_freq(self) -> bool:
350
+ """True if both a time dimension and a frequency dimension are known."""
351
+ return self._time_dim_id is not None and self._freq_dim_id is not None
352
+
353
+ def observe_batch_time_freq(self, df: pl.DataFrame, dim_cols: list[str], dim_phys: list[str]) -> None:
354
+ """Record the distinct ``(TIME_PERIOD, FREQ)`` pairs in *df*.
355
+
356
+ Cheap per-batch vectorised ``unique()`` — accumulated across batches so
357
+ `_resolve_time_bounds` can pick each period's duration from the
358
+ frequencies it actually co-occurs with.
359
+ """
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
+ 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)]
364
+ pairs = df.select(
365
+ pl.col(time_col).cast(pl.String).fill_null("").alias("__t"),
366
+ pl.col(freq_col).cast(pl.String).fill_null("").alias("__f"),
367
+ ).unique()
368
+ times = pairs.get_column("__t").to_list()
369
+ freqs = pairs.get_column("__f").to_list()
370
+ for time_id, freq_id in zip(times, freqs, strict=True):
371
+ self._time_freqs.setdefault(time_id, set()).add(freq_id)
372
+
373
+ def _duration_for_freq(self, freq_id: str) -> _Duration | None:
374
+ """Duration for a FREQ code: annotation override else convention."""
375
+ return self._freq_overrides.get(freq_id) or _convention_duration(freq_id)
376
+
377
+ def _resolve_time_bounds(self, code_id: str) -> tuple[str, str] | None:
378
+ """Period bounds for a TIME_PERIOD id, extended by FREQ where unambiguous.
379
+
380
+ Only extends past the plain id-shape bound — never shrinks below it — so
381
+ standard single-period frequencies and contradictory data stay identical
382
+ to the id-shape default. A period that co-occurs with conflicting
383
+ durations (mixed FREQ) falls back to the id-shape default.
384
+ """
385
+ base = _period_bounds(code_id)
386
+ freqs = self._time_freqs.get(code_id)
387
+ if base is None or not freqs:
388
+ return base
389
+ durations = {self._duration_for_freq(f) for f in freqs}
390
+ durations.discard(None)
391
+ if len(durations) != 1:
392
+ return base
393
+ extended = _bounds_from_anchor(code_id, next(iter(durations)))
394
+ if extended is None:
395
+ return base
396
+ return extended if extended[1] > base[1] else base
397
+
195
398
  def register_many(self, component_id: str, code_ids: list[str]) -> dict[str, str]:
196
399
  """Ensure every *code_id* has a position; return a focused str-map.
197
400
 
@@ -213,10 +416,16 @@ class ComponentTable:
213
416
  """Render the per-position metadata array for the structures block."""
214
417
  out: list[dict[str, object]] = []
215
418
  for key_pos, cid in enumerate(self._ids):
419
+ code_entries = self._codes[cid]
420
+ if cid == self._time_dim_id:
421
+ for code_id, value_entry in code_entries.items():
422
+ bounds = self._resolve_time_bounds(code_id)
423
+ if bounds is not None:
424
+ value_entry["start"], value_entry["end"] = bounds
216
425
  entry: dict[str, object] = {
217
426
  "id": cid,
218
427
  "name": self._names.get(cid, cid),
219
- "values": list(self._codes[cid].values()),
428
+ "values": list(code_entries.values()),
220
429
  }
221
430
  if self._kind == "dimension":
222
431
  entry["keyPosition"] = key_pos
@@ -298,6 +507,9 @@ def encode_batch(
298
507
  uniq = df.get_column(dim_phys[col_idx]).cast(pl.String).fill_null("").unique(maintain_order=True).to_list()
299
508
  dim_str_tables.append(dim_lookup.register_many(cid, uniq))
300
509
 
510
+ # Accumulate (TIME_PERIOD, FREQ) co-occurrence for duration-aware bounds.
511
+ dim_lookup.observe_batch_time_freq(df, dim_cols, dim_phys)
512
+
301
513
  attr_int_tables: list[dict[str, int]] = []
302
514
  for col_idx, cid in enumerate(attr_cols):
303
515
  uniq = df.get_column(attr_phys[col_idx]).cast(pl.String).drop_nulls().unique(maintain_order=True).to_list()
@@ -46,6 +46,7 @@ from sdmxlib.formats.sdmx_json._data_common import (
46
46
  decode_observations,
47
47
  display_names,
48
48
  encode_batch,
49
+ frequency_durations,
49
50
  resolve_columns,
50
51
  rows_to_lazyframe,
51
52
  )
@@ -116,7 +117,16 @@ def write_sdmx_json_data(
116
117
  dim_cols, dim_phys, attr_cols, attr_phys, _measure_id, measure_col = resolve_columns(binding)
117
118
 
118
119
  time_dim_id = dsd.time_dimension.id if dsd.time_dimension is not None else "TIME_PERIOD"
119
- dim_lookup = ComponentTable(dim_cols, dsd, kind="dimension", lang=lang, time_dim_id=time_dim_id)
120
+ freq_dim_id, freq_overrides = frequency_durations(dsd, lang)
121
+ dim_lookup = ComponentTable(
122
+ dim_cols,
123
+ dsd,
124
+ kind="dimension",
125
+ lang=lang,
126
+ time_dim_id=time_dim_id,
127
+ freq_dim_id=freq_dim_id,
128
+ freq_overrides=freq_overrides,
129
+ )
120
130
  attr_lookup = ComponentTable(attr_cols, dsd, kind="attribute", lang=lang)
121
131
 
122
132
  names = display_names(binding, dsd, lang)
@@ -60,6 +60,7 @@ from sdmxlib.formats.sdmx_json._data_common import (
60
60
  decode_observations,
61
61
  display_names,
62
62
  encode_batch,
63
+ frequency_durations,
63
64
  resolve_columns,
64
65
  rows_to_lazyframe,
65
66
  )
@@ -137,7 +138,16 @@ def write_sdmx_json_data(
137
138
  dim_cols, dim_phys, attr_cols, attr_phys, _measure_id, measure_col = resolve_columns(binding)
138
139
 
139
140
  time_dim_id = dsd.time_dimension.id if dsd.time_dimension is not None else "TIME_PERIOD"
140
- dim_lookup = ComponentTable(dim_cols, dsd, kind="dimension", lang=lang, time_dim_id=time_dim_id)
141
+ freq_dim_id, freq_overrides = frequency_durations(dsd, lang)
142
+ dim_lookup = ComponentTable(
143
+ dim_cols,
144
+ dsd,
145
+ kind="dimension",
146
+ lang=lang,
147
+ time_dim_id=time_dim_id,
148
+ freq_dim_id=freq_dim_id,
149
+ freq_overrides=freq_overrides,
150
+ )
141
151
  attr_lookup = ComponentTable(attr_cols, dsd, kind="attribute", lang=lang)
142
152
 
143
153
  names = display_names(binding, dsd, lang)
File without changes
File without changes
File without changes
File without changes
File without changes