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.
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/PKG-INFO +1 -1
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/pyproject.toml +1 -1
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/_data_common.py +231 -19
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/data_v1.py +11 -1
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/data_v2.py +11 -1
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/README.md +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/_duckdb.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/client.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/federated.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/filters.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/policy.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/providers.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/query.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/registry.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/api/session.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/builders/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/builders/contentconstraint.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/catalog.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/data_store.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/structure_values.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml/_common.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml31/reader.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/formats/sdmx_ml31/writer.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/local/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/local/data_store.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/local/registry.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/annotations.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/base.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/binding.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/category.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/code_query.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/codelist.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/collections.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/concept.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/constraint.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/convert.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/dataflow.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/dataset.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/datastructure.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/expr.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/hierarchy.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/hierarchy_query.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/in_memory_registry.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/istring.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/mapping.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/message.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/metadataflow.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/metadataset.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/metadatastructure.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/organisation.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/provision.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/ref.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/registry.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/representation.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/urn.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/model/validation.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/polars.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/py.typed +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/rest.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/sql.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/__init__.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/_kinds.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/lazy.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/readers.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/schema.py +0 -0
- {sdmxlib-0.41.0 → sdmxlib-0.41.2}/src/sdmxlib/storage/writers.py +0 -0
|
@@ -13,7 +13,8 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import calendar
|
|
15
15
|
import re
|
|
16
|
-
from
|
|
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
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
|
|
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
|
-
#
|
|
318
|
+
# codelist registration for components without one.
|
|
168
319
|
cl = getattr(comp, "resolved_codelist", None)
|
|
169
|
-
if cl is None:
|
|
170
|
-
|
|
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.
|
|
185
|
-
#
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|