overturetoosm.objects

Pydantic models needed throughout the project.

  1"""Pydantic models needed throughout the project."""
  2
  3# ruff: noqa: D415
  4
  5from enum import Enum
  6
  7try:
  8    from typing import Annotated
  9except ImportError:
 10    from typing import Annotated
 11
 12from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator
 13
 14from .resources import places_tags
 15
 16
 17class OvertureBaseModel(BaseModel):
 18    """Base model for Overture features."""
 19
 20    model_config = ConfigDict(extra="forbid")
 21
 22    version: int = Field(ge=0)
 23    theme: str | None = None
 24    type: str | None = None
 25    id: str | None = Field(None, pattern=r"^(\S.*)?\S$")
 26
 27
 28class Wikidata(RootModel):
 29    """Model for transportation segment wikidata."""
 30
 31    root: str = Field(description="Wikidata ID.", pattern=r"^Q\d+")
 32
 33
 34class Sources(BaseModel):
 35    """Overture sources model."""
 36
 37    property: str
 38    dataset: str
 39    license: str | None = None
 40    record_id: str | None = None
 41    confidence: float | None = Field(ge=0.0, le=1.0)
 42    update_time: str | None = Field(
 43        pattern=r"^([1-9]\d{3})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):([0-5]\d):([0-5]\d|60)(\.\d{1,3})?(Z|[-+]([01]\d|2[0-3]):[0-5]\d)$"
 44    )
 45
 46    @field_validator("confidence")
 47    @classmethod
 48    def set_default_if_none(cls, v: float) -> float:
 49        """@private"""
 50        return v if v is not None else 0.0
 51
 52    def get_osm_link(self) -> str | None:
 53        """Return the OSM link for the source."""
 54        if (
 55            self.record_id
 56            and self.record_id.startswith(("n", "w", "r"))
 57            and self.dataset == "OpenStreetMap"
 58        ):
 59            type_dict = {"n": "node", "w": "way", "r": "relation"}
 60            return f"https://www.openstreetmap.org/{type_dict[self.record_id[0]]}/{self.record_id[1:]}"
 61
 62
 63class RulesVariant(str, Enum):
 64    """Overture name rules variant model."""
 65
 66    alternate = "alternate"
 67    common = "common"
 68    official = "official"
 69    short = "short"
 70
 71
 72class Between(RootModel):
 73    """Model for transportation segment between."""
 74
 75    root: Annotated[list, Field(float, min_length=2, max_length=2)]
 76
 77
 78class Mode(str, Enum):
 79    """Model for political perspectives from which a named feature is viewed."""
 80
 81    accepted_by = "accepted_by"
 82    disputed_by = "disputed_by"
 83
 84
 85class Perspectives(BaseModel):
 86    """Model for political perspectives from which a named feature is viewed."""
 87
 88    mode: Mode
 89    countries: list[str] = Field(min_length=1)
 90
 91
 92class Rules(BaseModel):
 93    """Overture name rules model."""
 94
 95    variant: RulesVariant
 96    language: str | None = None
 97    value: str
 98    between: Between | None = None
 99    side: str | None = None
