diff --git a/sdmx/__init__.py b/sdmx/__init__.py index 20e09f37..6c50ca3a 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/convert/common.py b/sdmx/convert/common.py index dbe90e54..52469375 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 diff --git a/sdmx/format/json.py b/sdmx/format/json.py index e69de29b..b93b3e34 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" diff --git a/sdmx/tests/writer/conftest.py b/sdmx/tests/writer/conftest.py index 8e17e24a..8b856108 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 00000000..f305430d --- /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 diff --git a/sdmx/writer/json/__init__.py b/sdmx/writer/json/__init__.py new file mode 100644 index 00000000..d5c87c68 --- /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 00000000..1f81ce2a --- /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 00000000..bc2ed306 --- /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 00000000..fe7e14e1 --- /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