├── test ├── fixtures │ ├── input.txt │ ├── cli │ │ ├── reverse.csv │ │ ├── reverse_with_errors.csv │ │ ├── forward_noresult.csv │ │ ├── output.csv2 │ │ ├── forward.csv │ │ └── forward_with_headers.csv │ ├── no_ratelimit.json │ ├── 403_apikey_disabled.json │ ├── 401_not_authorized.json │ ├── 402_rate_limit_exceeded.json │ ├── mudgee_australia.json │ ├── badssl-com-chain.pem │ ├── donostia.json │ ├── muenster.json │ └── uk_postcode.json ├── __init__.py ├── test_error_not_authorized.py ├── test_error_blocked.py ├── test_reverse.py ├── test_geocoder_args.py ├── test_batch.py ├── test_flotify_dict.py ├── test_headers.py ├── test_error_ssl.py ├── test_error_ratelimit_exceeded.py ├── test_session.py ├── test_error_unknown.py ├── test_async.py ├── test_all.py ├── test_error_invalid_input.py └── cli │ ├── test_cli_args.py │ └── test_cli_run.py ├── opencage ├── version.py ├── __init__.py ├── command_line.py ├── batch.py └── geocoder.py ├── pytest.ini ├── batch-progress.gif ├── opencage_logo_300_150.png ├── .flake8 ├── SECURITY.md ├── tox.ini ├── .gitignore ├── examples ├── demo.py ├── flask_demo.py ├── batch.py └── addresses.csv ├── Vagrantfile ├── .github └── workflows │ └── build.yml ├── vagrant-provision.sh ├── LICENSE.txt ├── setup.py ├── Changes.txt └── README.md /test/fixtures/input.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /opencage/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '3.2.0' 2 | -------------------------------------------------------------------------------- /test/fixtures/cli/reverse.csv: -------------------------------------------------------------------------------- 1 | 51.9526622,7.6324709 -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | sys.path.insert(0, '.') 3 | -------------------------------------------------------------------------------- /test/fixtures/cli/reverse_with_errors.csv: -------------------------------------------------------------------------------- 1 | 50.101010 2 | -100,60.1 3 | 4 | a,b -------------------------------------------------------------------------------- /test/fixtures/cli/forward_noresult.csv: -------------------------------------------------------------------------------- 1 | id,full_address 2 | 123,NOWHERE-INTERESTING -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath = . 3 | asyncio_default_fixture_loop_scope = session 4 | -------------------------------------------------------------------------------- /batch-progress.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenCageData/python-opencage-geocoder/HEAD/batch-progress.gif -------------------------------------------------------------------------------- /opencage_logo_300_150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenCageData/python-opencage-geocoder/HEAD/opencage_logo_300_150.png -------------------------------------------------------------------------------- /test/fixtures/cli/output.csv2: -------------------------------------------------------------------------------- 1 | id,full_address,lat,lng,postcode 2 | 123,NOWHERE-INTERESTING,51.9526622,7.6324709,48153 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = .git,__pycache__,build,dist 4 | ignore = W503 5 | per-file-ignores = 6 | __init__.py: F401 7 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please see the [OpenCage security bounty page](https://opencagedata.com/security-bounty) 6 | -------------------------------------------------------------------------------- /test/fixtures/cli/forward.csv: -------------------------------------------------------------------------------- 1 | "Rathausmarkt 1",Hamburg,20095,Germany 2 | "10 Downing Street",London,"SW1A 2AA","United Kingdom" 3 | "C/ de Mallorca 401",Barcelona,08013,Spain -------------------------------------------------------------------------------- /opencage/__init__.py: -------------------------------------------------------------------------------- 1 | """ Base module for OpenCage stuff. """ 2 | 3 | from .version import __version__ 4 | 5 | __author__ = "OpenCage GmbH" 6 | __email__ = 'support@opencagedata.com' 7 | -------------------------------------------------------------------------------- /test/fixtures/cli/forward_with_headers.csv: -------------------------------------------------------------------------------- 1 | street and number,town,postcode,country 2 | "Rathausmarkt 1",Hamburg,20095,Germany 3 | "10 Downing Street",London,"SW1A 2AA","United Kingdom" 4 | "C/ de Mallorca 401",Barcelona,08013,Spain -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py39,py310,py311,py312,py313,lint 3 | 4 | [gh] 5 | python = 6 | 3.13 = py313 7 | 3.12 = py312 8 | 3.11 = py311 9 | 3.10 = py310 10 | 3.9 = py39 11 | 3.8 = py38 12 | 13 | [testenv] 14 | deps = 15 | responses 16 | pytest 17 | pytest-aiohttp 18 | pytest-asyncio 19 | commands = 20 | pytest test 21 | 22 | [testenv:lint] 23 | usedevelop = True 24 | deps = 25 | responses 26 | flake8>=7.0.0 27 | pytest 28 | commands = 29 | flake8 opencage examples/demo.py setup.py test 30 | -------------------------------------------------------------------------------- /test/fixtures/no_ratelimit.json: -------------------------------------------------------------------------------- 1 | { 2 | "documentation": "https://opencagedata.com/api", 3 | "licenses": [ 4 | { 5 | "name": "see attribution guide", 6 | "url": "https://opencagedata.com/credits" 7 | } 8 | ], 9 | "results": [ 10 | ], 11 | "status": { 12 | "code": 200, 13 | "message": "OK" 14 | }, 15 | "stay_informed": { 16 | "blog": "https://blog.opencagedata.com", 17 | "twitter": "https://twitter.com/OpenCage" 18 | }, 19 | "thanks": "For using an OpenCage API", 20 | "timestamp": { 21 | "created_http": "Sun, 07 Mar 2021 01:13:17 GMT", 22 | "created_unix": 1615079597 23 | }, 24 | "total_results": 0 25 | } -------------------------------------------------------------------------------- /test/fixtures/403_apikey_disabled.json: -------------------------------------------------------------------------------- 1 | { 2 | "documentation": "https://opencagedata.com/api", 3 | "licenses": [ 4 | { 5 | "name": "see attribution guide", 6 | "url": "https://opencagedata.com/credits" 7 | } 8 | ], 9 | "results": [], 10 | "status": { 11 | "code": 403, 12 | "message": "disabled" 13 | }, 14 | "stay_informed": { 15 | "blog": "https://blog.opencagedata.com", 16 | "twitter": "https://twitter.com/OpenCage" 17 | }, 18 | "thanks": "For using an OpenCage API", 19 | "timestamp": { 20 | "created_http": "Sun, 07 Mar 2021 01:19:06 GMT", 21 | "created_unix": 1615079946 22 | }, 23 | "total_results": 0 24 | } -------------------------------------------------------------------------------- /test/fixtures/401_not_authorized.json: -------------------------------------------------------------------------------- 1 | { 2 | "documentation": "https://opencagedata.com/api", 3 | "licenses": [{ 4 | "name": "see attribution guide", 5 | "url": "https://opencagedata.com/credits" 6 | }], 7 | "results": [], 8 | "status": { 9 | "code": 401, 10 | "message": "invalid API key" 11 | }, 12 | "stay_informed": { 13 | "blog": "https://blog.opencagedata.com", 14 | "twitter": "https://twitter.com/opencagedata" 15 | }, 16 | "thanks": "For using an OpenCage Data API", 17 | "timestamp": { 18 | "created_http": "Sun, 09 Jun 2019 19:58:46 GMT", 19 | "created_unix": 1560110326 20 | }, 21 | "total_results": 0 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | eggs/ 15 | lib/ 16 | lib64/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | 24 | # Installer logs 25 | pip-log.txt 26 | pip-delete-this-directory.txt 27 | 28 | # Unit test / coverage reports 29 | htmlcov/ 30 | .tox/ 31 | .coverage 32 | .cache 33 | nosetests.xml 34 | coverage.xml 35 | 36 | # Translations 37 | *.mo 38 | *.pot 39 | 40 | # Django stuff: 41 | *.log 42 | 43 | # Sphinx documentation 44 | docs/_build/ 45 | 46 | .*.swp 47 | .vagrant 48 | .pypirc 49 | .eggs/ 50 | 51 | # VS Code 52 | .vscode/ 53 | -------------------------------------------------------------------------------- /test/test_error_not_authorized.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import responses 5 | 6 | from opencage.geocoder import OpenCageGeocode 7 | from opencage.geocoder import NotAuthorizedError 8 | 9 | geocoder = OpenCageGeocode('unauthorized-key') 10 | 11 | 12 | @responses.activate 13 | def test_api_key_not_authorized(): 14 | responses.add( 15 | responses.GET, 16 | geocoder.url, 17 | body=Path('test/fixtures/401_not_authorized.json').read_text(encoding="utf-8"), 18 | status=401, 19 | ) 20 | 21 | with pytest.raises(NotAuthorizedError) as excinfo: 22 | geocoder.geocode("whatever") 23 | assert str(excinfo.value) == 'Your API key is not authorized. You may have entered it incorrectly.' 24 | -------------------------------------------------------------------------------- /test/test_error_blocked.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import responses 5 | 6 | from opencage.geocoder import OpenCageGeocode 7 | from opencage.geocoder import ForbiddenError 8 | 9 | 10 | geocoder = OpenCageGeocode('2e10e5e828262eb243ec0b54681d699a') # will always return 403 11 | 12 | 13 | @responses.activate 14 | def test_api_key_blocked(): 15 | responses.add( 16 | responses.GET, 17 | geocoder.url, 18 | body=Path('test/fixtures/403_apikey_disabled.json').read_text(encoding="utf-8"), 19 | status=403, 20 | ) 21 | 22 | with pytest.raises(ForbiddenError) as excinfo: 23 | geocoder.geocode("whatever") 24 | assert str(excinfo.value) == 'Your API key has been blocked or suspended.' 25 | -------------------------------------------------------------------------------- /test/test_reverse.py: -------------------------------------------------------------------------------- 1 | from opencage.geocoder import _query_for_reverse_geocoding 2 | 3 | 4 | def _expected_output(input_latlng, expected_output): 5 | def test(): 6 | lat, lng = input_latlng 7 | assert _query_for_reverse_geocoding(lat, lng) == expected_output 8 | return test 9 | 10 | 11 | def test_reverse(): 12 | _expected_output((10, 10), "10,10") 13 | _expected_output((10.0, 10.0), "10.0,10.0") 14 | _expected_output((0.000002, -120), "0.000002,-120") 15 | _expected_output((2.000002, -120), "2.000002,-120") 16 | _expected_output((2.000002, -120.000002), "2.000002,-120.000002") 17 | _expected_output((2.000002, -1.0000002), "2.000002,-1.0000002") 18 | _expected_output((2.000002, 0.0000001), "2.000002,0.0000001") 19 | 20 | _expected_output(("2.000002", "-120"), "2.000002,-120") 21 | -------------------------------------------------------------------------------- /test/test_geocoder_args.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from opencage.geocoder import OpenCageGeocode 4 | 5 | import os 6 | 7 | 8 | def test_protocol_http(): 9 | """Test that HTTP protocol can be set correctly""" 10 | geocoder = OpenCageGeocode('abcde', protocol='http') 11 | assert geocoder.url == 'http://api.opencagedata.com/geocode/v1/json' 12 | 13 | 14 | def test_api_key_env_var(): 15 | """Test that API key can be set by an environment variable""" 16 | 17 | os.environ['OPENCAGE_API_KEY'] = 'from-env-var' 18 | geocoder = OpenCageGeocode() 19 | assert geocoder.key == 'from-env-var' 20 | 21 | 22 | def test_custom_domain(): 23 | """Test that custom domain can be set""" 24 | geocoder = OpenCageGeocode('abcde', domain='example.com') 25 | assert geocoder.url == 'https://example.com/geocode/v1/json' 26 | -------------------------------------------------------------------------------- /test/fixtures/402_rate_limit_exceeded.json: -------------------------------------------------------------------------------- 1 | { 2 | "documentation": "https://opencagedata.com/api", 3 | "licenses": [ 4 | { 5 | "name": "see attribution guide", 6 | "url": "https://opencagedata.com/credits" 7 | } 8 | ], 9 | "rate": { 10 | "limit": 2500, 11 | "remaining": 0, 12 | "reset": 1615161600 13 | }, 14 | "results": [], 15 | "status": { 16 | "become_a_customer": "https://opencagedata.com/pricing", 17 | "code": 402, 18 | "message": "quota exceeded" 19 | }, 20 | "stay_informed": { 21 | "blog": "https://blog.opencagedata.com", 22 | "twitter": "https://twitter.com/OpenCage" 23 | }, 24 | "thanks": "For using an OpenCage API", 25 | "timestamp": { 26 | "created_http": "Sun, 07 Mar 2021 01:18:00 GMT", 27 | "created_unix": 1615079880 28 | }, 29 | "total_results": 0 30 | } -------------------------------------------------------------------------------- /examples/demo.py: -------------------------------------------------------------------------------- 1 | from pprint import pprint 2 | from opencage.geocoder import OpenCageGeocode 3 | 4 | APIKEY = 'your-key-here' 5 | 6 | geocoder = OpenCageGeocode(APIKEY) 7 | 8 | results = geocoder.reverse_geocode(44.8303087, -0.5761911) 9 | pprint(results) 10 | # [{'components': {'city': 'Bordeaux', 11 | # 'country': 'France', 12 | # 'country_code': 'fr', 13 | # 'county': 'Bordeaux', 14 | # 'house_number': '11', 15 | # 'political_union': 'European Union', 16 | # 'postcode': '33800', 17 | # 'road': 'Rue Sauteyron', 18 | # 'state': 'New Aquitaine', 19 | # 'suburb': 'Bordeaux Sud'}, 20 | # 'formatted': '11 Rue Sauteyron, 33800 Bordeaux, France', 21 | # 'geometry': {'lat': 44.8303087, 'lng': -0.5761911}}] 22 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | 6 | config.vm.box = 'bento/ubuntu-24.04' 7 | if RUBY_PLATFORM.include?('darwin') && RUBY_PLATFORM.include?('arm64') 8 | # Apple Silicon/M processor 9 | config.vm.box = 'gutehall/ubuntu24-04' 10 | end 11 | 12 | config.vm.synced_folder ".", "/home/vagrant/python-opencage-geocoder" 13 | 14 | # provision using a simple shell script 15 | config.vm.provision :shell, path: "vagrant-provision.sh", privileged: false 16 | 17 | 18 | # configure shared package cache if possible 19 | if Vagrant.has_plugin?("vagrant-cachier") 20 | config.cache.enable :apt 21 | config.cache.scope = :box 22 | end 23 | 24 | 25 | config.vm.provider "virtualbox" do |vb| 26 | vb.gui = false 27 | # vb.customize ["modifyvm", :id, "--memory", "1024"] 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/test_batch.py: -------------------------------------------------------------------------------- 1 | from opencage.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 26 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "11 22 2 * *" 8 | 9 | jobs: 10 | tox: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | py: 16 | - "3.13" 17 | - "3.12" 18 | - "3.11" 19 | - "3.10" 20 | - "3.9" 21 | - "3.8" 22 | os: 23 | - ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Setup python for test ${{ matrix.py }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.py }} 30 | - name: Install tox 31 | run: python -m pip install tox-gh>=1.2 32 | - name: Setup test suite 33 | run: tox -vv --notest 34 | - name: Run test suite 35 | run: tox --skip-pkg-install 36 | -------------------------------------------------------------------------------- /test/test_flotify_dict.py: -------------------------------------------------------------------------------- 1 | from opencage.geocoder import floatify_latlng 2 | 3 | 4 | def test_string(): 5 | assert floatify_latlng("123") == "123" 6 | 7 | 8 | def test_empty_dict(): 9 | assert floatify_latlng({}) == {} 10 | 11 | 12 | def test_empty_list(): 13 | assert floatify_latlng([]) == [] 14 | 15 | 16 | def test_dict_with_floats(): 17 | assert floatify_latlng({'geom': {'lat': 12.01, 'lng': -0.9}}) == {'geom': {'lat': 12.01, 'lng': -0.9}} 18 | 19 | 20 | def dict_with_stringified_floats(): 21 | assert floatify_latlng({'geom': {'lat': "12.01", 'lng': "-0.9"}}) == {'geom': {'lat': 12.01, 'lng': -0.9}} 22 | 23 | 24 | def dict_with_list(): 25 | assert floatify_latlng( 26 | {'results': [{'geom': {'lat': "12.01", 'lng': "-0.9"}}, {'geometry': {'lat': '0.1', 'lng': '10'}}]} 27 | ) == {'results': [{'geom': {'lat': 12.01, 'lng': -0.9}}, {'geometry': {'lat': 0.1, 'lng': 10}}]} 28 | 29 | 30 | def list_with_things(): 31 | assert floatify_latlng([{'foo': 'bar'}]) == [{'foo': 'bar'}] 32 | -------------------------------------------------------------------------------- /test/test_headers.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from pathlib import Path 4 | 5 | import os 6 | import re 7 | import responses 8 | 9 | from opencage.geocoder import OpenCageGeocode 10 | 11 | # reduce maximum backoff retry time from 120s to 1s 12 | os.environ['BACKOFF_MAX_TIME'] = '1' 13 | 14 | geocoder = OpenCageGeocode('abcde', user_agent_comment='OpenCage Test') 15 | 16 | user_agent_format = re.compile( 17 | r'^opencage-python/[\d\.]+ Python/[\d\.]+ (requests|aiohttp)/[\d\.]+ \(OpenCage Test\)$') 18 | 19 | 20 | @responses.activate 21 | def test_sync(): 22 | responses.add( 23 | responses.GET, 24 | geocoder.url, 25 | body=Path('test/fixtures/uk_postcode.json').read_text(encoding="utf-8"), 26 | status=200 27 | ) 28 | 29 | geocoder.geocode("EC1M 5RF") 30 | 31 | # Check the User-Agent header in the most recent request 32 | request = responses.calls[-1].request 33 | user_agent = request.headers['User-Agent'] 34 | 35 | assert user_agent_format.match(user_agent) is not None 36 | -------------------------------------------------------------------------------- /examples/flask_demo.py: -------------------------------------------------------------------------------- 1 | # Sample forward geocode: http://127.0.0.1:5000/forward/147%20Farm%20STreet%20Blackstone%20MA%2001504 2 | # 3 | # Sample reverse geocode: http://127.0.0.1:5000/reverse/42.036488/-71.519678/ 4 | import json 5 | from flask import Flask 6 | from flask import request 7 | from opencage.geocoder import OpenCageGeocode 8 | 9 | app = Flask(__name__) 10 | _key = OPEN_CAGE_KEY = "YOUR_OPEN_CAGE_KEY" 11 | _geocoder = OpenCageGeocode(OPEN_CAGE_KEY) 12 | 13 | @app.route("/forward/
") 14 | def forward(address): 15 | verbose = json.loads(request.args.get('verbose', "false").lower()) 16 | raw_result = _geocoder.geocode(address) 17 | formatted = [{"confidence": r["confidence"], "geometry": r["geometry"]} for r in raw_result if r["confidence"]] 18 | return json.dumps(raw_result if verbose else formatted) 19 | 20 | @app.route("/reverse///") 21 | def reverse(lat, lng): 22 | verbose = json.loads(request.args.get('verbose', "false").lower()) 23 | raw_result = _geocoder.reverse_geocode(float(lat), float(lng)) 24 | return json.dumps(raw_result if verbose else [r["components"] for r in raw_result]) 25 | 26 | if __name__ == "__main__": 27 | app.run(debug=True) 28 | -------------------------------------------------------------------------------- /test/test_error_ssl.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import ssl 4 | import pytest 5 | from opencage.geocoder import OpenCageGeocode, SSLError 6 | 7 | # NOTE: Testing keys https://opencagedata.com/api#testingkeys 8 | 9 | # Connect to a host that has an invalid certificate 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_sslerror(): 14 | bad_domain = 'wrong.host.badssl.com' 15 | async with OpenCageGeocode('6d0e711d72d74daeb2b0bfd2a5cdfdba', domain=bad_domain) as geocoder: 16 | with pytest.raises(SSLError) as excinfo: 17 | await geocoder.geocode_async("something") 18 | assert str(excinfo.value).startswith('SSL Certificate error') 19 | 20 | # Connect to OpenCage API domain but use certificate of another domain 21 | # This tests that sslcontext can be set. 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_sslerror_wrong_certificate(): 26 | sslcontext = ssl.create_default_context(cafile='test/fixtures/badssl-com-chain.pem') 27 | 28 | async with OpenCageGeocode('6d0e711d72d74daeb2b0bfd2a5cdfdba', sslcontext=sslcontext) as geocoder: 29 | with pytest.raises(SSLError) as excinfo: 30 | await geocoder.geocode_async("something") 31 | assert str(excinfo.value).startswith('SSL Certificate error') 32 | -------------------------------------------------------------------------------- /test/test_error_ratelimit_exceeded.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import responses 5 | 6 | from opencage.geocoder import OpenCageGeocode 7 | from opencage.geocoder import RateLimitExceededError 8 | 9 | geocoder = OpenCageGeocode('abcde') 10 | 11 | 12 | @responses.activate 13 | def test_no_rate_limit(): 14 | responses.add( 15 | responses.GET, 16 | geocoder.url, 17 | body=Path('test/fixtures/no_ratelimit.json').read_text(encoding="utf-8"), 18 | status=200 19 | ) 20 | # shouldn't raise an exception 21 | geocoder.geocode("whatever") 22 | 23 | 24 | @responses.activate 25 | def test_rate_limit_exceeded(): 26 | # 4372eff77b8343cebfc843eb4da4ddc4 will always return 402 27 | responses.add( 28 | responses.GET, 29 | geocoder.url, 30 | body=Path('test/fixtures/402_rate_limit_exceeded.json').read_text(encoding="utf-8"), 31 | status=402, 32 | headers={ 33 | 'X-RateLimit-Limit': '2500', 34 | 'X-RateLimit-Remaining': '0', 35 | 'X-RateLimit-Reset': '1402185600' 36 | } 37 | ) 38 | 39 | with pytest.raises(RateLimitExceededError) as excinfo: 40 | geocoder.geocode("whatever") 41 | assert 'You have used the requests available on your plan.' in str(excinfo.value) 42 | -------------------------------------------------------------------------------- /vagrant-provision.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo apt-get update -qq 4 | sudo apt-get install --no-install-recommends -qq -y make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev 5 | 6 | export PATH="${HOME}/.local/bin:$PATH" 7 | 8 | # pip and tox should be installed with system python, not any .pyenv environments 9 | sudo apt-get install -y python3-pip 10 | pip install --upgrade pip 11 | pip install tox 12 | 13 | 14 | # https://github.com/pyenv/pyenv-installer#readme 15 | curl -s -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash 16 | 17 | 18 | echo ' 19 | export PATH="${HOME}/.pyenv/bin:$PATH" 20 | eval "$(pyenv init -)" 21 | eval "$(pyenv virtualenv-init -)" 22 | ' >> ~/.bashrc 23 | source ~/.bashrc 24 | exec $SHELL 25 | 26 | # 'exec $SHELL' doesn't work well in a provision file. Likely you need to 27 | # run the following commands manually after 'vagrant up' 28 | 29 | for VERSION in 3.8 3.9 3.10 3.11 3.12 3.13; do 30 | VERSION=$(pyenv latest --known $VERSION) 31 | echo "Installing $VERSION ..." 32 | pyenv install --skip-existing $VERSION 33 | done 34 | 35 | # Any version not part of the globals isn't found by tox' envlist 36 | pyenv global $(pyenv versions --bare) 37 | pyenv versions 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 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. -------------------------------------------------------------------------------- /test/test_session.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | import responses 7 | 8 | from opencage.geocoder import OpenCageGeocode 9 | from opencage.geocoder import NotAuthorizedError 10 | 11 | 12 | def _any_result_around(results, lat=None, lon=None): 13 | for result in results: 14 | if (abs(result['geometry']['lat'] - lat) < 0.05 15 | and abs(result['geometry']['lng'] - lon) < 0.05): 16 | return True 17 | return False 18 | 19 | 20 | @responses.activate 21 | def test_success(): 22 | with OpenCageGeocode('abcde') as geocoder: 23 | responses.add( 24 | responses.GET, 25 | geocoder.url, 26 | body=Path('test/fixtures/uk_postcode.json').read_text(encoding="utf-8"), 27 | status=200 28 | ) 29 | 30 | results = geocoder.geocode("EC1M 5RF") 31 | assert _any_result_around(results, lat=51.5201666, lon=-0.0985142) 32 | 33 | 34 | @responses.activate 35 | def test_failure(): 36 | with OpenCageGeocode('unauthorized-key') as geocoder: 37 | responses.add( 38 | responses.GET, 39 | geocoder.url, 40 | body=Path('test/fixtures/401_not_authorized.json').read_text(encoding="utf-8"), 41 | status=401, 42 | ) 43 | 44 | with pytest.raises(NotAuthorizedError) as excinfo: 45 | geocoder.geocode("whatever") 46 | assert str(excinfo.value) == 'Your API key is not authorized. You may have entered it incorrectly.' 47 | -------------------------------------------------------------------------------- /test/test_error_unknown.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import responses 4 | 5 | from opencage.geocoder import OpenCageGeocode 6 | from opencage.geocoder import UnknownError 7 | 8 | geocoder = OpenCageGeocode('abcde') 9 | 10 | 11 | @responses.activate 12 | def test_http_500_status(): 13 | responses.add( 14 | responses.GET, 15 | geocoder.url, 16 | body='{}', 17 | status=500, 18 | ) 19 | 20 | with pytest.raises(UnknownError) as excinfo: 21 | geocoder.geocode('whatever') 22 | 23 | assert str(excinfo.value) == '500 status code from API' 24 | 25 | 26 | @responses.activate 27 | def test_non_json(): 28 | "These kinds of errors come from webserver and may not be JSON" 29 | responses.add( 30 | responses.GET, 31 | geocoder.url, 32 | body='

503 Service Unavailable

', 33 | headers={ 34 | 'Content-Type': 'text/html', 35 | }, 36 | status=503 37 | ) 38 | 39 | with pytest.raises(UnknownError) as excinfo: 40 | geocoder.geocode('whatever') 41 | 42 | assert str(excinfo.value) == 'Non-JSON result from server' 43 | 44 | 45 | @responses.activate 46 | def test_no_results_key(): 47 | responses.add( 48 | responses.GET, 49 | geocoder.url, 50 | body='{"spam": "eggs"}', 51 | status=200, # Need to specify status code with responses 52 | ) 53 | 54 | with pytest.raises(UnknownError) as excinfo: 55 | geocoder.geocode('whatever') 56 | 57 | assert str(excinfo.value) == "JSON from API doesn't have a 'results' key" 58 | -------------------------------------------------------------------------------- /test/test_async.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import pytest 4 | 5 | from opencage.geocoder import ForbiddenError, OpenCageGeocode, AioHttpError 6 | 7 | # NOTE: Testing keys https://opencagedata.com/api#testingkeys 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_success(): 12 | async with OpenCageGeocode('6d0e711d72d74daeb2b0bfd2a5cdfdba') as geocoder: 13 | results = await geocoder.geocode_async("EC1M 5RF") 14 | assert any( 15 | abs(result['geometry']['lat'] - 51.952659 < 0.05 16 | and abs(result['geometry']['lng'] - 7.632473) < 0.05) 17 | for result in results 18 | ) 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_failure(): 23 | async with OpenCageGeocode('6c79ee8e1ca44ad58ad1fc493ba9542f') as geocoder: 24 | with pytest.raises(ForbiddenError) as excinfo: 25 | await geocoder.geocode_async("Atlantis") 26 | 27 | assert str(excinfo.value) == 'Your API key has been blocked or suspended.' 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_without_async_session(): 32 | geocoder = OpenCageGeocode('4372eff77b8343cebfc843eb4da4ddc4') 33 | 34 | with pytest.raises(AioHttpError) as excinfo: 35 | await geocoder.geocode_async("Atlantis") 36 | 37 | assert str(excinfo.value) == 'Async methods must be used inside an async context.' 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_using_non_async_method(): 42 | async with OpenCageGeocode('6d0e711d72d74daeb2b0bfd2a5cdfdba') as geocoder: 43 | with pytest.raises(AioHttpError) as excinfo: 44 | await geocoder.geocode("Atlantis") 45 | 46 | assert str(excinfo.value) == 'Cannot use `geocode` in an async context, use `geocode_async`.' 47 | -------------------------------------------------------------------------------- /test/test_all.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from pathlib import Path 4 | 5 | import os 6 | import responses 7 | 8 | from opencage.geocoder import OpenCageGeocode 9 | 10 | # reduce maximum backoff retry time from 120s to 1s 11 | os.environ['BACKOFF_MAX_TIME'] = '1' 12 | 13 | 14 | geocoder = OpenCageGeocode('abcde') 15 | 16 | 17 | def _any_result_around(results, lat=None, lon=None): 18 | for result in results: 19 | if (abs(result['geometry']['lat'] - lat) < 0.05 20 | and abs(result['geometry']['lng'] - lon) < 0.05): 21 | return True 22 | return False 23 | 24 | 25 | @responses.activate 26 | def test_gb_postcode(): 27 | responses.add( 28 | responses.GET, 29 | geocoder.url, 30 | body=Path('test/fixtures/uk_postcode.json').read_text(encoding="utf-8"), 31 | status=200 32 | ) 33 | 34 | results = geocoder.geocode("EC1M 5RF") 35 | assert _any_result_around(results, lat=51.5201666, lon=-0.0985142) 36 | 37 | 38 | @responses.activate 39 | def test_australia(): 40 | responses.add( 41 | responses.GET, 42 | geocoder.url, 43 | body=Path('test/fixtures/mudgee_australia.json').read_text(encoding="utf-8"), 44 | status=200 45 | ) 46 | 47 | results = geocoder.geocode("Mudgee, Australia") 48 | assert _any_result_around(results, lat=-32.5980702, lon=149.5886383) 49 | 50 | 51 | @responses.activate 52 | def test_munster(): 53 | responses.add( 54 | responses.GET, 55 | geocoder.url, 56 | body=Path('test/fixtures/muenster.json').read_text(encoding="utf-8"), 57 | status=200 58 | ) 59 | 60 | results = geocoder.geocode("Münster") 61 | assert _any_result_around(results, lat=51.9625101, lon=7.6251879) 62 | 63 | 64 | @responses.activate 65 | def test_donostia(): 66 | responses.add( 67 | responses.GET, 68 | geocoder.url, 69 | body=Path('test/fixtures/donostia.json').read_text(encoding="utf-8"), 70 | status=200 71 | ) 72 | 73 | results = geocoder.geocode("Donostia") 74 | assert _any_result_around(results, lat=43.300836, lon=-1.9809529) 75 | 76 | # test that the results are in unicode 77 | assert results[0]['formatted'] == 'San Sebastián, Autonomous Community of the Basque Country, Spain' 78 | -------------------------------------------------------------------------------- /test/fixtures/mudgee_australia.json: -------------------------------------------------------------------------------- 1 | { 2 | "documentation": "https://opencagedata.com/api", 3 | "licenses": [ 4 | { 5 | "name": "see attribution guide", 6 | "url": "https://opencagedata.com/credits" 7 | } 8 | ], 9 | "rate": { 10 | "limit": 2500, 11 | "remaining": 2496, 12 | "reset": 1615161600 13 | }, 14 | "results": [ 15 | { 16 | "components": { 17 | "ISO_3166-1_alpha-2": "AU", 18 | "ISO_3166-1_alpha-3": "AUS", 19 | "_category": "building", 20 | "_type": "building", 21 | "city": "MUDGEE", 22 | "continent": "Oceania", 23 | "country": "Australia", 24 | "country_code": "au", 25 | "house_number": "46", 26 | "postcode": "2850", 27 | "state": "NEW SOUTH WALES", 28 | "state_code": "NSW", 29 | "street": "MARKET ST" 30 | }, 31 | "confidence": 10, 32 | "formatted": "46 MARKET ST, MUDGEE NSW 2850, Australia", 33 | "geometry": { 34 | "lat": -32.59086, 35 | "lng": 149.5897858 36 | } 37 | }, 38 | { 39 | "bounds": { 40 | "northeast": { 41 | "lat": -32.5713535, 42 | "lng": 149.6091869 43 | }, 44 | "southwest": { 45 | "lat": -32.6539627, 46 | "lng": 149.5470486 47 | } 48 | }, 49 | "components": { 50 | "ISO_3166-1_alpha-2": "AU", 51 | "ISO_3166-1_alpha-3": "AUS", 52 | "_category": "place", 53 | "_type": "city", 54 | "continent": "Oceania", 55 | "country": "Australia", 56 | "country_code": "au", 57 | "municipality": "Mid-Western Regional Council", 58 | "postcode": "2850", 59 | "state": "New South Wales", 60 | "state_code": "NSW", 61 | "town": "Mudgee" 62 | }, 63 | "confidence": 7, 64 | "formatted": "Mudgee NSW 2850, Australia", 65 | "geometry": { 66 | "lat": -32.5980702, 67 | "lng": 149.5886383 68 | } 69 | } 70 | ], 71 | "status": { 72 | "code": 200, 73 | "message": "OK" 74 | }, 75 | "stay_informed": { 76 | "blog": "https://blog.opencagedata.com", 77 | "twitter": "https://twitter.com/OpenCage" 78 | }, 79 | "thanks": "For using an OpenCage API", 80 | "timestamp": { 81 | "created_http": "Sun, 07 Mar 2021 01:14:09 GMT", 82 | "created_unix": 1615079649 83 | }, 84 | "total_results": 2 85 | } -------------------------------------------------------------------------------- /test/test_error_invalid_input.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import responses 4 | 5 | from opencage.geocoder import OpenCageGeocode 6 | from opencage.geocoder import InvalidInputError 7 | 8 | geocoder = OpenCageGeocode('abcde') 9 | 10 | 11 | @responses.activate 12 | def test_must_be_unicode_string(): 13 | responses.add( 14 | responses.GET, 15 | geocoder.url, 16 | body='{"results":{}}', 17 | status=200 18 | ) 19 | 20 | # Should not give errors 21 | geocoder.geocode('xxx') # ascii convertable 22 | geocoder.geocode('xxá') # unicode 23 | 24 | # But if it isn't a unicode string, it should give error 25 | utf8_string = "xxá".encode("utf-8") 26 | latin1_string = "xxá".encode("latin1") 27 | 28 | with pytest.raises(InvalidInputError) as excinfo: 29 | geocoder.geocode(utf8_string) 30 | assert str(excinfo.value) == f"Input must be a unicode string, not {utf8_string!r}" 31 | assert excinfo.value.bad_value == utf8_string 32 | 33 | with pytest.raises(InvalidInputError) as excinfo: 34 | geocoder.geocode(latin1_string) 35 | assert str(excinfo.value) == f"Input must be a unicode string, not {latin1_string!r}" 36 | assert excinfo.value.bad_value == latin1_string 37 | 38 | 39 | @responses.activate 40 | def test_reject_out_of_bounds_coordinates(): 41 | """Test that reverse geocoding rejects out-of-bounds latitude and longitude values.""" 42 | responses.add( 43 | responses.GET, 44 | geocoder.url, 45 | body='{"results":{}}', 46 | status=200 47 | ) 48 | 49 | # Valid coordinates should work 50 | geocoder.reverse_geocode(45.0, 90.0) 51 | geocoder.reverse_geocode(-45.0, -90.0) 52 | 53 | # Invalid latitude values (outside -90 to 90) 54 | with pytest.raises(InvalidInputError) as excinfo: 55 | geocoder.reverse_geocode(91.0, 45.0) 56 | assert "Latitude must be a number between -90 and 90" in str(excinfo.value) 57 | 58 | with pytest.raises(InvalidInputError) as excinfo: 59 | geocoder.reverse_geocode(-91.0, 45.0) 60 | assert "Latitude must be a number between -90 and 90" in str(excinfo.value) 61 | 62 | # Invalid longitude values (outside -180 to 180) 63 | with pytest.raises(InvalidInputError) as excinfo: 64 | geocoder.reverse_geocode(45.0, 181.0) 65 | assert "Longitude must be a number between -180 and 180" in str(excinfo.value) 66 | 67 | with pytest.raises(InvalidInputError) as excinfo: 68 | geocoder.reverse_geocode(45.0, -181.0) 69 | assert "Longitude must be a number between -180 and 180" in str(excinfo.value) 70 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | from setuptools import setup, find_packages 6 | 7 | # if you are not using vagrant, just delete os.link directly, 8 | # The hard link only saves a little disk space, so you should not care 9 | if os.environ.get('USER', '') == 'vagrant': 10 | del os.link 11 | 12 | 13 | ROOT_DIR = os.path.dirname(__file__) 14 | SOURCE_DIR = os.path.join(ROOT_DIR) 15 | 16 | if sys.version_info < (3, 8): 17 | raise RuntimeError( 18 | "opencage requires Python 3.8 or newer" 19 | "Use older opencage 1.x for Python 2.7 or 3.7" 20 | ) 21 | 22 | # try for testing 23 | try: 24 | with open(os.path.join(SOURCE_DIR, 'README.md'), encoding="utf-8") as f: 25 | LONG_DESCRIPTION = f.read() 26 | except FileNotFoundError: 27 | LONG_DESCRIPTION = "" 28 | 29 | setup( 30 | name="opencage", 31 | version="3.2.0", 32 | description="Wrapper module for the OpenCage Geocoder API", 33 | long_description=LONG_DESCRIPTION, 34 | long_description_content_type='text/markdown', 35 | author="OpenCage GmbH", 36 | author_email="info@opencagedata.com", 37 | url="https://github.com/OpenCageData/python-opencage-geocoder/", 38 | download_url="https://github.com/OpenCageData/python-opencage-geocoder/tarball/3.2.0", 39 | license="BSD", 40 | entry_points={ 41 | 'console_scripts': [ 42 | 'opencage=opencage.command_line:main' 43 | ] 44 | }, 45 | packages=find_packages(), 46 | include_package_data=True, 47 | zip_safe=False, 48 | keywords=['geocoding', 'geocoder'], 49 | classifiers=[ 50 | 'Environment :: Web Environment', 51 | "Development Status :: 5 - Production/Stable", 52 | 'Intended Audience :: Developers', 53 | 'License :: OSI Approved :: BSD License', 54 | 'Operating System :: OS Independent', 55 | "Programming Language :: Python :: 3 :: Only", 56 | 'Programming Language :: Python :: 3.8', 57 | 'Programming Language :: Python :: 3.9', 58 | 'Programming Language :: Python :: 3.10', 59 | 'Programming Language :: Python :: 3.11', 60 | 'Programming Language :: Python :: 3.12', 61 | 'Programming Language :: Python :: 3.13', 62 | 'Topic :: Scientific/Engineering :: GIS', 63 | 'Topic :: Utilities' 64 | ], 65 | install_requires=[ 66 | 'Requests>=2.31.0', 67 | 'backoff>=2.2.1', 68 | 'tqdm>=4.66.4', 69 | 'certifi>=2024.07.04', 70 | 'aiohttp>=3.10.5' 71 | ], 72 | test_suite='pytest', 73 | tests_require=[ 74 | 'responses>=0.25.7', 75 | 'flake8>=7.0.0', 76 | 'pytest>=7.4.0' 77 | ], 78 | ) 79 | -------------------------------------------------------------------------------- /Changes.txt: -------------------------------------------------------------------------------- 1 | v3.2.0 Mon May 26 2025 2 | New error 'InvalidInputError' when input coordinates are out of bound 3 | CLI: You can set OPENCAGE_API_KEY environment variable instead specify as parameter 4 | Tests: replace pylint library with flake for enforcing code style 5 | Tests: replace httpretty library with response 6 | Removed very outdated files from docs/ subdirectory 7 | 8 | v3.1.0 Thu Mar 20 2025 9 | CLI: Add special 'status' output column option 10 | CLI: fixed bug where --input-column for reverse would revert to the defaults 11 | 12 | v3.0.4 Mon Dec 30 2024 13 | CLI: Add special 'json' output column option 14 | 15 | v3.0.3 Sat Nov 9 2024 16 | CLI: add 'OpenCage CLI' to the HTTP user agent string 17 | Test suite: remove python 3.7, add 3.13 18 | 19 | v3.0.2 Thu Sep 12 2024 20 | CLI: better handling of empty lines and missing or invalid coordinates 21 | CLI: --dry-run prints an 'all good' message if no warnings 22 | 23 | v3.0.1 Wed Sep 5 2024 24 | CLI: rename parameter "extra-params" to "optional-api-params" 25 | CLI: parameter "unordered" which can lead to speedup on large files 26 | CLI: limit --workers to 1 for free trial accounts 27 | Batch example: replaced by CLI now 28 | 29 | v3.0.0 Wed Sep 4 2024 30 | Requires python 3.7 and asyncio package 31 | Inititial release of the 'opencage' CLI tool 32 | RateLimitExceededError no longer prints reset date 33 | Batch example: warn if no API key present earlier 34 | Batch example: some errors were not printed, e.g. invalid API key 35 | Batch example: Check latest version of opencage package is used 36 | Add python 3.12, no longer test against python 3.7 37 | 38 | v2.3.1 Wed Nov 15 2023 39 | New error 'SSLError' which is more explicit in case of SSL certificate chain issues 40 | Allow setting a domain name (only used in test suite) 41 | Allow setting sslcontext, for example to ignore SSL certificat errors (for debugging only) 42 | Batch example: Guess if input is coordinate pair, if so then do reverse geocoding 43 | Batch example: Give example of input file format 44 | Batch example: Ship CA root certificates instead of relying on those of the operating system 45 | 46 | v2.3.0 Tue 04 Jul 2023 47 | Batch example: Raise exception when API key fails (quota, missing API key) 48 | Batch example: Raise exception when input file contains an empty line. Better 49 | early than risking errors later during the async geocoding 50 | Batch example: The CSV '1, "street,city"' were parsed as 3 columns, not the 51 | expected 2 columns 52 | Test suite: Switched from Travis-CI to Github Actions 53 | Dependencies: Requests 2.26->2.31, backoff 1.10->2.2.1, because only those say to support Python 3.10 54 | 55 | v2.2.0 Fri 05 May 2023 56 | Using requests > 2.26 , pyopenssl no longer a dependency 57 | Allow setting the http protocol to make API requests (less secure) 58 | Batch example: add progress bar, can be switched off 59 | Batch example: add image of output to README 60 | Examples: mark scripts as executable 61 | 62 | v2.1.1 Thu 20 Apr 2023 63 | Add python 3.11, no longer test against python 3.6 64 | Test suite: add pytest-asyncio to enable async tests 65 | Batch example: exception handling caused another exception 66 | Batch example: by default don't request annotations (faster) 67 | Documentation improvements 68 | 69 | v2.1.0 Thu 13 Oct 2022 70 | Better handling of non-JSON error responses from API 71 | Test on Python 3.10 72 | 73 | v2.0.0 Thu 15 Jul 2021 74 | Python 2 no longer supported 75 | New geocode_async and reverse_geocode_async methods 76 | You can now reuse HTTP connections for multiple requests by using a `with` block 77 | Updated examples/batch.py using asyncronous/parallel requests, much faster 78 | Mayor refactor of test suite 79 | Some tests now run against the live API endpoint using test API keys 80 | 81 | v1.2.2 Tue 3 Nov 2020 82 | Test on Python 3.9 83 | 84 | v1.2.1 Wed 6 May 2020 85 | Ensure OpenSSL is available on installation 86 | Add exponential backoff to HTTP requests 87 | 88 | v1.2 Sun 9 Jun 2019 89 | Use https as default 90 | Handle 401 and 403 exceptions 91 | Documentation clean up 92 | Test proximity works and add an example to README 93 | 94 | v1.1.5 Sun 30 Sep 2018 95 | Update setup.py to show project description on pypi.org 96 | 97 | v1.1.4 Mon 19 Jul 2015 98 | Server response for exceeded quota is HTTP 402, not 429 99 | 100 | v1.1.3 Mon 29 Jun 2015 101 | * .tar.gz file was missing for v1.1.2 102 | 103 | v1.1.2 Mon 29 Jun 2015 104 | * Allow extra arguments to forward and reverse geocode call. Thanks https://github.com/CrazyPython -------------------------------------------------------------------------------- /examples/batch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Example script we used between 2021 and 2023. It's now being replaced by 4 | # the much more powerful CLI tool (see README.md file). 5 | # 6 | # Git version history will show how we kept adding features. Below is a 7 | # version with less features, on purpose, for better readability. 8 | # 9 | # Background tutorial on async programming with Python 10 | # https://realpython.com/async-io-python/ 11 | # 12 | # Requires Python 3.7 or newer. Tested with 3.8 and 3.9. 13 | # 14 | # Installation: 15 | # pip3 install opencage 16 | # 17 | 18 | import sys 19 | import csv 20 | import asyncio 21 | from opencage.geocoder import OpenCageGeocode 22 | 23 | API_KEY = '' 24 | INFILE = 'file_to_geocode.csv' 25 | OUTFILE = 'file_geocoded.csv' 26 | MAX_ITEMS = 100 # Set to 0 for unlimited 27 | NUM_WORKERS = 3 # For 10 requests per second try 2-5 28 | 29 | csv_writer = csv.writer(open(OUTFILE, 'w', encoding='utf8', newline='')) 30 | 31 | async def write_one_geocoding_result(geocoding_result, address, address_id): 32 | if geocoding_result is not None: 33 | geocoding_result = geocoding_result[0] 34 | row = [ 35 | address_id, 36 | geocoding_result['geometry']['lat'], 37 | geocoding_result['geometry']['lng'], 38 | # Any of these components might be empty : 39 | geocoding_result['components'].get('country', ''), 40 | geocoding_result['components'].get('county', ''), 41 | geocoding_result['components'].get('city', ''), 42 | geocoding_result['components'].get('postcode', ''), 43 | geocoding_result['components'].get('road', ''), 44 | geocoding_result['components'].get('house_number', ''), 45 | geocoding_result['confidence'], 46 | geocoding_result['formatted'] 47 | ] 48 | 49 | else: 50 | row = [ 51 | address_id, 52 | 0, # not to be confused with https://en.wikipedia.org/wiki/Null_Island 53 | 0, 54 | '', 55 | '', 56 | '', 57 | '', 58 | '', 59 | '', 60 | -1, # confidence values are 1-10 (lowest to highest), use -1 for unknown 61 | '' 62 | ] 63 | sys.stderr.write(f"not found, writing empty result: ${address}\n") 64 | csv_writer.writerow(row) 65 | 66 | 67 | async def geocode_one_address(address, address_id): 68 | async with OpenCageGeocode(API_KEY) as geocoder: 69 | geocoding_result = await geocoder.geocode_async(address) 70 | try: 71 | await write_one_geocoding_result(geocoding_result, address, address_id) 72 | except Exception as e: 73 | sys.stderr.write(e) 74 | 75 | 76 | 77 | async def run_worker(worker_name, queue): 78 | sys.stderr.write(f"Worker ${worker_name} starts...\n") 79 | while True: 80 | work_item = await queue.get() 81 | address_id = work_item['id'] 82 | address = work_item['address'] 83 | await geocode_one_address(address, address_id) 84 | queue.task_done() 85 | 86 | 87 | 88 | 89 | async def main(): 90 | assert sys.version_info >= (3, 7), "Script requires Python 3.7+." 91 | 92 | ## 1. Read CSV into a Queue 93 | ## Each work_item is an address and id. The id will be part of the output, 94 | ## easy to add more settings. Named 'work_item' to avoid the words 95 | ## 'address' or 'task' which are used elsewhere 96 | ## 97 | ## https://docs.python.org/3/library/asyncio-queue.html 98 | ## 99 | queue = asyncio.Queue(maxsize=MAX_ITEMS) 100 | 101 | csv_reader = csv.reader(open(INFILE, 'r', encoding='utf8')) 102 | 103 | for row in csv_reader: 104 | work_item = {'id': row[0], 'address': row[1]} 105 | await queue.put(work_item) 106 | if queue.full(): 107 | break 108 | 109 | sys.stderr.write(f"${queue.qsize()} work_items in queue\n") 110 | 111 | 112 | ## 2. Create tasks workers. That is coroutines, each taks take work_items 113 | ## from the queue until it's empty. Tasks run in parallel 114 | ## 115 | ## https://docs.python.org/3/library/asyncio-task.html#creating-tasks 116 | ## https://docs.python.org/3/library/asyncio-task.html#coroutine 117 | ## 118 | sys.stderr.write(f"Creating ${NUM_WORKERS} task workers...\n") 119 | tasks = [] 120 | for i in range(NUM_WORKERS): 121 | task = asyncio.create_task(run_worker(f'worker {i}', queue)) 122 | tasks.append(task) 123 | 124 | 125 | ## 3. Now workers do the geocoding 126 | ## 127 | sys.stderr.write("Now waiting for workers to finish processing queue...\n") 128 | await queue.join() 129 | 130 | 131 | ## 4. Cleanup 132 | ## 133 | for task in tasks: 134 | task.cancel() 135 | 136 | sys.stderr.write("All done.\n") 137 | 138 | 139 | asyncio.run(main()) 140 | -------------------------------------------------------------------------------- /test/fixtures/badssl-com-chain.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE8DCCA9igAwIBAgISA4mqZntfCH8MYyIVqUF1XZgpMA0GCSqGSIb3DQEBCwUA 3 | MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD 4 | EwJSMzAeFw0yMzEwMTkxNTUwMjlaFw0yNDAxMTcxNTUwMjhaMBcxFTATBgNVBAMM 5 | DCouYmFkc3NsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONj 6 | dsqxZsR+pDzWX6GLCy6ImoAT60LNYvs9U6BIQ+fatIWbMELAFD6jY+IP25hrVEr1 7 | bgwRWmAAOnUc2qKXdtx6KXXO3cAJoCSHFNBDEZqzg/+exj+3emQH8dVZiYAS2Rpd 8 | nL9uKc3xgDDb74p1m7J4JdMewHmebRUmMt0MbA0f8sxvhbv9wIXkgAZd6dKYPGzJ 9 | KJlCoQifPiJ66JwYk8WVGEJH9m8LNDse388MscfsuwvAAh9tt2Fq6rmV9s21P6qf 10 | JgjePl65e8fVjsEWBAvC/aMYvTUs7Gdqej0qByjESpt1LZClNomJDvIgqA9+5KsU 11 | yCXigT6OiPjtZhdhgw0CAwEAAaOCAhkwggIVMA4GA1UdDwEB/wQEAwIFoDAdBgNV 12 | HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4E 13 | FgQUqryJ2HM8bXniU+mPXLakkgUQobMwHwYDVR0jBBgwFoAUFC6zF7dYVsuuUAlA 14 | 5h+vnYsUwsYwVQYIKwYBBQUHAQEESTBHMCEGCCsGAQUFBzABhhVodHRwOi8vcjMu 15 | by5sZW5jci5vcmcwIgYIKwYBBQUHMAKGFmh0dHA6Ly9yMy5pLmxlbmNyLm9yZy8w 16 | IwYDVR0RBBwwGoIMKi5iYWRzc2wuY29tggpiYWRzc2wuY29tMBMGA1UdIAQMMAow 17 | CAYGZ4EMAQIBMIIBAwYKKwYBBAHWeQIEAgSB9ASB8QDvAHUA2ra/az+1tiKfm8K7 18 | XGvocJFxbLtRhIU0vaQ9MEjX+6sAAAGLSNh8nwAABAMARjBEAiBkJnQowOqs+tDj 19 | 7qXXu0PlDCvgvtEemuw1OvInlaHSrAIgcCZV5dJmGVrS1voinEpAzScJejhGB0vb 20 | G8dfKhJZD+wAdgA7U3d1Pi25gE6LMFsG/kA7Z9hPw/THvQANLXJv4frUFwAAAYtI 21 | 2HyZAAAEAwBHMEUCIQCJ+gamX0P/hGiIuu70hn8d0svHSOAMJs3D+eOjMVqsywIg 22 | JXR/lAknUTRU+SyfySDoQ22bDSXfYWZGHLFgAkiRo48wDQYJKoZIhvcNAQELBQAD 23 | ggEBAGE3PDg7p2N8aZyAyO0pGVb/ob9opu12g+diNIdRSjsKIE+TO3uClM2OxT0t 24 | 5GBz6Owbe010MQtqBKmX4Zm2LSLUm1kVhPh2ohWmA4hTyN3RG5W0IJ3red6VjrJY 25 | URhZQoXQb0gonxMs+zC+4GQ7+yqzWA1UkrWrURjjJCuljyoWF9sE7qEweomSQWnV 26 | v6bIF599/di1R2l5vcRq1DsQDgKaFY4IpKnvh3RhgO19YxlSS9ERRGBem3Aml9tb 27 | Yac12RmyuxsEAr0v75YeL3pAuq/1Rd5OeKfkm+K06Px3LxwcF92RljXkH6T2U8VM 28 | PEFKedHjYjAag3DUMqSuuGI+ONU= 29 | -----END CERTIFICATE----- 30 | -----BEGIN CERTIFICATE----- 31 | MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw 32 | TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh 33 | cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw 34 | WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg 35 | RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK 36 | AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP 37 | R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx 38 | sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm 39 | NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg 40 | Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG 41 | /kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC 42 | AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB 43 | Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA 44 | FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw 45 | AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw 46 | Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB 47 | gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W 48 | PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl 49 | ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz 50 | CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm 51 | lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4 52 | avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2 53 | yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O 54 | yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids 55 | hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+ 56 | HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv 57 | MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX 58 | nLRbwHOoq7hHwg== 59 | -----END CERTIFICATE----- 60 | -----BEGIN CERTIFICATE----- 61 | MIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/ 62 | MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT 63 | DkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow 64 | TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh 65 | cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwggIiMA0GCSqGSIb3DQEB 66 | AQUAA4ICDwAwggIKAoICAQCt6CRz9BQ385ueK1coHIe+3LffOJCMbjzmV6B493XC 67 | ov71am72AE8o295ohmxEk7axY/0UEmu/H9LqMZshftEzPLpI9d1537O4/xLxIZpL 68 | wYqGcWlKZmZsj348cL+tKSIG8+TA5oCu4kuPt5l+lAOf00eXfJlII1PoOK5PCm+D 69 | LtFJV4yAdLbaL9A4jXsDcCEbdfIwPPqPrt3aY6vrFk/CjhFLfs8L6P+1dy70sntK 70 | 4EwSJQxwjQMpoOFTJOwT2e4ZvxCzSow/iaNhUd6shweU9GNx7C7ib1uYgeGJXDR5 71 | bHbvO5BieebbpJovJsXQEOEO3tkQjhb7t/eo98flAgeYjzYIlefiN5YNNnWe+w5y 72 | sR2bvAP5SQXYgd0FtCrWQemsAXaVCg/Y39W9Eh81LygXbNKYwagJZHduRze6zqxZ 73 | Xmidf3LWicUGQSk+WT7dJvUkyRGnWqNMQB9GoZm1pzpRboY7nn1ypxIFeFntPlF4 74 | FQsDj43QLwWyPntKHEtzBRL8xurgUBN8Q5N0s8p0544fAQjQMNRbcTa0B7rBMDBc 75 | SLeCO5imfWCKoqMpgsy6vYMEG6KDA0Gh1gXxG8K28Kh8hjtGqEgqiNx2mna/H2ql 76 | PRmP6zjzZN7IKw0KKP/32+IVQtQi0Cdd4Xn+GOdwiK1O5tmLOsbdJ1Fu/7xk9TND 77 | TwIDAQABo4IBRjCCAUIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw 78 | SwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5pZGVudHJ1 79 | c3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTEp7Gkeyxx 80 | +tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEB 81 | ATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQu 82 | b3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0LmNvbS9E 83 | U1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFHm0WeZ7tuXkAXOACIjIGlj26Ztu 84 | MA0GCSqGSIb3DQEBCwUAA4IBAQAKcwBslm7/DlLQrt2M51oGrS+o44+/yQoDFVDC 85 | 5WxCu2+b9LRPwkSICHXM6webFGJueN7sJ7o5XPWioW5WlHAQU7G75K/QosMrAdSW 86 | 9MUgNTP52GE24HGNtLi1qoJFlcDyqSMo59ahy2cI2qBDLKobkx/J3vWraV0T9VuG 87 | WCLKTVXkcGdtwlfFRjlBz4pYg1htmf5X6DYO8A4jqv2Il9DjXA6USbW1FzXSLr9O 88 | he8Y4IWS6wY7bCkjCWDcRQJMEhg76fsO3txE+FiYruq9RUWhiF1myv4Q6W+CyBFC 89 | Dfvp7OOGAN6dEOM4+qR9sdjoSYKEBpsr6GtPAQw4dy753ec5 90 | -----END CERTIFICATE----- 91 | -------------------------------------------------------------------------------- /test/cli/test_cli_args.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import pytest 3 | from opencage.version import __version__ 4 | 5 | from opencage.command_line import parse_args 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def around(): 10 | yield 11 | try: 12 | pathlib.Path("test/fixtures/output.csv").unlink() 13 | except FileNotFoundError: 14 | pass 15 | 16 | 17 | def assert_parse_args_error(args, message, capfd): 18 | with pytest.raises(SystemExit): 19 | parse_args(args) 20 | 21 | _, err = capfd.readouterr() 22 | assert message in err 23 | 24 | 25 | def test_required_arguments(capfd): 26 | assert_parse_args_error( 27 | [], 28 | 'To display help use', 29 | capfd 30 | ) 31 | 32 | 33 | def test_invalid_command(capfd): 34 | assert_parse_args_error( 35 | [ 36 | "singasong" 37 | ], 38 | 'argument command: invalid choice', 39 | capfd 40 | ) 41 | 42 | 43 | def test_version_number(capfd): 44 | with pytest.raises(SystemExit): 45 | parse_args(['--version']) 46 | out, _ = capfd.readouterr() 47 | 48 | assert __version__ in out 49 | 50 | 51 | def test_invalid_api_key(capfd): 52 | assert_parse_args_error( 53 | [ 54 | "forward", 55 | "--api-key", "invalid", 56 | "--input", "test/fixtures/input.txt", 57 | "--output", "test/fixtures/output.csv" 58 | ], 59 | 'invalid API key', 60 | capfd 61 | ) 62 | 63 | 64 | def test_existing_output_file(capfd): 65 | assert_parse_args_error( 66 | [ 67 | "forward", 68 | "--api-key", "oc_gc_12345678901234567890123456789012", 69 | "--input", "test/fixtures/input.txt", 70 | "--output", "test/fixtures/input.txt" 71 | ], 72 | 'already exists', 73 | capfd 74 | ) 75 | 76 | 77 | def test_argument_range(capfd): 78 | assert_parse_args_error( 79 | [ 80 | "forward", 81 | "--api-key", "oc_gc_12345678901234567890123456789012", 82 | "--input", "test/fixtures/input.txt", 83 | "--output", "test/fixtures/output.csv", 84 | "--workers", "200" 85 | ], 86 | 'must be within [1, 20]', 87 | capfd 88 | ) 89 | 90 | 91 | def test_zero_based_list(capfd): 92 | assert_parse_args_error( 93 | [ 94 | "forward", 95 | "--api-key", "oc_gc_12345678901234567890123456789012", 96 | "--input", "test/fixtures/input.txt", 97 | "--output", "test/fixtures/output.csv", 98 | "--input-columns", "0,1,2" 99 | ], 100 | 'The lowest possible number is 1', 101 | capfd 102 | ) 103 | 104 | 105 | def test_full_argument_list(): 106 | args = parse_args([ 107 | "reverse", 108 | "--api-key", "oc_gc_12345678901234567890123456789012", 109 | "--input", "test/fixtures/input.txt", 110 | "--output", "test/fixtures/output.csv", 111 | "--headers", 112 | "--input-columns", "1,2", 113 | "--add-columns", "city,postcode", 114 | "--limit", "4", 115 | "--workers", "3", 116 | "--timeout", "2", 117 | "--retries", "1", 118 | "--dry-run", 119 | "--unordered", 120 | "--api-domain", "bulk.opencagedata.com", 121 | "--optional-api-params", "extra=1", 122 | "--no-progress", 123 | "--quiet" 124 | ]) 125 | 126 | assert args.command == "reverse" 127 | assert args.api_key == "oc_gc_12345678901234567890123456789012" 128 | assert args.input.name == "test/fixtures/input.txt" 129 | assert args.output == "test/fixtures/output.csv" 130 | assert args.headers is True 131 | assert args.input_columns == [1, 2] 132 | assert args.add_columns == ["city", "postcode"] 133 | assert args.limit == 4 134 | assert args.workers == 3 135 | assert args.timeout == 2 136 | assert args.retries == 1 137 | assert args.dry_run is True 138 | assert args.unordered is True 139 | assert args.api_domain == "bulk.opencagedata.com" 140 | assert args.optional_api_params == {"extra": "1"} 141 | assert args.no_progress is True 142 | assert args.quiet is True 143 | 144 | 145 | def test_defaults(): 146 | args = parse_args([ 147 | "forward", 148 | "--api-key", "12345678901234567890123456789012", 149 | "--input", "test/fixtures/input.txt", 150 | "--output", "test/fixtures/output.csv" 151 | ]) 152 | 153 | assert args.command == "forward" 154 | assert args.limit == 0 155 | assert args.headers is False 156 | assert args.input_columns == [1] 157 | assert args.add_columns == [ 158 | "lat", 159 | "lng", 160 | "_type", 161 | "_category", 162 | "country_code", 163 | "country", 164 | "state", 165 | "county", 166 | "_normalized_city", 167 | "postcode", 168 | "road", 169 | "house_number", 170 | "confidence", 171 | "formatted"] 172 | assert args.workers == 1 173 | assert args.timeout == 10 174 | assert args.retries == 10 175 | assert args.dry_run is False 176 | assert args.unordered is False 177 | assert args.api_domain == "api.opencagedata.com" 178 | assert args.optional_api_params == {} 179 | assert args.no_progress is False 180 | assert args.quiet is False 181 | 182 | 183 | def test_reverse_input_columns(): 184 | args = parse_args([ 185 | "reverse", 186 | "--api-key", "12345678901234567890123456789012", 187 | "--input", "test/fixtures/input.txt", 188 | "--output", "test/fixtures/output.csv" 189 | ]) 190 | assert args.input_columns == [1, 2] 191 | 192 | args = parse_args([ 193 | "reverse", 194 | "--api-key", "12345678901234567890123456789012", 195 | "--input", "test/fixtures/input.txt", 196 | "--output", "test/fixtures/output.csv", 197 | "--input-columns", '2,1' 198 | ]) 199 | 200 | assert args.input_columns == [2, 1] 201 | -------------------------------------------------------------------------------- /test/cli/test_cli_run.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import os 3 | import pytest 4 | 5 | from opencage.command_line import main 6 | 7 | # NOTE: Testing keys https://opencagedata.com/api#testingkeys 8 | TEST_APIKEY_200 = '6d0e711d72d74daeb2b0bfd2a5cdfdba' # always returns same address 9 | TEST_APIKEY_401 = '11111111111111111111111111111111' # invalid key 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def around(): 14 | yield 15 | try: 16 | pathlib.Path("test/fixtures/cli/output.csv").unlink() 17 | except FileNotFoundError: 18 | pass 19 | 20 | 21 | def assert_output(path, length, lines): 22 | assert pathlib.Path(path).exists() 23 | 24 | with open(path, "r", encoding="utf-8") as f: 25 | actual = f.readlines() 26 | # print(actual, file=sys.stderr) 27 | assert len(actual) == length 28 | 29 | for i, expected in enumerate(lines): 30 | assert actual[i].strip() == expected 31 | 32 | 33 | def test_forward(): 34 | main([ 35 | "forward", 36 | "--api-key", TEST_APIKEY_200, 37 | "--input", "test/fixtures/cli/forward.csv", 38 | "--output", "test/fixtures/cli/output.csv", 39 | "--input-columns", "2,3,4", 40 | "--add-columns", "country_code,country,postcode,city" 41 | ]) 42 | 43 | assert_output( 44 | path="test/fixtures/cli/output.csv", 45 | length=3, 46 | lines=['Rathausmarkt 1,Hamburg,20095,Germany,de,Germany,48153,Münster'] 47 | ) 48 | 49 | 50 | def test_reverse(): 51 | main([ 52 | "reverse", 53 | "--api-key", TEST_APIKEY_200, 54 | "--input", "test/fixtures/cli/reverse.csv", 55 | "--output", "test/fixtures/cli/output.csv", 56 | "--add-columns", "country_code,country,postcode" 57 | ]) 58 | 59 | assert_output( 60 | path="test/fixtures/cli/output.csv", 61 | length=1, 62 | lines=['51.9526622,7.6324709,de,Germany,48153'] 63 | ) 64 | 65 | 66 | def test_headers(): 67 | main([ 68 | "forward", 69 | "--api-key", TEST_APIKEY_200, 70 | "--input", "test/fixtures/cli/forward_with_headers.csv", 71 | "--output", "test/fixtures/cli/output.csv", 72 | "--input-columns", "1,2,3,4", 73 | "--headers", 74 | "--add-columns", "lat,lng,postcode" 75 | ]) 76 | 77 | assert_output( 78 | path="test/fixtures/cli/output.csv", 79 | length=4, 80 | lines=[ 81 | 'street and number,town,postcode,country,lat,lng,postcode', 82 | 'Rathausmarkt 1,Hamburg,20095,Germany,51.9526622,7.6324709,48153' 83 | ] 84 | ) 85 | 86 | 87 | def test_input_errors(capfd): 88 | main([ 89 | "reverse", 90 | "--api-key", TEST_APIKEY_200, 91 | "--input", "test/fixtures/cli/reverse_with_errors.csv", 92 | "--output", "test/fixtures/cli/output.csv", 93 | "--add-columns", "country_code,postcode", 94 | "--no-progress" 95 | ]) 96 | 97 | _, err = capfd.readouterr() 98 | # assert err == '' 99 | assert err.count("\n") == 7 100 | assert "Line 1 - Missing input column 2 in ['50.101010']" in err 101 | assert "Line 1 - Expected two comma-separated values for reverse geocoding, got ['50.101010']" in err 102 | assert "Line 3 - Empty line" in err 103 | assert "Line 3 - Missing input column 2 in ['']" in err 104 | assert "Line 3 - Expected two comma-separated values for reverse geocoding, got ['']" in err 105 | assert "Line 4 - Does not look like latitude and longitude: 'a' and 'b'" in err 106 | 107 | assert_output( 108 | path="test/fixtures/cli/output.csv", 109 | length=4, 110 | lines=[ 111 | '50.101010,,', 112 | '-100,60.1,,', 113 | ',,', 114 | 'a,b,,' 115 | ] 116 | ) 117 | 118 | 119 | def test_empty_result(): 120 | # 'NOWHERE-INTERESTING' is guaranteed to return no result 121 | # https://opencagedata.com/api#testingkeys 122 | main([ 123 | "forward", 124 | "--api-key", TEST_APIKEY_200, 125 | "--input", "test/fixtures/cli/forward_noresult.csv", 126 | "--output", "test/fixtures/cli/output.csv", 127 | "--input-columns", "2", 128 | "--headers", 129 | "--verbose", 130 | "--add-columns", "lat,lng,postcode" 131 | ]) 132 | 133 | assert_output( 134 | path="test/fixtures/cli/output.csv", 135 | length=2, 136 | lines=[ 137 | 'id,full_address,lat,lng,postcode', 138 | '123,NOWHERE-INTERESTING,,,' 139 | ] 140 | ) 141 | 142 | 143 | def test_invalid_api_key(capfd): 144 | main([ 145 | "forward", 146 | "--api-key", TEST_APIKEY_401, 147 | "--input", "test/fixtures/cli/forward_with_headers.csv", 148 | "--output", "test/fixtures/cli/output.csv" 149 | ]) 150 | 151 | _, err = capfd.readouterr() 152 | assert 'Your API key is not authorized' in err 153 | 154 | 155 | def test_dryrun(capfd): 156 | main([ 157 | "forward", 158 | "--api-key", TEST_APIKEY_200, 159 | "--input", "test/fixtures/cli/forward_with_headers.csv", 160 | "--output", "test/fixtures/cli/output.csv", 161 | "--dry-run" 162 | ]) 163 | 164 | assert not os.path.isfile("test/fixtures/cli/output.csv") 165 | 166 | out, _ = capfd.readouterr() 167 | assert out.count("\n") == 1 168 | assert "All good." in out 169 | 170 | 171 | def test_invalid_domain(capfd): 172 | main([ 173 | "forward", 174 | "--api-key", TEST_APIKEY_200, 175 | "--input", "test/fixtures/cli/forward.csv", 176 | "--output", "test/fixtures/cli/output.csv", 177 | "--api-domain", "invalid73585348.opencagedata.com" 178 | ]) 179 | 180 | _, err = capfd.readouterr() 181 | assert 'Cannot connect to host' in err 182 | 183 | # with dry-run no request will be made 184 | main([ 185 | "forward", 186 | "--api-key", TEST_APIKEY_200, 187 | "--input", "test/fixtures/cli/forward.csv", 188 | "--output", "test/fixtures/cli/output.csv", 189 | "--api-domain", "invalid73585348.opencagedata.com", 190 | "--dry-run" 191 | ]) 192 | _, err = capfd.readouterr() 193 | assert err == '' 194 | -------------------------------------------------------------------------------- /opencage/command_line.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import os 4 | import io 5 | import re 6 | import csv 7 | 8 | from opencage.batch import OpenCageBatchGeocoder 9 | from opencage.version import __version__ 10 | 11 | 12 | def main(args=sys.argv[1:]): 13 | options = parse_args(args) 14 | 15 | assert sys.version_info >= (3, 8), "Script requires Python 3.8 or newer" 16 | 17 | geocoder = OpenCageBatchGeocoder(options) 18 | 19 | with options.input as input_filename: 20 | with (io.StringIO() if options.dry_run else open(options.output, 'x', encoding='utf-8')) as output_io: 21 | reader = csv.reader(input_filename, strict=True, skipinitialspace=True) 22 | writer = csv.writer(output_io) 23 | 24 | geocoder(csv_input=reader, csv_output=writer) 25 | 26 | 27 | def parse_args(args): 28 | if len(args) == 0: 29 | print("To display help use 'opencage -h', 'opencage forward -h' or 'opencage reverse -h'", file=sys.stderr) 30 | sys.exit(1) 31 | 32 | parser = argparse.ArgumentParser(description=f'Opencage CLI {__version__}') 33 | parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}') 34 | 35 | subparsers = parser.add_subparsers(dest='command') 36 | subparsers.required = True 37 | 38 | subparser_forward = subparsers.add_parser( 39 | 'forward', help="Forward geocode a file (input is address, add coordinates)") 40 | subparser_reverse = subparsers.add_parser( 41 | 'reverse', help="Reverse geocode a file (input is coordinates, add full address)") 42 | 43 | for subparser in [subparser_forward, subparser_reverse]: 44 | subparser.add_argument("--api-key", required=True, type=api_key_type, help="Your OpenCage API key") 45 | subparser.add_argument( 46 | "--input", 47 | required=True, 48 | type=argparse.FileType( 49 | 'r', 50 | encoding='utf-8'), 51 | help="Input file name", 52 | metavar='FILENAME') 53 | subparser.add_argument( 54 | "--output", 55 | required=True, 56 | type=str, 57 | help="Output file name", 58 | metavar='FILENAME') 59 | 60 | add_optional_arguments(subparser) 61 | 62 | options = parser.parse_args(args) 63 | 64 | if os.path.exists(options.output) and not options.dry_run: 65 | if options.overwrite: 66 | os.remove(options.output) 67 | else: 68 | print( 69 | f"Error: The output file '{options.output}' already exists. You can add --overwrite to your command.", 70 | file=sys.stderr) 71 | sys.exit(1) 72 | 73 | if 0 in options.input_columns: 74 | print("Error: A column 0 in --input-columns does not exist. The lowest possible number is 1.", file=sys.stderr) 75 | sys.exit(1) 76 | 77 | return options 78 | 79 | 80 | def add_optional_arguments(parser): 81 | parser.add_argument( 82 | "--headers", 83 | action="store_true", 84 | help="If the first row should be treated as a header row") 85 | default_input_cols = '1,2' if re.match(r'.*reverse', parser.prog) else '1' 86 | parser.add_argument( 87 | "--input-columns", 88 | type=comma_separated_type(int), 89 | default=default_input_cols, 90 | help=f"Comma-separated list of integers (default '{default_input_cols}')", 91 | metavar='') 92 | default_add_cols = ( 93 | 'lat,lng,_type,_category,country_code,country,state,county,_normalized_city,' 94 | 'postcode,road,house_number,confidence,formatted' 95 | ) 96 | parser.add_argument( 97 | "--add-columns", 98 | type=comma_separated_type(str), 99 | default=default_add_cols, 100 | help=f"Comma-separated list of output columns (default '{default_add_cols}')", 101 | metavar='') 102 | parser.add_argument("--workers", type=ranged_type(int, 1, 20), default=1, 103 | help="Number of parallel geocoding requests (default 1)", metavar='') 104 | parser.add_argument("--timeout", type=ranged_type(int, 1, 60), default=10, 105 | help="Timeout in seconds (default 10)", metavar='') 106 | parser.add_argument("--retries", type=ranged_type(int, 1, 60), default=10, 107 | help="Number of retries (default 5)", metavar='') 108 | parser.add_argument("--api-domain", type=str, default="api.opencagedata.com", 109 | help="API domain (default api.opencagedata.com)", metavar='') 110 | parser.add_argument("--optional-api-params", type=comma_separated_dict_type, default="", 111 | help="Extra parameters for each request (e.g. language=fr,no_dedupe=1)", metavar='') 112 | parser.add_argument( 113 | "--limit", 114 | type=int, 115 | default=0, 116 | help="Stop after this number of lines in the input", 117 | metavar='') 118 | parser.add_argument( 119 | "--unordered", 120 | action="store_true", 121 | help="Allow the output lines to be in different order (can be faster)") 122 | parser.add_argument("--dry-run", action="store_true", help="Read the input file but no geocoding") 123 | parser.add_argument("--no-progress", action="store_true", help="Display no progress bar") 124 | parser.add_argument("--quiet", action="store_true", help="No progress bar and no messages") 125 | parser.add_argument("--overwrite", action="store_true", help="Delete the output file first if it exists") 126 | parser.add_argument("--verbose", action="store_true", help="Display debug information for each request") 127 | 128 | return parser 129 | 130 | 131 | def api_key_type(apikey): 132 | pattern = re.compile(r"^(oc_gc_)?[0-9a-f]{32}$") 133 | 134 | if not pattern.match(apikey): 135 | raise argparse.ArgumentTypeError("invalid API key") 136 | 137 | return apikey 138 | 139 | 140 | def ranged_type(value_type, min_value, max_value): 141 | def range_checker(arg: str): 142 | try: 143 | f = value_type(arg) 144 | except ValueError as exc: 145 | raise argparse.ArgumentTypeError(f'must be a valid {value_type}') from exc 146 | if f < min_value or f > max_value: 147 | raise argparse.ArgumentTypeError(f'must be within [{min_value}, {max_value}]') 148 | return f 149 | 150 | # Return function handle to checking function 151 | return range_checker 152 | 153 | 154 | def comma_separated_type(value_type): 155 | def comma_separated(arg: str): 156 | if not arg: 157 | return [] 158 | 159 | return [value_type(x) for x in arg.split(',')] 160 | 161 | return comma_separated 162 | 163 | 164 | def comma_separated_dict_type(arg): 165 | if not arg: 166 | return {} 167 | 168 | try: 169 | return dict([x.split('=') for x in arg.split(',')]) 170 | except ValueError as exc: 171 | raise argparse.ArgumentTypeError("must be a valid comma separated list of key=value pairs") from exc 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenCage Geocoding Module for Python 2 | 3 | A Python module to access the [OpenCage Geocoding API](https://opencagedata.com/). 4 | 5 | ## Build Status / Code Quality / etc 6 | 7 | [![PyPI version](https://badge.fury.io/py/opencage.svg)](https://badge.fury.io/py/opencage) 8 | [![Downloads](https://pepy.tech/badge/opencage/month)](https://pepy.tech/project/opencage) 9 | [![Versions](https://img.shields.io/pypi/pyversions/opencage)](https://pypi.org/project/opencage/) 10 | ![GitHub contributors](https://img.shields.io/github/contributors/opencagedata/python-opencage-geocoder) 11 | [![Build Status](https://github.com/OpenCageData/python-opencage-geocoder/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/OpenCageData/python-opencage-geocoder/actions/workflows/build.yml) 12 | ![Mastodon Follow](https://img.shields.io/mastodon/follow/109287663468501769?domain=https%3A%2F%2Fen.osm.town%2F&style=social) 13 | 14 | ## Tutorials 15 | 16 | You can find a [comprehensive tutorial for using this module on the OpenCage site](https://opencagedata.com/tutorials/geocode-in-python). 17 | 18 | There are two brief video tutorials on YouTube, one [covering forward geocoding](https://www.youtube.com/watch?v=9bXu8-LPr5c), one [covering reverse geocoding](https://www.youtube.com/watch?v=u-kkE4yA-z0). 19 | 20 | The module installs an `opencage` CLI tool for geocoding files. Check `opencage --help` or the [CLI tutorial](https://opencagedata.com/tutorials/geocode-commandline). 21 | 22 | 23 | ## Usage 24 | 25 | Supports Python 3.8 or newer. Starting opencage version 3.0 depends on asyncio package. 26 | 27 | Install the module: 28 | 29 | ```bash 30 | pip install opencage 31 | ``` 32 | 33 | Load the module: 34 | 35 | ```python 36 | from opencage.geocoder import OpenCageGeocode 37 | ``` 38 | 39 | Create an instance of the geocoder module, passing a valid OpenCage Data Geocoder API key 40 | as a parameter to the geocoder modules's constructor: 41 | 42 | ```python 43 | key = 'your-api-key-here' 44 | geocoder = OpenCageGeocode(key) 45 | ``` 46 | 47 | Pass a string containing the query or address to be geocoded to the modules' `geocode` method: 48 | 49 | ```python 50 | query = '82 Clerkenwell Road, London' 51 | results = geocoder.geocode(query) 52 | ``` 53 | 54 | You can add [additional parameters](https://opencagedata.com/api#forward-opt): 55 | 56 | ```python 57 | results = geocoder.geocode('London', no_annotations=1, language='es') 58 | ``` 59 | 60 | For example you can use the proximity parameter to provide the geocoder with a hint: 61 | 62 | ```python 63 | results = geocoder.geocode('London', proximity='42.828576, -81.406643') 64 | print(results[0]['formatted']) 65 | # u'London, ON N6A 3M8, Canada' 66 | ``` 67 | 68 | ### Reverse geocoding 69 | 70 | Turn a lat/long into an address with the `reverse_geocode` method: 71 | 72 | ```python 73 | result = geocoder.reverse_geocode(51.51024, -0.10303) 74 | ``` 75 | 76 | ### Sessions 77 | 78 | You can reuse your HTTP connection for multiple requests by 79 | using a `with` block. This can help performance when making 80 | a lot of requests: 81 | 82 | ```python 83 | queries = ['82 Clerkenwell Road, London', ...] 84 | with OpenCageGeocode(key) as geocoder: 85 | # Queries reuse the same HTTP connection 86 | results = [geocoder.geocode(query) for query in queries] 87 | ``` 88 | 89 | ### Asyncronous requests 90 | 91 | You can run requests in parallel with the `geocode_async` and `reverse_geocode_async` 92 | method which have the same parameters and response as their synronous counterparts. 93 | You will need at least Python 3.8 and the `asyncio` and `aiohttp` packages installed. 94 | 95 | ```python 96 | async with OpenCageGeocode(key) as geocoder: 97 | results = await geocoder.geocode_async(address) 98 | ``` 99 | 100 | For a more complete example and links to futher tutorials on asyncronous IO see 101 | `batch.py` in the `examples` directory. 102 | 103 | ### Non-SSL API use 104 | 105 | If you have trouble accesing the OpenCage API with https, e.g. issues with OpenSSL 106 | libraries in your enviroment, then you can set the 'http' protocol instead. Please 107 | understand that the connection to the OpenCage API will no longer be encrypted. 108 | 109 | ```python 110 | geocoder = OpenCageGeocode('your-api-key', 'http') 111 | ``` 112 | 113 | ### Exceptions 114 | 115 | If anything goes wrong, then an exception will be raised: 116 | 117 | - `InvalidInputError` for non-unicode query strings 118 | - `NotAuthorizedError` if API key is missing, invalid syntax or disabled 119 | - `ForbiddenError` API key is blocked or suspended 120 | - `RateLimitExceededError` if you go past your rate limit 121 | - `UnknownError` if there's some problem with the API (bad results, 500 status code, etc) 122 | 123 | ## Command-line batch geocoding 124 | 125 | Use `opencage forward` or `opencage reverse` 126 | 127 | ``` 128 | opencage forward --help 129 | 130 | options: 131 | -h, --help show this help message and exit 132 | --api-key API_KEY Your OpenCage API key 133 | --input FILENAME Input file name 134 | --output FILENAME Output file name 135 | --headers If the first row should be treated as a header row 136 | --input-columns Comma-separated list of integers (default '1') 137 | --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,json,status') 138 | --workers Number of parallel geocoding requests (default 1) 139 | --timeout Timeout in seconds (default 10) 140 | --retries Number of retries (default 5) 141 | --api-domain API domain (default api.opencagedata.com) 142 | --optional-api-params 143 | Extra parameters for each request (e.g. language=fr,no_dedupe=1) 144 | --unordered Allow the output lines to be in different order (can be faster) 145 | --limit Stop after this number of lines in the input 146 | --dry-run Read the input file but no geocoding 147 | --no-progress Display no progress bar 148 | --quiet No progress bar and no messages 149 | --overwrite Delete the output file first if it exists 150 | --verbose Display debug information for each request 151 | ``` 152 | 153 | 154 | 155 | 156 | ## Copyright & License 157 | 158 | This software is copyright OpenCage GmbH. 159 | Please see `LICENSE.txt` 160 | 161 | ### Who is OpenCage GmbH? 162 | 163 | 164 | 165 | We run a worldwide [geocoding API](https://opencagedata.com/api) and [geosearch](https://opencagedata.com/geosearch) service based on open data. 166 | Learn more [about us](https://opencagedata.com/about). 167 | 168 | 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/). 169 | -------------------------------------------------------------------------------- /test/fixtures/donostia.json: -------------------------------------------------------------------------------- 1 | { 2 | "documentation": "https://opencagedata.com/api", 3 | "licenses": [ 4 | { 5 | "name": "see attribution guide", 6 | "url": "https://opencagedata.com/credits" 7 | } 8 | ], 9 | "rate": { 10 | "limit": 2500, 11 | "remaining": 2498, 12 | "reset": 1615161600 13 | }, 14 | "results": [ 15 | { 16 | "bounds": { 17 | "northeast": { 18 | "lat": 43.3381594, 19 | "lng": -1.8878839 20 | }, 21 | "southwest": { 22 | "lat": 43.2178373, 23 | "lng": -2.0868082 24 | } 25 | }, 26 | "components": { 27 | "ISO_3166-1_alpha-2": "ES", 28 | "ISO_3166-1_alpha-3": "ESP", 29 | "_category": "place", 30 | "_type": "city", 31 | "city": "San Sebastián", 32 | "continent": "Europe", 33 | "country": "Spain", 34 | "country_code": "es", 35 | "municipality": "Donostialdea", 36 | "political_union": "European Union", 37 | "province": "Gipuzkoa", 38 | "state": "Autonomous Community of the Basque Country", 39 | "state_code": "PV" 40 | }, 41 | "confidence": 6, 42 | "formatted": "San Sebastián, Autonomous Community of the Basque Country, Spain", 43 | "geometry": { 44 | "lat": 43.3224219, 45 | "lng": -1.9838889 46 | } 47 | }, 48 | { 49 | "bounds": { 50 | "northeast": { 51 | "lat": 43.3235144, 52 | "lng": -1.987835 53 | }, 54 | "southwest": { 55 | "lat": 43.3227734, 56 | "lng": -1.9895938 57 | } 58 | }, 59 | "components": { 60 | "ISO_3166-1_alpha-2": "ES", 61 | "ISO_3166-1_alpha-3": "ESP", 62 | "_category": "natural/water", 63 | "_type": "water", 64 | "continent": "Europe", 65 | "country": "Spain", 66 | "country_code": "es", 67 | "political_union": "European Union", 68 | "suburb": "Parte Zaharra", 69 | "water": "Donostia" 70 | }, 71 | "confidence": 9, 72 | "formatted": "Donostia, Parte Zaharra, Spain", 73 | "geometry": { 74 | "lat": 43.3232109, 75 | "lng": -1.9884685 76 | } 77 | }, 78 | { 79 | "bounds": { 80 | "northeast": { 81 | "lat": 43.3449618, 82 | "lng": -1.8013445 83 | }, 84 | "southwest": { 85 | "lat": 43.3422299, 86 | "lng": -1.8022745 87 | } 88 | }, 89 | "components": { 90 | "ISO_3166-1_alpha-2": "ES", 91 | "ISO_3166-1_alpha-3": "ESP", 92 | "_category": "road", 93 | "_type": "road", 94 | "continent": "Europe", 95 | "country": "Spain", 96 | "country_code": "es", 97 | "county": "Bidasoa Beherea / Bajo Bidasoa", 98 | "hamlet": "Pinar", 99 | "political_union": "European Union", 100 | "postcode": "20301", 101 | "province": "Gipuzkoa", 102 | "road": "Donostia", 103 | "road_type": "residential", 104 | "state": "Autonomous Community of the Basque Country", 105 | "state_code": "PV", 106 | "town": "Irun" 107 | }, 108 | "confidence": 9, 109 | "formatted": "Donostia, 20301 Irun, Spain", 110 | "geometry": { 111 | "lat": 43.3432825, 112 | "lng": -1.8019222 113 | } 114 | }, 115 | { 116 | "bounds": { 117 | "northeast": { 118 | "lat": 42.6739972, 119 | "lng": 3.0325751 120 | }, 121 | "southwest": { 122 | "lat": 42.6730864, 123 | "lng": 3.0318899 124 | } 125 | }, 126 | "components": { 127 | "ISO_3166-1_alpha-2": "FR", 128 | "ISO_3166-1_alpha-3": "FRA", 129 | "_category": "road", 130 | "_type": "road", 131 | "continent": "Europe", 132 | "country": "France", 133 | "country_code": "fr", 134 | "county": "Pyrénées-Orientales", 135 | "municipality": "Perpignan", 136 | "political_union": "European Union", 137 | "postcode": "66140", 138 | "quarter": "L'Aviation", 139 | "road": "Donostia", 140 | "state": "Occitania", 141 | "state_code": "OCC", 142 | "town": "Canet-en-Roussillon" 143 | }, 144 | "confidence": 9, 145 | "formatted": "Donostia, 66140 Canet-en-Roussillon, France", 146 | "geometry": { 147 | "lat": 42.6734947, 148 | "lng": 3.0322839 149 | } 150 | }, 151 | { 152 | "bounds": { 153 | "northeast": { 154 | "lat": -31.312091, 155 | "lng": -64.4982232 156 | }, 157 | "southwest": { 158 | "lat": -31.3143529, 159 | "lng": -64.5032543 160 | } 161 | }, 162 | "components": { 163 | "ISO_3166-1_alpha-2": "AR", 164 | "ISO_3166-1_alpha-3": "ARG", 165 | "_category": "road", 166 | "_type": "road", 167 | "continent": "South America", 168 | "country": "Argentina", 169 | "country_code": "ar", 170 | "county": "Pedanía Rosario", 171 | "road": "Donostia", 172 | "road_type": "residential", 173 | "state": "Córdoba", 174 | "state_code": "X", 175 | "state_district": "Departamento Punilla", 176 | "village": "Bialet Massé" 177 | }, 178 | "confidence": 9, 179 | "formatted": "Donostia, Departamento Punilla, Bialet Massé, Argentina", 180 | "geometry": { 181 | "lat": -31.3129419, 182 | "lng": -64.5002494 183 | } 184 | }, 185 | { 186 | "bounds": { 187 | "northeast": { 188 | "lat": 43.3191704, 189 | "lng": -2.0063319 190 | }, 191 | "southwest": { 192 | "lat": 43.3190704, 193 | "lng": -2.0064319 194 | } 195 | }, 196 | "components": { 197 | "ISO_3166-1_alpha-2": "ES", 198 | "ISO_3166-1_alpha-3": "ESP", 199 | "_category": "transportation", 200 | "_type": "railway", 201 | "city": "San Sebastián", 202 | "continent": "Europe", 203 | "country": "Spain", 204 | "country_code": "es", 205 | "municipality": "Donostialdea", 206 | "political_union": "European Union", 207 | "postcode": "20008", 208 | "province": "Gipuzkoa", 209 | "railway": "Donostia", 210 | "road": "Funikularraren Plaza", 211 | "state": "Autonomous Community of the Basque Country", 212 | "state_code": "PV", 213 | "suburb": "Antiguo" 214 | }, 215 | "confidence": 9, 216 | "formatted": "Donostia, Funikularraren Plaza, 20008 San Sebastián, Spain", 217 | "geometry": { 218 | "lat": 43.3191204, 219 | "lng": -2.0063819 220 | } 221 | }, 222 | { 223 | "bounds": { 224 | "northeast": { 225 | "lat": 51.5147517, 226 | "lng": -0.160937 227 | }, 228 | "southwest": { 229 | "lat": 51.5146517, 230 | "lng": -0.161037 231 | } 232 | }, 233 | "components": { 234 | "ISO_3166-1_alpha-2": "GB", 235 | "ISO_3166-1_alpha-3": "GBR", 236 | "_category": "commerce", 237 | "_type": "restaurant", 238 | "city": "London", 239 | "continent": "Europe", 240 | "country": "United Kingdom", 241 | "country_code": "gb", 242 | "county": "Westminster", 243 | "county_code": "WSM", 244 | "house_number": "10", 245 | "postcode": "W1H 7JN", 246 | "restaurant": "Donostia", 247 | "road": "Seymour Place", 248 | "state": "England", 249 | "state_code": "ENG", 250 | "state_district": "Greater London", 251 | "suburb": "Marylebone" 252 | }, 253 | "confidence": 9, 254 | "formatted": "Donostia, 10 Seymour Place, London W1H 7JN, United Kingdom", 255 | "geometry": { 256 | "lat": 51.5147017, 257 | "lng": -0.160987 258 | } 259 | }, 260 | { 261 | "bounds": { 262 | "northeast": { 263 | "lat": 44.83445, 264 | "lng": -0.5666749 265 | }, 266 | "southwest": { 267 | "lat": 44.83435, 268 | "lng": -0.5667749 269 | } 270 | }, 271 | "components": { 272 | "ISO_3166-1_alpha-2": "FR", 273 | "ISO_3166-1_alpha-3": "FRA", 274 | "_category": "commerce", 275 | "_type": "restaurant", 276 | "city": "Bordeaux", 277 | "continent": "Europe", 278 | "country": "France", 279 | "country_code": "fr", 280 | "county": "Gironde", 281 | "house_number": "21", 282 | "municipality": "Bordeaux", 283 | "political_union": "European Union", 284 | "postcode": "33800", 285 | "restaurant": "Donostia", 286 | "road": "Place Meynard", 287 | "state": "New Aquitaine", 288 | "state_code": "NAQ", 289 | "suburb": "Saint-Michel" 290 | }, 291 | "confidence": 9, 292 | "formatted": "Donostia, 21 Place Meynard, 33800 Bordeaux, France", 293 | "geometry": { 294 | "lat": 44.8344, 295 | "lng": -0.5667249 296 | } 297 | } 298 | ], 299 | "status": { 300 | "code": 200, 301 | "message": "OK" 302 | }, 303 | "stay_informed": { 304 | "blog": "https://blog.opencagedata.com", 305 | "twitter": "https://twitter.com/OpenCage" 306 | }, 307 | "thanks": "For using an OpenCage API", 308 | "timestamp": { 309 | "created_http": "Sun, 07 Mar 2021 01:12:01 GMT", 310 | "created_unix": 1615079521 311 | }, 312 | "total_results": 8 313 | } -------------------------------------------------------------------------------- /test/fixtures/muenster.json: -------------------------------------------------------------------------------- 1 | { 2 | "documentation": "https://opencagedata.com/api", 3 | "licenses": [ 4 | { 5 | "name": "see attribution guide", 6 | "url": "https://opencagedata.com/credits" 7 | } 8 | ], 9 | "rate": { 10 | "limit": 2500, 11 | "remaining": 2497, 12 | "reset": 1615161600 13 | }, 14 | "results": [ 15 | { 16 | "bounds": { 17 | "northeast": { 18 | "lat": 52.0600251, 19 | "lng": 7.7743634 20 | }, 21 | "southwest": { 22 | "lat": 51.8401448, 23 | "lng": 7.4737853 24 | } 25 | }, 26 | "components": { 27 | "ISO_3166-1_alpha-2": "DE", 28 | "ISO_3166-1_alpha-3": "DEU", 29 | "_category": "place", 30 | "_type": "city", 31 | "city": "Münster", 32 | "continent": "Europe", 33 | "country": "Germany", 34 | "country_code": "de", 35 | "political_union": "European Union", 36 | "state": "North Rhine-Westphalia", 37 | "state_code": "NW" 38 | }, 39 | "confidence": 4, 40 | "formatted": "Münster, North Rhine-Westphalia, Germany", 41 | "geometry": { 42 | "lat": 51.9625101, 43 | "lng": 7.6251879 44 | } 45 | }, 46 | { 47 | "bounds": { 48 | "northeast": { 49 | "lat": 53.1689062, 50 | "lng": -6.949942 51 | }, 52 | "southwest": { 53 | "lat": 51.388867, 54 | "lng": -10.6626169 55 | } 56 | }, 57 | "components": { 58 | "ISO_3166-1_alpha-2": "IE", 59 | "ISO_3166-1_alpha-3": "IRL", 60 | "_category": "place", 61 | "_type": "state", 62 | "continent": "Europe", 63 | "country": "Ireland", 64 | "country_code": "ie", 65 | "political_union": "European Union", 66 | "state": "Munster", 67 | "state_code": "M" 68 | }, 69 | "confidence": 1, 70 | "formatted": "Munster, Ireland", 71 | "geometry": { 72 | "lat": 52.3076216, 73 | "lng": -8.5708973 74 | } 75 | }, 76 | { 77 | "bounds": { 78 | "northeast": { 79 | "lat": 48.9209128, 80 | "lng": 6.9284105 81 | }, 82 | "southwest": { 83 | "lat": 48.8961288, 84 | "lng": 6.8640003 85 | } 86 | }, 87 | "components": { 88 | "ISO_3166-1_alpha-2": "FR", 89 | "ISO_3166-1_alpha-3": "FRA", 90 | "_category": "place", 91 | "_type": "village", 92 | "continent": "Europe", 93 | "country": "France", 94 | "country_code": "fr", 95 | "county": "Moselle", 96 | "municipality": "Sarrebourg-Château-Salins", 97 | "political_union": "European Union", 98 | "postcode": "57670", 99 | "state": "Grand Est", 100 | "state_code": "GES", 101 | "village": "Munster" 102 | }, 103 | "confidence": 7, 104 | "formatted": "57670 Munster, France", 105 | "geometry": { 106 | "lat": 48.9154719, 107 | "lng": 6.9037077 108 | } 109 | }, 110 | { 111 | "bounds": { 112 | "northeast": { 113 | "lat": 49.4626882, 114 | "lng": 10.0571749 115 | }, 116 | "southwest": { 117 | "lat": 49.4226882, 118 | "lng": 10.0171749 119 | } 120 | }, 121 | "components": { 122 | "ISO_3166-1_alpha-2": "DE", 123 | "ISO_3166-1_alpha-3": "DEU", 124 | "_category": "place", 125 | "_type": "village", 126 | "continent": "Europe", 127 | "country": "Germany", 128 | "country_code": "de", 129 | "county": "Main-Tauber-Kreis", 130 | "political_union": "European Union", 131 | "postcode": "97993", 132 | "state": "Baden-Württemberg", 133 | "state_code": "BW", 134 | "town": "Creglingen", 135 | "village": "Münster" 136 | }, 137 | "confidence": 7, 138 | "formatted": "97993 Creglingen, Germany", 139 | "geometry": { 140 | "lat": 49.4426882, 141 | "lng": 10.0371749 142 | } 143 | }, 144 | { 145 | "bounds": { 146 | "northeast": { 147 | "lat": 48.0646256, 148 | "lng": 7.1665754 149 | }, 150 | "southwest": { 151 | "lat": 48.0286031, 152 | "lng": 7.0964409 153 | } 154 | }, 155 | "components": { 156 | "ISO_3166-1_alpha-2": "FR", 157 | "ISO_3166-1_alpha-3": "FRA", 158 | "_category": "place", 159 | "_type": "city", 160 | "continent": "Europe", 161 | "country": "France", 162 | "country_code": "fr", 163 | "county": "Haut-Rhin", 164 | "municipality": "Colmar-Ribeauvillé", 165 | "political_union": "European Union", 166 | "postcode": "68140", 167 | "state": "Grand Est", 168 | "state_code": "GES", 169 | "town": "Munster" 170 | }, 171 | "confidence": 7, 172 | "formatted": "68140 Munster, France", 173 | "geometry": { 174 | "lat": 48.0408618, 175 | "lng": 7.1371568 176 | } 177 | }, 178 | { 179 | "bounds": { 180 | "northeast": { 181 | "lat": 48.6515558, 182 | "lng": 10.9314006 183 | }, 184 | "southwest": { 185 | "lat": 48.5841708, 186 | "lng": 10.8788966 187 | } 188 | }, 189 | "components": { 190 | "ISO_3166-1_alpha-2": "DE", 191 | "ISO_3166-1_alpha-3": "DEU", 192 | "_category": "place", 193 | "_type": "village", 194 | "continent": "Europe", 195 | "country": "Germany", 196 | "country_code": "de", 197 | "county": "Landkreis Donau-Ries", 198 | "municipality": "Rain (Schwaben)", 199 | "political_union": "European Union", 200 | "postcode": "86692", 201 | "state": "Bavaria", 202 | "state_code": "BY", 203 | "village": "Münster" 204 | }, 205 | "confidence": 7, 206 | "formatted": "86692 Münster, Germany", 207 | "geometry": { 208 | "lat": 48.6242219, 209 | "lng": 10.9008883 210 | } 211 | }, 212 | { 213 | "bounds": { 214 | "northeast": { 215 | "lat": 50.4101197, 216 | "lng": 8.6366094 217 | }, 218 | "southwest": { 219 | "lat": 50.3701197, 220 | "lng": 8.5966094 221 | } 222 | }, 223 | "components": { 224 | "ISO_3166-1_alpha-2": "DE", 225 | "ISO_3166-1_alpha-3": "DEU", 226 | "_category": "place", 227 | "_type": "village", 228 | "continent": "Europe", 229 | "country": "Germany", 230 | "country_code": "de", 231 | "county": "Wetteraukreis", 232 | "political_union": "European Union", 233 | "state": "Hesse", 234 | "state_code": "HE", 235 | "town": "Butzbach", 236 | "village": "Münster" 237 | }, 238 | "confidence": 7, 239 | "formatted": "Butzbach, Hesse, Germany", 240 | "geometry": { 241 | "lat": 50.3901197, 242 | "lng": 8.6166094 243 | } 244 | }, 245 | { 246 | "bounds": { 247 | "northeast": { 248 | "lat": 47.4709858, 249 | "lng": 11.8640661 250 | }, 251 | "southwest": { 252 | "lat": 47.4020668, 253 | "lng": 11.7729344 254 | } 255 | }, 256 | "components": { 257 | "ISO_3166-1_alpha-2": "AT", 258 | "ISO_3166-1_alpha-3": "AUT", 259 | "_category": "place", 260 | "_type": "city", 261 | "city": "Gemeinde Münster", 262 | "continent": "Europe", 263 | "country": "Austria", 264 | "country_code": "at", 265 | "political_union": "European Union", 266 | "postcode": "6232", 267 | "region": "Bezirk Kufstein", 268 | "state": "Tyrol" 269 | }, 270 | "confidence": 7, 271 | "formatted": "Gemeinde Münster, Tyrol, Austria", 272 | "geometry": { 273 | "lat": 47.4366555, 274 | "lng": 11.8134682 275 | } 276 | }, 277 | { 278 | "bounds": { 279 | "northeast": { 280 | "lat": 46.9474256, 281 | "lng": 7.452171 282 | }, 283 | "southwest": { 284 | "lat": 46.9470562, 285 | "lng": 7.4510185 286 | } 287 | }, 288 | "components": { 289 | "ISO_3166-1_alpha-2": "CH", 290 | "ISO_3166-1_alpha-3": "CHE", 291 | "_category": "place_of_worship", 292 | "_type": "place_of_worship", 293 | "city": "Bern", 294 | "city_district": "Stadtteil I", 295 | "continent": "Europe", 296 | "country": "Switzerland", 297 | "country_code": "ch", 298 | "county": "Bern-Mittelland administrative district", 299 | "place_of_worship": "Münster", 300 | "postcode": "3011", 301 | "quarter": "Old City", 302 | "road": "Münsterplatz", 303 | "state": "Bern", 304 | "state_code": "BE", 305 | "state_district": "Bernese Mittelland administrative region" 306 | }, 307 | "confidence": 9, 308 | "formatted": "Münster, Münsterplatz, 3011 Bern, Switzerland", 309 | "geometry": { 310 | "lat": 46.9472379, 311 | "lng": 7.4515787 312 | } 313 | }, 314 | { 315 | "bounds": { 316 | "northeast": { 317 | "lat": 52.1952705, 318 | "lng": -104.9807394 319 | }, 320 | "southwest": { 321 | "lat": 52.1874551, 322 | "lng": -105.0067561 323 | } 324 | }, 325 | "components": { 326 | "ISO_3166-1_alpha-2": "CA", 327 | "ISO_3166-1_alpha-3": "CAN", 328 | "_category": "place", 329 | "_type": "county", 330 | "continent": "North America", 331 | "country": "Canada", 332 | "country_code": "ca", 333 | "county": "Muenster", 334 | "state": "Saskatchewan", 335 | "state_code": "SK" 336 | }, 337 | "confidence": 8, 338 | "formatted": "SK, Canada", 339 | "geometry": { 340 | "lat": 52.1925528, 341 | "lng": -104.9937505 342 | } 343 | } 344 | ], 345 | "status": { 346 | "code": 200, 347 | "message": "OK" 348 | }, 349 | "stay_informed": { 350 | "blog": "https://blog.opencagedata.com", 351 | "twitter": "https://twitter.com/OpenCage" 352 | }, 353 | "thanks": "For using an OpenCage API", 354 | "timestamp": { 355 | "created_http": "Sun, 07 Mar 2021 01:13:17 GMT", 356 | "created_unix": 1615079597 357 | }, 358 | "total_results": 10 359 | } -------------------------------------------------------------------------------- /test/fixtures/uk_postcode.json: -------------------------------------------------------------------------------- 1 | { 2 | "total_results": 10, 3 | "licenses": [{ 4 | "name": "CC-BY-SA", 5 | "url": "http://creativecommons.org/licenses/by-sa/3.0/" 6 | }, { 7 | "name": "ODbL", 8 | "url": "http://opendatacommons.org/licenses/odbl/summary/" 9 | }], 10 | "status": { 11 | "message": "OK", 12 | "code": 200 13 | }, 14 | "thanks": "For using an OpenCage Data API", 15 | "rate": { 16 | "limit": "2500", 17 | "remaining": 2487, 18 | "reset": 1402185600 19 | }, 20 | "results": [{ 21 | "annotations": {}, 22 | "components": { 23 | "country_name": "United Kingdom", 24 | "region": "Islington", 25 | "locality": "Clerkenwell" 26 | }, 27 | "formatted": "Clerkenwell, Islington, United Kingdom", 28 | "geometry": { 29 | "lat": "51.5221558691", 30 | "lng": "-0.100838524406" 31 | }, 32 | "bounds": null 33 | }, { 34 | "formatted": "82, Lokku Ltd, Clerkenwell Road, Clerkenwell, London Borough of Islington, London, EC1M 5RF, Greater London, England, United Kingdom, gb", 35 | "components": { 36 | "county": "London", 37 | "state_district": "Greater London", 38 | "road": "Clerkenwell Road", 39 | "country_code": "gb", 40 | "house_number": "82", 41 | "country": "United Kingdom", 42 | "city": "London Borough of Islington", 43 | "suburb": "Clerkenwell", 44 | "state": "England", 45 | "house": "Lokku Ltd", 46 | "postcode": "EC1M 5RF" 47 | }, 48 | "annotations": {}, 49 | "bounds": { 50 | "northeast": { 51 | "lng": "-0.1023889", 52 | "lat": "51.5226795" 53 | }, 54 | "southwest": { 55 | "lat": "51.5225795", 56 | "lng": "-0.1024889" 57 | } 58 | }, 59 | "geometry": { 60 | "lat": "51.5226295", 61 | "lng": "-0.1024389" 62 | } 63 | }, { 64 | "components": { 65 | "county": "London", 66 | "state_district": "Greater London", 67 | "road": "Clerkenwell Road", 68 | "country_code": "gb", 69 | "country": "United Kingdom", 70 | "city": "London Borough of Islington", 71 | "suburb": "Clerkenwell", 72 | "state": "England", 73 | "postcode": "EC1M 6DS" 74 | }, 75 | "annotations": {}, 76 | "formatted": "Clerkenwell Road, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb", 77 | "geometry": { 78 | "lat": "51.5225346", 79 | "lng": "-0.1027003" 80 | }, 81 | "bounds": { 82 | "northeast": { 83 | "lat": "51.5225759", 84 | "lng": "-0.1020597" 85 | }, 86 | "southwest": { 87 | "lat": "51.5225211", 88 | "lng": "-0.103223" 89 | } 90 | } 91 | }, { 92 | "formatted": "Clerkenwell Road, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb, Craft Central", 93 | "annotations": {}, 94 | "components": { 95 | "postcode": "EC1M 6DS", 96 | "arts_centre": "Craft Central", 97 | "state": "England", 98 | "suburb": "Clerkenwell", 99 | "country": "United Kingdom", 100 | "city": "London Borough of Islington", 101 | "country_code": "gb", 102 | "road": "Clerkenwell Road", 103 | "state_district": "Greater London", 104 | "county": "London" 105 | }, 106 | "bounds": { 107 | "northeast": { 108 | "lat": "51.52246", 109 | "lng": "-0.1027652" 110 | }, 111 | "southwest": { 112 | "lng": "-0.1028652", 113 | "lat": "51.52236" 114 | } 115 | }, 116 | "geometry": { 117 | "lng": "-0.1028152", 118 | "lat": "51.52241" 119 | } 120 | }, { 121 | "components": { 122 | "county": "London", 123 | "state_district": "Greater London", 124 | "restaurant": "Noodle Express", 125 | "road": "Albemarle Way", 126 | "country_code": "gb", 127 | "country": "United Kingdom", 128 | "city": "London Borough of Islington", 129 | "suburb": "Clerkenwell", 130 | "state": "England", 131 | "postcode": "EC1M 6DS" 132 | }, 133 | "annotations": {}, 134 | "formatted": "Noodle Express, Albemarle Way, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb", 135 | "geometry": { 136 | "lng": "-0.10255386845056", 137 | "lat": "51.5228195" 138 | }, 139 | "bounds": { 140 | "southwest": { 141 | "lng": "-0.102621", 142 | "lat": "51.5227781" 143 | }, 144 | "northeast": { 145 | "lat": "51.5228603", 146 | "lng": "-0.1024869" 147 | } 148 | } 149 | }, { 150 | "geometry": { 151 | "lat": "51.5229424", 152 | "lng": "-0.102380530769224" 153 | }, 154 | "bounds": { 155 | "northeast": { 156 | "lat": "51.5229759", 157 | "lng": "-0.1023064" 158 | }, 159 | "southwest": { 160 | "lng": "-0.1024639", 161 | "lat": "51.5229046" 162 | } 163 | }, 164 | "annotations": {}, 165 | "components": { 166 | "county": "London", 167 | "state_district": "Greater London", 168 | "road": "Albemarle Way", 169 | "country_code": "gb", 170 | "cafe": "PAR", 171 | "country": "United Kingdom", 172 | "city": "London Borough of Islington", 173 | "suburb": "Clerkenwell", 174 | "state": "England", 175 | "postcode": "EC1M 6DS" 176 | }, 177 | "formatted": "PAR, Albemarle Way, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb" 178 | }, { 179 | "formatted": "Workshop Coffee Co., 27, Clerkenwell Road, Clerkenwell, London Borough of Islington, London, EC1M 5RN, Greater London, England, United Kingdom, gb", 180 | "components": { 181 | "county": "London", 182 | "state_district": "Greater London", 183 | "road": "Clerkenwell Road", 184 | "country_code": "gb", 185 | "house_number": "27", 186 | "cafe": "Workshop Coffee Co.", 187 | "country": "United Kingdom", 188 | "city": "London Borough of Islington", 189 | "suburb": "Clerkenwell", 190 | "state": "England", 191 | "postcode": "EC1M 5RN" 192 | }, 193 | "annotations": {}, 194 | "bounds": { 195 | "southwest": { 196 | "lng": "-0.1024422", 197 | "lat": "51.5222246" 198 | }, 199 | "northeast": { 200 | "lng": "-0.1022307", 201 | "lat": "51.5224408" 202 | } 203 | }, 204 | "geometry": { 205 | "lat": "51.52234585", 206 | "lng": "-0.102338899572156" 207 | } 208 | }, { 209 | "components": { 210 | "county": "London", 211 | "state_district": "Greater London", 212 | "road": "St. John Street", 213 | "country_code": "gb", 214 | "country": "United Kingdom", 215 | "city": "London Borough of Islington", 216 | "suburb": "Clerkenwell", 217 | "hairdresser": "Franco & Co", 218 | "state": "England", 219 | "postcode": "EC1M 6DS" 220 | }, 221 | "annotations": {}, 222 | "formatted": "St. John Street, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb, Franco & Co", 223 | "geometry": { 224 | "lng": "-0.1024118", 225 | "lat": "51.5231165" 226 | }, 227 | "bounds": { 228 | "southwest": { 229 | "lng": "-0.1024618", 230 | "lat": "51.5230665" 231 | }, 232 | "northeast": { 233 | "lng": "-0.1023618", 234 | "lat": "51.5231665" 235 | } 236 | } 237 | }, { 238 | "bounds": { 239 | "northeast": { 240 | "lng": "-0.1023218", 241 | "lat": "51.5231688" 242 | }, 243 | "southwest": { 244 | "lat": "51.5229634", 245 | "lng": "-0.1024934" 246 | } 247 | }, 248 | "geometry": { 249 | "lng": "-0.102399365567707", 250 | "lat": "51.5230257" 251 | }, 252 | "formatted": "St. John Street, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb, MacCarthy", 253 | "annotations": {}, 254 | "components": { 255 | "county": "London", 256 | "state_district": "Greater London", 257 | "road": "St. John Street", 258 | "country_code": "gb", 259 | "country": "United Kingdom", 260 | "city": "London Borough of Islington", 261 | "suburb": "Clerkenwell", 262 | "hairdresser": "MacCarthy", 263 | "state": "England", 264 | "postcode": "EC1M 6DS" 265 | } 266 | }, { 267 | "geometry": { 268 | "lng": "-0.102730855172415", 269 | "lat": "51.52267345" 270 | }, 271 | "bounds": { 272 | "northeast": { 273 | "lng": "-0.1025498", 274 | "lat": "51.5227315" 275 | }, 276 | "southwest": { 277 | "lat": "51.5226068", 278 | "lng": "-0.1028931" 279 | } 280 | }, 281 | "annotations": {}, 282 | "components": { 283 | "county": "London", 284 | "state_district": "Greater London", 285 | "road": "Albemarle Way", 286 | "country_code": "gb", 287 | "house_number": "84", 288 | "country": "United Kingdom", 289 | "city": "London Borough of Islington", 290 | "suburb": "Clerkenwell", 291 | "state": "England", 292 | "house": "The Printworks", 293 | "postcode": "EC1M 6DS" 294 | }, 295 | "formatted": "84, The Printworks, Albemarle Way, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb" 296 | }], 297 | "timestamp": { 298 | "created_unix": 1402133768, 299 | "created_http": "Sat, 07 Jun 2014 09:36:08 GMT" 300 | } 301 | } -------------------------------------------------------------------------------- /opencage/batch.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import ssl 3 | import asyncio 4 | import traceback 5 | import threading 6 | import random 7 | import json 8 | 9 | from contextlib import suppress 10 | from urllib.parse import urlencode 11 | from tqdm import tqdm 12 | import certifi 13 | import backoff 14 | from opencage.geocoder import ( 15 | OpenCageGeocode, 16 | OpenCageGeocodeError, 17 | _query_for_reverse_geocoding, 18 | floatify_latlng 19 | ) 20 | 21 | 22 | class OpenCageBatchGeocoder(): 23 | 24 | """ Called from command_line.py 25 | init() receives the parsed command line parameters 26 | geocode() receive an input and output CSV reader/writer and loops over the data 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 | asyncio.run(self.geocode(*args, **kwargs)) 37 | 38 | async def geocode(self, csv_input, csv_output): 39 | if not self.options.dry_run: 40 | test = await self.test_request() 41 | if test['error']: 42 | self.log(test['error']) 43 | return 44 | if test['free'] is True and self.options.workers > 1: 45 | sys.stderr.write("Free trial account detected. Resetting number of workers to 1.\n") 46 | self.options.workers = 1 47 | 48 | if self.options.headers: 49 | header_columns = next(csv_input, None) 50 | if header_columns is None: 51 | return 52 | 53 | queue = asyncio.Queue(maxsize=self.options.limit) 54 | 55 | read_warnings = await self.read_input(csv_input, queue) 56 | 57 | if self.options.dry_run: 58 | if not read_warnings: 59 | print('All good.') 60 | return 61 | 62 | if self.options.headers: 63 | csv_output.writerow(header_columns + self.options.add_columns) 64 | 65 | progress_bar = not (self.options.no_progress or self.options.quiet) and \ 66 | tqdm(total=queue.qsize(), position=0, desc="Addresses geocoded", dynamic_ncols=True) 67 | 68 | tasks = [] 69 | for _ in range(self.options.workers): 70 | task = asyncio.create_task(self.worker(csv_output, queue, progress_bar)) 71 | tasks.append(task) 72 | 73 | # This starts the workers and waits until all are finished 74 | await queue.join() 75 | 76 | # All tasks done 77 | for task in tasks: 78 | task.cancel() 79 | 80 | if progress_bar: 81 | progress_bar.close() 82 | 83 | async def test_request(self): 84 | try: 85 | async with OpenCageGeocode( 86 | self.options.api_key, 87 | domain=self.options.api_domain, 88 | sslcontext=self.sslcontext, 89 | user_agent_comment=self.user_agent_comment 90 | ) as geocoder: 91 | result = await geocoder.geocode_async('Kendall Sq, Cambridge, MA', raw_response=True) 92 | 93 | free = False 94 | with suppress(KeyError): 95 | free = result['rate']['limit'] == 2500 96 | 97 | return {'error': None, 'free': free} 98 | except Exception as exc: 99 | return {'error': exc} 100 | 101 | async def read_input(self, csv_input, queue): 102 | any_warnings = False 103 | for index, row in enumerate(csv_input): 104 | line_number = index + 1 105 | 106 | if len(row) == 0: 107 | self.log(f"Line {line_number} - Empty line") 108 | any_warnings = True 109 | row = [''] 110 | 111 | item = await self.read_one_line(row, line_number) 112 | if item['warnings'] is True: 113 | any_warnings = True 114 | await queue.put(item) 115 | 116 | if queue.full(): 117 | break 118 | 119 | return any_warnings 120 | 121 | async def read_one_line(self, row, row_id): 122 | warnings = False 123 | 124 | if self.options.input_columns: 125 | input_columns = self.options.input_columns 126 | elif self.options.command == 'reverse': 127 | input_columns = [1, 2] 128 | else: 129 | input_columns = None 130 | 131 | if input_columns: 132 | address = [] 133 | try: 134 | for column in input_columns: 135 | # input_columns option uses 1-based indexing 136 | address.append(row[column - 1]) 137 | except IndexError: 138 | self.log(f"Line {row_id} - Missing input column {column} in {row}") 139 | warnings = True 140 | else: 141 | address = row 142 | 143 | if self.options.command == 'reverse': 144 | 145 | if len(address) != 2: 146 | self.log( 147 | f"Line {row_id} - Expected two comma-separated values for reverse geocoding, got {address}") 148 | else: 149 | # _query_for_reverse_geocoding attempts to convert into numbers. We rather have it fail 150 | # now than during the actual geocoding 151 | try: 152 | _query_for_reverse_geocoding(address[0], address[1]) 153 | except BaseException: 154 | self.log( 155 | f"Line {row_id} - Does not look like latitude and longitude: '{address[0]}' and '{address[1]}'") 156 | warnings = True 157 | address = [] 158 | 159 | return {'row_id': row_id, 'address': ','.join(address), 'original_columns': row, 'warnings': warnings} 160 | 161 | async def worker(self, csv_output, queue, progress): 162 | while True: 163 | item = await queue.get() 164 | 165 | try: 166 | await self.geocode_one_address(csv_output, item['row_id'], item['address'], item['original_columns']) 167 | 168 | if progress: 169 | progress.update(1) 170 | except Exception as exc: 171 | traceback.print_exception(exc, file=sys.stderr) 172 | finally: 173 | queue.task_done() 174 | 175 | async def geocode_one_address(self, csv_output, row_id, address, original_columns): 176 | def on_backoff(details): 177 | if not self.options.quiet: 178 | sys.stderr.write("Backing off {wait:0.1f} seconds afters {tries} tries " 179 | "calling function {target} with args {args} and kwargs " 180 | "{kwargs}\n".format(**details)) 181 | 182 | @backoff.on_exception(backoff.expo, 183 | asyncio.TimeoutError, 184 | max_time=self.options.timeout, 185 | max_tries=self.options.retries, 186 | on_backoff=on_backoff) 187 | async def _geocode_one_address(): 188 | async with OpenCageGeocode( 189 | self.options.api_key, 190 | domain=self.options.api_domain, 191 | sslcontext=self.sslcontext, 192 | user_agent_comment=self.user_agent_comment 193 | ) as geocoder: 194 | geocoding_results = None 195 | response = None 196 | params = {'no_annotations': 1, 'raw_response': True, **self.options.optional_api_params} 197 | 198 | try: 199 | if self.options.command == 'reverse': 200 | if ',' in address: 201 | lon, lat = address.split(',') 202 | response = await geocoder.reverse_geocode_async(lon, lat, **params) 203 | geocoding_results = floatify_latlng(response['results']) 204 | else: 205 | response = await geocoder.geocode_async(address, **params) 206 | geocoding_results = floatify_latlng(response['results']) 207 | except OpenCageGeocodeError as exc: 208 | self.log(str(exc)) 209 | except Exception as exc: 210 | traceback.print_exception(exc, file=sys.stderr) 211 | 212 | try: 213 | if geocoding_results is not None and len(geocoding_results): 214 | geocoding_result = geocoding_results[0] 215 | else: 216 | geocoding_result = None 217 | 218 | if self.options.verbose: 219 | self.log({ 220 | 'row_id': row_id, 221 | 'thread_id': threading.get_native_id(), 222 | 'request': geocoder.url + '?' + urlencode(geocoder._parse_request(address, params)), 223 | 'response': response 224 | }) 225 | 226 | await self.write_one_geocoding_result( 227 | csv_output, 228 | row_id, 229 | geocoding_result, 230 | response, 231 | original_columns 232 | ) 233 | except Exception as exc: 234 | traceback.print_exception(exc, file=sys.stderr) 235 | 236 | await _geocode_one_address() 237 | 238 | async def write_one_geocoding_result( 239 | self, 240 | csv_output, 241 | row_id, 242 | geocoding_result, 243 | raw_response, 244 | original_columns): 245 | row = original_columns 246 | 247 | for column in self.options.add_columns: 248 | if column == 'status': 249 | row.append(self.deep_get_result_value(raw_response, ['status', 'message'])) 250 | elif geocoding_result is None: 251 | row.append('') 252 | elif column in geocoding_result: 253 | row.append(self.deep_get_result_value(geocoding_result, [column], '')) 254 | elif column in geocoding_result['components']: 255 | row.append(self.deep_get_result_value(geocoding_result, ['components', column], '')) 256 | elif column in geocoding_result['geometry']: 257 | row.append(self.deep_get_result_value(geocoding_result, ['geometry', column], '')) 258 | elif column == 'FIPS': 259 | row.append( 260 | self.deep_get_result_value( 261 | geocoding_result, [ 262 | 'annotations', 'FIPS', 'county'], '')) 263 | elif column == 'json': 264 | row.append(json.dumps(geocoding_result, separators=(',', ':'))) # Compact JSON 265 | else: 266 | row.append('') 267 | 268 | # Enforce that row are written ordered. That means we might wait for other threads 269 | # to finish a task and make the overall process slower. Alternative would be to 270 | # use a second queue, or keep some results in memory. 271 | if not self.options.unordered: 272 | while row_id > self.write_counter: 273 | if self.options.verbose: 274 | self.log(f"Want to write row {row_id}, but write_counter is at {self.write_counter}") 275 | await asyncio.sleep(random.uniform(0.01, 0.1)) 276 | 277 | if self.options.verbose: 278 | self.log(f"Writing row {row_id}") 279 | csv_output.writerow(row) 280 | self.write_counter = self.write_counter + 1 281 | 282 | def log(self, message): 283 | if not self.options.quiet: 284 | sys.stderr.write(f"{message}\n") 285 | 286 | def deep_get_result_value(self, data, keys, default=None): 287 | for key in keys: 288 | if isinstance(data, dict): 289 | data = data.get(key, default) 290 | else: 291 | return default 292 | return data 293 | -------------------------------------------------------------------------------- /opencage/geocoder.py: -------------------------------------------------------------------------------- 1 | """ Geocoder module. """ 2 | 3 | from decimal import Decimal 4 | import collections 5 | 6 | import os 7 | import sys 8 | import requests 9 | import backoff 10 | from .version import __version__ 11 | 12 | try: 13 | import aiohttp 14 | AIOHTTP_AVAILABLE = True 15 | except ImportError: 16 | AIOHTTP_AVAILABLE = False 17 | 18 | DEFAULT_DOMAIN = 'api.opencagedata.com' 19 | 20 | 21 | def backoff_max_time(): 22 | return int(os.environ.get('BACKOFF_MAX_TIME', '120')) 23 | 24 | 25 | class OpenCageGeocodeError(Exception): 26 | 27 | """Base class for all errors/exceptions that can happen when geocoding.""" 28 | 29 | 30 | class InvalidInputError(OpenCageGeocodeError): 31 | 32 | """ 33 | There was a problem with the input you provided. 34 | 35 | :var message: Error message describing the bad input. 36 | :var bad_value: The value that caused the problem. 37 | """ 38 | 39 | def __init__(self, message, bad_value=None): 40 | super().__init__() 41 | self.message = message 42 | self.bad_value = bad_value 43 | 44 | def __unicode__(self): 45 | return self.message 46 | 47 | __str__ = __unicode__ 48 | 49 | 50 | class UnknownError(OpenCageGeocodeError): 51 | 52 | """There was a problem with the OpenCage server.""" 53 | 54 | 55 | class RateLimitExceededError(OpenCageGeocodeError): 56 | 57 | """ 58 | Exception raised when account has exceeded it's limit. 59 | """ 60 | 61 | def __unicode__(self): 62 | """Convert exception to a string.""" 63 | return ("You have used the requests available on your plan. " 64 | "Please purchase more if you wish to continue: https://opencagedata.com/pricing") 65 | 66 | __str__ = __unicode__ 67 | 68 | 69 | class NotAuthorizedError(OpenCageGeocodeError): 70 | 71 | """ 72 | Exception raised when an unautorized API key is used. 73 | """ 74 | 75 | def __unicode__(self): 76 | """Convert exception to a string.""" 77 | return "Your API key is not authorized. You may have entered it incorrectly." 78 | 79 | __str__ = __unicode__ 80 | 81 | 82 | class ForbiddenError(OpenCageGeocodeError): 83 | 84 | """ 85 | Exception raised when a blocked or suspended API key is used. 86 | """ 87 | 88 | def __unicode__(self): 89 | """Convert exception to a string.""" 90 | return "Your API key has been blocked or suspended." 91 | 92 | __str__ = __unicode__ 93 | 94 | 95 | class AioHttpError(OpenCageGeocodeError): 96 | 97 | """ 98 | Exceptions related to async HTTP calls with aiohttp 99 | """ 100 | 101 | 102 | class SSLError(OpenCageGeocodeError): 103 | 104 | """ 105 | Exception raised when SSL connection to OpenCage server fails. 106 | """ 107 | 108 | def __unicode__(self): 109 | """Convert exception to a string.""" 110 | return ("SSL Certificate error connecting to OpenCage API. This is usually due to " 111 | "outdated CA root certificates of the operating system. " 112 | ) 113 | 114 | __str__ = __unicode__ 115 | 116 | 117 | class OpenCageGeocode: 118 | 119 | """ 120 | Geocoder object. 121 | 122 | Initialize it with your API key: 123 | 124 | >>> geocoder = OpenCageGeocode('your-key-here') 125 | 126 | Query: 127 | 128 | >>> geocoder.geocode("London") 129 | 130 | Reverse geocode a latitude & longitude into a place: 131 | 132 | >>> geocoder.reverse_geocode(51.5104, -0.1021) 133 | 134 | """ 135 | 136 | session = None 137 | 138 | def __init__( 139 | self, 140 | key=None, 141 | protocol='https', 142 | domain=DEFAULT_DOMAIN, 143 | sslcontext=None, 144 | user_agent_comment=None): 145 | """Constructor.""" 146 | self.key = key if key is not None else os.environ.get('OPENCAGE_API_KEY') 147 | 148 | if self.key is None: 149 | raise ValueError( 150 | "API key not provided. " 151 | "Either pass a 'key' parameter or set the OPENCAGE_API_KEY environment variable." 152 | ) 153 | 154 | if protocol and protocol not in ('http', 'https'): 155 | protocol = 'https' 156 | self.url = protocol + '://' + domain + '/geocode/v1/json' 157 | 158 | # https://docs.aiohttp.org/en/stable/client_advanced.html#ssl-control-for-tcp-sockets 159 | self.sslcontext = sslcontext 160 | 161 | self.user_agent_comment = user_agent_comment 162 | 163 | def __enter__(self): 164 | self.session = requests.Session() 165 | return self 166 | 167 | def __exit__(self, *args): 168 | self.session.close() 169 | self.session = None 170 | return False 171 | 172 | async def __aenter__(self): 173 | if not AIOHTTP_AVAILABLE: 174 | raise AioHttpError("You must install `aiohttp` to use async methods") 175 | 176 | self.session = aiohttp.ClientSession() 177 | return self 178 | 179 | async def __aexit__(self, *args): 180 | await self.session.close() 181 | self.session = None 182 | return False 183 | 184 | def geocode(self, query, **kwargs): 185 | """ 186 | Given a string to search for, return the list (array) of results from OpenCage's Geocoder. 187 | 188 | :param string query: String to search for 189 | 190 | :returns: Dict results 191 | :raises InvalidInputError: if the query string is not a unicode string 192 | :raises RateLimitExceededError: if you have exceeded the number of queries you can make. 193 | : Exception says when you can try again 194 | :raises UnknownError: if something goes wrong with the OpenCage API 195 | 196 | """ 197 | 198 | if self.session and isinstance(self.session, aiohttp.client.ClientSession): 199 | raise AioHttpError("Cannot use `geocode` in an async context, use `geocode_async`.") 200 | 201 | raw_response = kwargs.pop('raw_response', False) 202 | request = self._parse_request(query, kwargs) 203 | response = self._opencage_request(request) 204 | 205 | if raw_response: 206 | return response 207 | 208 | return floatify_latlng(response['results']) 209 | 210 | async def geocode_async(self, query, **kwargs): 211 | """ 212 | Aync version of `geocode`. 213 | 214 | Given a string to search for, return the list (array) of results from OpenCage's Geocoder. 215 | 216 | :param string query: String to search for 217 | 218 | :returns: Dict results 219 | :raises InvalidInputError: if the query string is not a unicode string 220 | :raises RateLimitExceededError: if exceeded number of queries you can make. You can try again 221 | :raises UnknownError: if something goes wrong with the OpenCage API 222 | 223 | """ 224 | 225 | if not AIOHTTP_AVAILABLE: 226 | raise AioHttpError("You must install `aiohttp` to use async methods.") 227 | 228 | if not self.session: 229 | raise AioHttpError("Async methods must be used inside an async context.") 230 | 231 | if not isinstance(self.session, aiohttp.client.ClientSession): 232 | raise AioHttpError("You must use `geocode_async` in an async context.") 233 | 234 | raw_response = kwargs.pop('raw_response', False) 235 | request = self._parse_request(query, kwargs) 236 | response = await self._opencage_async_request(request) 237 | 238 | if raw_response: 239 | return response 240 | 241 | return floatify_latlng(response['results']) 242 | 243 | def reverse_geocode(self, lat, lng, **kwargs): 244 | """ 245 | Given a latitude & longitude, return an address for that point from OpenCage's Geocoder. 246 | 247 | :param lat: Latitude 248 | :param lng: Longitude 249 | :return: Results from OpenCageData 250 | :rtype: dict 251 | :raises RateLimitExceededError: if you have exceeded the number of queries you can make. 252 | : Exception says when you can try again 253 | :raises UnknownError: if something goes wrong with the OpenCage API 254 | :raises InvalidInputError: if the latitude or longitude is out of bounds 255 | """ 256 | 257 | self._validate_lat_lng(lat, lng) 258 | 259 | return self.geocode(_query_for_reverse_geocoding(lat, lng), **kwargs) 260 | 261 | async def reverse_geocode_async(self, lat, lng, **kwargs): 262 | """ 263 | Aync version of `reverse_geocode`. 264 | 265 | Given a latitude & longitude, return an address for that point from OpenCage's Geocoder. 266 | 267 | :param lat: Latitude 268 | :param lng: Longitude 269 | :return: Results from OpenCageData 270 | :rtype: dict 271 | :raises RateLimitExceededError: if exceeded number of queries you can make. You can try again 272 | :raises UnknownError: if something goes wrong with the OpenCage API 273 | :raises InvalidInputError: if the latitude or longitude is out of bounds 274 | """ 275 | 276 | self._validate_lat_lng(lat, lng) 277 | 278 | return await self.geocode_async(_query_for_reverse_geocoding(lat, lng), **kwargs) 279 | 280 | @backoff.on_exception( 281 | backoff.expo, 282 | (UnknownError, requests.exceptions.RequestException), 283 | max_tries=5, max_time=backoff_max_time) 284 | def _opencage_request(self, params): 285 | 286 | if self.session: 287 | response = self.session.get(self.url, params=params, headers=self._opencage_headers('aiohttp')) 288 | else: 289 | response = requests.get(self.url, params=params, headers=self._opencage_headers('requests')) 290 | 291 | try: 292 | response_json = response.json() 293 | except ValueError as excinfo: 294 | raise UnknownError("Non-JSON result from server") from excinfo 295 | 296 | if response.status_code == 401: 297 | raise NotAuthorizedError() 298 | 299 | if response.status_code == 403: 300 | raise ForbiddenError() 301 | 302 | if response.status_code in (402, 429): 303 | raise RateLimitExceededError() 304 | 305 | if response.status_code == 500: 306 | raise UnknownError("500 status code from API") 307 | 308 | if 'results' not in response_json: 309 | raise UnknownError("JSON from API doesn't have a 'results' key") 310 | 311 | return response_json 312 | 313 | def _opencage_headers(self, client): 314 | client_version = requests.__version__ 315 | if client == 'aiohttp': 316 | client_version = aiohttp.__version__ 317 | 318 | py_version = '.'.join(str(x) for x in sys.version_info[0:3]) 319 | 320 | comment = '' 321 | if self.user_agent_comment: 322 | comment = f" ({self.user_agent_comment})" 323 | 324 | return { 325 | 'User-Agent': f"opencage-python/{__version__} Python/{py_version} {client}/{client_version}{comment}" 326 | } 327 | 328 | async def _opencage_async_request(self, params): 329 | try: 330 | async with self.session.get(self.url, params=params, ssl=self.sslcontext) as response: 331 | try: 332 | response_json = await response.json() 333 | except ValueError as excinfo: 334 | raise UnknownError("Non-JSON result from server") from excinfo 335 | 336 | if response.status == 401: 337 | raise NotAuthorizedError() 338 | 339 | if response.status == 403: 340 | raise ForbiddenError() 341 | 342 | if response.status in (402, 429): 343 | raise RateLimitExceededError() 344 | 345 | if response.status == 500: 346 | raise UnknownError("500 status code from API") 347 | 348 | if 'results' not in response_json: 349 | raise UnknownError("JSON from API doesn't have a 'results' key") 350 | 351 | return response_json 352 | except aiohttp.ClientSSLError as exp: 353 | raise SSLError() from exp 354 | except aiohttp.client_exceptions.ClientConnectorCertificateError as exp: 355 | raise SSLError() from exp 356 | 357 | def _parse_request(self, query, params): 358 | if not isinstance(query, str): 359 | error_message = "Input must be a unicode string, not " + repr(query)[:100] 360 | raise InvalidInputError(error_message, bad_value=query) 361 | 362 | data = {'q': query, 'key': self.key} 363 | data.update(params) # Add user parameters 364 | return data 365 | 366 | def _validate_lat_lng(self, lat, lng): 367 | """ 368 | Validate latitude and longitude values. 369 | 370 | Raises InvalidInputError if the values are out of bounds. 371 | """ 372 | try: 373 | lat_float = float(lat) 374 | if not -90 <= lat_float <= 90: 375 | raise InvalidInputError(f"Latitude must be a number between -90 and 90, not {lat}", bad_value=lat) 376 | except ValueError: 377 | raise InvalidInputError(f"Latitude must be a number between -90 and 90, not {lat}", bad_value=lat) 378 | 379 | try: 380 | lng_float = float(lng) 381 | if not -180 <= lng_float <= 180: 382 | raise InvalidInputError(f"Longitude must be a number between -180 and 180, not {lng}", bad_value=lng) 383 | except ValueError: 384 | raise InvalidInputError(f"Longitude must be a number between -180 and 180, not {lng}", bad_value=lng) 385 | 386 | 387 | def _query_for_reverse_geocoding(lat, lng): 388 | """ 389 | Given a lat & lng, what's the string search query. 390 | 391 | If the API changes, change this function. Only for internal use. 392 | """ 393 | # have to do some stupid f/Decimal/str stuff to (a) ensure we get as much 394 | # decimal places as the user already specified and (b) to ensure we don't 395 | # get e-5 stuff 396 | return f"{Decimal(str(lat)):f},{Decimal(str(lng)):f}" 397 | 398 | 399 | def float_if_float(float_string): 400 | """ 401 | Given a float string, returns the float value. 402 | On value error returns the original string. 403 | """ 404 | try: 405 | float_val = float(float_string) 406 | return float_val 407 | except ValueError: 408 | return float_string 409 | 410 | 411 | def floatify_latlng(input_value): 412 | """ 413 | Work around a JSON dict with string, not float, lat/lngs. 414 | 415 | Given anything (list/dict/etc) it will return that thing again, *but* any 416 | dict (at any level) that has only 2 elements lat & lng, will be replaced 417 | with the lat & lng turned into floats. 418 | 419 | If the API returns the lat/lng as strings, and not numbers, then this 420 | function will 'clean them up' to be floats. 421 | """ 422 | if isinstance(input_value, collections.abc.Mapping): 423 | if len(input_value) == 2 and sorted(input_value.keys()) == ['lat', 'lng']: 424 | # This dict has only 2 keys 'lat' & 'lon' 425 | return {'lat': float_if_float(input_value["lat"]), 'lng': float_if_float(input_value["lng"])} 426 | 427 | return dict((key, floatify_latlng(value)) for key, value in input_value.items()) 428 | 429 | if isinstance(input_value, collections.abc.MutableSequence): 430 | return [floatify_latlng(x) for x in input_value] 431 | 432 | return input_value 433 | -------------------------------------------------------------------------------- /examples/addresses.csv: -------------------------------------------------------------------------------- 1 | id, address 2 | 1, "Via Allende 8, Cascina, Toscana, Italia" 3 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 4 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 5 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 6 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 7 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 8 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 9 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 10 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 11 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 12 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 13 | 1, "Via Allende 8, Cascina, Toscana, Italia" 14 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 15 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 16 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 17 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 18 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 19 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 20 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 21 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 22 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 23 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 24 | 1, "Via Allende 8, Cascina, Toscana, Italia" 25 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 26 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 27 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 28 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 29 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 30 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 31 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 32 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 33 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 34 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 35 | 1, "Via Allende 8, Cascina, Toscana, Italia" 36 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 37 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 38 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 39 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 40 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 41 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 42 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 43 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 44 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 45 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 46 | 1, "Via Allende 8, Cascina, Toscana, Italia" 47 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 48 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 49 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 50 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 51 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 52 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 53 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 54 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 55 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 56 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 57 | 1, "Via Allende 8, Cascina, Toscana, Italia" 58 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 59 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 60 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 61 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 62 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 63 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 64 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 65 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 66 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 67 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 68 | 1, "Via Allende 8, Cascina, Toscana, Italia" 69 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 70 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 71 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 72 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 73 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 74 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 75 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 76 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 77 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 78 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 79 | 1, "Via Allende 8, Cascina, Toscana, Italia" 80 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 81 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 82 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 83 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 84 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 85 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 86 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 87 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 88 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 89 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 90 | 1, "Via Allende 8, Cascina, Toscana, Italia" 91 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 92 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 93 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 94 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 95 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 96 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 97 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 98 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 99 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 100 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 101 | 1, "Via Allende 8, Cascina, Toscana, Italia" 102 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 103 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 104 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 105 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 106 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 107 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 108 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 109 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 110 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 111 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 112 | 1, "Via Allende 8, Cascina, Toscana, Italia" 113 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 114 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 115 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 116 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 117 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 118 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 119 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 120 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 121 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 122 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 123 | 1, "Via Allende 8, Cascina, Toscana, Italia" 124 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 125 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 126 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 127 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 128 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 129 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 130 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 131 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 132 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 133 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 134 | 1, "Via Allende 8, Cascina, Toscana, Italia" 135 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 136 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 137 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 138 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 139 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 140 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 141 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 142 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 143 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 144 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 145 | 1, "Via Allende 8, Cascina, Toscana, Italia" 146 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 147 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 148 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 149 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 150 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 151 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 152 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 153 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 154 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 155 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 156 | 1, "Via Allende 8, Cascina, Toscana, Italia" 157 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 158 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 159 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 160 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 161 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 162 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 163 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 164 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 165 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 166 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 167 | 1, "Via Allende 8, Cascina, Toscana, Italia" 168 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 169 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 170 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 171 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 172 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 173 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 174 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 175 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 176 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 177 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 178 | 1, "Via Allende 8, Cascina, Toscana, Italia" 179 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 180 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 181 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 182 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 183 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 184 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 185 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 186 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 187 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 188 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 189 | 1, "Via Allende 8, Cascina, Toscana, Italia" 190 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 191 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 192 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 193 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 194 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 195 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 196 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 197 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 198 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 199 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 200 | 1, "Via Allende 8, Cascina, Toscana, Italia" 201 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 202 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 203 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 204 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 205 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 206 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 207 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 208 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 209 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 210 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 211 | 1, "Via Allende 8, Cascina, Toscana, Italia" 212 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 213 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 214 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 215 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 216 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 217 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 218 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 219 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 220 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 221 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 222 | 1, "Via Allende 8, Cascina, Toscana, Italia" 223 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 224 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 225 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 226 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 227 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 228 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 229 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 230 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 231 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 232 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 233 | 1, "Via Allende 8, Cascina, Toscana, Italia" 234 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 235 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 236 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 237 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 238 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 239 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 240 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 241 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 242 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 243 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 244 | 1, "Via Allende 8, Cascina, Toscana, Italia" 245 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 246 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 247 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 248 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 249 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 250 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 251 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 252 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 253 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 254 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 255 | 1, "Via Allende 8, Cascina, Toscana, Italia" 256 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 257 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 258 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 259 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 260 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 261 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 262 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 263 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 264 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 265 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 266 | 1, "Via Allende 8, Cascina, Toscana, Italia" 267 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 268 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 269 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 270 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 271 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 272 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 273 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 274 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 275 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 276 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 277 | 1, "Via Allende 8, Cascina, Toscana, Italia" 278 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 279 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 280 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 281 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 282 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 283 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 284 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 285 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 286 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 287 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 288 | 1, "Via Allende 8, Cascina, Toscana, Italia" 289 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 290 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 291 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 292 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 293 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 294 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 295 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 296 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 297 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 298 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 299 | 1, "Via Allende 8, Cascina, Toscana, Italia" 300 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 301 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 302 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 303 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 304 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 305 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 306 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 307 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 308 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 309 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 310 | 1, "Via Allende 8, Cascina, Toscana, Italia" 311 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 312 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 313 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 314 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 315 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 316 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 317 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 318 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 319 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 320 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 321 | 1, "Via Allende 8, Cascina, Toscana, Italia" 322 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 323 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 324 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 325 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 326 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 327 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 328 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 329 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 330 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 331 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 332 | 1, "Via Allende 8, Cascina, Toscana, Italia" 333 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 334 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 335 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 336 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 337 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 338 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 339 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 340 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 341 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 342 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 343 | 1, "Via Allende 8, Cascina, Toscana, Italia" 344 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 345 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 346 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 347 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 348 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 349 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 350 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 351 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 352 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 353 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 354 | 1, "Via Allende 8, Cascina, Toscana, Italia" 355 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 356 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 357 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 358 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 359 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 360 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 361 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 362 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 363 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 364 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 365 | 1, "Via Allende 8, Cascina, Toscana, Italia" 366 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 367 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 368 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 369 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 370 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 371 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 372 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 373 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 374 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 375 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 376 | 1, "Via Allende 8, Cascina, Toscana, Italia" 377 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 378 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 379 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 380 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 381 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 382 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 383 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 384 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 385 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 386 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 387 | 1, "Via Allende 8, Cascina, Toscana, Italia" 388 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 389 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 390 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 391 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 392 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 393 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 394 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 395 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 396 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 397 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 398 | 1, "Via Allende 8, Cascina, Toscana, Italia" 399 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 400 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 401 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 402 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 403 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 404 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 405 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 406 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 407 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 408 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 409 | 1, "Via Allende 8, Cascina, Toscana, Italia" 410 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 411 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 412 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 413 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 414 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 415 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 416 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 417 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 418 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 419 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 420 | 1, "Via Allende 8, Cascina, Toscana, Italia" 421 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 422 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 423 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 424 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 425 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 426 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 427 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 428 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 429 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 430 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 431 | 1, "Via Allende 8, Cascina, Toscana, Italia" 432 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 433 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 434 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 435 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 436 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 437 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 438 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 439 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 440 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 441 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 442 | 1, "Via Allende 8, Cascina, Toscana, Italia" 443 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 444 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 445 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 446 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 447 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 448 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 449 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 450 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 451 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 452 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 453 | 1, "Via Allende 8, Cascina, Toscana, Italia" 454 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 455 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 456 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 457 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 458 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 459 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 460 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 461 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 462 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 463 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 464 | 1, "Via Allende 8, Cascina, Toscana, Italia" 465 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 466 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 467 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 468 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 469 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 470 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 471 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 472 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 473 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 474 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 475 | 1, "Via Allende 8, Cascina, Toscana, Italia" 476 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 477 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 478 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 479 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 480 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 481 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 482 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 483 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 484 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 485 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 486 | 1, "Via Allende 8, Cascina, Toscana, Italia" 487 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 488 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 489 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 490 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 491 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 492 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 493 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 494 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 495 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 496 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 497 | 1, "Via Allende 8, Cascina, Toscana, Italia" 498 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 499 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 500 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 501 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 502 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 503 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 504 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 505 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 506 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 507 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 508 | 1, "Via Allende 8, Cascina, Toscana, Italia" 509 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 510 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 511 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 512 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 513 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 514 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 515 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 516 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 517 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 518 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 519 | 1, "Via Allende 8, Cascina, Toscana, Italia" 520 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 521 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 522 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 523 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 524 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 525 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 526 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 527 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 528 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 529 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 530 | 1, "Via Allende 8, Cascina, Toscana, Italia" 531 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 532 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 533 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 534 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 535 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 536 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 537 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 538 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 539 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 540 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 541 | 1, "Via Allende 8, Cascina, Toscana, Italia" 542 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 543 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 544 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 545 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 546 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 547 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 548 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 549 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 550 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 551 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 552 | 1, "Via Allende 8, Cascina, Toscana, Italia" 553 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 554 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 555 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 556 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 557 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 558 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 559 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 560 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 561 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 562 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 563 | 1, "Via Allende 8, Cascina, Toscana, Italia" 564 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 565 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 566 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 567 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 568 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 569 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 570 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 571 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 572 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 573 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 574 | 1, "Via Allende 8, Cascina, Toscana, Italia" 575 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 576 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 577 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 578 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 579 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 580 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 581 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 582 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 583 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 584 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 585 | 1, "Via Allende 8, Cascina, Toscana, Italia" 586 | 2, "Via Coppi, 17, Formigine, Emilia-Romagna, Italia" 587 | 3, "Via Dei Salici 20, Gallarate, Lombardia, Italia" 588 | 4, "Via Vittorio Veneto N7, San Giuliano Terme, Toscana, Italia" 589 | 5, "Via Tiro A Segno 8, Gallarate, Lombardia, Italia" 590 | 6, "Urne Di Sopra 3, Bagnolo Mella, Lombardia, Italia" 591 | 7, "Via San Francesco D'assisi 84, Nichelino, Piemonte, Italia" 592 | 8, "Via Corletto, 10/A, Formigine, Emilia-Romagna, Italia" 593 | 9, "Via Beppe Fenoglio 17, Canale D'alba, Piemonte, Italia" 594 | 10, "Via 2 Giugno 19, Ponsacco, Toscana, Italia" 595 | 11, "Via Per Sassuolo, 74, Formigine, Emilia-Romagna, Italia" 596 | --------------------------------------------------------------------------------