geobatch 0.1.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.
geobatch/__init__.py ADDED
@@ -0,0 +1,97 @@
1
+ """geobatch: polite, resumable batch geocoding and reverse geocoding.
2
+
3
+ Quick start::
4
+
5
+ from geobatch import Geocoder, Nominatim
6
+
7
+ gc = Geocoder(Nominatim(user_agent="MyProject/1.0 (https://my-project.org)"))
8
+ result = gc.geocode_many(["Brandenburger Tor, Berlin", "Eiffel Tower, Paris"])
9
+ for rec in result:
10
+ print(rec.status, rec.result.lat if rec.result else None, rec.flags)
11
+ """
12
+
13
+ from ._version import __version__
14
+ from .batch import BatchInterrupted, BatchResult, Geocoder, Summary, to_query
15
+ from .cache import Cache, default_cache_path
16
+ from .errors import (
17
+ ConfigurationError,
18
+ GeobatchError,
19
+ HttpError,
20
+ MalformedResponseError,
21
+ MissingAPIKeyError,
22
+ ProviderError,
23
+ TransportError,
24
+ UsagePolicyError,
25
+ )
26
+ from .http import RetryPolicy
27
+ from .io import (
28
+ build_queries,
29
+ read_table,
30
+ to_geojson,
31
+ write_csv,
32
+ write_geojson,
33
+ write_gpkg,
34
+ write_output,
35
+ )
36
+ from .models import BatchRecord, GeocodeResult, Query
37
+ from .normalize import normalize_address, normalize_query
38
+ from .providers import (
39
+ PROVIDERS,
40
+ Geoapify,
41
+ GoogleGeocoding,
42
+ Mapbox,
43
+ Nominatim,
44
+ OpenCage,
45
+ Pelias,
46
+ Photon,
47
+ Provider,
48
+ SearchOptions,
49
+ TemplateProvider,
50
+ make_provider,
51
+ )
52
+ from .quality import QualityConfig
53
+
54
+ __all__ = [
55
+ "PROVIDERS",
56
+ "BatchInterrupted",
57
+ "BatchRecord",
58
+ "BatchResult",
59
+ "Cache",
60
+ "ConfigurationError",
61
+ "GeobatchError",
62
+ "GeocodeResult",
63
+ "Geocoder",
64
+ "Geoapify",
65
+ "GoogleGeocoding",
66
+ "HttpError",
67
+ "MalformedResponseError",
68
+ "Mapbox",
69
+ "MissingAPIKeyError",
70
+ "Nominatim",
71
+ "OpenCage",
72
+ "Pelias",
73
+ "Photon",
74
+ "Provider",
75
+ "ProviderError",
76
+ "QualityConfig",
77
+ "Query",
78
+ "RetryPolicy",
79
+ "SearchOptions",
80
+ "Summary",
81
+ "TemplateProvider",
82
+ "TransportError",
83
+ "UsagePolicyError",
84
+ "__version__",
85
+ "build_queries",
86
+ "default_cache_path",
87
+ "make_provider",
88
+ "normalize_address",
89
+ "normalize_query",
90
+ "read_table",
91
+ "to_geojson",
92
+ "to_query",
93
+ "write_csv",
94
+ "write_geojson",
95
+ "write_gpkg",
96
+ "write_output",
97
+ ]
geobatch/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m geobatch``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
geobatch/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
geobatch/batch.py ADDED
@@ -0,0 +1,526 @@
1
+ """The batch orchestrator: cache, de-duplication, concurrency, fallback, flags."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import threading
8
+ import time
9
+ from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import (
13
+ Any,
14
+ Callable,
15
+ Dict,
16
+ Iterable,
17
+ List,
18
+ Mapping,
19
+ Optional,
20
+ Sequence,
21
+ TextIO,
22
+ Tuple,
23
+ Union,
24
+ )
25
+
26
+ from .cache import Cache, make_key
27
+ from .errors import ConfigurationError, GeobatchError, ProviderError, UsagePolicyError
28
+ from .models import (
29
+ STATUS_ERROR,
30
+ STATUS_NOT_FOUND,
31
+ STATUS_OK,
32
+ STATUS_PENDING,
33
+ STATUS_SKIPPED,
34
+ BatchRecord,
35
+ GeocodeResult,
36
+ Query,
37
+ )
38
+ from .normalize import normalize_query, query_cache_text
39
+ from .progress import ProgressBar
40
+ from .providers.base import Provider, SearchOptions
41
+ from .quality import QualityConfig, assess_record, count_flags, flag_duplicates
42
+
43
+ QueryLike = Union[str, Query, Mapping[str, Any], None]
44
+ CacheLike = Union[Cache, str, "os.PathLike[str]", bool, None]
45
+
46
+
47
+ class BatchInterrupted(KeyboardInterrupt):
48
+ """Raised on Ctrl+C during a batch; ``.result`` holds the partial result.
49
+
50
+ Every lookup finished before the interruption is already in the cache, so
51
+ running the same batch again resumes where it stopped.
52
+ """
53
+
54
+ def __init__(self, result: BatchResult) -> None:
55
+ super().__init__("batch interrupted")
56
+ self.result = result
57
+
58
+
59
+ @dataclass
60
+ class Summary:
61
+ """Counts describing one batch run."""
62
+
63
+ kind: str = "forward"
64
+ total: int = 0
65
+ ok: int = 0
66
+ not_found: int = 0
67
+ error: int = 0
68
+ skipped: int = 0
69
+ pending: int = 0
70
+ unique_lookups: int = 0
71
+ cache_hits: int = 0
72
+ network_requests: int = 0
73
+ retries: int = 0
74
+ by_provider: Dict[str, int] = field(default_factory=dict)
75
+ by_precision: Dict[str, int] = field(default_factory=dict)
76
+ flags: Dict[str, int] = field(default_factory=dict)
77
+ elapsed_s: float = 0.0
78
+ interrupted: bool = False
79
+ aborted: str = ""
80
+
81
+ def to_dict(self) -> Dict[str, Any]:
82
+ return dict(self.__dict__)
83
+
84
+ def format(self) -> str:
85
+ lines = [
86
+ f"geobatch {self.kind} summary",
87
+ f" rows {self.total}",
88
+ f" ok {self.ok}",
89
+ f" not found {self.not_found}",
90
+ f" errors {self.error}",
91
+ f" skipped (empty) {self.skipped}",
92
+ ]
93
+ if self.pending:
94
+ lines.append(f" not processed {self.pending}")
95
+ lines += [
96
+ f" unique lookups {self.unique_lookups}",
97
+ f" cache hits {self.cache_hits}",
98
+ f" HTTP requests {self.network_requests} (retries {self.retries})",
99
+ f" elapsed {self.elapsed_s:.1f} s",
100
+ ]
101
+ if self.by_provider:
102
+ lines.append(
103
+ " by provider "
104
+ + ", ".join(f"{k}={v}" for k, v in sorted(self.by_provider.items()))
105
+ )
106
+ if self.by_precision:
107
+ lines.append(
108
+ " by precision "
109
+ + ", ".join(f"{k}={v}" for k, v in sorted(self.by_precision.items()))
110
+ )
111
+ if self.flags:
112
+ lines.append(
113
+ " quality flags " + ", ".join(f"{k}={v}" for k, v in sorted(self.flags.items()))
114
+ )
115
+ if self.interrupted:
116
+ lines.append(" INTERRUPTED: re-run the same command to resume from the cache")
117
+ if self.aborted:
118
+ lines.append(f" ABORTED: {self.aborted}")
119
+ return "\n".join(lines)
120
+
121
+
122
+ @dataclass
123
+ class BatchResult:
124
+ records: List[BatchRecord]
125
+ summary: Summary
126
+
127
+ def __iter__(self) -> Any:
128
+ return iter(self.records)
129
+
130
+ def __len__(self) -> int:
131
+ return len(self.records)
132
+
133
+
134
+ @dataclass
135
+ class _Outcome:
136
+ status: str
137
+ results: List[GeocodeResult] = field(default_factory=list)
138
+ provider: str = ""
139
+ from_cache: bool = False
140
+ attempts: int = 0
141
+ error: str = ""
142
+
143
+
144
+ def to_query(value: QueryLike) -> Query:
145
+ """Coerce a string, mapping or :class:`Query` into a :class:`Query`."""
146
+ if value is None:
147
+ return Query()
148
+ if isinstance(value, Query):
149
+ return value
150
+ if isinstance(value, str):
151
+ return Query(text=value)
152
+ if isinstance(value, Mapping):
153
+ allowed = {"text", "street", "city", "county", "state", "postcode", "country"}
154
+ unknown = set(value) - allowed
155
+ if unknown:
156
+ raise ConfigurationError(f"unknown query fields: {', '.join(sorted(unknown))}")
157
+ return Query(**{k: "" if v is None else str(v) for k, v in value.items()})
158
+ raise ConfigurationError(f"cannot turn {type(value).__name__} into a Query")
159
+
160
+
161
+ def _open_cache(cache: CacheLike) -> Optional[Cache]:
162
+ if cache is None or cache is False:
163
+ return None
164
+ if cache is True:
165
+ return Cache()
166
+ if isinstance(cache, Cache):
167
+ return cache
168
+ return Cache(Path(cache))
169
+
170
+
171
+ class Geocoder:
172
+ """Batch geocoder over a fallback chain of providers.
173
+
174
+ Parameters
175
+ ----------
176
+ providers:
177
+ One provider or a list tried in order (fallback chain).
178
+ cache:
179
+ ``True`` (default) for the per-user SQLite cache, a path, a
180
+ :class:`Cache`, or ``False`` to disable caching (refused for providers
181
+ whose policy requires caching, such as public Nominatim).
182
+ options:
183
+ Country/bbox biasing and language, see :class:`SearchOptions`.
184
+ quality:
185
+ Thresholds for quality flags; by default the expected countries and
186
+ bbox are taken from ``options``.
187
+ fallback_on_not_found:
188
+ Also ask the next provider when one finds nothing (not only on errors).
189
+ max_consecutive_errors:
190
+ Abort the batch after this many consecutive failed lookups (the
191
+ service is probably down or blocking you; hammering it is impolite).
192
+ notice_stream:
193
+ Where policy notices are printed (stderr by default, ``None`` = silent).
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ providers: Union[Provider, Sequence[Provider]],
199
+ cache: CacheLike = True,
200
+ options: Optional[SearchOptions] = None,
201
+ quality: Optional[QualityConfig] = None,
202
+ fallback_on_not_found: bool = True,
203
+ max_consecutive_errors: int = 25,
204
+ notice_stream: Optional[TextIO] = sys.stderr,
205
+ ) -> None:
206
+ chain = [providers] if isinstance(providers, Provider) else list(providers)
207
+ if not chain:
208
+ raise ConfigurationError("at least one provider is required")
209
+ labels = [p.label for p in chain]
210
+ if len(set(labels)) != len(labels):
211
+ raise ConfigurationError("providers in a chain need distinct labels")
212
+ self.providers: List[Provider] = chain
213
+ self.cache = _open_cache(cache)
214
+ for p in chain:
215
+ if p.requires_cache and self.cache is None:
216
+ raise UsagePolicyError(
217
+ f"{p.label}: results must be cached under this provider's usage policy; "
218
+ "enable the cache"
219
+ )
220
+ self.options = options or SearchOptions()
221
+ self.quality = quality or QualityConfig(
222
+ expected_countries=self.options.country_codes,
223
+ expected_bbox=self.options.bbox if self.options.bounded else None,
224
+ )
225
+ self.fallback_on_not_found = fallback_on_not_found
226
+ self.max_consecutive_errors = max(1, int(max_consecutive_errors))
227
+ self.notices: List[str] = [n for p in chain for n in p.notices]
228
+ if notice_stream is not None:
229
+ for note in self.notices:
230
+ notice_stream.write(f"geobatch: {note}\n")
231
+ notice_stream.flush()
232
+
233
+ # ------------------------------------------------------------------ core
234
+ def _lookup(
235
+ self,
236
+ kind: str,
237
+ key_text: str,
238
+ call: Callable[[Provider], Tuple[List[GeocodeResult], int]],
239
+ ) -> _Outcome:
240
+ errors: List[str] = []
241
+ not_found_by: List[str] = []
242
+ attempts = 0
243
+ all_cached = True
244
+ for prov in self.providers:
245
+ key = make_key(prov.provider_id, kind, key_text, prov.cache_params(self.options))
246
+ use_cache = self.cache is not None and prov.cacheable
247
+ if use_cache:
248
+ entry = self.cache.get(key) # type: ignore[union-attr]
249
+ if entry is not None:
250
+ if entry.found:
251
+ return _Outcome(STATUS_OK, entry.results, prov.label, all_cached, attempts)
252
+ not_found_by.append(prov.label)
253
+ if not self.fallback_on_not_found:
254
+ break
255
+ continue
256
+ all_cached = False
257
+ try:
258
+ results, n = call(prov)
259
+ attempts += n
260
+ except UsagePolicyError as exc:
261
+ errors.append(f"{prov.label}: {exc}")
262
+ continue
263
+ except ProviderError as exc:
264
+ errors.append(f"{prov.label}: {exc}")
265
+ continue
266
+ if use_cache:
267
+ self.cache.put( # type: ignore[union-attr]
268
+ key, prov.provider_id, kind, key_text, results, prov.cache_ttl
269
+ )
270
+ if results:
271
+ return _Outcome(STATUS_OK, results, prov.label, False, attempts)
272
+ not_found_by.append(prov.label)
273
+ if not self.fallback_on_not_found:
274
+ break
275
+ if not_found_by:
276
+ return _Outcome(
277
+ STATUS_NOT_FOUND,
278
+ provider=not_found_by[-1],
279
+ from_cache=all_cached,
280
+ attempts=attempts,
281
+ error="; ".join(errors),
282
+ )
283
+ return _Outcome(STATUS_ERROR, attempts=attempts, error="; ".join(errors))
284
+
285
+ def _run(
286
+ self,
287
+ kind: str,
288
+ records: List[BatchRecord],
289
+ jobs: Dict[str, Tuple[Callable[[Provider], Tuple[List[GeocodeResult], int]], List[int]]],
290
+ progress: Union[bool, ProgressBar],
291
+ expected: Optional[Sequence[str]],
292
+ ) -> BatchResult:
293
+ start = time.monotonic()
294
+ before = [(p.transport.stats.requests, p.transport.stats.retries) for p in self.providers]
295
+ summary = Summary(kind=kind, total=len(records), unique_lookups=len(jobs))
296
+ bar: Optional[ProgressBar]
297
+ if isinstance(progress, ProgressBar):
298
+ bar = progress
299
+ elif progress:
300
+ bar = ProgressBar(len(records), label="geocoding" if kind == "forward" else "reverse")
301
+ else:
302
+ bar = None
303
+ if bar is not None:
304
+ skipped = sum(1 for r in records if r.status == STATUS_SKIPPED)
305
+ if skipped:
306
+ bar.update(skipped)
307
+
308
+ abort = threading.Event()
309
+ abort_reason: List[str] = []
310
+ streak = [0]
311
+ streak_lock = threading.Lock()
312
+
313
+ def task(key_text: str) -> _Outcome:
314
+ if abort.is_set():
315
+ return _Outcome(STATUS_PENDING, error=abort_reason[0] if abort_reason else "")
316
+ call = jobs[key_text][0]
317
+ outcome = self._lookup(kind, key_text, call)
318
+ with streak_lock:
319
+ if outcome.status == STATUS_ERROR:
320
+ streak[0] += 1
321
+ if streak[0] >= self.max_consecutive_errors and not abort.is_set():
322
+ abort_reason.append(
323
+ f"{streak[0]} consecutive lookups failed (last: {outcome.error}); "
324
+ "stopping to avoid hammering the service"
325
+ )
326
+ abort.set()
327
+ else:
328
+ streak[0] = 0
329
+ return outcome
330
+
331
+ def apply(key_text: str, outcome: _Outcome) -> None:
332
+ for idx in jobs[key_text][1]:
333
+ rec = records[idx]
334
+ rec.status = outcome.status
335
+ rec.result = outcome.results[0] if outcome.results else None
336
+ rec.provider = outcome.provider
337
+ rec.from_cache = outcome.from_cache and outcome.status != STATUS_PENDING
338
+ rec.attempts = outcome.attempts
339
+ rec.error = outcome.error
340
+ if bar is not None and outcome.status != STATUS_PENDING:
341
+ bar.update(cached=rec.from_cache, failed=outcome.status == STATUS_ERROR)
342
+
343
+ workers = max(p.concurrency for p in self.providers)
344
+ pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="geobatch")
345
+ pending: Dict[Future[_Outcome], str] = {}
346
+ interrupted = False
347
+ try:
348
+ keys = iter(list(jobs))
349
+ # Keep a bounded number of tasks in flight so Ctrl+C stops quickly.
350
+ for key_text in keys:
351
+ pending[pool.submit(task, key_text)] = key_text
352
+ if len(pending) >= workers * 2:
353
+ break
354
+ while pending:
355
+ done, _ = wait(list(pending), return_when=FIRST_COMPLETED)
356
+ for fut in done:
357
+ apply(pending.pop(fut), fut.result())
358
+ nxt = next(keys, None)
359
+ if nxt is not None:
360
+ pending[pool.submit(task, nxt)] = nxt
361
+ except KeyboardInterrupt:
362
+ interrupted = True
363
+ abort_reason.append("interrupted")
364
+ abort.set()
365
+ for fut in pending:
366
+ fut.cancel()
367
+ finally:
368
+ # Requests already on the wire are allowed to finish (and be
369
+ # cached); queued work is cancelled or returns immediately.
370
+ pool.shutdown(wait=True, cancel_futures=True)
371
+ if bar is not None:
372
+ bar.close()
373
+
374
+ self._finish(records, summary, expected)
375
+ summary.elapsed_s = round(time.monotonic() - start, 3)
376
+ summary.network_requests = sum(
377
+ p.transport.stats.requests - b[0] for p, b in zip(self.providers, before)
378
+ )
379
+ summary.retries = sum(
380
+ p.transport.stats.retries - b[1] for p, b in zip(self.providers, before)
381
+ )
382
+ summary.cache_hits = len(
383
+ {query_key for query_key, (_, idxs) in jobs.items() if records[idxs[0]].from_cache}
384
+ )
385
+ summary.interrupted = interrupted
386
+ if abort_reason and not interrupted:
387
+ summary.aborted = abort_reason[0]
388
+ result = BatchResult(records, summary)
389
+ if interrupted:
390
+ raise BatchInterrupted(result)
391
+ return result
392
+
393
+ def _finish(
394
+ self,
395
+ records: List[BatchRecord],
396
+ summary: Summary,
397
+ expected: Optional[Sequence[str]],
398
+ ) -> None:
399
+ first = self.providers[0].label
400
+ for rec in records:
401
+ exp = expected[rec.index] if expected is not None and rec.index < len(expected) else ""
402
+ assess_record(rec, self.quality, expected_country=exp or "", first_provider=first)
403
+ flag_duplicates(records, self.quality.duplicate_decimals)
404
+ for rec in records:
405
+ if rec.status == STATUS_OK:
406
+ summary.ok += 1
407
+ summary.by_provider[rec.provider] = summary.by_provider.get(rec.provider, 0) + 1
408
+ if rec.result is not None:
409
+ p = rec.result.precision
410
+ summary.by_precision[p] = summary.by_precision.get(p, 0) + 1
411
+ elif rec.status == STATUS_NOT_FOUND:
412
+ summary.not_found += 1
413
+ elif rec.status == STATUS_ERROR:
414
+ summary.error += 1
415
+ elif rec.status == STATUS_SKIPPED:
416
+ summary.skipped += 1
417
+ else:
418
+ summary.pending += 1
419
+ summary.flags = count_flags(records)
420
+
421
+ # ------------------------------------------------------------------ public
422
+ def geocode_many(
423
+ self,
424
+ queries: Iterable[QueryLike],
425
+ rows: Optional[Sequence[Dict[str, Any]]] = None,
426
+ expected_countries: Optional[Sequence[str]] = None,
427
+ progress: Union[bool, ProgressBar] = False,
428
+ ) -> BatchResult:
429
+ """Forward-geocode many queries; identical queries are looked up once.
430
+
431
+ ``rows`` (optional) are attached to the records for output writers;
432
+ ``expected_countries`` gives a per-row ISO code used for the
433
+ ``country_mismatch`` flag.
434
+ """
435
+ records: List[BatchRecord] = []
436
+ jobs: Dict[
437
+ str, Tuple[Callable[[Provider], Tuple[List[GeocodeResult], int]], List[int]]
438
+ ] = {}
439
+ for i, raw in enumerate(queries):
440
+ query = normalize_query(to_query(raw))
441
+ rec = BatchRecord(index=i, query=query, row=dict(rows[i]) if rows else {})
442
+ records.append(rec)
443
+ if query.is_empty:
444
+ rec.status = STATUS_SKIPPED
445
+ rec.error = "empty address"
446
+ continue
447
+ key_text = query_cache_text(query)
448
+ if key_text in jobs:
449
+ jobs[key_text][1].append(i)
450
+ else:
451
+ jobs[key_text] = (self._forward_call(query), [i])
452
+ return self._run("forward", records, jobs, progress, expected_countries)
453
+
454
+ def _forward_call(self, query: Query) -> Callable[[Provider], Tuple[List[GeocodeResult], int]]:
455
+ options = self.options
456
+ return lambda prov: prov.geocode(query, options)
457
+
458
+ def _reverse_call(
459
+ self, lat: float, lon: float
460
+ ) -> Callable[[Provider], Tuple[List[GeocodeResult], int]]:
461
+ options = self.options
462
+ return lambda prov: prov.reverse(lat, lon, options)
463
+
464
+ def reverse_many(
465
+ self,
466
+ points: Iterable[Tuple[Any, Any]],
467
+ rows: Optional[Sequence[Dict[str, Any]]] = None,
468
+ progress: Union[bool, ProgressBar] = False,
469
+ decimals: int = 6,
470
+ ) -> BatchResult:
471
+ """Reverse-geocode ``(lat, lon)`` pairs; invalid pairs are skipped.
472
+
473
+ Points are rounded to ``decimals`` places (6 ~ 0.1 m) for caching and
474
+ de-duplication.
475
+ """
476
+ records: List[BatchRecord] = []
477
+ jobs: Dict[
478
+ str, Tuple[Callable[[Provider], Tuple[List[GeocodeResult], int]], List[int]]
479
+ ] = {}
480
+ for i, (lat_raw, lon_raw) in enumerate(points):
481
+ rec = BatchRecord(index=i, row=dict(rows[i]) if rows else {})
482
+ records.append(rec)
483
+ try:
484
+ lat, lon = float(lat_raw), float(lon_raw)
485
+ except (TypeError, ValueError):
486
+ lat = lon = float("nan")
487
+ if not (-90 <= lat <= 90 and -180 <= lon <= 180):
488
+ rec.status = STATUS_SKIPPED
489
+ rec.error = "invalid coordinates"
490
+ continue
491
+ lat, lon = round(lat, decimals), round(lon, decimals)
492
+ rec.point = (lat, lon)
493
+ key_text = f"{lat:.{decimals}f},{lon:.{decimals}f}"
494
+ if key_text in jobs:
495
+ jobs[key_text][1].append(i)
496
+ else:
497
+ jobs[key_text] = (self._reverse_call(lat, lon), [i])
498
+ return self._run("reverse", records, jobs, progress, None)
499
+
500
+ def geocode(self, query: QueryLike) -> BatchRecord:
501
+ """Geocode one address (with cache, fallback and flags)."""
502
+ return self.geocode_many([query]).records[0]
503
+
504
+ def reverse(self, lat: float, lon: float) -> BatchRecord:
505
+ """Reverse-geocode one point (with cache, fallback and flags)."""
506
+ return self.reverse_many([(lat, lon)]).records[0]
507
+
508
+ def close(self) -> None:
509
+ if self.cache is not None:
510
+ self.cache.close()
511
+
512
+ def __enter__(self) -> Geocoder:
513
+ return self
514
+
515
+ def __exit__(self, *exc: object) -> None:
516
+ self.close()
517
+
518
+
519
+ __all__ = [
520
+ "BatchInterrupted",
521
+ "BatchResult",
522
+ "GeobatchError",
523
+ "Geocoder",
524
+ "Summary",
525
+ "to_query",
526
+ ]