aio_overpass.element

Typed result set members.

  1"""Typed result set members."""
  2
  3import math
  4import re
  5from abc import ABC, abstractmethod
  6from collections import defaultdict
  7from collections.abc import Iterable, Iterator
  8from dataclasses import dataclass
  9from typing import Any, Generic, TypeAlias, TypeVar, cast
 10
 11from aio_overpass import Query
 12
 13import shapely.geometry
 14import shapely.ops
 15from shapely.geometry import LinearRing, LineString, MultiPolygon, Point, Polygon
 16from shapely.geometry.base import BaseGeometry, BaseMultipartGeometry
 17
 18
 19__docformat__ = "google"
 20__all__ = (
 21    "collect_elements",
 22    "Spatial",
 23    "Element",
 24    "Node",
 25    "Way",
 26    "Relation",
 27    "Relationship",
 28    "GeometryDetails",
 29    "Metadata",
 30    "Bbox",
 31    "GeoJsonDict",
 32    "OverpassDict",
 33    "SpatialDict",
 34)
 35
 36GeoJsonDict: TypeAlias = dict[str, Any]
 37"""
 38A dictionary representing a GeoJSON object.
 39"""
 40
 41OverpassDict: TypeAlias = dict[str, Any]
 42"""
 43A dictionary representing a JSON object returned by the Overpass API.
 44"""
 45
 46Bbox: TypeAlias = tuple[float, float, float, float]
 47"""
 48The bounding box of a spatial object.
 49
 50This tuple can be understood as any of
 51    - ``(s, w, n, e)``
 52    - ``(minlat, minlon, maxlat, maxlon)``
 53    - ``(minx, miny, maxx, maxy)``
 54"""
 55
 56
 57@dataclass(kw_only=True, slots=True)
 58class SpatialDict:
 59    """
 60    Mapping of spatial objects with the ``__geo_interface__`` property.
 61
 62    Objects of this class have the ``__geo_interface__`` property following a protocol
 63    [proposed](https://gist.github.com/sgillies/2217756) by Sean Gillies, which can make
 64    it easier to use spatial data in other Python software. An example of this is the ``shape()``
 65    function that builds Shapely geometries from any object with the ``__geo_interface__`` property.
 66
 67    Attributes:
 68        __geo_interface__: this is the proposed property that contains the spatial data
 69    """
 70
 71    __geo_interface__: dict
 72
 73
 74class Spatial(ABC):
 75    """
 76    Base class for (groups of) geospatial objects.
 77
 78    Classes that represent spatial features extend this class and implement the
 79    ``geojson`` property. Exporting objects in the GeoJSON format should make it possible
 80    to integrate them with other tools for visualization, or further analysis.
 81    The ability to re-import the exported GeoJSON structures as ``Spatial`` objects is not
 82    considered here.
 83    """
 84
 85    __slots__ = ("__validated__",)  # we use that field in tests
 86
 87    @property
 88    @abstractmethod
 89    def geojson(self) -> GeoJsonDict:
 90        """
 91        A mapping of this object, using the GeoJSON format.
 92
 93        The coordinate reference system for all GeoJSON coordinates is ``CRS:84``,
 94        which means every coordinate is a tuple of longitude and latitude (in that order)
 95        on the WGS 84 ellipsoid. Note that this order is flipped for all Shapely geometries
 96        that represent OpenStreetMap elements (latitude first, then longitude).
 97
 98        References:
 99            - https://osmdata.openstreetmap.de/info/projections.html
100            - https://tools.ietf.org/html/rfc7946#section-4
101        """
102        raise NotImplementedError
103
104    @property
105    def geo_interfaces(self) -> Iterator[SpatialDict]:
106        """A mapping of this object to ``SpatialDict``s that implement ``__geo_interface__``."""
107        geojson = self.geojson
108        match geojson["type"]:
109            case "FeatureCollection":
110                for feature in geojson["features"]:
111                    yield SpatialDict(__geo_interface__=feature)
112            case _:
113                yield SpatialDict(__geo_interface__=geojson)
114
115
116@dataclass(kw_only=True, slots=True)
117class Metadata:
118    """
119    Metadata concerning the most recent edit of an OSM element.
120
121    Attributes:
122        version: The version number of the element
123        timestamp: Timestamp (ISO 8601) of the most recent change of this element
124        changeset: The changeset in which the element was most recently changed
125        user_name: Name of the user that made the most recent change to the element
126        user_id: ID of the user that made the most recent change to the element
127    """
128
129    version: int
130    timestamp: str
131    changeset: int
132    user_name: str
133    user_id: int
134
135
136G = TypeVar("G", bound=BaseGeometry)
137
138
139@dataclass(kw_only=True, slots=True)
140class GeometryDetails(Generic[G]):
141    """
142    Element geometry with more info on its validity.
143
144    Shapely validity is based on an [OGC standard](https://www.ogc.org/standard/sfa/).
145
146    For MultiPolygons, one assertion is that its elements may only touch at a finite number
147    of Points, which means they may not share an edge on their exteriors. In terms of
148    OSM multipolygons, it makes sense to lift this requirement, and such geometries
149    end up in the ``accepted`` field.
150
151    For invalid MultiPolygon and Polygons, we use Shapely's ``make_valid()``. If and only if
152    the amount of polygons stays the same before and after making them valid,
153    they will end up in the ``valid`` field.
154
155    Attributes:
156        valid: if set, this is the original valid geometry
157        accepted: if set, this is the original geometry that is invalid by Shapely standards,
158                  but accepted by us
159        fixed: if set, this is the geometry fixed by ``make_valid()``
160        invalid: if set, this is the original invalid geometry
161        invalid_reason: if the original geometry is invalid by Shapely standards,
162                        this message states why
163    """
164
165    valid: G | None = None
166    accepted: G | None = None
167    fixed: G | None = None
168    invalid: G | None = None
169    invalid_reason: str | None = None
170
171    @property
172    def best(self) -> G | None:
173        """The "best" geometry, prioritizing ``fixed`` over ``invalid``."""
174        return self.valid or self.accepted or self.fixed or self.invalid
175
176
177@dataclass(kw_only=True, repr=False, eq=False)
178class Element(Spatial):
179    """
180    Elements are the basic components of OpenStreetMap's data.
181
182    A query's result set is made up of these elements.
183
184    Objects of this class do not necessarily describe OSM elements in their entirety.
185    The degrees of detail are decided by the ``out`` statements used in an Overpass query:
186    using ``out ids`` f.e. would only include an element's ID, but not its tags, geometry, etc.
187
188    Element geometries have coordinates in the EPSG:4326 coordinate reference system,
189    meaning that the coordinates are (latitude, longitude) tuples on the WGS 84 reference ellipsoid.
190    The geometries are Shapely objects, where the x/y coordinates refer to lat/lon.
191    Since Shapely works on the Cartesian plane, not all operations are useful: distances between
192    Shapely objects are Euclidean distances for instance - not geodetic distances.
193
194    Tags provide the semantics of elements. There are *classifying* tags, where for a certain key
195    there is a limited number of agreed upon values: the ``highway`` tag f.e. is used to identify
196    any kind of road, street or path, and to classify its importance within the road network.
197    Deviating values are perceived as erroneous. Other tags are *describing*, where any value
198    is acceptable for a certain key: the ``name`` tag is a prominent example for this.
199
200    Objects of this class are not meant to represent "derived elements" of the Overpass API,
201    that can have an entirely different data structure compared to traditional elements.
202    An example of this is the structure produced by ``out count``, but also special statements like
203    ``make`` and ``convert``.
204
205    Attributes:
206        id: A number that uniquely identifies an element of a certain type
207            (nodes, ways and relations each have their own ID space).
208            Note that this ID cannot safely be used to link to a specific object on OSM.
209            OSM IDs may change at any time, e.g. if an object is deleted and re-added.
210        tags: A list of key-value pairs that describe the element, or ``None`` if the element's
211              tags not included in the query's result set.
212        bounds: The bounding box of this element, or ``None`` when not using ``out bb``.
213                The ``bounds`` property of Shapely geometries can be used as a replacement.
214        center: The center of ``bounds``. If you need a coordinate that is inside the element's
215                geometry, consider Shapely's ``representative_point()`` and ``centroid``.
216        meta: Metadata of this element, or ``None`` when not using ``out meta``
217        relations: All relations that are **also in the query's result set**, and that
218                   **are known** to contain this element as a member.
219        geometry: The element's geometry, if available. For the specifics, refer to the
220                  documentation of this property in each subclass.
221
222    References:
223        - https://wiki.openstreetmap.org/wiki/Elements
224        - https://wiki.openstreetmap.org/wiki/Map_features
225        - https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL#out
226        - https://wiki.openstreetmap.org/wiki/Overpass_API/Permanent_ID
227    """
228
229    __slots__ = ()
230
231    id: int
232    tags: OverpassDict | None
233    bounds: Bbox | None
234    center: Point | None
235    meta: Metadata | None
236    relations: list["Relationship"]
237    geometry: BaseGeometry | None
238
239    def tag(self, key: str, default: Any = None) -> Any:
240        """
241        Get the tag value for the given key.
242
243        Returns ``default`` if there is no ``key`` tag.
244
245        References:
246            - https://wiki.openstreetmap.org/wiki/Tags
247        """
248        if not self.tags:
249            return default
250        return self.tags.get(key, default)
251
252    @property
253    def type(self) -> str:
254        """The element's type: "node", "way", or "relation"."""
255        match self:
256            case Node():
257                return "node"
258            case Way():
259                return "way"
260            case Relation():
261                return "relation"
262            case _:
263                raise AssertionError
264
265    @property
266    def link(self) -> str:
267        """This element on openstreetmap.org."""
268        return f"https://www.openstreetmap.org/{self.type}/{self.id}"
269
270    @property
271    def wikidata_id(self) -> str | None:
272        """
273        [Wikidata](https://www.wikidata.org) item ID of this element.
274
275        This is "perhaps, the most stable and reliable manner to obtain Permanent ID
276        of relevant spatial features".
277
278        References:
279            - https://wiki.openstreetmap.org/wiki/Permanent_ID
280            - https://wiki.openstreetmap.org/wiki/Overpass_API/Permanent_ID
281            - https://wiki.openstreetmap.org/wiki/Key:wikidata
282            - https://www.wikidata.org/wiki/Wikidata:Notability
283            - Nodes on Wikidata: https://www.wikidata.org/wiki/Property:P11693
284            - Ways on Wikidata: https://www.wikidata.org/wiki/Property:P10689
285            - Relations on Wikidata: https://www.wikidata.org/wiki/Property:P402
286        """
287        # since tag values are not enforced, use a regex to filter out bad IDs
288        if self.tags and "wikidata" in self.tags and _WIKIDATA_Q_ID.match(self.tags["wikidata"]):
289            return self.tags["wikidata"]
290        return None
291
292    @property
293    def wikidata_link(self) -> str | None:
294        """This element on wikidata.org."""
295        if self.wikidata_id:
296            return f"https://www.wikidata.org/wiki/{self.wikidata_id}"
297        return None
298
299    @property
300    def geojson(self) -> GeoJsonDict:
301        """
302        A mapping of this object, using the GeoJSON format.
303
304        Objects are mapped as the following:
305         - ``Node`` -> ``Feature`` with optional ``Point`` geometry
306         - ``Way`` -> ``Feature`` with optional ``LineString`` or ``Polygon`` geometry
307         - ``Relation`` with geometry -> ``Feature`` with ``Polygon`` or ``MultiPolygon`` geometry
308         - ``Relation`` -> ``FeatureCollection`` (nested ``Relations`` are mapped to unlocated
309           ``Features``)
310
311        ``Feature`` properties contain all the following keys if they are present for the element:
312        ``id``, ``type``, ``role``, ``tags``, ``nodes``, ``bounds``, ``center``, ``timestamp``,
313        ``version``, ``changeset``, ``user``, ``uid``.
314        The JSON object in ``properties`` is therefore very similar to the original JSON object
315        returned by Overpass, skipping only ``geometry``, ``lat`` and ``lon``.
316
317        Additionally, features inside ``FeatureCollections`` receive the special ``__rel__``
318        property. Its value is an object containing all properties of the relation the collection
319        represents. This works around the fact that ``FeatureCollections`` have no ``properties``
320        member. The benefit is that you can take relation tags into consideration when styling
321        and rendering their members on a map (f.e. with Leaflet). The downside is that these
322        properties are duplicated for every feature.
323        """
324        if isinstance(self, Relation) and not self.geometry:
325            return {
326                "type": "FeatureCollection",
327                "features": [_geojson_feature(relship) for relship in self.members],
328            }
329
330        return _geojson_feature(self)
331
332    def __repr__(self) -> str:
333        return f"{type(self).__name__}({self.id})"
334
335
336@dataclass(kw_only=True, slots=True, repr=False, eq=False)
337class Node(Element):
338    """
339    A point in space, at a specific coordinate.
340
341    Nodes are used to define standalone point features (e.g. a bench),
342    or to define the shape or "path" of a way.
343
344    Attributes:
345        geometry: A Point or ``None`` if the coordinate is not included in the query's result set.
346
347    References:
348        - https://wiki.openstreetmap.org/wiki/Node
349    """
350
351    geometry: Point | None
352
353
354@dataclass(kw_only=True, slots=True, repr=False, eq=False)
355class Way(Element):
356    """
357    A way is an ordered list of nodes.
358
359    An open way is a way whose first node is not its last node (e.g. a railway line).
360    A closed way is a way whose first node is also its last node, and may be interpreted either
361    as a closed polyline (e.g. a roundabout), an area (e.g. a patch of grass), or both
362    (e.g. a roundabout surrounding a grassy area).
363
364    Attributes:
365        node_ids: The IDs of the nodes that make up this way, or ``None`` if they are not included
366                  in the query's result set.
367        geometry: A Linestring if the way is open, a LinearRing if the way is closed,
368                  a Polygon if the way is closed and its tags indicate that it represents an area,
369                  or ``None`` if the geometry is not included in the query's result set.
370        geometry_details: More info on the validity of ``geometry``.
371
372    References:
373        - https://wiki.openstreetmap.org/wiki/Way
374    """
375
376    node_ids: list[int] | None
377    geometry: LineString | LinearRing | Polygon | None
378    geometry_details: GeometryDetails[LineString | LinearRing | Polygon] | None
379
380
381@dataclass(kw_only=True, slots=True, repr=False, eq=False)
382class Relation(Element):
383    """
384    A relation is a group of nodes and ways that have a logical or geographic relationship.
385
386    This relationship is described through its tags.
387
388    A relation may define an area geometry, which may have boundaries made up of several
389    unclosed ways. Relations of ``type=multipolygon`` may have boundaries ("outer" role) and
390    holes ("inner" role) made up of several unclosed ways.
391
392    Tags describing the multipolygon always go on the relation. The inner and outer ways are tagged
393    if they describe something in their own right. For example,
394     - a multipolygon relation may be tagged as landuse=forest if it outlines a forest,
395     - its outer ways may be tagged as barrier=fence if the forest is fenced,
396     - and its inner ways may be tagged as natural=water if there is a lake within the forest
397       boundaries.
398
399    Attributes:
400        members: Ordered member elements of this relation, with an optional role
401        geometry: If this relation is deemed to represent an area, these are the complex polygons
402                  whose boundaries and holes are made up of the ways inside the relation. Members
403                  that are not ways, or are not part of any polygon boundary, are not part of the
404                  result geometry. This is ``None`` if the geometry of the relation members is not
405                  included in the query's result set, or if the relation is not deemed to represent
406                  an area.
407        geometry_details: More info on the validity of ``geometry``.
408
409    References:
410        - https://wiki.openstreetmap.org/wiki/Relation
411        - https://wiki.openstreetmap.org/wiki/Relation:multipolygon
412        - https://wiki.openstreetmap.org/wiki/Relation:boundary
413    """
414
415    members: list["Relationship"]
416    geometry: Polygon | MultiPolygon | None
417    geometry_details: GeometryDetails[Polygon | MultiPolygon] | None
418
419    def __iter__(self) -> Iterator[tuple[str | None, Element]]:
420        for relship in self.members:
421            yield relship.role, relship.member
422
423
424@dataclass(kw_only=True, slots=True, repr=False)
425class Relationship(Spatial):
426    """
427    The relationship of an element that is part of a relation, with an optional role.
428
429    Attributes:
430        member:     any element
431        relation:   a relation that the member is a part of
432        role:       describes the function of the member in the context of the relation
433
434    References:
435        - https://wiki.openstreetmap.org/wiki/Relation#Roles
436    """
437
438    member: Element
439    relation: Relation
440    role: str | None
441
442    @property
443    def geojson(self) -> GeoJsonDict:
444        """
445        A mapping of ``member``.
446
447        This is ``member.geojson``, with the added properties ``role`` and ``__rel__``.
448        """
449        return _geojson_feature(self)
450
451    def __repr__(self) -> str:
452        role = f" as '{self.role}'" if self.role else " "
453        return f"{type(self).__name__}({self.member}{role} in {self.relation})"
454
455
456_KNOWN_ELEMENTS = {"node", "way", "relation"}
457
458
459_ElementKey: TypeAlias = tuple[str, int]
460"""Elements are uniquely identified by the tuple (type, id)."""
461
462_MemberKey: TypeAlias = tuple[_ElementKey, str]
463"""Relation members are identified by their element key and role."""
464
465
466class _ElementCollector:
467    __slots__ = (
468        "member_dict",
469        "result_set",
470        "typed_dict",
471        "untyped_dict",
472    )
473
474    def __init__(self) -> None:
475        self.result_set: list[_ElementKey] = []
476        self.typed_dict: dict[_ElementKey, Element] = {}
477        self.untyped_dict: dict[_ElementKey, OverpassDict] = defaultdict(dict)
478        self.member_dict: dict[int, list[_MemberKey]] = defaultdict(list)
479
480
481def collect_elements(query: Query) -> list[Element]:
482    """
483    Produce typed elements from the result set of a query.
484
485    This function exclusively collects elements that are of type "node", "way", or "relation".
486
487    Element data is "conflated", which means that if elements appear more than once in a
488    result set, their data is merged. This is useful f.e. when querying tags for relation members:
489    using ``rel(...); out tags;`` will only print tags for relation itself, not its members.
490    For those you will have to recurse down from the relation, which means members will show
491    up twice in the result set: once untagged as a member of the relation, and once tagged at
492    the top level. This function will have these two occurrences point to the same, single object.
493
494    The order of elements and relation members is retained.
495
496    If you want to query Overpass' "area" elements, you should use the `way(pivot)` or `rel(pivot)`
497    filter to select the element of the chosen type that defines the outline of the given area.
498    Otherwise, they will be ignored.
499
500    Derived elements with other types - f.e. produced by ``make`` and ``convert`` statements
501    or when using ``out count`` - are ignored. If you need to work with other derived elements,
502    you should not use this function.
503
504    Args:
505        query: a finished query
506
507    Returns:
508        the result set as a list of typed elements
509
510    Raises:
511        ValueError: If the input query is unfinished/has no result set.
512        KeyError: The only times there should be missing keys is when either using ``out noids``,
513                  or when building derived elements that are missing common keys.
514    """
515    if not query.done:
516        msg = "query has no result set"
517        raise ValueError(msg)
518
519    collector = _ElementCollector()
520    _collect_untyped(query, collector)
521    _collect_typed(collector)
522    _collect_relationships(collector)
523    return [collector.typed_dict[elem_key] for elem_key in collector.result_set]
524
525
526def _collect_untyped(query: Query, collector: _ElementCollector) -> None:
527    if query.result_set is None:
528        raise AssertionError
529
530    # Here we populate 'untyped_dict' with both top level elements, and
531    # relation members, while conflating their data if they appear as both.
532    # We also populate 'member_dict'.
533    for elem_dict in query.result_set:
534        if elem_dict.get("type") not in _KNOWN_ELEMENTS:
535            continue
536
537        key: _ElementKey = (elem_dict["type"], elem_dict["id"])
538
539        collector.result_set.append(key)
540        collector.untyped_dict[key].update(elem_dict)
541
542        if elem_dict["type"] != "relation":
543            continue
544
545        for mem in elem_dict["members"]:
546            key = (mem["type"], mem["ref"])
547            collector.untyped_dict[key].update(mem)
548            collector.member_dict[elem_dict["id"]].append((key, mem.get("role")))
549
550
551def _collect_typed(collector: _ElementCollector) -> None:
552    for elem_key, elem_dict in collector.untyped_dict.items():
553        (elem_type, elem_id) = elem_key
554
555        geometry = _geometry(elem_dict)
556
557        args = dict(
558            id=elem_id,
559            tags=elem_dict.get("tags"),
560            bounds=tuple(elem_dict["bounds"].values()) if "bounds" in elem_dict else None,
561            center=Point(elem_dict["center"].values()) if "center" in elem_dict else None,
562            meta=Metadata(
563                timestamp=elem_dict["timestamp"],
564                version=elem_dict["version"],
565                changeset=elem_dict["changeset"],
566                user_name=elem_dict["user"],
567                user_id=elem_dict["uid"],
568            )
569            if "timestamp" in elem_dict
570            else None,
571            relations=[],  # add later
572            geometry=geometry,
573        )
574
575        cls: type[Element]
576
577        match elem_type:
578            case "node":
579                cls = Node
580                assert geometry is None or geometry.is_valid
581            case "way":
582                cls = Way
583                args["node_ids"] = elem_dict.get("nodes")
584                args["geometry_details"] = None
585                if geometry and (geometry_details := _try_validate_geometry(geometry)) is not None:
586                    args["geometry_details"] = geometry_details
587                    args["geometry"] = geometry_details.best
588            case "relation":
589                cls = Relation
590                args["members"] = []  # add later
591                args["geometry_details"] = None
592                if geometry and (geometry_details := _try_validate_geometry(geometry)) is not None:
593                    args["geometry_details"] = geometry_details
594                    args["geometry"] = geometry_details.best
595            case _:
596                raise AssertionError
597
598        elem = cls(**args)  # pyright: ignore[reportArgumentType]
599        collector.typed_dict[elem_key] = elem
600
601
602def _collect_relationships(collector: _ElementCollector) -> None:
603    for rel_id, mem_roles in collector.member_dict.items():
604        rel = cast(Relation, collector.typed_dict[("relation", rel_id)])
605
606        for mem_key, mem_role in mem_roles:
607            mem = collector.typed_dict[mem_key]
608            relship = Relationship(member=mem, relation=rel, role=mem_role or None)
609            mem.relations.append(relship)
610            rel.members.append(relship)
611
612
613def _try_validate_geometry(geom: G) -> GeometryDetails[G]:
614    if geom.is_valid:
615        return GeometryDetails(valid=geom)
616
617    invalid_reason = shapely.is_valid_reason(geom)
618
619    if invalid_reason.startswith("Self-intersection") and isinstance(geom, MultiPolygon):
620        # we allow self-intersecting multi-polygons, if
621        # (1) the intersection is just lines or points, and
622        # (2) all the polygons inside are valid
623        intersection = shapely.intersection_all(geom.geoms)
624        accept = not isinstance(intersection, Polygon | MultiPolygon) and all(
625            poly.is_valid for poly in geom.geoms
626        )
627
628        if accept:
629            return GeometryDetails(accepted=geom, invalid_reason=invalid_reason)
630
631        return GeometryDetails(invalid=geom, invalid_reason=invalid_reason)
632
633    if isinstance(geom, Polygon):
634        valid_polygons = [g for g in _flatten(shapely.make_valid(geom)) if isinstance(g, Polygon)]
635        if len(valid_polygons) == 1:
636            return GeometryDetails(
637                fixed=valid_polygons[0],
638                invalid=geom,
639                invalid_reason=invalid_reason,
640            )
641
642    if isinstance(geom, MultiPolygon):
643        valid_polygons = [g for g in _flatten(shapely.make_valid(geom)) if isinstance(g, Polygon)]
644        if len(valid_polygons) == len(geom.geoms):
645            return GeometryDetails(
646                fixed=MultiPolygon(valid_polygons),
647                invalid=geom,
648                invalid_reason=invalid_reason,
649            )
650
651    return GeometryDetails(invalid=geom, invalid_reason=invalid_reason)
652
653
654def _geometry(raw_elem: OverpassDict) -> BaseGeometry | None:
655    """
656    Construct the geometry a given OSM element makes up.
657
658    Args:
659        raw_elem: an element from a query's result set
660
661    Returns:
662        - None if there is no geometry available for this element.
663        - Point when given a node.
664        - LineString when given an open way.
665        - LinearRing when given a closed way, that supposedly is *not* an area.
666        - Polygon when given a closed way, that supposedly is an area.
667        - (Multi-)Polygons containing given a (multipolygon) relation.
668          Relation members that are not ways, or are not part of any polygon boundary, are
669          not part of the result geometry.
670
671    Raises:
672        ValueError: if element is not of type 'node', 'way', 'relation', or 'area'
673    """
674    if raw_elem.get("type") not in _KNOWN_ELEMENTS:
675        msg = "expected element of type 'node', 'way', 'relation', or 'area'"
676        raise ValueError(msg)
677
678    if raw_elem["type"] == "node":
679        lat, lon = raw_elem.get("lat"), raw_elem.get("lon")
680        if lat and lon:
681            return Point(lat, lon)
682
683    if raw_elem["type"] == "way":
684        ls = _line(raw_elem)
685        if ls and ls.is_ring and _is_area_element(raw_elem):
686            return Polygon(ls)
687        return ls
688
689    if _is_area_element(raw_elem):
690        outers = (
691            ls
692            for ls in (
693                _line(mem) for mem in raw_elem.get("members", ()) if mem.get("role") == "outer"
694            )
695            if ls
696        )
697        inners = (
698            ls
699            for ls in (
700                _line(mem) for mem in raw_elem.get("members", ()) if mem.get("role") == "inner"
701            )
702            if ls
703        )
704
705        shells = [ls for ls in _flatten(shapely.ops.linemerge(outers)) if ls.is_closed]
706        holes = [ls for ls in _flatten(shapely.ops.linemerge(inners)) if ls.is_closed]
707
708        polys = [
709            Polygon(shell=shell, holes=[hole for hole in holes if shell.contains(hole)])
710            for shell in shells
711        ]
712
713        if len(polys) == 1:
714            return polys[0]
715
716        return MultiPolygon(polys)
717
718    return None
719
720
721def _line(way: OverpassDict) -> LineString | LinearRing | None:
722    """Returns the geometry of a way in the result set."""
723    if "geometry" not in way or len(way["geometry"]) < 2:
724        return None
725    is_ring = way["geometry"][0] == way["geometry"][-1]
726    cls = LinearRing if is_ring else LineString
727    return cls((c["lat"], c["lon"]) for c in way["geometry"])
728
729
730def _flatten(obj: BaseGeometry) -> Iterable[BaseGeometry]:
731    """Recursively flattens multipart geometries."""
732    if isinstance(obj, BaseMultipartGeometry):
733        return (nested for contained in obj.geoms for nested in _flatten(contained))
734    return (obj,)
735
736
737def _is_area_element(el: OverpassDict) -> bool:
738    """
739    Decide if ``el`` likely represents an area, and should be viewed as a (multi-)polygon.
740
741    Args:
742        el: a way or relation from a query's result set
743
744    Returns:
745        ``False`` if the input is not a relation or closed way.
746        ``False``, unless there are tags which indicate that the way represents an area.
747
748    References:
749        - https://wiki.openstreetmap.org/wiki/Overpass_API/Areas
750        - https://github.com/drolbr/Overpass-API/blob/master/src/rules/areas.osm3s
751          (from 2018-04-09)
752        - https://wiki.openstreetmap.org/wiki/Overpass_turbo/Polygon_Features
753        - https://github.com/tyrasd/osm-polygon-features/blob/master/polygon-features.json
754          (from 2016-11-03)
755    """
756    # Check if the element is explicitly an area
757    if el["type"] == "area":
758        return True
759
760    # Check if a given way is open
761    if el["type"] == "way" and ("geometry" not in el or el["geometry"][0] != el["geometry"][-1]):
762        return False
763
764    # Assume not an area if there are no tags available
765    if "tags" not in el:
766        return False
767
768    tags = el["tags"]
769
770    # Check if the element is explicitly tagged as not area
771    if tags.get("area") == "no":
772        return False
773
774    # Check if there is a tag where any value other than 'no' suggests area
775    # (note: Overpass may require the "name" tag as well)
776    if any(tags.get(name, "no") != "no" for name in _AREA_TAG_NAMES):
777        return True
778
779    # Check if there are tag values that suggest area
780    # (note: Overpass may require the "name" tag as well)
781    return any(
782        (
783            v in _AREA_TAG_VALUES_ONE_OF.get(k, ())
784            or v not in _AREA_TAG_VALUES_NONE_OF.get(k, (v,))
785            for k, v in tags.items()
786        )
787    )
788
789
790_AREA_TAG_NAMES = {
791    "area",
792    "area:highway",
793    "amenity",
794    "boundary",
795    "building",
796    "building:part",
797    "craft",
798    "golf",
799    "historic",
800    "indoor",
801    "landuse",
802    "leisure",
803    "military",
804    "office",
805    "place",
806    "public_transport",
807    "ruins",
808    "shop",
809    "tourism",
810    # for relations
811    "admin_level",
812    "postal_code",
813    "addr:postcode",
814}
815
816_AREA_TAG_VALUES_ONE_OF = {
817    "barrier": {"city_wall", "ditch", "hedge", "retaining_wall", "wall", "spikes"},
818    "highway": {"services", "rest_area", "escape", "elevator"},
819    "power": {"plant", "substation", "generator", "transformer"},
820    "railway": {"station", "turntable", "roundhouse", "platform"},
821    "waterway": {"riverbank", "dock", "boatyard", "dam"},
822    # for relations
823    "type": {"multipolygon"},
824}
825
826_AREA_TAG_VALUES_NONE_OF = {
827    "aeroway": {"no", "taxiway"},
828    "man_made": {"no", "cutline", "embankment", "pipeline"},
829    "natural": {"no", "coastline", "cliff", "ridge", "arete", "tree_row"},
830}
831
832_WIKIDATA_Q_ID = re.compile(r"^Q\d+$")
833
834
835def _geojson_properties(obj: Element | Relationship) -> GeoJsonDict:
836    elem = obj if isinstance(obj, Element) else obj.member
837
838    properties = {
839        "id": elem.id,
840        "type": elem.type,
841        "tags": elem.tags,
842        "bounds": elem.bounds,
843        "center": elem.center.coords[0] if elem.center else None,
844        "timestamp": elem.meta.timestamp if elem.meta else None,
845        "version": elem.meta.version if elem.meta else None,
846        "changeset": elem.meta.changeset if elem.meta else None,
847        "user": elem.meta.user_name if elem.meta else None,
848        "uid": elem.meta.user_id if elem.meta else None,
849        "nodes": getattr(elem, "nodes", None),
850    }
851
852    properties = {k: v for k, v in properties.items() if v is not None}
853
854    if isinstance(obj, Relationship):
855        properties["role"] = obj.role or ""
856        properties["__rel__"] = _geojson_properties(obj.relation)
857
858    return properties
859
860
861def _geojson_geometry(obj: Element | Relationship) -> GeoJsonDict | None:
862    elem = obj if isinstance(obj, Element) else obj.member
863
864    if not elem.geometry:
865        return None
866
867    # Flip coordinates for GeoJSON compliance.
868    geom = shapely.ops.transform(lambda lat, lon: (lon, lat), elem.geometry)
869
870    # GeoJSON-like mapping that implements __geo_interface__.
871    mapping = shapely.geometry.mapping(geom)
872
873    # This geometry does not exist in GeoJSON.
874    if mapping["type"] == "LinearRing":
875        mapping["type"] = "LineString"
876
877    return mapping
878
879
880def _geojson_bbox(obj: Element | Relationship) -> Bbox | None:
881    elem = obj if isinstance(obj, Element) else obj.member
882
883    geom = elem.geometry
884    if not geom:
885        return elem.bounds
886
887    bounds = geom.bounds  # can be (nan, nan, nan, nan)
888    if not any(math.isnan(c) for c in bounds):
889        (minlat, minlon, maxlat, maxlon) = bounds
890        return minlon, minlat, maxlon, maxlat
891
892    return None
893
894
895def _geojson_feature(obj: Element | Relationship) -> GeoJsonDict:
896    feature = {
897        "type": "Feature",
898        "geometry": _geojson_geometry(obj),
899        "properties": _geojson_properties(obj),
900    }
901
902    bbox = _geojson_bbox(obj)
903    if bbox:
904        feature["bbox"] = bbox  # type: ignore
905
906    return feature
def collect_elements(query: aio_overpass.query.Query) -> list[Element]:
482def collect_elements(query: Query) -> list[Element]:
483    """
484    Produce typed elements from the result set of a query.
485
486    This function exclusively collects elements that are of type "node", "way", or "relation".
487
488    Element data is "conflated", which means that if elements appear more than once in a
489    result set, their data is merged. This is useful f.e. when querying tags for relation members:
490    using ``rel(...); out tags;`` will only print tags for relation itself, not its members.
491    For those you will have to recurse down from the relation, which means members will show
492    up twice in the result set: once untagged as a member of the relation, and once tagged at
493    the top level. This function will have these two occurrences point to the same, single object.
494
495    The order of elements and relation members is retained.
496
497    If you want to query Overpass' "area" elements, you should use the `way(pivot)` or `rel(pivot)`
498    filter to select the element of the chosen type that defines the outline of the given area.
499    Otherwise, they will be ignored.
500
501    Derived elements with other types - f.e. produced by ``make`` and ``convert`` statements
502    or when using ``out count`` - are ignored. If you need to work with other derived elements,
503    you should not use this function.
504
505    Args:
506        query: a finished query
507
508    Returns:
509        the result set as a list of typed elements
510
511    Raises:
512        ValueError: If the input query is unfinished/has no result set.
513        KeyError: The only times there should be missing keys is when either using ``out noids``,
514                  or when building derived elements that are missing common keys.
515    """
516    if not query.done:
517        msg = "query has no result set"
518        raise ValueError(msg)
519
520    collector = _ElementCollector()
521    _collect_untyped(query, collector)
522    _collect_typed(collector)
523    _collect_relationships(collector)
524    return [collector.typed_dict[elem_key] for elem_key in collector.result_set]

