Skip to content

Commit 67b381b

Browse files
authored
Merge pull request #175 from khaeru/enh/2024-W16
Miscellaneous improvements for 2024-W16
2 parents 8d5e467 + 4dc8043 commit 67b381b

21 files changed

+958
-669
lines changed

.github/workflows/pytest.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ jobs:
3030
# commented: only enable once next Python version enters RC
3131
# - "3.13.0-rc.1" # Development version
3232

33+
# Work around https://github.com/actions/setup-python/issues/696
34+
exclude:
35+
- {os: macos-latest, python-version: "3.8"}
36+
- {os: macos-latest, python-version: "3.9"}
37+
include:
38+
- {os: macos-13, python-version: "3.8"}
39+
- {os: macos-13, python-version: "3.9"}
40+
3341
fail-fast: false
3442

3543
runs-on: ${{ matrix.os }}

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
repos:
22
- repo: https://github.com/pre-commit/mirrors-mypy
3-
rev: v1.9.0
3+
rev: v1.10.0
44
hooks:
55
- id: mypy
66
additional_dependencies:
7+
- lxml-stubs
78
- pandas-stubs
89
- pytest
910
- requests-cache
1011
- requests-mock
1112
- types-Jinja2
12-
- types-lxml
1313
- types-python-dateutil
1414
- types-PyYAML
1515
- types-requests
1616
args: []
1717
- repo: https://github.com/astral-sh/ruff-pre-commit
18-
rev: v0.3.3
18+
rev: v0.4.2
1919
hooks:
2020
- id: ruff
2121
- id: ruff-format

README.rst

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
sdmx: Statistical data and metadata exchange
22
********************************************
3+
|pypi| |rtd| |services| |gha| |codecov|
34

4-
.. image:: https://github.com/khaeru/sdmx/actions/workflows/pytest.yaml/badge.svg
5-
:target: https://github.com/khaeru/sdmx/actions
6-
:alt: Status badge for the "pytest" continuous integration workflow
7-
.. image:: https://codecov.io/gh/khaeru/sdmx/branch/main/graph/badge.svg
8-
:target: https://codecov.io/gh/khaeru/sdmx
9-
:alt: Codecov test coverage badge
10-
.. image:: https://readthedocs.org/projects/sdmx1/badge/?version=latest
11-
:target: https://sdmx1.readthedocs.io/en/latest
12-
:alt: Documentation status badge
13-
.. image:: https://img.shields.io/pypi/v/sdmx1.svg
5+
.. |pypi| image:: https://img.shields.io/pypi/v/sdmx1.svg
146
:target: https://pypi.org/project/sdmx1
157
:alt: PyPI version badge
16-
.. image:: https://img.shields.io/badge/services-status-informational
8+
.. |rtd| image:: https://readthedocs.org/projects/sdmx1/badge/?version=latest
9+
:target: https://sdmx1.readthedocs.io/en/latest
10+
:alt: Documentation status badge
11+
.. |services| image:: https://img.shields.io/badge/services-status-informational
1712
:target: https://khaeru.github.io/sdmx/
1813
:alt: Status of supported service endpoints
14+
.. |gha| image:: https://github.com/khaeru/sdmx/actions/workflows/pytest.yaml/badge.svg
15+
:target: https://github.com/khaeru/sdmx/actions
16+
:alt: Status badge for the "pytest" continuous integration workflow
17+
.. |codecov| image:: https://codecov.io/gh/khaeru/sdmx/branch/main/graph/badge.svg
18+
:target: https://codecov.io/gh/khaeru/sdmx
19+
:alt: Codecov test coverage badge
1920

2021
`Source code @ Github <https://github.com/khaeru/sdmx/>`_ —
2122
`Authors <https://github.com/khaeru/sdmx/graphs/contributors>`_

doc/sources.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,11 @@ In order to identify available data flows:
235235
SDMX-ML —
236236
`Website <https://ilostat.ilo.org/resources/sdmx-tools/>`__
237237

238+
.. versionchanged:: 2.15.0
239+
240+
Sometime before 2024-04-26, the base URL of this source changed from ``https://www.ilo.org/sdmx/rest`` to ``https://sdmx.ilo.org/rest``.
241+
The "SDMX query builder" at the above URL reflects the change, but the documentation still shows the old URL, and there does not appear to have been any public announcement about the new URL, retirement of the old URL, etc.
242+
Thanks :gh-user:`SebaJeku` for the tip (:issue:`177`).
238243

