Skip to content

Commit

Permalink
src - Add Result.to_json() with examples-based tests (#101)
Browse files Browse the repository at this point in the history
* Add Result.to_json() with examples-based tests

Revert bad change to tox.ini

* Minimal serialization docs

* Fix problems identified by pre-commit hook
  • Loading branch information
ezheidtmann authored Dec 4, 2023
1 parent 290054d commit 719d4ce
Show file tree
Hide file tree
Showing 4 changed files with 97 additions and 0 deletions.
25 changes: 25 additions & 0 deletions docs/source/example.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
55 changes: 55 additions & 0 deletions overpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -334,6 +344,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,
Expand Down Expand Up @@ -620,6 +642,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],
Expand Down Expand Up @@ -782,6 +809,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":
"""
Expand Down Expand Up @@ -965,6 +998,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":
"""
Expand Down Expand Up @@ -1104,6 +1144,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":
"""
Expand Down Expand Up @@ -1244,6 +1292,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,
Expand Down
16 changes: 16 additions & 0 deletions tests/test_json.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,78 @@
import pytest
import simplejson

import overpy

from tests import read_file
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):
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):
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):
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()
Expand Down
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ envlist = py37,py38,py39,py310,py311,pypy39
[testenv]
deps =
pytest
simplejson
pytest-cov
commands = pytest --cov overpy --cov-report=term-missing -v tests/

Expand Down

0 comments on commit 719d4ce

Please sign in to comment.