Produce typed elements from the result set of a query.

This function exclusively collects elements that are of type "node", "way", or "relation".

Element data is "conflated", which means that if elements appear more than once in a result set, their data is merged. This is useful f.e. when querying tags for relation members: using rel(...); out tags; will only print tags for relation itself, not its members. For those you will have to recurse down from the relation, which means members will show up twice in the result set: once untagged as a member of the relation, and once tagged at the top level. This function will have these two occurrences point to the same, single object.

The order of elements and relation members is retained.

If you want to query Overpass' "area" elements, you should use the way(pivot) or rel(pivot) filter to select the element of the chosen type that defines the outline of the given area. Otherwise, they will be ignored.

Derived elements with other types - f.e. produced by make and convert statements or when using out count - are ignored. If you need to work with other derived elements, you should not use this function.

Arguments:
  • query: a finished query
Returns:

the result set as a list of typed elements

Raises:
  • ValueError: If the input query is unfinished/has no result set.
  • KeyError: The only times there should be missing keys is when either using out noids, or when building derived elements that are missing common keys.
class Spatial(abc.ABC):
 75class Spatial(ABC):
 76    """
 77    Base class for (groups of) geospatial objects.
 78
 79    Classes that represent spatial features extend this class and implement the
 80    ``geojson`` property. Exporting objects in the GeoJSON format should make it possible
 81    to integrate them with other tools for visualization, or further analysis.
 82    The ability to re-import the exported GeoJSON structures as ``Spatial`` objects is not
 83    considered here.
 84    """
 85
 86    __slots__ = ("__validated__",)  # we use that field in tests
 87
 88    @property
 89    @abstractmethod
 90    def geojson(self) -> GeoJsonDict:
 91        """
 92        A mapping of this object, using the GeoJSON format.
 93
 94        The coordinate reference system for all GeoJSON coordinates is ``CRS:84``,
 95        which means every coordinate is a tuple of longitude and latitude (in that order)
 96        on the WGS 84 ellipsoid. Note that this order is flipped for all Shapely geometries
 97        that represent OpenStreetMap elements (latitude first, then longitude).
 98
 99        References:
100            - https://osmdata.openstreetmap.de/info/projections.html
101            - https://tools.ietf.org/html/rfc7946#section-4
102        """
103        raise NotImplementedError
104
105    @property
106    def geo_interfaces(self) -> Iterator[SpatialDict]:
107        """A mapping of this object to ``SpatialDict``s that implement ``__geo_interface__``."""
108        geojson = self.geojson
109        match geojson["type"]:
110            case "FeatureCollection":
111                for feature in geojson["features"]:
112                    yield SpatialDict(__geo_interface__=feature)
113            case _:
114                yield SpatialDict(__geo_interface__=geojson)