239244
.. _IMF:
240245

doc/whatsnew.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ Next release
77
============
88

99
- Adjust the :doc:`example` for current data returned by :ref:`ESTAT <ESTAT>` (:issue:`169`, :pull:`170`).
10-
- :meth:`.StructureMessage.get` can match on :meth:`.IdentifiableArtefact.urn` (:pull:`170`).
10+
- Update the base URL of the :ref:`ILO <ILO>` source (:pull:`175`; thanks :gh-user:`SebaJeku` for :issue:`177`).
11+
- :meth:`.StructureMessage.get` can match on :attr:`.IdentifiableArtefact.urn` (:pull:`170`).
1112
This makes the method more useful in the case that a message includes artefacts with the same ID but different :attr:`~.MaintainableArtefact.maintainer` and/or :attr:`~.VersionableArtefact.version`.
13+
- :func:`.urn.make` can handle :class:`.DataConsumerScheme`, :class:`.OrganisationScheme`, :class:`.ReportingTaxonomy`, :class:`.TransformationScheme`, and :class:`.VTLMappingScheme` (:pull:`175`).
14+
- New method :meth:`.StructureMessage.iter_objects` (:pull:`175`).
15+
- New method :meth:`.DataMessage.update` (:pull:`175`).
1216
- Bugfix: :class:`.ItemScheme` could not be :func:`copy.deepcopy` 'd (:pull:`170`).
17+
- Bugfix: :class:`.TypeError` was raised on :meth:`.Client.get` from an SDMX-JSON source (:pull:`175`).
1318

1419
v2.14.0 (2024-02-20)
1520
====================

sdmx/message.py

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,17 @@
1313
from datetime import datetime
1414
from itertools import chain
1515
from operator import attrgetter
16-
from typing import TYPE_CHECKING, List, Optional, Text, Type, Union, get_args
16+
from typing import (
17+
TYPE_CHECKING,
18+
Generator,
19+
List,
20+
Optional,
21+
Text,
22+
Tuple,
23+
Type,
24+
Union,
25+
get_args,
26+
)
1727

1828
from sdmx import model
1929
from sdmx.dictlike import DictLike, summarize_dictlike
@@ -166,7 +176,7 @@ def __repr__(self):
166176
lines.extend(_summarize(self, ["footer", "response"]))
167177
return "\n ".join(lines)
168178

169-
def compare(self, other, strict=True):
179+
def compare(self, other, strict=True) -> bool:
170180
"""Return :obj:`True` if `self` is the same as `other`.
171181
172182
Two Messages are the same if their :attr:`header` and :attr:`footer` compare
@@ -179,7 +189,7 @@ def compare(self, other, strict=True):
179189
"""
180190
return self.header.compare(other.header, strict) and (
181191
self.footer is other.footer is None
182-
or self.footer.compare(other.footer, strict)
192+
or self.footer.compare(other.footer, strict) # type: ignore [union-attr]
183193
)
184194

185195

@@ -292,6 +302,8 @@ def get(
292302
of different classes, or two objects of the same class with different
293303
:attr:`~.MaintainableArtefact.maintainer` or
294304
:attr:`~.VersionableArtefact.version`.
305+
306+
.. todo:: Support passing a URN.
295307
"""
296308
id_ = (
297309
obj_or_id.id
@@ -313,11 +325,21 @@ def get(
313325

314326
return candidates[0] if len(candidates) == 1 else None
315327

316-
def iter_collections(self):
328+
def iter_collections(self) -> Generator[Tuple[str, type], None, None]:
317329
"""Iterate over collections."""
318330
for f in direct_fields(self.__class__):
319331
yield f.name, get_args(f.type)[1]
320332

333+
def iter_objects(
334+
self, external_reference: bool = True
335+
) -> Generator[common.MaintainableArtefact, None, None]:
336+
"""Iterate over all objects in the message."""
337+
for _, cls in self.iter_collections():
338+
for obj in self.objects(cls).values():
339+
if not external_reference and obj.is_external_reference:
340+
continue
341+
yield obj
342+
321343
def objects(self, cls):
322344
"""Get a reference to the attribute for objects of type `cls`.
323345
@@ -352,10 +374,9 @@ def __repr__(self):
352374
class DataMessage(Message):
353375
"""SDMX Data Message.
354376
355-
.. note:: A DataMessage may contain zero or more :class:`.DataSet`, so
356-
:attr:`data` is a list. To retrieve the first (and possibly only)
357-
data set in the message, access the first element of the list:
358-
``msg.data[0]``.
377+
.. note:: A DataMessage may contain zero or more :class:`.DataSet`, so :attr:`data`
378+
is a list. To retrieve the first (and possibly only) data set in the message,
379+
access the first element of the list: :py:`msg.data[0]`.
359380
"""
360381

361382
#: :class:`list` of :class:`.DataSet`.
@@ -427,6 +448,74 @@ def compare(self, other, strict=True):
427448
and all(ds[0].compare(ds[1], strict) for ds in zip(self.data, other.data))
428449
)
429450

