From 0248e577d223eb9f02dd54cd4db473c87c8791de Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Mon, 27 Oct 2025 00:56:52 +0100 Subject: [PATCH 1/4] Add .format.json submodule --- sdmx/format/json.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/sdmx/format/json.py b/sdmx/format/json.py index e69de29bb..b93b3e345 100644 --- a/sdmx/format/json.py +++ b/sdmx/format/json.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + +from sdmx.format.common import Format + + +@dataclass +class JSONFormat(Format): + """Information about an SDMX-JSON format.""" + + suffix = "json" + + +class JSON_v10(JSONFormat): + version = "1.0" + + +class JSON_v20(JSONFormat): + version = "2.0.0" + + +class JSON_v21(JSONFormat): + version = "2.1.0" From 7a9fb3b27be632062ee72b75e2f8ec98ac00523b Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Mon, 27 Oct 2025 00:58:30 +0100 Subject: [PATCH 2/4] Add DispatchConverter.__init_subclass__() --- sdmx/convert/common.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/sdmx/convert/common.py b/sdmx/convert/common.py index dbe90e547..524693757 100644 --- a/sdmx/convert/common.py +++ b/sdmx/convert/common.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from copy import deepcopy from typing import Any @@ -46,6 +47,10 @@ class DispatchConverter(Converter): _registry: dict[type, Callable] + def __init_subclass__(cls, **kwargs) -> None: + super().__init_subclass__(**kwargs) + cls._registry = deepcopy(getattr(cls, "_registry", dict())) + def convert(self, obj, **kwargs): # Use either type(obj) or a parent type to retrieve a conversion function for i, cls in enumerate(type(obj).mro()): @@ -72,14 +77,7 @@ def register(cls, func: "Callable"): `func` must have an argument named `obj` that is annotated with a particular type. """ - try: - registry = getattr(cls, "_registry") - except AttributeError: - # First call → registry does not exist → create it - registry = dict() - setattr(cls, "_registry", registry) - # Register `func` for the class of the `obj` argument - registry[getattr(func, "__annotations__")["obj"]] = func + cls._registry[getattr(func, "__annotations__")["obj"]] = func return func From d1c7bdc7fa77ecf9f46ab7f339c162792975212b Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Mon, 27 Oct 2025 00:57:36 +0100 Subject: [PATCH 3/4] Add .writer.json submodule, sdmx.to_json() --- sdmx/__init__.py | 2 ++ sdmx/writer/json/__init__.py | 18 +++++++++++++++ sdmx/writer/json/base.py | 11 +++++++++ sdmx/writer/json/v1.py | 12 ++++++++++ sdmx/writer/json/v2.py | 43 ++++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+) create mode 100644 sdmx/writer/json/__init__.py create mode 100644 sdmx/writer/json/base.py create mode 100644 sdmx/writer/json/v1.py create mode 100644 sdmx/writer/json/v2.py diff --git a/sdmx/__init__.py b/sdmx/__init__.py index 20e09f37d..6c50ca3a0 100644 --- a/sdmx/__init__.py +++ b/sdmx/__init__.py @@ -8,6 +8,7 @@ from sdmx.rest import Resource from sdmx.source import add_source, get_source, list_sources from sdmx.writer.csv import to_csv +from sdmx.writer.json import to_json from sdmx.writer.xml import to_xml __all__ = [ @@ -22,6 +23,7 @@ "read_sdmx", "read_url", "to_csv", + "to_json", "to_pandas", "to_xml", "to_sdmx", diff --git a/sdmx/writer/json/__init__.py b/sdmx/writer/json/__init__.py new file mode 100644 index 000000000..d5c87c68c --- /dev/null +++ b/sdmx/writer/json/__init__.py @@ -0,0 +1,18 @@ +from typing import TYPE_CHECKING + +from sdmx.format.json import JSON_v20, JSON_v21 + +if TYPE_CHECKING: + from sdmx.message import DataMessage, StructureMessage + + +def to_json(obj: "DataMessage | StructureMessage", **kwargs) -> str: + format = kwargs.get("format", JSON_v20) + if format in (JSON_v20, JSON_v21): + from . import v2 + + return v2.JSONWriter(**kwargs).convert(obj) + else: + from . import v1 + + return v1.JSONWriter(**kwargs).convert(obj) diff --git a/sdmx/writer/json/base.py b/sdmx/writer/json/base.py new file mode 100644 index 000000000..1f81ce2a9 --- /dev/null +++ b/sdmx/writer/json/base.py @@ -0,0 +1,11 @@ +from typing import TYPE_CHECKING + +from sdmx.convert.common import DispatchConverter + +if TYPE_CHECKING: + from sdmx.format.json import JSONFormat + + +class BaseJSONWriter(DispatchConverter): + def __init__(self, format: "JSONFormat") -> None: + pass diff --git a/sdmx/writer/json/v1.py b/sdmx/writer/json/v1.py new file mode 100644 index 000000000..bc2ed3069 --- /dev/null +++ b/sdmx/writer/json/v1.py @@ -0,0 +1,12 @@ +from sdmx import message + +from .base import BaseJSONWriter + + +class JSONWriter(BaseJSONWriter): + pass + + +@JSONWriter.register +def _message(w: "JSONWriter", obj: message.Message): + raise NotImplementedError("Write SDMX-JSON 1.0") diff --git a/sdmx/writer/json/v2.py b/sdmx/writer/json/v2.py new file mode 100644 index 000000000..fe7e14e19 --- /dev/null +++ b/sdmx/writer/json/v2.py @@ -0,0 +1,43 @@ +import json +from typing import Any + +from sdmx import message + +from .base import BaseJSONWriter + + +class JSONWriter(BaseJSONWriter): + pass + + +@JSONWriter.register +def _data_message(w: "JSONWriter", obj: message.DataMessage): + result = {"meta": w.convert(obj.header)} + return json.dumps(result) + + +@JSONWriter.register +def _error_message(w: "JSONWriter", obj: message.ErrorMessage): + result = {"meta": w.convert(obj.header)} + return json.dumps(result) + + +@JSONWriter.register +def _metadata_message(w: "JSONWriter", obj: message.MetadataMessage): + result = {"meta": w.convert(obj.header)} + return json.dumps(result) + + +@JSONWriter.register +def _structure_message(w: "JSONWriter", obj: message.StructureMessage): + result = {"meta": w.convert(obj.header)} + return json.dumps(result) + + +@JSONWriter.register +def _header(w: "JSONWriter", obj: message.Header): + result: dict[str, Any] = {"test": obj.test} + if obj.id: + result.update(id=obj.id) + + return result From d34482c01ca3ca49c8330a79075089c056d4c323 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Mon, 27 Oct 2025 00:58:11 +0100 Subject: [PATCH 4/4] Add basic tests of to_json() --- sdmx/tests/writer/conftest.py | 18 ++++++++++++++++++ sdmx/tests/writer/test_json.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 sdmx/tests/writer/test_json.py diff --git a/sdmx/tests/writer/conftest.py b/sdmx/tests/writer/conftest.py index 8e17e24ab..8b8561080 100644 --- a/sdmx/tests/writer/conftest.py +++ b/sdmx/tests/writer/conftest.py @@ -1,6 +1,9 @@ +from datetime import datetime + import pytest from sdmx import message +from sdmx.model import common from sdmx.model.common import Agency, Codelist from sdmx.model.v21 import Annotation @@ -11,6 +14,21 @@ ] +@pytest.fixture(scope="module") +def agency() -> common.Agency: + return common.Agency(id="TEST") + + +@pytest.fixture(scope="module") +def header(agency) -> message.Header: + return message.Header( + id="N_A", + prepared=datetime.now(), + receiver=common.Agency(id="N_A"), + sender=agency, + ) + + @pytest.fixture def codelist(): """A Codelist for writer testing.""" diff --git a/sdmx/tests/writer/test_json.py b/sdmx/tests/writer/test_json.py new file mode 100644 index 000000000..f305430d8 --- /dev/null +++ b/sdmx/tests/writer/test_json.py @@ -0,0 +1,32 @@ +import pytest + +import sdmx +from sdmx import message +from sdmx.format.json import JSON_v10, JSON_v20, JSON_v21, JSONFormat + + +@pytest.mark.parametrize( + "message_class", + [ + message.DataMessage, + message.ErrorMessage, + message.MetadataMessage, + message.StructureMessage, + ], +) +@pytest.mark.parametrize( + "json_format", + [ + pytest.param(JSON_v10, marks=pytest.mark.xfail(raises=NotImplementedError)), + JSON_v20, + JSON_v21, + ], +) +def test_empty_message( + header: message.Header, message_class, json_format: JSONFormat +) -> None: + msg = message_class(header=header) + + result = sdmx.to_json(msg, format=json_format) + + assert '{"meta": {"test": false, "id": "N_A"}}' == result