Base class for (groups of) geospatial objects.

Classes that represent spatial features extend this class and implement the geojson property. Exporting objects in the GeoJSON format should make it possible to integrate them with other tools for visualization, or further analysis. The ability to re-import the exported GeoJSON structures as Spatial objects is not considered here.

geojson: dict[str, typing.Any]
 88    @property
 89    @abstractmethod
 90    def geojson(self) -> GeoJsonDict:
 91        """
 92        A mapping of this object, using the GeoJSON format.
 93
 94        The coordinate reference system for all GeoJSON coordinates is ``CRS:84``,
 95        which means every coordinate is a tuple of longitude and latitude (in that order)
 96        on the WGS 84 ellipsoid. Note that this order is flipped for all Shapely geometries
 97        that represent OpenStreetMap elements (latitude first, then longitude).
 98
 99        References:
100            - https://osmdata.openstreetmap.de/info/projections.html
101            - https://tools.ietf.org/html/rfc7946#section-4
102        """
103        raise NotImplementedError

A mapping of this object, using the GeoJSON format.

The coordinate reference system for all GeoJSON coordinates is CRS:84, which means every coordinate is a tuple of longitude and latitude (in that order) on the WGS 84 ellipsoid. Note that this order is flipped for all Shapely geometries that represent OpenStreetMap elements (latitude first, then longitude).