451+
def update(self) -> None:
452+
"""Update :attr:`.observation_dimension`.
453+
454+
The observation dimensions (or dimension observation) is determined
455+
automatically if:
456+
457+
1. There is at least 1 :class:`DataSet <.BaseDataSet>` in the message.
458+
2. For at least 1 data set:
459+
460+
- :attr:`~.BaseDataSet.structured_by` is defined.
461+
- There is at least 1 :class:`.Observation` in the data set. (:meth:`.update`
462+
checks only the first observation.)
463+
- The :attr:`.Observation.dimension` is a :class:`.Key` referring to exactly
464+
1 dimension.
465+
466+
3. The dimension indicated by (2) is the same for all DataSets in the message.
467+
468+
If not all these conditions are met, messages are logged with level DEBUG, and
469+
:attr:`.observation_dimension` is set to :any:`None`.
470+
471+
.. note:: :meth:`.update` is not automatically called when data sets are added
472+
to or removed from :attr:`.data`. User code **should** call :meth:`.update`
473+
to reflect such changes.
474+
"""
475+
if not self.data:
476+
log.debug("No DataSet in message")
477+
self.observation_dimension = None
478+
return
479+
480+
dims = set()
481+
for ds in self.data:
482+
try:
483+
assert ds.structured_by
484+
485+
# Use the first observation
486+
assert len(ds.obs)
487+
o0 = ds.obs[0]
488+
assert o0.dimension
489+
490+
# Identify the dimensions specified per-observation
491+
d_a_o = tuple(o0.dimension.values.keys())
492+
493+
if 1 == len(d_a_o):
494+
# Single dimension-at-observation
495+
# Record as an attribute of the DataMessage
496+
dims.add(ds.structured_by.dimensions.get(d_a_o[0]))
497+
else:
498+
dims.add(d_a_o)
499+
except AssertionError:
500+
continue
501+
502+
if len(dims) == 1 and not all(isinstance(d, tuple) for d in dims):
503+
self.observation_dimension = dims.pop()
504+
else:
505+
if len(dims) == 1:
506+
log.debug(f"More than 1 dimension at observation level: {dims.pop()}")
507+
elif len(dims) > 1:
508+
log.debug(
509+
f"Multiple data sets with different observation dimension: {dims}"
510+
)
511+
elif not dims:
512+
log.debug(
513+
f"Unable to determine observation dimension for {len(self.data)} "
514+
"data set(s). Data set(s) may lack structure reference or "
515+
"observations."
516+
)
517+
self.observation_dimension = None
518+
430519

431520
@dataclass
432521
class MetadataMessage(DataMessage):

sdmx/model/common.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -956,9 +956,12 @@ def getdefault(self, id, cls=None, **kwargs) -> CT:
956956
return component
957957

958958
# Properties of components
959-
def __getitem__(self, key) -> CT:
960-
"""Convenience access to components."""
961-
return self.components[key]
959+
def __getitem__(self, index: int) -> CT:
960+
"""Convenience access to :attr:`components` by index.
961+
962+
To retrieve components by ID, use :meth:`get`.
963+
"""
964+
return self.components[index]
962965

963966
def __len__(self):
964967
return len(self.components)
@@ -2543,8 +2546,20 @@ class BaseContentConstraint:
25432546
PACKAGE = dict()
25442547

