diff --git a/Changes.txt b/Changes.txt
index a986334..26aca73 100644
--- a/Changes.txt
+++ b/Changes.txt
@@ -1,5 +1,15 @@
+v1.0.0 Sat May 2 2026
+ Extracted from the `opencage` Python package; the CLI is now a standalone
+ `opencage-cli` distribution that depends on `opencage>=3.3.1` from PyPI.
+ No feature changes. Internal Python module renamed to `opencage_cli`.
+ Vendored two small helpers (`_query_for_reverse_geocoding`, `_floatify_latlng`)
+ from `opencage.geocoder` as private methods of `OpenCageBatchGeocoder` so the
+ CLI no longer depends on the library's private API.
+
+== Below: changelog from when the CLI shipped inside the `opencage` package ==
+
v3.3.2 Sun Jun 08 2026
- Improve validation of the host domain parameter
+ Improve validation of the host domain parameter (library-only change)
v3.3.1 Sun Mar 29 2026
Validate the host domain parameter. Should be opencagedata.com subdomain or localhost
diff --git a/README.md b/README.md
index 3d79629..2a7c3fc 100644
--- a/README.md
+++ b/README.md
@@ -1,131 +1,30 @@
-# OpenCage Geocoding Module for Python
+# OpenCage CLI
-A Python module to access the [OpenCage Geocoding API](https://opencagedata.com/).
+A command-line tool for the [OpenCage Geocoding API](https://opencagedata.com/), for forward and reverse geocoding of CSV files.
## Build Status / Code Quality / etc
-[](https://badge.fury.io/py/opencage)
-[](https://pepy.tech/project/opencage)
-[](https://pypi.org/project/opencage/)
-
-[](https://github.com/OpenCageData/python-opencage-geocoder/actions/workflows/build.yml)
-
+[](https://badge.fury.io/py/opencage-cli)
+[](https://pypi.org/project/opencage-cli/)
+[](https://github.com/OpenCageData/opencage-cli/actions/workflows/build.yml)
-## Tutorials
+## Tutorial
-You can find a [comprehensive tutorial for using this module on the OpenCage site](https://opencagedata.com/tutorials/geocode-in-python).
+See the [CLI tutorial on the OpenCage site](https://opencagedata.com/tutorials/geocode-commandline) for a full walk-through.
-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).
+## Installation
-The module installs an `opencage` CLI tool for geocoding files. Check `opencage --help` or the [CLI tutorial](https://opencagedata.com/tutorials/geocode-commandline).
-
-## Working with AI / Agent Skill
-
-There is an [Agent Skill for working with the OpenCage Geocoding API](https://github.com/OpenCageData/opencage-skills/) which includes a reference file for developing in Python using this module.
-
-## Usage
-
-Supports Python 3.9 or newer. Starting opencage version 3.0 depends on asyncio package.
-
-Install the module:
+Supports Python 3.9 or newer.
```bash
-pip install opencage
-```
-
-Load the module:
-
-```python
-from opencage.geocoder import OpenCageGeocode
-```
-
-Create an instance of the geocoder module, passing a valid OpenCage Data Geocoder API key
-as a parameter to the geocoder modules's constructor:
-
-```python
-key = 'your-api-key-here'
-geocoder = OpenCageGeocode(key)
-```
-
-Pass a string containing the query or address to be geocoded to the modules' `geocode` method:
-
-```python
-query = '82 Clerkenwell Road, London'
-results = geocoder.geocode(query)
-```
-
-You can add [additional parameters](https://opencagedata.com/api#forward-opt):
-
-```python
-results = geocoder.geocode('London', no_annotations=1, language='es')
-```
-
-For example you can use the proximity parameter to provide the geocoder with a hint:
-
-```python
-results = geocoder.geocode('London', proximity='42.828576, -81.406643')
-print(results[0]['formatted'])
-# u'London, ON N6A 3M8, Canada'
+pip install opencage-cli
```
-### Reverse geocoding
-
-Turn a lat/long into an address with the `reverse_geocode` method:
+This installs an `opencage` executable on your `PATH`. The Python geocoding library it uses (the `opencage` package on PyPI) is pulled in as a dependency.
-```python
-result = geocoder.reverse_geocode(51.51024, -0.10303)
-```
-
-### Sessions
-
-You can reuse your HTTP connection for multiple requests by
-using a `with` block. This can help performance when making
-a lot of requests:
-
-```python
-queries = ['82 Clerkenwell Road, London', ...]
-with OpenCageGeocode(key) as geocoder:
- # Queries reuse the same HTTP connection
- results = [geocoder.geocode(query) for query in queries]
-```
-
-### Asyncronous requests
-
-You can run requests in parallel with the `geocode_async` and `reverse_geocode_async`
-method which have the same parameters and response as their synronous counterparts.
-You will need at least Python 3.8 and the `asyncio` and `aiohttp` packages installed.
-
-```python
-async with OpenCageGeocode(key) as geocoder:
- results = await geocoder.geocode_async(address)
-```
-
-For a more complete example and links to futher tutorials on asyncronous IO see
-`batch.py` in the `examples` directory.
-
-### Non-SSL API use
-
-If you have trouble accesing the OpenCage API with https, e.g. issues with OpenSSL
-libraries in your enviroment, then you can set the 'http' protocol instead. Please
-understand that the connection to the OpenCage API will no longer be encrypted.
-
-```python
-geocoder = OpenCageGeocode('your-api-key', 'http')
-```
-
-### Exceptions
-
-If anything goes wrong, then an exception will be raised:
-
-- `InvalidInputError` for non-unicode query strings
-- `NotAuthorizedError` if API key is missing, invalid syntax or disabled
-- `ForbiddenError` API key is blocked or suspended
-- `RateLimitExceededError` if you go past your rate limit
-- `UnknownError` if there's some problem with the API (bad results, 500 status code, etc)
-
-## Command-line batch geocoding
+## Usage
-Use `opencage forward` or `opencage reverse`
+Use `opencage forward` or `opencage reverse`:
```
opencage forward --help
@@ -137,10 +36,10 @@ options:
--output FILENAME Output file name
--headers If the first row should be treated as a header row
--input-columns Comma-separated list of integers (default '1')
- --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')
+ --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')
--workers Number of parallel geocoding requests (default 1)
--timeout Timeout in seconds (default 10)
- --retries Number of retries (default 5)
+ --retries Number of retries (default 10)
--api-domain API domain (default api.opencagedata.com)
--optional-api-params
Extra parameters for each request (e.g. language=fr,no_dedupe=1)
@@ -155,6 +54,15 @@ options:
+See [`examples/addresses.csv`](examples/addresses.csv) for sample input.
+
+## Working with AI / Agent Skill
+
+There is an [Agent Skill for working with the OpenCage Geocoding API](https://github.com/OpenCageData/opencage-skills/).
+
+## Python library
+
+If you want to call the OpenCage API directly from Python rather than via this CLI, install [the `opencage` library](https://pypi.org/project/opencage/) — `pip install opencage`.
## Copyright & License
diff --git a/Vagrantfile b/Vagrantfile
index 0850652..95a07c5 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -9,7 +9,7 @@ Vagrant.configure("2") do |config|
config.vm.box = 'gutehall/ubuntu24-04'
end
- config.vm.synced_folder ".", "/home/vagrant/python-opencage-geocoder"
+ config.vm.synced_folder ".", "/home/vagrant/opencage-cli"
# provision using a simple shell script
config.vm.provision :shell, path: "vagrant-provision.sh", privileged: false
diff --git a/examples/batch.py b/examples/batch.py
deleted file mode 100755
index 71502e0..0000000
--- a/examples/batch.py
+++ /dev/null
@@ -1,139 +0,0 @@
-#!/usr/bin/env python3
-
-# Example script we used between 2021 and 2023. It's now being replaced by
-# the much more powerful CLI tool (see README.md file).
-#
-# Git version history will show how we kept adding features. Below is a
-# version with less features, on purpose, for better readability.
-#
-# Background tutorial on async programming with Python
-# https://realpython.com/async-io-python/
-#
-# Requires Python 3.7 or newer. Tested with 3.8 and 3.9.
-#
-# Installation:
-# pip3 install opencage
-#
-
-import sys
-import csv
-import asyncio
-from opencage.geocoder import OpenCageGeocode
-
-API_KEY = ''
-INFILE = 'file_to_geocode.csv'
-OUTFILE = 'file_geocoded.csv'
-MAX_ITEMS = 100 # Set to 0 for unlimited
-NUM_WORKERS = 3 # For 10 requests per second try 2-5
-
-csv_writer = csv.writer(open(OUTFILE, 'w', encoding='utf8', newline=''))
-
-async def write_one_geocoding_result(geocoding_result, address, address_id):
- if geocoding_result is not None:
- geocoding_result = geocoding_result[0]
- row = [
- address_id,
- geocoding_result['geometry']['lat'],
- geocoding_result['geometry']['lng'],
- # Any of these components might be empty :
- geocoding_result['components'].get('country', ''),
- geocoding_result['components'].get('county', ''),
- geocoding_result['components'].get('city', ''),
- geocoding_result['components'].get('postcode', ''),
- geocoding_result['components'].get('road', ''),
- geocoding_result['components'].get('house_number', ''),
- geocoding_result['confidence'],
- geocoding_result['formatted']
- ]
-
- else:
- row = [
- address_id,
- 0, # not to be confused with https://en.wikipedia.org/wiki/Null_Island
- 0,
- '',
- '',
- '',
- '',
- '',
- '',
- -1, # confidence values are 1-10 (lowest to highest), use -1 for unknown
- ''
- ]
- sys.stderr.write(f"not found, writing empty result: ${address}\n")
- csv_writer.writerow(row)
-
-
-async def geocode_one_address(address, address_id):
- async with OpenCageGeocode(API_KEY) as geocoder:
- geocoding_result = await geocoder.geocode_async(address)
- try:
- await write_one_geocoding_result(geocoding_result, address, address_id)
- except Exception as e:
- sys.stderr.write(e)
-
-
-
-async def run_worker(worker_name, queue):
- sys.stderr.write(f"Worker ${worker_name} starts...\n")
- while True:
- work_item = await queue.get()
- address_id = work_item['id']
- address = work_item['address']
- await geocode_one_address(address, address_id)
- queue.task_done()
-
-
-
-
-async def main():
- assert sys.version_info >= (3, 7), "Script requires Python 3.7+."
-
- ## 1. Read CSV into a Queue
- ## Each work_item is an address and id. The id will be part of the output,
- ## easy to add more settings. Named 'work_item' to avoid the words
- ## 'address' or 'task' which are used elsewhere
- ##
- ## https://docs.python.org/3/library/asyncio-queue.html
- ##
- queue = asyncio.Queue(maxsize=MAX_ITEMS)
-
- csv_reader = csv.reader(open(INFILE, 'r', encoding='utf8'))
-
- for row in csv_reader:
- work_item = {'id': row[0], 'address': row[1]}
- await queue.put(work_item)
- if queue.full():
- break
-
- sys.stderr.write(f"${queue.qsize()} work_items in queue\n")
-
-
- ## 2. Create tasks workers. That is coroutines, each taks take work_items
- ## from the queue until it's empty. Tasks run in parallel
- ##
- ## https://docs.python.org/3/library/asyncio-task.html#creating-tasks
- ## https://docs.python.org/3/library/asyncio-task.html#coroutine
- ##
- sys.stderr.write(f"Creating ${NUM_WORKERS} task workers...\n")
- tasks = []
- for i in range(NUM_WORKERS):
- task = asyncio.create_task(run_worker(f'worker {i}', queue))
- tasks.append(task)
-
-
- ## 3. Now workers do the geocoding
- ##
- sys.stderr.write("Now waiting for workers to finish processing queue...\n")
- await queue.join()
-
-
- ## 4. Cleanup
- ##
- for task in tasks:
- task.cancel()
-
- sys.stderr.write("All done.\n")
-
-
-asyncio.run(main())
diff --git a/examples/demo.py b/examples/demo.py
deleted file mode 100755
index 3d516a9..0000000
--- a/examples/demo.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from pprint import pprint
-from opencage.geocoder import OpenCageGeocode
-
-APIKEY = 'your-key-here'
-
-geocoder = OpenCageGeocode(APIKEY)
-
-results = geocoder.reverse_geocode(44.8303087, -0.5761911)
-pprint(results)
-# [{'components': {'city': 'Bordeaux',
-# 'country': 'France',
-# 'country_code': 'fr',
-# 'county': 'Bordeaux',
-# 'house_number': '11',
-# 'political_union': 'European Union',
-# 'postcode': '33800',
-# 'road': 'Rue Sauteyron',
-# 'state': 'New Aquitaine',
-# 'suburb': 'Bordeaux Sud'},
-# 'formatted': '11 Rue Sauteyron, 33800 Bordeaux, France',
-# 'geometry': {'lat': 44.8303087, 'lng': -0.5761911}}]
diff --git a/examples/flask_demo.py b/examples/flask_demo.py
deleted file mode 100755
index 1061277..0000000
--- a/examples/flask_demo.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# Sample forward geocode: http://127.0.0.1:5000/forward/147%20Farm%20STreet%20Blackstone%20MA%2001504
-#
-# Sample reverse geocode: http://127.0.0.1:5000/reverse/42.036488/-71.519678/
-import json
-from flask import Flask
-from flask import request
-from opencage.geocoder import OpenCageGeocode
-
-app = Flask(__name__)
-_key = OPEN_CAGE_KEY = "YOUR_OPEN_CAGE_KEY"
-_geocoder = OpenCageGeocode(OPEN_CAGE_KEY)
-
-@app.route("/forward/
")
-def forward(address):
- verbose = json.loads(request.args.get('verbose', "false").lower())
- raw_result = _geocoder.geocode(address)
- formatted = [{"confidence": r["confidence"], "geometry": r["geometry"]} for r in raw_result if r["confidence"]]
- return json.dumps(raw_result if verbose else formatted)
-
-@app.route("/reverse///")
-def reverse(lat, lng):
- verbose = json.loads(request.args.get('verbose', "false").lower())
- raw_result = _geocoder.reverse_geocode(float(lat), float(lng))
- return json.dumps(raw_result if verbose else [r["components"] for r in raw_result])
-
-if __name__ == "__main__":
- app.run(debug=True)
diff --git a/opencage/__init__.py b/opencage/__init__.py
deleted file mode 100644
index d40fc7c..0000000
--- a/opencage/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-""" Base module for OpenCage stuff. """
-
-from .version import __version__
-
-__author__ = "OpenCage GmbH"
-__email__ = 'support@opencagedata.com'
diff --git a/opencage/geocoder.py b/opencage/geocoder.py
deleted file mode 100644
index 2f53c6e..0000000
--- a/opencage/geocoder.py
+++ /dev/null
@@ -1,558 +0,0 @@
-"""Geocoder module for the OpenCage API."""
-
-from decimal import Decimal
-import collections
-
-import os
-import sys
-from urllib.parse import urlsplit
-import requests
-import backoff
-from .version import __version__
-
-try:
- import aiohttp
- AIOHTTP_AVAILABLE = True
-except ImportError:
- AIOHTTP_AVAILABLE = False
-
-DEFAULT_DOMAIN = 'api.opencagedata.com'
-
-
-def _validate_domain(domain):
- """Validate that the API domain is an allowed hostname.
-
- Only subdomains of opencagedata.com, localhost, and 0.0.0.0 are
- permitted. An optional port suffix (e.g. ``localhost:8080``) is allowed.
-
- Args:
- domain: Hostname string, optionally with a port.
-
- Returns:
- The validated ``host`` or ``host:port`` string, re-serialized from
- the parsed components so any scheme, userinfo, path, query, or
- fragment is rejected rather than silently absorbed.
-
- Raises:
- ValueError: If the domain is not in the allow-list, or carries any
- URL component beyond host and port.
- """
- if not isinstance(domain, str) or not domain:
- raise ValueError("Invalid API domain.")
-
- try:
- parts = urlsplit('//' + domain)
- port = parts.port
- except ValueError as exc:
- raise ValueError("Invalid API domain.") from exc
-
- if (parts.scheme or parts.username or parts.password
- or parts.path or parts.query or parts.fragment):
- raise ValueError(
- "Invalid API domain. Must be a bare hostname, optionally with a port."
- )
-
- hostname = parts.hostname
- if not hostname:
- raise ValueError("Invalid API domain.")
-
- if (hostname not in ('localhost', '0.0.0.0')
- and not hostname.endswith('.opencagedata.com')):
- raise ValueError(
- "Invalid API domain. "
- "Must be a subdomain of opencagedata.com, localhost, or 0.0.0.0."
- )
-
- return f"{hostname}:{port}" if port is not None else hostname
-
-
-def backoff_max_time():
- """Return the maximum backoff time in seconds for retrying API requests.
-
- Returns:
- Maximum time in seconds, from the BACKOFF_MAX_TIME environment
- variable or 120 by default.
- """
- return int(os.environ.get('BACKOFF_MAX_TIME', '120'))
-
-
-class OpenCageGeocodeError(Exception):
- """Base class for all errors/exceptions that can happen when geocoding."""
-
-
-class InvalidInputError(OpenCageGeocodeError):
- """There was a problem with the input you provided.
-
- Attributes:
- message: Error message describing the bad input.
- bad_value: The value that caused the problem.
- """
-
- def __init__(self, message, bad_value=None):
- super().__init__()
- self.message = message
- self.bad_value = bad_value
-
- def __unicode__(self):
- return self.message
-
- __str__ = __unicode__
-
-
-class UnknownError(OpenCageGeocodeError):
- """There was a problem with the OpenCage server."""
-
-
-class RateLimitExceededError(OpenCageGeocodeError):
- """Exception raised when account has exceeded its limit."""
-
- def __unicode__(self):
- """Convert exception to a string."""
- return ("You have used the requests available on your plan. "
- "Please purchase more if you wish to continue: https://opencagedata.com/pricing")
-
- __str__ = __unicode__
-
-
-class NotAuthorizedError(OpenCageGeocodeError):
- """Exception raised when an unauthorized API key is used."""
-
- def __unicode__(self):
- """Convert exception to a string."""
- return "Your API key is not authorized. You may have entered it incorrectly."
-
- __str__ = __unicode__
-
-
-class ForbiddenError(OpenCageGeocodeError):
- """Exception raised when a blocked or suspended API key is used."""
-
- def __unicode__(self):
- """Convert exception to a string."""
- return "Your API key has been blocked or suspended."
-
- __str__ = __unicode__
-
-
-class AioHttpError(OpenCageGeocodeError):
- """Exception raised for errors related to async HTTP calls with aiohttp."""
-
-
-class SSLError(OpenCageGeocodeError):
- """Exception raised when SSL connection to OpenCage server fails."""
-
- def __unicode__(self):
- """Convert exception to a string."""
- return ("SSL Certificate error connecting to OpenCage API. This is usually due to "
- "outdated CA root certificates of the operating system. "
- )
-
- __str__ = __unicode__
-
-
-class OpenCageGeocode:
- """Client for the OpenCage Geocoding API.
-
- Supports both synchronous and asynchronous geocoding. Can be used as
- a context manager for connection pooling.
-
- Example:
- >>> geocoder = OpenCageGeocode('your-key-here')
- >>> geocoder.geocode("London")
- >>> geocoder.reverse_geocode(51.5104, -0.1021)
- """
-
- session = None
-
- def __init__(
- self,
- key=None,
- protocol='https',
- domain=DEFAULT_DOMAIN,
- sslcontext=None,
- user_agent_comment=None):
- """Initialize the geocoder.
-
- Args:
- key: OpenCage API key. If not provided, reads from the
- OPENCAGE_API_KEY environment variable.
- protocol: HTTP protocol to use, either 'http' or 'https'.
- domain: API domain to connect to.
- sslcontext: SSL context for async (aiohttp) connections.
- user_agent_comment: Optional comment appended to the User-Agent header.
-
- Raises:
- ValueError: If no API key is provided or found in the environment.
- """
- self.key = key if key is not None else os.environ.get('OPENCAGE_API_KEY')
-
- if self.key is None:
- raise ValueError(
- "API key not provided. "
- "Either pass a 'key' parameter or set the OPENCAGE_API_KEY environment variable."
- )
-
- if protocol and protocol not in ('http', 'https'):
- protocol = 'https'
- domain = _validate_domain(domain)
- self.url = protocol + '://' + domain + '/geocode/v1/json'
-
- # https://docs.aiohttp.org/en/stable/client_advanced.html#ssl-control-for-tcp-sockets
- self.sslcontext = sslcontext
-
- self.user_agent_comment = user_agent_comment
-
- def __enter__(self):
- self.session = requests.Session()
- return self
-
- def __exit__(self, *args):
- self.session.close()
- self.session = None
- return False
-
- async def __aenter__(self):
- if not AIOHTTP_AVAILABLE:
- raise AioHttpError("You must install `aiohttp` to use async methods")
-
- self.session = aiohttp.ClientSession()
- return self
-
- async def __aexit__(self, *args):
- await self.session.close()
- self.session = None
- return False
-
- def geocode(self, query, **kwargs):
- """Geocode an address string.
-
- Args:
- query: Address or place name to geocode.
- **kwargs: Additional API parameters (e.g. language, countrycode).
- Pass raw_response=True to get the full API response dict
- instead of just the results list.
-
- Returns:
- List of geocoding results with lat/lng and components, or the
- full API response dict if raw_response=True.
-
- Raises:
- InvalidInputError: If query is not a unicode string.
- RateLimitExceededError: If API quota is exceeded.
- UnknownError: If something goes wrong with the OpenCage API.
- AioHttpError: If called inside an async context manager.
- """
-
- if self.session and isinstance(self.session, aiohttp.client.ClientSession):
- raise AioHttpError("Cannot use `geocode` in an async context, use `geocode_async`.")
-
- raw_response = kwargs.pop('raw_response', False)
- request = self._parse_request(query, kwargs)
- response = self._opencage_request(request)
-
- if raw_response:
- return response
-
- return floatify_latlng(response['results'])
-
- async def geocode_async(self, query, **kwargs):
- """Async version of geocode.
-
- Must be used inside an async context manager (``async with``).
-
- Args:
- query: Address or place name to geocode.
- **kwargs: Additional API parameters (e.g. language, countrycode).
- Pass raw_response=True to get the full API response dict
- instead of just the results list.
-
- Returns:
- List of geocoding results with lat/lng and components, or the
- full API response dict if raw_response=True.
-
- Raises:
- InvalidInputError: If query is not a unicode string.
- RateLimitExceededError: If API quota is exceeded.
- UnknownError: If something goes wrong with the OpenCage API.
- AioHttpError: If aiohttp is not installed or no async session is active.
- """
-
- if not AIOHTTP_AVAILABLE:
- raise AioHttpError("You must install `aiohttp` to use async methods.")
-
- if not self.session:
- raise AioHttpError("Async methods must be used inside an async context.")
-
- if not isinstance(self.session, aiohttp.client.ClientSession):
- raise AioHttpError("You must use `geocode_async` in an async context.")
-
- raw_response = kwargs.pop('raw_response', False)
- request = self._parse_request(query, kwargs)
- response = await self._opencage_async_request(request)
-
- if raw_response:
- return response
-
- return floatify_latlng(response['results'])
-
- def reverse_geocode(self, lat, lng, **kwargs):
- """Reverse geocode a latitude/longitude pair into an address.
-
- Args:
- lat: Latitude (-90 to 90).
- lng: Longitude (-180 to 180).
- **kwargs: Additional API parameters (e.g. language, countrycode).
-
- Returns:
- List of geocoding results with address components.
-
- Raises:
- InvalidInputError: If latitude or longitude is out of bounds.
- RateLimitExceededError: If API quota is exceeded.
- UnknownError: If something goes wrong with the OpenCage API.
- """
-
- self._validate_lat_lng(lat, lng)
-
- return self.geocode(_query_for_reverse_geocoding(lat, lng), **kwargs)
-
- async def reverse_geocode_async(self, lat, lng, **kwargs):
- """Async version of reverse_geocode.
-
- Must be used inside an async context manager (``async with``).
-
- Args:
- lat: Latitude (-90 to 90).
- lng: Longitude (-180 to 180).
- **kwargs: Additional API parameters (e.g. language, countrycode).
-
- Returns:
- List of geocoding results with address components.
-
- Raises:
- InvalidInputError: If latitude or longitude is out of bounds.
- RateLimitExceededError: If API quota is exceeded.
- UnknownError: If something goes wrong with the OpenCage API.
- """
-
- self._validate_lat_lng(lat, lng)
-
- return await self.geocode_async(_query_for_reverse_geocoding(lat, lng), **kwargs)
-
- @backoff.on_exception(
- backoff.expo,
- (UnknownError, requests.exceptions.RequestException),
- max_tries=5, max_time=backoff_max_time)
- def _opencage_request(self, params):
- """Send a synchronous geocoding request to the OpenCage API.
-
- Args:
- params: Dict of query parameters for the API request.
-
- Returns:
- Parsed JSON response dict from the API.
-
- Raises:
- NotAuthorizedError: If the API key is invalid.
- ForbiddenError: If the API key is blocked or suspended.
- RateLimitExceededError: If the rate limit is exceeded.
- UnknownError: If the server returns an error or invalid JSON.
- """
- if self.session:
- response = self.session.get(self.url, params=params, headers=self._opencage_headers('aiohttp'), timeout=30)
- else:
- response = requests.get(self.url, params=params, headers=self._opencage_headers('requests'), timeout=30)
-
- try:
- response_json = response.json()
- except ValueError as excinfo:
- raise UnknownError("Non-JSON result from server") from excinfo
-
- if response.status_code == 401:
- raise NotAuthorizedError()
-
- if response.status_code == 403:
- raise ForbiddenError()
-
- if response.status_code in (402, 429):
- raise RateLimitExceededError()
-
- if response.status_code == 500:
- raise UnknownError("500 status code from API")
-
- if 'results' not in response_json:
- raise UnknownError("JSON from API doesn't have a 'results' key")
-
- return response_json
-
- def _opencage_headers(self, client):
- """Build the HTTP headers for an API request.
-
- Args:
- client: HTTP client name ('requests' or 'aiohttp').
-
- Returns:
- Dict with User-Agent header.
- """
- client_version = requests.__version__
- if client == 'aiohttp':
- client_version = aiohttp.__version__
-
- py_version = '.'.join(str(x) for x in sys.version_info[0:3])
-
- comment = ''
- if self.user_agent_comment:
- clean = self.user_agent_comment.replace('\r', '').replace('\n', '')
- comment = f" ({clean})"
-
- return {
- 'User-Agent': f"opencage-python/{__version__} Python/{py_version} {client}/{client_version}{comment}"
- }
-
- async def _opencage_async_request(self, params):
- """Send an async geocoding request to the OpenCage API.
-
- Args:
- params: Dict of query parameters for the API request.
-
- Returns:
- Parsed JSON response dict from the API.
-
- Raises:
- NotAuthorizedError: If the API key is invalid.
- ForbiddenError: If the API key is blocked or suspended.
- RateLimitExceededError: If the rate limit is exceeded.
- UnknownError: If the server returns an error or invalid JSON.
- SSLError: If the SSL connection fails.
- """
- try:
- timeout = aiohttp.ClientTimeout(total=30)
- async with self.session.get(self.url, params=params, ssl=self.sslcontext, timeout=timeout) as response:
- try:
- response_json = await response.json()
- except ValueError as excinfo:
- raise UnknownError("Non-JSON result from server") from excinfo
-
- if response.status == 401:
- raise NotAuthorizedError()
-
- if response.status == 403:
- raise ForbiddenError()
-
- if response.status in (402, 429):
- raise RateLimitExceededError()
-
- if response.status == 500:
- raise UnknownError("500 status code from API")
-
- if 'results' not in response_json:
- raise UnknownError("JSON from API doesn't have a 'results' key")
-
- return response_json
- except aiohttp.ClientSSLError as exp:
- raise SSLError() from exp
- except aiohttp.client_exceptions.ClientConnectorCertificateError as exp:
- raise SSLError() from exp
-
- def _parse_request(self, query, params):
- """Build the request parameters dict for an API call.
-
- Args:
- query: The geocoding query string.
- params: Additional API parameters from the caller.
-
- Returns:
- Dict of parameters ready to send to the API.
-
- Raises:
- InvalidInputError: If query is not a unicode string.
- """
- if not isinstance(query, str):
- error_message = "Input must be a unicode string, not " + repr(query)[:100]
- raise InvalidInputError(error_message, bad_value=query)
-
- data = {'q': query, 'key': self.key}
- data.update(params) # Add user parameters
- return data
-
- def _validate_lat_lng(self, lat, lng):
- """Validate latitude and longitude values.
-
- Args:
- lat: Latitude value to validate.
- lng: Longitude value to validate.
-
- Raises:
- InvalidInputError: If latitude is not in [-90, 90] or longitude
- is not in [-180, 180].
- """
- try:
- lat_float = float(lat)
- if not -90 <= lat_float <= 90:
- raise InvalidInputError(f"Latitude must be a number between -90 and 90, not {lat}", bad_value=lat)
- except ValueError:
- raise InvalidInputError(f"Latitude must be a number between -90 and 90, not {lat}", bad_value=lat)
-
- try:
- lng_float = float(lng)
- if not -180 <= lng_float <= 180:
- raise InvalidInputError(f"Longitude must be a number between -180 and 180, not {lng}", bad_value=lng)
- except ValueError:
- raise InvalidInputError(f"Longitude must be a number between -180 and 180, not {lng}", bad_value=lng)
-
-
-def _query_for_reverse_geocoding(lat, lng):
- """Build the query string for a reverse geocoding request.
-
- Args:
- lat: Latitude value.
- lng: Longitude value.
-
- Returns:
- Comma-separated string of lat and lng with full decimal precision.
- """
- # have to do some stupid f/Decimal/str stuff to (a) ensure we get as much
- # decimal places as the user already specified and (b) to ensure we don't
- # get e-5 stuff
- return f"{Decimal(str(lat)):f},{Decimal(str(lng)):f}"
-
-
-def float_if_float(float_string):
- """Convert a string to float if possible.
-
- Args:
- float_string: String to attempt to convert.
-
- Returns:
- The float value if conversion succeeds, or the original string.
- """
- try:
- float_val = float(float_string)
- return float_val
- except ValueError:
- return float_string
-
-
-def floatify_latlng(input_value):
- """Recursively convert string lat/lng values to floats in API results.
-
- Any dict at any nesting level that has exactly two keys 'lat' and 'lng'
- will have its values converted to floats.
-
- Args:
- input_value: A dict, list, or scalar from the API response.
-
- Returns:
- The same structure with lat/lng string values converted to floats.
- """
- if isinstance(input_value, collections.abc.Mapping):
- if len(input_value) == 2 and sorted(input_value.keys()) == ['lat', 'lng']:
- # This dict has only 2 keys 'lat' & 'lon'
- return {'lat': float_if_float(input_value["lat"]), 'lng': float_if_float(input_value["lng"])}
-
- return dict((key, floatify_latlng(value)) for key, value in input_value.items())
-
- if isinstance(input_value, collections.abc.MutableSequence):
- return [floatify_latlng(x) for x in input_value]
-
- return input_value
diff --git a/opencage/version.py b/opencage/version.py
deleted file mode 100644
index 6093f83..0000000
--- a/opencage/version.py
+++ /dev/null
@@ -1 +0,0 @@
-__version__ = '3.3.2'
diff --git a/opencage_cli/__init__.py b/opencage_cli/__init__.py
new file mode 100644
index 0000000..467d467
--- /dev/null
+++ b/opencage_cli/__init__.py
@@ -0,0 +1,6 @@
+"""Command-line interface for the OpenCage Geocoding API."""
+
+from .version import __version__
+
+__author__ = "OpenCage GmbH"
+__email__ = "support@opencagedata.com"
diff --git a/opencage/batch.py b/opencage_cli/batch.py
similarity index 88%
rename from opencage/batch.py
rename to opencage_cli/batch.py
index 9385065..2180107 100644
--- a/opencage/batch.py
+++ b/opencage_cli/batch.py
@@ -1,22 +1,19 @@
import sys
import ssl
import asyncio
+import collections
import traceback
import threading
import random
import json
from contextlib import suppress
+from decimal import Decimal
from urllib.parse import urlencode
from tqdm import tqdm
import certifi
import backoff
-from opencage.geocoder import (
- OpenCageGeocode,
- OpenCageGeocodeError,
- _query_for_reverse_geocoding,
- floatify_latlng
-)
+from opencage.geocoder import OpenCageGeocode, OpenCageGeocodeError
class OpenCageBatchGeocoder():
@@ -184,7 +181,7 @@ async def read_one_line(self, row, row_id):
# _query_for_reverse_geocoding attempts to convert into numbers. We rather have it fail
# now than during the actual geocoding
try:
- _query_for_reverse_geocoding(address[0], address[1])
+ self._query_for_reverse_geocoding(address[0], address[1])
except BaseException:
self.log(
f"Line {row_id} - Does not look like latitude and longitude: '{address[0]}' and '{address[1]}'")
@@ -250,10 +247,10 @@ async def _geocode_one_address():
if ',' in address:
lon, lat = address.split(',')
response = await geocoder.reverse_geocode_async(lon, lat, **params)
- geocoding_results = floatify_latlng(response['results'])
+ geocoding_results = self._floatify_latlng(response['results'])
else:
response = await geocoder.geocode_async(address, **params)
- geocoding_results = floatify_latlng(response['results'])
+ geocoding_results = self._floatify_latlng(response['results'])
except OpenCageGeocodeError as exc:
self.log(str(exc))
except Exception as exc:
@@ -374,3 +371,39 @@ def deep_get_result_value(self, data, keys, default=None):
else:
return default
return data
+
+ def _query_for_reverse_geocoding(self, lat, lng):
+ """Format a (lat, lng) pair as the string the API expects for reverse geocoding.
+
+ Uses ``Decimal(str(...))`` to preserve the precision the caller specified
+ and to avoid scientific notation (e.g. ``1e-5``).
+ """
+ return f"{Decimal(str(lat)):f},{Decimal(str(lng)):f}"
+
+ def _floatify_latlng(self, input_value):
+ """Recursively convert ``{'lat': ..., 'lng': ...}`` dicts to floats.
+
+ Walks lists and dicts; any dict that contains exactly the two keys
+ ``lat`` and ``lng`` is rewritten with float values. Other structures
+ pass through unchanged. Works around the API occasionally returning
+ lat/lng as strings.
+ """
+ if isinstance(input_value, collections.abc.Mapping):
+ if len(input_value) == 2 and sorted(input_value.keys()) == ['lat', 'lng']:
+ return {
+ 'lat': self._float_if_float(input_value['lat']),
+ 'lng': self._float_if_float(input_value['lng']),
+ }
+
+ return dict((key, self._floatify_latlng(value)) for key, value in input_value.items())
+
+ if isinstance(input_value, collections.abc.MutableSequence):
+ return [self._floatify_latlng(x) for x in input_value]
+
+ return input_value
+
+ def _float_if_float(self, value):
+ try:
+ return float(value)
+ except ValueError:
+ return value
diff --git a/opencage/command_line.py b/opencage_cli/command_line.py
similarity index 97%
rename from opencage/command_line.py
rename to opencage_cli/command_line.py
index 3ffee99..341a438 100644
--- a/opencage/command_line.py
+++ b/opencage_cli/command_line.py
@@ -5,8 +5,8 @@
import re
import csv
-from opencage.batch import OpenCageBatchGeocoder
-from opencage.version import __version__
+from opencage_cli.batch import OpenCageBatchGeocoder
+from opencage_cli.version import __version__
def main(args=sys.argv[1:]):
@@ -123,7 +123,7 @@ def add_optional_arguments(parser):
parser.add_argument("--timeout", type=ranged_type(int, 1, 60), default=10,
help="Timeout in seconds (default 10)", metavar='')
parser.add_argument("--retries", type=ranged_type(int, 1, 60), default=10,
- help="Number of retries (default 5)", metavar='')
+ help="Number of retries (default 10)", metavar='')
parser.add_argument("--api-domain", type=str, default="api.opencagedata.com",
help="API domain (default api.opencagedata.com)", metavar='')
parser.add_argument("--optional-api-params", type=comma_separated_dict_type, default="",
diff --git a/opencage_cli/version.py b/opencage_cli/version.py
new file mode 100644
index 0000000..5becc17
--- /dev/null
+++ b/opencage_cli/version.py
@@ -0,0 +1 @@
+__version__ = "1.0.0"
diff --git a/pyproject.toml b/pyproject.toml
index b182c35..bba52d7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,16 +3,16 @@ requires = ["setuptools>=65.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
-name = "opencage"
+name = "opencage-cli"
dynamic = ["version"]
-description = "Wrapper module for the OpenCage Geocoder API"
+description = "Command-line tool for the OpenCage Geocoding API"
readme = "README.md"
requires-python = ">=3.9"
license = "BSD-3-Clause"
authors = [{name = "OpenCage GmbH", email = "info@opencagedata.com"}]
-keywords = ["geocoding", "geocoder"]
+keywords = ["geocoding", "geocoder", "cli", "command-line", "batch"]
classifiers = [
- "Environment :: Web Environment",
+ "Environment :: Console",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
@@ -27,11 +27,10 @@ classifiers = [
"Topic :: Utilities",
]
dependencies = [
- "Requests>=2.31.0",
- "backoff>=2.2.1",
+ "opencage>=3.3.1",
"tqdm>=4.66.4",
+ "backoff>=2.2.1",
"certifi>=2024.07.04",
- "aiohttp>=3.10.5",
]
[project.optional-dependencies]
@@ -44,18 +43,18 @@ dev = [
]
[project.scripts]
-opencage = "opencage.command_line:main"
+opencage = "opencage_cli.command_line:main"
[project.urls]
-Repository = "https://github.com/OpenCageData/python-opencage-geocoder"
-Download = "https://github.com/OpenCageData/python-opencage-geocoder"
+Repository = "https://github.com/OpenCageData/opencage-cli"
+Download = "https://github.com/OpenCageData/opencage-cli"
[tool.setuptools.dynamic]
-version = {attr = "opencage.version.__version__"}
+version = {attr = "opencage_cli.version.__version__"}
[tool.coverage.run]
branch = true
-source = ["opencage"]
+source = ["opencage_cli"]
[tool.coverage.report]
show_missing = true
diff --git a/test/cli/test_cli_args.py b/test/cli/test_cli_args.py
index 43011f4..28b7726 100644
--- a/test/cli/test_cli_args.py
+++ b/test/cli/test_cli_args.py
@@ -1,8 +1,8 @@
import pathlib
import pytest
-from opencage.version import __version__
+from opencage_cli.version import __version__
-from opencage.command_line import parse_args
+from opencage_cli.command_line import parse_args
@pytest.fixture(autouse=True)
diff --git a/test/cli/test_cli_run.py b/test/cli/test_cli_run.py
index 89fbb27..f4c03cd 100644
--- a/test/cli/test_cli_run.py
+++ b/test/cli/test_cli_run.py
@@ -2,7 +2,7 @@
import os
import pytest
-from opencage.command_line import main
+from opencage_cli.command_line import main
# NOTE: Testing keys https://opencagedata.com/api#testingkeys
TEST_APIKEY_200 = '6d0e711d72d74daeb2b0bfd2a5cdfdba' # always returns same address
diff --git a/test/fixtures/401_not_authorized.json b/test/fixtures/401_not_authorized.json
deleted file mode 100644
index 564187a..0000000
--- a/test/fixtures/401_not_authorized.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "documentation": "https://opencagedata.com/api",
- "licenses": [{
- "name": "see attribution guide",
- "url": "https://opencagedata.com/credits"
- }],
- "results": [],
- "status": {
- "code": 401,
- "message": "invalid API key"
- },
- "stay_informed": {
- "blog": "https://blog.opencagedata.com",
- "twitter": "https://twitter.com/opencagedata"
- },
- "thanks": "For using an OpenCage Data API",
- "timestamp": {
- "created_http": "Sun, 09 Jun 2019 19:58:46 GMT",
- "created_unix": 1560110326
- },
- "total_results": 0
-}
\ No newline at end of file
diff --git a/test/fixtures/402_rate_limit_exceeded.json b/test/fixtures/402_rate_limit_exceeded.json
deleted file mode 100644
index 0449419..0000000
--- a/test/fixtures/402_rate_limit_exceeded.json
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- "documentation": "https://opencagedata.com/api",
- "licenses": [
- {
- "name": "see attribution guide",
- "url": "https://opencagedata.com/credits"
- }
- ],
- "rate": {
- "limit": 2500,
- "remaining": 0,
- "reset": 1615161600
- },
- "results": [],
- "status": {
- "become_a_customer": "https://opencagedata.com/pricing",
- "code": 402,
- "message": "quota exceeded"
- },
- "stay_informed": {
- "blog": "https://blog.opencagedata.com",
- "twitter": "https://twitter.com/OpenCage"
- },
- "thanks": "For using an OpenCage API",
- "timestamp": {
- "created_http": "Sun, 07 Mar 2021 01:18:00 GMT",
- "created_unix": 1615079880
- },
- "total_results": 0
-}
\ No newline at end of file
diff --git a/test/fixtures/403_apikey_disabled.json b/test/fixtures/403_apikey_disabled.json
deleted file mode 100644
index 1d1dcd6..0000000
--- a/test/fixtures/403_apikey_disabled.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "documentation": "https://opencagedata.com/api",
- "licenses": [
- {
- "name": "see attribution guide",
- "url": "https://opencagedata.com/credits"
- }
- ],
- "results": [],
- "status": {
- "code": 403,
- "message": "disabled"
- },
- "stay_informed": {
- "blog": "https://blog.opencagedata.com",
- "twitter": "https://twitter.com/OpenCage"
- },
- "thanks": "For using an OpenCage API",
- "timestamp": {
- "created_http": "Sun, 07 Mar 2021 01:19:06 GMT",
- "created_unix": 1615079946
- },
- "total_results": 0
-}
\ No newline at end of file
diff --git a/test/fixtures/badssl-com-chain.pem b/test/fixtures/badssl-com-chain.pem
deleted file mode 100644
index 8be3165..0000000
--- a/test/fixtures/badssl-com-chain.pem
+++ /dev/null
@@ -1,90 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIE8DCCA9igAwIBAgISA4mqZntfCH8MYyIVqUF1XZgpMA0GCSqGSIb3DQEBCwUA
-MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD
-EwJSMzAeFw0yMzEwMTkxNTUwMjlaFw0yNDAxMTcxNTUwMjhaMBcxFTATBgNVBAMM
-DCouYmFkc3NsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONj
-dsqxZsR+pDzWX6GLCy6ImoAT60LNYvs9U6BIQ+fatIWbMELAFD6jY+IP25hrVEr1
-bgwRWmAAOnUc2qKXdtx6KXXO3cAJoCSHFNBDEZqzg/+exj+3emQH8dVZiYAS2Rpd
-nL9uKc3xgDDb74p1m7J4JdMewHmebRUmMt0MbA0f8sxvhbv9wIXkgAZd6dKYPGzJ
-KJlCoQifPiJ66JwYk8WVGEJH9m8LNDse388MscfsuwvAAh9tt2Fq6rmV9s21P6qf
-JgjePl65e8fVjsEWBAvC/aMYvTUs7Gdqej0qByjESpt1LZClNomJDvIgqA9+5KsU
-yCXigT6OiPjtZhdhgw0CAwEAAaOCAhkwggIVMA4GA1UdDwEB/wQEAwIFoDAdBgNV
-HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4E
-FgQUqryJ2HM8bXniU+mPXLakkgUQobMwHwYDVR0jBBgwFoAUFC6zF7dYVsuuUAlA
-5h+vnYsUwsYwVQYIKwYBBQUHAQEESTBHMCEGCCsGAQUFBzABhhVodHRwOi8vcjMu
-by5sZW5jci5vcmcwIgYIKwYBBQUHMAKGFmh0dHA6Ly9yMy5pLmxlbmNyLm9yZy8w
-IwYDVR0RBBwwGoIMKi5iYWRzc2wuY29tggpiYWRzc2wuY29tMBMGA1UdIAQMMAow
-CAYGZ4EMAQIBMIIBAwYKKwYBBAHWeQIEAgSB9ASB8QDvAHUA2ra/az+1tiKfm8K7
-XGvocJFxbLtRhIU0vaQ9MEjX+6sAAAGLSNh8nwAABAMARjBEAiBkJnQowOqs+tDj
-7qXXu0PlDCvgvtEemuw1OvInlaHSrAIgcCZV5dJmGVrS1voinEpAzScJejhGB0vb
-G8dfKhJZD+wAdgA7U3d1Pi25gE6LMFsG/kA7Z9hPw/THvQANLXJv4frUFwAAAYtI
-2HyZAAAEAwBHMEUCIQCJ+gamX0P/hGiIuu70hn8d0svHSOAMJs3D+eOjMVqsywIg
-JXR/lAknUTRU+SyfySDoQ22bDSXfYWZGHLFgAkiRo48wDQYJKoZIhvcNAQELBQAD
-ggEBAGE3PDg7p2N8aZyAyO0pGVb/ob9opu12g+diNIdRSjsKIE+TO3uClM2OxT0t
-5GBz6Owbe010MQtqBKmX4Zm2LSLUm1kVhPh2ohWmA4hTyN3RG5W0IJ3red6VjrJY
-URhZQoXQb0gonxMs+zC+4GQ7+yqzWA1UkrWrURjjJCuljyoWF9sE7qEweomSQWnV
-v6bIF599/di1R2l5vcRq1DsQDgKaFY4IpKnvh3RhgO19YxlSS9ERRGBem3Aml9tb
-Yac12RmyuxsEAr0v75YeL3pAuq/1Rd5OeKfkm+K06Px3LxwcF92RljXkH6T2U8VM
-PEFKedHjYjAag3DUMqSuuGI+ONU=
------END CERTIFICATE-----
------BEGIN CERTIFICATE-----
-MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw
-TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
-cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw
-WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
-RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
-AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP
-R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx
-sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm
-NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg
-Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG
-/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC
-AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB
-Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA
-FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw
-AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw
-Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB
-gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W
-PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl
-ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz
-CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm
-lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4
-avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2
-yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O
-yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids
-hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+
-HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv
-MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX
-nLRbwHOoq7hHwg==
------END CERTIFICATE-----
------BEGIN CERTIFICATE-----
-MIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/
-MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
-DkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow
-TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
-cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwggIiMA0GCSqGSIb3DQEB
-AQUAA4ICDwAwggIKAoICAQCt6CRz9BQ385ueK1coHIe+3LffOJCMbjzmV6B493XC
-ov71am72AE8o295ohmxEk7axY/0UEmu/H9LqMZshftEzPLpI9d1537O4/xLxIZpL
-wYqGcWlKZmZsj348cL+tKSIG8+TA5oCu4kuPt5l+lAOf00eXfJlII1PoOK5PCm+D
-LtFJV4yAdLbaL9A4jXsDcCEbdfIwPPqPrt3aY6vrFk/CjhFLfs8L6P+1dy70sntK
-4EwSJQxwjQMpoOFTJOwT2e4ZvxCzSow/iaNhUd6shweU9GNx7C7ib1uYgeGJXDR5
-bHbvO5BieebbpJovJsXQEOEO3tkQjhb7t/eo98flAgeYjzYIlefiN5YNNnWe+w5y
-sR2bvAP5SQXYgd0FtCrWQemsAXaVCg/Y39W9Eh81LygXbNKYwagJZHduRze6zqxZ
-Xmidf3LWicUGQSk+WT7dJvUkyRGnWqNMQB9GoZm1pzpRboY7nn1ypxIFeFntPlF4
-FQsDj43QLwWyPntKHEtzBRL8xurgUBN8Q5N0s8p0544fAQjQMNRbcTa0B7rBMDBc
-SLeCO5imfWCKoqMpgsy6vYMEG6KDA0Gh1gXxG8K28Kh8hjtGqEgqiNx2mna/H2ql
-PRmP6zjzZN7IKw0KKP/32+IVQtQi0Cdd4Xn+GOdwiK1O5tmLOsbdJ1Fu/7xk9TND
-TwIDAQABo4IBRjCCAUIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw
-SwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5pZGVudHJ1
-c3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTEp7Gkeyxx
-+tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEB
-ATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQu
-b3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0LmNvbS9E
-U1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFHm0WeZ7tuXkAXOACIjIGlj26Ztu
-MA0GCSqGSIb3DQEBCwUAA4IBAQAKcwBslm7/DlLQrt2M51oGrS+o44+/yQoDFVDC
-5WxCu2+b9LRPwkSICHXM6webFGJueN7sJ7o5XPWioW5WlHAQU7G75K/QosMrAdSW
-9MUgNTP52GE24HGNtLi1qoJFlcDyqSMo59ahy2cI2qBDLKobkx/J3vWraV0T9VuG
-WCLKTVXkcGdtwlfFRjlBz4pYg1htmf5X6DYO8A4jqv2Il9DjXA6USbW1FzXSLr9O
-he8Y4IWS6wY7bCkjCWDcRQJMEhg76fsO3txE+FiYruq9RUWhiF1myv4Q6W+CyBFC
-Dfvp7OOGAN6dEOM4+qR9sdjoSYKEBpsr6GtPAQw4dy753ec5
------END CERTIFICATE-----
diff --git a/test/fixtures/cli/output.csv2 b/test/fixtures/cli/output.csv2
deleted file mode 100644
index 30942f9..0000000
--- a/test/fixtures/cli/output.csv2
+++ /dev/null
@@ -1,2 +0,0 @@
-id,full_address,lat,lng,postcode
-123,NOWHERE-INTERESTING,51.9526622,7.6324709,48153
diff --git a/test/fixtures/donostia.json b/test/fixtures/donostia.json
deleted file mode 100644
index c721a04..0000000
--- a/test/fixtures/donostia.json
+++ /dev/null
@@ -1,313 +0,0 @@
-{
- "documentation": "https://opencagedata.com/api",
- "licenses": [
- {
- "name": "see attribution guide",
- "url": "https://opencagedata.com/credits"
- }
- ],
- "rate": {
- "limit": 2500,
- "remaining": 2498,
- "reset": 1615161600
- },
- "results": [
- {
- "bounds": {
- "northeast": {
- "lat": 43.3381594,
- "lng": -1.8878839
- },
- "southwest": {
- "lat": 43.2178373,
- "lng": -2.0868082
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "ES",
- "ISO_3166-1_alpha-3": "ESP",
- "_category": "place",
- "_type": "city",
- "city": "San Sebastián",
- "continent": "Europe",
- "country": "Spain",
- "country_code": "es",
- "municipality": "Donostialdea",
- "political_union": "European Union",
- "province": "Gipuzkoa",
- "state": "Autonomous Community of the Basque Country",
- "state_code": "PV"
- },
- "confidence": 6,
- "formatted": "San Sebastián, Autonomous Community of the Basque Country, Spain",
- "geometry": {
- "lat": 43.3224219,
- "lng": -1.9838889
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 43.3235144,
- "lng": -1.987835
- },
- "southwest": {
- "lat": 43.3227734,
- "lng": -1.9895938
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "ES",
- "ISO_3166-1_alpha-3": "ESP",
- "_category": "natural/water",
- "_type": "water",
- "continent": "Europe",
- "country": "Spain",
- "country_code": "es",
- "political_union": "European Union",
- "suburb": "Parte Zaharra",
- "water": "Donostia"
- },
- "confidence": 9,
- "formatted": "Donostia, Parte Zaharra, Spain",
- "geometry": {
- "lat": 43.3232109,
- "lng": -1.9884685
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 43.3449618,
- "lng": -1.8013445
- },
- "southwest": {
- "lat": 43.3422299,
- "lng": -1.8022745
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "ES",
- "ISO_3166-1_alpha-3": "ESP",
- "_category": "road",
- "_type": "road",
- "continent": "Europe",
- "country": "Spain",
- "country_code": "es",
- "county": "Bidasoa Beherea / Bajo Bidasoa",
- "hamlet": "Pinar",
- "political_union": "European Union",
- "postcode": "20301",
- "province": "Gipuzkoa",
- "road": "Donostia",
- "road_type": "residential",
- "state": "Autonomous Community of the Basque Country",
- "state_code": "PV",
- "town": "Irun"
- },
- "confidence": 9,
- "formatted": "Donostia, 20301 Irun, Spain",
- "geometry": {
- "lat": 43.3432825,
- "lng": -1.8019222
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 42.6739972,
- "lng": 3.0325751
- },
- "southwest": {
- "lat": 42.6730864,
- "lng": 3.0318899
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "FR",
- "ISO_3166-1_alpha-3": "FRA",
- "_category": "road",
- "_type": "road",
- "continent": "Europe",
- "country": "France",
- "country_code": "fr",
- "county": "Pyrénées-Orientales",
- "municipality": "Perpignan",
- "political_union": "European Union",
- "postcode": "66140",
- "quarter": "L'Aviation",
- "road": "Donostia",
- "state": "Occitania",
- "state_code": "OCC",
- "town": "Canet-en-Roussillon"
- },
- "confidence": 9,
- "formatted": "Donostia, 66140 Canet-en-Roussillon, France",
- "geometry": {
- "lat": 42.6734947,
- "lng": 3.0322839
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": -31.312091,
- "lng": -64.4982232
- },
- "southwest": {
- "lat": -31.3143529,
- "lng": -64.5032543
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "AR",
- "ISO_3166-1_alpha-3": "ARG",
- "_category": "road",
- "_type": "road",
- "continent": "South America",
- "country": "Argentina",
- "country_code": "ar",
- "county": "Pedanía Rosario",
- "road": "Donostia",
- "road_type": "residential",
- "state": "Córdoba",
- "state_code": "X",
- "state_district": "Departamento Punilla",
- "village": "Bialet Massé"
- },
- "confidence": 9,
- "formatted": "Donostia, Departamento Punilla, Bialet Massé, Argentina",
- "geometry": {
- "lat": -31.3129419,
- "lng": -64.5002494
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 43.3191704,
- "lng": -2.0063319
- },
- "southwest": {
- "lat": 43.3190704,
- "lng": -2.0064319
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "ES",
- "ISO_3166-1_alpha-3": "ESP",
- "_category": "transportation",
- "_type": "railway",
- "city": "San Sebastián",
- "continent": "Europe",
- "country": "Spain",
- "country_code": "es",
- "municipality": "Donostialdea",
- "political_union": "European Union",
- "postcode": "20008",
- "province": "Gipuzkoa",
- "railway": "Donostia",
- "road": "Funikularraren Plaza",
- "state": "Autonomous Community of the Basque Country",
- "state_code": "PV",
- "suburb": "Antiguo"
- },
- "confidence": 9,
- "formatted": "Donostia, Funikularraren Plaza, 20008 San Sebastián, Spain",
- "geometry": {
- "lat": 43.3191204,
- "lng": -2.0063819
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 51.5147517,
- "lng": -0.160937
- },
- "southwest": {
- "lat": 51.5146517,
- "lng": -0.161037
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "GB",
- "ISO_3166-1_alpha-3": "GBR",
- "_category": "commerce",
- "_type": "restaurant",
- "city": "London",
- "continent": "Europe",
- "country": "United Kingdom",
- "country_code": "gb",
- "county": "Westminster",
- "county_code": "WSM",
- "house_number": "10",
- "postcode": "W1H 7JN",
- "restaurant": "Donostia",
- "road": "Seymour Place",
- "state": "England",
- "state_code": "ENG",
- "state_district": "Greater London",
- "suburb": "Marylebone"
- },
- "confidence": 9,
- "formatted": "Donostia, 10 Seymour Place, London W1H 7JN, United Kingdom",
- "geometry": {
- "lat": 51.5147017,
- "lng": -0.160987
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 44.83445,
- "lng": -0.5666749
- },
- "southwest": {
- "lat": 44.83435,
- "lng": -0.5667749
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "FR",
- "ISO_3166-1_alpha-3": "FRA",
- "_category": "commerce",
- "_type": "restaurant",
- "city": "Bordeaux",
- "continent": "Europe",
- "country": "France",
- "country_code": "fr",
- "county": "Gironde",
- "house_number": "21",
- "municipality": "Bordeaux",
- "political_union": "European Union",
- "postcode": "33800",
- "restaurant": "Donostia",
- "road": "Place Meynard",
- "state": "New Aquitaine",
- "state_code": "NAQ",
- "suburb": "Saint-Michel"
- },
- "confidence": 9,
- "formatted": "Donostia, 21 Place Meynard, 33800 Bordeaux, France",
- "geometry": {
- "lat": 44.8344,
- "lng": -0.5667249
- }
- }
- ],
- "status": {
- "code": 200,
- "message": "OK"
- },
- "stay_informed": {
- "blog": "https://blog.opencagedata.com",
- "twitter": "https://twitter.com/OpenCage"
- },
- "thanks": "For using an OpenCage API",
- "timestamp": {
- "created_http": "Sun, 07 Mar 2021 01:12:01 GMT",
- "created_unix": 1615079521
- },
- "total_results": 8
-}
\ No newline at end of file
diff --git a/test/fixtures/mudgee_australia.json b/test/fixtures/mudgee_australia.json
deleted file mode 100644
index 4e31faf..0000000
--- a/test/fixtures/mudgee_australia.json
+++ /dev/null
@@ -1,85 +0,0 @@
-{
- "documentation": "https://opencagedata.com/api",
- "licenses": [
- {
- "name": "see attribution guide",
- "url": "https://opencagedata.com/credits"
- }
- ],
- "rate": {
- "limit": 2500,
- "remaining": 2496,
- "reset": 1615161600
- },
- "results": [
- {
- "components": {
- "ISO_3166-1_alpha-2": "AU",
- "ISO_3166-1_alpha-3": "AUS",
- "_category": "building",
- "_type": "building",
- "city": "MUDGEE",
- "continent": "Oceania",
- "country": "Australia",
- "country_code": "au",
- "house_number": "46",
- "postcode": "2850",
- "state": "NEW SOUTH WALES",
- "state_code": "NSW",
- "street": "MARKET ST"
- },
- "confidence": 10,
- "formatted": "46 MARKET ST, MUDGEE NSW 2850, Australia",
- "geometry": {
- "lat": -32.59086,
- "lng": 149.5897858
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": -32.5713535,
- "lng": 149.6091869
- },
- "southwest": {
- "lat": -32.6539627,
- "lng": 149.5470486
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "AU",
- "ISO_3166-1_alpha-3": "AUS",
- "_category": "place",
- "_type": "city",
- "continent": "Oceania",
- "country": "Australia",
- "country_code": "au",
- "municipality": "Mid-Western Regional Council",
- "postcode": "2850",
- "state": "New South Wales",
- "state_code": "NSW",
- "town": "Mudgee"
- },
- "confidence": 7,
- "formatted": "Mudgee NSW 2850, Australia",
- "geometry": {
- "lat": -32.5980702,
- "lng": 149.5886383
- }
- }
- ],
- "status": {
- "code": 200,
- "message": "OK"
- },
- "stay_informed": {
- "blog": "https://blog.opencagedata.com",
- "twitter": "https://twitter.com/OpenCage"
- },
- "thanks": "For using an OpenCage API",
- "timestamp": {
- "created_http": "Sun, 07 Mar 2021 01:14:09 GMT",
- "created_unix": 1615079649
- },
- "total_results": 2
-}
\ No newline at end of file
diff --git a/test/fixtures/muenster.json b/test/fixtures/muenster.json
deleted file mode 100644
index 927204e..0000000
--- a/test/fixtures/muenster.json
+++ /dev/null
@@ -1,359 +0,0 @@
-{
- "documentation": "https://opencagedata.com/api",
- "licenses": [
- {
- "name": "see attribution guide",
- "url": "https://opencagedata.com/credits"
- }
- ],
- "rate": {
- "limit": 2500,
- "remaining": 2497,
- "reset": 1615161600
- },
- "results": [
- {
- "bounds": {
- "northeast": {
- "lat": 52.0600251,
- "lng": 7.7743634
- },
- "southwest": {
- "lat": 51.8401448,
- "lng": 7.4737853
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "DE",
- "ISO_3166-1_alpha-3": "DEU",
- "_category": "place",
- "_type": "city",
- "city": "Münster",
- "continent": "Europe",
- "country": "Germany",
- "country_code": "de",
- "political_union": "European Union",
- "state": "North Rhine-Westphalia",
- "state_code": "NW"
- },
- "confidence": 4,
- "formatted": "Münster, North Rhine-Westphalia, Germany",
- "geometry": {
- "lat": 51.9625101,
- "lng": 7.6251879
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 53.1689062,
- "lng": -6.949942
- },
- "southwest": {
- "lat": 51.388867,
- "lng": -10.6626169
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "IE",
- "ISO_3166-1_alpha-3": "IRL",
- "_category": "place",
- "_type": "state",
- "continent": "Europe",
- "country": "Ireland",
- "country_code": "ie",
- "political_union": "European Union",
- "state": "Munster",
- "state_code": "M"
- },
- "confidence": 1,
- "formatted": "Munster, Ireland",
- "geometry": {
- "lat": 52.3076216,
- "lng": -8.5708973
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 48.9209128,
- "lng": 6.9284105
- },
- "southwest": {
- "lat": 48.8961288,
- "lng": 6.8640003
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "FR",
- "ISO_3166-1_alpha-3": "FRA",
- "_category": "place",
- "_type": "village",
- "continent": "Europe",
- "country": "France",
- "country_code": "fr",
- "county": "Moselle",
- "municipality": "Sarrebourg-Château-Salins",
- "political_union": "European Union",
- "postcode": "57670",
- "state": "Grand Est",
- "state_code": "GES",
- "village": "Munster"
- },
- "confidence": 7,
- "formatted": "57670 Munster, France",
- "geometry": {
- "lat": 48.9154719,
- "lng": 6.9037077
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 49.4626882,
- "lng": 10.0571749
- },
- "southwest": {
- "lat": 49.4226882,
- "lng": 10.0171749
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "DE",
- "ISO_3166-1_alpha-3": "DEU",
- "_category": "place",
- "_type": "village",
- "continent": "Europe",
- "country": "Germany",
- "country_code": "de",
- "county": "Main-Tauber-Kreis",
- "political_union": "European Union",
- "postcode": "97993",
- "state": "Baden-Württemberg",
- "state_code": "BW",
- "town": "Creglingen",
- "village": "Münster"
- },
- "confidence": 7,
- "formatted": "97993 Creglingen, Germany",
- "geometry": {
- "lat": 49.4426882,
- "lng": 10.0371749
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 48.0646256,
- "lng": 7.1665754
- },
- "southwest": {
- "lat": 48.0286031,
- "lng": 7.0964409
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "FR",
- "ISO_3166-1_alpha-3": "FRA",
- "_category": "place",
- "_type": "city",
- "continent": "Europe",
- "country": "France",
- "country_code": "fr",
- "county": "Haut-Rhin",
- "municipality": "Colmar-Ribeauvillé",
- "political_union": "European Union",
- "postcode": "68140",
- "state": "Grand Est",
- "state_code": "GES",
- "town": "Munster"
- },
- "confidence": 7,
- "formatted": "68140 Munster, France",
- "geometry": {
- "lat": 48.0408618,
- "lng": 7.1371568
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 48.6515558,
- "lng": 10.9314006
- },
- "southwest": {
- "lat": 48.5841708,
- "lng": 10.8788966
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "DE",
- "ISO_3166-1_alpha-3": "DEU",
- "_category": "place",
- "_type": "village",
- "continent": "Europe",
- "country": "Germany",
- "country_code": "de",
- "county": "Landkreis Donau-Ries",
- "municipality": "Rain (Schwaben)",
- "political_union": "European Union",
- "postcode": "86692",
- "state": "Bavaria",
- "state_code": "BY",
- "village": "Münster"
- },
- "confidence": 7,
- "formatted": "86692 Münster, Germany",
- "geometry": {
- "lat": 48.6242219,
- "lng": 10.9008883
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 50.4101197,
- "lng": 8.6366094
- },
- "southwest": {
- "lat": 50.3701197,
- "lng": 8.5966094
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "DE",
- "ISO_3166-1_alpha-3": "DEU",
- "_category": "place",
- "_type": "village",
- "continent": "Europe",
- "country": "Germany",
- "country_code": "de",
- "county": "Wetteraukreis",
- "political_union": "European Union",
- "state": "Hesse",
- "state_code": "HE",
- "town": "Butzbach",
- "village": "Münster"
- },
- "confidence": 7,
- "formatted": "Butzbach, Hesse, Germany",
- "geometry": {
- "lat": 50.3901197,
- "lng": 8.6166094
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 47.4709858,
- "lng": 11.8640661
- },
- "southwest": {
- "lat": 47.4020668,
- "lng": 11.7729344
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "AT",
- "ISO_3166-1_alpha-3": "AUT",
- "_category": "place",
- "_type": "city",
- "city": "Gemeinde Münster",
- "continent": "Europe",
- "country": "Austria",
- "country_code": "at",
- "political_union": "European Union",
- "postcode": "6232",
- "region": "Bezirk Kufstein",
- "state": "Tyrol"
- },
- "confidence": 7,
- "formatted": "Gemeinde Münster, Tyrol, Austria",
- "geometry": {
- "lat": 47.4366555,
- "lng": 11.8134682
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 46.9474256,
- "lng": 7.452171
- },
- "southwest": {
- "lat": 46.9470562,
- "lng": 7.4510185
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "CH",
- "ISO_3166-1_alpha-3": "CHE",
- "_category": "place_of_worship",
- "_type": "place_of_worship",
- "city": "Bern",
- "city_district": "Stadtteil I",
- "continent": "Europe",
- "country": "Switzerland",
- "country_code": "ch",
- "county": "Bern-Mittelland administrative district",
- "place_of_worship": "Münster",
- "postcode": "3011",
- "quarter": "Old City",
- "road": "Münsterplatz",
- "state": "Bern",
- "state_code": "BE",
- "state_district": "Bernese Mittelland administrative region"
- },
- "confidence": 9,
- "formatted": "Münster, Münsterplatz, 3011 Bern, Switzerland",
- "geometry": {
- "lat": 46.9472379,
- "lng": 7.4515787
- }
- },
- {
- "bounds": {
- "northeast": {
- "lat": 52.1952705,
- "lng": -104.9807394
- },
- "southwest": {
- "lat": 52.1874551,
- "lng": -105.0067561
- }
- },
- "components": {
- "ISO_3166-1_alpha-2": "CA",
- "ISO_3166-1_alpha-3": "CAN",
- "_category": "place",
- "_type": "county",
- "continent": "North America",
- "country": "Canada",
- "country_code": "ca",
- "county": "Muenster",
- "state": "Saskatchewan",
- "state_code": "SK"
- },
- "confidence": 8,
- "formatted": "SK, Canada",
- "geometry": {
- "lat": 52.1925528,
- "lng": -104.9937505
- }
- }
- ],
- "status": {
- "code": 200,
- "message": "OK"
- },
- "stay_informed": {
- "blog": "https://blog.opencagedata.com",
- "twitter": "https://twitter.com/OpenCage"
- },
- "thanks": "For using an OpenCage API",
- "timestamp": {
- "created_http": "Sun, 07 Mar 2021 01:13:17 GMT",
- "created_unix": 1615079597
- },
- "total_results": 10
-}
\ No newline at end of file
diff --git a/test/fixtures/no_ratelimit.json b/test/fixtures/no_ratelimit.json
deleted file mode 100644
index d2ab5a1..0000000
--- a/test/fixtures/no_ratelimit.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "documentation": "https://opencagedata.com/api",
- "licenses": [
- {
- "name": "see attribution guide",
- "url": "https://opencagedata.com/credits"
- }
- ],
- "results": [
- ],
- "status": {
- "code": 200,
- "message": "OK"
- },
- "stay_informed": {
- "blog": "https://blog.opencagedata.com",
- "twitter": "https://twitter.com/OpenCage"
- },
- "thanks": "For using an OpenCage API",
- "timestamp": {
- "created_http": "Sun, 07 Mar 2021 01:13:17 GMT",
- "created_unix": 1615079597
- },
- "total_results": 0
-}
\ No newline at end of file
diff --git a/test/fixtures/uk_postcode.json b/test/fixtures/uk_postcode.json
deleted file mode 100644
index 822f35e..0000000
--- a/test/fixtures/uk_postcode.json
+++ /dev/null
@@ -1,301 +0,0 @@
-{
- "total_results": 10,
- "licenses": [{
- "name": "CC-BY-SA",
- "url": "http://creativecommons.org/licenses/by-sa/3.0/"
- }, {
- "name": "ODbL",
- "url": "http://opendatacommons.org/licenses/odbl/summary/"
- }],
- "status": {
- "message": "OK",
- "code": 200
- },
- "thanks": "For using an OpenCage Data API",
- "rate": {
- "limit": "2500",
- "remaining": 2487,
- "reset": 1402185600
- },
- "results": [{
- "annotations": {},
- "components": {
- "country_name": "United Kingdom",
- "region": "Islington",
- "locality": "Clerkenwell"
- },
- "formatted": "Clerkenwell, Islington, United Kingdom",
- "geometry": {
- "lat": "51.5221558691",
- "lng": "-0.100838524406"
- },
- "bounds": null
- }, {
- "formatted": "82, Lokku Ltd, Clerkenwell Road, Clerkenwell, London Borough of Islington, London, EC1M 5RF, Greater London, England, United Kingdom, gb",
- "components": {
- "county": "London",
- "state_district": "Greater London",
- "road": "Clerkenwell Road",
- "country_code": "gb",
- "house_number": "82",
- "country": "United Kingdom",
- "city": "London Borough of Islington",
- "suburb": "Clerkenwell",
- "state": "England",
- "house": "Lokku Ltd",
- "postcode": "EC1M 5RF"
- },
- "annotations": {},
- "bounds": {
- "northeast": {
- "lng": "-0.1023889",
- "lat": "51.5226795"
- },
- "southwest": {
- "lat": "51.5225795",
- "lng": "-0.1024889"
- }
- },
- "geometry": {
- "lat": "51.5226295",
- "lng": "-0.1024389"
- }
- }, {
- "components": {
- "county": "London",
- "state_district": "Greater London",
- "road": "Clerkenwell Road",
- "country_code": "gb",
- "country": "United Kingdom",
- "city": "London Borough of Islington",
- "suburb": "Clerkenwell",
- "state": "England",
- "postcode": "EC1M 6DS"
- },
- "annotations": {},
- "formatted": "Clerkenwell Road, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb",
- "geometry": {
- "lat": "51.5225346",
- "lng": "-0.1027003"
- },
- "bounds": {
- "northeast": {
- "lat": "51.5225759",
- "lng": "-0.1020597"
- },
- "southwest": {
- "lat": "51.5225211",
- "lng": "-0.103223"
- }
- }
- }, {
- "formatted": "Clerkenwell Road, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb, Craft Central",
- "annotations": {},
- "components": {
- "postcode": "EC1M 6DS",
- "arts_centre": "Craft Central",
- "state": "England",
- "suburb": "Clerkenwell",
- "country": "United Kingdom",
- "city": "London Borough of Islington",
- "country_code": "gb",
- "road": "Clerkenwell Road",
- "state_district": "Greater London",
- "county": "London"
- },
- "bounds": {
- "northeast": {
- "lat": "51.52246",
- "lng": "-0.1027652"
- },
- "southwest": {
- "lng": "-0.1028652",
- "lat": "51.52236"
- }
- },
- "geometry": {
- "lng": "-0.1028152",
- "lat": "51.52241"
- }
- }, {
- "components": {
- "county": "London",
- "state_district": "Greater London",
- "restaurant": "Noodle Express",
- "road": "Albemarle Way",
- "country_code": "gb",
- "country": "United Kingdom",
- "city": "London Borough of Islington",
- "suburb": "Clerkenwell",
- "state": "England",
- "postcode": "EC1M 6DS"
- },
- "annotations": {},
- "formatted": "Noodle Express, Albemarle Way, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb",
- "geometry": {
- "lng": "-0.10255386845056",
- "lat": "51.5228195"
- },
- "bounds": {
- "southwest": {
- "lng": "-0.102621",
- "lat": "51.5227781"
- },
- "northeast": {
- "lat": "51.5228603",
- "lng": "-0.1024869"
- }
- }
- }, {
- "geometry": {
- "lat": "51.5229424",
- "lng": "-0.102380530769224"
- },
- "bounds": {
- "northeast": {
- "lat": "51.5229759",
- "lng": "-0.1023064"
- },
- "southwest": {
- "lng": "-0.1024639",
- "lat": "51.5229046"
- }
- },
- "annotations": {},
- "components": {
- "county": "London",
- "state_district": "Greater London",
- "road": "Albemarle Way",
- "country_code": "gb",
- "cafe": "PAR",
- "country": "United Kingdom",
- "city": "London Borough of Islington",
- "suburb": "Clerkenwell",
- "state": "England",
- "postcode": "EC1M 6DS"
- },
- "formatted": "PAR, Albemarle Way, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb"
- }, {
- "formatted": "Workshop Coffee Co., 27, Clerkenwell Road, Clerkenwell, London Borough of Islington, London, EC1M 5RN, Greater London, England, United Kingdom, gb",
- "components": {
- "county": "London",
- "state_district": "Greater London",
- "road": "Clerkenwell Road",
- "country_code": "gb",
- "house_number": "27",
- "cafe": "Workshop Coffee Co.",
- "country": "United Kingdom",
- "city": "London Borough of Islington",
- "suburb": "Clerkenwell",
- "state": "England",
- "postcode": "EC1M 5RN"
- },
- "annotations": {},
- "bounds": {
- "southwest": {
- "lng": "-0.1024422",
- "lat": "51.5222246"
- },
- "northeast": {
- "lng": "-0.1022307",
- "lat": "51.5224408"
- }
- },
- "geometry": {
- "lat": "51.52234585",
- "lng": "-0.102338899572156"
- }
- }, {
- "components": {
- "county": "London",
- "state_district": "Greater London",
- "road": "St. John Street",
- "country_code": "gb",
- "country": "United Kingdom",
- "city": "London Borough of Islington",
- "suburb": "Clerkenwell",
- "hairdresser": "Franco & Co",
- "state": "England",
- "postcode": "EC1M 6DS"
- },
- "annotations": {},
- "formatted": "St. John Street, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb, Franco & Co",
- "geometry": {
- "lng": "-0.1024118",
- "lat": "51.5231165"
- },
- "bounds": {
- "southwest": {
- "lng": "-0.1024618",
- "lat": "51.5230665"
- },
- "northeast": {
- "lng": "-0.1023618",
- "lat": "51.5231665"
- }
- }
- }, {
- "bounds": {
- "northeast": {
- "lng": "-0.1023218",
- "lat": "51.5231688"
- },
- "southwest": {
- "lat": "51.5229634",
- "lng": "-0.1024934"
- }
- },
- "geometry": {
- "lng": "-0.102399365567707",
- "lat": "51.5230257"
- },
- "formatted": "St. John Street, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb, MacCarthy",
- "annotations": {},
- "components": {
- "county": "London",
- "state_district": "Greater London",
- "road": "St. John Street",
- "country_code": "gb",
- "country": "United Kingdom",
- "city": "London Borough of Islington",
- "suburb": "Clerkenwell",
- "hairdresser": "MacCarthy",
- "state": "England",
- "postcode": "EC1M 6DS"
- }
- }, {
- "geometry": {
- "lng": "-0.102730855172415",
- "lat": "51.52267345"
- },
- "bounds": {
- "northeast": {
- "lng": "-0.1025498",
- "lat": "51.5227315"
- },
- "southwest": {
- "lat": "51.5226068",
- "lng": "-0.1028931"
- }
- },
- "annotations": {},
- "components": {
- "county": "London",
- "state_district": "Greater London",
- "road": "Albemarle Way",
- "country_code": "gb",
- "house_number": "84",
- "country": "United Kingdom",
- "city": "London Borough of Islington",
- "suburb": "Clerkenwell",
- "state": "England",
- "house": "The Printworks",
- "postcode": "EC1M 6DS"
- },
- "formatted": "84, The Printworks, Albemarle Way, Clerkenwell, London Borough of Islington, London, EC1M 6DS, Greater London, England, United Kingdom, gb"
- }],
- "timestamp": {
- "created_unix": 1402133768,
- "created_http": "Sat, 07 Jun 2014 09:36:08 GMT"
- }
-}
\ No newline at end of file
diff --git a/test/test_all.py b/test/test_all.py
deleted file mode 100644
index 01b59d9..0000000
--- a/test/test_all.py
+++ /dev/null
@@ -1,77 +0,0 @@
-# encoding: utf-8
-
-from pathlib import Path
-
-import os
-import responses
-
-from opencage.geocoder import OpenCageGeocode
-
-# reduce maximum backoff retry time from 120s to 1s
-os.environ['BACKOFF_MAX_TIME'] = '1'
-
-
-geocoder = OpenCageGeocode('abcde')
-
-
-def _any_result_around(results, lat=None, lon=None):
- for result in results:
- if (abs(result['geometry']['lat'] - lat) < 0.05
- and abs(result['geometry']['lng'] - lon) < 0.05):
- return True
- return False
-
-
-@responses.activate
-def test_gb_postcode():
- responses.add(
- responses.GET,
- geocoder.url,
- body=Path('test/fixtures/uk_postcode.json').read_text(encoding="utf-8"),
- status=200
- )
-
- results = geocoder.geocode("EC1M 5RF")
- assert _any_result_around(results, lat=51.5201666, lon=-0.0985142)
-
-
-@responses.activate
-def test_australia():
- responses.add(
- responses.GET,
- geocoder.url,
- body=Path('test/fixtures/mudgee_australia.json').read_text(encoding="utf-8"),
- status=200
- )
-
- results = geocoder.geocode("Mudgee, Australia")
- assert _any_result_around(results, lat=-32.5980702, lon=149.5886383)
-
-
-@responses.activate
-def test_munster():
- responses.add(
- responses.GET,
- geocoder.url,
- body=Path('test/fixtures/muenster.json').read_text(encoding="utf-8"),
- status=200
- )
-
- results = geocoder.geocode("Münster")
- assert _any_result_around(results, lat=51.9625101, lon=7.6251879)
-
-
-@responses.activate
-def test_donostia():
- responses.add(
- responses.GET,
- geocoder.url,
- body=Path('test/fixtures/donostia.json').read_text(encoding="utf-8"),
- status=200
- )
-
- results = geocoder.geocode("Donostia")
- assert _any_result_around(results, lat=43.300836, lon=-1.9809529)
-
- # test that the results are in unicode
- assert results[0]['formatted'] == 'San Sebastián, Autonomous Community of the Basque Country, Spain'
diff --git a/test/test_async.py b/test/test_async.py
deleted file mode 100644
index 746a9a1..0000000
--- a/test/test_async.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# encoding: utf-8
-
-import pytest
-
-from opencage.geocoder import ForbiddenError, OpenCageGeocode, AioHttpError
-
-# NOTE: Testing keys https://opencagedata.com/api#testingkeys
-
-
-@pytest.mark.asyncio
-async def test_success():
- async with OpenCageGeocode('6d0e711d72d74daeb2b0bfd2a5cdfdba') as geocoder:
- results = await geocoder.geocode_async("EC1M 5RF")
- assert any(
- abs(result['geometry']['lat'] - 51.952659 < 0.05
- and abs(result['geometry']['lng'] - 7.632473) < 0.05)
- for result in results
- )
-
-
-@pytest.mark.asyncio
-async def test_failure():
- async with OpenCageGeocode('6c79ee8e1ca44ad58ad1fc493ba9542f') as geocoder:
- with pytest.raises(ForbiddenError) as excinfo:
- await geocoder.geocode_async("Atlantis")
-
- assert str(excinfo.value) == 'Your API key has been blocked or suspended.'
-
-
-@pytest.mark.asyncio
-async def test_without_async_session():
- geocoder = OpenCageGeocode('4372eff77b8343cebfc843eb4da4ddc4')
-
- with pytest.raises(AioHttpError) as excinfo:
- await geocoder.geocode_async("Atlantis")
-
- assert str(excinfo.value) == 'Async methods must be used inside an async context.'
-
-
-@pytest.mark.asyncio
-async def test_using_non_async_method():
- async with OpenCageGeocode('6d0e711d72d74daeb2b0bfd2a5cdfdba') as geocoder:
- with pytest.raises(AioHttpError) as excinfo:
- await geocoder.geocode("Atlantis")
-
- assert str(excinfo.value) == 'Cannot use `geocode` in an async context, use `geocode_async`.'
diff --git a/test/test_batch.py b/test/test_batch.py
index 3139dd5..d168d08 100644
--- a/test/test_batch.py
+++ b/test/test_batch.py
@@ -1,4 +1,4 @@
-from opencage.batch import OpenCageBatchGeocoder
+from opencage_cli.batch import OpenCageBatchGeocoder
batch = OpenCageBatchGeocoder({})
diff --git a/test/test_error_blocked.py b/test/test_error_blocked.py
deleted file mode 100644
index b55df6f..0000000
--- a/test/test_error_blocked.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from pathlib import Path
-
-import pytest
-import responses
-
-from opencage.geocoder import OpenCageGeocode
-from opencage.geocoder import ForbiddenError
-
-
-geocoder = OpenCageGeocode('2e10e5e828262eb243ec0b54681d699a') # will always return 403
-
-
-@responses.activate
-def test_api_key_blocked():
- responses.add(
- responses.GET,
- geocoder.url,
- body=Path('test/fixtures/403_apikey_disabled.json').read_text(encoding="utf-8"),
- status=403,
- )
-
- with pytest.raises(ForbiddenError) as excinfo:
- geocoder.geocode("whatever")
- assert str(excinfo.value) == 'Your API key has been blocked or suspended.'
diff --git a/test/test_error_invalid_input.py b/test/test_error_invalid_input.py
deleted file mode 100644
index fcaa5c2..0000000
--- a/test/test_error_invalid_input.py
+++ /dev/null
@@ -1,69 +0,0 @@
-import pytest
-
-import responses
-
-from opencage.geocoder import OpenCageGeocode
-from opencage.geocoder import InvalidInputError
-
-geocoder = OpenCageGeocode('abcde')
-
-
-@responses.activate
-def test_must_be_unicode_string():
- responses.add(
- responses.GET,
- geocoder.url,
- body='{"results":{}}',
- status=200
- )
-
- # Should not give errors
- geocoder.geocode('xxx') # ascii convertable
- geocoder.geocode('xxá') # unicode
-
- # But if it isn't a unicode string, it should give error
- utf8_string = "xxá".encode("utf-8")
- latin1_string = "xxá".encode("latin1")
-
- with pytest.raises(InvalidInputError) as excinfo:
- geocoder.geocode(utf8_string)
- assert str(excinfo.value) == f"Input must be a unicode string, not {utf8_string!r}"
- assert excinfo.value.bad_value == utf8_string
-
- with pytest.raises(InvalidInputError) as excinfo:
- geocoder.geocode(latin1_string)
- assert str(excinfo.value) == f"Input must be a unicode string, not {latin1_string!r}"
- assert excinfo.value.bad_value == latin1_string
-
-
-@responses.activate
-def test_reject_out_of_bounds_coordinates():
- """Test that reverse geocoding rejects out-of-bounds latitude and longitude values."""
- responses.add(
- responses.GET,
- geocoder.url,
- body='{"results":{}}',
- status=200
- )
-
- # Valid coordinates should work
- geocoder.reverse_geocode(45.0, 90.0)
- geocoder.reverse_geocode(-45.0, -90.0)
-
- # Invalid latitude values (outside -90 to 90)
- with pytest.raises(InvalidInputError) as excinfo:
- geocoder.reverse_geocode(91.0, 45.0)
- assert "Latitude must be a number between -90 and 90" in str(excinfo.value)
-
- with pytest.raises(InvalidInputError) as excinfo:
- geocoder.reverse_geocode(-91.0, 45.0)
- assert "Latitude must be a number between -90 and 90" in str(excinfo.value)
-
- # Invalid longitude values (outside -180 to 180)
- with pytest.raises(InvalidInputError) as excinfo:
- geocoder.reverse_geocode(45.0, 181.0)
- assert "Longitude must be a number between -180 and 180" in str(excinfo.value)
-
- with pytest.raises(InvalidInputError) as excinfo:
- geocoder.reverse_geocode(45.0, -181.0)
- assert "Longitude must be a number between -180 and 180" in str(excinfo.value)
diff --git a/test/test_error_not_authorized.py b/test/test_error_not_authorized.py
deleted file mode 100644
index 15a95a7..0000000
--- a/test/test_error_not_authorized.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from pathlib import Path
-
-import pytest
-import responses
-
-from opencage.geocoder import OpenCageGeocode
-from opencage.geocoder import NotAuthorizedError
-
-geocoder = OpenCageGeocode('unauthorized-key')
-
-
-@responses.activate
-def test_api_key_not_authorized():
- responses.add(
- responses.GET,
- geocoder.url,
- body=Path('test/fixtures/401_not_authorized.json').read_text(encoding="utf-8"),
- status=401,
- )
-
- with pytest.raises(NotAuthorizedError) as excinfo:
- geocoder.geocode("whatever")
- assert str(excinfo.value) == 'Your API key is not authorized. You may have entered it incorrectly.'
diff --git a/test/test_error_ratelimit_exceeded.py b/test/test_error_ratelimit_exceeded.py
deleted file mode 100644
index 4e0ab08..0000000
--- a/test/test_error_ratelimit_exceeded.py
+++ /dev/null
@@ -1,41 +0,0 @@
-from pathlib import Path
-
-import pytest
-import responses
-
-from opencage.geocoder import OpenCageGeocode
-from opencage.geocoder import RateLimitExceededError
-
-geocoder = OpenCageGeocode('abcde')
-
-
-@responses.activate
-def test_no_rate_limit():
- responses.add(
- responses.GET,
- geocoder.url,
- body=Path('test/fixtures/no_ratelimit.json').read_text(encoding="utf-8"),
- status=200
- )
- # shouldn't raise an exception
- geocoder.geocode("whatever")
-
-
-@responses.activate
-def test_rate_limit_exceeded():
- # 4372eff77b8343cebfc843eb4da4ddc4 will always return 402
- responses.add(
- responses.GET,
- geocoder.url,
- body=Path('test/fixtures/402_rate_limit_exceeded.json').read_text(encoding="utf-8"),
- status=402,
- headers={
- 'X-RateLimit-Limit': '2500',
- 'X-RateLimit-Remaining': '0',
- 'X-RateLimit-Reset': '1402185600'
- }
- )
-
- with pytest.raises(RateLimitExceededError) as excinfo:
- geocoder.geocode("whatever")
- assert 'You have used the requests available on your plan.' in str(excinfo.value)
diff --git a/test/test_error_ssl.py b/test/test_error_ssl.py
deleted file mode 100644
index 9f77417..0000000
--- a/test/test_error_ssl.py
+++ /dev/null
@@ -1,34 +0,0 @@
-# encoding: utf-8
-
-import ssl
-import pytest
-from opencage.geocoder import OpenCageGeocode, SSLError
-
-# NOTE: Testing keys https://opencagedata.com/api#testingkeys
-
-# Connect to a host that has an invalid certificate
-
-
-@pytest.mark.asyncio
-async def test_sslerror():
- # Use a bad certificate (from badssl.com) against the real OpenCage domain
- # to trigger an SSL error without needing a non-allowed domain
- sslcontext = ssl.create_default_context(cafile='test/fixtures/badssl-com-chain.pem')
-
- async with OpenCageGeocode('6d0e711d72d74daeb2b0bfd2a5cdfdba', sslcontext=sslcontext) as geocoder:
- with pytest.raises(SSLError) as excinfo:
- await geocoder.geocode_async("something")
- assert str(excinfo.value).startswith('SSL Certificate error')
-
-# Connect to OpenCage API domain but use certificate of another domain
-# This tests that sslcontext can be set.
-
-
-@pytest.mark.asyncio
-async def test_sslerror_wrong_certificate():
- sslcontext = ssl.create_default_context(cafile='test/fixtures/badssl-com-chain.pem')
-
- async with OpenCageGeocode('6d0e711d72d74daeb2b0bfd2a5cdfdba', sslcontext=sslcontext) as geocoder:
- with pytest.raises(SSLError) as excinfo:
- await geocoder.geocode_async("something")
- assert str(excinfo.value).startswith('SSL Certificate error')
diff --git a/test/test_error_unknown.py b/test/test_error_unknown.py
deleted file mode 100644
index 05e3ad4..0000000
--- a/test/test_error_unknown.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import pytest
-
-import responses
-
-from opencage.geocoder import OpenCageGeocode
-from opencage.geocoder import UnknownError
-
-geocoder = OpenCageGeocode('abcde')
-
-
-@responses.activate
-def test_http_500_status():
- responses.add(
- responses.GET,
- geocoder.url,
- body='{}',
- status=500,
- )
-
- with pytest.raises(UnknownError) as excinfo:
- geocoder.geocode('whatever')
-
- assert str(excinfo.value) == '500 status code from API'
-
-
-@responses.activate
-def test_non_json():
- "These kinds of errors come from webserver and may not be JSON"
- responses.add(
- responses.GET,
- geocoder.url,
- body='503 Service Unavailable
',
- headers={
- 'Content-Type': 'text/html',
- },
- status=503
- )
-
- with pytest.raises(UnknownError) as excinfo:
- geocoder.geocode('whatever')
-
- assert str(excinfo.value) == 'Non-JSON result from server'
-
-
-@responses.activate
-def test_no_results_key():
- responses.add(
- responses.GET,
- geocoder.url,
- body='{"spam": "eggs"}',
- status=200, # Need to specify status code with responses
- )
-
- with pytest.raises(UnknownError) as excinfo:
- geocoder.geocode('whatever')
-
- assert str(excinfo.value) == "JSON from API doesn't have a 'results' key"
diff --git a/test/test_flotify_dict.py b/test/test_flotify_dict.py
deleted file mode 100644
index 26cdf36..0000000
--- a/test/test_flotify_dict.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from opencage.geocoder import floatify_latlng
-
-
-def test_string():
- assert floatify_latlng("123") == "123"
-
-
-def test_empty_dict():
- assert floatify_latlng({}) == {}
-
-
-def test_empty_list():
- assert floatify_latlng([]) == []
-
-
-def test_dict_with_floats():
- assert floatify_latlng({'geom': {'lat': 12.01, 'lng': -0.9}}) == {'geom': {'lat': 12.01, 'lng': -0.9}}
-
-
-def dict_with_stringified_floats():
- assert floatify_latlng({'geom': {'lat': "12.01", 'lng': "-0.9"}}) == {'geom': {'lat': 12.01, 'lng': -0.9}}
-
-
-def dict_with_list():
- assert floatify_latlng(
- {'results': [{'geom': {'lat': "12.01", 'lng': "-0.9"}}, {'geometry': {'lat': '0.1', 'lng': '10'}}]}
- ) == {'results': [{'geom': {'lat': 12.01, 'lng': -0.9}}, {'geometry': {'lat': 0.1, 'lng': 10}}]}
-
-
-def list_with_things():
- assert floatify_latlng([{'foo': 'bar'}]) == [{'foo': 'bar'}]
diff --git a/test/test_geocoder_args.py b/test/test_geocoder_args.py
deleted file mode 100644
index 2935f42..0000000
--- a/test/test_geocoder_args.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# encoding: utf-8
-
-import os
-
-import pytest
-
-from opencage.geocoder import OpenCageGeocode
-
-
-def test_protocol_http():
- """Test that HTTP protocol can be set correctly"""
- geocoder = OpenCageGeocode('abcde', protocol='http')
- assert geocoder.url == 'http://api.opencagedata.com/geocode/v1/json'
-
-
-def test_api_key_env_var():
- """Test that API key can be set by an environment variable"""
-
- os.environ['OPENCAGE_API_KEY'] = 'from-env-var'
- geocoder = OpenCageGeocode()
- assert geocoder.key == 'from-env-var'
-
-
-def test_custom_domain():
- """Test that custom domain can be set"""
- geocoder = OpenCageGeocode('abcde', domain='api2.opencagedata.com')
- assert geocoder.url == 'https://api2.opencagedata.com/geocode/v1/json'
-
-
-def test_custom_domain_localhost():
- """Test that localhost domain can be set"""
- geocoder = OpenCageGeocode('abcde', domain='localhost:8080')
- assert geocoder.url == 'https://localhost:8080/geocode/v1/json'
-
-
-def test_custom_domain_invalid():
- """Test that invalid domains are rejected"""
- with pytest.raises(ValueError, match="Invalid API domain"):
- OpenCageGeocode('abcde', domain='www.example.com')
-
-
-@pytest.mark.parametrize("bad_domain", [
- "example.com/api.opencagedata.com",
- "example.com?api.opencagedata.com",
- "user@api.opencagedata.com",
- "",
-])
-def test_custom_domain_rejects_extra_url_parts(bad_domain):
- """Domain must be a bare hostname, not a URL with path/query/userinfo."""
- with pytest.raises(ValueError, match="Invalid API domain"):
- OpenCageGeocode('abcde', domain=bad_domain)
-
-
-def test_custom_domain_with_port_preserved():
- """Valid subdomain with explicit port still works after validation."""
- geocoder = OpenCageGeocode('abcde', domain='api2.opencagedata.com:8443')
- assert geocoder.url == 'https://api2.opencagedata.com:8443/geocode/v1/json'
diff --git a/test/test_headers.py b/test/test_headers.py
deleted file mode 100644
index bbb68be..0000000
--- a/test/test_headers.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# encoding: utf-8
-
-from pathlib import Path
-
-import os
-import re
-import responses
-
-from opencage.geocoder import OpenCageGeocode
-
-# reduce maximum backoff retry time from 120s to 1s
-os.environ['BACKOFF_MAX_TIME'] = '1'
-
-geocoder = OpenCageGeocode('abcde', user_agent_comment='OpenCage Test')
-
-user_agent_format = re.compile(
- r'^opencage-python/[\d\.]+ Python/[\d\.]+ (requests|aiohttp)/[\d\.]+ \(OpenCage Test\)$')
-
-
-@responses.activate
-def test_sync():
- responses.add(
- responses.GET,
- geocoder.url,
- body=Path('test/fixtures/uk_postcode.json').read_text(encoding="utf-8"),
- status=200
- )
-
- geocoder.geocode("EC1M 5RF")
-
- # Check the User-Agent header in the most recent request
- request = responses.calls[-1].request
- user_agent = request.headers['User-Agent']
-
- assert user_agent_format.match(user_agent) is not None
-
-
-@responses.activate
-def test_user_agent_comment_crlf_stripped():
- """Test that CR/LF characters are stripped from user_agent_comment to prevent header injection."""
- geocoder_crlf = OpenCageGeocode('abcde', user_agent_comment="bad\r\nInjected-Header: value")
-
- responses.add(
- responses.GET,
- geocoder_crlf.url,
- body=Path('test/fixtures/uk_postcode.json').read_text(encoding="utf-8"),
- status=200
- )
-
- geocoder_crlf.geocode("EC1M 5RF")
-
- request = responses.calls[-1].request
- user_agent = request.headers['User-Agent']
-
- assert '\r' not in user_agent
- assert '\n' not in user_agent
- assert 'Injected-Header' in user_agent # still present, just on the same line
diff --git a/test/test_reverse.py b/test/test_reverse.py
deleted file mode 100644
index 8c39058..0000000
--- a/test/test_reverse.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from opencage.geocoder import _query_for_reverse_geocoding
-
-
-def _expected_output(input_latlng, expected_output):
- def test():
- lat, lng = input_latlng
- assert _query_for_reverse_geocoding(lat, lng) == expected_output
- return test
-
-
-def test_reverse():
- _expected_output((10, 10), "10,10")
- _expected_output((10.0, 10.0), "10.0,10.0")
- _expected_output((0.000002, -120), "0.000002,-120")
- _expected_output((2.000002, -120), "2.000002,-120")
- _expected_output((2.000002, -120.000002), "2.000002,-120.000002")
- _expected_output((2.000002, -1.0000002), "2.000002,-1.0000002")
- _expected_output((2.000002, 0.0000001), "2.000002,0.0000001")
-
- _expected_output(("2.000002", "-120"), "2.000002,-120")
diff --git a/test/test_session.py b/test/test_session.py
deleted file mode 100644
index d9dfa88..0000000
--- a/test/test_session.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# encoding: utf-8
-
-from pathlib import Path
-
-import pytest
-import responses
-
-from opencage.geocoder import OpenCageGeocode
-from opencage.geocoder import NotAuthorizedError
-
-
-def _any_result_around(results, lat=None, lon=None):
- for result in results:
- if (abs(result['geometry']['lat'] - lat) < 0.05
- and abs(result['geometry']['lng'] - lon) < 0.05):
- return True
- return False
-
-
-@responses.activate
-def test_success():
- with OpenCageGeocode('abcde') as geocoder:
- responses.add(
- responses.GET,
- geocoder.url,
- body=Path('test/fixtures/uk_postcode.json').read_text(encoding="utf-8"),
- status=200
- )
-
- results = geocoder.geocode("EC1M 5RF")
- assert _any_result_around(results, lat=51.5201666, lon=-0.0985142)
-
-
-@responses.activate
-def test_failure():
- with OpenCageGeocode('unauthorized-key') as geocoder:
- responses.add(
- responses.GET,
- geocoder.url,
- body=Path('test/fixtures/401_not_authorized.json').read_text(encoding="utf-8"),
- status=401,
- )
-
- with pytest.raises(NotAuthorizedError) as excinfo:
- geocoder.geocode("whatever")
- assert str(excinfo.value) == 'Your API key is not authorized. You may have entered it incorrectly.'
diff --git a/tox.ini b/tox.ini
index 1ea90b4..722a38e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -18,7 +18,7 @@ deps =
pytest-aiohttp
pytest-asyncio
commands =
- pytest --cov=opencage --cov-report=term-missing test
+ pytest --cov=opencage_cli --cov-report=term-missing test
[testenv:lint]
usedevelop = True
@@ -27,4 +27,4 @@ deps =
flake8>=7.0.0
pytest
commands =
- flake8 opencage examples/demo.py test
+ flake8 opencage_cli test