References:
geo_interfaces: collections.abc.Iterator[SpatialDict]
105    @property
106    def geo_interfaces(self) -> Iterator[SpatialDict]:
107        """A mapping of this object to ``SpatialDict``s that implement ``__geo_interface__``."""
108        geojson = self.geojson
109        match geojson["type"]:
110            case "FeatureCollection":
111                for feature in geojson["features"]:
112                    yield SpatialDict(__geo_interface__=feature)
113            case _:
114                yield SpatialDict(__geo_interface__=geojson)

A mapping of this object to SpatialDicts that implement __geo_interface__.

@dataclass(kw_only=True, repr=False, eq=False)
class Element(Spatial):
178@dataclass(kw_only=True, repr=False, eq=False)
179class Element(Spatial):
180    """
181    Elements are the basic components of OpenStreetMap's data.
182
183    A query's result set is made up of these elements.
184
185    Objects of this class do not necessarily describe OSM elements in their entirety.
186    The degrees of detail are decided by the ``out`` statements used in an Overpass query:
187    using ``out ids`` f.e. would only include an element's ID, but not its tags, geometry, etc.
188
189    Element geometries have coordinates in the EPSG:4326 coordinate reference system,
190    meaning that the coordinates are (latitude, longitude) tuples on the WGS 84 reference ellipsoid.
191    The geometries are Shapely objects, where the x/y coordinates refer to lat/lon.
192    Since Shapely works on the Cartesian plane, not all operations are useful: distances between
193    Shapely objects are Euclidean distances for instance - not geodetic distances.
194
195    Tags provide the semantics of elements. There are *classifying* tags, where for a certain key
196    there is a limited number of agreed upon values: the ``highway`` tag f.e. is used to identify
197    any kind of road, street or path, and to classify its importance within the road network.
198    Deviating values are perceived as erroneous. Other tags are *describing*, where any value
199    is acceptable for a certain key: the ``name`` tag is a prominent example for this.
200
201    Objects of this class are not meant to represent "derived elements" of the Overpass API,
202    that can have an entirely different data structure compared to traditional elements.
203    An example of this is the structure produced by ``out count``, but also special statements like
204    ``make`` and ``convert``.
205
206    Attributes:
207        id: A number that uniquely identifies an element of a certain type
208            (nodes, ways and relations each have their own ID space).
209            Note that this ID cannot safely be used to link to a specific object on OSM.
210            OSM IDs may change at any time, e.g. if an object is deleted and re-added.
211        tags: A list of key-value pairs that describe the element, or ``None`` if the element's
212              tags not included in the query's result set.
213        bounds: The bounding box of this element, or ``None`` when not using ``out bb``.
214                The ``bounds`` property of Shapely geometries can be used as a replacement.
215        center: The center of ``bounds``. If you need a coordinate that is inside the element's
216                geometry, consider Shapely's ``representative_point()`` and ``centroid``.
217        meta: Metadata of this element, or ``None`` when not using ``out meta``
218        relations: All relations that are **also in the query's result set**, and that
219                   **are known** to contain this element as a member.
220        geometry: The element's geometry, if available. For the specifics, refer to the
221                  documentation of this property in each subclass.
222
223    References:
224        - https://wiki.openstreetmap.org/wiki/Elements
225        - https://wiki.openstreetmap.org/wiki/Map_features
226        - https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL#out
227        - https://wiki.openstreetmap.org/wiki/Overpass_API/Permanent_ID
228    """
229
230    __slots__ = ()
231
232    id: int
233    tags: OverpassDict | None
234    bounds: Bbox | None
235    center: Point | None
236    meta: Metadata | None
237    relations: list["Relationship"]
238    geometry: BaseGeometry | None
239
240    def tag(self, key: str, default: Any = None) -> Any:
241        """
242        Get the tag value for the given key.
243
244        Returns ``default`` if there is no ``key`` tag.
245
246        References:
247            - https://wiki.openstreetmap.org/wiki/Tags
248        """
249        if not self.tags:
250            return default
251        return self.tags.get(key, default)
252
253    @property
254    def type(self) -> str:
255        """The element's type: "node", "way", or "relation"."""
256        match self:
257            case Node():
258                return "node"
259            case Way():
260                return "way"
261            case Relation():
262                return "relation"
263            case _:
264                raise AssertionError
265
266    @property
267    def link(self) -> str:
268        """This element on openstreetmap.org."""
269        return f"https://www.openstreetmap.org/{self.type}/{self.id}"
270
271    @property
272    def wikidata_id(self) -> str | None:
273        """
274        [Wikidata](https://www.wikidata.org) item ID of this element.
275
276        This is "perhaps, the most stable and reliable manner to obtain Permanent ID
277        of relevant spatial features".
278
279        References:
280            - https://wiki.openstreetmap.org/wiki/Permanent_ID
281            - https://wiki.openstreetmap.org/wiki/Overpass_API/Permanent_ID
282            - https://wiki.openstreetmap.org/wiki/Key:wikidata
283            - https://www.wikidata.org/wiki/Wikidata:Notability
284            - Nodes on Wikidata: https://www.wikidata.org/wiki/Property:P11693
285            - Ways on Wikidata: https://www.wikidata.org/wiki/Property:P10689
286            - Relations on Wikidata: https://www.wikidata.org/wiki/Property:P402
287        """
288        # since tag values are not enforced, use a regex to filter out bad IDs
289        if self.tags and "wikidata" in self.tags and _WIKIDATA_Q_ID.match(self.tags["wikidata"]):
290            return self.tags["wikidata"]
291        return None
292
293    @property
294    def wikidata_link(self) -> str | None:
295        """This element on wikidata.org."""
296        if self.wikidata_id:
297            return f"https://www.wikidata.org/wiki/{self.wikidata_id}"
298        return None
299
300    @property
301    def geojson(self) -> GeoJsonDict:
302        """
303        A mapping of this object, using the GeoJSON format.
304
305        Objects are mapped as the following:
306         - ``Node`` -> ``Feature`` with optional ``Point`` geometry
307         - ``Way`` -> ``Feature`` with optional ``LineString`` or ``Polygon`` geometry
308         - ``Relation`` with geometry -> ``Feature`` with ``Polygon`` or ``MultiPolygon`` geometry
309         - ``Relation`` -> ``FeatureCollection`` (nested ``Relations`` are mapped to unlocated
310           ``Features``)
311
312        ``Feature`` properties contain all the following keys if they are present for the element:
313        ``id``, ``type``, ``role``, ``tags``, ``nodes``, ``bounds``, ``center``, ``timestamp``,
314        ``version``, ``changeset``, ``user``, ``uid``.
315        The JSON object in ``properties`` is therefore very similar to the original JSON object
316        returned by Overpass, skipping only ``geometry``, ``lat`` and ``lon``.
317
318        Additionally, features inside ``FeatureCollections`` receive the special ``__rel__``
319        property. Its value is an object containing all properties of the relation the collection
320        represents. This works around the fact that ``FeatureCollections`` have no ``properties``
321        member. The benefit is that you can take relation tags into consideration when styling
322        and rendering their members on a map (f.e. with Leaflet). The downside is that these
323        properties are duplicated for every feature.
324        """
325        if isinstance(self, Relation) and not self.geometry:
326            return {
327                "type": "FeatureCollection",
328                "features": [_geojson_feature(relship) for relship in self.members],
329            }
330
331        return _geojson_feature(self)
332
333    def __repr__(self) -> str:
334        return f"{type(self).__name__}({self.id})"