25452548
_PACKAGE_CLASS: Dict[str, set] = {
2546-
"base": {"Agency", "AgencyScheme", "DataProvider", "DataProviderScheme"},
2547-
"categoryscheme": {"Category", "Categorisation", "CategoryScheme"},
2549+
"base": {
2550+
"Agency",
2551+
"AgencyScheme",
2552+
"DataProvider",
2553+
"DataConsumerScheme",
2554+
"DataProviderScheme",
2555+
"OrganisationScheme",
2556+
},
2557+
"categoryscheme": {
2558+
"Category",
2559+
"Categorisation",
2560+
"CategoryScheme",
2561+
"ReportingTaxonomy",
2562+
},
25482563
"codelist": {
25492564
"Code",
25502565
"Codelist",
@@ -2571,7 +2586,9 @@ class BaseContentConstraint:
25712586
"CustomTypeScheme",
25722587
"NamePersonalisationScheme",
25732588
"RulesetScheme",
2589+
"TransformationScheme",
25742590
"UserDefinedOperatorScheme",
2591+
"VTLMappingScheme",
25752592
},
25762593
}
25772594

sdmx/reader/base.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import logging
22
from abc import ABC, abstractmethod
33
from functools import lru_cache
4-
from typing import List
4+
from typing import TYPE_CHECKING, ClassVar, List, Optional
5+
from warnings import warn
56

67
from sdmx.format import MediaType
78

9+
if TYPE_CHECKING:
10+
import sdmx.model.common
11+
812
log = logging.getLogger(__name__)
913

1014

1115
class BaseReader(ABC):
1216
#: List of media types handled by the reader.
13-
media_types: List[MediaType] = []
17+
media_types: ClassVar[List[MediaType]] = []
1418

1519
#: List of file name suffixes handled by the reader.
16-
suffixes: List[str] = []
20+
suffixes: ClassVar[List[str]] = []
1721

1822
@classmethod
1923
def detect(cls, content: bytes) -> bool:
@@ -41,19 +45,44 @@ def supports_suffix(cls, value: str) -> bool:
4145
return value.lower() in cls.suffixes
4246

4347
@abstractmethod
44-
def read_message(self, source, dsd=None):
48+
def read_message(
49+
self,
50+
source,
51+
structure: Optional["sdmx.model.common.Structure"] = None,
52+
**kwargs,
53+
):
4554
"""Read message from *source*.
4655
4756
Parameters
4857
----------
4958
source : file-like
5059
Message content.
51-
dsd : :class:`DataStructureDefinition <.BaseDataStructureDefinition>`, optional
52-
DSD for aid in reading `source`.
60+
structure :
61+
:class:`DataStructure <.BaseDataStructureDefinition>` or
62+
:class:`MetadataStructure <.BaseMetadataStructureDefinition>`
63+
for aid in reading `source`.
5364
5465
Returns
5566
-------
5667
:class:`.Message`
5768
An instance of a Message subclass.
5869
"""
5970
pass # pragma: no cover
71+
72+
@classmethod
73+
def _handle_deprecated_kwarg(
74+
cls, structure: Optional["sdmx.model.common.Structure"], kwargs
75+
) -> Optional["sdmx.model.common.Structure"]:
76+
try:
77+
dsd = kwargs.pop("dsd")
78+
except KeyError:
79+
dsd = None
80+
else:
81+
warn(
82+
"Reader.read_message(…, dsd=…) keyword argument; use structure=…",
83+
DeprecationWarning,
84+
stacklevel=2,
85+
)
86+
if structure and structure is not dsd:
87+
raise ValueError(f"Mismatched structure={structure}, dsd={dsd}")
88+
return structure or dsd

sdmx/reader/json.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@ class Reader(BaseReader):
3737
def detect(cls, content):
3838
return content.startswith(b"{")
3939

40-
def read_message(self, source, dsd=None): # noqa: C901 TODO reduce complexity 15 → ≤11
40+
def read_message(self, source, structure=None, **kwargs): # noqa: C901 TODO reduce complexity 15 → ≤11
4141
# Initialize message instance
4242
msg = DataMessage()
4343

44+
dsd = self._handle_deprecated_kwarg(structure, kwargs)
4445
if dsd: # pragma: no cover
4546
# Store explicit DSD, if any
4647
msg.dataflow.structure = dsd

0 commit comments

Comments
 (0)