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.
Files changed (95) hide show
  1. pymissive/__init__.py +3 -0
  2. pymissive/__main__.py +9 -0
  3. pymissive/archives/__init__.py +142 -0
  4. pymissive/archives/__main__.py +9 -0
  5. pymissive/archives/address.py +272 -0
  6. pymissive/archives/address_backends/__init__.py +29 -0
  7. pymissive/archives/address_backends/base.py +610 -0
  8. pymissive/archives/address_backends/geoapify.py +221 -0
  9. pymissive/archives/address_backends/geocode_earth.py +210 -0
  10. pymissive/archives/address_backends/google_maps.py +371 -0
  11. pymissive/archives/address_backends/here.py +348 -0
  12. pymissive/archives/address_backends/locationiq.py +271 -0
  13. pymissive/archives/address_backends/mapbox.py +314 -0
  14. pymissive/archives/address_backends/maps_co.py +257 -0
  15. pymissive/archives/address_backends/nominatim.py +348 -0
  16. pymissive/archives/address_backends/opencage.py +292 -0
  17. pymissive/archives/address_backends/pelias_mixin.py +181 -0
  18. pymissive/archives/address_backends/photon.py +322 -0
  19. pymissive/archives/cli.py +42 -0
  20. pymissive/archives/helpers.py +45 -0
  21. pymissive/archives/missive.py +64 -0
  22. pymissive/archives/providers/__init__.py +167 -0
  23. pymissive/archives/providers/apn.py +171 -0
  24. pymissive/archives/providers/ar24.py +204 -0
  25. pymissive/archives/providers/base/__init__.py +203 -0
  26. pymissive/archives/providers/base/_attachments.py +166 -0
  27. pymissive/archives/providers/base/branded.py +341 -0
  28. pymissive/archives/providers/base/common.py +781 -0
  29. pymissive/archives/providers/base/email.py +422 -0
  30. pymissive/archives/providers/base/email_message.py +85 -0
  31. pymissive/archives/providers/base/monitoring.py +150 -0
  32. pymissive/archives/providers/base/notification.py +187 -0
  33. pymissive/archives/providers/base/postal.py +742 -0
  34. pymissive/archives/providers/base/postal_defaults.py +26 -0
  35. pymissive/archives/providers/base/sms.py +213 -0
  36. pymissive/archives/providers/base/voice_call.py +82 -0
  37. pymissive/archives/providers/brevo.py +363 -0
  38. pymissive/archives/providers/certeurope.py +249 -0
  39. pymissive/archives/providers/django_email.py +182 -0
  40. pymissive/archives/providers/fcm.py +91 -0
  41. pymissive/archives/providers/laposte.py +392 -0
  42. pymissive/archives/providers/maileva.py +511 -0
  43. pymissive/archives/providers/mailgun.py +118 -0
  44. pymissive/archives/providers/messenger.py +74 -0
  45. pymissive/archives/providers/notification.py +112 -0
  46. pymissive/archives/providers/sendgrid.py +160 -0
  47. pymissive/archives/providers/ses.py +185 -0
  48. pymissive/archives/providers/signal.py +68 -0
  49. pymissive/archives/providers/slack.py +80 -0
  50. pymissive/archives/providers/smtp.py +190 -0
  51. pymissive/archives/providers/teams.py +91 -0
  52. pymissive/archives/providers/telegram.py +69 -0
  53. pymissive/archives/providers/twilio.py +310 -0
  54. pymissive/archives/providers/vonage.py +208 -0
  55. pymissive/archives/sender.py +339 -0
  56. pymissive/archives/status.py +22 -0
  57. pymissive/cli.py +42 -0
  58. pymissive/config.py +397 -0
  59. pymissive/helpers.py +0 -0
  60. pymissive/providers/apn.py +8 -0
  61. pymissive/providers/ar24.py +8 -0
  62. pymissive/providers/base/__init__.py +64 -0
  63. pymissive/providers/base/acknowledgement.py +6 -0
  64. pymissive/providers/base/attachments.py +10 -0
  65. pymissive/providers/base/branded.py +16 -0
  66. pymissive/providers/base/email.py +2 -0
  67. pymissive/providers/base/notification.py +2 -0
  68. pymissive/providers/base/postal.py +2 -0
  69. pymissive/providers/base/sms.py +2 -0
  70. pymissive/providers/base/voice_call.py +2 -0
  71. pymissive/providers/brevo.py +420 -0
  72. pymissive/providers/certeurope.py +8 -0
  73. pymissive/providers/django_email.py +8 -0
  74. pymissive/providers/fcm.py +8 -0
  75. pymissive/providers/laposte.py +8 -0
  76. pymissive/providers/maileva.py +8 -0
  77. pymissive/providers/mailgun.py +8 -0
  78. pymissive/providers/messenger.py +8 -0
  79. pymissive/providers/notification.py +8 -0
  80. pymissive/providers/partner.py +650 -0
  81. pymissive/providers/scaleway.py +498 -0
  82. pymissive/providers/sendgrid.py +8 -0
  83. pymissive/providers/ses.py +8 -0
  84. pymissive/providers/signal.py +8 -0
  85. pymissive/providers/slack.py +8 -0
  86. pymissive/providers/smtp.py +8 -0
  87. pymissive/providers/teams.py +8 -0
  88. pymissive/providers/telegram.py +8 -0
  89. pymissive/providers/twilio.py +8 -0
  90. pymissive/providers/vonage.py +8 -0
  91. python_missive-0.2.0.dist-info/METADATA +152 -0
  92. python_missive-0.2.0.dist-info/RECORD +95 -0
  93. python_missive-0.2.0.dist-info/WHEEL +5 -0
  94. python_missive-0.2.0.dist-info/entry_points.txt +2 -0
  95. 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
+ )