Elements are the basic components of OpenStreetMap's data.

A query's result set is made up of these elements.

Objects of this class do not necessarily describe OSM elements in their entirety. The degrees of detail are decided by the out statements used in an Overpass query: using out ids f.e. would only include an element's ID, but not its tags, geometry, etc.

Element geometries have coordinates in the EPSG:4326 coordinate reference system, meaning that the coordinates are (latitude, longitude) tuples on the WGS 84 reference ellipsoid. The geometries are Shapely objects, where the x/y coordinates refer to lat/lon. Since Shapely works on the Cartesian plane, not all operations are useful: distances between Shapely objects are Euclidean distances for instance - not geodetic distances.

Tags provide the semantics of elements. There are classifying tags, where for a certain key there is a limited number of agreed upon values: the highway tag f.e. is used to identify any kind of road, street or path, and to classify its importance within the road network. Deviating values are perceived as erroneous. Other tags are describing, where any value is acceptable for a certain key: the name tag is a prominent example for this.

Objects of this class are not meant to represent "derived elements" of the Overpass API, that can have an entirely different data structure compared to traditional elements. An example of this is the structure produced by out count, but also special statements like make and convert.

Attributes:
  • id: A number that uniquely identifies an element of a certain type (nodes, ways and relations each have their own ID space). Note that this ID cannot safely be used to link to a specific object on OSM. OSM IDs may change at any time, e.g. if an object is deleted and re-added.
  • tags: A list of key-value pairs that describe the element, or None if the element's tags not included in the query's result set.
  • bounds: The bounding box of this element, or None when not using out bb. The bounds property of Shapely geometries can be used as a replacement.
  • center: The center of bounds. If you need a coordinate that is inside the element's geometry, consider Shapely's representative_point() and centroid.
  • meta: Metadata of this element, or None when not using out meta
  • relations: All relations that are also in the query's result set, and that are known to contain this element as a member.
  • geometry: The element's geometry, if available. For the specifics, refer to the documentation of this property in each subclass.
