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.
- opencage_cli-1.0.0/LICENSE.txt +13 -0
- opencage_cli-1.0.0/PKG-INFO +116 -0
- opencage_cli-1.0.0/README.md +79 -0
- opencage_cli-1.0.0/opencage_cli/__init__.py +6 -0
- opencage_cli-1.0.0/opencage_cli/batch.py +409 -0
- opencage_cli-1.0.0/opencage_cli/command_line.py +234 -0
- opencage_cli-1.0.0/opencage_cli/version.py +1 -0
- opencage_cli-1.0.0/opencage_cli.egg-info/PKG-INFO +116 -0
- opencage_cli-1.0.0/opencage_cli.egg-info/SOURCES.txt +15 -0
- opencage_cli-1.0.0/opencage_cli.egg-info/dependency_links.txt +1 -0
- opencage_cli-1.0.0/opencage_cli.egg-info/entry_points.txt +2 -0
- opencage_cli-1.0.0/opencage_cli.egg-info/requires.txt +11 -0
- opencage_cli-1.0.0/opencage_cli.egg-info/top_level.txt +1 -0
- opencage_cli-1.0.0/pyproject.toml +66 -0
- opencage_cli-1.0.0/setup.cfg +4 -0
- opencage_cli-1.0.0/setup.py +6 -0
- opencage_cli-1.0.0/test/test_batch.py +25 -0
|
@@ -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
|
+
[](https://badge.fury.io/py/opencage-cli)
|
|
45
|
+
[](https://pypi.org/project/opencage-cli/)
|
|
46
|
+
[](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
|
+
[](https://badge.fury.io/py/opencage-cli)
|
|
8
|
+
[](https://pypi.org/project/opencage-cli/)
|
|
9
|
+
[](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,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
|
+
[](https://badge.fury.io/py/opencage-cli)
|
|
45
|
+
[](https://pypi.org/project/opencage-cli/)
|
|
46
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|