Coverage for src/overturetoosm/objects.py: 92%
191 statements
« prev ^ index » next coverage.py v7.6.1, created at 2025-07-22 06:43 -0400
« prev ^ index » next coverage.py v7.6.1, created at 2025-07-22 06:43 -0400
1"""Pydantic models needed throughout the project."""
3# ruff: noqa: D415
5from enum import Enum
7try:
8 from typing import Annotated
9except ImportError:
10 from typing import Annotated
12from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator
14from .resources import places_tags
17class OvertureBaseModel(BaseModel):
18 """Base model for Overture features."""
20 model_config = ConfigDict(extra="forbid")
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$")
28class Wikidata(RootModel):
29 """Model for transportation segment wikidata."""
31 root: str = Field(description="Wikidata ID.", pattern=r"^Q\d+")
34class Sources(BaseModel):
35 """Overture sources model."""
37 property: str
38 dataset: str
39 record_id: str | None = None
40 confidence: float | None = Field(ge=0.0, le=1.0)
41 update_time: str | None = None
43 @field_validator("confidence")
44 @classmethod
45 def set_default_if_none(cls, v: float) -> float:
46 """@private"""
47 return v if v is not None else 0.0
49 def get_osm_link(self) -> str | None:
50 """Return the OSM link for the source."""
51 if (
52 self.record_id
53 and self.record_id.startswith(("n", "w", "r"))
54 and self.dataset == "OpenStreetMap"
55 ):
56 type_dict = {"n": "node", "w": "way", "r": "relation"}
57 return f"https://www.openstreetmap.org/{type_dict[self.record_id[0]]}/{self.record_id[1:]}"
60class RulesVariant(str, Enum):
61 """Overture name rules variant model."""
63 alternate = "alternate"
64 common = "common"
65 official = "official"
66 short = "short"
69class Between(RootModel):
70 """Model for transportation segment between."""
72 root: Annotated[list, Field(float, min_length=2, max_length=2)]
75class Rules(BaseModel):
76 """Overture name rules model."""
78 variant: RulesVariant
79 language: str | None = None
80 value: str
81 between: Between | None = None
82 side: str | None = None
85class Names(BaseModel):
86 """Overture names model."""
88 primary: str
89 common: dict[str, str] | None
90 rules: list[Rules] | None
93class PlaceAddress(BaseModel):
94 """Overture addresses model."""
96 freeform: str | None
97 locality: str | None
98 postcode: str | None
99 region: str | None
100 country: str | None = Field(pattern=r"^[A-Z]{2}$")
103class Categories(BaseModel):
104 """Overture categories model."""
106 main: str
107 alternate: list[str] | None
110class Brand(BaseModel):
111 """Overture brand model."""
113 wikidata: Wikidata | None = None
114 names: Names
116 def to_osm(self) -> dict[str, str]:
117 """Convert brand properties to OSM tags."""
118 osm = {"brand": self.names.primary}
119 if self.wikidata: 119 ↛ 121line 119 didn't jump to line 121 because the condition on line 119 was always true
120 osm.update({"brand:wikidata": str(self.wikidata.root)})
121 return osm
124class Socials(RootModel):
125 """Overture socials model."""
127 root: list[str]
129 def to_osm(self) -> dict[str, str]:
130 """Convert socials properties to OSM tags."""
131 new_props = {}
132 for social in self.root:
133 if "facebook" in social:
134 new_props["contact:facebook"] = social
135 elif "twitter" in str(social): 135 ↛ 132line 135 didn't jump to line 132 because the condition on line 135 was always true
136 new_props["contact:twitter"] = social
137 return new_props
140class PlaceProps(OvertureBaseModel):
141 """Overture properties model.
143 Use this model directly if you want to manipulate the `place` properties yourself.
144 """
146 update_time: str
147 sources: list[Sources]
148 names: Names
149 brand: Brand | None = None
150 categories: Categories | None = None
151 confidence: float = Field(ge=0.0, le=1.0)
152 websites: list[str] | None = None
153 socials: Socials | None = None
154 emails: list[str] | None = None
155 phones: list[str] | None = None
156 addresses: list[PlaceAddress]
158 def to_osm(
159 self, confidence: float, region_tag: str, unmatched: str
160 ) -> dict[str, str]:
161 """Convert Overture's place properties to OSM tags.
163 Used internally by the `overturetoosm.process_place` function.
164 """
165 new_props = {}
166 if self.confidence < confidence:
167 raise ConfidenceError(confidence, self.confidence)
169 if self.categories:
170 prim = places_tags.get(self.categories.main)
171 if prim:
172 new_props = {**new_props, **prim}
173 elif unmatched == "force":
174 new_props["type"] = self.categories.main
175 elif unmatched == "error":
176 raise UnmatchedError(self.categories.main)
178 if self.names.primary: 178 ↛ 181line 178 didn't jump to line 181 because the condition on line 178 was always true
179 new_props["name"] = self.names.primary
181 if self.phones is not None: 181 ↛ 184line 181 didn't jump to line 184 because the condition on line 181 was always true
182 new_props["phone"] = self.phones[0]
184 if self.websites is not None and self.websites[0]: 184 ↛ 187line 184 didn't jump to line 187 because the condition on line 184 was always true
185 new_props["website"] = str(self.websites[0])
187 if add := self.addresses[0]: 187 ↛ 199line 187 didn't jump to line 199 because the condition on line 187 was always true
188 if add.freeform: 188 ↛ 190line 188 didn't jump to line 190 because the condition on line 188 was always true
189 new_props["addr:street_address"] = add.freeform
190 if add.country: 190 ↛ 192line 190 didn't jump to line 192 because the condition on line 190 was always true
191 new_props["addr:country"] = add.country
192 if add.postcode: 192 ↛ 194line 192 didn't jump to line 194 because the condition on line 192 was always true
193 new_props["addr:postcode"] = add.postcode
194 if add.locality: 194 ↛ 196line 194 didn't jump to line 196 because the condition on line 194 was always true
195 new_props["addr:city"] = add.locality
196 if add.region: 196 ↛ 199line 196 didn't jump to line 199 because the condition on line 196 was always true
197 new_props[region_tag] = add.region
199 if self.sources: 199 ↛ 202line 199 didn't jump to line 202 because the condition on line 199 was always true
200 new_props["source"] = source_statement(self.sources)
202 if self.socials: 202 ↛ 205line 202 didn't jump to line 205 because the condition on line 202 was always true
203 new_props.update(self.socials.to_osm())
205 if self.brand:
206 new_props.update(self.brand.to_osm())
208 return new_props
211class ConfidenceError(Exception):
212 """Confidence error exception.
214 This exception is raised when the confidence level of an item is below the
215 user-defined level. It contains the original confidence level and the confidence
216 level of the item.
218 Attributes:
219 confidence_level (float): The set confidence level.
220 confidence_item (float): The confidence of the item.
221 message (str): The error message.
222 """
224 def __init__(
225 self,
226 confidence_level: float,
227 confidence_item: float,
228 message: str = "Confidence in this item is too low.",
229 ) -> None:
230 """@private"""
231 self.confidence_level = confidence_level
232 self.confidence_item = confidence_item
233 self.message = message
234 super().__init__(message)
236 def __str__(self) -> str:
237 """@private"""
238 lev = f"confidence_level={self.confidence_level}"
239 item = f"confidence_item={self.confidence_item}"
240 return f"""{self.message} {lev}, {item}"""
243class UnmatchedError(Exception):
244 """Unmatched category error.
246 This exception is raised when an item's Overture category does not have a
247 corresponding OSM definition. Edit
248 [the OSM Wiki page](https://wiki.openstreetmap.org/wiki/Overture_categories)
249 to add a definition to this category.
251 Attributes:
252 category (str): The Overture category that is unmatched.
253 message (str): The error message.
254 """
256 def __init__(
257 self, category: str, message: str = "Overture category is unmatched."
258 ) -> None:
259 """@private"""
260 self.category = category
261 self.message = message
262 super().__init__(message)
264 def __str__(self) -> str:
265 """@private"""
266 return f"{self.message} { category={self.category}} "
269class BuildingProps(OvertureBaseModel):
270 """Overture building properties.
272 Use this model if you want to manipulate the `building` properties yourself.
273 """
275 has_parts: bool
276 sources: list[Sources]
277 class_: str | None = Field(alias="class", default=None)
278 subtype: str | None = None
279 names: Names | None = None
280 level: int | None = None
281 height: float | None = None
282 is_underground: bool | None = None
283 num_floors: int | None = Field(serialization_alias="building:levels", default=None)
284 num_floors_underground: int | None = Field(
285 serialization_alias="building:levels:underground", default=None
286 )
287 min_height: float | None = None
288 min_floor: int | None = Field(
289 serialization_alias="building:min_level", default=None
290 )
291 facade_color: str | None = Field(
292 serialization_alias="building:colour", default=None
293 )
294 facade_material: str | None = Field(
295 serialization_alias="building:material", default=None
296 )
297 roof_material: str | None = Field(serialization_alias="roof:material", default=None)
298 roof_shape: str | None = Field(serialization_alias="roof:shape", default=None)
299 roof_direction: str | None = Field(
300 serialization_alias="roof:direction", default=None
301 )
302 roof_orientation: str | None = Field(
303 serialization_alias="roof:orientation", default=None
304 )
305 roof_color: str | None = Field(serialization_alias="roof:colour", default=None)
306 roof_height: float | None = Field(serialization_alias="roof:height", default=None)
308 def to_osm(self, confidence: float) -> dict[str, str]:
309 """Convert properties to OSM tags.
311 Used internally by`overturetoosm.process_building` function.
312 """
313 new_props = {}
314 confidences = {source.confidence for source in self.sources}
315 if any(conf and conf < confidence for conf in confidences):
316 raise ConfidenceError(confidence, max({i for i in confidences if i}))
318 new_props["building"] = self.class_ if self.class_ else "yes"
320 new_props["source"] = source_statement(self.sources)
322 prop_obj = self.model_dump(exclude_none=True, by_alias=True).items()
323 new_props.update(
324 {k: v for k, v in prop_obj if k.startswith(("roof", "building"))}
325 )
326 new_props.update({k: round(v, 2) for k, v in prop_obj if k.endswith("height")})
328 if self.is_underground:
329 new_props["location"] = "underground"
330 if self.names: 330 ↛ 332line 330 didn't jump to line 332 because the condition on line 330 was always true
331 new_props["name"] = self.names.primary
332 return new_props
335class AddressLevel(BaseModel):
336 """Overture address level model."""
338 value: str
341class AddressProps(OvertureBaseModel):
342 """Overture address properties.
344 Use this model directly if you want to manipulate the `address` properties yourself.
345 """
347 number: str | None = Field(serialization_alias="addr:housenumber")
348 street: str | None = Field(serialization_alias="addr:street")
349 postcode: str | None = Field(serialization_alias="addr:postcode")
350 country: str | None = Field(serialization_alias="addr:country")
351 address_levels: (
352 None | (Annotated[list[AddressLevel], Field(min_length=1, max_length=5)])
353 ) = Field(default_factory=list)
354 sources: list[Sources]
356 def to_osm(self, style: str) -> dict[str, str]:
357 """Convert properties to OSM tags.
359 Used internally by `overturetoosm.process_address`.
360 """
361 obj_dict = {
362 k: v
363 for k, v in self.model_dump(exclude_none=True, by_alias=True).items()
364 if k.startswith("addr:")
365 }
366 obj_dict["source"] = source_statement(self.sources)
368 if self.address_levels and len(self.address_levels) > 0 and style == "US":
369 obj_dict["addr:state"] = str(self.address_levels[0].value)
371 return obj_dict
374def source_statement(source: list[Sources]) -> str:
375 """Return a source statement from a list of sources."""
376 return (
377 ", ".join(sorted({i.dataset.strip(", ") for i in source}))
378 + " via overturetoosm"
379 )