100    perspectives: Perspectives | None = None
101
102
103class Names(BaseModel):
104    """Overture names model."""
105
106    primary: str
107    common: list[tuple[str, str]] | None
108    rules: list[Rules] | None
109
110    def to_osm(self) -> dict[str, str]:
111        """Convert names to OSM tags."""
112        names = {}
113        if self.primary:
114            names["name"] = self.primary
115
116        return names
117
118
119class PlaceAddress(BaseModel):
120    """Overture addresses model."""
121
122    freeform: str | None
123    locality: str | None
124    postcode: str | None
125    region: str | None
126    country: str | None = Field(pattern=r"^[A-Z]{2}$")
127
128    def to_osm(self, region_tag: str) -> dict[str, str]:
129        """Convert address to OSM tags."""
130        address_info = {}
131        if self.freeform:
132            address_info["addr:full"] = self.freeform
133        if self.country:
134            address_info["addr:country"] = self.country
135        if self.postcode:
136            address_info["addr:postcode"] = self.postcode
137        if self.locality:
138            address_info["addr:city"] = self.locality
139        if self.region:
140            address_info[region_tag] = self.region
141
142        return address_info
143
144
145class Categories(BaseModel):
146    """Overture categories model."""
147
148    primary: str
149    alternate: list[str] | None
150
151    def to_osm(self, unmatched: str) -> dict[str, str]:
152        """Convert categories to OSM tags."""
153        prim = places_tags.get(self.primary)
154        if prim:
155            return prim
156        elif unmatched == "force":
157            return {"type": self.primary}
158        elif unmatched == "error":
159            raise UnmatchedError(self.primary)
160        return {}
161
162
163class Brand(BaseModel):
164    """Overture brand model."""
165
166    wikidata: Wikidata | None = None
167    names: Names
168
169    def to_osm(self) -> dict[str, str]:
170        """Convert brand properties to OSM tags."""
171        osm = {"brand": self.names.primary}
172        if self.wikidata:
173            osm.update({"brand:wikidata": str(self.wikidata.root)})
174        return osm
175
176
177class Socials(RootModel):
178    """Overture socials model."""
179
180    root: list[str]
181
182    def to_osm(self) -> dict[str, str]:
183        """Convert socials properties to OSM tags."""
184        new_props = {}
185        for social in self.root:
186            if "facebook" in social:
187                new_props["contact:facebook"] = social
188            elif "twitter" in str(social):
189                new_props["contact:twitter"] = social
190        return new_props
191
192
193class OperatingStatus(str, Enum):
194    """Enum for place operating status."""
195
196    open = "open"
197    permanently_closed = "permanently_closed"
198    temporarily_closed = "temporarily_closed"
199
200
201class PlaceProps(OvertureBaseModel):
202    """Overture properties model.
203
204    Use this model directly if you want to manipulate the `place` properties yourself.
205    """
206
207    model_config = ConfigDict(extra="ignore")
208
209    sources: list[Sources]
210    names: Names
211    brand: Brand | None = None
212    categories: Categories | None = None
213    basic_category: str | None = Field(pattern=r"^[a-z0-9]+(_[a-z0-9]+)*$")
214    confidence: float = Field(ge=0.0, le=1.0)
215    websites: list[str | None] | None = None
216    socials: Socials | None = None
217    emails: list[str | None] | None = None
218    phones: list[str | None] | None = None
219    addresses: list[PlaceAddress]
220    operating_status: OperatingStatus | None = None
221
222    def _validate_license(self, required_license: str | None) -> None:
223        """Validate that sources meet license requirements.
224
225        Args:
226            required_license: The required license string (e.g., "CDLA").
227                             If None, no validation is performed.
228
229        Raises:
230            LicenseError: If no sources have the required license and at least
231                         one source has a non-null license.
232        """
233        if required_license is None:
234            return
235
236        found_licenses = [s.license for s in self.sources if s.license is not None]
237
238        # If all licenses are null, pass the validation
239        if not found_licenses:
240            return
241
242        # Check if any source has the required license
243        for source in self.sources:
244            if source.license and required_license in source.license:
245                return
246
247        # If we get here, no source has the required license
248        raise LicenseError(required_license, found_licenses)
249
250    def to_osm(
251        self,
252        confidence: float,
253        region_tag: str,
254        unmatched: str,
255        required_license: str | None = None,
256    ) -> dict[str, str]:
257        """Convert Overture's place properties to OSM tags.
258
259        Used internally by the `overturetoosm.process_place` function.
260        """
261        if self.confidence < confidence:
262            raise ConfidenceError(confidence, self.confidence)
263
264        self._validate_license(required_license)
265
266        new_props = {}
267
268        # Categories
269        if self.categories:
270            new_props.update(self.categories.to_osm(unmatched))
271
272        # Names
273        if self.names:
274            new_props.update(self.names.to_osm())
275
276        # Contact information
277        new_props.update(self._process_contact_info())
278
279        # Addresses
280        if self.addresses:
281            new_props.update(self.addresses[0].to_osm(region_tag))
282
283        # Sources
284        new_props["source"] = source_statement(self.sources)
285
286        # Socials and Brand
287        if self.socials:
288            new_props.update(self.socials.to_osm())
289        if self.brand:
290            new_props.update(self.brand.to_osm())
291
292        return new_props
293
294    def _process_contact_info(self) -> dict[str, str]:
295        """Process contact information."""
296        contact_info = {}
297        if not is_none_or_list_of_nones(self.phones):
298            contact_info["phone"] = self.phones[0]
299        if not is_none_or_list_of_nones(self.websites):
300            contact_info["website"] = str(self.websites[0])
301        if not is_none_or_list_of_nones(self.emails):
302            contact_info["email"] = self.emails[0]
303        return contact_info
304
305
306class ConfidenceError(Exception):
307    """Confidence error exception.
308
309    This exception is raised when the confidence level of an item is below the
310    user-defined level. It contains the original confidence level and the confidence
311    level of the item.
312
313    Attributes:
314        confidence_level (float): The set confidence level.
315        confidence_item (float): The confidence of the item.
316        message (str): The error message.
317    """
318
319    def __init__(
320        self,
321        confidence_level: float,
322        confidence_item: float,
323        message: str = "Confidence in this item is too low.",
324    ) -> None:
325        """@private"""
326        self.confidence_level = confidence_level
327        self.confidence_item = confidence_item
328        self.message = message
329        super().__init__(message)
330
331    def __str__(self) -> str:
332        """@private"""
333        lev = f"confidence_level={self.confidence_level}"
334        item = f"confidence_item={self.confidence_item}"
335        return f"""{self.message} {lev}, {item}"""
336
337
338class UnmatchedError(Exception):
339    """Unmatched category error.
340
341    This exception is raised when an item's Overture category does not have a
342    corresponding OSM definition. Edit
343    [the OSM Wiki page](https://wiki.openstreetmap.org/wiki/Overture_categories)
344    to add a definition to this category.
345
346    Attributes:
347        category (str): The Overture category that is unmatched.
348        message (str): The error message.
349    """
350
351    def __init__(
352        self, category: str, message: str = "Overture category is unmatched."
353    ) -> None:
354        """@private"""
355        self.category = category
356        self.message = message
357        super().__init__(message)
358
359    def __str__(self) -> str:
360        """@private"""
361        return f"{self.message} {{category={self.category}}}"
362
363
364class LicenseError(Exception):
365    """License compatibility error.
366
367    This exception is raised when a feature's source licenses do not meet
368    the required license criteria.
369
370    Attributes:
371        required_license (str): The required license string.
372        found_licenses (list[str]): The licenses found in the sources.
373        message (str): The error message.
374    """
375
376    def __init__(
377        self,
378        required_license: str,
379        found_licenses: list[str],
380        message: str = "Feature does not meet license requirements.",
381    ) -> None:
382        """@private"""
383        self.required_license = required_license
384        self.found_licenses = found_licenses
385        self.message = message
386        super().__init__(message)
387
388    def __str__(self) -> str:
389        """@private"""
390        return f"{self.message} {{required_license={self.required_license}, found_licenses={self.found_licenses}}}"
391
392
393class BuildingProps(OvertureBaseModel):
394    """Overture building properties.
395
396    Use this model if you want to manipulate the `building` properties yourself.
397    """
398
399    has_parts: bool
400    sources: list[Sources]
401    class_: str | None = Field(alias="class", default=None)
402    subtype: str | None = None
403    names: Names | None = None
404    level: int | None = None
405    height: float | None = None
406    is_underground: bool | None = None
407    num_floors: int | None = Field(serialization_alias="building:levels", default=None)
408    num_floors_underground: int | None = Field(
409        serialization_alias="building:levels:underground", default=None
410    )
411    min_height: float | None = None
412    min_floor: int | None = Field(
413        serialization_alias="building:min_level", default=None
414    )
415    facade_color: str | None = Field(
416        serialization_alias="building:colour", default=None
417    )
418    facade_material: str | None = Field(
419        serialization_alias="building:material", default=None
420    )
421    roof_material: str | None = Field(serialization_alias="roof:material", default=None)
422    roof_shape: str | None = Field(serialization_alias="roof:shape", default=None)
423    roof_direction: str | None = Field(
424        serialization_alias="roof:direction", default=None
425    )
426    roof_orientation: str | None = Field(
427        serialization_alias="roof:orientation", default=None
428    )
429    roof_color: str | None = Field(serialization_alias="roof:colour", default=None)
430    roof_height: float | None = Field(serialization_alias="roof:height", default=None)
431
432    def _validate_license(self, required_license: str | None) -> None:
433        """Validate that sources meet license requirements.
434
435        Args:
436            required_license: The required license string (e.g., "CDLA").
437                             If None, no validation is performed.
438
439        Raises:
440            LicenseError: If no sources have the required license and at least
441                         one source has a non-null license.
442        """
443        if required_license is None:
444            return
445
446        found_licenses = [s.license for s in self.sources if s.license is not None]
447
448        # If all licenses are null, pass the validation
449        if not found_licenses:
450            return
451
452        # Check if any source has the required license
453        for source in self.sources:
454            if source.license and required_license in source.license:
455                return
456
457        # If we get here, no source has the required license
458        raise LicenseError(required_license, found_licenses)
459
460    def to_osm(
461        self, confidence: float, required_license: str | None = None
462    ) -> dict[str, str]:
463        """Convert properties to OSM tags.
464
465        Used internally by`overturetoosm.process_building` function.
466        """
467        new_props = {}
468        confidences = {source.confidence for source in self.sources}
469        if any(conf and conf < confidence for conf in confidences):
470            raise ConfidenceError(confidence, max({i for i in confidences if i}))
471
472        self._validate_license(required_license)
473
474        new_props["building"] = self.class_ if self.class_ else "yes"
475
476        new_props["source"] = source_statement(self.sources)
477
478        prop_obj = self.model_dump(exclude_none=True, by_alias=True).items()
479        new_props.update(
480            {k: v for k, v in prop_obj if k.startswith(("roof", "building"))}
481        )
482        new_props.update({k: round(v, 2) for k, v in prop_obj if k.endswith("height")})
483
484        if self.is_underground:
485            new_props["location"] = "underground"
486        if self.names:
487            new_props["name"] = self.names.primary
488        return new_props
489
490
491class AddressLevel(BaseModel):
492    """Overture address level model."""
493
494    value: str
495
496
497class AddressProps(OvertureBaseModel):
498    """Overture address properties.
499
500    Use this model directly if you want to manipulate the `address` properties yourself.
501    """
502
503    number: str | None = Field(serialization_alias="addr:housenumber", default=None)
504    street: str | None = Field(serialization_alias="addr:street", default=None)
505    unit: str | None = Field(serialization_alias="addr:unit", default=None)
506    postcode: str | None = Field(serialization_alias="addr:postcode", default=None)
507    postal_city: str | None = Field(serialization_alias="addr:city", default=None)
508    country: str | None = Field(serialization_alias="addr:country", default=None)
509    address_levels: (
510        None | (Annotated[list[AddressLevel], Field(min_length=1, max_length=5)])
511    ) = Field(default_factory=list)
512    sources: list[Sources]
513
514    def _validate_license(self, required_license: str | None) -> None:
515        """Validate that sources meet license requirements.
516
517        Args:
518            required_license: The required license string (e.g., "CDLA").
519                             If None, no validation is performed.
520
521        Raises:
522            LicenseError: If no sources have the required license and at least
523                         one source has a non-null license.
524        """
525        if required_license is None:
526            return
527
528        found_licenses = [s.license for s in self.sources if s.license is not None]
529
530        # If all licenses are null, pass the validation
531        if not found_licenses:
532            return
533
534        # Check if any source has the required license
535        for source in self.sources:
536            if source.license and required_license in source.license:
537                return
538
539        # If we get here, no source has the required license
540        raise LicenseError(required_license, found_licenses)
541
542    def to_osm(self, style: str, required_license: str | None = None) -> dict[str, str]:
543        """Convert properties to OSM tags.
544
545        Used internally by `overturetoosm.process_address`.
546        """
547        self._validate_license(required_license)
548
549        obj_dict = {
550            k: v
551            for k, v in self.model_dump(exclude_none=True, by_alias=True).items()
552            if k.startswith("addr:")
553        }
554        obj_dict["source"] = source_statement(self.sources)
555
556        if self.address_levels and len(self.address_levels) > 0 and style == "US":
557            obj_dict["addr:state"] = str(self.address_levels[0].value)
558
559        return obj_dict
560
561
562def source_statement(source: list[Sources]) -> str:
563    """Return a source statement from a list of sources."""
564    return (
565        ", ".join(sorted({i.dataset.strip(", ") for i in source}))
566        + " via overturetoosm"
567    )
568
569
570def is_none_or_list_of_nones(value) -> bool:
571    """Check whether a given value is either None or a list containing only None values.
572
573    Args:
574        value: The value to check. Can be of any type.
575
576    Returns:
577        bool: True if the value is None or a list containing only None values,
578              False otherwise.
579    """
580    if value is None:
581        return True
582
583    if isinstance(value, list):
584        return len(value) > 0 and all(item is None for item in value)
585
586    return False
class OvertureBaseModel(pydantic.main.BaseModel):
18class OvertureBaseModel(BaseModel):
19    """Base model for Overture features."""
20
21    model_config = ConfigDict(extra="forbid")
22
23    version: int = Field(ge=0)
24    theme: str | None = None
25    type: str | None = None
26    id: str | None = Field(None, pattern=r"^(\S.*)?\S$")

