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 +97 -0
- geobatch/__main__.py +7 -0
- geobatch/_version.py +1 -0
- geobatch/batch.py +526 -0
- geobatch/cache.py +185 -0
- geobatch/cli.py +367 -0
- geobatch/errors.py +52 -0
- geobatch/http.py +201 -0
- geobatch/io.py +320 -0
- geobatch/models.py +182 -0
- geobatch/normalize.py +73 -0
- geobatch/pandas_ext.py +95 -0
- geobatch/progress.py +95 -0
- geobatch/providers/__init__.py +61 -0
- geobatch/providers/base.py +291 -0
- geobatch/providers/commercial.py +514 -0
- geobatch/providers/nominatim.py +270 -0
- geobatch/providers/pelias.py +156 -0
- geobatch/providers/photon.py +148 -0
- geobatch/providers/template.py +228 -0
- geobatch/py.typed +0 -0
- geobatch/quality.py +120 -0
- geobatch/ratelimit.py +90 -0
- geobatch-0.1.0.dist-info/METADATA +338 -0
- geobatch-0.1.0.dist-info/RECORD +29 -0
- geobatch-0.1.0.dist-info/WHEEL +5 -0
- geobatch-0.1.0.dist-info/entry_points.txt +2 -0
- geobatch-0.1.0.dist-info/licenses/LICENSE +21 -0
- geobatch-0.1.0.dist-info/top_level.txt +1 -0
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
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
|
+
]
|