python-missive 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pymissive/__init__.py +3 -0
- pymissive/__main__.py +9 -0
- pymissive/archives/__init__.py +142 -0
- pymissive/archives/__main__.py +9 -0
- pymissive/archives/address.py +272 -0
- pymissive/archives/address_backends/__init__.py +29 -0
- pymissive/archives/address_backends/base.py +610 -0
- pymissive/archives/address_backends/geoapify.py +221 -0
- pymissive/archives/address_backends/geocode_earth.py +210 -0
- pymissive/archives/address_backends/google_maps.py +371 -0
- pymissive/archives/address_backends/here.py +348 -0
- pymissive/archives/address_backends/locationiq.py +271 -0
- pymissive/archives/address_backends/mapbox.py +314 -0
- pymissive/archives/address_backends/maps_co.py +257 -0
- pymissive/archives/address_backends/nominatim.py +348 -0
- pymissive/archives/address_backends/opencage.py +292 -0
- pymissive/archives/address_backends/pelias_mixin.py +181 -0
- pymissive/archives/address_backends/photon.py +322 -0
- pymissive/archives/cli.py +42 -0
- pymissive/archives/helpers.py +45 -0
- pymissive/archives/missive.py +64 -0
- pymissive/archives/providers/__init__.py +167 -0
- pymissive/archives/providers/apn.py +171 -0
- pymissive/archives/providers/ar24.py +204 -0
- pymissive/archives/providers/base/__init__.py +203 -0
- pymissive/archives/providers/base/_attachments.py +166 -0
- pymissive/archives/providers/base/branded.py +341 -0
- pymissive/archives/providers/base/common.py +781 -0
- pymissive/archives/providers/base/email.py +422 -0
- pymissive/archives/providers/base/email_message.py +85 -0
- pymissive/archives/providers/base/monitoring.py +150 -0
- pymissive/archives/providers/base/notification.py +187 -0
- pymissive/archives/providers/base/postal.py +742 -0
- pymissive/archives/providers/base/postal_defaults.py +26 -0
- pymissive/archives/providers/base/sms.py +213 -0
- pymissive/archives/providers/base/voice_call.py +82 -0
- pymissive/archives/providers/brevo.py +363 -0
- pymissive/archives/providers/certeurope.py +249 -0
- pymissive/archives/providers/django_email.py +182 -0
- pymissive/archives/providers/fcm.py +91 -0
- pymissive/archives/providers/laposte.py +392 -0
- pymissive/archives/providers/maileva.py +511 -0
- pymissive/archives/providers/mailgun.py +118 -0
- pymissive/archives/providers/messenger.py +74 -0
- pymissive/archives/providers/notification.py +112 -0
- pymissive/archives/providers/sendgrid.py +160 -0
- pymissive/archives/providers/ses.py +185 -0
- pymissive/archives/providers/signal.py +68 -0
- pymissive/archives/providers/slack.py +80 -0
- pymissive/archives/providers/smtp.py +190 -0
- pymissive/archives/providers/teams.py +91 -0
- pymissive/archives/providers/telegram.py +69 -0
- pymissive/archives/providers/twilio.py +310 -0
- pymissive/archives/providers/vonage.py +208 -0
- pymissive/archives/sender.py +339 -0
- pymissive/archives/status.py +22 -0
- pymissive/cli.py +42 -0
- pymissive/config.py +397 -0
- pymissive/helpers.py +0 -0
- pymissive/providers/apn.py +8 -0
- pymissive/providers/ar24.py +8 -0
- pymissive/providers/base/__init__.py +64 -0
- pymissive/providers/base/acknowledgement.py +6 -0
- pymissive/providers/base/attachments.py +10 -0
- pymissive/providers/base/branded.py +16 -0
- pymissive/providers/base/email.py +2 -0
- pymissive/providers/base/notification.py +2 -0
- pymissive/providers/base/postal.py +2 -0
- pymissive/providers/base/sms.py +2 -0
- pymissive/providers/base/voice_call.py +2 -0
- pymissive/providers/brevo.py +420 -0
- pymissive/providers/certeurope.py +8 -0
- pymissive/providers/django_email.py +8 -0
- pymissive/providers/fcm.py +8 -0
- pymissive/providers/laposte.py +8 -0
- pymissive/providers/maileva.py +8 -0
- pymissive/providers/mailgun.py +8 -0
- pymissive/providers/messenger.py +8 -0
- pymissive/providers/notification.py +8 -0
- pymissive/providers/partner.py +650 -0
- pymissive/providers/scaleway.py +498 -0
- pymissive/providers/sendgrid.py +8 -0
- pymissive/providers/ses.py +8 -0
- pymissive/providers/signal.py +8 -0
- pymissive/providers/slack.py +8 -0
- pymissive/providers/smtp.py +8 -0
- pymissive/providers/teams.py +8 -0
- pymissive/providers/telegram.py +8 -0
- pymissive/providers/twilio.py +8 -0
- pymissive/providers/vonage.py +8 -0
- python_missive-0.2.0.dist-info/METADATA +152 -0
- python_missive-0.2.0.dist-info/RECORD +95 -0
- python_missive-0.2.0.dist-info/WHEEL +5 -0
- python_missive-0.2.0.dist-info/entry_points.txt +2 -0
- python_missive-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Nominatim (OpenStreetMap) address verification backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .base import BaseAddressBackend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NominatimAddressBackend(BaseAddressBackend):
|
|
11
|
+
"""Nominatim (OpenStreetMap) Geocoding API backend for address verification.
|
|
12
|
+
|
|
13
|
+
Completely free, no API key required. Uses OpenStreetMap data.
|
|
14
|
+
Rate limit: 1 request per second (respected automatically).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name = "nominatim"
|
|
18
|
+
display_name = "OpenStreetMap Nominatim"
|
|
19
|
+
config_keys = ["NOMINATIM_BASE_URL", "NOMINATIM_USER_AGENT"]
|
|
20
|
+
required_packages = ["requests"]
|
|
21
|
+
documentation_url = "https://nominatim.org/release-docs/develop/api/Overview/"
|
|
22
|
+
site_url = "https://nominatim.org"
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
25
|
+
"""Initialize Nominatim backend.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config: Optional configuration dict with:
|
|
29
|
+
- NOMINATIM_BASE_URL: Custom Nominatim server URL (default: official)
|
|
30
|
+
- NOMINATIM_USER_AGENT: User agent string (required by ToS)
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(config)
|
|
33
|
+
self._base_url = self._config.get(
|
|
34
|
+
"NOMINATIM_BASE_URL", "https://nominatim.openstreetmap.org"
|
|
35
|
+
)
|
|
36
|
+
self._user_agent = self._config.get(
|
|
37
|
+
"NOMINATIM_USER_AGENT", "python-missive/1.0"
|
|
38
|
+
)
|
|
39
|
+
self._last_request_time = 0.0
|
|
40
|
+
|
|
41
|
+
def _make_request(
|
|
42
|
+
self, endpoint: str, params: Optional[Dict[str, Any]] = None
|
|
43
|
+
) -> Dict[str, Any]:
|
|
44
|
+
"""Make a request to the Nominatim API."""
|
|
45
|
+
self._rate_limit_with_interval("_last_request_time", 1.0)
|
|
46
|
+
|
|
47
|
+
default_params: Dict[str, Any] = {
|
|
48
|
+
"format": "json",
|
|
49
|
+
"addressdetails": 1,
|
|
50
|
+
"limit": 5,
|
|
51
|
+
}
|
|
52
|
+
headers = {"User-Agent": self._user_agent}
|
|
53
|
+
|
|
54
|
+
return self._request_json(
|
|
55
|
+
self._base_url,
|
|
56
|
+
endpoint,
|
|
57
|
+
params,
|
|
58
|
+
default_params=default_params,
|
|
59
|
+
headers=headers,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def validate_address(
|
|
63
|
+
self,
|
|
64
|
+
address_line1: Optional[str] = None,
|
|
65
|
+
address_line2: Optional[str] = None,
|
|
66
|
+
address_line3: Optional[str] = None,
|
|
67
|
+
city: Optional[str] = None,
|
|
68
|
+
postal_code: Optional[str] = None,
|
|
69
|
+
state: Optional[str] = None,
|
|
70
|
+
country: Optional[str] = None,
|
|
71
|
+
query: Optional[str] = None,
|
|
72
|
+
**kwargs: Any,
|
|
73
|
+
) -> Dict[str, Any]:
|
|
74
|
+
"""Validate an address using Nominatim."""
|
|
75
|
+
query_string, failure = self._resolve_components_query(
|
|
76
|
+
query=query,
|
|
77
|
+
context=locals(),
|
|
78
|
+
failure_builder=lambda msg: self._build_validation_failure(error=msg),
|
|
79
|
+
)
|
|
80
|
+
if failure:
|
|
81
|
+
return failure
|
|
82
|
+
|
|
83
|
+
params = {"q": query_string}
|
|
84
|
+
if country:
|
|
85
|
+
params["countrycodes"] = country.lower()
|
|
86
|
+
|
|
87
|
+
result = self._make_request("/search", params)
|
|
88
|
+
|
|
89
|
+
if "error" in result:
|
|
90
|
+
return self._build_validation_failure(error=result["error"])
|
|
91
|
+
|
|
92
|
+
features: list[Dict[str, Any]] = result if isinstance(result, list) else []
|
|
93
|
+
|
|
94
|
+
def _extract_with_coordinates(feature: Dict[str, Any]) -> Dict[str, Any]:
|
|
95
|
+
payload = self._extract_address_from_result(feature)
|
|
96
|
+
lat = feature.get("lat")
|
|
97
|
+
lon = feature.get("lon")
|
|
98
|
+
if lat and lon:
|
|
99
|
+
payload["latitude"] = float(lat)
|
|
100
|
+
payload["longitude"] = float(lon)
|
|
101
|
+
return payload
|
|
102
|
+
|
|
103
|
+
payload = self._feature_validation_payload(
|
|
104
|
+
features=features,
|
|
105
|
+
extractor=_extract_with_coordinates,
|
|
106
|
+
formatted_getter=lambda feature: feature.get("display_name", ""),
|
|
107
|
+
confidence_getter=lambda feature, _normalized: float(
|
|
108
|
+
min(feature.get("importance", 0.0) * 2.0, 1.0)
|
|
109
|
+
),
|
|
110
|
+
suggestion_formatter=lambda feature, normalized_suggestion: {
|
|
111
|
+
"formatted_address": feature.get("display_name", ""),
|
|
112
|
+
"confidence": float(
|
|
113
|
+
min(feature.get("importance", 0.0) * 2.0, 1.0)
|
|
114
|
+
),
|
|
115
|
+
"latitude": normalized_suggestion.get("latitude"),
|
|
116
|
+
"longitude": normalized_suggestion.get("longitude"),
|
|
117
|
+
},
|
|
118
|
+
valid_threshold=0.5,
|
|
119
|
+
warning_threshold=0.7,
|
|
120
|
+
missing_error="No address found",
|
|
121
|
+
max_suggestions=4,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if features:
|
|
125
|
+
importance = features[0].get("importance", 0.0)
|
|
126
|
+
if importance < 0.5:
|
|
127
|
+
payload["warnings"].append("Low importance match")
|
|
128
|
+
|
|
129
|
+
return payload
|
|
130
|
+
|
|
131
|
+
def geocode(
|
|
132
|
+
self,
|
|
133
|
+
address_line1: Optional[str] = None,
|
|
134
|
+
address_line2: Optional[str] = None,
|
|
135
|
+
address_line3: Optional[str] = None,
|
|
136
|
+
city: Optional[str] = None,
|
|
137
|
+
postal_code: Optional[str] = None,
|
|
138
|
+
state: Optional[str] = None,
|
|
139
|
+
country: Optional[str] = None,
|
|
140
|
+
query: Optional[str] = None,
|
|
141
|
+
**kwargs: Any,
|
|
142
|
+
) -> Dict[str, Any]:
|
|
143
|
+
"""Geocode an address to coordinates using Nominatim."""
|
|
144
|
+
|
|
145
|
+
def _request(query_string: str) -> Dict[str, Any]:
|
|
146
|
+
params = {"q": query_string, "limit": 1}
|
|
147
|
+
if country:
|
|
148
|
+
params["countrycodes"] = country.lower()
|
|
149
|
+
return self._make_request("/search", params)
|
|
150
|
+
|
|
151
|
+
accuracy_map = {
|
|
152
|
+
"house": "ROOFTOP",
|
|
153
|
+
"building": "ROOFTOP",
|
|
154
|
+
"place": "STREET",
|
|
155
|
+
"highway": "STREET",
|
|
156
|
+
"amenity": "STREET",
|
|
157
|
+
"boundary": "CITY",
|
|
158
|
+
"administrative": "CITY",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
def _handle(result: Dict[str, Any], _query: str) -> Dict[str, Any]:
|
|
162
|
+
features: list[Dict[str, Any]] = result if isinstance(result, list) else []
|
|
163
|
+
|
|
164
|
+
def _extract_with_coordinates(feature: Dict[str, Any]) -> Dict[str, Any]:
|
|
165
|
+
payload = self._extract_address_from_result(feature)
|
|
166
|
+
lat = feature.get("lat")
|
|
167
|
+
lon = feature.get("lon")
|
|
168
|
+
if lat is not None:
|
|
169
|
+
payload["latitude"] = float(lat)
|
|
170
|
+
if lon is not None:
|
|
171
|
+
payload["longitude"] = float(lon)
|
|
172
|
+
return payload
|
|
173
|
+
|
|
174
|
+
return self._feature_geocode_payload(
|
|
175
|
+
features=features,
|
|
176
|
+
extractor=_extract_with_coordinates,
|
|
177
|
+
formatted_getter=lambda feature: feature.get("display_name", ""),
|
|
178
|
+
accuracy_getter=lambda feature, _normalized: accuracy_map.get(
|
|
179
|
+
feature.get("class", ""), "UNKNOWN"
|
|
180
|
+
),
|
|
181
|
+
confidence_getter=lambda feature, _normalized: float(
|
|
182
|
+
min(feature.get("importance", 0.0) * 2.0, 1.0)
|
|
183
|
+
),
|
|
184
|
+
missing_error="No address found",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return self._execute_geocode_flow(
|
|
188
|
+
query=query,
|
|
189
|
+
context=locals(),
|
|
190
|
+
failure_builder=self._build_geocode_failure,
|
|
191
|
+
request_callable=_request,
|
|
192
|
+
result_handler=_handle,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def reverse_geocode(
|
|
196
|
+
self, latitude: float, longitude: float, **kwargs: Any
|
|
197
|
+
) -> Dict[str, Any]:
|
|
198
|
+
"""Reverse geocode coordinates to an address using Nominatim."""
|
|
199
|
+
params = {"lat": str(latitude), "lon": str(longitude)}
|
|
200
|
+
if "language" in kwargs:
|
|
201
|
+
params["accept-language"] = kwargs["language"]
|
|
202
|
+
|
|
203
|
+
result = self._make_request("/reverse", params)
|
|
204
|
+
|
|
205
|
+
if "error" in result:
|
|
206
|
+
return {
|
|
207
|
+
"address_line1": None,
|
|
208
|
+
"address_line2": None,
|
|
209
|
+
"address_line3": None,
|
|
210
|
+
"city": None,
|
|
211
|
+
"postal_code": None,
|
|
212
|
+
"state": None,
|
|
213
|
+
"country": None,
|
|
214
|
+
"formatted_address": None,
|
|
215
|
+
"confidence": 0.0,
|
|
216
|
+
"errors": [result["error"]],
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if not isinstance(result, dict):
|
|
220
|
+
return {
|
|
221
|
+
"address_line1": None,
|
|
222
|
+
"address_line2": None,
|
|
223
|
+
"address_line3": None,
|
|
224
|
+
"city": None,
|
|
225
|
+
"postal_code": None,
|
|
226
|
+
"state": None,
|
|
227
|
+
"country": None,
|
|
228
|
+
"formatted_address": None,
|
|
229
|
+
"confidence": 0.0,
|
|
230
|
+
"errors": ["No address found"],
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
normalized = self._extract_address_from_result(result)
|
|
234
|
+
|
|
235
|
+
lat = result.get("lat")
|
|
236
|
+
lon = result.get("lon")
|
|
237
|
+
if lat and lon:
|
|
238
|
+
normalized["latitude"] = float(lat)
|
|
239
|
+
normalized["longitude"] = float(lon)
|
|
240
|
+
|
|
241
|
+
importance = result.get("importance", 0.0)
|
|
242
|
+
confidence = min(importance * 2.0, 1.0)
|
|
243
|
+
place_id = result.get("place_id")
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
**normalized,
|
|
247
|
+
"formatted_address": result.get("display_name", ""),
|
|
248
|
+
"confidence": confidence,
|
|
249
|
+
"address_reference": (
|
|
250
|
+
str(place_id) if place_id else normalized.get("address_reference")
|
|
251
|
+
),
|
|
252
|
+
"errors": [],
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
def get_address_by_reference(
|
|
256
|
+
self, address_reference: str, **kwargs: Any
|
|
257
|
+
) -> Dict[str, Any]:
|
|
258
|
+
"""Retrieve an address by its place_id using Nominatim lookup endpoint."""
|
|
259
|
+
if not address_reference:
|
|
260
|
+
return self._build_empty_address_payload(
|
|
261
|
+
address_reference=address_reference,
|
|
262
|
+
error="address_reference is required",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
place_id = int(address_reference)
|
|
267
|
+
except (ValueError, TypeError):
|
|
268
|
+
return self._build_empty_address_payload(
|
|
269
|
+
address_reference=address_reference,
|
|
270
|
+
error="Invalid place_id format",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
params = {"place_id": place_id, "format": "json", "addressdetails": 1}
|
|
274
|
+
if "language" in kwargs:
|
|
275
|
+
params["accept-language"] = kwargs["language"]
|
|
276
|
+
|
|
277
|
+
result = self._make_request("/lookup", params)
|
|
278
|
+
|
|
279
|
+
if "error" in result:
|
|
280
|
+
return self._build_empty_address_payload(
|
|
281
|
+
address_reference=address_reference, error=result["error"]
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if not isinstance(result, list) or not result:
|
|
285
|
+
return self._build_empty_address_payload(
|
|
286
|
+
address_reference=address_reference,
|
|
287
|
+
error="No address found for this place_id",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
address_result = result[0]
|
|
291
|
+
normalized = self._extract_address_from_result(address_result)
|
|
292
|
+
|
|
293
|
+
lat = address_result.get("lat")
|
|
294
|
+
lon = address_result.get("lon")
|
|
295
|
+
importance = address_result.get("importance", 0.0)
|
|
296
|
+
confidence = min(importance * 2.0, 1.0)
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
**normalized,
|
|
300
|
+
"formatted_address": address_result.get("display_name", ""),
|
|
301
|
+
"latitude": float(lat) if lat else None,
|
|
302
|
+
"longitude": float(lon) if lon else None,
|
|
303
|
+
"confidence": confidence,
|
|
304
|
+
"address_reference": address_reference,
|
|
305
|
+
"errors": [],
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
def _extract_address_from_result(self, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
309
|
+
"""Extract address components from a Nominatim result."""
|
|
310
|
+
address = result.get("address", {})
|
|
311
|
+
|
|
312
|
+
address_line1 = ""
|
|
313
|
+
house_number = address.get("house_number", "")
|
|
314
|
+
road = address.get("road", "")
|
|
315
|
+
if house_number and road:
|
|
316
|
+
address_line1 = f"{house_number} {road}".strip()
|
|
317
|
+
elif road:
|
|
318
|
+
address_line1 = road
|
|
319
|
+
|
|
320
|
+
city = (
|
|
321
|
+
address.get("city")
|
|
322
|
+
or address.get("town")
|
|
323
|
+
or address.get("village")
|
|
324
|
+
or address.get("municipality")
|
|
325
|
+
or ""
|
|
326
|
+
)
|
|
327
|
+
postal_code = address.get("postcode", "")
|
|
328
|
+
state = (
|
|
329
|
+
address.get("state")
|
|
330
|
+
or address.get("region")
|
|
331
|
+
or address.get("province")
|
|
332
|
+
or ""
|
|
333
|
+
)
|
|
334
|
+
country = address.get("country_code", "").upper()
|
|
335
|
+
|
|
336
|
+
# Extract place_id for reverse lookup
|
|
337
|
+
place_id = result.get("place_id")
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
"address_line1": address_line1,
|
|
341
|
+
"address_line2": "",
|
|
342
|
+
"address_line3": "",
|
|
343
|
+
"city": city,
|
|
344
|
+
"postal_code": postal_code,
|
|
345
|
+
"state": state,
|
|
346
|
+
"country": country,
|
|
347
|
+
"address_reference": str(place_id) if place_id else None,
|
|
348
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""OpenCage Geocoding API address verification backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .base import BaseAddressBackend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OpenCageAddressBackend(BaseAddressBackend):
|
|
11
|
+
"""OpenCage Geocoding API backend for address verification.
|
|
12
|
+
|
|
13
|
+
Free tier: 5000 requests/day.
|
|
14
|
+
Requires API key (free registration).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name = "opencage"
|
|
18
|
+
display_name = "OpenCage"
|
|
19
|
+
config_keys = ["OPENCAGE_API_KEY", "OPENCAGE_BASE_URL"]
|
|
20
|
+
required_packages = ["requests"]
|
|
21
|
+
documentation_url = "https://opencagedata.com/api"
|
|
22
|
+
site_url = "https://opencagedata.com"
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
25
|
+
"""Initialize OpenCage backend.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config: Optional configuration dict with:
|
|
29
|
+
- OPENCAGE_API_KEY: API key (required)
|
|
30
|
+
- OPENCAGE_BASE_URL: Custom base URL (default: official)
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(config)
|
|
33
|
+
self._api_key = self._config.get("OPENCAGE_API_KEY")
|
|
34
|
+
if not self._api_key:
|
|
35
|
+
raise ValueError("OPENCAGE_API_KEY is required")
|
|
36
|
+
self._base_url = self._config.get(
|
|
37
|
+
"OPENCAGE_BASE_URL", "https://api.opencagedata.com/geocode/v1"
|
|
38
|
+
)
|
|
39
|
+
self._last_request_time = 0.0
|
|
40
|
+
|
|
41
|
+
def _make_request(
|
|
42
|
+
self, endpoint: str, params: Optional[Dict[str, Any]] = None
|
|
43
|
+
) -> Dict[str, Any]:
|
|
44
|
+
"""Make a request to the OpenCage API."""
|
|
45
|
+
self._rate_limit_with_interval("_last_request_time", 0.5)
|
|
46
|
+
|
|
47
|
+
default_params: Dict[str, Any] = {"key": self._api_key}
|
|
48
|
+
return self._request_json(
|
|
49
|
+
self._base_url,
|
|
50
|
+
endpoint,
|
|
51
|
+
params,
|
|
52
|
+
default_params=default_params,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def _extract_address_from_result(self, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
56
|
+
"""Extract address components from an OpenCage result."""
|
|
57
|
+
components = result.get("components", {})
|
|
58
|
+
|
|
59
|
+
# Extract address_line1
|
|
60
|
+
address_line1 = ""
|
|
61
|
+
if components.get("house_number") and components.get("road"):
|
|
62
|
+
address_line1 = f"{components.get('house_number')} {components.get('road')}".strip()
|
|
63
|
+
elif components.get("road"):
|
|
64
|
+
address_line1 = components.get("road", "")
|
|
65
|
+
elif result.get("formatted"):
|
|
66
|
+
# Fallback: use first part of formatted address
|
|
67
|
+
formatted = result.get("formatted", "")
|
|
68
|
+
parts = formatted.split(",")
|
|
69
|
+
if parts:
|
|
70
|
+
address_line1 = parts[0].strip()
|
|
71
|
+
|
|
72
|
+
# Extract city (try multiple fields)
|
|
73
|
+
city = (
|
|
74
|
+
components.get("city")
|
|
75
|
+
or components.get("town")
|
|
76
|
+
or components.get("village")
|
|
77
|
+
or components.get("municipality")
|
|
78
|
+
or ""
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Extract state/region
|
|
82
|
+
state = (
|
|
83
|
+
components.get("state")
|
|
84
|
+
or components.get("region")
|
|
85
|
+
or components.get("state_district")
|
|
86
|
+
or ""
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Extract country code
|
|
90
|
+
country_code = (
|
|
91
|
+
components.get("country_code", "").upper() if components.get("country_code") else ""
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
"address_line1": address_line1 or "",
|
|
96
|
+
"address_line2": "",
|
|
97
|
+
"address_line3": "",
|
|
98
|
+
"city": city,
|
|
99
|
+
"postal_code": components.get("postcode", ""),
|
|
100
|
+
"state": state,
|
|
101
|
+
"country": country_code,
|
|
102
|
+
"address_reference": str(result.get("annotations", {}).get("geohash", "")),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def validate_address(
|
|
106
|
+
self,
|
|
107
|
+
address_line1: Optional[str] = None,
|
|
108
|
+
address_line2: Optional[str] = None,
|
|
109
|
+
address_line3: Optional[str] = None,
|
|
110
|
+
city: Optional[str] = None,
|
|
111
|
+
postal_code: Optional[str] = None,
|
|
112
|
+
state: Optional[str] = None,
|
|
113
|
+
country: Optional[str] = None,
|
|
114
|
+
query: Optional[str] = None,
|
|
115
|
+
**kwargs: Any,
|
|
116
|
+
) -> Dict[str, Any]:
|
|
117
|
+
"""Validate an address using OpenCage."""
|
|
118
|
+
query_string, failure = self._resolve_components_query(
|
|
119
|
+
query=query,
|
|
120
|
+
context=locals(),
|
|
121
|
+
failure_builder=lambda msg: self._build_validation_failure(error=msg),
|
|
122
|
+
)
|
|
123
|
+
if failure:
|
|
124
|
+
return failure
|
|
125
|
+
|
|
126
|
+
params: Dict[str, Any] = {"q": query_string, "limit": 5, "no_annotations": 0}
|
|
127
|
+
if country:
|
|
128
|
+
params["countrycode"] = country.lower()
|
|
129
|
+
if "language" in kwargs:
|
|
130
|
+
params["language"] = kwargs["language"]
|
|
131
|
+
|
|
132
|
+
result = self._make_request("/json", params)
|
|
133
|
+
|
|
134
|
+
if "error" in result:
|
|
135
|
+
return self._build_validation_failure(error=result["error"])
|
|
136
|
+
|
|
137
|
+
results = result.get("results", [])
|
|
138
|
+
|
|
139
|
+
def _extract_feature(feature: Dict[str, Any]) -> Dict[str, Any]:
|
|
140
|
+
payload = self._extract_address_from_result(feature)
|
|
141
|
+
geometry = feature.get("geometry", {})
|
|
142
|
+
lat = geometry.get("lat")
|
|
143
|
+
lon = geometry.get("lng")
|
|
144
|
+
if lat is not None:
|
|
145
|
+
payload["latitude"] = float(lat)
|
|
146
|
+
if lon is not None:
|
|
147
|
+
payload["longitude"] = float(lon)
|
|
148
|
+
confidence_raw = feature.get("confidence", 0)
|
|
149
|
+
payload["confidence"] = float(min(confidence_raw / 10.0, 1.0))
|
|
150
|
+
annotations = feature.get("annotations", {})
|
|
151
|
+
geohash = annotations.get("geohash")
|
|
152
|
+
if geohash:
|
|
153
|
+
payload["address_reference"] = str(geohash)
|
|
154
|
+
return payload
|
|
155
|
+
|
|
156
|
+
return self._feature_validation_payload(
|
|
157
|
+
features=results,
|
|
158
|
+
extractor=_extract_feature,
|
|
159
|
+
formatted_getter=lambda feature: feature.get("formatted", ""),
|
|
160
|
+
confidence_getter=lambda feature, normalized: normalized.get("confidence", 0.0),
|
|
161
|
+
suggestion_formatter=lambda feature, normalized_suggestion: {
|
|
162
|
+
"formatted_address": feature.get("formatted", ""),
|
|
163
|
+
"confidence": normalized_suggestion.get("confidence", 0.0),
|
|
164
|
+
"latitude": normalized_suggestion.get("latitude"),
|
|
165
|
+
"longitude": normalized_suggestion.get("longitude"),
|
|
166
|
+
},
|
|
167
|
+
valid_threshold=0.5,
|
|
168
|
+
warning_threshold=0.7,
|
|
169
|
+
missing_error="No address found",
|
|
170
|
+
max_suggestions=4,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def geocode(
|
|
174
|
+
self,
|
|
175
|
+
address_line1: Optional[str] = None,
|
|
176
|
+
address_line2: Optional[str] = None,
|
|
177
|
+
address_line3: Optional[str] = None,
|
|
178
|
+
city: Optional[str] = None,
|
|
179
|
+
postal_code: Optional[str] = None,
|
|
180
|
+
state: Optional[str] = None,
|
|
181
|
+
country: Optional[str] = None,
|
|
182
|
+
query: Optional[str] = None,
|
|
183
|
+
**kwargs: Any,
|
|
184
|
+
) -> Dict[str, Any]:
|
|
185
|
+
"""Geocode an address to coordinates using OpenCage."""
|
|
186
|
+
|
|
187
|
+
def _request(query_string: str) -> Dict[str, Any]:
|
|
188
|
+
params: Dict[str, Any] = {"q": query_string, "limit": 1, "no_annotations": 0}
|
|
189
|
+
if country:
|
|
190
|
+
params["countrycode"] = country.lower()
|
|
191
|
+
if "language" in kwargs:
|
|
192
|
+
params["language"] = kwargs["language"]
|
|
193
|
+
return self._make_request("/json", params)
|
|
194
|
+
|
|
195
|
+
accuracy_map = {
|
|
196
|
+
"house_number": "ROOFTOP",
|
|
197
|
+
"road": "STREET",
|
|
198
|
+
"city": "CITY",
|
|
199
|
+
"town": "CITY",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
def _handle(result: Dict[str, Any], _query: str) -> Dict[str, Any]:
|
|
203
|
+
results = result.get("results", [])
|
|
204
|
+
|
|
205
|
+
def _extract_feature(feature: Dict[str, Any]) -> Dict[str, Any]:
|
|
206
|
+
payload = self._extract_address_from_result(feature)
|
|
207
|
+
geometry = feature.get("geometry", {})
|
|
208
|
+
lat = geometry.get("lat")
|
|
209
|
+
lon = geometry.get("lng")
|
|
210
|
+
if lat is not None:
|
|
211
|
+
payload["latitude"] = float(lat)
|
|
212
|
+
if lon is not None:
|
|
213
|
+
payload["longitude"] = float(lon)
|
|
214
|
+
confidence_raw = feature.get("confidence", 0)
|
|
215
|
+
payload["confidence"] = float(min(confidence_raw / 10.0, 1.0))
|
|
216
|
+
annotations = feature.get("annotations", {})
|
|
217
|
+
geohash = annotations.get("geohash")
|
|
218
|
+
if geohash:
|
|
219
|
+
payload["address_reference"] = str(geohash)
|
|
220
|
+
payload["formatted_address"] = feature.get("formatted", "")
|
|
221
|
+
return payload
|
|
222
|
+
|
|
223
|
+
def _accuracy_from_feature(
|
|
224
|
+
feature: Dict[str, Any], _normalized: Dict[str, Any]
|
|
225
|
+
) -> str:
|
|
226
|
+
components = feature.get("components", {})
|
|
227
|
+
for key in ("house_number", "road", "city", "town"):
|
|
228
|
+
if components.get(key):
|
|
229
|
+
return accuracy_map[key]
|
|
230
|
+
return "APPROXIMATE"
|
|
231
|
+
|
|
232
|
+
return self._feature_geocode_payload(
|
|
233
|
+
features=results,
|
|
234
|
+
extractor=_extract_feature,
|
|
235
|
+
formatted_getter=lambda feature: feature.get("formatted", ""),
|
|
236
|
+
accuracy_getter=_accuracy_from_feature,
|
|
237
|
+
confidence_getter=lambda feature, normalized: normalized.get("confidence", 0.0),
|
|
238
|
+
missing_error="No address found",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return self._execute_geocode_flow(
|
|
242
|
+
query=query,
|
|
243
|
+
context=locals(),
|
|
244
|
+
failure_builder=self._build_geocode_failure,
|
|
245
|
+
request_callable=_request,
|
|
246
|
+
result_handler=_handle,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def reverse_geocode(self, latitude: float, longitude: float, **kwargs: Any) -> Dict[str, Any]:
|
|
250
|
+
"""Reverse geocode coordinates to an address using OpenCage."""
|
|
251
|
+
params: Dict[str, Any] = {
|
|
252
|
+
"q": f"{latitude},{longitude}",
|
|
253
|
+
"no_annotations": 0,
|
|
254
|
+
}
|
|
255
|
+
if "language" in kwargs:
|
|
256
|
+
params["language"] = kwargs["language"]
|
|
257
|
+
|
|
258
|
+
result = self._make_request("/json", params)
|
|
259
|
+
|
|
260
|
+
if "error" in result:
|
|
261
|
+
return self._build_empty_address_payload(error=result["error"])
|
|
262
|
+
|
|
263
|
+
results = result.get("results", [])
|
|
264
|
+
if not results:
|
|
265
|
+
return self._build_empty_address_payload(error="No address found")
|
|
266
|
+
|
|
267
|
+
best_match = results[0]
|
|
268
|
+
normalized = self._extract_address_from_result(best_match)
|
|
269
|
+
normalized["latitude"] = latitude
|
|
270
|
+
normalized["longitude"] = longitude
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
**normalized,
|
|
274
|
+
"formatted_address": best_match.get("formatted", ""),
|
|
275
|
+
"errors": [],
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
def get_address_by_reference(self, address_reference: str, **kwargs: Any) -> Dict[str, Any]:
|
|
279
|
+
"""Get address by reference ID using OpenCage.
|
|
280
|
+
|
|
281
|
+
OpenCage uses geohash as reference.
|
|
282
|
+
We can use reverse geocode with coordinates decoded from geohash.
|
|
283
|
+
However, OpenCage doesn't have a direct lookup by geohash endpoint.
|
|
284
|
+
"""
|
|
285
|
+
# OpenCage doesn't support direct lookup by geohash
|
|
286
|
+
# We would need to decode geohash to coordinates and use reverse_geocode
|
|
287
|
+
return self._build_empty_address_payload(
|
|
288
|
+
error=(
|
|
289
|
+
"OpenCage does not support direct lookup by reference ID. "
|
|
290
|
+
"Use reverse_geocode with coordinates instead."
|
|
291
|
+
)
|
|
292
|
+
)
|