Base model for Overture features.

version: int = PydanticUndefined
theme: str | None = None
type: str | None = None
id: str | None = None
class Wikidata(pydantic.main.BaseModel, typing.Generic[~RootModelRootType]):
29class Wikidata(RootModel):
30    """Model for transportation segment wikidata."""
31
32    root: str = Field(description="Wikidata ID.", pattern=r"^Q\d+")

Model for transportation segment wikidata.

root: str = PydanticUndefined

Wikidata ID.

class Sources(pydantic.main.BaseModel):
35class Sources(BaseModel):
36    """Overture sources model."""
37
38    property: str
39    dataset: str
40    license: str | None = None
41    record_id: str | None = None
42    confidence: float | None = Field(ge=0.0, le=1.0)
43    update_time: str | None = Field(
44        pattern=r"^([1-9]\d{3})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):([0-5]\d):([0-5]\d|60)(\.\d{1,3})?(Z|[-+]([01]\d|2[0-3]):[0-5]\d)$"
45    )
46
47    @field_validator("confidence")
48    @classmethod
49    def set_default_if_none(cls, v: float) -> float:
50        """@private"""
51        return v if v is not None else 0.0
52
53    def get_osm_link(self) -> str | None:
54        """Return the OSM link for the source."""
55        if (
56            self.record_id
57            and self.record_id.startswith(("n", "w", "r"))
58            and self.dataset == "OpenStreetMap"
59        ):
60            type_dict = {"n": "node", "w": "way", "r": "relation"}
61            return f"https://www.openstreetmap.org/{type_dict[self.record_id[0]]}/{self.record_id[1:]}"