References:
Element( *, id: int, tags: dict[str, typing.Any] | None, bounds: tuple[float, float, float, float] | None, center: shapely.geometry.point.Point | None, meta: Metadata | None, relations: list[Relationship], geometry: shapely.geometry.base.BaseGeometry | None)
id: int
tags: dict[str, typing.Any] | None
bounds: tuple[float, float, float, float] | None
center: shapely.geometry.point.Point | None
meta: Metadata | None
relations: list[Relationship]
geometry: shapely.geometry.base.BaseGeometry | None
def tag(self, key: str, default: Any = None) -> Any:
240    def tag(self, key: str, default: Any = None) -> Any:
241        """
242        Get the tag value for the given key.
243
244        Returns ``default`` if there is no ``key`` tag.
245
246        References:
247            - https://wiki.openstreetmap.org/wiki/Tags
248        """
249        if not self.tags:
250            return default
251        return self.tags.get(key, default)

Get the tag value for the given key.

Returns default if there is no key tag.

References:
type: str
253    @property
254    def type(self) -> str:
255        """The element's type: "node", "way", or "relation"."""
256        match self:
257            case Node():
258                return "node"
259            case Way():
260                return "way"
261            case Relation():
262                return "relation"
263            case _:
264                raise AssertionError

The element's type: "node", "way", or "relation".

wikidata_id: str | None
271    @property
272    def wikidata_id(self) -> str | None:
273        """
274        [Wikidata](https://www.wikidata.org) item ID of this element.
275
276        This is "perhaps, the most stable and reliable manner to obtain Permanent ID
277        of relevant spatial features".
278
279        References:
280            - https://wiki.openstreetmap.org/wiki/Permanent_ID
281            - https://wiki.openstreetmap.org/wiki/Overpass_API/Permanent_ID
282            - https://wiki.openstreetmap.org/wiki/Key:wikidata
283            - https://www.wikidata.org/wiki/Wikidata:Notability
284            - Nodes on Wikidata: https://www.wikidata.org/wiki/Property:P11693
285            - Ways on Wikidata: https://www.wikidata.org/wiki/Property:P10689
286            - Relations on Wikidata: https://www.wikidata.org/wiki/Property:P402
287        """
288        # since tag values are not enforced, use a regex to filter out bad IDs
289        if self.tags and "wikidata" in self.tags and _WIKIDATA_Q_ID.match(self.tags["wikidata"]):
290            return self.tags["wikidata"]
291        return None
geojson: dict[str, typing.Any]
300    @property
301    def geojson(self) -> GeoJsonDict:
302        """
303        A mapping of this object, using the GeoJSON format.
304
305        Objects are mapped as the following:
306         - ``Node`` -> ``Feature`` with optional ``Point`` geometry
307         - ``Way`` -> ``Feature`` with optional ``LineString`` or ``Polygon`` geometry
308         - ``Relation`` with geometry -> ``Feature`` with ``Polygon`` or ``MultiPolygon`` geometry
309         - ``Relation`` -> ``FeatureCollection`` (nested ``Relations`` are mapped to unlocated
310           ``Features``)
311
312        ``Feature`` properties contain all the following keys if they are present for the element:
313        ``id``, ``type``, ``role``, ``tags``, ``nodes``, ``bounds``, ``center``, ``timestamp``,
314        ``version``, ``changeset``, ``user``, ``uid``.
315        The JSON object in ``properties`` is therefore very similar to the original JSON object
316        returned by Overpass, skipping only ``geometry``, ``lat`` and ``lon``.
317
318        Additionally, features inside ``FeatureCollections`` receive the special ``__rel__``
319        property. Its value is an object containing all properties of the relation the collection
320        represents. This works around the fact that ``FeatureCollections`` have no ``properties``
321        member. The benefit is that you can take relation tags into consideration when styling
322        and rendering their members on a map (f.e. with Leaflet). The downside is that these
323        properties are duplicated for every feature.
324        """
325        if isinstance(self, Relation) and not self.geometry:
326            return {
327                "type": "FeatureCollection",
328                "features": [_geojson_feature(relship) for relship in self.members],
329            }
330
331        return _geojson_feature(self)

