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

Convert names to OSM tags.

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

Convert address to OSM tags.

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

Overture categories model.

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

Convert categories to OSM tags.

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

Overture brand model.

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

Convert brand properties to OSM tags.

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

Overture socials model.

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

Convert socials properties to OSM tags.

class OperatingStatus(builtins.str, enum.Enum):
195class OperatingStatus(str, Enum):
196    """Enum for place operating status."""
197
198    open = "open"
199    permanently_closed = "permanently_closed"
200    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):
203class PlaceProps(OvertureBaseModel):
204    """Overture properties model.
205
206    Use this model directly if you want to manipulate the `place` properties yourself.
207    """
208
209    model_config = ConfigDict(extra="ignore")
210
211    sources: list[Sources]
212    names: Names
213    brand: Brand | None = None
214    categories: Categories | None = None
215    basic_category: str | None = Field(
216        pattern=r"^[a-z0-9]+(_[a-z0-9]+)*$", default=None
217    )
218    confidence: float = Field(ge=0.0, le=1.0)
219    websites: list[str | None] | None = None
220    socials: Socials | None = None
221    emails: list[str | None] | None = None
222    phones: list[str | None] | None = None
223    addresses: list[PlaceAddress]
224    operating_status: OperatingStatus | None = None
225
226    def _validate_license(self, required_license: str | None) -> None:
227        """Validate that sources meet license requirements.
228
229        Args:
230            required_license: The required license string (e.g., "CDLA").
231                             If None, no validation is performed.
232
233        Raises:
234            LicenseError: If no sources have the required license and at least
235                         one source has a non-null license.
236        """
237        if required_license is None:
238            return
239
240        found_licenses = [s.license for s in self.sources if s.license is not None]
241
242        # If all licenses are null, pass the validation
243        if not found_licenses:
244            return
245
246        # Check if any source has the required license
247        for source in self.sources:
248            if source.license and required_license in source.license:
249                return
250
251        # If we get here, no source has the required license
252        raise LicenseError(required_license, found_licenses)
253
254    def to_osm(
255        self,
256        confidence: float,
257        region_tag: str,
258        unmatched: str,
259        required_license: str | None = None,
260    ) -> dict[str, str]:
261        """Convert Overture's place properties to OSM tags.
262
263        Used internally by the `overturetoosm.process_place` function.
264        """
265        if self.confidence < confidence:
266            raise ConfidenceError(confidence, self.confidence)
267
268        self._validate_license(required_license)
269
270        new_props = {}
271
272        # Categories
273        if self.categories:
274            new_props.update(self.categories.to_osm(unmatched))
275
276        # Names
277        if self.names:
278            new_props.update(self.names.to_osm())
279
280        # Contact information
281        new_props.update(self._process_contact_info())
282
283        # Addresses
284        if self.addresses:
285            new_props.update(self.addresses[0].to_osm(region_tag))
286
287        # Sources
288        new_props["source"] = source_statement(self.sources)
289
290        # Socials and Brand
291        if self.socials:
292            new_props.update(self.socials.to_osm())
293        if self.brand:
294            new_props.update(self.brand.to_osm())
295
296        return new_props
297
298    def _process_contact_info(self) -> dict[str, str]:
299        """Process contact information."""
300        contact_info = {}
301        if not is_none_or_list_of_nones(self.phones):
302            contact_info["phone"] = self.phones[0]
303        if not is_none_or_list_of_nones(self.websites):
304            contact_info["website"] = str(self.websites[0])
305        if not is_none_or_list_of_nones(self.emails):
306            contact_info["email"] = self.emails[0]
307        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 = None
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]:
254    def to_osm(
255        self,
256        confidence: float,
257        region_tag: str,
258        unmatched: str,
259        required_license: str | None = None,
260    ) -> dict[str, str]:
261        """Convert Overture's place properties to OSM tags.
262
263        Used internally by the `overturetoosm.process_place` function.
264        """
265        if self.confidence < confidence:
266            raise ConfidenceError(confidence, self.confidence)
267
268        self._validate_license(required_license)
269
270        new_props = {}
271
272        # Categories
273        if self.categories:
274            new_props.update(self.categories.to_osm(unmatched))
275
276        # Names
277        if self.names:
278            new_props.update(self.names.to_osm())
279
280        # Contact information
281        new_props.update(self._process_contact_info())
282
283        # Addresses
284        if self.addresses:
285            new_props.update(self.addresses[0].to_osm(region_tag))
286
287        # Sources
288        new_props["source"] = source_statement(self.sources)
289
290        # Socials and Brand
291        if self.socials:
292            new_props.update(self.socials.to_osm())
293        if self.brand:
294            new_props.update(self.brand.to_osm())
295
296        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):
310class ConfidenceError(Exception):
311    """Confidence error exception.
312
313    This exception is raised when the confidence level of an item is below the
314    user-defined level. It contains the original confidence level and the confidence
315    level of the item.
316
317    Attributes:
318        confidence_level (float): The set confidence level.
319        confidence_item (float): The confidence of the item.
320        message (str): The error message.
321    """
322
323    def __init__(
324        self,
325        confidence_level: float,
326        confidence_item: float,
327        message: str = "Confidence in this item is too low.",
328    ) -> None:
329        """@private"""
330        self.confidence_level = confidence_level
331        self.confidence_item = confidence_item
332        self.message = message
333        super().__init__(message)
334
335    def __str__(self) -> str:
336        """@private"""
337        lev = f"confidence_level={self.confidence_level}"
338        item = f"confidence_item={self.confidence_item}"
339        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):
342class UnmatchedError(Exception):
343    """Unmatched category error.
344
345    This exception is raised when an item's Overture category does not have a
346    corresponding OSM definition. Edit
347    [the OSM Wiki page](https://wiki.openstreetmap.org/wiki/Overture_categories)
348    to add a definition to this category.
349
350    Attributes:
351        category (str): The Overture category that is unmatched.
352        message (str): The error message.
353    """
354
355    def __init__(
356        self, category: str, message: str = "Overture category is unmatched."
357    ) -> None:
358        """@private"""
359        self.category = category
360        self.message = message
361        super().__init__(message)
362
363    def __str__(self) -> str:
364        """@private"""
365        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):
368class LicenseError(Exception):
369    """License compatibility error.
370
371    This exception is raised when a feature's source licenses do not meet
372    the required license criteria.
373
374    Attributes:
375        required_license (str): The required license string.
376        found_licenses (list[str]): The licenses found in the sources.
377        message (str): The error message.
378    """
379
380    def __init__(
381        self,
382        required_license: str,
383        found_licenses: list[str],
384        message: str = "Feature does not meet license requirements.",
385    ) -> None:
386        """@private"""
387        self.required_license = required_license
388        self.found_licenses = found_licenses
389        self.message = message
390        super().__init__(message)
391
392    def __str__(self) -> str:
393        """@private"""
394        return (
395            f"{self.message} {{required_license={self.required_license}, "
396            "found_licenses={self.found_licenses}}}"
397        )

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

Overture address level model.

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

Return a source statement from a list of sources.

def is_none_or_list_of_nones(value) -> bool:
577def is_none_or_list_of_nones(value) -> bool:
578    """Check whether a given value is either None or a list containing only None values.
579
580    Args:
581        value: The value to check. Can be of any type.
582
583    Returns:
584        bool: True if the value is None or a list containing only None values,
585              False otherwise.
586    """
587    if value is None:
588        return True
589
590    if isinstance(value, list):
591        return len(value) > 0 and all(item is None for item in value)
592
593    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.