Overture sources model.

property: str = PydanticUndefined
dataset: str = PydanticUndefined
license: str | None = None
record_id: str | None = None
confidence: float | None = PydanticUndefined
update_time: str | None = PydanticUndefined
class RulesVariant(builtins.str, enum.Enum):
64class RulesVariant(str, Enum):
65    """Overture name rules variant model."""
66
67    alternate = "alternate"
68    common = "common"
69    official = "official"
70    short = "short"

Overture name rules variant model.

alternate = <RulesVariant.alternate: 'alternate'>
common = <RulesVariant.common: 'common'>
official = <RulesVariant.official: 'official'>
short = <RulesVariant.short: 'short'>
class Between(pydantic.main.BaseModel, typing.Generic[~RootModelRootType]):
73class Between(RootModel):
74    """Model for transportation segment between."""
75
76    root: Annotated[list, Field(float, min_length=2, max_length=2)]

Model for transportation segment between.

root: Annotated[list, FieldInfo(annotation=NoneType, required=False, default=<class 'float'>, metadata=[MinLen(min_length=2), MaxLen(max_length=2)])] = <class 'float'>
class Mode(builtins.str, enum.Enum):
79class Mode(str, Enum):
80    """Model for political perspectives from which a named feature is viewed."""
81
82    accepted_by = "accepted_by"
83    disputed_by = "disputed_by"

Model for political perspectives from which a named feature is viewed.

accepted_by = <Mode.accepted_by: 'accepted_by'>
disputed_by = <Mode.disputed_by: 'disputed_by'>
class Perspectives(pydantic.main.BaseModel):
86class Perspectives(BaseModel):
87    """Model for political perspectives from which a named feature is viewed."""
88
89    mode: Mode
90    countries: list[str] = Field(min_length=1)

Model for political perspectives from which a named feature is viewed.

mode: Mode = PydanticUndefined
countries: list[str] = PydanticUndefined
class Rules(pydantic.main.BaseModel):
 93class Rules(BaseModel):
 94    """Overture name rules model."""
 95
 96    variant: RulesVariant
 97    language: str | None = None
 98    value: str
 99    between: Between | None = None
100    side: str | None = None
101    perspectives: Perspectives | None = None

Overture name rules model.

variant: RulesVariant = PydanticUndefined
language: str | None = None
value: str = PydanticUndefined
between: Between | None = None
side: str | None = None
perspectives: Perspectives | None = None
class Names(pydantic.main.BaseModel):
104class Names(BaseModel):
105    """Overture names model."""
106
107    primary: str
108    common: list[tuple[str, str]] | None
109    rules: list[Rules] | None
110
111    def to_osm(self) -> dict[str, str]:
112        """Convert names to OSM tags."""
113        names = {}
114        if self.primary:
115            names["name"] = self.primary
116
117        return names

Overture names model.

primary: str = PydanticUndefined
common: list[tuple[str, str]] | None = PydanticUndefined
rules: list[Rules] | None = PydanticUndefined
def to_osm(self) -> dict[str, str]:
111    def to_osm(self) -> dict[str, str]:
112        """Convert names to OSM tags."""
113        names = {}
114        if self.primary:
115            names["name"] = self.primary
116
117        return names

Convert names to OSM tags.

class PlaceAddress(pydantic.main.BaseModel):
120class PlaceAddress(BaseModel):
121    """Overture addresses model."""
122
123    freeform: str | None
124    locality: str | None
125    postcode: str | None
126    region: str | None
127    country: str | None = Field(pattern=r"^[A-Z]{2}$")
128
129    def to_osm(self, region_tag: str) -> dict[str, str]:
130        """Convert address to OSM tags."""
131        address_info = {}
132        if self.freeform:
133            address_info["addr:full"] = self.freeform
134        if self.country:
135            address_info["addr:country"] = self.country
136        if self.postcode:
137            address_info["addr:postcode"] = self.postcode
138        if self.locality:
139            address_info["addr:city"] = self.locality
140        if self.region:
141            address_info[region_tag] = self.region
142
143        return address_info

Overture addresses model.

freeform: str | None = PydanticUndefined
locality: str | None = PydanticUndefined
postcode: str | None = PydanticUndefined
region: str | None = PydanticUndefined
country: str | None = PydanticUndefined
def to_osm(self, region_tag: str) -> dict[str, str]:
129    def to_osm(self, region_tag: str) -> dict[str, str]:
130        """Convert address to OSM tags."""
131        address_info = {}
132        if self.freeform:
133            address_info["addr:full"] = self.freeform
134        if self.country:
135            address_info["addr:country"] = self.country
136        if self.postcode:
137            address_info["addr:postcode"] = self.postcode
138        if self.locality:
139            address_info["addr:city"] = self.locality
140        if self.region:
141            address_info[region_tag] = self.region
142
143        return address_info

Convert address to OSM tags.

class Categories(pydantic.main.BaseModel):
146class Categories(BaseModel):
147    """Overture categories model."""
148
149    primary: str
150    alternate: list[str] | None
151
152    def to_osm(self, unmatched: str) -> dict[str, str]:
153        """Convert categories to OSM tags."""
154        prim = places_tags.get(self.primary)
155        if prim:
156            return prim
157        elif unmatched == "force":
158            return {"type": self.primary}
159        elif unmatched == "error":
160            raise UnmatchedError(self.primary)
161        return {}

Overture categories model.

primary: str = PydanticUndefined
alternate: list[str] | None = PydanticUndefined
def to_osm(self, unmatched: str) -> dict[str, str]:
152    def to_osm(self, unmatched: str) -> dict[str, str]:
153        """Convert categories to OSM tags."""
154        prim = places_tags.get(self.primary)
155        if prim:
156            return prim
157        elif unmatched == "force":
158            return {"type": self.primary}
159        elif unmatched == "error":
160            raise UnmatchedError(self.primary)
161        return {}

Convert categories to OSM tags.

class Brand(pydantic.main.BaseModel):
164class Brand(BaseModel):
165    """Overture brand model."""
166
167    wikidata: Wikidata | None = None
168    names: Names
169
170    def to_osm(self) -> dict[str, str]:
171        """Convert brand properties to OSM tags."""
172        osm = {"brand": self.names.primary}
173        if self.wikidata:
174            osm.update({"brand:wikidata": str(self.wikidata.root)})
175        return osm

Overture brand model.

wikidata: Wikidata | None = None
names: Names = PydanticUndefined
def to_osm(self) -> dict[str, str]:
170    def to_osm(self) -> dict[str, str]:
171        """Convert brand properties to OSM tags."""
172        osm = {"brand": self.names.primary}
173        if self.wikidata:
174            osm.update({"brand:wikidata": str(self.wikidata.root)})
175        return osm

Convert brand properties to OSM tags.

class Socials(pydantic.main.BaseModel, typing.Generic[~RootModelRootType]):
178class Socials(RootModel):
179    """Overture socials model."""
180
181    root: list[str]
182
183    def to_osm(self) -> dict[str, str]:
184        """Convert socials properties to OSM tags."""
185        new_props = {}
186        for social in self.root:
187            if "facebook" in social:
188                new_props["contact:facebook"] = social
189            elif "twitter" in str(social):
190                new_props["contact:twitter"] = social
191        return new_props

Overture socials model.

root: list[str] = PydanticUndefined
def to_osm(self) -> dict[str, str]:
183    def to_osm(self) -> dict[str, str]:
184        """Convert socials properties to OSM tags."""
185        new_props = {}
186        for social in self.root:
187            if "facebook" in social:
188                new_props["contact:facebook"] = social
189            elif "twitter" in str(social):
190                new_props["contact:twitter"] = social
191        return new_props

Convert socials properties to OSM tags.

class OperatingStatus(builtins.str, enum.Enum):
194class OperatingStatus(str, Enum):
195    """Enum for place operating status."""
196
197    open = "open"
198    permanently_closed = "permanently_closed"
199    temporarily_closed = "temporarily_closed"

Enum for place operating status.