A mapping of this object, using the GeoJSON format.

Objects are mapped as the following:
  • Node -> Feature with optional Point geometry
  • Way -> Feature with optional LineString or Polygon geometry
  • Relation with geometry -> Feature with Polygon or MultiPolygon geometry
  • Relation -> FeatureCollection (nested Relations are mapped to unlocated Features)

Feature properties contain all the following keys if they are present for the element: id, type, role, tags, nodes, bounds, center, timestamp, version, changeset, user, uid. The JSON object in properties is therefore very similar to the original JSON object returned by Overpass, skipping only geometry, lat and lon.

Additionally, features inside FeatureCollections receive the special __rel__ property. Its value is an object containing all properties of the relation the collection represents. This works around the fact that FeatureCollections have no properties member. The benefit is that you can take relation tags into consideration when styling and rendering their members on a map (f.e. with Leaflet). The downside is that these properties are duplicated for every feature.

Inherited Members
Spatial
geo_interfaces
@dataclass(kw_only=True, slots=True, repr=False, eq=False)
class Node(Element):
337@dataclass(kw_only=True, slots=True, repr=False, eq=False)
338class Node(Element):
339    """
340    A point in space, at a specific coordinate.
341
342    Nodes are used to define standalone point features (e.g. a bench),
343    or to define the shape or "path" of a way.
344
345    Attributes:
346        geometry: A Point or ``None`` if the coordinate is not included in the query's result set.
347
348    References:
349        - https://wiki.openstreetmap.org/wiki/Node
350    """
351
352    geometry: Point | None

A point in space, at a specific coordinate.

Nodes are used to define standalone point features (e.g. a bench), or to define the shape or "path" of a way.

Attributes:
  • geometry: A Point or None if the coordinate is not included in the query's result set.
References:
Node( *, id: int, tags: dict[str, typing.Any] | None, bounds: tuple[float, float, float, float] | None, center: shapely.geometry.point.Point | None, meta: Metadata | None, relations: list[Relationship], geometry: shapely.geometry.point.Point | None)
geometry: shapely.geometry.point.Point | None
id: int
tags: dict[str, typing.Any] | None
bounds: tuple[float, float, float, float] | None
center: shapely.geometry.point.Point | None
meta: Metadata | None
relations: list[Relationship]
@dataclass(kw_only=True, slots=True, repr=False, eq=False)
class Way(Element):
355@dataclass(kw_only=True, slots=True, repr=False, eq=False)
356class Way(Element):
357    """
358    A way is an ordered list of nodes.
359
360    An open way is a way whose first node is not its last node (e.g. a railway line).
361    A closed way is a way whose first node is also its last node, and may be interpreted either
362    as a closed polyline (e.g. a roundabout), an area (e.g. a patch of grass), or both
363    (e.g. a roundabout surrounding a grassy area).
364
365    Attributes:
366        node_ids: The IDs of the nodes that make up this way, or ``None`` if they are not included
367                  in the query's result set.
368        geometry: A Linestring if the way is open, a LinearRing if the way is closed,
369                  a Polygon if the way is closed and its tags indicate that it represents an area,
370                  or ``None`` if the geometry is not included in the query's result set.
371        geometry_details: More info on the validity of ``geometry``.
372
373    References:
374        - https://wiki.openstreetmap.org/wiki/Way
375    """
376
377    node_ids: list[int] | None
378    geometry: LineString | LinearRing | Polygon | None
379    geometry_details: GeometryDetails[LineString | LinearRing | Polygon] | None

A way is an ordered list of nodes.

An open way is a way whose first node is not its last node (e.g. a railway line). A closed way is a way whose first node is also its last node, and may be interpreted either as a closed polyline (e.g. a roundabout), an area (e.g. a patch of grass), or both (e.g. a roundabout surrounding a grassy area).

Attributes:
  • node_ids: The IDs of the nodes that make up this way, or None if they are not included in the query's result set.
  • geometry: A Linestring if the way is open, a LinearRing if the way is closed, a Polygon if the way is closed and its tags indicate that it represents an area, or None if the geometry is not included in the query's result set.
  • geometry_details: More info on the validity of geometry.
References:
Way( *, id: int, tags: dict[str, typing.Any] | None, bounds: tuple[float, float, float, float] | None, center: shapely.geometry.point.Point | None, meta: Metadata | None, relations: list[Relationship], geometry: shapely.geometry.linestring.LineString | shapely.geometry.polygon.LinearRing | shapely.geometry.polygon.Polygon | None, node_ids: list[int] | None, geometry_details: Optional[GeometryDetails[shapely.geometry.linestring.LineString | shapely.geometry.polygon.LinearRing | shapely.geometry.polygon.Polygon]])
node_ids: list[int] | None
geometry: shapely.geometry.linestring.LineString | shapely.geometry.polygon.LinearRing | shapely.geometry.polygon.Polygon | None
geometry_details: Optional[GeometryDetails[shapely.geometry.linestring.LineString | shapely.geometry.polygon.LinearRing | shapely.geometry.polygon.Polygon]]
id: int
tags: dict[str, typing.Any] | None
bounds: tuple[float, float, float, float] | None
center: shapely.geometry.point.Point | None
meta: Metadata | None
relations: list[Relationship]
@dataclass(kw_only=True, slots=True, repr=False, eq=False)
class Relation(Element):
382@dataclass(kw_only=True, slots=True, repr=False, eq=False)
383class Relation(Element):
384    """
385    A relation is a group of nodes and ways that have a logical or geographic relationship.
386
387    This relationship is described through its tags.
388
389    A relation may define an area geometry, which may have boundaries made up of several
390    unclosed ways. Relations of ``type=multipolygon`` may have boundaries ("outer" role) and
391    holes ("inner" role) made up of several unclosed ways.
392
393    Tags describing the multipolygon always go on the relation. The inner and outer ways are tagged
394    if they describe something in their own right. For example,
395     - a multipolygon relation may be tagged as landuse=forest if it outlines a forest,
396     - its outer ways may be tagged as barrier=fence if the forest is fenced,
397     - and its inner ways may be tagged as natural=water if there is a lake within the forest
398       boundaries.
399
400    Attributes:
401        members: Ordered member elements of this relation, with an optional role
402        geometry: If this relation is deemed to represent an area, these are the complex polygons
403                  whose boundaries and holes are made up of the ways inside the relation. Members
404                  that are not ways, or are not part of any polygon boundary, are not part of the
405                  result geometry. This is ``None`` if the geometry of the relation members is not
406                  included in the query's result set, or if the relation is not deemed to represent
407                  an area.
408        geometry_details: More info on the validity of ``geometry``.
409
410    References:
411        - https://wiki.openstreetmap.org/wiki/Relation
412        - https://wiki.openstreetmap.org/wiki/Relation:multipolygon
413        - https://wiki.openstreetmap.org/wiki/Relation:boundary
414    """
415
416    members: list["Relationship"]
417    geometry: Polygon | MultiPolygon | None
418    geometry_details: GeometryDetails[Polygon | MultiPolygon] | None
419
420    def __iter__(self) -> Iterator[tuple[str | None, Element]]:
421        for relship in self.members:
422            yield relship.role, relship.member

A relation is a group of nodes and ways that have a logical or geographic relationship.

This relationship is described through its tags.

A relation may define an area geometry, which may have boundaries made up of several unclosed ways. Relations of type=multipolygon may have boundaries ("outer" role) and holes ("inner" role) made up of several unclosed ways.

Tags describing the multipolygon always go on the relation. The inner and outer ways are tagged if they describe something in their own right. For example,

  • a multipolygon relation may be tagged as landuse=forest if it outlines a forest,
  • its outer ways may be tagged as barrier=fence if the forest is fenced,
  • and its inner ways may be tagged as natural=water if there is a lake within the forest boundaries.
Attributes:
  • members: Ordered member elements of this relation, with an optional role
  • geometry: If this relation is deemed to represent an area, these are the complex polygons whose boundaries and holes are made up of the ways inside the relation. Members that are not ways, or are not part of any polygon boundary, are not part of the result geometry. This is None if the geometry of the relation members is not included in the query's result set, or if the relation is not deemed to represent an area.
  • geometry_details: More info on the validity of geometry.
References:
Relation( *, id: int, tags: dict[str, typing.Any] | None, bounds: tuple[float, float, float, float] | None, center: shapely.geometry.point.Point | None, meta: Metadata | None, relations: list[Relationship], geometry: shapely.geometry.polygon.Polygon | shapely.geometry.multipolygon.MultiPolygon | None, members: list[Relationship], geometry_details: Optional[GeometryDetails[shapely.geometry.polygon.Polygon | shapely.geometry.multipolygon.MultiPolygon]])
members: list[Relationship]
geometry: shapely.geometry.polygon.Polygon | shapely.geometry.multipolygon.MultiPolygon | None
geometry_details: Optional[GeometryDetails[shapely.geometry.polygon.Polygon | shapely.geometry.multipolygon.MultiPolygon]]
id: int
tags: dict[str, typing.Any] | None
bounds: tuple[float, float, float, float] | None
center: shapely.geometry.point.Point | None
meta: Metadata | None
relations: list[Relationship]
@dataclass(kw_only=True, slots=True, repr=False)
class Relationship(Spatial):
425@dataclass(kw_only=True, slots=True, repr=False)
426class Relationship(Spatial):
427    """
428    The relationship of an element that is part of a relation, with an optional role.
429
430    Attributes:
431        member:     any element
432        relation:   a relation that the member is a part of
433        role:       describes the function of the member in the context of the relation
434
435    References:
436        - https://wiki.openstreetmap.org/wiki/Relation#Roles
437    """
438
439    member: Element
440    relation: Relation
441    role: str | None
442
443    @property
444    def geojson(self) -> GeoJsonDict:
445        """
446        A mapping of ``member``.
447
448        This is ``member.geojson``, with the added properties ``role`` and ``__rel__``.
449        """
450        return _geojson_feature(self)
451
452    def __repr__(self) -> str:
453        role = f" as '{self.role}'" if self.role else " "
454        return f"{type(self).__name__}({self.member}{role} in {self.relation})"

The relationship of an element that is part of a relation, with an optional role.

Attributes:
  • member: any element
  • relation: a relation that the member is a part of
  • role: describes the function of the member in the context of the relation
References:
Relationship( *, member: Element, relation: Relation, role: str | None)
member: Element
relation: Relation
role: str | None
geojson: dict[str, typing.Any]
443    @property
444    def geojson(self) -> GeoJsonDict:
445        """
446        A mapping of ``member``.
447
448        This is ``member.geojson``, with the added properties ``role`` and ``__rel__``.
449        """
450        return _geojson_feature(self)

A mapping of member.

This is member.geojson, with the added properties role and __rel__.

Inherited Members
Spatial
geo_interfaces
@dataclass(kw_only=True, slots=True)
class GeometryDetails(typing.Generic[~G]):
140@dataclass(kw_only=True, slots=True)
141class GeometryDetails(Generic[G]):
142    """
143    Element geometry with more info on its validity.
144
145    Shapely validity is based on an [OGC standard](https://www.ogc.org/standard/sfa/).
146
147    For MultiPolygons, one assertion is that its elements may only touch at a finite number
148    of Points, which means they may not share an edge on their exteriors. In terms of
149    OSM multipolygons, it makes sense to lift this requirement, and such geometries
150    end up in the ``accepted`` field.
151
152    For invalid MultiPolygon and Polygons, we use Shapely's ``make_valid()``. If and only if
153    the amount of polygons stays the same before and after making them valid,
154    they will end up in the ``valid`` field.
155
156    Attributes:
157        valid: if set, this is the original valid geometry
158        accepted: if set, this is the original geometry that is invalid by Shapely standards,
159                  but accepted by us
160        fixed: if set, this is the geometry fixed by ``make_valid()``
161        invalid: if set, this is the original invalid geometry
162        invalid_reason: if the original geometry is invalid by Shapely standards,
163                        this message states why
164    """
165
166    valid: G | None = None
167    accepted: G | None = None
168    fixed: G | None = None
169    invalid: G | None = None
170    invalid_reason: str | None = None
171
172    @property
173    def best(self) -> G | None:
174        """The "best" geometry, prioritizing ``fixed`` over ``invalid``."""
175        return self.valid or self.accepted or self.fixed or self.invalid

Element geometry with more info on its validity.

Shapely validity is based on an OGC standard.

For MultiPolygons, one assertion is that its elements may only touch at a finite number of Points, which means they may not share an edge on their exteriors. In terms of OSM multipolygons, it makes sense to lift this requirement, and such geometries end up in the accepted field.

For invalid MultiPolygon and Polygons, we use Shapely's make_valid(). If and only if the amount of polygons stays the same before and after making them valid, they will end up in the valid field.

Attributes:
  • valid: if set, this is the original valid geometry
  • accepted: if set, this is the original geometry that is invalid by Shapely standards, but accepted by us
  • fixed: if set, this is the geometry fixed by make_valid()
  • invalid: if set, this is the original invalid geometry
  • invalid_reason: if the original geometry is invalid by Shapely standards, this message states why
GeometryDetails( *, valid: Optional[~G] = None, accepted: Optional[~G] = None, fixed: Optional[~G] = None, invalid: Optional[~G] = None, invalid_reason: str | None = None)
valid: Optional[~G]
accepted: Optional[~G]
fixed: Optional[~G]
invalid: Optional[~G]
invalid_reason: str | None
best: Optional[~G]
172    @property
173    def best(self) -> G | None:
174        """The "best" geometry, prioritizing ``fixed`` over ``invalid``."""
175        return self.valid or self.accepted or self.fixed or self.invalid

The "best" geometry, prioritizing fixed over invalid.

@dataclass(kw_only=True, slots=True)
class Metadata:
117@dataclass(kw_only=True, slots=True)
118class Metadata:
119    """
120    Metadata concerning the most recent edit of an OSM element.
121
122    Attributes:
123        version: The version number of the element
124        timestamp: Timestamp (ISO 8601) of the most recent change of this element
125        changeset: The changeset in which the element was most recently changed
126        user_name: Name of the user that made the most recent change to the element
127        user_id: ID of the user that made the most recent change to the element
128    """
129
130    version: int
131    timestamp: str
132    changeset: int
133    user_name: str
134    user_id: int

Metadata concerning the most recent edit of an OSM element.

Attributes:
  • version: The version number of the element
  • timestamp: Timestamp (ISO 8601) of the most recent change of this element
  • changeset: The changeset in which the element was most recently changed
  • user_name: Name of the user that made the most recent change to the element
  • user_id: ID of the user that made the most recent change to the element
Metadata( *, version: int, timestamp: str, changeset: int, user_name: str, user_id: int)
version: int
timestamp: str
changeset: int
user_name: str
user_id: int
Bbox: TypeAlias = tuple[float, float, float, float]

The bounding box of a spatial object.

This tuple can be understood as any of - (s, w, n, e) - (minlat, minlon, maxlat, maxlon) - (minx, miny, maxx, maxy)

GeoJsonDict: TypeAlias = dict[str, typing.Any]

A dictionary representing a GeoJSON object.

OverpassDict: TypeAlias = dict[str, typing.Any]

A dictionary representing a JSON object returned by the Overpass API.

@dataclass(kw_only=True, slots=True)
class SpatialDict:
58@dataclass(kw_only=True, slots=True)
59class SpatialDict:
60    """
61    Mapping of spatial objects with the ``__geo_interface__`` property.
62
63    Objects of this class have the ``__geo_interface__`` property following a protocol
64    [proposed](https://gist.github.com/sgillies/2217756) by Sean Gillies, which can make
65    it easier to use spatial data in other Python software. An example of this is the ``shape()``
66    function that builds Shapely geometries from any object with the ``__geo_interface__`` property.
67
68    Attributes:
69        __geo_interface__: this is the proposed property that contains the spatial data
70    """
71
72    __geo_interface__: dict

Mapping of spatial objects with the __geo_interface__ property.

Objects of this class have the __geo_interface__ property following a protocol proposed by Sean Gillies, which can make it easier to use spatial data in other Python software. An example of this is the shape() function that builds Shapely geometries from any object with the __geo_interface__ property.

Attributes:
  • __geo_interface__: this is the proposed property that contains the spatial data
SpatialDict(*, __geo_interface__: dict)