diff --git a/docs/source/example.rst b/docs/source/example.rst index f749227..d7664e3 100644 --- a/docs/source/example.rst +++ b/docs/source/example.rst @@ -258,3 +258,28 @@ Line 20-21: Line 22-25: The resolved nodes have been added to the result set and are available to be used again later. + +Serialization +---- + +Result objects can be converted to a dictionary, in the same format as the +Overpass API ``json`` output format. + +.. code-block:: pycon + >>> import overpy, simplejson + >>> api = overpy.Overpass() + >>> result = api.query("[out:xml];node(50.745,7.17,50.75,7.18);out;") + >>> other_result = overpy.Result.from_json(result.to_json()) + >>> assert other_result != result + >>> assert other_result.to_json() == result.to_json() + >>> assert len(result.nodes) == len(other_result.nodes) + >>> assert len(result.ways) == len(other_result.ways) + +Serializing the dictionary to JSON requires rendering Decimal values as JSON +numbers, and then parsing with ``Overpass.parse_json()``. The third-party +package ``simplejson`` works for this application: + +.. code-block:: pycon + >>> result_str = simplejson.dumps(result.to_json()) + >>> new_result = api.parse_json(result_str) + >>> assert len(result.nodes) == len(new_result.nodes) diff --git a/overpy/__init__.py b/overpy/__init__.py index a85bb01..c2ab3f4 100644 --- a/overpy/__init__.py +++ b/overpy/__init__.py @@ -32,6 +32,16 @@ "visible": lambda v: v.lower() == "true" } +GLOBAL_ATTRIBUTE_SERIALIZERS: Dict[str, Callable] = { + "timestamp": lambda dt: datetime.strftime(dt, "%Y-%m-%dT%H:%M:%SZ"), +} + + +def _attributes_to_json(attributes: dict): + def attr_serializer(k): + return GLOBAL_ATTRIBUTE_SERIALIZERS.get(k, lambda v: v) + return {k: attr_serializer(k)(v) for k, v in attributes.items()} + def is_valid_type( element: Union["Area", "Node", "Relation", "Way"], @@ -366,6 +376,18 @@ def from_json(cls, data: dict, api: Optional[Overpass] = None) -> "Result": return result + def to_json(self) -> dict: + def elements_to_json(): + for elem_cls in [Node, Way, Relation, Area]: + for element in self.get_elements(elem_cls): + yield element.to_json() + + return { + "version": 0.6, + "generator": "Overpy Serializer", + "elements": list(elements_to_json()) + } + @classmethod def from_xml( cls, @@ -664,6 +686,11 @@ def from_json(cls: Type[ElementTypeVar], data: dict, result: Optional[Result] = """ raise NotImplementedError + def to_json(self) -> dict: + d = {"type": self._type_value, "id": self.id, "tags": self.tags} + d.update(_attributes_to_json(self.attributes)) + return d + @classmethod def from_xml( cls: Type[ElementTypeVar], @@ -826,6 +853,12 @@ def from_json(cls, data: dict, result: Optional[Result] = None) -> "Node": return cls(node_id=node_id, lat=lat, lon=lon, tags=tags, attributes=attributes, result=result) + def to_json(self) -> dict: + d = super().to_json() + d["lat"] = self.lat + d["lon"] = self.lon + return d + @classmethod def from_xml(cls, child: xml.etree.ElementTree.Element, result: Optional[Result] = None) -> "Node": """ @@ -1012,6 +1045,13 @@ def from_json(cls, data: dict, result: Optional[Result] = None) -> "Way": way_id=way_id ) + def to_json(self) -> dict: + d = super().to_json() + if self.center_lat is not None and self.center_lon is not None: + d["center"] = {"lat": self.center_lat, "lon": self.center_lon} + d["nodes"] = self._node_ids + return d + @classmethod def from_xml(cls, child: xml.etree.ElementTree.Element, result: Optional[Result] = None) -> "Way": """ @@ -1151,6 +1191,14 @@ def from_json(cls, data: dict, result: Optional[Result] = None) -> "Relation": result=result ) + def to_json(self) -> dict: + d = super().to_json() + if self.center_lat is not None and self.center_lon is not None: + d["center"] = {"lat": self.center_lat, "lon": self.center_lon} + + d["members"] = [member.to_json() for member in self.members] + return d + @classmethod def from_xml(cls, child: xml.etree.ElementTree.Element, result: Optional[Result] = None) -> "Relation": """ @@ -1291,6 +1339,13 @@ def from_json(cls, data: dict, result: Optional[Result] = None) -> "RelationMemb result=result ) + def to_json(self): + d = {"type": self._type_value, "ref": self.ref, "role": self.role} + if self.geometry is not None: + d["geometry"] = [{"lat": v.lat, "lon": v.lon} for v in self.geometry] + d.update(_attributes_to_json(self.attributes)) + return d + @classmethod def from_xml( cls, diff --git a/tests/test_json.py b/tests/test_json.py index 9a96d0e..3dbdb0e 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1,4 +1,5 @@ import pytest +import simplejson import overpy @@ -6,11 +7,18 @@ from tests.base_class import BaseTestAreas, BaseTestNodes, BaseTestRelation, BaseTestWay +def reparse(api: overpy.Overpass, r: overpy.Result): + # we need `simplejson` because core `json` can't serialize Decimals in the way + # that we would like without enormous hacks + return api.parse_json(simplejson.dumps(r.to_json())) + + class TestAreas(BaseTestAreas): def test_area01(self): api = overpy.Overpass() result = api.parse_json(read_file("json/area-01.json")) self._test_area01(result) + self._test_area01(reparse(api, result)) class TestNodes(BaseTestNodes): @@ -18,6 +26,7 @@ def test_node01(self): api = overpy.Overpass() result = api.parse_json(read_file("json/node-01.json")) self._test_node01(result) + self._test_node01(reparse(api, result)) class TestRelation(BaseTestRelation): @@ -25,21 +34,25 @@ def test_relation01(self): api = overpy.Overpass() result = api.parse_json(read_file("json/relation-01.json")) self._test_relation01(result) + self._test_relation01(reparse(api, result)) def test_relation02(self): api = overpy.Overpass() result = api.parse_json(read_file("json/relation-02.json")) self._test_relation02(result) + self._test_relation02(reparse(api, result)) def test_relation03(self): api = overpy.Overpass() result = api.parse_json(read_file("json/relation-03.json")) self._test_relation03(result) + self._test_relation03(reparse(api, result)) def test_relation04(self): api = overpy.Overpass() result = api.parse_json(read_file("json/relation-04.json")) self._test_relation04(result) + self._test_relation04(reparse(api, result)) class TestWay(BaseTestWay): @@ -47,16 +60,19 @@ def test_way01(self): api = overpy.Overpass() result = api.parse_json(read_file("json/way-01.json")) self._test_way01(result) + self._test_way01(reparse(api, result)) def test_way02(self): api = overpy.Overpass() result = api.parse_json(read_file("json/way-02.json")) self._test_way02(result) + self._test_way02(reparse(api, result)) def test_way03(self): api = overpy.Overpass() result = api.parse_json(read_file("json/way-03.json")) self._test_way03(result) + self._test_way03(reparse(api, result)) def test_way04(self): api = overpy.Overpass() diff --git a/tox.ini b/tox.ini index d4360ac..c7114fb 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,7 @@ envlist = py36,py37,py38,py39,pypy3 [testenv] deps = pytest + simplejson pytest-cov commands = pytest --cov overpy --cov-report=term-missing -v tests/