open = <OperatingStatus.open: 'open'>
permanently_closed = <OperatingStatus.permanently_closed: 'permanently_closed'>
temporarily_closed = <OperatingStatus.temporarily_closed: 'temporarily_closed'>
class PlaceProps(OvertureBaseModel):
202class PlaceProps(OvertureBaseModel):
203    """Overture properties model.
204
205    Use this model directly if you want to manipulate the `place` properties yourself.
206    """
207
208    model_config = ConfigDict(extra="ignore")
209
210    sources: list[Sources]
211    names: Names
212    brand: Brand | None = None
213    categories: Categories | None = None
214    basic_category: str | None = Field(pattern=r"^[a-z0-9]+(_[a-z0-9]+)*$")
215    confidence: float = Field(ge=0.0, le=1.0)
216    websites: list[str | None] | None = None
217    socials: Socials | None = None
218    emails: list[str | None] | None = None
219    phones: list[str | None] | None = None
220    addresses: list[PlaceAddress]
221    operating_status: OperatingStatus | None = None
222
223    def _validate_license(self, required_license: str | None) -> None:
224        """Validate that sources meet license requirements.
225
226        Args:
227            required_license: The required license string (e.g., "CDLA").
228                             If None, no validation is performed.
229
230        Raises:
231            LicenseError: If no sources have the required license and at least
232                         one source has a non-null license.
233        """
234        if required_license is None:
235            return
236
237        found_licenses = [s.license for s in self.sources if s.license is not None]
238
239        # If all licenses are null, pass the validation
240        if not found_licenses:
241            return
242
243        # Check if any source has the required license
244        for source in self.sources:
245            if source.license and required_license in source.license:
246                return
247
248        # If we get here, no source has the required license
249        raise LicenseError(required_license, found_licenses)
250
251    def to_osm(
252        self,
253        confidence: float,
254        region_tag: str,
255        unmatched: str,
256        required_license: str | None = None,
257    ) -> dict[str, str]:
258        """Convert Overture's place properties to OSM tags.
259
260        Used internally by the `overturetoosm.process_place` function.
261        """
262        if self.confidence < confidence:
263            raise ConfidenceError(confidence, self.confidence)
264
265        self._validate_license(required_license)
266
267        new_props = {}
268
269        # Categories
270        if self.categories:
271            new_props.update(self.categories.to_osm(unmatched))
272
273        # Names
274        if self.names:
275            new_props.update(self.names.to_osm())
276
277        # Contact information
278        new_props.update(self._process_contact_info())
279
280        # Addresses
281        if self.addresses:
282            new_props.update(self.addresses[0].to_osm(region_tag))
283
284        # Sources
285        new_props["source"] = source_statement(self.sources)
286
287        # Socials and Brand
288        if self.socials:
289            new_props.update(self.socials.to_osm())
290        if self.brand:
291            new_props.update(self.brand.to_osm())
292
293        return new_props
294
295    def _process_contact_info(self) -> dict[str, str]:
296        """Process contact information."""
297        contact_info = {}
298        if not is_none_or_list_of_nones(self.phones):
299            contact_info["phone"] = self.phones[0]
300        if not is_none_or_list_of_nones(self.websites):
301            contact_info["website"] = str(self.websites[0])
302        if not is_none_or_list_of_nones(self.emails):
303            contact_info["email"] = self.emails[0]
304        return contact_info

Overture properties model.

Use this model directly if you want to manipulate the place properties yourself.

sources: list[Sources] = PydanticUndefined
names: Names = PydanticUndefined
brand: Brand | None = None
categories: Categories | None = None
basic_category: str | None = PydanticUndefined
confidence: float = PydanticUndefined
websites: list[str | None] | None = None
socials: Socials | None = None
emails: list[str | None] | None = None
phones: list[str | None] | None = None
addresses: list[PlaceAddress] = PydanticUndefined
operating_status: OperatingStatus | None = None
def to_osm( self, confidence: float, region_tag: str, unmatched: str, required_license: str | None = None) -> dict[str, str]:
251    def to_osm(
252        self,
253        confidence: float,
254        region_tag: str,
255        unmatched: str,
256        required_license: str | None = None,
257    ) -> dict[str, str]:
258        """Convert Overture's place properties to OSM tags.
259
260        Used internally by the `overturetoosm.process_place` function.
261        """
262        if self.confidence < confidence:
263            raise ConfidenceError(confidence, self.confidence)
264
265        self._validate_license(required_license)
266
267        new_props = {}
268
269        # Categories
270        if self.categories:
271            new_props.update(self.categories.to_osm(unmatched))
272
273        # Names
274        if self.names:
275            new_props.update(self.names.to_osm())
276
277        # Contact information
278        new_props.update(self._process_contact_info())
279
280        # Addresses
281        if self.addresses:
282            new_props.update(self.addresses[0].to_osm(region_tag))
283
284        # Sources
285        new_props["source"] = source_statement(self.sources)
286
287        # Socials and Brand
288        if self.socials:
289            new_props.update(self.socials.to_osm())
290        if self.brand:
291            new_props.update(self.brand.to_osm())
292
293        return new_props

Convert Overture's place properties to OSM tags.

Used internally by the overturetoosm.process_place function.

Inherited Members
OvertureBaseModel
version
theme
type
id
class ConfidenceError(builtins.Exception):
307class ConfidenceError(Exception):
308    """Confidence error exception.
309
310    This exception is raised when the confidence level of an item is below the
311    user-defined level. It contains the original confidence level and the confidence
312    level of the item.
313
314    Attributes:
315        confidence_level (float): The set confidence level.
316        confidence_item (float): The confidence of the item.
317        message (str): The error message.
318    """
319
320    def __init__(
321        self,
322        confidence_level: float,
323        confidence_item: float,
324        message: str = "Confidence in this item is too low.",
325    ) -> None:
326        """@private"""
327        self.confidence_level = confidence_level
328        self.confidence_item = confidence_item
329        self.message = message
330        super().__init__(message)
331
332    def __str__(self) -> str:
333        """@private"""
334        lev = f"confidence_level={self.confidence_level}"
335        item = f"confidence_item={self.confidence_item}"
336        return f"""{self.message} {lev}, {item}"""

Confidence error exception.

This exception is raised when the confidence level of an item is below the user-defined level. It contains the original confidence level and the confidence level of the item.

Attributes:
  • confidence_level (float): The set confidence level.
  • confidence_item (float): The confidence of the item.
  • message (str): The error message.
confidence_level
confidence_item
message
class UnmatchedError(builtins.Exception):
339class UnmatchedError(Exception):
340    """Unmatched category error.
341
342    This exception is raised when an item's Overture category does not have a
343    corresponding OSM definition. Edit
344    [the OSM Wiki page](https://wiki.openstreetmap.org/wiki/Overture_categories)
345    to add a definition to this category.
346
347    Attributes:
348        category (str): The Overture category that is unmatched.
349        message (str): The error message.
350    """
351
352    def __init__(
353        self, category: str, message: str = "Overture category is unmatched."
354    ) -> None:
355        """@private"""
356        self.category = category
357        self.message = message
358        super().__init__(message)
359
360    def __str__(self) -> str:
361        """@private"""
362        return f"{self.message} {{category={self.category}}}"

Unmatched category error.

This exception is raised when an item's Overture category does not have a corresponding OSM definition. Edit the OSM Wiki page to add a definition to this category.

Attributes:
  • category (str): The Overture category that is unmatched.
  • message (str): The error message.
category
message
class LicenseError(builtins.Exception):
365class LicenseError(Exception):
366    """License compatibility error.
367
368    This exception is raised when a feature's source licenses do not meet
369    the required license criteria.
370
371    Attributes:
372        required_license (str): The required license string.
373        found_licenses (list[str]): The licenses found in the sources.
374        message (str): The error message.
375    """
376
377    def __init__(
378        self,
379        required_license: str,
380        found_licenses: list[str],
381        message: str = "Feature does not meet license requirements.",
382    ) -> None:
383        """@private"""
384        self.required_license = required_license
385        self.found_licenses = found_licenses
386        self.message = message
387        super().__init__(message)
388
389    def __str__(self) -> str:
390        """@private"""
391        return f"{self.message} {{required_license={self.required_license}, found_licenses={self.found_licenses}}}"

License compatibility error.

This exception is raised when a feature's source licenses do not meet the required license criteria.

Attributes:
  • required_license (str): The required license string.
  • found_licenses (list[str]): The licenses found in the sources.
  • message (str): The error message.
required_license
found_licenses
message
class BuildingProps(OvertureBaseModel):
394class BuildingProps(OvertureBaseModel):
395    """Overture building properties.
396
397    Use this model if you want to manipulate the `building` properties yourself.
398    """
399
400    has_parts: bool
401    sources: list[Sources]
402    class_: str | None = Field(alias="class", default=None)
403    subtype: str | None = None
404    names: Names | None = None
405    level: int | None = None
406    height: float | None = None
407    is_underground: bool | None = None
408    num_floors: int | None = Field(serialization_alias="building:levels", default=None)
409    num_floors_underground: int | None = Field(
410        serialization_alias="building:levels:underground", default=None
411    )
412    min_height: float | None = None
413    min_floor: int | None = Field(
414        serialization_alias="building:min_level", default=None
415    )
416    facade_color: str | None = Field(
417        serialization_alias="building:colour", default=None
418    )
419    facade_material: str | None = Field(
420        serialization_alias="building:material", default=None
421    )
422    roof_material: str | None = Field(serialization_alias="roof:material", default=None)
423    roof_shape: str | None = Field(serialization_alias="roof:shape", default=None)
424    roof_direction: str | None = Field(
425        serialization_alias="roof:direction", default=None
426    )
427    roof_orientation: str | None = Field(
428        serialization_alias="roof:orientation", default=None
429    )
430    roof_color: str | None = Field(serialization_alias="roof:colour", default=None)
431    roof_height: float | None = Field(serialization_alias="roof:height", default=None)
432
433    def _validate_license(self, required_license: str | None) -> None:
434        """Validate that sources meet license requirements.
435
436        Args:
437            required_license: The required license string (e.g., "CDLA").
438                             If None, no validation is performed.
439
440        Raises:
441            LicenseError: If no sources have the required license and at least
442                         one source has a non-null license.
443        """
444        if required_license is None:
445            return
446
447        found_licenses = [s.license for s in self.sources if s.license is not None]
448
449        # If all licenses are null, pass the validation
450        if not found_licenses:
451            return
452
453        # Check if any source has the required license
454        for source in self.sources:
455            if source.license and required_license in source.license:
456                return
457
458        # If we get here, no source has the required license
459        raise LicenseError(required_license, found_licenses)
460
461    def to_osm(
462        self, confidence: float, required_license: str | None = None
463    ) -> dict[str, str]:
464        """Convert properties to OSM tags.
465
466        Used internally by`overturetoosm.process_building` function.
467        """
468        new_props = {}
469        confidences = {source.confidence for source in self.sources}
470        if any(conf and conf < confidence for conf in confidences):
471            raise ConfidenceError(confidence, max({i for i in confidences if i}))
472
473        self._validate_license(required_license)
474
475        new_props["building"] = self.class_ if self.class_ else "yes"
476
477        new_props["source"] = source_statement(self.sources)
478
479        prop_obj = self.model_dump(exclude_none=True, by_alias=True).items()
480        new_props.update(
481            {k: v for k, v in prop_obj if k.startswith(("roof", "building"))}
482        )
483        new_props.update({k: round(v, 2) for k, v in prop_obj if k.endswith("height")})
484
485        if self.is_underground:
486            new_props["location"] = "underground"
487        if self.names:
488            new_props["name"] = self.names.primary
489        return new_props

Overture building properties.

Use this model if you want to manipulate the building properties yourself.

has_parts: bool = PydanticUndefined
sources: list[Sources] = PydanticUndefined
class_: str | None = None
subtype: str | None = None
names: Names | None = None
level: int | None = None
height: float | None = None
is_underground: bool | None = None
num_floors: int | None = None
num_floors_underground: int | None = None
min_height: float | None = None
min_floor: int | None = None
facade_color: str | None = None
facade_material: str | None = None
roof_material: str | None = None
roof_shape: str | None = None
roof_direction: str | None = None
roof_orientation: str | None = None
roof_color: str | None = None
roof_height: float | None = None
def to_osm( self, confidence: float, required_license: str | None = None) -> dict[str, str]:
461    def to_osm(
462        self, confidence: float, required_license: str | None = None
463    ) -> dict[str, str]:
464        """Convert properties to OSM tags.
465
466        Used internally by`overturetoosm.process_building` function.
467        """
468        new_props = {}
469        confidences = {source.confidence for source in self.sources}
470        if any(conf and conf < confidence for conf in confidences):
471            raise ConfidenceError(confidence, max({i for i in confidences if i}))
472
473        self._validate_license(required_license)
474
475        new_props["building"] = self.class_ if self.class_ else "yes"
476
477        new_props["source"] = source_statement(self.sources)
478
479        prop_obj = self.model_dump(exclude_none=True, by_alias=True).items()
480        new_props.update(
481            {k: v for k, v in prop_obj if k.startswith(("roof", "building"))}
482        )
483        new_props.update({k: round(v, 2) for k, v in prop_obj if k.endswith("height")})
484
485        if self.is_underground:
486            new_props["location"] = "underground"
487        if self.names:
488            new_props["name"] = self.names.primary
489        return new_props

Convert properties to OSM tags.

Used internally byoverturetoosm.process_building function.

Inherited Members
OvertureBaseModel
version
theme
type
id
class AddressLevel(pydantic.main.BaseModel):
492class AddressLevel(BaseModel):
493    """Overture address level model."""
494
495    value: str

Overture address level model.

value: str = PydanticUndefined
class AddressProps(OvertureBaseModel):
498class AddressProps(OvertureBaseModel):
499    """Overture address properties.
500
501    Use this model directly if you want to manipulate the `address` properties yourself.
502    """
503
504    number: str | None = Field(serialization_alias="addr:housenumber", default=None)
505    street: str | None = Field(serialization_alias="addr:street", default=None)
506    unit: str | None = Field(serialization_alias="addr:unit", default=None)
507    postcode: str | None = Field(serialization_alias="addr:postcode", default=None)
508    postal_city: str | None = Field(serialization_alias="addr:city", default=None)
509    country: str | None = Field(serialization_alias="addr:country", default=None)
510    address_levels: (
511        None | (Annotated[list[AddressLevel], Field(min_length=1, max_length=5)])
512    ) = Field(default_factory=list)
513    sources: list[Sources]
514
515    def _validate_license(self, required_license: str | None) -> None:
516        """Validate that sources meet license requirements.
517
518        Args:
519            required_license: The required license string (e.g., "CDLA").
520                             If None, no validation is performed.
521
522        Raises:
523            LicenseError: If no sources have the required license and at least
524                         one source has a non-null license.
525        """
526        if required_license is None:
527            return
528
529        found_licenses = [s.license for s in self.sources if s.license is not None]
530
531        # If all licenses are null, pass the validation
532        if not found_licenses:
533            return
534
535        # Check if any source has the required license
536        for source in self.sources:
537            if source.license and required_license in source.license:
538                return
539
540        # If we get here, no source has the required license
541        raise LicenseError(required_license, found_licenses)
542
543    def to_osm(self, style: str, required_license: str | None = None) -> dict[str, str]:
544        """Convert properties to OSM tags.
545
546        Used internally by `overturetoosm.process_address`.
547        """
548        self._validate_license(required_license)
549
550        obj_dict = {
551            k: v
552            for k, v in self.model_dump(exclude_none=True, by_alias=True).items()
553            if k.startswith("addr:")
554        }
555        obj_dict["source"] = source_statement(self.sources)
556
557        if self.address_levels and len(self.address_levels) > 0 and style == "US":
558            obj_dict["addr:state"] = str(self.address_levels[0].value)
559
560        return obj_dict

Overture address properties.

Use this model directly if you want to manipulate the address properties yourself.

number: str | None = None
street: str | None = None
unit: str | None = None
postcode: str | None = None
postal_city: str | None = None
country: str | None = None
address_levels: Optional[Annotated[list[AddressLevel], FieldInfo(annotation=NoneType, required=True, metadata=[MinLen(min_length=1), MaxLen(max_length=5)])]] = PydanticUndefined
sources: list[Sources] = PydanticUndefined
def to_osm(self, style: str, required_license: str | None = None) -> dict[str, str]:
543    def to_osm(self, style: str, required_license: str | None = None) -> dict[str, str]:
544        """Convert properties to OSM tags.
545
546        Used internally by `overturetoosm.process_address`.
547        """
548        self._validate_license(required_license)
549
550        obj_dict = {
551            k: v
552            for k, v in self.model_dump(exclude_none=True, by_alias=True).items()
553            if k.startswith("addr:")
554        }
555        obj_dict["source"] = source_statement(self.sources)
556
557        if self.address_levels and len(self.address_levels) > 0 and style == "US":
558            obj_dict["addr:state"] = str(self.address_levels[0].value)
559
560        return obj_dict

Convert properties to OSM tags.

Used internally by overturetoosm.process_address.

Inherited Members
OvertureBaseModel
version
theme
type
id
def source_statement(source: list[Sources]) -> str:
563def source_statement(source: list[Sources]) -> str:
564    """Return a source statement from a list of sources."""
565    return (
566        ", ".join(sorted({i.dataset.strip(", ") for i in source}))
567        + " via overturetoosm"
568    )

Return a source statement from a list of sources.

def is_none_or_list_of_nones(value) -> bool:
571def is_none_or_list_of_nones(value) -> bool:
572    """Check whether a given value is either None or a list containing only None values.
573
574    Args:
575        value: The value to check. Can be of any type.
576
577    Returns:
578        bool: True if the value is None or a list containing only None values,
579              False otherwise.
580    """
581    if value is None:
582        return True
583
584    if isinstance(value, list):
585        return len(value) > 0 and all(item is None for item in value)
586
587    return False

Check whether a given value is either None or a list containing only None values.

Arguments:
  • value: The value to check. Can be of any type.
Returns:

bool: True if the value is None or a list containing only None values, False otherwise.