opencage-cli 1.0.0__tar.gz

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.
@@ -0,0 +1,13 @@
1
+ 3-Clause BSD License
2
+
3
+ Copyright (c) 2014-21 OpenCage GmbH - https://opencagedata.com
4
+
5
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
8
+
9
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
10
+
11
+ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
12
+
13
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: opencage-cli
3
+ Version: 1.0.0
4
+ Summary: Command-line tool for the OpenCage Geocoding API
5
+ Author-email: OpenCage GmbH <info@opencagedata.com>
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Repository, https://github.com/OpenCageData/opencage-cli
8
+ Project-URL: Download, https://github.com/OpenCageData/opencage-cli
9
+ Keywords: geocoding,geocoder,cli,command-line,batch
10
+ Classifier: Environment :: Console
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Scientific/Engineering :: GIS
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE.txt
26
+ Requires-Dist: opencage>=3.3.1
27
+ Requires-Dist: tqdm>=4.66.4
28
+ Requires-Dist: backoff>=2.2.1
29
+ Requires-Dist: certifi>=2024.07.04
30
+ Provides-Extra: dev
31
+ Requires-Dist: responses>=0.25.7; extra == "dev"
32
+ Requires-Dist: flake8>=7.0.0; extra == "dev"
33
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
34
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
35
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # OpenCage CLI
39
+
40
+ A command-line tool for the [OpenCage Geocoding API](https://opencagedata.com/), for forward and reverse geocoding of CSV files.
41
+
42
+ ## Build Status / Code Quality / etc
43
+
44
+ [![PyPI version](https://badge.fury.io/py/opencage-cli.svg)](https://badge.fury.io/py/opencage-cli)
45
+ [![Versions](https://img.shields.io/pypi/pyversions/opencage-cli)](https://pypi.org/project/opencage-cli/)
46
+ [![Build Status](https://github.com/OpenCageData/opencage-cli/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/OpenCageData/opencage-cli/actions/workflows/build.yml)
47
+
48
+ ## Tutorial
49
+
50
+ See the [CLI tutorial on the OpenCage site](https://opencagedata.com/tutorials/geocode-commandline) for a full walk-through.
51
+
52
+ ## Installation
53
+
54
+ Supports Python 3.9 or newer.
55
+
56
+ ```bash
57
+ pip install opencage-cli
58
+ ```
59
+
60
+ This installs an `opencage` executable on your `PATH`. The Python geocoding library it uses (the `opencage` package on PyPI) is pulled in as a dependency.
61
+
62
+ ## Usage
63
+
64
+ Use `opencage forward` or `opencage reverse`:
65
+
66
+ ```
67
+ opencage forward --help
68
+
69
+ options:
70
+ -h, --help show this help message and exit
71
+ --api-key API_KEY Your OpenCage API key
72
+ --input FILENAME Input file name
73
+ --output FILENAME Output file name
74
+ --headers If the first row should be treated as a header row
75
+ --input-columns Comma-separated list of integers (default '1')
76
+ --add-columns Comma-separated list of output columns (default 'lat,lng,_type,_category,country_code,country,state,county,_normalized_city,postcode,road,house_number,confidence,formatted')
77
+ --workers Number of parallel geocoding requests (default 1)
78
+ --timeout Timeout in seconds (default 10)
79
+ --retries Number of retries (default 10)
80
+ --api-domain API domain (default api.opencagedata.com)
81
+ --optional-api-params
82
+ Extra parameters for each request (e.g. language=fr,no_dedupe=1)
83
+ --unordered Allow the output lines to be in different order (can be faster)
84
+ --limit Stop after this number of lines in the input
85
+ --dry-run Read the input file but no geocoding
86
+ --no-progress Display no progress bar
87
+ --quiet No progress bar and no messages
88
+ --overwrite Delete the output file first if it exists
89
+ --verbose Display debug information for each request
90
+ ```
91
+
92
+ <img src="batch-progress.gif"/>
93
+
94
+ See [`examples/addresses.csv`](examples/addresses.csv) for sample input.
95
+
96
+ ## Working with AI / Agent Skill
97
+
98
+ There is an [Agent Skill for working with the OpenCage Geocoding API](https://github.com/OpenCageData/opencage-skills/).
99
+
100
+ ## Python library
101
+
102
+ If you want to call the OpenCage API directly from Python rather than via this CLI, install [the `opencage` library](https://pypi.org/project/opencage/) — `pip install opencage`.
103
+
104
+ ## Copyright & License
105
+
106
+ This software is copyright OpenCage GmbH.
107
+ Please see `LICENSE.txt`
108
+
109
+ ### Who is OpenCage GmbH?
110
+
111
+ <a href="https://opencagedata.com"><img src="opencage_logo_300_150.png"/></a>
112
+
113
+ We run a worldwide [geocoding API](https://opencagedata.com/api) and [geosearch](https://opencagedata.com/geosearch) service based on open data.
114
+ Learn more [about us](https://opencagedata.com/about).
115
+
116
+ We also run [Geomob](https://thegeomob.com), a series of regular meetups for location based service creators, where we do our best to highlight geoinnovation. If you like geo stuff, you will probably enjoy [the Geomob podcast](https://thegeomob.com/podcast/).
@@ -0,0 +1,79 @@
1
+ # OpenCage CLI
2
+
3
+ A command-line tool for the [OpenCage Geocoding API](https://opencagedata.com/), for forward and reverse geocoding of CSV files.
4
+
5
+ ## Build Status / Code Quality / etc
6
+
7
+ [![PyPI version](https://badge.fury.io/py/opencage-cli.svg)](https://badge.fury.io/py/opencage-cli)
8
+ [![Versions](https://img.shields.io/pypi/pyversions/opencage-cli)](https://pypi.org/project/opencage-cli/)
9
+ [![Build Status](https://github.com/OpenCageData/opencage-cli/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/OpenCageData/opencage-cli/actions/workflows/build.yml)
10
+
11
+ ## Tutorial
12
+
13
+ See the [CLI tutorial on the OpenCage site](https://opencagedata.com/tutorials/geocode-commandline) for a full walk-through.
14
+
15
+ ## Installation
16
+
17
+ Supports Python 3.9 or newer.
18
+
19
+ ```bash
20
+ pip install opencage-cli
21
+ ```
22
+
23
+ This installs an `opencage` executable on your `PATH`. The Python geocoding library it uses (the `opencage` package on PyPI) is pulled in as a dependency.
24
+
25
+ ## Usage
26
+
27
+ Use `opencage forward` or `opencage reverse`:
28
+
29
+ ```
30
+ opencage forward --help
31
+
32
+ options:
33
+ -h, --help show this help message and exit
34
+ --api-key API_KEY Your OpenCage API key
35
+ --input FILENAME Input file name
36
+ --output FILENAME Output file name
37
+ --headers If the first row should be treated as a header row
38
+ --input-columns Comma-separated list of integers (default '1')
39
+ --add-columns Comma-separated list of output columns (default 'lat,lng,_type,_category,country_code,country,state,county,_normalized_city,postcode,road,house_number,confidence,formatted')
40
+ --workers Number of parallel geocoding requests (default 1)
41
+ --timeout Timeout in seconds (default 10)
42
+ --retries Number of retries (default 10)
43
+ --api-domain API domain (default api.opencagedata.com)
44
+ --optional-api-params
45
+ Extra parameters for each request (e.g. language=fr,no_dedupe=1)
46
+ --unordered Allow the output lines to be in different order (can be faster)
47
+ --limit Stop after this number of lines in the input
48
+ --dry-run Read the input file but no geocoding
49
+ --no-progress Display no progress bar
50
+ --quiet No progress bar and no messages
51
+ --overwrite Delete the output file first if it exists
52
+ --verbose Display debug information for each request
53
+ ```
54
+
55
+ <img src="batch-progress.gif"/>
56
+
57
+ See [`examples/addresses.csv`](examples/addresses.csv) for sample input.
58
+
59
+ ## Working with AI / Agent Skill
60
+
61
+ There is an [Agent Skill for working with the OpenCage Geocoding API](https://github.com/OpenCageData/opencage-skills/).
62
+
63
+ ## Python library
64
+
65
+ If you want to call the OpenCage API directly from Python rather than via this CLI, install [the `opencage` library](https://pypi.org/project/opencage/) — `pip install opencage`.
66
+
67
+ ## Copyright & License
68
+
69
+ This software is copyright OpenCage GmbH.
70
+ Please see `LICENSE.txt`
71
+
72
+ ### Who is OpenCage GmbH?
73
+
74
+ <a href="https://opencagedata.com"><img src="opencage_logo_300_150.png"/></a>
75
+
76
+ We run a worldwide [geocoding API](https://opencagedata.com/api) and [geosearch](https://opencagedata.com/geosearch) service based on open data.
77
+ Learn more [about us](https://opencagedata.com/about).
78
+
79
+ We also run [Geomob](https://thegeomob.com), a series of regular meetups for location based service creators, where we do our best to highlight geoinnovation. If you like geo stuff, you will probably enjoy [the Geomob podcast](https://thegeomob.com/podcast/).
@@ -0,0 +1,6 @@
1
+ """Command-line interface for the OpenCage Geocoding API."""
2
+
3
+ from .version import __version__
4
+
5
+ __author__ = "OpenCage GmbH"
6
+ __email__ = "support@opencagedata.com"
@@ -0,0 +1,409 @@
1
+ import sys
2
+ import ssl
3
+ import asyncio
4
+ import collections
5
+ import traceback
6
+ import threading
7
+ import random
8
+ import json
9
+
10
+ from contextlib import suppress
11
+ from decimal import Decimal
12
+ from urllib.parse import urlencode
13
+ from tqdm import tqdm
14
+ import certifi
15
+ import backoff
16
+ from opencage.geocoder import OpenCageGeocode, OpenCageGeocodeError
17
+
18
+
19
+ class OpenCageBatchGeocoder():
20
+ """Batch geocoder that processes CSV files using the OpenCage API.
21
+
22
+ Reads rows from a CSV input, geocodes each address using async workers,
23
+ and writes results to a CSV output.
24
+
25
+ Args:
26
+ options: Parsed command-line options from argparse.
27
+ """
28
+
29
+ def __init__(self, options):
30
+ self.options = options
31
+ self.sslcontext = ssl.create_default_context(cafile=certifi.where())
32
+ self.user_agent_comment = 'OpenCage CLI'
33
+ self.write_counter = 1
34
+
35
+ def __call__(self, *args, **kwargs):
36
+ """Run the batch geocoder synchronously via asyncio.run."""
37
+ asyncio.run(self.geocode(*args, **kwargs))
38
+
39
+ async def geocode(self, csv_input, csv_output):
40
+ """Process a CSV input, geocode each row, and write results.
41
+
42
+ Args:
43
+ csv_input: CSV reader for input rows.
44
+ csv_output: CSV writer for output rows.
45
+ """
46
+ if not self.options.dry_run:
47
+ test = await self.test_request()
48
+ if test['error']:
49
+ self.log(test['error'])
50
+ return
51
+ if test['free'] is True and self.options.workers > 1:
52
+ sys.stderr.write("Free trial account detected. Resetting number of workers to 1.\n")
53
+ self.options.workers = 1
54
+
55
+ if self.options.headers:
56
+ header_columns = next(csv_input, None)
57
+ if header_columns is None:
58
+ return
59
+
60
+ queue = asyncio.Queue(maxsize=self.options.limit)
61
+
62
+ read_warnings = await self.read_input(csv_input, queue)
63
+
64
+ if self.options.dry_run:
65
+ if not read_warnings:
66
+ print('All good.')
67
+ return
68
+
69
+ if self.options.headers:
70
+ csv_output.writerow(header_columns + self.options.add_columns)
71
+
72
+ progress_bar = not (self.options.no_progress or self.options.quiet) and \
73
+ tqdm(total=queue.qsize(), position=0, desc="Addresses geocoded", dynamic_ncols=True)
74
+
75
+ tasks = []
76
+ for _ in range(self.options.workers):
77
+ task = asyncio.create_task(self.worker(csv_output, queue, progress_bar))
78
+ tasks.append(task)
79
+
80
+ # This starts the workers and waits until all are finished
81
+ await queue.join()
82
+
83
+ # All tasks done
84
+ for task in tasks:
85
+ task.cancel()
86
+
87
+ if progress_bar:
88
+ progress_bar.close()
89
+
90
+ async def test_request(self):
91
+ """Send a test geocoding request to verify the API key.
92
+
93
+ Returns:
94
+ Dict with 'error' (None or exception) and 'free' (bool indicating
95
+ whether a free trial account is being used).
96
+ """
97
+ try:
98
+ async with OpenCageGeocode(
99
+ self.options.api_key,
100
+ domain=self.options.api_domain,
101
+ sslcontext=self.sslcontext,
102
+ user_agent_comment=self.user_agent_comment
103
+ ) as geocoder:
104
+ result = await geocoder.geocode_async('Kendall Sq, Cambridge, MA', raw_response=True)
105
+
106
+ free = False
107
+ with suppress(KeyError):
108
+ free = result['rate']['limit'] == 2500
109
+
110
+ return {'error': None, 'free': free}
111
+ except Exception as exc:
112
+ return {'error': exc}
113
+
114
+ async def read_input(self, csv_input, queue):
115
+ """Read all rows from CSV input and add them to the work queue.
116
+
117
+ Args:
118
+ csv_input: CSV reader for input rows.
119
+ queue: Async queue to populate with parsed input items.
120
+
121
+ Returns:
122
+ True if any warnings were encountered while reading, False otherwise.
123
+ """
124
+ any_warnings = False
125
+ for index, row in enumerate(csv_input):
126
+ line_number = index + 1
127
+
128
+ if len(row) == 0:
129
+ self.log(f"Line {line_number} - Empty line")
130
+ any_warnings = True
131
+ row = ['']
132
+
133
+ item = await self.read_one_line(row, line_number)
134
+ if item['warnings'] is True:
135
+ any_warnings = True
136
+ await queue.put(item)
137
+
138
+ if queue.full():
139
+ break
140
+
141
+ return any_warnings
142
+
143
+ async def read_one_line(self, row, row_id):
144
+ """Parse a single CSV row into a work item for geocoding.
145
+
146
+ Args:
147
+ row: List of column values from the CSV reader.
148
+ row_id: 1-based line number of the row in the input.
149
+
150
+ Returns:
151
+ Dict with keys 'row_id', 'address', 'original_columns',
152
+ and 'warnings'.
153
+ """
154
+ warnings = False
155
+
156
+ if self.options.input_columns:
157
+ input_columns = self.options.input_columns
158
+ elif self.options.command == 'reverse':
159
+ input_columns = [1, 2]
160
+ else:
161
+ input_columns = None
162
+
163
+ if input_columns:
164
+ address = []
165
+ try:
166
+ for column in input_columns:
167
+ # input_columns option uses 1-based indexing
168
+ address.append(row[column - 1])
169
+ except IndexError:
170
+ self.log(f"Line {row_id} - Missing input column {column} in {row}")
171
+ warnings = True
172
+ else:
173
+ address = row
174
+
175
+ if self.options.command == 'reverse':
176
+
177
+ if len(address) != 2:
178
+ self.log(
179
+ f"Line {row_id} - Expected two comma-separated values for reverse geocoding, got {address}")
180
+ else:
181
+ # _query_for_reverse_geocoding attempts to convert into numbers. We rather have it fail
182
+ # now than during the actual geocoding
183
+ try:
184
+ self._query_for_reverse_geocoding(address[0], address[1])
185
+ except BaseException:
186
+ self.log(
187
+ f"Line {row_id} - Does not look like latitude and longitude: '{address[0]}' and '{address[1]}'")
188
+ warnings = True
189
+ address = []
190
+
191
+ return {'row_id': row_id, 'address': ','.join(address), 'original_columns': row, 'warnings': warnings}
192
+
193
+ async def worker(self, csv_output, queue, progress):
194
+ """Consume items from the queue and geocode each one.
195
+
196
+ Args:
197
+ csv_output: CSV writer for output rows.
198
+ queue: Async queue of work items to process.
199
+ progress: tqdm progress bar, or False if disabled.
200
+ """
201
+ while True:
202
+ item = await queue.get()
203
+
204
+ try:
205
+ await self.geocode_one_address(csv_output, item['row_id'], item['address'], item['original_columns'])
206
+
207
+ if progress:
208
+ progress.update(1)
209
+ except Exception as exc:
210
+ traceback.print_exception(exc, file=sys.stderr)
211
+ finally:
212
+ queue.task_done()
213
+
214
+ async def geocode_one_address(self, csv_output, row_id, address, original_columns):
215
+ """Geocode a single address and write the result to the output.
216
+
217
+ Args:
218
+ csv_output: CSV writer for output rows.
219
+ row_id: 1-based line number of the row in the input.
220
+ address: Address string (or lat,lng for reverse geocoding).
221
+ original_columns: Original CSV row columns to preserve in output.
222
+ """
223
+ def on_backoff(details):
224
+ if not self.options.quiet:
225
+ sys.stderr.write("Backing off {wait:0.1f} seconds afters {tries} tries "
226
+ "calling function {target} with args {args} and kwargs "
227
+ "{kwargs}\n".format(**details))
228
+
229
+ @backoff.on_exception(backoff.expo,
230
+ asyncio.TimeoutError,
231
+ max_time=self.options.timeout,
232
+ max_tries=self.options.retries,
233
+ on_backoff=on_backoff)
234
+ async def _geocode_one_address():
235
+ async with OpenCageGeocode(
236
+ self.options.api_key,
237
+ domain=self.options.api_domain,
238
+ sslcontext=self.sslcontext,
239
+ user_agent_comment=self.user_agent_comment
240
+ ) as geocoder:
241
+ geocoding_results = None
242
+ response = None
243
+ params = {'no_annotations': 1, 'raw_response': True, **self.options.optional_api_params}
244
+
245
+ try:
246
+ if self.options.command == 'reverse':
247
+ if ',' in address:
248
+ lon, lat = address.split(',')
249
+ response = await geocoder.reverse_geocode_async(lon, lat, **params)
250
+ geocoding_results = self._floatify_latlng(response['results'])
251
+ else:
252
+ response = await geocoder.geocode_async(address, **params)
253
+ geocoding_results = self._floatify_latlng(response['results'])
254
+ except OpenCageGeocodeError as exc:
255
+ self.log(str(exc))
256
+ except Exception as exc:
257
+ traceback.print_exception(exc, file=sys.stderr)
258
+
259
+ try:
260
+ if geocoding_results is not None and len(geocoding_results):
261
+ geocoding_result = geocoding_results[0]
262
+ else:
263
+ geocoding_result = None
264
+
265
+ if self.options.verbose:
266
+ self.log({
267
+ 'row_id': row_id,
268
+ 'thread_id': threading.get_native_id(),
269
+ 'request': geocoder.url + '?' + urlencode(geocoder._parse_request(address, params)),
270
+ 'response': response
271
+ })
272
+
273
+ await self.write_one_geocoding_result(
274
+ csv_output,
275
+ row_id,
276
+ geocoding_result,
277
+ response,
278
+ original_columns
279
+ )
280
+ except Exception as exc:
281
+ traceback.print_exception(exc, file=sys.stderr)
282
+
283
+ await _geocode_one_address()
284
+
285
+ async def write_one_geocoding_result(
286
+ self,
287
+ csv_output,
288
+ row_id,
289
+ geocoding_result,
290
+ raw_response,
291
+ original_columns):
292
+ """Write a single geocoding result row to the CSV output.
293
+
294
+ Appends the requested output columns to the original CSV columns.
295
+ Rows are written in order unless the --unordered option is set.
296
+
297
+ Args:
298
+ csv_output: CSV writer for output rows.
299
+ row_id: 1-based line number of the row in the input.
300
+ geocoding_result: First result dict from the API, or None.
301
+ raw_response: Full API response dict.
302
+ original_columns: Original CSV row columns to preserve in output.
303
+ """
304
+ row = original_columns
305
+
306
+ for column in self.options.add_columns:
307
+ if column == 'status':
308
+ row.append(self.deep_get_result_value(raw_response, ['status', 'message']))
309
+ elif geocoding_result is None:
310
+ row.append('')
311
+ elif column in geocoding_result:
312
+ row.append(self.deep_get_result_value(geocoding_result, [column], ''))
313
+ elif column in geocoding_result['components']:
314
+ row.append(self.deep_get_result_value(geocoding_result, ['components', column], ''))
315
+ elif column in geocoding_result['geometry']:
316
+ row.append(self.deep_get_result_value(geocoding_result, ['geometry', column], ''))
317
+ elif column == 'FIPS':
318
+ row.append(
319
+ self.deep_get_result_value(
320
+ geocoding_result, [
321
+ 'annotations', 'FIPS', 'county'], ''))
322
+ elif column == 'json':
323
+ row.append(json.dumps(geocoding_result, separators=(',', ':'))) # Compact JSON
324
+ else:
325
+ row.append('')
326
+
327
+ # Enforce that row are written ordered. That means we might wait for other threads
328
+ # to finish a task and make the overall process slower. Alternative would be to
329
+ # use a second queue, or keep some results in memory.
330
+ if not self.options.unordered:
331
+ while row_id > self.write_counter:
332
+ if self.options.verbose:
333
+ self.log(f"Want to write row {row_id}, but write_counter is at {self.write_counter}")
334
+ await asyncio.sleep(random.uniform(0.01, 0.1))
335
+
336
+ if self.options.verbose:
337
+ self.log(f"Writing row {row_id}")
338
+ csv_output.writerow(row)
339
+ self.write_counter = self.write_counter + 1
340
+
341
+ def log(self, message):
342
+ """Write a message to stderr unless quiet mode is enabled.
343
+
344
+ Args:
345
+ message: Message string to display.
346
+ """
347
+ if not self.options.quiet:
348
+ sys.stderr.write(f"{message}\n")
349
+
350
+ def deep_get_result_value(self, data, keys, default=None):
351
+ """Retrieve a nested value from a dict using a list of keys.
352
+
353
+ Args:
354
+ data: Dict to traverse.
355
+ keys: List of keys to follow in sequence.
356
+ default: Value to return if any key is missing.
357
+
358
+ Returns:
359
+ The nested value, or default if the path doesn't exist.
360
+
361
+ Example:
362
+ >>> data = {'status': {'code': 200, 'message': 'OK'}}
363
+ >>> self.deep_get_result_value(data, ['status', 'message'])
364
+ 'OK'
365
+ >>> self.deep_get_result_value(data, ['missing', 'key'], '')
366
+ ''
367
+ """
368
+ for key in keys:
369
+ if isinstance(data, dict):
370
+ data = data.get(key, default)
371
+ else:
372
+ return default
373
+ return data
374
+
375
+ def _query_for_reverse_geocoding(self, lat, lng):
376
+ """Format a (lat, lng) pair as the string the API expects for reverse geocoding.
377
+
378
+ Uses ``Decimal(str(...))`` to preserve the precision the caller specified
379
+ and to avoid scientific notation (e.g. ``1e-5``).
380
+ """
381
+ return f"{Decimal(str(lat)):f},{Decimal(str(lng)):f}"
382
+
383
+ def _floatify_latlng(self, input_value):
384
+ """Recursively convert ``{'lat': ..., 'lng': ...}`` dicts to floats.
385
+
386
+ Walks lists and dicts; any dict that contains exactly the two keys
387
+ ``lat`` and ``lng`` is rewritten with float values. Other structures
388
+ pass through unchanged. Works around the API occasionally returning
389
+ lat/lng as strings.
390
+ """
391
+ if isinstance(input_value, collections.abc.Mapping):
392
+ if len(input_value) == 2 and sorted(input_value.keys()) == ['lat', 'lng']:
393
+ return {
394
+ 'lat': self._float_if_float(input_value['lat']),
395
+ 'lng': self._float_if_float(input_value['lng']),
396
+ }
397
+
398
+ return dict((key, self._floatify_latlng(value)) for key, value in input_value.items())
399
+
400
+ if isinstance(input_value, collections.abc.MutableSequence):
401
+ return [self._floatify_latlng(x) for x in input_value]
402
+
403
+ return input_value
404
+
405
+ def _float_if_float(self, value):
406
+ try:
407
+ return float(value)
408
+ except ValueError:
409
+ return value
@@ -0,0 +1,234 @@
1
+ import argparse
2
+ import sys
3
+ import io
4
+ from pathlib import Path
5
+ import re
6
+ import csv
7
+
8
+ from opencage_cli.batch import OpenCageBatchGeocoder
9
+ from opencage_cli.version import __version__
10
+
11
+
12
+ def main(args=sys.argv[1:]):
13
+ """Entry point for the OpenCage CLI.
14
+
15
+ Args:
16
+ args: Command-line arguments (defaults to sys.argv[1:]).
17
+ """
18
+ options = parse_args(args)
19
+
20
+ geocoder = OpenCageBatchGeocoder(options)
21
+
22
+ with options.input as input_filename:
23
+ with (io.StringIO() if options.dry_run else open(options.output, 'x', encoding='utf-8')) as output_io:
24
+ reader = csv.reader(input_filename, strict=True, skipinitialspace=True)
25
+ writer = csv.writer(output_io)
26
+
27
+ geocoder(csv_input=reader, csv_output=writer)
28
+
29
+
30
+ def parse_args(args):
31
+ """Parse and validate command-line arguments.
32
+
33
+ Args:
34
+ args: List of command-line argument strings.
35
+
36
+ Returns:
37
+ Parsed argparse.Namespace with all options set.
38
+ """
39
+ if len(args) == 0:
40
+ print("To display help use 'opencage -h', 'opencage forward -h' or 'opencage reverse -h'", file=sys.stderr)
41
+ sys.exit(1)
42
+
43
+ parser = argparse.ArgumentParser(description=f'OpenCage CLI {__version__}')
44
+ parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
45
+
46
+ subparsers = parser.add_subparsers(dest='command')
47
+ subparsers.required = True
48
+
49
+ subparser_forward = subparsers.add_parser(
50
+ 'forward', help="Forward geocode a file (input is address, add coordinates)")
51
+ subparser_reverse = subparsers.add_parser(
52
+ 'reverse', help="Reverse geocode a file (input is coordinates, add full address)")
53
+
54
+ for subparser in [subparser_forward, subparser_reverse]:
55
+ subparser.add_argument("--api-key", required=True, type=api_key_type, help="Your OpenCage API key")
56
+ subparser.add_argument(
57
+ "--input",
58
+ required=True,
59
+ type=argparse.FileType(
60
+ 'r',
61
+ encoding='utf-8'),
62
+ help="Input file name",
63
+ metavar='FILENAME')
64
+ subparser.add_argument(
65
+ "--output",
66
+ required=True,
67
+ type=str,
68
+ help="Output file name",
69
+ metavar='FILENAME')
70
+
71
+ add_optional_arguments(subparser)
72
+
73
+ options = parser.parse_args(args)
74
+
75
+ if Path(options.output).exists() and not options.dry_run:
76
+ if options.overwrite:
77
+ Path(options.output).unlink()
78
+ else:
79
+ print(
80
+ f"Error: The output file '{options.output}' already exists. You can add --overwrite to your command.",
81
+ file=sys.stderr)
82
+ sys.exit(1)
83
+
84
+ if 0 in options.input_columns:
85
+ print("Error: A column 0 in --input-columns does not exist. The lowest possible number is 1.", file=sys.stderr)
86
+ sys.exit(1)
87
+
88
+ return options
89
+
90
+
91
+ def add_optional_arguments(parser):
92
+ """Add optional arguments shared by forward and reverse subcommands.
93
+
94
+ Args:
95
+ parser: argparse subparser to add arguments to.
96
+
97
+ Returns:
98
+ The parser with arguments added.
99
+ """
100
+ parser.add_argument(
101
+ "--headers",
102
+ action="store_true",
103
+ help="If the first row should be treated as a header row")
104
+ default_input_cols = '1,2' if re.match(r'.*reverse', parser.prog) else '1'
105
+ parser.add_argument(
106
+ "--input-columns",
107
+ type=comma_separated_type(int),
108
+ default=default_input_cols,
109
+ help=f"Comma-separated list of integers (default '{default_input_cols}')",
110
+ metavar='')
111
+ default_add_cols = (
112
+ 'lat,lng,_type,_category,country_code,country,state,county,_normalized_city,'
113
+ 'postcode,road,house_number,confidence,formatted'
114
+ )
115
+ parser.add_argument(
116
+ "--add-columns",
117
+ type=comma_separated_type(str),
118
+ default=default_add_cols,
119
+ help=f"Comma-separated list of output columns (default '{default_add_cols}')",
120
+ metavar='')
121
+ parser.add_argument("--workers", type=ranged_type(int, 1, 20), default=1,
122
+ help="Number of parallel geocoding requests (default 1)", metavar='')
123
+ parser.add_argument("--timeout", type=ranged_type(int, 1, 60), default=10,
124
+ help="Timeout in seconds (default 10)", metavar='')
125
+ parser.add_argument("--retries", type=ranged_type(int, 1, 60), default=10,
126
+ help="Number of retries (default 10)", metavar='')
127
+ parser.add_argument("--api-domain", type=str, default="api.opencagedata.com",
128
+ help="API domain (default api.opencagedata.com)", metavar='')
129
+ parser.add_argument("--optional-api-params", type=comma_separated_dict_type, default="",
130
+ help="Extra parameters for each request (e.g. language=fr,no_dedupe=1)", metavar='')
131
+ parser.add_argument(
132
+ "--limit",
133
+ type=int,
134
+ default=0,
135
+ help="Stop after this number of lines in the input",
136
+ metavar='')
137
+ parser.add_argument(
138
+ "--unordered",
139
+ action="store_true",
140
+ help="Allow the output lines to be in different order (can be faster)")
141
+ parser.add_argument("--dry-run", action="store_true", help="Read the input file but no geocoding")
142
+ parser.add_argument("--no-progress", action="store_true", help="Display no progress bar")
143
+ parser.add_argument("--quiet", action="store_true", help="No progress bar and no messages")
144
+ parser.add_argument("--overwrite", action="store_true", help="Delete the output file first if it exists")
145
+ parser.add_argument("--verbose", action="store_true", help="Display debug information for each request")
146
+
147
+ return parser
148
+
149
+
150
+ def api_key_type(apikey):
151
+ """Validate an OpenCage API key format.
152
+
153
+ Expects a 32-character lowercase hex string, optionally prefixed
154
+ with ``oc_gc_`` (e.g. ``oc_gc_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d``
155
+ or ``1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d``).
156
+
157
+ Args:
158
+ apikey: API key string to validate.
159
+
160
+ Returns:
161
+ The validated API key string.
162
+
163
+ Raises:
164
+ argparse.ArgumentTypeError: If the key doesn't match the expected format.
165
+ """
166
+ pattern = re.compile(r"^(oc_gc_)?[0-9a-f]{32}$")
167
+
168
+ if not pattern.match(apikey):
169
+ raise argparse.ArgumentTypeError("invalid API key")
170
+
171
+ return apikey
172
+
173
+
174
+ def ranged_type(value_type, min_value, max_value):
175
+ """Create an argparse type function that enforces a value range.
176
+
177
+ Args:
178
+ value_type: Type to convert the argument to (e.g. int, float).
179
+ min_value: Minimum allowed value (inclusive).
180
+ max_value: Maximum allowed value (inclusive).
181
+
182
+ Returns:
183
+ A type-checking function suitable for argparse's type parameter.
184
+ """
185
+ def range_checker(arg: str):
186
+ try:
187
+ f = value_type(arg)
188
+ except ValueError as exc:
189
+ raise argparse.ArgumentTypeError(f'must be a valid {value_type}') from exc
190
+ if f < min_value or f > max_value:
191
+ raise argparse.ArgumentTypeError(f'must be within [{min_value}, {max_value}]')
192
+ return f
193
+
194
+ # Return function handle to checking function
195
+ return range_checker
196
+
197
+
198
+ def comma_separated_type(value_type):
199
+ """Create an argparse type function that parses comma-separated values.
200
+
201
+ Args:
202
+ value_type: Type to convert each element to (e.g. int, str).
203
+
204
+ Returns:
205
+ A type-checking function suitable for argparse's type parameter.
206
+ """
207
+ def comma_separated(arg: str):
208
+ if not arg:
209
+ return []
210
+
211
+ return [value_type(x) for x in arg.split(',')]
212
+
213
+ return comma_separated
214
+
215
+
216
+ def comma_separated_dict_type(arg):
217
+ """Parse a comma-separated list of key=value pairs into a dict.
218
+
219
+ Args:
220
+ arg: String like "key1=val1,key2=val2".
221
+
222
+ Returns:
223
+ Dict of parsed key-value pairs, or empty dict if arg is empty.
224
+
225
+ Raises:
226
+ argparse.ArgumentTypeError: If the string is not valid key=value format.
227
+ """
228
+ if not arg:
229
+ return {}
230
+
231
+ try:
232
+ return dict([x.split('=') for x in arg.split(',')])
233
+ except ValueError as exc:
234
+ raise argparse.ArgumentTypeError("must be a valid comma separated list of key=value pairs") from exc
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: opencage-cli
3
+ Version: 1.0.0
4
+ Summary: Command-line tool for the OpenCage Geocoding API
5
+ Author-email: OpenCage GmbH <info@opencagedata.com>
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Repository, https://github.com/OpenCageData/opencage-cli
8
+ Project-URL: Download, https://github.com/OpenCageData/opencage-cli
9
+ Keywords: geocoding,geocoder,cli,command-line,batch
10
+ Classifier: Environment :: Console
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Scientific/Engineering :: GIS
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE.txt
26
+ Requires-Dist: opencage>=3.3.1
27
+ Requires-Dist: tqdm>=4.66.4
28
+ Requires-Dist: backoff>=2.2.1
29
+ Requires-Dist: certifi>=2024.07.04
30
+ Provides-Extra: dev
31
+ Requires-Dist: responses>=0.25.7; extra == "dev"
32
+ Requires-Dist: flake8>=7.0.0; extra == "dev"
33
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
34
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
35
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # OpenCage CLI
39
+
40
+ A command-line tool for the [OpenCage Geocoding API](https://opencagedata.com/), for forward and reverse geocoding of CSV files.
41
+
42
+ ## Build Status / Code Quality / etc
43
+
44
+ [![PyPI version](https://badge.fury.io/py/opencage-cli.svg)](https://badge.fury.io/py/opencage-cli)
45
+ [![Versions](https://img.shields.io/pypi/pyversions/opencage-cli)](https://pypi.org/project/opencage-cli/)
46
+ [![Build Status](https://github.com/OpenCageData/opencage-cli/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/OpenCageData/opencage-cli/actions/workflows/build.yml)
47
+
48
+ ## Tutorial
49
+
50
+ See the [CLI tutorial on the OpenCage site](https://opencagedata.com/tutorials/geocode-commandline) for a full walk-through.
51
+
52
+ ## Installation
53
+
54
+ Supports Python 3.9 or newer.
55
+
56
+ ```bash
57
+ pip install opencage-cli
58
+ ```
59
+
60
+ This installs an `opencage` executable on your `PATH`. The Python geocoding library it uses (the `opencage` package on PyPI) is pulled in as a dependency.
61
+
62
+ ## Usage
63
+
64
+ Use `opencage forward` or `opencage reverse`:
65
+
66
+ ```
67
+ opencage forward --help
68
+
69
+ options:
70
+ -h, --help show this help message and exit
71
+ --api-key API_KEY Your OpenCage API key
72
+ --input FILENAME Input file name
73
+ --output FILENAME Output file name
74
+ --headers If the first row should be treated as a header row
75
+ --input-columns Comma-separated list of integers (default '1')
76
+ --add-columns Comma-separated list of output columns (default 'lat,lng,_type,_category,country_code,country,state,county,_normalized_city,postcode,road,house_number,confidence,formatted')
77
+ --workers Number of parallel geocoding requests (default 1)
78
+ --timeout Timeout in seconds (default 10)
79
+ --retries Number of retries (default 10)
80
+ --api-domain API domain (default api.opencagedata.com)
81
+ --optional-api-params
82
+ Extra parameters for each request (e.g. language=fr,no_dedupe=1)
83
+ --unordered Allow the output lines to be in different order (can be faster)
84
+ --limit Stop after this number of lines in the input
85
+ --dry-run Read the input file but no geocoding
86
+ --no-progress Display no progress bar
87
+ --quiet No progress bar and no messages
88
+ --overwrite Delete the output file first if it exists
89
+ --verbose Display debug information for each request
90
+ ```
91
+
92
+ <img src="batch-progress.gif"/>
93
+
94
+ See [`examples/addresses.csv`](examples/addresses.csv) for sample input.
95
+
96
+ ## Working with AI / Agent Skill
97
+
98
+ There is an [Agent Skill for working with the OpenCage Geocoding API](https://github.com/OpenCageData/opencage-skills/).
99
+
100
+ ## Python library
101
+
102
+ If you want to call the OpenCage API directly from Python rather than via this CLI, install [the `opencage` library](https://pypi.org/project/opencage/) — `pip install opencage`.
103
+
104
+ ## Copyright & License
105
+
106
+ This software is copyright OpenCage GmbH.
107
+ Please see `LICENSE.txt`
108
+
109
+ ### Who is OpenCage GmbH?
110
+
111
+ <a href="https://opencagedata.com"><img src="opencage_logo_300_150.png"/></a>
112
+
113
+ We run a worldwide [geocoding API](https://opencagedata.com/api) and [geosearch](https://opencagedata.com/geosearch) service based on open data.
114
+ Learn more [about us](https://opencagedata.com/about).
115
+
116
+ We also run [Geomob](https://thegeomob.com), a series of regular meetups for location based service creators, where we do our best to highlight geoinnovation. If you like geo stuff, you will probably enjoy [the Geomob podcast](https://thegeomob.com/podcast/).
@@ -0,0 +1,15 @@
1
+ LICENSE.txt
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ opencage_cli/__init__.py
6
+ opencage_cli/batch.py
7
+ opencage_cli/command_line.py
8
+ opencage_cli/version.py
9
+ opencage_cli.egg-info/PKG-INFO
10
+ opencage_cli.egg-info/SOURCES.txt
11
+ opencage_cli.egg-info/dependency_links.txt
12
+ opencage_cli.egg-info/entry_points.txt
13
+ opencage_cli.egg-info/requires.txt
14
+ opencage_cli.egg-info/top_level.txt
15
+ test/test_batch.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ opencage = opencage_cli.command_line:main
@@ -0,0 +1,11 @@
1
+ opencage>=3.3.1
2
+ tqdm>=4.66.4
3
+ backoff>=2.2.1
4
+ certifi>=2024.07.04
5
+
6
+ [dev]
7
+ responses>=0.25.7
8
+ flake8>=7.0.0
9
+ pytest>=7.4.0
10
+ pytest-asyncio>=0.21.0
11
+ pytest-cov>=4.1.0
@@ -0,0 +1 @@
1
+ opencage_cli
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["setuptools>=65.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "opencage-cli"
7
+ dynamic = ["version"]
8
+ description = "Command-line tool for the OpenCage Geocoding API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "BSD-3-Clause"
12
+ authors = [{name = "OpenCage GmbH", email = "info@opencagedata.com"}]
13
+ keywords = ["geocoding", "geocoder", "cli", "command-line", "batch"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Scientific/Engineering :: GIS",
27
+ "Topic :: Utilities",
28
+ ]
29
+ dependencies = [
30
+ "opencage>=3.3.1",
31
+ "tqdm>=4.66.4",
32
+ "backoff>=2.2.1",
33
+ "certifi>=2024.07.04",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "responses>=0.25.7",
39
+ "flake8>=7.0.0",
40
+ "pytest>=7.4.0",
41
+ "pytest-asyncio>=0.21.0",
42
+ "pytest-cov>=4.1.0",
43
+ ]
44
+
45
+ [project.scripts]
46
+ opencage = "opencage_cli.command_line:main"
47
+
48
+ [project.urls]
49
+ Repository = "https://github.com/OpenCageData/opencage-cli"
50
+ Download = "https://github.com/OpenCageData/opencage-cli"
51
+
52
+ [tool.setuptools.dynamic]
53
+ version = {attr = "opencage_cli.version.__version__"}
54
+
55
+ [tool.coverage.run]
56
+ branch = true
57
+ source = ["opencage_cli"]
58
+
59
+ [tool.coverage.report]
60
+ show_missing = true
61
+ exclude_lines = [
62
+ "pragma: no cover",
63
+ "def __repr__",
64
+ "raise NotImplementedError",
65
+ "if __name__ == .__main__.:",
66
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env python
2
+ """Backwards-compatible setup.py shim. All config is in pyproject.toml."""
3
+
4
+ from setuptools import setup
5
+
6
+ setup()
@@ -0,0 +1,25 @@
1
+ from opencage_cli.batch import OpenCageBatchGeocoder
2
+
3
+ batch = OpenCageBatchGeocoder({})
4
+
5
+
6
+ def test_deep_get_result_value():
7
+ result = {
8
+ 'annotations': {
9
+ 'FIPS': {
10
+ 'state': 'CA'
11
+ }
12
+ },
13
+ 'components': {
14
+ 'street': 'Main Road'
15
+ }
16
+ }
17
+
18
+ assert batch.deep_get_result_value(result, ['hello', 'world']) is None
19
+
20
+ assert batch.deep_get_result_value(result, ['components', 'street']) == 'Main Road'
21
+ assert batch.deep_get_result_value(result, ['components', 'city']) is None
22
+ assert batch.deep_get_result_value(result, ['components', 'city'], '') == ''
23
+
24
+ assert batch.deep_get_result_value([], ['hello', 'world']) is None
25
+ assert batch.deep_get_result_value(None, ['hello', 'world']) is None