diff --git a/docs/source/api.rst b/docs/source/api.rst index c697d3b..a2ffb6d 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -23,6 +23,9 @@ Elements .. autoclass:: Element :members: +.. autoclass:: Area + :members: + .. autoclass:: Node :members: @@ -39,6 +42,9 @@ Relation Members .. autoclass:: RelationMember :members: +.. autoclass:: RelationArea + :members: + .. autoclass:: RelationNode :members: diff --git a/examples/get_areas.py b/examples/get_areas.py new file mode 100644 index 0000000..44e7710 --- /dev/null +++ b/examples/get_areas.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +import overpy + +api = overpy.Overpass() + +# fetch all areas +# More info on http://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_API_by_Example +result = api.query(""" +area[name="Troisdorf"]; +out; +""") + +for area in result.areas: + print( + "Name: %s (%i)" % ( + area.tags.get("name", "n/a"), + area.id + ) + ) + for n, v in area.tags.items(): + print(" Tag: %s = %s" % (n, v)) diff --git a/overpy/__init__.py b/overpy/__init__.py index 4675a16..793c9b1 100644 --- a/overpy/__init__.py +++ b/overpy/__init__.py @@ -12,7 +12,6 @@ __uri__, __version__ ) - PY2 = sys.version_info[0] == 2 PY3 = sys.version_info[0] == 3 @@ -40,7 +39,6 @@ def is_valid_type(element, cls): class Overpass(object): - """ Class to access the Overpass API """ @@ -168,7 +166,6 @@ def parse_xml(self, data, encoding="utf-8", parser=None): class Result(object): - """ Class to handle the result. """ @@ -182,11 +179,12 @@ def __init__(self, elements=None, api=None): """ if elements is None: elements = [] + self._areas = OrderedDict((element.id, element) for element in elements if is_valid_type(element, Area)) self._nodes = OrderedDict((element.id, element) for element in elements if is_valid_type(element, Node)) self._ways = OrderedDict((element.id, element) for element in elements if is_valid_type(element, Way)) self._relations = OrderedDict((element.id, element) for element in elements if is_valid_type(element, Relation)) - self._class_collection_map = {Node: self._nodes, Way: self._ways, Relation: self._relations} + self._class_collection_map = {Node: self._nodes, Way: self._ways, Relation: self._relations, Area: self._areas} self.api = api def expand(self, other): @@ -202,7 +200,7 @@ def expand(self, other): if not isinstance(other, Result): raise ValueError("Provided argument has to be instance of overpy:Result()") - other_collection_map = {Node: other.nodes, Way: other.ways, Relation: other.relations} + other_collection_map = {Node: other.nodes, Way: other.ways, Relation: other.relations, Area: other.areas} for element_type, own_collection in self._class_collection_map.items(): for element in other_collection_map[element_type]: if is_valid_type(element, element_type) and element.id not in own_collection: @@ -256,6 +254,9 @@ def get_way_ids(self): def get_relation_ids(self): return self.get_ids(filter_cls=Relation) + def get_area_ids(self): + return self.get_ids(filter_cls=Area) + @classmethod def from_json(cls, data, api=None): """ @@ -269,7 +270,7 @@ def from_json(cls, data, api=None): :rtype: overpy.Result """ result = cls(api=api) - for elem_cls in [Node, Way, Relation]: + for elem_cls in [Node, Way, Relation, Area]: for element in data.get("elements", []): e_type = element.get("type") if hasattr(e_type, "lower") and e_type.lower() == elem_cls._type_value: @@ -296,7 +297,7 @@ def from_xml(cls, data, api=None, parser=XML_PARSER_SAX): import xml.etree.ElementTree as ET root = ET.fromstring(data) - for elem_cls in [Node, Way, Relation]: + for elem_cls in [Node, Way, Relation, Area]: for child in root: if child.tag.lower() == elem_cls._type_value: result.append(elem_cls.from_xml(child, result=result)) @@ -316,6 +317,51 @@ def from_xml(cls, data, api=None, parser=XML_PARSER_SAX): raise Exception("Unknown XML parser") return result + def get_area(self, area_id, resolve_missing=False): + """ + Get an area by its ID. + + :param area_id: The area ID + :type area_id: Integer + :param resolve_missing: Query the Overpass API if the area is missing in the result set. + :return: The area + :rtype: overpy.Area + :raises overpy.exception.DataIncomplete: The requested way is not available in the result cache. + :raises overpy.exception.DataIncomplete: If resolve_missing is True and the area can't be resolved. + """ + areas = self.get_areas(area_id=area_id) + if len(areas) == 0: + if resolve_missing is False: + raise exception.DataIncomplete("Resolve missing area is disabled") + + query = ("\n" + "[out:json];\n" + "area({area_id});\n" + "out body;\n" + ) + query = query.format( + area_id=area_id + ) + tmp_result = self.api.query(query) + self.expand(tmp_result) + + areas = self.get_areas(area_id=area_id) + + if len(areas) == 0: + raise exception.DataIncomplete("Unable to resolve requested areas") + + return areas[0] + + def get_areas(self, area_id=None, **kwargs): + """ + Alias for get_elements() but filter the result by Area + + :param area_id: The Id of the area + :type area_id: Integer + :return: List of elements + """ + return self.get_elements(Area, elem_id=area_id, **kwargs) + def get_node(self, node_id, resolve_missing=False): """ Get a node by its ID. @@ -451,6 +497,8 @@ def get_ways(self, way_id=None, **kwargs): """ return self.get_elements(Way, elem_id=way_id, **kwargs) + area_ids = property(get_area_ids) + areas = property(get_areas) node_ids = property(get_node_ids) nodes = property(get_nodes) relation_ids = property(get_relation_ids) @@ -460,7 +508,6 @@ def get_ways(self, way_id=None, **kwargs): class Element(object): - """ Base element """ @@ -492,8 +539,106 @@ def __init__(self, attributes=None, result=None, tags=None): self.tags = tags -class Node(Element): +class Area(Element): + """ + Class to represent an element of type area + """ + + _type_value = "area" + + def __init__(self, area_id=None, **kwargs): + """ + :param area_id: Id of the area element + :type area_id: Integer + :param kwargs: Additional arguments are passed directly to the parent class + """ + + Element.__init__(self, **kwargs) + #: The id of the way + self.id = area_id + + def __repr__(self): + return "".format(self.id) + + @classmethod + def from_json(cls, data, result=None): + """ + Create new Area element from JSON data + + :param data: Element data from JSON + :type data: Dict + :param result: The result this element belongs to + :type result: overpy.Result + :return: New instance of Way + :rtype: overpy.Area + :raises overpy.exception.ElementDataWrongType: If type value of the passed JSON data does not match. + """ + if data.get("type") != cls._type_value: + raise exception.ElementDataWrongType( + type_expected=cls._type_value, + type_provided=data.get("type") + ) + + tags = data.get("tags", {}) + + area_id = data.get("id") + + attributes = {} + ignore = ["id", "tags", "type"] + for n, v in data.items(): + if n in ignore: + continue + attributes[n] = v + + return cls(area_id=area_id, attributes=attributes, tags=tags, result=result) + + @classmethod + def from_xml(cls, child, result=None): + """ + Create new way element from XML data + + :param child: XML node to be parsed + :type child: xml.etree.ElementTree.Element + :param result: The result this node belongs to + :type result: overpy.Result + :return: New Way oject + :rtype: overpy.Way + :raises overpy.exception.ElementDataWrongType: If name of the xml child node doesn't match + :raises ValueError: If the ref attribute of the xml node is not provided + :raises ValueError: If a tag doesn't have a name + """ + if child.tag.lower() != cls._type_value: + raise exception.ElementDataWrongType( + type_expected=cls._type_value, + type_provided=child.tag.lower() + ) + + tags = {} + + for sub_child in child: + if sub_child.tag.lower() == "tag": + name = sub_child.attrib.get("k") + if name is None: + raise ValueError("Tag without name/key.") + value = sub_child.attrib.get("v") + tags[name] = value + + area_id = child.attrib.get("id") + if area_id is not None: + area_id = int(area_id) + + attributes = {} + ignore = ["id"] + for n, v in child.attrib.items(): + if n in ignore: + continue + attributes[n] = v + + return cls(area_id=area_id, attributes=attributes, tags=tags, result=result) + + +class Node(Element): """ Class to represent an element of type node """ @@ -604,7 +749,6 @@ def from_xml(cls, child, result=None): class Way(Element): - """ Class to represent an element of type way """ @@ -627,7 +771,7 @@ def __init__(self, way_id=None, center_lat=None, center_lon=None, node_ids=None, #: List of Ids of the associated nodes self._node_ids = node_ids - + #: The lat/lon of the center of the way (optional depending on query) self.center_lat = center_lat self.center_lon = center_lon @@ -792,7 +936,6 @@ def from_xml(cls, child, result=None): class Relation(Element): - """ Class to represent an element of type relation """ @@ -884,7 +1027,7 @@ def from_xml(cls, child, result=None): tags = {} members = [] - supported_members = [RelationNode, RelationWay, RelationRelation] + supported_members = [RelationNode, RelationWay, RelationRelation, RelationArea] for sub_child in child: if sub_child.tag.lower() == "tag": name = sub_child.attrib.get("k") @@ -918,7 +1061,6 @@ def from_xml(cls, child, result=None): class RelationMember(object): - """ Base class to represent a member of a relation. """ @@ -1014,6 +1156,16 @@ def __repr__(self): return "".format(self.ref, self.role) +class RelationArea(RelationMember): + _type_value = "area" + + def resolve(self, resolve_missing=False): + return self._result.get_area(self.ref, resolve_missing=resolve_missing) + + def __repr__(self): + return "".format(self.ref, self.role) + + class OSMSAXHandler(handler.ContentHandler): """ SAX parser for Overpass XML response. @@ -1075,7 +1227,7 @@ def _handle_start_center(self, attrs): self._curr['center_lat'] = Decimal(attrs['lat']) if attrs.get('lon', None) is not None: self._curr['center_lon'] = Decimal(attrs['lon']) - + def _handle_start_tag(self, attrs): """ Handle opening tag element @@ -1146,6 +1298,29 @@ def _handle_end_way(self): self._result.append(Way(result=self._result, **self._curr)) self._curr = {} + def _handle_start_area(self, attrs): + """ + Handle opening area element + + :param attrs: Attributes of the element + :type attrs: Dict + """ + self._curr = { + 'attributes': dict(attrs), + 'tags': {}, + 'area_id': None + } + if attrs.get('id', None) is not None: + self._curr['area_id'] = int(attrs['id']) + del self._curr['attributes']['id'] + + def _handle_end_area(self): + """ + Handle closing area element + """ + self._result.append(Area(result=self._result, **self._curr)) + self._curr = {} + def _handle_start_nd(self, attrs): """ Handle opening nd element @@ -1200,7 +1375,9 @@ def _handle_start_member(self, attrs): if attrs.get('role', None): params['role'] = attrs['role'] - if attrs['type'] == 'node': + if attrs['type'] == 'area': + self._curr['members'].append(RelationArea(**params)) + elif attrs['type'] == 'node': self._curr['members'].append(RelationNode(**params)) elif attrs['type'] == 'way': self._curr['members'].append(RelationWay(**params)) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..f0a1f6e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,14 @@ +Data +==== + +Queries to get test data. + +Have a look at https://overpass-turbo.eu/ to test the queries. + +area-01 (2016-11-22) +-------------------- + +``` +area[name="Troisdorf"]; +out; +``` \ No newline at end of file diff --git a/tests/base_class.py b/tests/base_class.py index ae5f4e2..a6bdfb0 100644 --- a/tests/base_class.py +++ b/tests/base_class.py @@ -9,6 +9,73 @@ from tests import read_file +class BaseTestAreas(object): + def _test_area01(self, result): + assert len(result.areas) == 4 + assert len(result.nodes) == 0 + assert len(result.relations) == 0 + assert len(result.ways) == 0 + + area = result.areas[0] + + assert isinstance(area, overpy.Area) + assert isinstance(area.id, int) + assert area.id == 2448756446 + + assert isinstance(area.tags, dict) + assert len(area.tags) == 12 + + area = result.areas[1] + + assert isinstance(area, overpy.Area) + assert isinstance(area.id, int) + assert area.id == 3600055060 + + assert isinstance(area.tags, dict) + assert len(area.tags) == 13 + + area = result.areas[2] + assert isinstance(area, overpy.Area) + assert isinstance(area.id, int) + assert area.id == 3605945175 + + assert isinstance(area.tags, dict) + assert len(area.tags) == 12 + + area = result.areas[3] + assert isinstance(area, overpy.Area) + assert isinstance(area.id, int) + assert area.id == 3605945176 + + assert isinstance(area.tags, dict) + assert len(area.tags) == 14 + + # try to get a single area by id + area = result.get_area(3605945175) + assert area.id == 3605945175 + + # try to get a single area by id not available in the result + with pytest.raises(overpy.exception.DataIncomplete): + result.get_area(123456) + + # area_ids is an alias for get_node_ids() and should return the same data + for area_ids in (result.area_ids, result.get_area_ids()): + assert len(area_ids) == 4 + assert area_ids[0] == 2448756446 + assert area_ids[1] == 3600055060 + assert area_ids[2] == 3605945175 + assert area_ids[3] == 3605945176 + + assert len(result.node_ids) == 0 + assert len(result.get_node_ids()) == 0 + + assert len(result.relation_ids) == 0 + assert len(result.get_relation_ids()) == 0 + + assert len(result.way_ids) == 0 + assert len(result.get_way_ids()) == 0 + + class BaseTestNodes(object): def _test_node01(self, result): assert len(result.nodes) == 3 diff --git a/tests/json/area-01.json b/tests/json/area-01.json new file mode 100644 index 0000000..183f2d3 --- /dev/null +++ b/tests/json/area-01.json @@ -0,0 +1,92 @@ +{ + "version": 0.6, + "generator": "Overpass API", + "osm3s": { + "timestamp_osm_base": "2016-11-22T21:04:02Z", + "timestamp_areas_base": "2016-11-22T20:25:03Z", + "copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL." + }, + "elements": [ + +{ + "type": "area", + "id": 2448756446, + "tags": { + "addr:city": "Troisdorf", + "addr:postcode": "53840", + "area": "yes", + "description": "Troisdorf Bahnsteig Gleis 9", + "name": "Troisdorf", + "public_transport": "platform", + "railway": "platform", + "ref": "9", + "train": "yes", + "wheelchair": "no", + "wheelchair:description": "Plattformlift ist vorhanden, Betriebsbereitschaft nach 8 Jahren stillstand fraglich.", + "width": "5" + } +} +, +{ + "type": "area", + "id": 3600055060, + "tags": { + "TMC:cid_58:tabcd_1:Class": "Area", + "TMC:cid_58:tabcd_1:LCLversion": "8.00", + "TMC:cid_58:tabcd_1:LocationCode": "2550", + "admin_level": "8", + "boundary": "administrative", + "de:amtlicher_gemeindeschluessel": "05382068", + "de:place": "town", + "de:regionalschluessel": "053820068068", + "name": "Troisdorf", + "name:prefix": "Stadt", + "type": "boundary", + "wikidata": "Q3900", + "wikipedia": "de:Troisdorf" + } +} +, +{ + "type": "area", + "id": 3605945175, + "tags": { + "addr:city": "Troisdorf", + "addr:postcode": "53840", + "description": "Troisdorf Bahnsteig Gleis 1+2", + "local_ref": "1;2", + "name": "Troisdorf", + "public_transport": "platform", + "railway": "platform", + "ref": "1/2", + "tactile_paving": "yes", + "train": "yes", + "type": "multipolygon", + "wheelchair": "yes" + } +} +, +{ + "type": "area", + "id": 3605945176, + "tags": { + "addr:city": "Troisdorf", + "addr:postcode": "53840", + "description": "Troisdorf Bahnsteig Gleis 5+6", + "name": "Troisdorf", + "phone": "+49 221 1411055", + "public_transport": "platform", + "railway": "platform", + "ref": "5/6", + "tactile_paving": "yes", + "train": "yes", + "type": "multipolygon", + "wheelchair": "yes", + "wheelchair:description": "Der Aufzug zu diesem Bahnsteig ist oft defekt, bitte informieren. Betriebszustand kann bei 3S-Zentrale erfragt werden (siehe Telefonnummer, aber Vorsicht, die sind nicht immer informiert!)", + "width": "6" + } +} + + + ] +} diff --git a/tests/json/result-expand-02.json b/tests/json/result-expand-02.json index 069d3b0..fa6fc11 100644 --- a/tests/json/result-expand-02.json +++ b/tests/json/result-expand-02.json @@ -6,6 +6,10 @@ "copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL." }, "elements": [ + { + "type": "area", + "id": 3605945176 + }, { "type": "node", "id": 3233854233, diff --git a/tests/test_json.py b/tests/test_json.py index b988eb9..60aab04 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -2,10 +2,17 @@ import overpy -from tests.base_class import BaseTestNodes, BaseTestRelation, BaseTestWay +from tests.base_class import BaseTestAreas, BaseTestNodes, BaseTestRelation, BaseTestWay from tests.base_class import read_file +class TestAreas(BaseTestAreas): + def test_area01(self): + api = overpy.Overpass() + result = api.parse_json(read_file("json/area-01.json")) + self._test_area01(result) + + class TestNodes(BaseTestNodes): def test_node01(self): api = overpy.Overpass() diff --git a/tests/test_result.py b/tests/test_result.py index 9831716..0e169af 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -45,6 +45,40 @@ def test_expand_01(self): assert len(result1.ways) == 2 +class TestArea(object): + def test_missing_unresolvable(self): + url, t = new_server_thread(HandleResponseJSON02) + t.start() + + api = overpy.Overpass() + api.url = url + result1 = api.parse_json(read_file("json/result-expand-01.json")) + + with pytest.raises(overpy.exception.DataIncomplete): + result1.get_area(123, resolve_missing=True) + t.join() + + def test_missing_resolvable(self): + url, t = new_server_thread(HandleResponseJSON02) + t.start() + + api = overpy.Overpass() + api.url = url + result1 = api.parse_json(read_file("json/result-expand-01.json")) + + # Node must not be available + with pytest.raises(overpy.exception.DataIncomplete): + result1.get_area(3605945176) + + # Node must be available + area = result1.get_area(3605945176, resolve_missing=True) + + assert isinstance(area, overpy.Area) + assert area.id == 3605945176 + + t.join() + + class TestNode(object): def test_missing_unresolvable(self): url, t = new_server_thread(HandleResponseJSON02) diff --git a/tests/test_xml.py b/tests/test_xml.py index 84623f5..81ad359 100644 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -2,10 +2,21 @@ import overpy -from tests.base_class import BaseTestNodes, BaseTestRelation, BaseTestWay +from tests.base_class import BaseTestAreas, BaseTestNodes, BaseTestRelation, BaseTestWay from tests.base_class import read_file +class TestAreas(BaseTestAreas): + def test_node01(self): + api = overpy.Overpass() + # DOM + result = api.parse_xml(read_file("xml/area-01.xml"), parser=overpy.XML_PARSER_DOM) + self._test_area01(result) + # SAX + result = api.parse_xml(read_file("xml/area-01.xml"), parser=overpy.XML_PARSER_SAX) + self._test_area01(result) + + class TestNodes(BaseTestNodes): def test_node01(self): api = overpy.Overpass() diff --git a/tests/xml/area-01.xml b/tests/xml/area-01.xml new file mode 100644 index 0000000..e831268 --- /dev/null +++ b/tests/xml/area-01.xml @@ -0,0 +1,66 @@ + + +The data included in this document is from www.openstreetmap.org. The data is made available under ODbL. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +