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

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 record_id: str | None = None 

40 confidence: float | None = Field(ge=0.0, le=1.0) 

41 update_time: str | None = None 

42 

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 

48 

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:]}" 

58 

59 

60class RulesVariant(str, Enum): 

61 """Overture name rules variant model.""" 

62 

63 alternate = "alternate" 

64 common = "common" 

65 official = "official" 

66 short = "short" 

67 

68 

69class Between(RootModel): 

70 """Model for transportation segment between.""" 

71 

72 root: Annotated[list, Field(float, min_length=2, max_length=2)] 

73 

74 

75class Rules(BaseModel): 

76 """Overture name rules model.""" 

77 

78 variant: RulesVariant 

79 language: str | None = None 

80 value: str 

81 between: Between | None = None 

82 side: str | None = None 

83 

84 

85class Names(BaseModel): 

86 """Overture names model.""" 

87 

88 primary: str 

89 common: dict[str, str] | None 

90 rules: list[Rules] | None 

91 

92 

93class PlaceAddress(BaseModel): 

94 """Overture addresses model.""" 

95 

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}$") 

101 

102 

103class Categories(BaseModel): 

104 """Overture categories model.""" 

105 

106 main: str 

107 alternate: list[str] | None 

108 

109 

110class Brand(BaseModel): 

111 """Overture brand model.""" 

112 

113 wikidata: Wikidata | None = None 

114 names: Names 

115 

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 

122 

123 

124class Socials(RootModel): 

125 """Overture socials model.""" 

126 

127 root: list[str] 

128 

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 

138 

139 

140class PlaceProps(OvertureBaseModel): 

141 """Overture properties model. 

142 

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

144 """ 

145 

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] 

157 

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. 

162 

163 Used internally by the `overturetoosm.process_place` function. 

164 """ 

165 new_props = {} 

166 if self.confidence < confidence: 

167 raise ConfidenceError(confidence, self.confidence) 

168 

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) 

177 

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 

180 

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] 

183 

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]) 

186 

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 

198 

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) 

201 

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()) 

204 

205 if self.brand: 

206 new_props.update(self.brand.to_osm()) 

207 

208 return new_props 

209 

210 

211class ConfidenceError(Exception): 

212 """Confidence error exception. 

213 

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. 

217 

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 """ 

223 

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) 

235 

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}""" 

241 

242 

243class UnmatchedError(Exception): 

244 """Unmatched category error. 

245 

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. 

250 

251 Attributes: 

252 category (str): The Overture category that is unmatched. 

253 message (str): The error message. 

254 """ 

255 

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) 

263 

264 def __str__(self) -> str: 

265 """@private""" 

266 return f"{self.message} { category={self.category}} " 

267 

268 

269class BuildingProps(OvertureBaseModel): 

270 """Overture building properties. 

271 

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

273 """ 

274 

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) 

307 

308 def to_osm(self, confidence: float) -> dict[str, str]: 

309 """Convert properties to OSM tags. 

310 

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})) 

317 

318 new_props["building"] = self.class_ if self.class_ else "yes" 

319 

320 new_props["source"] = source_statement(self.sources) 

321 

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")}) 

327 

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 

333 

334 

335class AddressLevel(BaseModel): 

336 """Overture address level model.""" 

337 

338 value: str 

339 

340 

341class AddressProps(OvertureBaseModel): 

342 """Overture address properties. 

343 

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

345 """ 

346 

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] 

355 

356 def to_osm(self, style: str) -> dict[str, str]: 

357 """Convert properties to OSM tags. 

358 

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) 

367 

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) 

370 

371 return obj_dict 

372 

373 

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 )