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 -[![PyPI version](https://badge.fury.io/py/opencage.svg)](https://badge.fury.io/py/opencage) -[![Downloads](https://pepy.tech/badge/opencage/month)](https://pepy.tech/project/opencage) -[![Versions](https://img.shields.io/pypi/pyversions/opencage)](https://pypi.org/project/opencage/) -![GitHub contributors](https://img.shields.io/github/contributors/opencagedata/python-opencage-geocoder) -[![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) -![Mastodon Follow](https://img.shields.io/mastodon/follow/109287663468501769?domain=https%3A%2F%2Fen.osm.town%2F&style=social) +[![PyPI version](https://badge.fury.io/py/opencage-cli.svg)](https://badge.fury.io/py/opencage-cli) +[![Versions](https://img.shields.io/pypi/pyversions/opencage-cli)](https://pypi.org/project/opencage-cli/) +[![Build Status](https://github.com/OpenCageData/opencage-cli/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/OpenCageData/opencage-cli/actions/workflows/build.yml) -## 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