diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 4e1ef42..714a09e 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,31 +1,41 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries +name: Test and Publish ipdata to PyPI -name: Upload Python Package - -on: - release: - types: [created] +on: push jobs: - deploy: - + build-n-publish: + name: Build and publish to PyPI runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: Set up Python 3.10.4 + uses: actions/setup-python@v5 with: - python-version: '3.x' + python-version: 3.10.4 - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + python -m pip install python-dotenv + if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi + - name: Test with pytest run: | - python setup.py sdist bdist_wheel - twine upload dist/* + python -m pytest + env: + IPDATA_API_KEY: ${{ secrets.IPDATA_API_KEY }} + - name: Install build + run: >- + python -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: >- + python -m + build + . + - name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_PASSWORD }} diff --git a/.gitignore b/.gitignore index bd21622..9e09b91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,14 @@ -publish.sh -.idea/ +build/ +__pycache__/ +*.csv +*.txt +!requirements.txt +*.jsonl +.env +*.egg-info/ +*.egg +*.pyc +.hypothesis/ +.vscode/ +.pytest_cache/ +.venv/ \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE similarity index 97% rename from LICENSE.txt rename to LICENSE index 2d76ef0..79e94e7 100644 --- a/LICENSE.txt +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2017 Jonathan Kosgei +Copyright 2022 ipdata LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/MANIFEST b/MANIFEST deleted file mode 100644 index 8a8bac1..0000000 --- a/MANIFEST +++ /dev/null @@ -1,6 +0,0 @@ -# file GENERATED by distutils, do NOT edit -README -setup.cfg -setup.py -ipdata/__init__.py -ipdata/ipdata.py diff --git a/README.md b/README.md index 9d8fa77..f59dcfa 100644 --- a/README.md +++ b/README.md @@ -1,273 +1,748 @@ -# Getting Started +[![PyPI version](https://badge.fury.io/py/ipdata.svg)](https://badge.fury.io/py/ipdata) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/ipdata/python/python-publish.yml?branch=master) + +> **🎉 Introducing IPTrie** — A fast, type-safe data structure for IP lookups with longest-prefix matching. Supports both IPv4 and IPv6. [Learn more →](#iptrie) + +# Official Python client library and CLI for the ipdata API + +This is a Python client and command line interface (CLI) for the [ipdata.co](https://ipdata.co) IP Geolocation API. ipdata offers a fast, highly-available API to enrich IP Addresses with Location, Company, Threat Intelligence and numerous other data attributes. + +Note that you need an API Key to use this package. You can get a free one with a 1,500 daily request limit by [Signing up here](https://ipdata.co/sign-up.html). + +Visit our [Documentation](https://docs.ipdata.co/) for more examples and tutorials. + +[![asciicast](https://asciinema.org/a/371292.svg)](https://asciinema.org/a/371292) + +## Table of Contents + +- [Installation](#installation) +- [Library Usage](#library-usage) + - [Looking up the calling IP Address](#looking-up-the-calling-ip-address) + - [Looking up any IP Address](#looking-up-any-ip-address) + - [Getting only one field](#getting-only-one-field) + - [Getting a number of specific fields](#getting-a-number-of-specific-fields) + - [Bulk Lookups](#bulk-lookups) +- [Using the ipdata CLI](#using-the-ipdata-cli) + - [Windows Installation Notes](#windows-installation-notes) + - [Available commands](#available-commands) + - [Initialize the cli with your API Key](#initialize-the-cli-with-your-api-key) + - [Look up your own IP address](#look-up-your-own-ip-address) + - [Look up any IP address](#look-up-any-ip-address-1) + - [Copying results to clipboard](#copying-results-to-clipboard) + - [Filtering results by a list of fields](#filtering-results-by-a-list-of-fields) + - [Batch lookup](#batch-lookup) + - [Available Fields](#available-fields) +- [Geofeed tools](#geofeed-tools) +- [IPTrie](#iptrie) + - [Features](#features) + - [Quick Start](#quick-start) + - [IPv6 Support](#ipv6-support) + - [Use Cases](#use-cases) + - [Network Classification](#network-classification) + - [GeoIP Lookup](#geoip-lookup) + - [Access Control Lists](#access-control-lists) + - [API Reference](#api-reference) + - [Constructor](#constructor) + - [Methods](#methods) + - [Exceptions](#exceptions) + - [Input Validation](#input-validation) + - [Performance](#performance) +- [Errors](#errors) +- [Tests](#tests) -This is a Python client for the [ipdata.co](https://ipdata.co) IP Geolocation API. ipdata offers a fast, highly-available API to enrich IP Addresses with Location, Company, Threat Intelligence and numerous other data attributes. +## Installation -Note you need an API Key to access the API. To get a key on the 1500 requests a day free tier, sign up at https://ipdata.co/registration.html. If you need higher volume, sign up for your preferred plan at https://ipdata.co/pricing.html. +Install the latest version of the cli with `pip`. -Visit our [Documentation](https://docs.ipdata.co/) for more information. +```bash +pip install ipdata +``` -## Installation +or `easy_install` -``` -pip3 install ipdata +```bash +easy_install ipdata ``` -## Usage +## Library Usage -### Looking Up the Calling IP Address +You need a valid API key from ipdata to use the library. You can get a free key by [Signing up here](https://ipdata.co/sign-up.html). -``` -from ipdata import ipdata -from pprint import pprint -# Create an instance of an ipdata object. Replace `test` with your API Key -ipdata = ipdata.IPData('test') -response = ipdata.lookup() -pprint(response) -``` +Replace `test` with your API Key in the following examples. -### Looking Up any IP Address +### Looking up the calling IP Address +You can look up the calling IP address, that is, the IP address of the computer you are running this on by not passing an IP address to the `lookup` method. + +```python +>>> import ipdata +>>> ipdata.api_key = "" +>>> ipdata.lookup() ``` -from ipdata import ipdata -from pprint import pprint -# Create an instance of an ipdata object. Replace `test` with your API Key -ipdata = ipdata.IPData('test') -response = ipdata.lookup('69.78.70.144') + +### Looking up any IP Address + +You can look up any valid IPv4 or IPv6 address by passing it to the `lookup` method. + +```python +>>> import ipdata +>>> ipdata.api_key = "" +>>> response = ipdata.lookup('69.78.70.144') pprint(response) ``` -Response +
Sample Response + +```json +{ + "ip": "69.78.70.144", + "is_eu": false, + "city": null, + "region": null, + "region_code": null, + "country_name": "United States", + "country_code": "US", + "continent_name": "North America", + "continent_code": "NA", + "latitude": 37.750999450683594, + "longitude": -97.8219985961914, + "postal": null, + "calling_code": "1", + "flag": "https://ipdata.co/flags/us.png", + "emoji_flag": "\ud83c\uddfa\ud83c\uddf8", + "emoji_unicode": "U+1F1FA U+1F1F8", + "asn": { + "asn": "AS6167", + "name": "Verizon Business", + "domain": "verizon.com", + "route": "69.78.0.0/17", + "type": "business" + }, + "company": { + "name": "Verizon Business", + "domain": "verizon.com", + "network": "69.78.0.0/17", + "type": "business" + }, + "carrier": { + "name": "Verizon", + "mcc": "310", + "mnc": "004" + }, + "languages": [ + { + "name": "English", + "native": "English", + "code": "en" + } + ], + "currency": { + "name": "US Dollar", + "code": "USD", + "symbol": "$", + "native": "$", + "plural": "US dollars" + }, + "time_zone": { + "name": null, + "abbr": null, + "offset": null, + "is_dst": null, + "current_time": null + }, + "threat": { + "is_tor": false, + "is_icloud_relay": false, + "is_proxy": false, + "is_datacenter": false, + "is_anonymous": false, + "is_known_attacker": false, + "is_known_abuser": false, + "is_threat": false, + "is_bogon": false, + "blocklists": [] + }, + "count": "3895", + "status": 200 +} +``` +
-``` -{'asn': 'AS6167', - 'calling_code': '1', - 'carrier': {'mcc': '310', 'mnc': '004', 'name': 'Verizon'}, - 'city': 'Farmersville', - 'continent_code': 'NA', - 'continent_name': 'North America', - 'count': '1506', - 'country_code': 'US', - 'country_name': 'United States', - 'currency': {'code': 'USD', - 'name': 'US Dollar', - 'native': '$', - 'plural': 'US dollars', - 'symbol': '$'}, - 'emoji_flag': '🇺🇸', - 'emoji_unicode': 'U+1F1FA U+1F1F8', - 'flag': 'https://ipdata.co/flags/us.png', - 'ip': '69.78.70.144', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': 33.1659, - 'longitude': -96.3686, - 'organisation': 'Cellco Partnership DBA Verizon Wireless', - 'postal': '75442', - 'region': 'Texas', - 'region_code': 'TX', - 'status': 200, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'CDT', - 'current_time': '2019-04-28T17:56:59.246755-05:00', - 'is_dst': True, - 'name': 'America/Chicago', - 'offset': '-0500'}} -``` ### Getting only one field -``` -from ipdata import ipdata -from pprint import pprint -# Create an instance of an ipdata object. Replace `test` with your API Key -ipdata = ipdata.IPData('test') -response = ipdata.lookup('8.8.8.8', select_field='organisation') -pprint(response) +If you only need a single data attribute about an IP address you can extract it by passing a `select_field` parameter to the `lookup` method. + +```python +>>> import ipdata +>>> ipdata.api_key = "" +>>> ipdata.lookup('8.8.8.8', select_field='asn') ``` Response -``` -{'organisation': 'Google LLC', 'status': 200} +```json +{ + "asn": { + "asn": "AS15169", + "name": "Google LLC", + "domain": "about.google", + "route": "8.8.8.0/24", + "type": "business" + }, + "status": 200 +} ``` ### Getting a number of specific fields -``` -from ipdata import ipdata -from pprint import pprint -# Create an instance of an ipdata object. Replace `test` with your API Key -ipdata = ipdata.IPData('test') -response = ipdata.lookup('8.8.8.8',fields=['ip','organisation','country_name']) -pprint(response) +If instead you need to get multiple specific fields you can pass a list of valid field names in a `fields` parameter. + +```python +>>> import ipdata +>>> ipdata.api_key = "" +>>> ipdata.lookup('8.8.8.8',fields=['ip','asn','country_name']) ``` Response -``` -{'country_name': 'United States', - 'ip': '8.8.8.8', - 'organisation': 'Google LLC', - 'status': 200} +```json +{ + "ip": "8.8.8.8", + "asn": { + "asn": "AS15169", + "name": "Google LLC", + "domain": "about.google", + "route": "8.8.8.0/24", + "type": "business" + }, + "country_name": "United States", + "status": 200 +} ``` ### Bulk Lookups +The API provides a `/bulk` endpoint that allows you to look up upto 100 IP addresses at a time. This is convenient for quickly clearing your backlog. + +NOTE: Alternatively it is much simpler to process bulk lookups using the `ipdata` cli's `batch` command. All you need is a csv file with a list of IP addresses and you can get your results as either a JSON file or a CSV file! See the [CLI Bulk Lookup Documentation](https://docs.ipdata.co/command-line-interface/bulk-lookups-recommended) for details. + +```python +>>> import ipdata +>>> ipdata.api_key = "" +>>> ipdata.bulk(['8.8.8.8','1.1.1.1']) +``` + +
Sample Response + +``` +{ + "responses": [ + { + "ip": "8.8.8.8", + "is_eu": false, + "city": null, + "region": null, + "region_code": null, + "country_name": "United States", + "country_code": "US", + "continent_name": "North America", + "continent_code": "NA", + "latitude": 37.750999450683594, + "longitude": -97.8219985961914, + "postal": null, + "calling_code": "1", + "flag": "https://ipdata.co/flags/us.png", + "emoji_flag": "\ud83c\uddfa\ud83c\uddf8", + "emoji_unicode": "U+1F1FA U+1F1F8", + "asn": { + "asn": "AS15169", + "name": "Google LLC", + "domain": "about.google", + "route": "8.8.8.0/24", + "type": "business" + }, + "company": { + "name": "Google LLC", + "domain": "google.com", + "network": "8.8.8.0/24", + "type": "business" + }, + "languages": [ + { + "name": "English", + "native": "English", + "code": "en" + } + ], + "currency": { + "name": "US Dollar", + "code": "USD", + "symbol": "$", + "native": "$", + "plural": "US dollars" + }, + "time_zone": { + "name": null, + "abbr": null, + "offset": null, + "is_dst": null, + "current_time": null + }, + "threat": { + "is_tor": false, + "is_icloud_relay": false, + "is_proxy": false, + "is_datacenter": false, + "is_anonymous": false, + "is_known_attacker": false, + "is_known_abuser": false, + "is_threat": false, + "is_bogon": false, + "blocklists": [] + }, + "count": "3931" + }, + { + "ip": "1.1.1.1", + "is_eu": null, + "city": null, + "region": null, + "region_code": null, + "country_name": null, + "country_code": null, + "continent_name": null, + "continent_code": null, + "latitude": null, + "longitude": null, + "postal": null, + "calling_code": null, + "flag": null, + "emoji_flag": null, + "emoji_unicode": null, + "asn": { + "asn": "AS13335", + "name": "Cloudflare, Inc.", + "domain": "cloudflare.com", + "route": "1.1.1.0/24", + "type": "business" + }, + "company": { + "name": "Cloudflare, Inc.", + "domain": "cloudflare.com", + "network": "1.1.1.0/24", + "type": "business" + }, + "languages": null, + "currency": { + "name": null, + "code": null, + "symbol": null, + "native": null, + "plural": null + }, + "time_zone": { + "name": null, + "abbr": null, + "offset": null, + "is_dst": null, + "current_time": null + }, + "threat": { + "is_tor": false, + "is_icloud_relay": false, + "is_proxy": false, + "is_datacenter": false, + "is_anonymous": false, + "is_known_attacker": false, + "is_known_abuser": false, + "is_threat": false, + "is_bogon": false, + "blocklists": [] + }, + "count": "3931" + } + ], + "status": 200 +} +``` +
+ +## Using the ipdata CLI + + +### Windows Installation Notes + +IPData CLI needs [Python 3.9+](https://www.python.org/downloads/windows/). Python Windows installation program +provides PIP so you can install IPData CLI the same way: +``` +pip install ipdata +``` + +### Available commands + +```shell +ipdata --help +Usage: ipdata [OPTIONS] COMMAND [ARGS]... + + Welcome to the ipdata CLI + +Options: + --api-key TEXT Your ipdata API Key. Get one for free from + https://ipdata.co/sign-up.html + --help Show this message and exit. + +Commands: + lookup* Lookup resources by using the IPData class methods. + batch Batch command for doing fast bulk processing. + init Initialize the CLI by setting an API key. + usage Get today's usage. + validate Validates a geofeed file. ``` -from ipdata import ipdata -from pprint import pprint -# Create an instance of an ipdata object. Replace `test` with your API Key -ipdata = ipdata.IPData('test') -response = ipdata.bulk_lookup(['8.8.8.8','1.1.1.1']) -pprint(response) + +### Initialize the cli with your API Key + +You need a valid API key from ipdata to use the cli. You can get a free key by [Signing up here](https://ipdata.co/sign-up.html). + +```shell +ipdata init + _ _ _ +(_)_ __ __| | __ _| |_ __ _ +| | '_ \ / _` |/ _` | __/ _` | +| | |_) | (_| | (_| | || (_| | +|_| .__/ \__,_|\__,_|\__\__,_| + |_| + +✨ Successfully initialized. ``` -Response +You can also pass the `--api-key ` parameter to any command to specify a different API Key. +### Look up your own IP address + +Running the `ipdata` command without any parameters will look up the IP address of the computer you are running the command on. Alternatively you can explicitly look up your own IP address by running `ipdata`. + + +```shell +ipdata ``` -{'responses': [{'asn': 'AS15169', - 'calling_code': '1', - 'city': None, - 'continent_code': 'NA', - 'continent_name': 'North America', - 'count': '1506', - 'country_code': 'US', - 'country_name': 'United States', - 'currency': {'code': 'USD', - 'name': 'US Dollar', - 'native': '$', - 'plural': 'US dollars', - 'symbol': '$'}, - 'emoji_flag': '🇺🇸', - 'emoji_unicode': 'U+1F1FA U+1F1F8', - 'flag': 'https://ipdata.co/flags/us.png', - 'ip': '8.8.8.8', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': 37.751, - 'longitude': -97.822, - 'organisation': 'Google LLC', - 'postal': None, - 'region': None, - 'region_code': None, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'CDT', - 'current_time': '2019-04-28T18:02:48.035425-05:00', - 'is_dst': True, - 'name': 'America/Chicago', - 'offset': '-0500'}}, - {'asn': 'AS13335', - 'calling_code': '61', - 'city': None, - 'continent_code': 'OC', - 'continent_name': 'Oceania', - 'count': '1506', - 'country_code': 'AU', - 'country_name': 'Australia', - 'currency': {'code': 'AUD', - 'name': 'Australian Dollar', - 'native': '$', - 'plural': 'Australian dollars', - 'symbol': 'AU$'}, - 'emoji_flag': '🇦🇺', - 'emoji_unicode': 'U+1F1E6 U+1F1FA', - 'flag': 'https://ipdata.co/flags/au.png', - 'ip': '1.1.1.1', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': -33.494, - 'longitude': 143.2104, - 'organisation': 'Cloudflare, Inc.', - 'postal': None, - 'region': None, - 'region_code': None, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'AEST', - 'current_time': '2019-04-29T09:02:48.036287+10:00', - 'is_dst': False, - 'name': 'Australia/Sydney', - 'offset': '+1000'}}], - 'status': 200} -``` - -## Available Fields -A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields) +To pretty print the result pass the `-p` flag -## Errors +``` +╭────────────────────────────────╮ ╭────────────╮ ╭─────────────────╮ ╭──────────╮ ╭─────────────╮ ╭───────────────╮ ╭──────────────╮ ╭────────────────╮ ╭────────────────╮ ╭────────╮ ╭──────────────╮ +│ ip │ │ is_eu │ │ city │ │ region │ │ region_code │ │ country_name │ │ country_code │ │ continent_name │ │ continent_code │ │ postal │ │ calling_code │ +│ 24.24.24.24 │ │ False │ │ Syracuse │ │ New York │ │ NY │ │ United States │ │ US │ │ North America │ │ NA │ │ 13261 │ │ 1 │ +╰────────────────────────────────╯ ╰────────────╯ ╰─────────────────╯ ╰──────────╯ ╰─────────────╯ ╰───────────────╯ ╰──────────────╯ ╰────────────────╯ ╰────────────────╯ ╰────────╯ ╰──────────────╯ +╭────────────────────────────────╮ ╭────────────╮ ╭─────────────────╮ ╭──────────╮ +│ flag │ │ emoji_flag │ │ emoji_unicode │ │ count │ +│ https://ipdata.co/flags/us.png │ │ 🇺🇸 │ │ U+1F1FA U+1F1F8 │ │ 4086 │ +╰────────────────────────────────╯ ╰────────────╯ ╰─────────────────╯ ╰──────────╯ +╭────────────────────────────────╮ ╭──────────────────╮ ╭─────────────────╮ ╭────────────────╮ ╭───────────────────────────────╮ ╭───────────────────────╮ +│ asn │ │ company │ │ languages │ │ currency │ │ time_zone │ │ threat │ +│ ├── asn │ │ ├── name │ │ └── │ │ ├── name │ │ ├── name │ │ ├── is_tor │ +│ │ AS11351 │ │ │ Rr-Route │ │ ├── name │ │ │ US Dollar │ │ │ America/New_York │ │ │ False │ +│ ├── name │ │ ├── domain │ │ │ English │ │ ├── code │ │ ├── abbr │ │ ├── is_icloud_relay │ +│ │ Charter Communications Inc │ │ │ │ │ ├── native │ │ │ USD │ │ │ EDT │ │ │ False │ +│ ├── domain │ │ ├── network │ │ │ English │ │ ├── symbol │ │ ├── offset │ │ ├── is_proxy │ +│ │ spectrum.com │ │ │ 24.24.0.0/19 │ │ └── code │ │ │ $ │ │ │ -0400 │ │ │ False │ +│ ├── route │ │ └── type │ │ en │ │ ├── native │ │ ├── is_dst │ │ ├── is_datacenter │ +│ │ 24.24.0.0/18 │ │ business │ ╰─────────────────╯ │ │ $ │ │ │ True │ │ │ False │ +│ └── type │ ╰──────────────────╯ │ └── plural │ │ └── current_time │ │ ├── is_anonymous │ +│ business │ │ US dollars │ │ 2022-07-15T16:59:44-04:00 │ │ │ False │ +╰────────────────────────────────╯ ╰────────────────╯ ╰───────────────────────────────╯ │ ├── is_known_attacker │ + │ │ False │ + │ ├── is_known_abuser │ + │ │ False │ + │ ├── is_threat │ + │ │ False │ + │ ├── is_bogon │ + │ │ False │ + │ └── blocklists │ + │ [] │ + ╰───────────────────────╯ +``` -A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) +### Look up any IP address -## Tests +You can pass any valid IPv4 or IPv6 address to the `ipdata` command to look it up. In case an invalid value is passed you will get the error `ERROR 'BLEH' does not appear to be an IPv4 or IPv6 address"`. -To run all tests +```shell +ipdata 8.8.8.8 +``` + +### Copying results to clipboard + +Use `-c` to copy the results to the clipboard! ``` -python3 test_ipdata.py +ipdata 1.1.1.1 -f ip -f asn -c +📋️ Copied result to clipboard! ``` -## ipdata CLI +### Filtering results by a list of fields -Usage: `ipdata [OPTIONS] COMMAND [ARGS]...` +Use `--fields` to filter the responses -Options: - `--api-key` TEXT IPData API Key +```shell +ipdata --fields city --fields country_name' +``` -Commands: - `batch` - `info` - `init` - `me` +or use `-f` -### ipdata CLI Examples +```shell +ipdata 1.1.1.1 -f ip -f asn +``` -#### Initialize with API Key +```json +{ + "ip": "1.1.1.1", + "asn": { + "asn": "AS13335", + "name": "Cloudflare, Inc.", + "domain": "cloudflare.com", + "route": "1.1.1.0/24", + "type": "business" + }, + "status": 200 +} ``` -ipdata init + +### Batch lookup + +Perhaps the most useful command provided by the CLI is the ability to process a csv file with a list of IP addresses and write the results to file as either CSV or JSONL/NDJSON! It could be a list of tens of thousands to millions of IP addresses and it will all be processed and the results filtered to your liking! + +When you use the JSON output format, the results are written to the output file you provide with one result per line. Each line being a valid and full JSON response object. +If you only need a few fields eg. only the country name you can specify a field argument with the names of the fields you want, if you combine this with the CSV output format you will get very clean results with only the data you need! + +### To get full JSON responses for further processing + +This is the default output format, so you could omit the `--format JSON` + ``` -You may also pass `--api-key ` extra param to any command to -specify API Key. +ipdata batch my_ip_backlog.csv --output geolocation_results.json --format JSON +``` + +### Batch lookup with output to CSV file -#### Lookup your own IP address ``` -ipdata +ipdata batch my_ip_backlog.csv --output results.csv --output-format CSV --fields ip --fields country_code +``` + +The `--fields` option is required to use CSV output. + +#### Example Results + +```csv +# ip,country_code +107.175.75.83,US +35.155.95.229,US +13.0.0.164,US +209.248.120.14,US +142.0.202.238,US +... ``` -or + +### Available Fields + +A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields) + +## Geofeed tools + +Geofeed publishers can use the `ipdata validate` command to validate their geofeeds before submission to ipdata. This will catch most but not all issues that might cause processing your geofeed to fail. + +The validation closely follows https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-02 with one caveat, we expect the country field to be provided at the minimum. + +You can provide either a url or a path to a local file. + +```shell +ipdata validate https://example.com/geofeed.txt ``` -ipdata me + +or + +```shell +ipdata validate geofeed.txt ``` -#### Look up an IP address + +## IPTrie + +IPTrie is a production-ready, type-safe trie for IP addresses and CIDR prefixes with longest-prefix matching. + +### Features + +- **Dual-stack support**: Handles both IPv4 and IPv6 addresses seamlessly +- **Longest-prefix matching**: Automatically finds the most specific matching prefix +- **Type-safe**: Full generic type support with comprehensive type hints +- **Pythonic API**: Familiar dictionary-like interface (`[]`, `in`, `del`, `len`, iteration) +- **Input validation**: Robust validation using Python's `ipaddress` module +- **Custom exceptions**: Clear, specific exceptions for better error handling +- **Well-tested**: Comprehensive test suite with edge cases covered + +### Quick Start + +```python +from ipdata import IPTrie + +# Create an IPTrie with string values +ip_trie: IPTrie[str] = IPTrie() + +# Add network prefixes +ip_trie["10.0.0.0/8"] = "class-a-private" +ip_trie["10.1.0.0/16"] = "datacenter" +ip_trie["10.1.1.0/24"] = "web-servers" + +# Longest-prefix matching +print(ip_trie["10.1.1.100"]) # "web-servers" +print(ip_trie["10.1.2.100"]) # "datacenter" +print(ip_trie["10.2.0.1"]) # "class-a-private" + +# Check membership +print("10.1.1.50" in ip_trie) # True +print("192.168.1.1" in ip_trie) # False + +# Get the matching prefix +print(ip_trie.parent("10.1.1.100")) # "10.1.1.0/24" + +# Safe access with default +print(ip_trie.get("8.8.8.8", "unknown")) # "unknown" ``` -ipdata + +### IPv6 Support + +```python +ip_trie: IPTrie[str] = IPTrie() + +ip_trie["2001:db8::/32"] = "documentation" +ip_trie["2001:db8:1::/48"] = "specific-block" + +print(ip_trie["2001:db8:1::1"]) # "specific-block" +print(ip_trie["2001:db8:2::1"]) # "documentation" ``` -#### Look up an I address and filter result by specifying coma separated list of fields + +### Use Cases + +#### Network Classification + +```python +from ipdata import IPTrie + +classifier: IPTrie[dict] = IPTrie() +classifier["10.0.0.0/8"] = {"type": "private", "rfc": "1918"} +classifier["172.16.0.0/12"] = {"type": "private", "rfc": "1918"} +classifier["192.168.0.0/16"] = {"type": "private", "rfc": "1918"} +classifier["0.0.0.0/0"] = {"type": "public", "rfc": None} + +def classify_ip(ip: str) -> dict: + return classifier.get(ip, {"type": "unknown"}) + +print(classify_ip("192.168.1.100")) # {"type": "private", "rfc": "1918"} +print(classify_ip("8.8.8.8")) # {"type": "public", "rfc": None} ``` -ipdata --fields ip,country_code + +#### GeoIP Lookup + +```python +from ipdata import IPTrie + +geo_db: IPTrie[str] = IPTrie() +geo_db["8.8.8.0/24"] = "US" +geo_db["1.1.1.0/24"] = "AU" + +def get_country(ip: str) -> str: + return geo_db.get(ip, "Unknown") ``` -#### Batch lookup + +#### Access Control Lists + +```python +from ipdata import IPTrie + +acl: IPTrie[bool] = IPTrie() +acl["192.168.1.0/24"] = True # Allow internal +acl["10.0.0.0/8"] = True # Allow VPN +acl["0.0.0.0/0"] = False # Deny all others + +def is_allowed(ip: str) -> bool: + return acl.get(ip, False) ``` -ipdata --output + +### API Reference + +#### Constructor + +```python +IPTrie[T]() # Create an empty IPTrie with value type T ``` -#### Batch lookup with output to CSV file + +#### Methods + +| Method | Description | +|--------|-------------| +| `__setitem__(key, value)` | Set value for IP/prefix | +| `__getitem__(key)` | Get value using longest-prefix match (raises `KeyNotFoundError`) | +| `get(key, default=None)` | Get value or default if not found | +| `__delitem__(key)` | Delete exact prefix | +| `__contains__(key)` | Check if IP matches any prefix | +| `has_key(key)` | Check if exact prefix exists | +| `parent(key)` | Get the longest matching prefix string | +| `children(key)` | Get all more specific prefixes | +| `__len__()` | Count of all prefixes | +| `__iter__()` | Iterate over all prefixes | +| `keys()` | Iterator over prefixes | +| `values()` | Iterator over values | +| `items()` | Iterator over (prefix, value) tuples | +| `clear()` | Remove all entries | + +#### Exceptions + +| Exception | Description | +|-----------|-------------| +| `IPTrieError` | Base exception class | +| `InvalidIPError` | Invalid IP address or network format | +| `KeyNotFoundError` | No matching prefix found (also a `KeyError`) | + +### Input Validation + +IPTrie validates all inputs using Python's `ipaddress` module: + +```python +from ipdata import IPTrie, InvalidIPError + +ip_trie: IPTrie[str] = IPTrie() + +# These work +ip_trie["192.168.1.0/24"] = "valid" +ip_trie["2001:db8::1"] = "valid" + +# These raise InvalidIPError +try: + ip_trie["not-an-ip"] = "invalid" +except InvalidIPError as e: + print(f"Error: {e}") + +try: + ip_trie[""] = "empty" +except InvalidIPError as e: + print(f"Error: {e}") ``` -ipdata --output --output-format CSV --fields ip,country_code + +### Performance + +IPTrie uses Patricia tries (via `pytricia`) internally, providing: + +- **O(k)** lookup time where k is the prefix length (32 for IPv4, 128 for IPv6) +- **Memory efficient** storage of overlapping prefixes +- **Fast iteration** over all prefixes + +## Errors + +A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) + + +## Tests + +To run all tests + +```shell +pytest ``` -`--fields` option is required in case of CSV output. diff --git a/build/lib/ipdata/__init__.py b/build/lib/ipdata/__init__.py deleted file mode 100644 index 3ddbd99..0000000 --- a/build/lib/ipdata/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from ipdata import * - -__version__ = "3.2" \ No newline at end of file diff --git a/build/lib/ipdata/ipdata.py b/build/lib/ipdata/ipdata.py deleted file mode 100644 index b339125..0000000 --- a/build/lib/ipdata/ipdata.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Call the https://api.ipdata.co API from Python.""" -import requests, ipaddress -class APIKeyNotSet(Exception): - pass - -class IncompatibleParameters(Exception): - pass - -class IPData: - base_url = 'https://api.ipdata.co/' - bulk_url = 'https://api.ipdata.co/bulk' - valid_fields = {'ip', 'is_eu', 'city', 'region', 'region_code', 'country_name', 'country_code', 'continent_name', 'continent_code', 'latitude', 'longitude', 'asn', 'organisation', 'postal', 'calling_code', 'flag', 'emoji_flag', 'emoji_unicode', 'carrier', 'languages', 'currency', 'time_zone', 'threat', 'count', 'status'} - def __init__(self, api_key=None): - if not api_key: - raise APIKeyNotSet("Missing API Key") - self.api_key = api_key - self.headers = {'user-agent': 'ipdata-pypi'} - def _validate_fields(self, select_field=None, fields=[]): - if select_field and not select_field in self.valid_fields: - raise ValueError(f"{select_field} is not a valid field.") - if fields: - if not isinstance(fields, list): - raise ValueError('"fields" should be a list.') - for field in fields: - if field not in self.valid_fields: - raise ValueError(f"{field} is not a valid field.") - def _validate_ip_address(self, ip): - try: - ipaddress.ip_address(ip) - except Exception: - raise - if ipaddress.ip_address(ip).is_private: - raise ValueError(f"{ip} is a private IP Address") - def lookup(self, ip=None, select_field=None, fields=[]): - query = "" - query_params = {'api-key':self.api_key} - if ip: - self._validate_ip_address(ip) - query += f"{ip}/" - if select_field and fields: - raise IncompatibleParameters("The \"select_field\" and \"fields\" parameters cannot be used at the same time.") - if select_field: - self._validate_fields(select_field=select_field) - query += f"{select_field}/" - if fields: - self._validate_fields(fields=fields) - query_params['fields'] = ','.join(fields) - response = requests.get(f"{self.base_url}{query}", headers=self.headers, params=query_params) - status_code = response.status_code - if select_field and status_code == 200: - try: - response = {select_field: response.json(), 'status': status_code} - return response - except Exception: - response = {select_field: response.text, 'status': status_code} - return response - response = response.json() - response['status'] = status_code - return response - def bulk_lookup(self, ips=[], fields=[]): - query_params = {'api-key':self.api_key} - if len(ips) < 2: - raise ValueError('Bulk Lookup requires more than 1 IP Address in the payload.') - for ip in ips: - self._validate_ip_address(ip) - if fields: - self._validate_fields(fields=fields) - query_params['fields'] = ','.join(fields) - response = requests.post(f"{self.bulk_url}", headers=self.headers, params=query_params, json=ips) - status_code = response.status_code - if not status_code == 200: - response = response.json() - response['status'] = status_code - return response - response = {'responses': response.json(), 'status': status_code} - return response diff --git a/build/lib/ipdata/test_ipdata.py b/build/lib/ipdata/test_ipdata.py deleted file mode 100644 index 14631e7..0000000 --- a/build/lib/ipdata/test_ipdata.py +++ /dev/null @@ -1,32 +0,0 @@ -from ipdata import * -import unittest - -class TestAPIMethods(unittest.TestCase): - - def test_param_less(self): - ipdata = IPData('test') - status_code = ipdata.lookup().get('status') - self.assertEqual(status_code, 200) - - def test_param(self): - ipdata = IPData('test') - status_code = ipdata.lookup('8.8.8.8').get('status') - self.assertEqual(status_code, 200) - - def test_select_field(self): - ipdata = IPData('test') - response = ipdata.lookup('8.8.8.8', select_field='ip') - self.assertEqual(response, {'ip': '8.8.8.8', 'status': 200}) - - def test_fields_param(self): - ipdata = IPData('test') - response = ipdata.lookup('8.8.8.8',fields=['ip']) - self.assertEqual(response, {'ip': '8.8.8.8', 'status': 200}) - -# def test_bulk_lookup(self): -# ipdata = ipdata('paid-key-here') -# response = ipdata.bulk_lookup(['8.8.8.8','1.1.1.1'],fields=['ip']) -# self.assertEqual(response, {'response': [{'ip': '8.8.8.8'}, {'ip': '1.1.1.1'}], 'status': 200}) - -if __name__ == '__main__': - unittest.main() diff --git a/dist/ipdata-1.0.tar.gz b/dist/ipdata-1.0.tar.gz deleted file mode 100644 index 82daa49..0000000 Binary files a/dist/ipdata-1.0.tar.gz and /dev/null differ diff --git a/dist/ipdata-1.1.tar.gz b/dist/ipdata-1.1.tar.gz deleted file mode 100644 index 948b16c..0000000 Binary files a/dist/ipdata-1.1.tar.gz and /dev/null differ diff --git a/dist/ipdata-1.2.tar.gz b/dist/ipdata-1.2.tar.gz deleted file mode 100644 index 0f4243e..0000000 Binary files a/dist/ipdata-1.2.tar.gz and /dev/null differ diff --git a/dist/ipdata-1.3.tar.gz b/dist/ipdata-1.3.tar.gz deleted file mode 100644 index 54cc7a5..0000000 Binary files a/dist/ipdata-1.3.tar.gz and /dev/null differ diff --git a/dist/ipdata-1.4.tar.gz b/dist/ipdata-1.4.tar.gz deleted file mode 100644 index 30d7ad2..0000000 Binary files a/dist/ipdata-1.4.tar.gz and /dev/null differ diff --git a/dist/ipdata-1.5.tar.gz b/dist/ipdata-1.5.tar.gz deleted file mode 100644 index 0e1e005..0000000 Binary files a/dist/ipdata-1.5.tar.gz and /dev/null differ diff --git a/dist/ipdata-1.6.tar.gz b/dist/ipdata-1.6.tar.gz deleted file mode 100644 index ad4f73e..0000000 Binary files a/dist/ipdata-1.6.tar.gz and /dev/null differ diff --git a/dist/ipdata-1.7.tar.gz b/dist/ipdata-1.7.tar.gz deleted file mode 100644 index a4f40a8..0000000 Binary files a/dist/ipdata-1.7.tar.gz and /dev/null differ diff --git a/dist/ipdata-1.8.tar.gz b/dist/ipdata-1.8.tar.gz deleted file mode 100644 index 4dd2c4d..0000000 Binary files a/dist/ipdata-1.8.tar.gz and /dev/null differ diff --git a/dist/ipdata-1.9.tar.gz b/dist/ipdata-1.9.tar.gz deleted file mode 100644 index 0e40c4b..0000000 Binary files a/dist/ipdata-1.9.tar.gz and /dev/null differ diff --git a/dist/ipdata-2.0.tar.gz b/dist/ipdata-2.0.tar.gz deleted file mode 100644 index e39364b..0000000 Binary files a/dist/ipdata-2.0.tar.gz and /dev/null differ diff --git a/dist/ipdata-2.1.tar.gz b/dist/ipdata-2.1.tar.gz deleted file mode 100644 index 6b66689..0000000 Binary files a/dist/ipdata-2.1.tar.gz and /dev/null differ diff --git a/dist/ipdata-2.2.tar.gz b/dist/ipdata-2.2.tar.gz deleted file mode 100644 index 1bfe39f..0000000 Binary files a/dist/ipdata-2.2.tar.gz and /dev/null differ diff --git a/dist/ipdata-2.3.tar.gz b/dist/ipdata-2.3.tar.gz deleted file mode 100644 index 0f29d64..0000000 Binary files a/dist/ipdata-2.3.tar.gz and /dev/null differ diff --git a/dist/ipdata-2.4.tar.gz b/dist/ipdata-2.4.tar.gz deleted file mode 100644 index 48453b6..0000000 Binary files a/dist/ipdata-2.4.tar.gz and /dev/null differ diff --git a/dist/ipdata-2.5.tar.gz b/dist/ipdata-2.5.tar.gz deleted file mode 100644 index 22f2880..0000000 Binary files a/dist/ipdata-2.5.tar.gz and /dev/null differ diff --git a/dist/ipdata-2.7.tar.gz b/dist/ipdata-2.7.tar.gz deleted file mode 100644 index 6bf8337..0000000 Binary files a/dist/ipdata-2.7.tar.gz and /dev/null differ diff --git a/dist/ipdata-2.8-py3-none-any.whl b/dist/ipdata-2.8-py3-none-any.whl deleted file mode 100644 index 6c7d2fa..0000000 Binary files a/dist/ipdata-2.8-py3-none-any.whl and /dev/null differ diff --git a/dist/ipdata-2.8.tar.gz b/dist/ipdata-2.8.tar.gz deleted file mode 100644 index 3a38e0f..0000000 Binary files a/dist/ipdata-2.8.tar.gz and /dev/null differ diff --git a/dist/ipdata-3.0-py3-none-any.whl b/dist/ipdata-3.0-py3-none-any.whl deleted file mode 100644 index 38f5623..0000000 Binary files a/dist/ipdata-3.0-py3-none-any.whl and /dev/null differ diff --git a/dist/ipdata-3.0.tar.gz b/dist/ipdata-3.0.tar.gz deleted file mode 100644 index 8884ec2..0000000 Binary files a/dist/ipdata-3.0.tar.gz and /dev/null differ diff --git a/dist/ipdata-3.1-py3-none-any.whl b/dist/ipdata-3.1-py3-none-any.whl deleted file mode 100644 index ad9e1a6..0000000 Binary files a/dist/ipdata-3.1-py3-none-any.whl and /dev/null differ diff --git a/dist/ipdata-3.1.tar.gz b/dist/ipdata-3.1.tar.gz deleted file mode 100644 index 6278469..0000000 Binary files a/dist/ipdata-3.1.tar.gz and /dev/null differ diff --git a/dist/ipdata-3.2-py3-none-any.whl b/dist/ipdata-3.2-py3-none-any.whl deleted file mode 100644 index b5a6bcc..0000000 Binary files a/dist/ipdata-3.2-py3-none-any.whl and /dev/null differ diff --git a/dist/ipdata-3.2.tar.gz b/dist/ipdata-3.2.tar.gz deleted file mode 100644 index 998b767..0000000 Binary files a/dist/ipdata-3.2.tar.gz and /dev/null differ diff --git a/ipdata.egg-info/PKG-INFO b/ipdata.egg-info/PKG-INFO deleted file mode 100644 index 682b947..0000000 --- a/ipdata.egg-info/PKG-INFO +++ /dev/null @@ -1,238 +0,0 @@ -Metadata-Version: 2.1 -Name: ipdata -Version: 3.3.1 -Summary: Python Client for the ipdata IP Geolocation API -Home-page: https://github.com/ipdata/python -Author: Jonathan Kosgei -Author-email: jonatha@ipdata.co -License: MIT -Description: # Getting Started - - This is a Python client for the [ipdata.co](https://ipdata.co) IP Geolocation API. ipdata offers a fast, highly-available API to enrich IP Addresses with Location, Company, Threat Intelligence and numerous other data attributes. - - Note you need an API Key to access the API. To get a key on the 1500 requests a day free tier, sign up at https://ipdata.co/registration.html. If you need higher volume, sign up for your preferred plan at https://ipdata.co/pricing.html. - - Visit our [Documentation](https://docs.ipdata.co/) for more information. - - ## Installation - - ``` - pip3 install ipdata - ``` - - ## Usage - - 1. Looking Up the Calling IP Address - - ``` - from ipdata import ipdata - from pprint import pprint - # Create an instance of an ipdata object. Replace `test` with your API Key - ipdata = ipdata.IPData('test') - response = ipdata.lookup() - pprint(response) - ``` - - 2. Looking Up any IP Address - - ``` - from ipdata import ipdata - from pprint import pprint - # Create an instance of an ipdata object. Replace `test` with your API Key - ipdata = ipdata.IPData('test') - response = ipdata.lookup('69.78.70.144') - pprint(response) - ``` - - Response - - ``` - {'asn': 'AS6167', - 'calling_code': '1', - 'carrier': {'mcc': '310', 'mnc': '004', 'name': 'Verizon'}, - 'city': 'Farmersville', - 'continent_code': 'NA', - 'continent_name': 'North America', - 'count': '1506', - 'country_code': 'US', - 'country_name': 'United States', - 'currency': {'code': 'USD', - 'name': 'US Dollar', - 'native': '$', - 'plural': 'US dollars', - 'symbol': '$'}, - 'emoji_flag': '🇺🇸', - 'emoji_unicode': 'U+1F1FA U+1F1F8', - 'flag': 'https://ipdata.co/flags/us.png', - 'ip': '69.78.70.144', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': 33.1659, - 'longitude': -96.3686, - 'organisation': 'Cellco Partnership DBA Verizon Wireless', - 'postal': '75442', - 'region': 'Texas', - 'region_code': 'TX', - 'status': 200, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'CDT', - 'current_time': '2019-04-28T17:56:59.246755-05:00', - 'is_dst': True, - 'name': 'America/Chicago', - 'offset': '-0500'}} - ``` - - 3. Getting only one field - - ``` - from ipdata import ipdata - from pprint import pprint - # Create an instance of an ipdata object. Replace `test` with your API Key - ipdata = ipdata.IPData('test') - response = ipdata.lookup('8.8.8.8', select_field='organisation') - pprint(response) - ``` - - Response - - ``` - {'organisation': 'Google LLC', 'status': 200} - ``` - - 4. Getting a number of specific fields - - ``` - from ipdata import ipdata - from pprint import pprint - # Create an instance of an ipdata object. Replace `test` with your API Key - ipdata = ipdata.IPData('test') - response = ipdata.lookup('8.8.8.8',fields=['ip','organisation','country_name']) - pprint(response) - ``` - - Response - - ``` - {'country_name': 'United States', - 'ip': '8.8.8.8', - 'organisation': 'Google LLC', - 'status': 200} - ``` - - 5. Bulk Lookups - - ``` - from ipdata import ipdata - from pprint import pprint - # Create an instance of an ipdata object. Replace `test` with your API Key - ipdata = ipdata.IPData('test') - response = ipdata.bulk_lookup(['8.8.8.8','1.1.1.1']) - pprint(response) - ``` - - Response - - ``` - {'responses': [{'asn': 'AS15169', - 'calling_code': '1', - 'city': None, - 'continent_code': 'NA', - 'continent_name': 'North America', - 'count': '1506', - 'country_code': 'US', - 'country_name': 'United States', - 'currency': {'code': 'USD', - 'name': 'US Dollar', - 'native': '$', - 'plural': 'US dollars', - 'symbol': '$'}, - 'emoji_flag': '🇺🇸', - 'emoji_unicode': 'U+1F1FA U+1F1F8', - 'flag': 'https://ipdata.co/flags/us.png', - 'ip': '8.8.8.8', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': 37.751, - 'longitude': -97.822, - 'organisation': 'Google LLC', - 'postal': None, - 'region': None, - 'region_code': None, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'CDT', - 'current_time': '2019-04-28T18:02:48.035425-05:00', - 'is_dst': True, - 'name': 'America/Chicago', - 'offset': '-0500'}}, - {'asn': 'AS13335', - 'calling_code': '61', - 'city': None, - 'continent_code': 'OC', - 'continent_name': 'Oceania', - 'count': '1506', - 'country_code': 'AU', - 'country_name': 'Australia', - 'currency': {'code': 'AUD', - 'name': 'Australian Dollar', - 'native': '$', - 'plural': 'Australian dollars', - 'symbol': 'AU$'}, - 'emoji_flag': '🇦🇺', - 'emoji_unicode': 'U+1F1E6 U+1F1FA', - 'flag': 'https://ipdata.co/flags/au.png', - 'ip': '1.1.1.1', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': -33.494, - 'longitude': 143.2104, - 'organisation': 'Cloudflare, Inc.', - 'postal': None, - 'region': None, - 'region_code': None, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'AEST', - 'current_time': '2019-04-29T09:02:48.036287+10:00', - 'is_dst': False, - 'name': 'Australia/Sydney', - 'offset': '+1000'}}], - 'status': 200} - ``` - - ## Available Fields - - A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields) - - ## Errors - - A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) - - ## Tests - - To run all tests - - ``` - python3 test_ipdata.py - ``` -Platform: UNKNOWN -Classifier: License :: OSI Approved :: MIT License -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.7 -Description-Content-Type: text/markdown diff --git a/ipdata.egg-info/SOURCES.txt b/ipdata.egg-info/SOURCES.txt deleted file mode 100644 index c8f6eb1..0000000 --- a/ipdata.egg-info/SOURCES.txt +++ /dev/null @@ -1,31 +0,0 @@ -LICENSE.txt -MANIFEST -README.md -requirements.txt -setup.cfg -setup.py -dist/ipdata-1.0.tar.gz -dist/ipdata-1.1.tar.gz -dist/ipdata-1.2.tar.gz -dist/ipdata-1.3.tar.gz -dist/ipdata-1.4.tar.gz -dist/ipdata-1.5.tar.gz -dist/ipdata-1.6.tar.gz -dist/ipdata-1.7.tar.gz -dist/ipdata-1.8.tar.gz -dist/ipdata-1.9.tar.gz -dist/ipdata-2.0.tar.gz -dist/ipdata-2.1.tar.gz -dist/ipdata-2.2.tar.gz -dist/ipdata-2.3.tar.gz -dist/ipdata-2.4.tar.gz -dist/ipdata-2.5.tar.gz -ipdata/__init__.py -ipdata/cli.py -ipdata/ipdata.py -ipdata/test_ipdata.py -ipdata.egg-info/PKG-INFO -ipdata.egg-info/SOURCES.txt -ipdata.egg-info/dependency_links.txt -ipdata.egg-info/requires.txt -ipdata.egg-info/top_level.txt \ No newline at end of file diff --git a/ipdata.egg-info/dependency_links.txt b/ipdata.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/ipdata.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ipdata.egg-info/requires.txt b/ipdata.egg-info/requires.txt deleted file mode 100644 index 454fd7c..0000000 --- a/ipdata.egg-info/requires.txt +++ /dev/null @@ -1,3 +0,0 @@ -requests -ipaddress -click \ No newline at end of file diff --git a/ipdata.egg-info/top_level.txt b/ipdata.egg-info/top_level.txt deleted file mode 100644 index 3eb32e1..0000000 --- a/ipdata.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -ipdata diff --git a/ipdata/__init__.py b/ipdata/__init__.py deleted file mode 100644 index 3ddbd99..0000000 --- a/ipdata/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from ipdata import * - -__version__ = "3.2" \ No newline at end of file diff --git a/ipdata/cli.py b/ipdata/cli.py deleted file mode 100644 index c563a28..0000000 --- a/ipdata/cli.py +++ /dev/null @@ -1,240 +0,0 @@ -import csv -import json -import os -import sys -from ipaddress import ip_address -from pathlib import Path -from sys import stderr, stdout - -import click - -if __name__ == '__main__': - from ipdata import IPData -else: - from .ipdata import IPData - - -class WrongAPIKey(Exception): - pass - - -class IPAddressType(click.ParamType): - name = 'IP_Address' - - def convert(self, value, param, ctx): - try: - return ip_address(value) - except: - self.fail(f'{value} is not valid IPv4 or IPv6 address') - - def __str__(self) -> str: - return 'IP Address' - - -@click.group(help='CLI for IPData API', invoke_without_command=True) -@click.option('--api-key', required=False, default=None, help='IPData API Key') -@click.pass_context -def cli(ctx, api_key): - ctx.ensure_object(dict) - ctx.obj['api-key'] = get_and_check_api_key(api_key) - if ctx.invoked_subcommand is None: - print_ip_info(api_key) - else: - pass - - -def get_api_key_path(): - home = str(Path.home()) - return os.path.join(home, '.ipdata') - - -def get_api_key(): - key_path = get_api_key_path() - if os.path.exists(key_path): - with open(key_path, 'r') as f: - for line in f: - if line: - return line - else: - return None - - -def get_and_check_api_key(api_key: str = None) -> str: - if api_key is None: - api_key = get_api_key() - if api_key is None: - print(f'Please specify IPData API Key', file=stderr) - raise WrongAPIKey - return api_key - - -@cli.command() -@click.argument('api-key', required=True, type=str) -def init(api_key): - key_path = get_api_key_path() - - ipdata = IPData(api_key) - res = ipdata.lookup('8.8.8.8') - if res['status'] == 200: - existing_api_key = get_api_key() - if existing_api_key: - print(f'Warning: You already have an IPData API Key "{existing_api_key}" listed in {key_path}. ' - f'It will be overwritten with {api_key}', - file=stderr) - - with open(key_path, 'w') as f: - f.write(api_key) - print(f'New API Key is saved to {key_path}') - else: - print(f'Failed to check the API Key (Error: {res["status"]}): {res["message"]}', - file=stderr) - - -def json_filter(json, fields): - res = dict() - for name in fields: - if name in json: - res[name] = json[name] - elif name.find('.') != -1: - parts = name.split('.') - part = parts[0] if len(parts) > 1 else None - if part and part in json: - sub_value = json_filter(json[part], ('.'.join(parts[1:]), )) - if isinstance(sub_value, dict): - if part not in res: - res[part] = sub_value - else: - res[part] = {**res[part], **sub_value} - else: - res[part] = sub_value - else: - pass - return res - - -@cli.command() -@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') -@click.pass_context -def me(ctx, fields): - print_ip_info(ctx.obj['api-key'], ip=None, fields=fields.split(',') if fields else None) - - -@cli.command() -@click.argument('ip-list', required=True, type=click.File(mode='r', encoding='utf-8')) -@click.option('--output', required=False, default=stdout, type=click.File(mode='w', encoding='utf-8'), - help='Output to file or stdout') -@click.option('--output-format', required=False, type=click.Choice(('JSON', 'CSV'), case_sensitive=False), default='JSON', - help='Format of output') -@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') -@click.pass_context -def batch(ctx, ip_list, output, output_format, fields): - extract_fields = fields.split(',') if fields else None - - if output_format == 'CSV' and extract_fields is None: - print(f'Output in CSV format is not supported without specification of exactly fields to extract ' - f'because of plain nature of CSV format. Please use JSON format instead.', file=stderr) - return - - result_context = {} - if output_format == 'CSV': - print(f'# {fields}', file=output) # print comment with columns - result_context['writer'] = csv.writer(output) - - def print_result(res): - result_context['writer'].writerow([res[k] for k in extract_fields]) - - def finish(): - pass - - elif output_format == 'JSON': - result_context['results'] = [] - - def print_result(res): - result_context['results'].append(res) - - def finish(): - json.dump(result_context, fp=output) - - else: - print(f'Unsupported format: {output_format}', file=stderr) - return - - for ip in ip_list: - ip = ip.strip() - if len(ip) > 0: - print_result(get_ip_info(ctx.obj['api-key'], ip=ip.strip(), fields=extract_fields)) - finish() - - -@click.command() -@click.argument('ip', required=True, type=IPAddressType()) -@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') -@click.option('--api-key', required=False, default=None, help='IPData API Key') -def ip(ip, fields, api_key): - print_ip_info(get_and_check_api_key(api_key), - ip=ip, fields=fields.split(',') if fields else None) - - -def print_ip_info(api_key, ip=None, fields=None): - try: - json.dump(get_ip_info(api_key, ip, fields), stdout) - except ValueError as e: - print(f'Error: IP address {e}', file=stderr) - - -def get_ip_info(api_key, ip=None, fields=None): - ip_data = IPData(get_and_check_api_key(api_key)) - if ip: - res = ip_data.lookup(ip) - else: - res = ip_data.lookup() - if fields and len(fields) > 0: - return json_filter(res, fields) - else: - return res - - -def lookup_field(data, field): - if field in data: - return field, data[field] - elif '.' in field: - parent, children = field.split('.') - parent_field, parent_data = lookup_field(data, parent) - if parent_field: - children_field, children_data = lookup_field(parent_data, children) - return parent_field, {parent_field: children_data} - return None, None - - -# @cli.command() -# @click.argument('ip', type=str) -# @click.argument('fields', type=str, nargs=-1) -# @click.option('--api_key', required=False, default=None, help='IPData API Key') -# def ip(ip, fields, api_key): -# print_ip_info(api_key, ip, fields) - - -@cli.command() -@click.pass_context -def info(ctx): - res = IPData(get_and_check_api_key(ctx.obj['api-key'])).lookup('8.8.8.8') - print(f'Number of requests made: {res["count"]}') - - -def is_ip_address(value): - try: - ip_address(value) - return True - except ValueError: - return False - - -def todo(): - if len(sys.argv) >= 2 and is_ip_address(sys.argv[1]): - ip() - else: - cli(obj={}) - - -if __name__ == '__main__': - todo() diff --git a/ipdata/ipdata.py b/ipdata/ipdata.py deleted file mode 100644 index d6306a8..0000000 --- a/ipdata/ipdata.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Call the https://api.ipdata.co API from Python.""" - -import ipaddress -import requests - - -class APIKeyNotSet(Exception): - pass - - -class IncompatibleParameters(Exception): - pass - - -class IPData: - base_url = 'https://api.ipdata.co/' - bulk_url = 'https://api.ipdata.co/bulk' - valid_fields = {'ip', 'is_eu', 'city', 'region', 'region_code', 'country_name', 'country_code', 'continent_name', - 'continent_code', 'latitude', 'longitude', 'asn', 'organisation', 'postal', 'calling_code', 'flag', - 'emoji_flag', 'emoji_unicode', 'carrier', 'languages', 'currency', 'time_zone', 'threat', 'count', - 'status'} - - def __init__(self, api_key): - if not api_key: - raise APIKeyNotSet("Missing API Key") - self.api_key = api_key - self.headers = {'user-agent': 'ipdata-pypi'} - - def _validate_fields(self, select_field=None, fields=None): - if fields is None: - fields = [] - - if select_field and select_field not in self.valid_fields: - raise ValueError(f"{select_field} is not a valid field.") - if fields: - if not isinstance(fields, list): - raise ValueError('"fields" should be a list.') - for field in fields: - if field not in self.valid_fields: - raise ValueError(f"{field} is not a valid field.") - - def _validate_ip_address(self, ip): - try: - ipaddress.ip_address(ip) - except Exception: - raise - if ipaddress.ip_address(ip).is_private: - raise ValueError(f"{ip} is a private IP Address") - - def lookup(self, ip=None, select_field=None, fields=None): - if fields is None: - fields = [] - - query = "" - query_params = {'api-key': self.api_key} - if ip: - self._validate_ip_address(ip) - query += f"{ip}/" - if select_field and fields: - raise IncompatibleParameters( - "The \"select_field\" and \"fields\" parameters cannot be used at the same time.") - if select_field: - self._validate_fields(select_field=select_field) - query += f"{select_field}/" - if fields: - self._validate_fields(fields=fields) - query_params['fields'] = ','.join(fields) - response = requests.get(f"{self.base_url}{query}", headers=self.headers, params=query_params) - status_code = response.status_code - if select_field and status_code == 200: - try: - response = {select_field: response.json(), 'status': status_code} - return response - except Exception: - response = {select_field: response.text, 'status': status_code} - return response - response = response.json() - response['status'] = status_code - return response - - def bulk_lookup(self, ips=None, fields=None): - if ips is None: - ips = [] - if fields is None: - fields = [] - - query_params = {'api-key': self.api_key} - if len(ips) < 2: - raise ValueError('Bulk Lookup requires more than 1 IP Address in the payload.') - for ip in ips: - self._validate_ip_address(ip) - if fields: - self._validate_fields(fields=fields) - query_params['fields'] = ','.join(fields) - response = requests.post(f"{self.bulk_url}", headers=self.headers, params=query_params, json=ips) - status_code = response.status_code - if not status_code == 200: - response = response.json() - response['status'] = status_code - return response - response = {'responses': response.json(), 'status': status_code} - return response diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py deleted file mode 100644 index b1a56a0..0000000 --- a/ipdata/test_cli.py +++ /dev/null @@ -1,20 +0,0 @@ -from unittest import TestCase - -from ipdata.cli import json_filter - - -class CliTestCase(TestCase): - def test_json_filter(self): - json = {'a': {'b': 1, 'c': 2}, 'd': 3} - - res = json_filter(json, ('a.b',)) - self.assertDictEqual({'a': {'b': 1}}, res) - - res = json_filter(json, ('a',)) - self.assertDictEqual({'a': {'b': 1, 'c': 2}}, res) - - res = json_filter(json, ('a.c', 'd')) - self.assertDictEqual({'a': {'c': 2}, 'd': 3}, res) - - res = json_filter(json, ('d',)) - self.assertDictEqual({'d': 3}, res) diff --git a/ipdata/test_ipdata.py b/ipdata/test_ipdata.py deleted file mode 100644 index 14631e7..0000000 --- a/ipdata/test_ipdata.py +++ /dev/null @@ -1,32 +0,0 @@ -from ipdata import * -import unittest - -class TestAPIMethods(unittest.TestCase): - - def test_param_less(self): - ipdata = IPData('test') - status_code = ipdata.lookup().get('status') - self.assertEqual(status_code, 200) - - def test_param(self): - ipdata = IPData('test') - status_code = ipdata.lookup('8.8.8.8').get('status') - self.assertEqual(status_code, 200) - - def test_select_field(self): - ipdata = IPData('test') - response = ipdata.lookup('8.8.8.8', select_field='ip') - self.assertEqual(response, {'ip': '8.8.8.8', 'status': 200}) - - def test_fields_param(self): - ipdata = IPData('test') - response = ipdata.lookup('8.8.8.8',fields=['ip']) - self.assertEqual(response, {'ip': '8.8.8.8', 'status': 200}) - -# def test_bulk_lookup(self): -# ipdata = ipdata('paid-key-here') -# response = ipdata.bulk_lookup(['8.8.8.8','1.1.1.1'],fields=['ip']) -# self.assertEqual(response, {'response': [{'ip': '8.8.8.8'}, {'ip': '1.1.1.1'}], 'status': 200}) - -if __name__ == '__main__': - unittest.main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..40519b5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[build-system] +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +markers = [ + "geofeed", + "api", +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 23adf64..1cbcb21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,9 @@ requests -click \ No newline at end of file +rich +click +click_default_group +pyperclip +pytest +hypothesis +python-dotenv +pytricia \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 6110861..df6fb94 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,42 @@ -[bumpversion] -current_version = 3.3.1 - [metadata] -description-file = README.md +name = ipdata +version = 4.0.9 +author = Jonathan Kosgei +author_email = jonathan@ipdata.co +description = This is the official IPData client library for Python +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/ipdata/python +project_urls = + Bug Tracker = https://github.com/ipdata/python/issues +classifiers = + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 + License :: OSI Approved :: MIT License + Operating System :: OS Independent + +[options] +package_dir = + = src +packages = find: +python_requires = >=3.9 +install_requires = + requests + rich + click + click_default_group + pyperclip + pytest + hypothesis + python-dotenv + pytricia + +[options.entry_points] +console_scripts = + ipdata = ipdata.cli:cli +[options.packages.find] +where = src \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 35239cc..0000000 --- a/setup.py +++ /dev/null @@ -1,34 +0,0 @@ -import pathlib -from setuptools import setup - -# The directory containing this file -HERE = pathlib.Path(__file__).parent - -# The text of the README file -README = (HERE / "README.md").read_text() - -# This call to setup() does all the work -setup( - name="ipdata", - version="3.3.1", - description="Python Client for the ipdata IP Geolocation API", - long_description=README, - long_description_content_type="text/markdown", - url="https://github.com/ipdata/python", - author="Jonathan Kosgei", - author_email="jonatha@ipdata.co", - license="MIT", - classifiers=[ - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - ], - packages=["ipdata"], - include_package_data=True, - install_requires=["requests", "ipaddress", "click"], - entry_points={ - 'console_scripts': [ - 'ipdata = ipdata.cli:todo', - ] - }, -) diff --git a/src/ipdata/__init__.py b/src/ipdata/__init__.py new file mode 100644 index 0000000..8d521de --- /dev/null +++ b/src/ipdata/__init__.py @@ -0,0 +1,36 @@ +""" + Convenience methods for using the library. + + Example: + >>> import ipdata + >>> ipdata.api_key = + >>> ipdata.lookup() # or ipdata.lookup("8.8.8.8") +""" +from .ipdata import IPData +from .iptrie import IPTrie + +# Configuration +api_key = None +endpoint = "https://api.ipdata.co/" +default_client = None + + +def lookup(resource="", fields=[]): + return _proxy("lookup", resource=resource, fields=fields) + + +def bulk(resources, fields=[]): + return _proxy("bulk", resources, fields=fields) + + +def _proxy(method, *args, **kwargs): + """Create an IPData client if one doesn't exist.""" + global default_client + if not default_client: + default_client = IPData( + api_key, + endpoint + ) + + fn = getattr(default_client, method) + return fn(*args, **kwargs) diff --git a/src/ipdata/cli.py b/src/ipdata/cli.py new file mode 100644 index 0000000..07ed0a0 --- /dev/null +++ b/src/ipdata/cli.py @@ -0,0 +1,523 @@ +"""This is the official IPData Command Line Interface. + +Use it to do one-off lookups or high throughput bulk lookups. With a ton of convenience features eg. +copying a result to the clipboard with '-c', pretty printing results in easy to parse panels with '-p' and more! + + $ ipdata --help + Usage: ipdata [OPTIONS] COMMAND [ARGS]... + + Welcome to the ipdata CLI + + Options: + --api-key TEXT Your ipdata API Key. Get one for free from + https://ipdata.co/sign-up.html + --help Show this message and exit. + + Commands: + lookup* + batch + init + usage + validate + + $ ipdata + Your IP + + $ ipdata 1.1.1.1 -f ip -f asn + { + "ip": "1.1.1.1", + "asn": { + "asn": "AS13335", + "name": "Cloudflare, Inc.", + "domain": "cloudflare.com", + "route": "1.1.1.0/24", + "type": "business" + }, + "status": 200 + } + + $ ipdata 1.1.1.1 -f ip -f asn -c + 📋️ Copied result to clipboard! + + $ ipdata 8.8.8.8 -f ip -p + ╭───────────────╮ + │ country_name │ + │ United States │ + ╰───────────────╯ +""" +import csv +import io +import json +import logging +import os +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from sys import stderr + +import click +import pyperclip +from click_default_group import DefaultGroup +from rich import print, print_json +from rich.columns import Columns +from rich.console import Console +from rich.logging import RichHandler +from rich.panel import Panel +from rich.progress import Progress +from rich.tree import Tree + +from .lolcat import LolCat +from .geofeeds import Geofeed, GeofeedValidationError +from .ipdata import DotDict, IPData + +console = Console() + +FORMAT = "%(message)s" +logging.basicConfig( + level="ERROR", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()] +) +log = logging.getLogger("rich") + +API_KEY_FILE = f"{Path.home()}/.ipdata" + + +def _lookup(ipdata, *args, **kwargs): + """ + Wrapper for looking up individual resources with error handling. Takes the same arguments and returns the same response as ipdata.lookup. + """ + try: + response = ipdata.lookup(*args, **kwargs) + except Exception as e: + log.error(e) + else: + return response + + +def print_ascii_logo(): + """ + Print cool ascii logo with lolcat. + """ + options = DotDict({"animate": False, "os": 6, "spread": 3.0, "freq": 0.1}) + logo = """ + _ _ _ +(_)_ __ __| | __ _| |_ __ _ +| | '_ \ / _` |/ _` | __/ _` | +| | |_) | (_| | (_| | || (_| | +|_| .__/ \__,_|\__,_|\__\__,_| + |_| + """ + lol = LolCat() + lol.cat(io.StringIO(logo), options) + + +def pretty_print_data(data): + """ + Users rich to generate panels for individual API response fields for better readability! + + :param data: the response from ipdata.lookup + """ + # we print single value panels first then multiple value panels for better organization + single_value_panels = [] + multiple_value_panels = [] + + # if data is empty do nothing + if not data: + return + + # push the blocklists field up a level, it's usually nested under the threat data + if data.get("threat", {}).get("blocklists"): + data["blocklists"] = data.get("threat", {}).pop("blocklists") + + # generate panels! + for key, value in data.items(): + # simple case + if type(value) in [str, bool]: + single_value_panels.append( + Panel(f"[b]{key}[/b]\n[yellow]{value}", expand=True) + ) + + # if the value is a dictionary we generate a tree inside a panel + if type(value) is dict: + tree = Tree(key) + for k, v in value.items(): + if key == "threat": + if v: + sub_tree = tree.add(f"[b]{k}[/b]\n[bright_red]{v}") + else: + sub_tree = tree.add(f"[b]{k}[/b]\n[green]{v}") + else: + sub_tree = tree.add(f"[b]{k}[/b]\n[yellow]{v}") + multiple_value_panels.append(Panel(tree, expand=False)) + + # if value if a list we generate nested trees + if type(value) is list: + tree = Tree(key) + for item in value: + branch = tree.add("") + for k, v in item.items(): + _ = branch.add(f"[b]{k}[/b]\n[yellow]{v}") + multiple_value_panels.append(Panel(tree, expand=False)) + + # print the single value panels to the console + console.print(Columns(single_value_panels), overflow="ignore", crop=False) + + # print the multiple value panels to the console + console.print(Columns(multiple_value_panels), overflow="ignore", crop=False) + + +@click.group( + help="Welcome to the ipdata CLI", + cls=DefaultGroup, + default_if_no_args=True, + default="lookup", +) +@click.option( + "--api-key", + required=False, + default=None, + help="Your ipdata API Key. Get one for free from https://ipdata.co/sign-up.html", +) +@click.pass_context +def cli(ctx, api_key): + """ + This is the main entry point for the CLI. We first check for the presence of an API key at ~/.ipdata and make it accessible to all other commands in our group. + Note that the 'init' and 'validate' commands are the only ones that can run without an API key present. + + :param api_key: A valid IPData API key + """ + ctx.ensure_object(dict) + key = ctx.obj["api-key"] = ( + Path(API_KEY_FILE).read_text() if Path(API_KEY_FILE).exists() else None + ) + + # Allow init and validate to run without an API key + if not ctx.invoked_subcommand in ["init", "validate"] and key is None: + print( + f'Please initialize the cli by running "ipdata init " then try again', + file=stderr, + ) + sys.exit(1) + + +@cli.command() +@click.argument( + "api-key", + required=True, + type=str, +) +def init(api_key): + """ + Initialize the CLI by setting an API key. + + :param api_key: A valid IPData API key + """ + ipdata = IPData(api_key) + + # We verify that the API key can successfully make requests by making one + with console.status("Verifying key ...", spinner="dots12"): + response = _lookup(ipdata, "8.8.8.8") + + # Handle success + if response.status == 200: + with open(API_KEY_FILE, "w") as f: + f.write(api_key) + print_ascii_logo() + print(f"✨ Successfully initialized.") + else: + # Handle failure + print( + f"Initialization failed. Error: {response.status}): {response.message}", + file=stderr, + ) + + +@cli.command() +@click.option("--api-key", "-k", required=False, default=None, help="ipdata API Key") +@click.pass_context +def usage(ctx, api_key): + """ + Get today's usage. Quota resets every day at 00:00 UTC. + """ + api_key = ctx.obj["api-key"] + ipdata = IPData(api_key) + with console.status("Getting usage ...", spinner="dots12"): + response = _lookup(ipdata) + print(f"{int(response.count):,}") + + +@cli.command(default=True) +@click.argument("resource", required=False, type=str, default="") +@click.option( + "--fields", + "-f", + required=False, + multiple=True, + default=[], +) +@click.option( + "--exclude", + "-e", + required=False, + multiple=True, + default=[], +) +@click.option("--api-key", "-k", required=False, default=None, help="ipdata API Key") +@click.option( + "--pretty-print", + "-p", + is_flag=True, + required=False, + default=False, + help="Pretty prints results as panels", +) +@click.option( + "--raw", + "-r", + is_flag=True, + required=False, + default=False, + help="Disable pretty printing", +) +@click.option( + "--copy", + "-c", + is_flag=True, + required=False, + default=False, + help="Copy the result to the clipboard", +) +@click.pass_context +def lookup(ctx, resource, fields, api_key, pretty_print, raw, copy, exclude): + """ + Lookup resources by using the IPData class methods. + + :param resource: The resource to lookup + :param fields: A list of supported fields passed as multiple parameters eg. "... -f ip -f country_name" + :param api_key: A valid API key + :param pretty_print: Whether to pretty print the response with panels + :param raw: Whether to print raw unformatted but syntax-highlighted JSON + :param copy: Copy the response to the clipboard + """ + api_key = api_key if api_key else ctx.obj["api-key"] + ipdata = IPData(api_key) + + # enforce mutual exclusivity of fields and exclude + if exclude and fields: + raise click.ClickException( + "'--fields / -f' and '--exclude / -e' are mutually exclusive." + ) + + # if the user wants to exclude some fields, get all the fields in fields that are not in exclude + if exclude: + fields = set(ipdata.valid_fields).difference(set(exclude)) + + with console.status( + f"""Looking up {resource if resource else "this device's IP address"}""", + spinner="dots12", + ): + data = _lookup(ipdata, resource, fields=fields) + + if copy: + pyperclip.copy(json.dumps(data)) + print(f"📋️ Copied result to clipboard!") + elif raw: + print(data) + elif pretty_print: + pretty_print_data(data) + else: + print_json(data=data) + + +def chunks(lst, n=100): + """Yield successive n-sized chunks from lst.""" + for i in range(0, len(lst), n): + yield lst[i : i + n] + + +def process(resources, processor, fields): + """Farm out work to worker threads.""" + n_workers = os.cpu_count() + with ThreadPoolExecutor(n_workers) as executor: + futures = [ + executor.submit(processor, chunk, fields) for chunk in chunks(resources) + ] + for future in as_completed(futures): + try: + result = future.result() + except Exception as e: + log.error(e) + else: + yield result + + +@cli.command() +@click.argument("input", required=True, type=click.File(mode="r", encoding="utf-8")) +@click.option("--fields", "-f", required=False, multiple=True) +@click.option( + "--exclude", + "-e", + required=False, + multiple=True, + default=[], +) +@click.option( + "--output", "-o", required=True, type=click.File(mode="w", encoding="utf-8") +) +@click.option( + "--format", + required=False, + type=click.Choice(("JSON", "CSV"), case_sensitive=False), + default="JSON", + help="Output file format", +) +@click.pass_context +def batch(ctx, input, fields, output, format, exclude): + """ + Batch command for doing fast bulk processing. + + :param input: A list of resources to lookup either IP addresses or ASNs. You could mix them however this could break output processing when writing to CSV + :param fields: A list of valid fields to extract from the individual responses + :param output: The path to write the results to + :param format: The format to write the results to. When using the CSV format it is required to provide fields. + """ + + if format == "CSV" and not fields: + print( + f'You need to specify a "--fields" argument with a list of fields to extract to get results in CSV. To get entire responses use JSON.', + file=stderr, + ) + return + + # enforce mutual exclusivity of fields and exclude + if exclude and fields: + raise click.ClickException( + "'--fields / -f' and '--exclude / -e' are mutually exclusive." + ) + + # if the user wants to exclude some fields, get all the fields in fields that are not in exclude + if exclude: + fields = set(ipdata.valid_fields).difference(set(exclude)) + + # Prepare requests + ipdata = IPData(ctx.obj["api-key"]) + resources = [resource.strip() for resource in input.readlines()] + bulk_results = process(resources, ipdata.bulk, fields) + + # Prepare CSV writing by expanding fieldnames eg. asn to [asn, name, domain] etc + csv_writer = None + fieldnames = [] + for field in fields: + if field == "asn": + fieldnames += [ + f"asn_{sub_field}" + for sub_field in ["asn", "name", "domain", "route", "type"] + ] + continue + elif field == "company": + fieldnames += [ + f"company_{sub_field}" + for sub_field in ["name", "domain", "network", "type"] + ] + continue + elif field == "threat": + fieldnames += [ + f"threat_{sub_field}" + for sub_field in [ + "is_tor", + "is_icloud_relay", + "is_proxy", + "is_datacenter", + "is_anonymous", + "is_known_attacker", + "is_known_abuser", + "is_threat", + "is_bogon", + "blocklists", + ] + ] + continue + elif field in ipdata.valid_fields: + fieldnames += [field] + + # Do lookups concurrenctly using threads in batches of 100 each + with Progress() as progress: + # update progress bar + task = progress.add_task("[green]Processing...", total=len(resources)) + + # write individual results to file + for bulk_result in bulk_results: + for result in bulk_result.get("responses", {}): + progress.update(task, advance=1) + if format == "JSON": + output.write(f"{json.dumps(result)}\n") + if format == "CSV": + if not csv_writer: + # create writer if none exists + csv_writer = csv.DictWriter(output, fieldnames=fieldnames) + csv_writer.writeheader() + + # prepare row + row = {} + for field, value in result.items(): + if type(value) is dict: + for k, v in result[field].items(): + row[f"{field}_{k}"] = v + else: + row[field] = value + + # ensure no unexpected fields + try: + # handle when dict fields are none eg. when asn, company or carrier are empty + row_copy = row.copy() + for key in row_copy: + if key not in fieldnames: + row.pop(key) + + # write results + csv_writer.writerow(row) + except ValueError as e: + log.error(f"Error writing row: {row}. Error: {e}") + + +@cli.command() +@click.argument("feed", required=True, type=str) +def validate(feed): + """ + Validates a geofeed file. + + :param feed: Either a valid URL (with the https:// path) or a file path + """ + geofeed = Geofeed(feed) + valid = True + for entry in geofeed: + if type(entry) is GeofeedValidationError: + log.error(entry) + valid = False + else: + try: + entry.validate() + + # keep count of valid entries + geofeed.valid_count += 1 + except GeofeedValidationError as e: + valid = False + log.error(e) + + if geofeed.total_count == 0: + log.error(f"The provided geofeed is empty") + + valid_percentage = geofeed.valid_count / geofeed.total_count * 100 + print( + f"{geofeed.source} has {geofeed.valid_count:,} ({valid_percentage:.2f}%) valid entries." + ) + + if not valid: + sys.exit(1) + + print( + "✨ Success! Your geofeed is valid and ready for publishing! Send an email to corrections@ipdata.co with the URL of this feed." + ) + + +if __name__ == "__main__": + cli() diff --git a/src/ipdata/codes.py b/src/ipdata/codes.py new file mode 100644 index 0000000..a83c5f0 --- /dev/null +++ b/src/ipdata/codes.py @@ -0,0 +1,2 @@ +COUNTRIES = ["AC", "AD", "AE", "AF", "AG", "AI", "AI", "AL", "AM", "AN", "AO", "AP", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BQ", "BR", "BS", "BT", "BU", "BV", "BW", "BX", "BY", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CP", "CQ", "CR", "CS", "CS", "CT", "CU", "CV", "CW", "CX", "CY", "CZ", "DD", "DE", "DG", "DJ", "DK", "DM", "DO", "DY", "DY", "DZ", "EA", "EC", "EE", "EF", "EG", "EH", "EM", "EP", "ER", "ES", "ET", "EU", "EV", "EW", "EZ", "FI", "FJ", "FK", "FL", "FM", "FO", "FQ", "FR", "FX", "GA", "GB", "GC", "GD", "GE", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "HV", "IB", "IC", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JA", "JE", "JM", "JO", "JP", "JT", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LF", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MI", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NH", "NI", "NL", "NO", "NP", "NQ", "NR", "NT", "NU", "NZ", "OA", "OM", "PA", "PC", "PE", "PF", "PG", "PH", "PI", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PU", "PW", "PY", "PZ", "QA", "RA", "RB", "RC", "RE", "RH", "RH", "RI", "RL", "RM", "RN", "RO", "RP", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SF", "SG", "SH", "SI", "SJ", "SK", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SU", "SV", "SX", "SY", "SZ", "TA", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TP", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UK", "UM", "UN", "US", "UY", "UZ", "VA", "VC", "VD", "VE", "VG", "VI", "VN", "VU", "WF", "WG", "WK", "WL", "WO", "WS", "WV", "YD", "YE", "YT", "YU", "YV", "ZA", "ZM", "ZR", "ZW"] +REGION_CODES = ["AD-02", "AD-03", "AD-04", "AD-05", "AD-06", "AD-07", "AD-08", "AE-AJ", "AE-AZ", "AE-DU", "AE-FU", "AE-RK", "AE-SH", "AE-UQ", "AF-BAL", "AF-BAM", "AF-BDG", "AF-BDS", "AF-BGL", "AF-DAY", "AF-FRA", "AF-FYB", "AF-GHA", "AF-GHO", "AF-HEL", "AF-HER", "AF-JOW", "AF-KAB", "AF-KAN", "AF-KAP", "AF-KDZ", "AF-KHO", "AF-KNR", "AF-LAG", "AF-LOG", "AF-NAN", "AF-NIM", "AF-NUR", "AF-PAN", "AF-PAR", "AF-PIA", "AF-PKA", "AF-SAM", "AF-SAR", "AF-TAK", "AF-URU", "AF-WAR", "AF-ZAB", "AG-10", "AG-11", "AG-03", "AG-04", "AG-05", "AG-06", "AG-07", "AG-08", "AL-01", "AL-02", "AL-03", "AL-04", "AL-05", "AL-06", "AL-07", "AL-08", "AL-09", "AL-10", "AL-11", "AL-12", "AM-AG", "AM-AR", "AM-AV", "AM-GR", "AM-KT", "AM-LO", "AM-SH", "AM-SU", "AM-TV", "AM-VD", "AM-ER", "AO-BGO", "AO-BGU", "AO-BIE", "AO-CAB", "AO-CCU", "AO-CNN", "AO-CNO", "AO-CUS", "AO-HUA", "AO-HUI", "AO-LNO", "AO-LSU", "AO-LUA", "AO-MAL", "AO-MOX", "AO-NAM", "AO-UIG", "AO-ZAI", "AR-A", "AR-B", "AR-D", "AR-E", "AR-F", "AR-G", "AR-H", "AR-J", "AR-K", "AR-L", "AR-M", "AR-N", "AR-P", "AR-Q", "AR-R", "AR-S", "AR-T", "AR-U", "AR-V", "AR-W", "AR-X", "AR-Y", "AR-Z", "AR-C", "AT-1", "AT-2", "AT-3", "AT-4", "AT-5", "AT-6", "AT-7", "AT-8", "AT-9", "AU-ACT", "AU-NT", "AU-NSW", "AU-QLD", "AU-SA", "AU-TAS", "AU-VIC", "AU-WA", "AZ-BA", "AZ-GA", "AZ-LA", "AZ-MI", "AZ-NA", "AZ-SA", "AZ-SM", "AZ-SR", "AZ-XA", "AZ-YE", "AZ-ABS", "AZ-AGA", "AZ-AGC", "AZ-AGM", "AZ-AGS", "AZ-AGU", "AZ-AST", "AZ-BAL", "AZ-BAR", "AZ-BEY", "AZ-BIL", "AZ-CAB", "AZ-CAL", "AZ-DAS", "AZ-FUZ", "AZ-GAD", "AZ-GOR", "AZ-GOY", "AZ-GYG", "AZ-HAC", "AZ-IMI", "AZ-ISM", "AZ-KAL", "AZ-KUR", "AZ-LAC", "AZ-LAN", "AZ-LER", "AZ-MAS", "AZ-NEF", "AZ-OGU", "AZ-QAB", "AZ-QAX", "AZ-QAZ", "AZ-QBA", "AZ-QBI", "AZ-QOB", "AZ-QUS", "AZ-SAB", "AZ-SAK", "AZ-SAL", "AZ-SAT", "AZ-SBN", "AZ-SIY", "AZ-SKR", "AZ-SMI", "AZ-SMX", "AZ-SUS", "AZ-TAR", "AZ-TOV", "AZ-UCA", "AZ-XAC", "AZ-XCI", "AZ-XIZ", "AZ-XVD", "AZ-YAR", "AZ-YEV", "AZ-ZAN", "AZ-ZAQ", "AZ-ZAR", "AZ-NX", "AZ-NV", "AZ-BAB", "AZ-CUL", "AZ-KAN", "AZ-ORD", "AZ-SAD", "AZ-SAH", "AZ-SAR", "BA-BIH", "BA-SRP", "BA-BRC", "BB-01", "BB-02", "BB-03", "BB-04", "BB-05", "BB-06", "BB-07", "BB-08", "BB-09", "BB-10", "BB-11", "BD-A", "BD-02", "BD-06", "BD-07", "BD-25", "BD-50", "BD-51", "BD-B", "BD-01", "BD-04", "BD-08", "BD-09", "BD-10", "BD-11", "BD-16", "BD-29", "BD-31", "BD-47", "BD-56", "BD-C", "BD-13", "BD-15", "BD-17", "BD-18", "BD-26", "BD-33", "BD-35", "BD-36", "BD-40", "BD-42", "BD-53", "BD-62", "BD-63", "BD-D", "BD-05", "BD-12", "BD-22", "BD-23", "BD-27", "BD-30", "BD-37", "BD-39", "BD-43", "BD-58", "BD-E", "BD-03", "BD-24", "BD-44", "BD-45", "BD-48", "BD-49", "BD-54", "BD-59", "BD-F", "BD-14", "BD-19", "BD-28", "BD-32", "BD-46", "BD-52", "BD-55", "BD-64", "BD-G", "BD-20", "BD-38", "BD-60", "BD-61", "BD-H", "BD-21", "BD-34", "BD-41", "BD-57", "BE-BRU", "BE-VLG", "BE-VAN", "BE-VBR", "BE-VLI", "BE-VOV", "BE-VWV", "BE-WAL", "BE-WBR", "BE-WHT", "BE-WLG", "BE-WLX", "BE-WNA", "BF-01", "BF-BAL", "BF-BAN", "BF-KOS", "BF-MOU", "BF-NAY", "BF-SOR", "BF-02", "BF-COM", "BF-LER", "BF-03", "BF-KAD", "BF-04", "BF-BLG", "BF-KOP", "BF-KOT", "BF-05", "BF-BAM", "BF-NAM", "BF-SMT", "BF-06", "BF-BLK", "BF-SIS", "BF-SNG", "BF-ZIR", "BF-07", "BF-BAZ", "BF-NAO", "BF-ZOU", "BF-08", "BF-GNA", "BF-GOU", "BF-KMD", "BF-KMP", "BF-TAP", "BF-09", "BF-HOU", "BF-KEN", "BF-TUI", "BF-10", "BF-LOR", "BF-PAS", "BF-YAT", "BF-ZON", "BF-11", "BF-GAN", "BF-KOW", "BF-OUB", "BF-12", "BF-OUD", "BF-SEN", "BF-SOM", "BF-YAG", "BF-13", "BF-BGR", "BF-IOB", "BF-NOU", "BF-PON", "BG-01", "BG-02", "BG-03", "BG-04", "BG-05", "BG-06", "BG-07", "BG-08", "BG-09", "BG-10", "BG-11", "BG-12", "BG-13", "BG-14", "BG-15", "BG-16", "BG-17", "BG-18", "BG-19", "BG-20", "BG-21", "BG-22", "BG-23", "BG-24", "BG-25", "BG-26", "BG-27", "BG-28", "BH-13", "BH-14", "BH-15", "BH-17", "BI-BB", "BI-BL", "BI-BM", "BI-BR", "BI-CA", "BI-CI", "BI-GI", "BI-KI", "BI-KR", "BI-KY", "BI-MA", "BI-MU", "BI-MW", "BI-MY", "BI-NG", "BI-RM", "BI-RT", "BI-RY", "BJ-AK", "BJ-AL", "BJ-AQ", "BJ-BO", "BJ-CO", "BJ-DO", "BJ-KO", "BJ-LI", "BJ-MO", "BJ-OU", "BJ-PL", "BJ-ZO", "BN-BE", "BN-BM", "BN-TE", "BN-TU", "BO-B", "BO-C", "BO-H", "BO-L", "BO-N", "BO-O", "BO-P", "BO-S", "BO-T", "BQ-BO", "BQ-SA", "BQ-SE", "BR-AC", "BR-AL", "BR-AM", "BR-AP", "BR-BA", "BR-CE", "BR-ES", "BR-GO", "BR-MA", "BR-MG", "BR-MS", "BR-MT", "BR-PA", "BR-PB", "BR-PE", "BR-PI", "BR-PR", "BR-RJ", "BR-RN", "BR-RO", "BR-RR", "BR-RS", "BR-SC", "BR-SE", "BR-SP", "BR-TO", "BR-DF", "BS-AK", "BS-BI", "BS-BP", "BS-BY", "BS-CE", "BS-CI", "BS-CK", "BS-CO", "BS-CS", "BS-EG", "BS-EX", "BS-FP", "BS-GC", "BS-HI", "BS-HT", "BS-IN", "BS-LI", "BS-MC", "BS-MG", "BS-MI", "BS-NE", "BS-NO", "BS-NS", "BS-RC", "BS-RI", "BS-SA", "BS-SE", "BS-SO", "BS-SS", "BS-SW", "BS-WG", "BS-NP", "BT-11", "BT-12", "BT-13", "BT-14", "BT-15", "BT-21", "BT-22", "BT-23", "BT-24", "BT-31", "BT-32", "BT-33", "BT-34", "BT-41", "BT-42", "BT-43", "BT-44", "BT-45", "BT-GA", "BT-TY", "BW-CE", "BW-CH", "BW-GH", "BW-KG", "BW-KL", "BW-KW", "BW-NE", "BW-NW", "BW-SE", "BW-SO", "BW-JW", "BW-LO", "BW-SP", "BW-ST", "BW-FR", "BW-GA", "BY-HM", "BY-BR", "BY-HO", "BY-HR", "BY-MA", "BY-MI", "BY-VI", "BZ-BZ", "BZ-CY", "BZ-CZL", "BZ-OW", "BZ-SC", "BZ-TOL", "CA-NT", "CA-NU", "CA-YT", "CA-AB", "CA-BC", "CA-MB", "CA-NB", "CA-NL", "CA-NS", "CA-ON", "CA-PE", "CA-QC", "CA-SK", "CD-BC", "CD-BU", "CD-EQ", "CD-HK", "CD-HL", "CD-HU", "CD-IT", "CD-KC", "CD-KE", "CD-KG", "CD-KL", "CD-KS", "CD-LO", "CD-LU", "CD-MA", "CD-MN", "CD-MO", "CD-NK", "CD-NU", "CD-SA", "CD-SK", "CD-SU", "CD-TA", "CD-TO", "CD-TU", "CD-KN", "CF-KB", "CF-SE", "CF-AC", "CF-BB", "CF-BK", "CF-HK", "CF-HM", "CF-HS", "CF-KG", "CF-LB", "CF-MB", "CF-MP", "CF-NM", "CF-OP", "CF-UK", "CF-VK", "CF-BGF", "CG-11", "CG-12", "CG-13", "CG-14", "CG-15", "CG-16", "CG-2", "CG-5", "CG-7", "CG-8", "CG-9", "CG-BZV", "CH-AG", "CH-AI", "CH-AR", "CH-BE", "CH-BL", "CH-BS", "CH-FR", "CH-GE", "CH-GL", "CH-GR", "CH-JU", "CH-LU", "CH-NE", "CH-NW", "CH-OW", "CH-SG", "CH-SH", "CH-SO", "CH-SZ", "CH-TG", "CH-TI", "CH-UR", "CH-VD", "CH-VS", "CH-ZG", "CH-ZH", "CI-AB", "CI-YM", "CI-BS", "CI-CM", "CI-DN", "CI-GD", "CI-LC", "CI-LG", "CI-MG", "CI-SM", "CI-SV", "CI-VB", "CI-WR", "CI-ZZ", "CL-AI", "CL-AN", "CL-AP", "CL-AR", "CL-AT", "CL-BI", "CL-CO", "CL-LI", "CL-LL", "CL-LR", "CL-MA", "CL-ML", "CL-NB", "CL-RM", "CL-TA", "CL-VS", "CM-AD", "CM-CE", "CM-EN", "CM-ES", "CM-LT", "CM-NO", "CM-NW", "CM-OU", "CM-SU", "CM-SW", "CN-AH", "CN-FJ", "CN-GD", "CN-GS", "CN-GZ", "CN-HA", "CN-HB", "CN-HE", "CN-HI", "CN-HL", "CN-HN", "CN-JL", "CN-JS", "CN-JX", "CN-LN", "CN-QH", "CN-SC", "CN-SD", "CN-SN", "CN-SX", "CN-TW", "CN-YN", "CN-ZJ", "CN-GX", "CN-NM", "CN-NX", "CN-XJ", "CN-XZ", "CN-HK", "CN-MO", "CN-BJ", "CN-CQ", "CN-SH", "CN-TJ", "CO-AMA", "CO-ANT", "CO-ARA", "CO-ATL", "CO-BOL", "CO-BOY", "CO-CAL", "CO-CAQ", "CO-CAS", "CO-CAU", "CO-CES", "CO-CHO", "CO-COR", "CO-CUN", "CO-GUA", "CO-GUV", "CO-HUI", "CO-LAG", "CO-MAG", "CO-MET", "CO-NAR", "CO-NSA", "CO-PUT", "CO-QUI", "CO-RIS", "CO-SAN", "CO-SAP", "CO-SUC", "CO-TOL", "CO-VAC", "CO-VAU", "CO-VID", "CO-DC", "CR-A", "CR-C", "CR-G", "CR-H", "CR-L", "CR-P", "CR-SJ", "CU-01", "CU-03", "CU-04", "CU-05", "CU-06", "CU-07", "CU-08", "CU-09", "CU-10", "CU-11", "CU-12", "CU-13", "CU-14", "CU-15", "CU-16", "CU-99", "CV-B", "CV-BV", "CV-PA", "CV-PN", "CV-RB", "CV-RG", "CV-SL", "CV-SV", "CV-TS", "CV-S", "CV-BR", "CV-CA", "CV-CF", "CV-CR", "CV-MA", "CV-MO", "CV-PR", "CV-RS", "CV-SD", "CV-SF", "CV-SM", "CV-SO", "CV-SS", "CV-TA", "CY-01", "CY-02", "CY-03", "CY-04", "CY-05", "CY-06", "CZ-20", "CZ-201", "CZ-202", "CZ-203", "CZ-204", "CZ-205", "CZ-206", "CZ-207", "CZ-208", "CZ-209", "CZ-20A", "CZ-20B", "CZ-20C", "CZ-31", "CZ-311", "CZ-312", "CZ-313", "CZ-314", "CZ-315", "CZ-316", "CZ-317", "CZ-32", "CZ-321", "CZ-322", "CZ-323", "CZ-324", "CZ-325", "CZ-326", "CZ-327", "CZ-41", "CZ-411", "CZ-412", "CZ-413", "CZ-42", "CZ-421", "CZ-422", "CZ-423", "CZ-424", "CZ-425", "CZ-426", "CZ-427", "CZ-51", "CZ-511", "CZ-512", "CZ-513", "CZ-514", "CZ-52", "CZ-521", "CZ-522", "CZ-523", "CZ-524", "CZ-525", "CZ-53", "CZ-531", "CZ-532", "CZ-533", "CZ-534", "CZ-63", "CZ-631", "CZ-632", "CZ-633", "CZ-634", "CZ-635", "CZ-64", "CZ-641", "CZ-642", "CZ-643", "CZ-644", "CZ-645", "CZ-646", "CZ-647", "CZ-71", "CZ-711", "CZ-712", "CZ-713", "CZ-714", "CZ-715", "CZ-72", "CZ-721", "CZ-722", "CZ-723", "CZ-724", "CZ-80", "CZ-801", "CZ-802", "CZ-803", "CZ-804", "CZ-805", "CZ-806", "CZ-10", "DE-BB", "DE-BE", "DE-BW", "DE-BY", "DE-HB", "DE-HE", "DE-HH", "DE-MV", "DE-NI", "DE-NW", "DE-RP", "DE-SH", "DE-SL", "DE-SN", "DE-ST", "DE-TH", "DJ-AR", "DJ-AS", "DJ-DI", "DJ-OB", "DJ-TA", "DJ-DJ", "DK-81", "DK-82", "DK-83", "DK-84", "DK-85", "DM-02", "DM-03", "DM-04", "DM-05", "DM-06", "DM-07", "DM-08", "DM-09", "DM-10", "DM-11", "DO-33", "DO-06", "DO-14", "DO-19", "DO-20", "DO-34", "DO-05", "DO-15", "DO-26", "DO-27", "DO-35", "DO-09", "DO-18", "DO-25", "DO-36", "DO-13", "DO-24", "DO-28", "DO-37", "DO-07", "DO-22", "DO-38", "DO-03", "DO-04", "DO-10", "DO-16", "DO-39", "DO-23", "DO-29", "DO-30", "DO-40", "DO-32", "DO-01", "DO-41", "DO-02", "DO-17", "DO-21", "DO-31", "DO-42", "DO-08", "DO-11", "DO-12", "DZ-01", "DZ-02", "DZ-03", "DZ-04", "DZ-05", "DZ-06", "DZ-07", "DZ-08", "DZ-09", "DZ-10", "DZ-11", "DZ-12", "DZ-13", "DZ-14", "DZ-15", "DZ-16", "DZ-17", "DZ-18", "DZ-19", "DZ-20", "DZ-21", "DZ-22", "DZ-23", "DZ-24", "DZ-25", "DZ-26", "DZ-27", "DZ-28", "DZ-29", "DZ-30", "DZ-31", "DZ-32", "DZ-33", "DZ-34", "DZ-35", "DZ-36", "DZ-37", "DZ-38", "DZ-39", "DZ-40", "DZ-41", "DZ-42", "DZ-43", "DZ-44", "DZ-45", "DZ-46", "DZ-47", "DZ-48", "EC-A", "EC-B", "EC-C", "EC-D", "EC-E", "EC-F", "EC-G", "EC-H", "EC-I", "EC-L", "EC-M", "EC-N", "EC-O", "EC-P", "EC-R", "EC-S", "EC-SD", "EC-SE", "EC-T", "EC-U", "EC-W", "EC-X", "EC-Y", "EC-Z", "EE-37", "EE-296", "EE-424", "EE-446", "EE-784", "EE-141", "EE-198", "EE-245", "EE-305", "EE-338", "EE-353", "EE-431", "EE-651", "EE-653", "EE-719", "EE-726", "EE-890", "EE-39", "EE-205", "EE-45", "EE-321", "EE-511", "EE-514", "EE-735", "EE-130", "EE-251", "EE-442", "EE-803", "EE-50", "EE-247", "EE-486", "EE-618", "EE-52", "EE-567", "EE-255", "EE-834", "EE-56", "EE-184", "EE-441", "EE-907", "EE-60", "EE-663", "EE-191", "EE-272", "EE-661", "EE-792", "EE-901", "EE-903", "EE-928", "EE-64", "EE-284", "EE-622", "EE-708", "EE-68", "EE-624", "EE-214", "EE-303", "EE-430", "EE-638", "EE-712", "EE-809", "EE-71", "EE-293", "EE-317", "EE-503", "EE-668", "EE-74", "EE-478", "EE-689", "EE-714", "EE-79", "EE-793", "EE-171", "EE-283", "EE-291", "EE-432", "EE-528", "EE-586", "EE-796", "EE-81", "EE-557", "EE-824", "EE-855", "EE-84", "EE-897", "EE-480", "EE-615", "EE-899", "EE-87", "EE-919", "EE-142", "EE-698", "EE-732", "EE-917", "EG-ALX", "EG-ASN", "EG-AST", "EG-BA", "EG-BH", "EG-BNS", "EG-C", "EG-DK", "EG-DT", "EG-FYM", "EG-GH", "EG-GZ", "EG-IS", "EG-JS", "EG-KB", "EG-KFS", "EG-KN", "EG-LX", "EG-MN", "EG-MNF", "EG-MT", "EG-PTS", "EG-SHG", "EG-SHR", "EG-SIN", "EG-SUZ", "EG-WAD", "ER-AN", "ER-DK", "ER-DU", "ER-GB", "ER-MA", "ER-SK", "ES-AN", "ES-AL", "ES-CA", "ES-CO", "ES-GR", "ES-H", "ES-J", "ES-MA", "ES-SE", "ES-AR", "ES-HU", "ES-TE", "ES-Z", "ES-AS", "ES-O", "ES-CB", "ES-S", "ES-CL", "ES-AV", "ES-BU", "ES-LE", "ES-P", "ES-SA", "ES-SG", "ES-SO", "ES-VA", "ES-ZA", "ES-CM", "ES-AB", "ES-CR", "ES-CU", "ES-GU", "ES-TO", "ES-CN", "ES-GC", "ES-TF", "ES-CT", "ES-B", "ES-GI", "ES-L", "ES-T", "ES-EX", "ES-BA", "ES-CC", "ES-GA", "ES-C", "ES-LU", "ES-OR", "ES-PO", "ES-IB", "ES-PM", "ES-MC", "ES-MU", "ES-MD", "ES-M", "ES-NC", "ES-NA", "ES-PV", "ES-BI", "ES-SS", "ES-VI", "ES-RI", "ES-LO", "ES-VC", "ES-A", "ES-CS", "ES-V", "ES-CE", "ES-ML", "ET-AF", "ET-AM", "ET-BE", "ET-GA", "ET-HA", "ET-OR", "ET-SI", "ET-SN", "ET-SO", "ET-TI", "ET-AA", "ET-DD", "FI-01", "FI-02", "FI-03", "FI-04", "FI-05", "FI-06", "FI-07", "FI-08", "FI-09", "FI-10", "FI-11", "FI-12", "FI-13", "FI-14", "FI-15", "FI-16", "FI-17", "FI-18", "FI-19", "FJ-R", "FJ-C", "FJ-09", "FJ-10", "FJ-12", "FJ-13", "FJ-14", "FJ-E", "FJ-04", "FJ-05", "FJ-06", "FJ-N", "FJ-02", "FJ-03", "FJ-07", "FJ-W", "FJ-01", "FJ-08", "FJ-11", "FM-KSA", "FM-PNI", "FM-TRK", "FM-YAP", "FR-ARA", "FR-01", "FR-03", "FR-07", "FR-15", "FR-26", "FR-38", "FR-42", "FR-43", "FR-63", "FR-69", "FR-73", "FR-74", "FR-69M", "FR-BFC", "FR-21", "FR-25", "FR-39", "FR-58", "FR-70", "FR-71", "FR-89", "FR-90", "FR-BRE", "FR-22", "FR-29", "FR-35", "FR-56", "FR-CVL", "FR-18", "FR-28", "FR-36", "FR-37", "FR-41", "FR-45", "FR-GES", "FR-08", "FR-10", "FR-51", "FR-52", "FR-54", "FR-55", "FR-57", "FR-88", "FR-6AE", "FR-67", "FR-68", "FR-HDF", "FR-02", "FR-59", "FR-60", "FR-62", "FR-80", "FR-IDF", "FR-77", "FR-78", "FR-91", "FR-92", "FR-93", "FR-94", "FR-95", "FR-75C", "FR-NAQ", "FR-16", "FR-17", "FR-19", "FR-23", "FR-24", "FR-33", "FR-40", "FR-47", "FR-64", "FR-79", "FR-86", "FR-87", "FR-NOR", "FR-14", "FR-27", "FR-50", "FR-61", "FR-76", "FR-OCC", "FR-09", "FR-11", "FR-12", "FR-30", "FR-31", "FR-32", "FR-34", "FR-46", "FR-48", "FR-65", "FR-66", "FR-81", "FR-82", "FR-PAC", "FR-04", "FR-05", "FR-06", "FR-13", "FR-83", "FR-84", "FR-PDL", "FR-44", "FR-49", "FR-53", "FR-72", "FR-85", "FR-CP", "FR-BL", "FR-MF", "FR-PF", "FR-PM", "FR-WF", "FR-20R", "FR-2A", "FR-2B", "FR-NC", "FR-TF", "FR-972", "FR-973", "FR-971", "FR-974", "FR-976", "GA-1", "GA-2", "GA-3", "GA-4", "GA-5", "GA-6", "GA-7", "GA-8", "GA-9", "GB-ENG", "GB-BKM", "GB-CAM", "GB-CMA", "GB-DBY", "GB-DEV", "GB-DOR", "GB-ESS", "GB-ESX", "GB-GLS", "GB-HAM", "GB-HRT", "GB-KEN", "GB-LAN", "GB-LEC", "GB-LIN", "GB-NFK", "GB-NTH", "GB-NTT", "GB-NYK", "GB-OXF", "GB-SFK", "GB-SOM", "GB-SRY", "GB-STS", "GB-WAR", "GB-WOR", "GB-WSX", "GB-BAS", "GB-BBD", "GB-BCP", "GB-BDF", "GB-BNH", "GB-BPL", "GB-BRC", "GB-BST", "GB-CBF", "GB-CHE", "GB-CHW", "GB-CON", "GB-DAL", "GB-DER", "GB-DUR", "GB-ERY", "GB-HAL", "GB-HEF", "GB-HPL", "GB-IOS", "GB-IOW", "GB-KHL", "GB-LCE", "GB-LUT", "GB-MDB", "GB-MDW", "GB-MIK", "GB-NBL", "GB-NEL", "GB-NGM", "GB-NLN", "GB-NSM", "GB-PLY", "GB-POR", "GB-PTE", "GB-RCC", "GB-RDG", "GB-RUT", "GB-SGC", "GB-SHR", "GB-SLG", "GB-SOS", "GB-STE", "GB-STH", "GB-STT", "GB-SWD", "GB-TFW", "GB-THR", "GB-TOB", "GB-WBK", "GB-WIL", "GB-WNM", "GB-WOK", "GB-WRT", "GB-YOR", "GB-BIR", "GB-BNS", "GB-BOL", "GB-BRD", "GB-BUR", "GB-CLD", "GB-COV", "GB-DNC", "GB-DUD", "GB-GAT", "GB-KIR", "GB-KWL", "GB-LDS", "GB-LIV", "GB-MAN", "GB-NET", "GB-NTY", "GB-OLD", "GB-RCH", "GB-ROT", "GB-SAW", "GB-SFT", "GB-SHF", "GB-SHN", "GB-SKP", "GB-SLF", "GB-SND", "GB-SOL", "GB-STY", "GB-TAM", "GB-TRF", "GB-WGN", "GB-WKF", "GB-WLL", "GB-WLV", "GB-WRL", "GB-BDG", "GB-BEN", "GB-BEX", "GB-BNE", "GB-BRY", "GB-CMD", "GB-CRY", "GB-EAL", "GB-ENF", "GB-GRE", "GB-HAV", "GB-HCK", "GB-HIL", "GB-HMF", "GB-HNS", "GB-HRW", "GB-HRY", "GB-ISL", "GB-KEC", "GB-KTT", "GB-LBH", "GB-LEW", "GB-MRT", "GB-NWM", "GB-RDB", "GB-RIC", "GB-STN", "GB-SWK", "GB-TWH", "GB-WFT", "GB-WND", "GB-WSM", "GB-LND", "GB-SCT", "GB-ABD", "GB-ABE", "GB-AGB", "GB-ANS", "GB-CLK", "GB-DGY", "GB-DND", "GB-EAY", "GB-EDH", "GB-EDU", "GB-ELN", "GB-ELS", "GB-ERW", "GB-FAL", "GB-FIF", "GB-GLG", "GB-HLD", "GB-IVC", "GB-MLN", "GB-MRY", "GB-NAY", "GB-NLK", "GB-ORK", "GB-PKN", "GB-RFW", "GB-SAY", "GB-SCB", "GB-SLK", "GB-STG", "GB-WDU", "GB-WLN", "GB-ZET", "GB-WLS", "GB-AGY", "GB-BGE", "GB-BGW", "GB-CAY", "GB-CGN", "GB-CMN", "GB-CRF", "GB-CWY", "GB-DEN", "GB-FLN", "GB-GWN", "GB-MON", "GB-MTY", "GB-NTL", "GB-NWP", "GB-PEM", "GB-POW", "GB-RCT", "GB-SWA", "GB-TOF", "GB-VGL", "GB-WRX", "GB-NIR", "GB-ABC", "GB-AND", "GB-ANN", "GB-BFS", "GB-CCG", "GB-DRS", "GB-FMO", "GB-LBC", "GB-MEA", "GB-MUL", "GB-NMD", "GD-01", "GD-02", "GD-03", "GD-04", "GD-05", "GD-06", "GD-10", "GE-GU", "GE-IM", "GE-KA", "GE-KK", "GE-MM", "GE-RL", "GE-SJ", "GE-SK", "GE-SZ", "GE-AB", "GE-AJ", "GE-TB", "GH-AA", "GH-AF", "GH-AH", "GH-BE", "GH-BO", "GH-CP", "GH-EP", "GH-NE", "GH-NP", "GH-OT", "GH-SV", "GH-TV", "GH-UE", "GH-UW", "GH-WN", "GH-WP", "GL-AV", "GL-KU", "GL-QE", "GL-QT", "GL-SM", "GM-L", "GM-M", "GM-N", "GM-U", "GM-W", "GM-B", "GN-C", "GN-B", "GN-BF", "GN-BK", "GN-FR", "GN-GA", "GN-KN", "GN-D", "GN-CO", "GN-DU", "GN-FO", "GN-KD", "GN-TE", "GN-F", "GN-DB", "GN-DI", "GN-FA", "GN-KS", "GN-K", "GN-KA", "GN-KE", "GN-KO", "GN-MD", "GN-SI", "GN-L", "GN-KB", "GN-LA", "GN-LE", "GN-ML", "GN-TO", "GN-M", "GN-DL", "GN-MM", "GN-PI", "GN-N", "GN-BE", "GN-GU", "GN-LO", "GN-MC", "GN-NZ", "GN-YO", "GQ-C", "GQ-CS", "GQ-DJ", "GQ-KN", "GQ-LI", "GQ-WN", "GQ-I", "GQ-AN", "GQ-BN", "GQ-BS", "GR-69", "GR-A", "GR-B", "GR-C", "GR-D", "GR-E", "GR-F", "GR-G", "GR-H", "GR-I", "GR-J", "GR-K", "GR-L", "GR-M", "GT-01", "GT-02", "GT-03", "GT-04", "GT-05", "GT-06", "GT-07", "GT-08", "GT-09", "GT-10", "GT-11", "GT-12", "GT-13", "GT-14", "GT-15", "GT-16", "GT-17", "GT-18", "GT-19", "GT-20", "GT-21", "GT-22", "GW-BS", "GW-L", "GW-BA", "GW-GA", "GW-N", "GW-BM", "GW-CA", "GW-OI", "GW-S", "GW-BL", "GW-QU", "GW-TO", "GY-BA", "GY-CU", "GY-DE", "GY-EB", "GY-ES", "GY-MA", "GY-PM", "GY-PT", "GY-UD", "GY-UT", "HN-AT", "HN-CH", "HN-CL", "HN-CM", "HN-CP", "HN-CR", "HN-EP", "HN-FM", "HN-GD", "HN-IB", "HN-IN", "HN-LE", "HN-LP", "HN-OC", "HN-OL", "HN-SB", "HN-VA", "HN-YO", "HR-01", "HR-02", "HR-03", "HR-04", "HR-05", "HR-06", "HR-07", "HR-08", "HR-09", "HR-10", "HR-11", "HR-12", "HR-13", "HR-14", "HR-15", "HR-16", "HR-17", "HR-18", "HR-19", "HR-20", "HR-21", "HT-AR", "HT-CE", "HT-GA", "HT-ND", "HT-NE", "HT-NI", "HT-NO", "HT-OU", "HT-SD", "HT-SE", "HU-BA", "HU-BE", "HU-BK", "HU-BZ", "HU-CS", "HU-FE", "HU-GS", "HU-HB", "HU-HE", "HU-JN", "HU-KE", "HU-NO", "HU-PE", "HU-SO", "HU-SZ", "HU-TO", "HU-VA", "HU-VE", "HU-ZA", "HU-BC", "HU-DE", "HU-DU", "HU-EG", "HU-ER", "HU-GY", "HU-HV", "HU-KM", "HU-KV", "HU-MI", "HU-NK", "HU-NY", "HU-PS", "HU-SD", "HU-SF", "HU-SH", "HU-SK", "HU-SN", "HU-SS", "HU-ST", "HU-TB", "HU-VM", "HU-ZE", "HU-BU", "ID-JW", "ID-BT", "ID-JB", "ID-JI", "ID-JT", "ID-YO", "ID-JK", "ID-KA", "ID-KB", "ID-KI", "ID-KS", "ID-KT", "ID-KU", "ID-ML", "ID-MA", "ID-MU", "ID-NU", "ID-BA", "ID-NB", "ID-NT", "ID-PP", "ID-PA", "ID-PB", "ID-SL", "ID-GO", "ID-SA", "ID-SG", "ID-SN", "ID-SR", "ID-ST", "ID-SM", "ID-AC", "ID-BB", "ID-BE", "ID-JA", "ID-KR", "ID-LA", "ID-RI", "ID-SB", "ID-SS", "ID-SU", "IE-C", "IE-G", "IE-LM", "IE-MO", "IE-RN", "IE-SO", "IE-L", "IE-CW", "IE-D", "IE-KE", "IE-KK", "IE-LD", "IE-LH", "IE-LS", "IE-MH", "IE-OY", "IE-WH", "IE-WW", "IE-WX", "IE-M", "IE-CE", "IE-CO", "IE-KY", "IE-LK", "IE-TA", "IE-WD", "IE-U", "IE-CN", "IE-DL", "IE-MN", "IL-D", "IL-HA", "IL-JM", "IL-M", "IL-TA", "IL-Z", "IN-AN", "IN-CH", "IN-DH", "IN-DL", "IN-JK", "IN-LA", "IN-LD", "IN-PY", "IN-AP", "IN-AR", "IN-AS", "IN-BR", "IN-CT", "IN-GA", "IN-GJ", "IN-HP", "IN-HR", "IN-JH", "IN-KA", "IN-KL", "IN-MH", "IN-ML", "IN-MN", "IN-MP", "IN-MZ", "IN-NL", "IN-OR", "IN-PB", "IN-RJ", "IN-SK", "IN-TG", "IN-TN", "IN-TR", "IN-UP", "IN-UT", "IN-WB", "IQ-AN", "IQ-BA", "IQ-BB", "IQ-BG", "IQ-DI", "IQ-DQ", "IQ-KA", "IQ-KI", "IQ-MA", "IQ-MU", "IQ-NA", "IQ-NI", "IQ-QA", "IQ-SD", "IQ-WA", "IQ-KR", "IQ-AR", "IQ-DA", "IQ-SU", "IR-00", "IR-01", "IR-02", "IR-03", "IR-04", "IR-05", "IR-06", "IR-07", "IR-08", "IR-09", "IR-10", "IR-11", "IR-12", "IR-13", "IR-14", "IR-15", "IR-16", "IR-17", "IR-18", "IR-19", "IR-20", "IR-21", "IR-22", "IR-23", "IR-24", "IR-25", "IR-26", "IR-27", "IR-28", "IR-29", "IR-30", "IS-1", "IS-GAR", "IS-HAF", "IS-KJO", "IS-KOP", "IS-MOS", "IS-RKV", "IS-SEL", "IS-2", "IS-GRN", "IS-RKN", "IS-SDN", "IS-SVG", "IS-3", "IS-AKN", "IS-BOG", "IS-DAB", "IS-EOM", "IS-GRU", "IS-HEL", "IS-HVA", "IS-SKO", "IS-SNF", "IS-STY", "IS-4", "IS-ARN", "IS-BOL", "IS-ISA", "IS-KAL", "IS-RHH", "IS-SDV", "IS-STR", "IS-TAL", "IS-VER", "IS-5", "IS-AKH", "IS-BLO", "IS-HUT", "IS-HUV", "IS-SKG", "IS-SSF", "IS-SSS", "IS-6", "IS-AKU", "IS-DAV", "IS-EYF", "IS-FJL", "IS-GRY", "IS-HRG", "IS-LAN", "IS-NOR", "IS-SBH", "IS-SBT", "IS-SKU", "IS-THG", "IS-TJO", "IS-7", "IS-FJD", "IS-FLR", "IS-MUL", "IS-SHF", "IS-VOP", "IS-8", "IS-ASA", "IS-BLA", "IS-FLA", "IS-GOG", "IS-HRU", "IS-HVE", "IS-MYR", "IS-RGE", "IS-RGY", "IS-SFA", "IS-SKF", "IS-SOG", "IS-SOL", "IS-VEM", "IT-21", "IT-AL", "IT-AT", "IT-BI", "IT-CN", "IT-NO", "IT-VB", "IT-VC", "IT-TO", "IT-25", "IT-BG", "IT-BS", "IT-CO", "IT-CR", "IT-LC", "IT-LO", "IT-MB", "IT-MN", "IT-PV", "IT-SO", "IT-VA", "IT-MI", "IT-34", "IT-BL", "IT-PD", "IT-RO", "IT-TV", "IT-VI", "IT-VR", "IT-VE", "IT-42", "IT-IM", "IT-SP", "IT-SV", "IT-GE", "IT-45", "IT-FC", "IT-FE", "IT-MO", "IT-PC", "IT-PR", "IT-RA", "IT-RE", "IT-RN", "IT-BO", "IT-52", "IT-AR", "IT-GR", "IT-LI", "IT-LU", "IT-MS", "IT-PI", "IT-PO", "IT-PT", "IT-SI", "IT-FI", "IT-55", "IT-PG", "IT-TR", "IT-57", "IT-AN", "IT-AP", "IT-FM", "IT-MC", "IT-PU", "IT-62", "IT-FR", "IT-LT", "IT-RI", "IT-VT", "IT-RM", "IT-65", "IT-AQ", "IT-CH", "IT-PE", "IT-TE", "IT-67", "IT-CB", "IT-IS", "IT-72", "IT-AV", "IT-BN", "IT-CE", "IT-SA", "IT-NA", "IT-75", "IT-BR", "IT-BT", "IT-FG", "IT-LE", "IT-TA", "IT-BA", "IT-77", "IT-MT", "IT-PZ", "IT-78", "IT-CS", "IT-CZ", "IT-KR", "IT-VV", "IT-RC", "IT-23", "IT-32", "IT-BZ", "IT-TN", "IT-36", "IT-GO", "IT-PN", "IT-TS", "IT-UD", "IT-82", "IT-AG", "IT-CL", "IT-EN", "IT-RG", "IT-SR", "IT-TP", "IT-CT", "IT-ME", "IT-PA", "IT-88", "IT-NU", "IT-OR", "IT-SS", "IT-SU", "IT-CA", "JM-01", "JM-02", "JM-03", "JM-04", "JM-05", "JM-06", "JM-07", "JM-08", "JM-09", "JM-10", "JM-11", "JM-12", "JM-13", "JM-14", "JO-AJ", "JO-AM", "JO-AQ", "JO-AT", "JO-AZ", "JO-BA", "JO-IR", "JO-JA", "JO-KA", "JO-MA", "JO-MD", "JO-MN", "JP-01", "JP-02", "JP-03", "JP-04", "JP-05", "JP-06", "JP-07", "JP-08", "JP-09", "JP-10", "JP-11", "JP-12", "JP-13", "JP-14", "JP-15", "JP-16", "JP-17", "JP-18", "JP-19", "JP-20", "JP-21", "JP-22", "JP-23", "JP-24", "JP-25", "JP-26", "JP-27", "JP-28", "JP-29", "JP-30", "JP-31", "JP-32", "JP-33", "JP-34", "JP-35", "JP-36", "JP-37", "JP-38", "JP-39", "JP-40", "JP-41", "JP-42", "JP-43", "JP-44", "JP-45", "JP-46", "JP-47", "KE-01", "KE-02", "KE-03", "KE-04", "KE-05", "KE-06", "KE-07", "KE-08", "KE-09", "KE-10", "KE-11", "KE-12", "KE-13", "KE-14", "KE-15", "KE-16", "KE-17", "KE-18", "KE-19", "KE-20", "KE-21", "KE-22", "KE-23", "KE-24", "KE-25", "KE-26", "KE-27", "KE-28", "KE-29", "KE-30", "KE-31", "KE-32", "KE-33", "KE-34", "KE-35", "KE-36", "KE-37", "KE-38", "KE-39", "KE-40", "KE-41", "KE-42", "KE-43", "KE-44", "KE-45", "KE-46", "KE-47", "KG-GB", "KG-GO", "KG-B", "KG-C", "KG-J", "KG-N", "KG-O", "KG-T", "KG-Y", "KH-12", "KH-1", "KH-10", "KH-11", "KH-13", "KH-14", "KH-15", "KH-16", "KH-17", "KH-18", "KH-19", "KH-2", "KH-20", "KH-21", "KH-22", "KH-23", "KH-24", "KH-25", "KH-3", "KH-4", "KH-5", "KH-6", "KH-7", "KH-8", "KH-9", "KI-G", "KI-L", "KI-P", "KM-A", "KM-G", "KM-M", "KN-K", "KN-01", "KN-02", "KN-03", "KN-06", "KN-08", "KN-09", "KN-11", "KN-13", "KN-15", "KN-N", "KN-04", "KN-05", "KN-07", "KN-10", "KN-12", "KP-13", "KP-02", "KP-03", "KP-04", "KP-05", "KP-06", "KP-07", "KP-08", "KP-09", "KP-10", "KP-01", "KP-14", "KR-11", "KR-26", "KR-27", "KR-28", "KR-29", "KR-30", "KR-31", "KR-41", "KR-42", "KR-43", "KR-44", "KR-45", "KR-46", "KR-47", "KR-48", "KR-49", "KR-50", "KW-AH", "KW-FA", "KW-HA", "KW-JA", "KW-KU", "KW-MU", "KZ-AKM", "KZ-AKT", "KZ-ALM", "KZ-ATY", "KZ-KAR", "KZ-KUS", "KZ-KZY", "KZ-MAN", "KZ-PAV", "KZ-SEV", "KZ-VOS", "KZ-YUZ", "KZ-ZAP", "KZ-ZHA", "KZ-ALA", "KZ-AST", "KZ-SHY", "LA-VT", "LA-AT", "LA-BK", "LA-BL", "LA-CH", "LA-HO", "LA-KH", "LA-LM", "LA-LP", "LA-OU", "LA-PH", "LA-SL", "LA-SV", "LA-VI", "LA-XA", "LA-XE", "LA-XI", "LA-XS", "LB-AK", "LB-AS", "LB-BA", "LB-BH", "LB-BI", "LB-JA", "LB-JL", "LB-NA", "LC-01", "LC-02", "LC-03", "LC-05", "LC-06", "LC-07", "LC-08", "LC-10", "LC-11", "LC-12", "LI-01", "LI-02", "LI-03", "LI-04", "LI-05", "LI-06", "LI-07", "LI-08", "LI-09", "LI-10", "LI-11", "LK-1", "LK-11", "LK-12", "LK-13", "LK-2", "LK-21", "LK-22", "LK-23", "LK-3", "LK-31", "LK-32", "LK-33", "LK-4", "LK-41", "LK-42", "LK-43", "LK-44", "LK-45", "LK-5", "LK-51", "LK-52", "LK-53", "LK-6", "LK-61", "LK-62", "LK-7", "LK-71", "LK-72", "LK-8", "LK-81", "LK-82", "LK-9", "LK-91", "LK-92", "LR-BG", "LR-BM", "LR-CM", "LR-GB", "LR-GG", "LR-GK", "LR-GP", "LR-LO", "LR-MG", "LR-MO", "LR-MY", "LR-NI", "LR-RG", "LR-RI", "LR-SI", "LS-A", "LS-B", "LS-C", "LS-D", "LS-E", "LS-F", "LS-G", "LS-H", "LS-J", "LS-K", "LT-AL", "LT-02", "LT-03", "LT-24", "LT-55", "LT-07", "LT-KL", "LT-20", "LT-31", "LT-21", "LT-22", "LT-46", "LT-48", "LT-28", "LT-KU", "LT-15", "LT-10", "LT-13", "LT-16", "LT-18", "LT-36", "LT-38", "LT-05", "LT-MR", "LT-25", "LT-41", "LT-56", "LT-14", "LT-17", "LT-PN", "LT-32", "LT-06", "LT-23", "LT-33", "LT-34", "LT-40", "LT-SA", "LT-43", "LT-01", "LT-11", "LT-19", "LT-30", "LT-37", "LT-44", "LT-TA", "LT-12", "LT-45", "LT-50", "LT-29", "LT-TE", "LT-26", "LT-35", "LT-51", "LT-39", "LT-UT", "LT-04", "LT-09", "LT-27", "LT-54", "LT-60", "LT-59", "LT-VL", "LT-57", "LT-42", "LT-47", "LT-49", "LT-52", "LT-53", "LT-58", "LT-08", "LU-CA", "LU-CL", "LU-DI", "LU-EC", "LU-ES", "LU-GR", "LU-LU", "LU-ME", "LU-RD", "LU-RM", "LU-VD", "LU-WI", "LV-002", "LV-007", "LV-011", "LV-015", "LV-016", "LV-022", "LV-026", "LV-033", "LV-041", "LV-042", "LV-047", "LV-050", "LV-052", "LV-054", "LV-056", "LV-058", "LV-059", "LV-062", "LV-067", "LV-068", "LV-073", "LV-077", "LV-080", "LV-087", "LV-088", "LV-089", "LV-091", "LV-094", "LV-097", "LV-099", "LV-101", "LV-102", "LV-106", "LV-111", "LV-112", "LV-113", "LV-DGV", "LV-JEL", "LV-JUR", "LV-LPX", "LV-REZ", "LV-RIX", "LV-VEN", "LY-BA", "LY-BU", "LY-DR", "LY-GT", "LY-JA", "LY-JG", "LY-JI", "LY-JU", "LY-KF", "LY-MB", "LY-MI", "LY-MJ", "LY-MQ", "LY-NL", "LY-NQ", "LY-SB", "LY-SR", "LY-TB", "LY-WA", "LY-WD", "LY-WS", "LY-ZA", "MA-01", "MA-MDF", "MA-TNG", "MA-CHE", "MA-FAH", "MA-HOC", "MA-LAR", "MA-OUZ", "MA-TET", "MA-02", "MA-OUJ", "MA-BER", "MA-DRI", "MA-FIG", "MA-GUF", "MA-JRA", "MA-NAD", "MA-TAI", "MA-03", "MA-FES", "MA-MEK", "MA-BOM", "MA-HAJ", "MA-IFR", "MA-MOU", "MA-SEF", "MA-TAO", "MA-TAZ", "MA-04", "MA-RAB", "MA-SAL", "MA-SKH", "MA-KEN", "MA-KHE", "MA-NOU", "MA-SIK", "MA-SIL", "MA-05", "MA-AZI", "MA-BEM", "MA-FQH", "MA-KHN", "MA-KHO", "MA-06", "MA-CAS", "MA-MOH", "MA-BES", "MA-BRR", "MA-CHT", "MA-JDI", "MA-MED", "MA-SET", "MA-SIB", "MA-07", "MA-MAR", "MA-CHI", "MA-ESI", "MA-HAO", "MA-KES", "MA-REH", "MA-SAF", "MA-YUS", "MA-08", "MA-ERR", "MA-MID", "MA-OUA", "MA-TIN", "MA-ZAG", "MA-09", "MA-AGD", "MA-INE", "MA-TAR", "MA-TAT", "MA-TIZ", "MA-10", "MA-ASZ", "MA-GUE", "MA-SIF", "MA-TNT", "MA-11", "MA-BOD", "MA-ESM", "MA-LAA", "MA-TAF", "MA-12", "MA-AOU", "MA-OUD", "MC-CL", "MC-CO", "MC-FO", "MC-GA", "MC-JE", "MC-LA", "MC-MA", "MC-MC", "MC-MG", "MC-MO", "MC-MU", "MC-PH", "MC-SD", "MC-SO", "MC-SP", "MC-SR", "MC-VR", "MD-AN", "MD-BR", "MD-BS", "MD-CA", "MD-CL", "MD-CM", "MD-CR", "MD-CS", "MD-CT", "MD-DO", "MD-DR", "MD-DU", "MD-ED", "MD-FA", "MD-FL", "MD-GL", "MD-HI", "MD-IA", "MD-LE", "MD-NI", "MD-OC", "MD-OR", "MD-RE", "MD-RI", "MD-SD", "MD-SI", "MD-SO", "MD-ST", "MD-SV", "MD-TA", "MD-TE", "MD-UN", "MD-GA", "MD-SN", "MD-BA", "MD-BD", "MD-CU", "ME-01", "ME-02", "ME-03", "ME-04", "ME-05", "ME-06", "ME-07", "ME-08", "ME-09", "ME-10", "ME-11", "ME-12", "ME-13", "ME-14", "ME-15", "ME-16", "ME-17", "ME-18", "ME-19", "ME-20", "ME-21", "ME-22", "ME-23", "ME-24", "MG-A", "MG-D", "MG-F", "MG-M", "MG-T", "MG-U", "MH-L", "MH-ALL", "MH-EBO", "MH-ENI", "MH-JAB", "MH-JAL", "MH-KIL", "MH-KWA", "MH-LAE", "MH-LIB", "MH-NMK", "MH-NMU", "MH-RON", "MH-UJA", "MH-WTH", "MH-T", "MH-ALK", "MH-ARN", "MH-AUR", "MH-LIK", "MH-MAJ", "MH-MAL", "MH-MEJ", "MH-MIL", "MH-UTI", "MH-WTJ", "MK-101", "MK-102", "MK-103", "MK-104", "MK-105", "MK-106", "MK-107", "MK-108", "MK-109", "MK-201", "MK-202", "MK-203", "MK-204", "MK-205", "MK-206", "MK-207", "MK-208", "MK-209", "MK-210", "MK-211", "MK-301", "MK-303", "MK-304", "MK-307", "MK-308", "MK-310", "MK-311", "MK-312", "MK-313", "MK-401", "MK-402", "MK-403", "MK-404", "MK-405", "MK-406", "MK-407", "MK-408", "MK-409", "MK-410", "MK-501", "MK-502", "MK-503", "MK-504", "MK-505", "MK-506", "MK-507", "MK-508", "MK-509", "MK-601", "MK-602", "MK-603", "MK-604", "MK-605", "MK-606", "MK-607", "MK-608", "MK-609", "MK-701", "MK-702", "MK-703", "MK-704", "MK-705", "MK-706", "MK-801", "MK-802", "MK-803", "MK-804", "MK-805", "MK-806", "MK-807", "MK-808", "MK-809", "MK-810", "MK-811", "MK-812", "MK-813", "MK-814", "MK-815", "MK-816", "MK-817", "ML-1", "ML-10", "ML-2", "ML-3", "ML-4", "ML-5", "ML-6", "ML-7", "ML-8", "ML-9", "ML-BKO", "MM-01", "MM-02", "MM-03", "MM-04", "MM-05", "MM-06", "MM-07", "MM-11", "MM-12", "MM-13", "MM-14", "MM-15", "MM-16", "MM-17", "MM-18", "MN-035", "MN-037", "MN-039", "MN-041", "MN-043", "MN-046", "MN-047", "MN-049", "MN-051", "MN-053", "MN-055", "MN-057", "MN-059", "MN-061", "MN-063", "MN-064", "MN-065", "MN-067", "MN-069", "MN-071", "MN-073", "MN-1", "MR-01", "MR-02", "MR-03", "MR-04", "MR-05", "MR-06", "MR-07", "MR-08", "MR-09", "MR-10", "MR-11", "MR-12", "MR-13", "MR-14", "MR-15", "MT-01", "MT-02", "MT-03", "MT-04", "MT-05", "MT-06", "MT-07", "MT-08", "MT-09", "MT-10", "MT-11", "MT-12", "MT-13", "MT-14", "MT-15", "MT-16", "MT-17", "MT-18", "MT-19", "MT-20", "MT-21", "MT-22", "MT-23", "MT-24", "MT-25", "MT-26", "MT-27", "MT-28", "MT-29", "MT-30", "MT-31", "MT-32", "MT-33", "MT-34", "MT-35", "MT-36", "MT-37", "MT-38", "MT-39", "MT-40", "MT-41", "MT-42", "MT-43", "MT-44", "MT-45", "MT-46", "MT-47", "MT-48", "MT-49", "MT-50", "MT-51", "MT-52", "MT-53", "MT-54", "MT-55", "MT-56", "MT-57", "MT-58", "MT-59", "MT-60", "MT-61", "MT-62", "MT-63", "MT-64", "MT-65", "MT-66", "MT-67", "MT-68", "MU-BL", "MU-FL", "MU-GP", "MU-MO", "MU-PA", "MU-PL", "MU-PW", "MU-RR", "MU-SA", "MU-AG", "MU-CC", "MU-RO", "MV-00", "MV-02", "MV-03", "MV-04", "MV-05", "MV-07", "MV-08", "MV-12", "MV-13", "MV-14", "MV-17", "MV-20", "MV-23", "MV-24", "MV-25", "MV-26", "MV-27", "MV-28", "MV-29", "MV-01", "MV-MLE", "MW-C", "MW-DE", "MW-DO", "MW-KS", "MW-LI", "MW-MC", "MW-NI", "MW-NK", "MW-NU", "MW-SA", "MW-N", "MW-CT", "MW-KR", "MW-LK", "MW-MZ", "MW-NB", "MW-RU", "MW-S", "MW-BA", "MW-BL", "MW-CK", "MW-CR", "MW-MG", "MW-MH", "MW-MU", "MW-MW", "MW-NE", "MW-NS", "MW-PH", "MW-TH", "MW-ZO", "MX-AGU", "MX-BCN", "MX-BCS", "MX-CAM", "MX-CHH", "MX-CHP", "MX-COA", "MX-COL", "MX-DUR", "MX-GRO", "MX-GUA", "MX-HID", "MX-JAL", "MX-MEX", "MX-MIC", "MX-MOR", "MX-NAY", "MX-NLE", "MX-OAX", "MX-PUE", "MX-QUE", "MX-ROO", "MX-SIN", "MX-SLP", "MX-SON", "MX-TAB", "MX-TAM", "MX-TLA", "MX-VER", "MX-YUC", "MX-ZAC", "MX-CMX", "MY-14", "MY-15", "MY-16", "MY-01", "MY-02", "MY-03", "MY-04", "MY-05", "MY-06", "MY-07", "MY-08", "MY-09", "MY-10", "MY-11", "MY-12", "MY-13", "MZ-MPM", "MZ-A", "MZ-B", "MZ-G", "MZ-I", "MZ-L", "MZ-N", "MZ-P", "MZ-Q", "MZ-S", "MZ-T", "NA-CA", "NA-ER", "NA-HA", "NA-KA", "NA-KE", "NA-KH", "NA-KU", "NA-KW", "NA-OD", "NA-OH", "NA-ON", "NA-OS", "NA-OT", "NA-OW", "NE-8", "NE-1", "NE-2", "NE-3", "NE-4", "NE-5", "NE-6", "NE-7", "NG-AB", "NG-AD", "NG-AK", "NG-AN", "NG-BA", "NG-BE", "NG-BO", "NG-BY", "NG-CR", "NG-DE", "NG-EB", "NG-ED", "NG-EK", "NG-EN", "NG-GO", "NG-IM", "NG-JI", "NG-KD", "NG-KE", "NG-KN", "NG-KO", "NG-KT", "NG-KW", "NG-LA", "NG-NA", "NG-NI", "NG-OG", "NG-ON", "NG-OS", "NG-OY", "NG-PL", "NG-RI", "NG-SO", "NG-TA", "NG-YO", "NG-ZA", "NG-FC", "NI-BO", "NI-CA", "NI-CI", "NI-CO", "NI-ES", "NI-GR", "NI-JI", "NI-LE", "NI-MD", "NI-MN", "NI-MS", "NI-MT", "NI-NS", "NI-RI", "NI-SJ", "NI-AN", "NI-AS", "NL-DR", "NL-FL", "NL-FR", "NL-GE", "NL-GR", "NL-LI", "NL-NB", "NL-NH", "NL-OV", "NL-UT", "NL-ZE", "NL-ZH", "NL-AW", "NL-CW", "NL-SX", "NL-BQ1", "NL-BQ2", "NL-BQ3", "NO-03", "NO-11", "NO-15", "NO-18", "NO-30", "NO-34", "NO-38", "NO-42", "NO-46", "NO-50", "NO-54", "NO-21", "NO-22", "NP-1", "NP-BA", "NP-JA", "NP-NA", "NP-2", "NP-BH", "NP-KA", "NP-RA", "NP-3", "NP-DH", "NP-GA", "NP-LU", "NP-4", "NP-KO", "NP-ME", "NP-SA", "NP-5", "NP-MA", "NP-SE", "NP-P1", "NP-P2", "NP-P3", "NP-P4", "NP-P5", "NP-P6", "NP-P7", "NR-01", "NR-02", "NR-03", "NR-04", "NR-05", "NR-06", "NR-07", "NR-08", "NR-09", "NR-10", "NR-11", "NR-12", "NR-13", "NR-14", "NZ-AUK", "NZ-BOP", "NZ-CAN", "NZ-GIS", "NZ-HKB", "NZ-MBH", "NZ-MWT", "NZ-NSN", "NZ-NTL", "NZ-OTA", "NZ-STL", "NZ-TAS", "NZ-TKI", "NZ-WGN", "NZ-WKO", "NZ-WTC", "NZ-CIT", "OM-BJ", "OM-BS", "OM-BU", "OM-DA", "OM-MA", "OM-MU", "OM-SJ", "OM-SS", "OM-WU", "OM-ZA", "OM-ZU", "PA-EM", "PA-KY", "PA-NB", "PA-NT", "PA-1", "PA-10", "PA-2", "PA-3", "PA-4", "PA-5", "PA-6", "PA-7", "PA-8", "PA-9", "PE-AMA", "PE-ANC", "PE-APU", "PE-ARE", "PE-AYA", "PE-CAJ", "PE-CAL", "PE-CUS", "PE-HUC", "PE-HUV", "PE-ICA", "PE-JUN", "PE-LAL", "PE-LAM", "PE-LIM", "PE-LOR", "PE-MDD", "PE-MOQ", "PE-PAS", "PE-PIU", "PE-PUN", "PE-SAM", "PE-TAC", "PE-TUM", "PE-UCA", "PE-LMA", "PG-NCD", "PG-CPK", "PG-CPM", "PG-EBR", "PG-EHG", "PG-EPW", "PG-ESW", "PG-GPK", "PG-HLA", "PG-JWK", "PG-MBA", "PG-MPL", "PG-MPM", "PG-MRL", "PG-NIK", "PG-NPP", "PG-SAN", "PG-SHM", "PG-WBK", "PG-WHM", "PG-WPD", "PG-NSB", "PH-00", "PH-01", "PH-ILN", "PH-ILS", "PH-LUN", "PH-PAN", "PH-02", "PH-BTN", "PH-CAG", "PH-ISA", "PH-NUV", "PH-QUI", "PH-03", "PH-AUR", "PH-BAN", "PH-BUL", "PH-NUE", "PH-PAM", "PH-TAR", "PH-ZMB", "PH-05", "PH-ALB", "PH-CAN", "PH-CAS", "PH-CAT", "PH-MAS", "PH-SOR", "PH-06", "PH-AKL", "PH-ANT", "PH-CAP", "PH-GUI", "PH-ILI", "PH-NEC", "PH-07", "PH-BOH", "PH-CEB", "PH-NER", "PH-SIG", "PH-08", "PH-BIL", "PH-EAS", "PH-LEY", "PH-NSA", "PH-SLE", "PH-WSA", "PH-09", "PH-BAS", "PH-ZAN", "PH-ZAS", "PH-ZSI", "PH-10", "PH-BUK", "PH-CAM", "PH-MSC", "PH-MSR", "PH-11", "PH-COM", "PH-DAO", "PH-DAS", "PH-DAV", "PH-DVO", "PH-SAR", "PH-SCO", "PH-12", "PH-LAN", "PH-NCO", "PH-SUK", "PH-13", "PH-AGN", "PH-AGS", "PH-DIN", "PH-SUN", "PH-SUR", "PH-14", "PH-LAS", "PH-MAG", "PH-SLU", "PH-TAW", "PH-15", "PH-ABR", "PH-APA", "PH-BEN", "PH-IFU", "PH-KAL", "PH-MOU", "PH-40", "PH-BTG", "PH-CAV", "PH-LAG", "PH-QUE", "PH-RIZ", "PH-41", "PH-MAD", "PH-MDC", "PH-MDR", "PH-PLW", "PH-ROM", "PK-GB", "PK-JK", "PK-IS", "PK-BA", "PK-KP", "PK-PB", "PK-SD", "PL-02", "PL-04", "PL-06", "PL-08", "PL-10", "PL-12", "PL-14", "PL-16", "PL-18", "PL-20", "PL-22", "PL-24", "PL-26", "PL-28", "PL-30", "PL-32", "PS-BTH", "PS-DEB", "PS-GZA", "PS-HBN", "PS-JEM", "PS-JEN", "PS-JRH", "PS-KYS", "PS-NBS", "PS-NGZ", "PS-QQA", "PS-RBH", "PS-RFH", "PS-SLT", "PS-TBS", "PS-TKM", "PT-20", "PT-30", "PT-01", "PT-02", "PT-03", "PT-04", "PT-05", "PT-06", "PT-07", "PT-08", "PT-09", "PT-10", "PT-11", "PT-12", "PT-13", "PT-14", "PT-15", "PT-16", "PT-17", "PT-18", "PW-002", "PW-004", "PW-010", "PW-050", "PW-100", "PW-150", "PW-212", "PW-214", "PW-218", "PW-222", "PW-224", "PW-226", "PW-227", "PW-228", "PW-350", "PW-370", "PY-1", "PY-10", "PY-11", "PY-12", "PY-13", "PY-14", "PY-15", "PY-16", "PY-19", "PY-2", "PY-3", "PY-4", "PY-5", "PY-6", "PY-7", "PY-8", "PY-9", "PY-ASU", "QA-DA", "QA-KH", "QA-MS", "QA-RA", "QA-SH", "QA-US", "QA-WA", "QA-ZA", "RO-AB", "RO-AG", "RO-AR", "RO-BC", "RO-BH", "RO-BN", "RO-BR", "RO-BT", "RO-BV", "RO-BZ", "RO-CJ", "RO-CL", "RO-CS", "RO-CT", "RO-CV", "RO-DB", "RO-DJ", "RO-GJ", "RO-GL", "RO-GR", "RO-HD", "RO-HR", "RO-IF", "RO-IL", "RO-IS", "RO-MH", "RO-MM", "RO-MS", "RO-NT", "RO-OT", "RO-PH", "RO-SB", "RO-SJ", "RO-SM", "RO-SV", "RO-TL", "RO-TM", "RO-TR", "RO-VL", "RO-VN", "RO-VS", "RO-B", "RS-KM", "RS-25", "RS-26", "RS-27", "RS-28", "RS-29", "RS-VO", "RS-01", "RS-02", "RS-03", "RS-04", "RS-05", "RS-06", "RS-07", "RS-00", "RS-08", "RS-09", "RS-10", "RS-11", "RS-12", "RS-13", "RS-14", "RS-15", "RS-16", "RS-17", "RS-18", "RS-19", "RS-20", "RS-21", "RS-22", "RS-23", "RS-24", "RU-AMU", "RU-ARK", "RU-AST", "RU-BEL", "RU-BRY", "RU-CHE", "RU-IRK", "RU-IVA", "RU-KEM", "RU-KGD", "RU-KGN", "RU-KIR", "RU-KLU", "RU-KOS", "RU-KRS", "RU-LEN", "RU-LIP", "RU-MAG", "RU-MOS", "RU-MUR", "RU-NGR", "RU-NIZ", "RU-NVS", "RU-OMS", "RU-ORE", "RU-ORL", "RU-PNZ", "RU-PSK", "RU-ROS", "RU-RYA", "RU-SAK", "RU-SAM", "RU-SAR", "RU-SMO", "RU-SVE", "RU-TAM", "RU-TOM", "RU-TUL", "RU-TVE", "RU-TYU", "RU-ULY", "RU-VGG", "RU-VLA", "RU-VLG", "RU-VOR", "RU-YAR", "RU-CHU", "RU-KHM", "RU-NEN", "RU-YAN", "RU-MOW", "RU-SPE", "RU-YEV", "RU-ALT", "RU-KAM", "RU-KDA", "RU-KHA", "RU-KYA", "RU-PER", "RU-PRI", "RU-STA", "RU-ZAB", "RU-AD", "RU-AL", "RU-BA", "RU-BU", "RU-CE", "RU-CU", "RU-DA", "RU-IN", "RU-KB", "RU-KC", "RU-KK", "RU-KL", "RU-KO", "RU-KR", "RU-ME", "RU-MO", "RU-SA", "RU-SE", "RU-TA", "RU-TY", "RU-UD", "RW-01", "RW-02", "RW-03", "RW-04", "RW-05", "SA-01", "SA-02", "SA-03", "SA-04", "SA-05", "SA-06", "SA-07", "SA-08", "SA-09", "SA-10", "SA-11", "SA-12", "SA-14", "SB-CE", "SB-CH", "SB-GU", "SB-IS", "SB-MK", "SB-ML", "SB-RB", "SB-TE", "SB-WE", "SB-CT", "SC-01", "SC-02", "SC-03", "SC-04", "SC-05", "SC-06", "SC-07", "SC-08", "SC-09", "SC-10", "SC-11", "SC-12", "SC-13", "SC-14", "SC-15", "SC-16", "SC-17", "SC-18", "SC-19", "SC-20", "SC-21", "SC-22", "SC-23", "SC-24", "SC-25", "SC-26", "SC-27", "SD-DC", "SD-DE", "SD-DN", "SD-DS", "SD-DW", "SD-GD", "SD-GK", "SD-GZ", "SD-KA", "SD-KH", "SD-KN", "SD-KS", "SD-NB", "SD-NO", "SD-NR", "SD-NW", "SD-RS", "SD-SI", "SE-AB", "SE-AC", "SE-BD", "SE-C", "SE-D", "SE-E", "SE-F", "SE-G", "SE-H", "SE-I", "SE-K", "SE-M", "SE-N", "SE-O", "SE-S", "SE-T", "SE-U", "SE-W", "SE-X", "SE-Y", "SE-Z", "SG-01", "SG-02", "SG-03", "SG-04", "SG-05", "SH-AC", "SH-HL", "SH-TA", "SI-001", "SI-002", "SI-003", "SI-004", "SI-005", "SI-006", "SI-007", "SI-008", "SI-009", "SI-010", "SI-011", "SI-012", "SI-013", "SI-014", "SI-015", "SI-016", "SI-017", "SI-018", "SI-019", "SI-020", "SI-021", "SI-022", "SI-023", "SI-024", "SI-025", "SI-026", "SI-027", "SI-028", "SI-029", "SI-030", "SI-031", "SI-032", "SI-033", "SI-034", "SI-035", "SI-036", "SI-037", "SI-038", "SI-039", "SI-040", "SI-041", "SI-042", "SI-043", "SI-044", "SI-045", "SI-046", "SI-047", "SI-048", "SI-049", "SI-050", "SI-051", "SI-052", "SI-053", "SI-054", "SI-055", "SI-056", "SI-057", "SI-058", "SI-059", "SI-060", "SI-061", "SI-062", "SI-063", "SI-064", "SI-065", "SI-066", "SI-067", "SI-068", "SI-069", "SI-070", "SI-071", "SI-072", "SI-073", "SI-074", "SI-075", "SI-076", "SI-077", "SI-078", "SI-079", "SI-080", "SI-081", "SI-082", "SI-083", "SI-084", "SI-085", "SI-086", "SI-087", "SI-088", "SI-089", "SI-090", "SI-091", "SI-092", "SI-093", "SI-094", "SI-095", "SI-096", "SI-097", "SI-098", "SI-099", "SI-100", "SI-101", "SI-102", "SI-103", "SI-104", "SI-105", "SI-106", "SI-107", "SI-108", "SI-109", "SI-110", "SI-111", "SI-112", "SI-113", "SI-114", "SI-115", "SI-116", "SI-117", "SI-118", "SI-119", "SI-120", "SI-121", "SI-122", "SI-123", "SI-124", "SI-125", "SI-126", "SI-127", "SI-128", "SI-129", "SI-130", "SI-131", "SI-132", "SI-133", "SI-134", "SI-135", "SI-136", "SI-137", "SI-138", "SI-139", "SI-140", "SI-141", "SI-142", "SI-143", "SI-144", "SI-146", "SI-147", "SI-148", "SI-149", "SI-150", "SI-151", "SI-152", "SI-153", "SI-154", "SI-155", "SI-156", "SI-157", "SI-158", "SI-159", "SI-160", "SI-161", "SI-162", "SI-163", "SI-164", "SI-165", "SI-166", "SI-167", "SI-168", "SI-169", "SI-170", "SI-171", "SI-172", "SI-173", "SI-174", "SI-175", "SI-176", "SI-177", "SI-178", "SI-179", "SI-180", "SI-181", "SI-182", "SI-183", "SI-184", "SI-185", "SI-186", "SI-187", "SI-188", "SI-189", "SI-190", "SI-191", "SI-192", "SI-193", "SI-194", "SI-195", "SI-196", "SI-197", "SI-198", "SI-199", "SI-200", "SI-201", "SI-202", "SI-203", "SI-204", "SI-205", "SI-206", "SI-207", "SI-208", "SI-209", "SI-210", "SI-211", "SI-212", "SI-213", "SK-BC", "SK-BL", "SK-KI", "SK-NI", "SK-PV", "SK-TA", "SK-TC", "SK-ZI", "SL-W", "SL-E", "SL-N", "SL-NW", "SL-S", "SM-01", "SM-02", "SM-03", "SM-04", "SM-05", "SM-06", "SM-07", "SM-08", "SM-09", "SN-DB", "SN-DK", "SN-FK", "SN-KA", "SN-KD", "SN-KE", "SN-KL", "SN-LG", "SN-MT", "SN-SE", "SN-SL", "SN-TC", "SN-TH", "SN-ZG", "SO-AW", "SO-BK", "SO-BN", "SO-BR", "SO-BY", "SO-GA", "SO-GE", "SO-HI", "SO-JD", "SO-JH", "SO-MU", "SO-NU", "SO-SA", "SO-SD", "SO-SH", "SO-SO", "SO-TO", "SO-WO", "SR-BR", "SR-CM", "SR-CR", "SR-MA", "SR-NI", "SR-PM", "SR-PR", "SR-SA", "SR-SI", "SR-WA", "SS-BN", "SS-BW", "SS-EC", "SS-EE", "SS-EW", "SS-JG", "SS-LK", "SS-NU", "SS-UY", "SS-WR", "ST-P", "ST-01", "ST-02", "ST-03", "ST-04", "ST-05", "ST-06", "SV-AH", "SV-CA", "SV-CH", "SV-CU", "SV-LI", "SV-MO", "SV-PA", "SV-SA", "SV-SM", "SV-SO", "SV-SS", "SV-SV", "SV-UN", "SV-US", "SY-DI", "SY-DR", "SY-DY", "SY-HA", "SY-HI", "SY-HL", "SY-HM", "SY-ID", "SY-LA", "SY-QU", "SY-RA", "SY-RD", "SY-SU", "SY-TA", "SZ-HH", "SZ-LU", "SZ-MA", "SZ-SH", "TD-BA", "TD-BG", "TD-BO", "TD-CB", "TD-EE", "TD-EO", "TD-GR", "TD-HL", "TD-KA", "TD-LC", "TD-LO", "TD-LR", "TD-MA", "TD-MC", "TD-ME", "TD-MO", "TD-ND", "TD-OD", "TD-SA", "TD-SI", "TD-TA", "TD-TI", "TD-WF", "TG-C", "TG-K", "TG-M", "TG-P", "TG-S", "TH-10", "TH-11", "TH-12", "TH-13", "TH-14", "TH-15", "TH-16", "TH-17", "TH-18", "TH-19", "TH-20", "TH-21", "TH-22", "TH-23", "TH-24", "TH-25", "TH-26", "TH-27", "TH-30", "TH-31", "TH-32", "TH-33", "TH-34", "TH-35", "TH-36", "TH-37", "TH-38", "TH-39", "TH-40", "TH-41", "TH-42", "TH-43", "TH-44", "TH-45", "TH-46", "TH-47", "TH-48", "TH-49", "TH-50", "TH-51", "TH-52", "TH-53", "TH-54", "TH-55", "TH-56", "TH-57", "TH-58", "TH-60", "TH-61", "TH-62", "TH-63", "TH-64", "TH-65", "TH-66", "TH-67", "TH-70", "TH-71", "TH-72", "TH-73", "TH-74", "TH-75", "TH-76", "TH-77", "TH-80", "TH-81", "TH-82", "TH-83", "TH-84", "TH-85", "TH-86", "TH-90", "TH-91", "TH-92", "TH-93", "TH-94", "TH-95", "TH-96", "TH-S", "TJ-KT", "TJ-SU", "TJ-GB", "TJ-DU", "TJ-RA", "TL-AL", "TL-AN", "TL-BA", "TL-BO", "TL-CO", "TL-DI", "TL-ER", "TL-LA", "TL-LI", "TL-MF", "TL-MT", "TL-VI", "TL-OE", "TM-S", "TM-A", "TM-B", "TM-D", "TM-L", "TM-M", "TN-11", "TN-12", "TN-13", "TN-14", "TN-21", "TN-22", "TN-23", "TN-31", "TN-32", "TN-33", "TN-34", "TN-41", "TN-42", "TN-43", "TN-51", "TN-52", "TN-53", "TN-61", "TN-71", "TN-72", "TN-73", "TN-81", "TN-82", "TN-83", "TO-01", "TO-02", "TO-03", "TO-04", "TO-05", "TR-01", "TR-02", "TR-03", "TR-04", "TR-05", "TR-06", "TR-07", "TR-08", "TR-09", "TR-10", "TR-11", "TR-12", "TR-13", "TR-14", "TR-15", "TR-16", "TR-17", "TR-18", "TR-19", "TR-20", "TR-21", "TR-22", "TR-23", "TR-24", "TR-25", "TR-26", "TR-27", "TR-28", "TR-29", "TR-30", "TR-31", "TR-32", "TR-33", "TR-34", "TR-35", "TR-36", "TR-37", "TR-38", "TR-39", "TR-40", "TR-41", "TR-42", "TR-43", "TR-44", "TR-45", "TR-46", "TR-47", "TR-48", "TR-49", "TR-50", "TR-51", "TR-52", "TR-53", "TR-54", "TR-55", "TR-56", "TR-57", "TR-58", "TR-59", "TR-60", "TR-61", "TR-62", "TR-63", "TR-64", "TR-65", "TR-66", "TR-67", "TR-68", "TR-69", "TR-70", "TR-71", "TR-72", "TR-73", "TR-74", "TR-75", "TR-76", "TR-77", "TR-78", "TR-79", "TR-80", "TR-81", "TT-ARI", "TT-CHA", "TT-PTF", "TT-CTT", "TT-DMN", "TT-MRC", "TT-PED", "TT-PRT", "TT-SGE", "TT-SIP", "TT-SJL", "TT-TUP", "TT-TOB", "TT-POS", "TT-SFO", "TV-NIT", "TV-NKF", "TV-NKL", "TV-NMA", "TV-NMG", "TV-NUI", "TV-VAI", "TV-FUN", "TW-CYI", "TW-HSZ", "TW-KEE", "TW-KHH", "TW-NWT", "TW-TAO", "TW-TNN", "TW-TPE", "TW-TXG", "TW-CHA", "TW-CYQ", "TW-HSQ", "TW-HUA", "TW-ILA", "TW-KIN", "TW-LIE", "TW-MIA", "TW-NAN", "TW-PEN", "TW-PIF", "TW-TTT", "TW-YUN", "TZ-01", "TZ-02", "TZ-03", "TZ-04", "TZ-05", "TZ-06", "TZ-07", "TZ-08", "TZ-09", "TZ-10", "TZ-11", "TZ-12", "TZ-13", "TZ-14", "TZ-15", "TZ-16", "TZ-17", "TZ-18", "TZ-19", "TZ-20", "TZ-21", "TZ-22", "TZ-23", "TZ-24", "TZ-25", "TZ-26", "TZ-27", "TZ-28", "TZ-29", "TZ-30", "TZ-31", "UA-30", "UA-40", "UA-05", "UA-07", "UA-09", "UA-12", "UA-14", "UA-18", "UA-21", "UA-23", "UA-26", "UA-32", "UA-35", "UA-46", "UA-48", "UA-51", "UA-53", "UA-56", "UA-59", "UA-61", "UA-63", "UA-65", "UA-68", "UA-71", "UA-74", "UA-77", "UA-43", "UG-C", "UG-101", "UG-103", "UG-104", "UG-105", "UG-106", "UG-107", "UG-108", "UG-109", "UG-110", "UG-111", "UG-112", "UG-113", "UG-114", "UG-115", "UG-116", "UG-117", "UG-118", "UG-119", "UG-120", "UG-121", "UG-122", "UG-123", "UG-124", "UG-125", "UG-126", "UG-102", "UG-E", "UG-201", "UG-202", "UG-203", "UG-204", "UG-205", "UG-206", "UG-207", "UG-208", "UG-209", "UG-210", "UG-211", "UG-212", "UG-213", "UG-214", "UG-215", "UG-216", "UG-217", "UG-218", "UG-219", "UG-220", "UG-221", "UG-222", "UG-223", "UG-224", "UG-225", "UG-226", "UG-227", "UG-228", "UG-229", "UG-230", "UG-231", "UG-232", "UG-233", "UG-234", "UG-235", "UG-236", "UG-237", "UG-N", "UG-301", "UG-302", "UG-303", "UG-304", "UG-305", "UG-306", "UG-307", "UG-308", "UG-309", "UG-310", "UG-311", "UG-312", "UG-313", "UG-314", "UG-315", "UG-316", "UG-317", "UG-318", "UG-319", "UG-320", "UG-321", "UG-322", "UG-323", "UG-324", "UG-325", "UG-326", "UG-327", "UG-328", "UG-329", "UG-330", "UG-331", "UG-332", "UG-333", "UG-334", "UG-335", "UG-336", "UG-337", "UG-W", "UG-401", "UG-402", "UG-403", "UG-404", "UG-405", "UG-406", "UG-407", "UG-408", "UG-409", "UG-410", "UG-411", "UG-412", "UG-413", "UG-414", "UG-415", "UG-416", "UG-417", "UG-418", "UG-419", "UG-420", "UG-421", "UG-422", "UG-423", "UG-424", "UG-425", "UG-426", "UG-427", "UG-428", "UG-429", "UG-430", "UG-431", "UG-432", "UG-433", "UG-434", "UG-435", "UM-67", "UM-71", "UM-76", "UM-79", "UM-81", "UM-84", "UM-86", "UM-89", "UM-95", "US-DC", "US-AS", "US-GU", "US-MP", "US-PR", "US-UM", "US-VI", "US-AK", "US-AL", "US-AR", "US-AZ", "US-CA", "US-CO", "US-CT", "US-DE", "US-FL", "US-GA", "US-HI", "US-IA", "US-ID", "US-IL", "US-IN", "US-KS", "US-KY", "US-LA", "US-MA", "US-MD", "US-ME", "US-MI", "US-MN", "US-MO", "US-MS", "US-MT", "US-NC", "US-ND", "US-NE", "US-NH", "US-NJ", "US-NM", "US-NV", "US-NY", "US-OH", "US-OK", "US-OR", "US-PA", "US-RI", "US-SC", "US-SD", "US-TN", "US-TX", "US-UT", "US-VA", "US-VT", "US-WA", "US-WI", "US-WV", "US-WY", "UY-AR", "UY-CA", "UY-CL", "UY-CO", "UY-DU", "UY-FD", "UY-FS", "UY-LA", "UY-MA", "UY-MO", "UY-PA", "UY-RN", "UY-RO", "UY-RV", "UY-SA", "UY-SJ", "UY-SO", "UY-TA", "UY-TT", "UZ-AN", "UZ-BU", "UZ-FA", "UZ-JI", "UZ-NG", "UZ-NW", "UZ-QA", "UZ-SA", "UZ-SI", "UZ-SU", "UZ-TO", "UZ-XO", "UZ-QR", "UZ-TK", "VC-01", "VC-02", "VC-03", "VC-04", "VC-05", "VC-06", "VE-B", "VE-C", "VE-D", "VE-E", "VE-F", "VE-G", "VE-H", "VE-I", "VE-J", "VE-K", "VE-L", "VE-M", "VE-N", "VE-O", "VE-P", "VE-R", "VE-S", "VE-T", "VE-U", "VE-V", "VE-X", "VE-Y", "VE-Z", "VE-W", "VE-A", "VN-01", "VN-02", "VN-03", "VN-04", "VN-05", "VN-06", "VN-07", "VN-09", "VN-13", "VN-14", "VN-18", "VN-20", "VN-21", "VN-22", "VN-23", "VN-24", "VN-25", "VN-26", "VN-27", "VN-28", "VN-29", "VN-30", "VN-31", "VN-32", "VN-33", "VN-34", "VN-35", "VN-36", "VN-37", "VN-39", "VN-40", "VN-41", "VN-43", "VN-44", "VN-45", "VN-46", "VN-47", "VN-49", "VN-50", "VN-51", "VN-52", "VN-53", "VN-54", "VN-55", "VN-56", "VN-57", "VN-58", "VN-59", "VN-61", "VN-63", "VN-66", "VN-67", "VN-68", "VN-69", "VN-70", "VN-71", "VN-72", "VN-73", "VN-CT", "VN-DN", "VN-HN", "VN-HP", "VN-SG", "VU-MAP", "VU-PAM", "VU-SAM", "VU-SEE", "VU-TAE", "VU-TOB", "WF-AL", "WF-SG", "WF-UV", "WS-AA", "WS-AL", "WS-AT", "WS-FA", "WS-GE", "WS-GI", "WS-PA", "WS-SA", "WS-TU", "WS-VF", "WS-VS", "YE-SA", "YE-AB", "YE-AD", "YE-AM", "YE-BA", "YE-DA", "YE-DH", "YE-HD", "YE-HJ", "YE-HU", "YE-IB", "YE-JA", "YE-LA", "YE-MA", "YE-MR", "YE-MW", "YE-RA", "YE-SD", "YE-SH", "YE-SN", "YE-SU", "YE-TA", "ZA-EC", "ZA-FS", "ZA-GP", "ZA-KZN", "ZA-LP", "ZA-MP", "ZA-NC", "ZA-NW", "ZA-WC", "ZM-01", "ZM-02", "ZM-03", "ZM-04", "ZM-05", "ZM-06", "ZM-07", "ZM-08", "ZM-09", "ZM-10", "ZW-BU", "ZW-HA", "ZW-MA", "ZW-MC", "ZW-ME", "ZW-MI", "ZW-MN", "ZW-MS", "ZW-MV", "ZW-MW"] \ No newline at end of file diff --git a/src/ipdata/geofeeds.py b/src/ipdata/geofeeds.py new file mode 100644 index 0000000..f614e3f --- /dev/null +++ b/src/ipdata/geofeeds.py @@ -0,0 +1,193 @@ +""" + Geofeeds classes used in the CLI for the validator. +""" +import csv +import hashlib +import ipaddress +import logging +import random +from pathlib import Path + +import requests +from rich.logging import RichHandler + +from .codes import COUNTRIES, REGION_CODES + +FORMAT = "%(message)s" +logging.basicConfig( + level="ERROR", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()] +) +log = logging.getLogger("rich") + +pwd = Path(__file__).parent.resolve() + + +class GeofeedValidationError(Exception): + pass + + +class Geofeed(object): + """ + Create an instance of a Geofeed object. + + :param source: Either a URL or a local file containing geofeed formatted data according to https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-05. + :param dir: The directory where the downloaded copy of a geofeed is cached + """ + + def __init__(self, source, dir="/tmp") -> None: + self.source = source + self.dir = dir + self.cache_path = None + self.total_count = 0 + self.valid_count = 0 + # Ensure uniqueness https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-05#appendix-A + self.prefixes = set() + + def _download(self): + """ + Downloads and caches the geofeed if a valid URL is provided + + :raises Exception: if a failure occurs when downloading the geofeed + """ + random_name = hashlib.sha224( str(random.getrandbits(256)).encode('utf-8') ).hexdigest()[:16] + cache_path = f"{self.dir}/{random_name}.csv" + + try: + response = requests.get(self.source, timeout=60) + text = response.text + with open(cache_path, "w") as f: + f.write( + "\n".join( + line for line in text.split("\n") if not line.startswith("#") + ) + ) # don't write comments + except Exception: + log.exception(f"Failed to get {self.source}") + else: + # set cache path if download was successful + self.cache_path = cache_path + + def entries(self): + """ + Reads each line in a geofeed and yields Entry objects, one for each line. + + :raises GeofeedValidationError: if it find a duplicate or if the expected CSV format is invalid eg. if the line has != 5 columns. There are a number of optional fields however the minimum number of commans should be present i.e. 4. + Also note that though the RFC recommends simply discarding extra columns to allow for additional fields in the future, we strictly check for 5 columns to prevent breaking out CSV parsing. + :yields: An Entry object + """ + # set path to the source by default + path = self.source + + # if the source is a url and we haven't already download it, try to download it and update path to the location of the download cache + if "://" in self.source and not self.cache_path: + self._download() + path = self.cache_path + # if path still contains a url it means the download was not successful in which case we yield nothing! + if not path: + log.warning(f"No cache path for {self.source} found. Download likely failed.") + return + + with open(path) as f: + reader = csv.reader(f) + for entry in reader: + # keep count of the number of entries + self.total_count += 1 + + # generate error if there are not exactly 5 fields + if len(entry) != 5: + yield GeofeedValidationError( + f"[{entry}] is not a valid geofeed entry. The 'IP Range' and 'Country' fields are required however you must have the requisite minimum number of commas (currently 4) present" + ) + return + + # instantiate an Entry object + geofeed_entry = Entry(*entry) + + # check for duplicates and generate error if found + if geofeed_entry.ip_range in self.prefixes: + yield GeofeedValidationError( + f"Duplicate prefixes found {geofeed_entry.ip_range}" + ) + return + + self.prefixes.add(geofeed_entry.ip_range) + + # generate entry + yield geofeed_entry + + def __iter__(self): + yield from self.entries() + +class Entry(object): + """ + Create an instance of an Entry object + + :param ip_range: A valid IP address prefix + :param country: A valid ISO 3166-1 alpha 2 country code + :param region: A valid ISO 3166-1 alpha 2 subdivision code + :param city: The city where the IP range is allocated + :param postal_code: The postal code of the location the IP range is allocated + """ + + def __init__( + self, ip_range, country, region=None, city=None, postal_code=None + ) -> None: + self.ip_range = ip_range + self.country = country.upper() + self.region = region + self.city = city + self.postal_code = postal_code + self.row = [ + self.ip_range, + self.country, + self.region, + self.city, + self.postal_code, + ] + + def __str__(self) -> str: + return f"{self.ip_range},{self.country},{self.region},{self.city},{self.postal_code}" + + def validate(self): + """ + Validation rules for geofeed entries. Note that though the spec states that all fields other than the IP range are optional, we require at the minimum at country code. Entries without a country code will not pass validation. + + :raises GeofeedValidationError: if any of the validation rules are violated + """ + + # 1. Each IP range field MUST be either a single IP address or an IP prefix in CIDR notation in conformance with section 3.1 of[RFC4632] for IPv4 or section 2.3 of [RFC4291] for IPv6. + # Reference: https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-02#section-2.1.1.1 + + try: + ipaddress.ip_network(self.ip_range) + except ValueError: + raise GeofeedValidationError( + f"[{self}] does not have a valid IP address or prefix" + ) + + # 2 (a) Country code must be present at a minimum + + if not self.country: + raise GeofeedValidationError(f"[{self}] is missing a country code") + + # 2 (b) Country code must be a valid 2-letter ISO 3166-1 alpha 2 country code + # Reference: https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-02#section-2.1.1.2 + if not self.country in COUNTRIES: + raise GeofeedValidationError( + f"[{self}] ({self.country}) is not a valid ISO 3166-1 alpha 2 subdivision code" + ) + + # The region code is optional, but if it exists we need to validate it + if self.region: + # 3 (a) The region field, if non-empty, MUST be a ISO region code conforming to ISO 3166-2 [ISO.3166.2]. Parsers SHOULD treat this field case-insensitively. + # Reference: https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-02#section-2.1.1.3 + if not self.region in REGION_CODES: + raise GeofeedValidationError( + f"[{self}] ({self.region}) is not a valid ISO 3166-1 alpha 2 region code" + ) + + # 3 (b) The region must be in the same country + if not self.region.split("-")[0] == self.country: + raise GeofeedValidationError( + f"[{self}] the region code provided is in a different country" + ) diff --git a/src/ipdata/ipdata.py b/src/ipdata/ipdata.py new file mode 100644 index 0000000..26b0817 --- /dev/null +++ b/src/ipdata/ipdata.py @@ -0,0 +1,278 @@ +"""This is the official IPData client library for Python. + +IPData client libraries allow for looking up the geolocation, +ownership and threat profile or any IP address and ASN. + +Example: + >>> from ipdata import IPData + >>> ipdata = IPData("YOUR API KEY") + >>> ipdata.lookup() # or ipdata.lookup("8.8.8.8") + +Alternatively thanks to the convenience methods in __init__.py you can do + +Example + >>> import ipdata + >>> ipdata.api_key = + >>> ipdata.lookup() # or ipdata.lookup("8.8.8.8") + +:class:`~.IPData` is the primary class for making API requests. +""" + +import os +import ipaddress +import requests +import logging +import urllib3 +import functools + +from requests.adapters import HTTPAdapter, Retry +from rich.logging import RichHandler + +FORMAT = "%(message)s" +logging.basicConfig( + level="ERROR", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()] +) + + +class IPDataException(Exception): + pass + + +class DotDict(dict): + """ + dot.notation access to dictionary attributes + Based on https://stackoverflow.com/a/23689767/3176550 + """ + + def __getattr__(*args): + val = dict.get(*args) + if type(val) is dict: + return DotDict(val) + elif type(val) is list: + return [DotDict(item) for item in val] + return val + + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ + + +class IPData(object): + """ + Instances of IPData are used to make API requests. To make requests to the EU endpoint, set "endpoint" to "https://eu-api.ipdata.co". + + :param api_key: A valid IPData API key + :param endpoint: The API endpoint to send requests to + :param timeout: The default requests timeout + :param retry_limit: The maximum number of retries in case of failure + :param retry_backoff_factor: The number of seconds to sleep in between retries. With a 1 second backoff calls with be retried up to 7 times at the following intervals: 0.5, 1, 2, 4, 8, 16, 32 + :param debug: A boolean used to set the log level. Set to True when debugging. + """ + + log = logging.getLogger("rich") + + valid_fields = { + "ip", + "is_eu", + "city", + "region", + "region_code", + "country_name", + "country_code", + "continent_name", + "continent_code", + "latitude", + "longitude", + "asn", + "postal", + "calling_code", + "flag", + "emoji_flag", + "emoji_unicode", + "carrier", + "languages", + "currency", + "time_zone", + "threat", + "count", + "status", + "company", + } + + def __init__( + self, + api_key=os.environ.get("IPDATA_API_KEY"), + endpoint="https://api.ipdata.co/", + timeout=60, + retry_limit=7, + retry_backoff_factor=1, + debug=False, + ): + if not api_key: + raise IPDataException("API Key not set. Set an API key via the 'IPDATA_API_KEY' environment variable or see the docs for other ways to do so.") + # Request settings + self.api_key = api_key + self.endpoint = endpoint.rstrip("/") # remove trailing / + self._timeout = timeout + self._headers = {"user-agent": "ipdata-python"} # set default UserAgent + self._query_params = {"api-key": self.api_key} + + # Enable debugging + if debug: + self.log.setLevel(logging.DEBUG) + + # Work around renamed argument in urllib3. + if hasattr(urllib3.util.Retry.DEFAULT, "allowed_methods"): + methods_arg = "allowed_methods" + else: + methods_arg = "method_whitelist" + + # Retry settings + retry_args = { + "total": retry_limit, + "backoff_factor": retry_backoff_factor, + "status_forcelist": [429, 500, 502, 503, 504], + methods_arg: {"POST"}, + } + + adapter = HTTPAdapter(max_retries=urllib3.Retry(**retry_args)) + + self._session = requests.Session() + self._session.mount("http", adapter) + + def _validate_fields(self, fields): + """ + Validates the fields passed in by the user, first ensuring it's a collection. In prior versions 'fields' was a string, however it now needs to be a collection. + + :param fields: A collection of supported fields + :raises ValueError: if 'fields' is not one of ['list', 'tuple' or 'set'] or if 'fields' contains unsupported fields + """ + if not type(fields) in [list, set, tuple]: + raise ValueError("'fields' must be of type 'list', 'tuple' or 'set'.") + + # Get all unsupported fields + diff = set(fields).difference(self.valid_fields) + if diff: + raise ValueError( + f"The field(s) {diff} are not supported. Only {self.valid_fields} are supported." + ) + + def _validate_ip_address(self, ip): + """ + Checks that 'ip' is a valid IP Address. + + :param ip: A string + :raises ValueError: if 'ip' is either not a valid IP address or is a reserved IP address eg. private, reserved or multicast + """ + request_ip = ipaddress.ip_address(ip) + if request_ip.is_private or request_ip.is_reserved or request_ip.is_multicast: + raise ValueError(f"{ip} is a reserved IP Address") + + def lookup(self, resource="", fields=[]): + """ + Makes a GET request to the IPData API for the specified 'resource' and the given 'fields'. + + :param resource: Either an IP address or an ASN prefixed by "AS" eg. "AS15169" + :param fields: A collection of API fields to be returned + + :returns: An API response as a DotDict object to allow dot notation access of fields eg. data.ip, data.company.name, data.threat.blocklists[0].name etc + + :raises IPDataException: if the API call fails or if there is a failure in decoding the response. + :raises ValueError: if 'resource' is not a string + """ + if type(resource) is not str: + raise ValueError(f"{resource} must be of type 'str'") + + resource = ( + resource.upper() + ) # capitalize resource in case it's a typoed ASN query eg. as2 + + if resource and not resource.startswith("AS"): + self._validate_ip_address(resource) + + self._validate_fields(fields) + query_params = self._query_params | {"fields": ",".join(fields)} + + # Make the request + try: + response = self._session.get( + f"{self.endpoint}/{resource}", + headers=self._headers, + params=query_params, + timeout=self._timeout, + ) + except Exception as e: + raise IPDataException(e) + + status_code = response.status_code + + # Decode the response and add metadata + try: + data = DotDict(response.json()) + data["status"] = status_code + if "eu-api" in self.endpoint: + data["endpoint"] = "EU" + except ValueError: + raise IPDataException( + f"An error occured while decoding the API response: {response.text}" + ) + + return data + + def bulk(self, resources, fields=[]): + """ + Lookup up to 100 resources in one request. Makes a POST request wth the resources as a JSON array and the specified fields. + + :param resources: A list of resources. This can be a list of IP addresses or ASNs or a mix of both which is not recommended unless you handle it during parsing. Mixing resources might lead to weird behavior when writing results to CSV. + :param fields: A collection of API fields to be returned. + + :returns: A DotDict object with the data contained under the 'responses' key. + + :raises ValueError: if resources it not a collection + :raises ValueError: if resources contains 0 items + :raises IPDataException: if the API call fails or if there is an error decoding the response + """ + + if type(resources) not in [list, set]: + raise ValueError(f"{resources} must be of type 'list' or 'set'") + + if len(resources) < 1: + raise ValueError("Bulk lookups must contain at least 1 resource.") + + self._validate_fields(fields) + query_params = self._query_params | {"fields": ",".join(fields)} + + # Make the requests + try: + response = self._session.post( + f"{self.endpoint}/bulk", + headers=self._headers, + params=query_params, + json=resources, + ) + except Exception as e: + raise IPDataException(f"Error when looking up {resources} - {e}") + + status_code = response.status_code + + # Decode the response + try: + data = response.json() + except ValueError: + raise IPDataException( + f"An error occured while decoding the API response: {response.text}" + ) + + # If the request returned a non 200 response we add metadata and return the response + if not status_code == 200: + data["status"] = status_code + return data + + result = DotDict( + { + "responses": [DotDict(resource) for resource in data], + "status": status_code, + } + ) + if "eu-api" in self.endpoint: + result["endpoint"] = "EU" + return result diff --git a/src/ipdata/iptrie.py b/src/ipdata/iptrie.py new file mode 100644 index 0000000..c6b6102 --- /dev/null +++ b/src/ipdata/iptrie.py @@ -0,0 +1,372 @@ +""" +IP Trie module for efficient IP address and CIDR prefix lookups. + +This module provides a dictionary-like data structure that supports both IPv4 and IPv6 +addresses with longest-prefix matching using Patricia tries. + +Example: + >>> ip_trie = IPTrie() + >>> ip_trie["192.168.0.0/16"] = "private-network" + >>> ip_trie["192.168.1.100"] + 'private-network' + >>> ip_trie.parent("192.168.1.100") + '192.168.0.0/16' +""" + +from __future__ import annotations + +import ipaddress +import socket +from collections.abc import Iterator +from typing import TypeVar, Generic + +from pytricia import PyTricia + +T = TypeVar("T") + + +class IPTrieError(Exception): + """Base exception for IPTrie errors.""" + + pass + + +class InvalidIPError(IPTrieError): + """Raised when an invalid IP address or network is provided.""" + + pass + + +class KeyNotFoundError(IPTrieError, KeyError): + """Raised when a key is not found in the trie.""" + + pass + +def _normalize_ip_key(key: str) -> tuple[str, bool]: + if not isinstance(key, str): + raise InvalidIPError(f"Key must be a string, got {type(key).__name__}") + + key = key.strip() + if not key: + raise InvalidIPError("Key cannot be empty") + + addr = key.rsplit("/", 1)[0] if "/" in key else key + + try: + socket.inet_pton(socket.AF_INET, addr) + return key, False + except socket.error: + pass + + try: + socket.inet_pton(socket.AF_INET6, addr) + return key, True + except socket.error: + raise InvalidIPError(f"Invalid IP address or network: {key!r}") + + +class IPTrie(Generic[T]): + """ + A trie-based container for IP addresses and CIDR prefixes. + + Supports both IPv4 and IPv6 addresses with longest-prefix matching. + Keys can be individual IP addresses or CIDR notation networks. + + This class uses Patricia tries internally for efficient lookups, + providing O(k) lookup time where k is the prefix length. + + Attributes: + IPV4_BITS: Number of bits in IPv4 addresses (32). + IPV6_BITS: Number of bits in IPv6 addresses (128). + + Example: + >>> ip_trie = IPTrie[str]() + >>> ip_trie["10.0.0.0/8"] = "class-a-private" + >>> ip_trie["10.1.0.0/16"] = "datacenter" + >>> ip_trie["10.1.2.3"] + 'datacenter' + >>> ip_trie.parent("10.1.2.3") + '10.1.0.0/16' + """ + + IPV4_BITS: int = 32 + IPV6_BITS: int = 128 + + def __init__(self) -> None: + """Initialize an empty IPTrie.""" + self._ipv4: PyTricia = PyTricia(self.IPV4_BITS) + self._ipv6: PyTricia = PyTricia(self.IPV6_BITS) + + def _get_trie(self, is_ipv6: bool) -> PyTricia: + """Return the appropriate trie based on IP version.""" + return self._ipv6 if is_ipv6 else self._ipv4 + + def __setitem__(self, key: str, value: T) -> None: + """ + Set a value for an IP address or network prefix. + + Args: + key: An IP address or CIDR notation network string. + value: The value to associate with the key. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + + Example: + >>> ip_trie["192.168.1.0/24"] = "local-network" + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + self._get_trie(is_ipv6)[normalized_key] = value + + def __getitem__(self, key: str) -> T: + """ + Get the value for an IP address using longest-prefix matching. + + Args: + key: An IP address or CIDR notation network string. + + Returns: + The value associated with the longest matching prefix. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + KeyNotFoundError: If no matching prefix is found. + + Example: + >>> ip_trie["192.168.1.100"] + 'local-network' + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + trie = self._get_trie(is_ipv6) + + if normalized_key not in trie: + raise KeyNotFoundError(key) + + return trie[normalized_key] + + def get(self, key: str, default: T | None = None) -> T | None: + """ + Get the value for an IP address, returning default if not found. + + Args: + key: An IP address or CIDR notation network string. + default: Value to return if key is not found. + + Returns: + The value associated with the longest matching prefix, + or the default value if not found. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + + Example: + >>> ip_trie.get("8.8.8.8", "unknown") + 'unknown' + """ + try: + return self[key] + except KeyNotFoundError: + return default + + def __delitem__(self, key: str) -> None: + """ + Delete an exact prefix from the trie. + + Args: + key: An IP address or CIDR notation network string. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + KeyNotFoundError: If the exact prefix is not found. + + Example: + >>> del ip_trie["192.168.1.0/24"] + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + trie = self._get_trie(is_ipv6) + + if not trie.has_key(normalized_key): + raise KeyNotFoundError(key) + + del trie[normalized_key] + + def __contains__(self, key: object) -> bool: + """ + Check if an IP address matches any prefix in the trie. + + Args: + key: An IP address or CIDR notation network string. + + Returns: + True if a matching prefix exists, False otherwise. + + Example: + >>> "192.168.1.100" in ip_trie + True + """ + if not isinstance(key, str): + return False + + try: + normalized_key, is_ipv6 = _normalize_ip_key(key) + return normalized_key in self._get_trie(is_ipv6) + except InvalidIPError: + return False + + def has_key(self, key: str) -> bool: + """ + Check if an exact prefix exists in the trie. + + Unlike __contains__, this checks for an exact match rather than + longest-prefix matching. + + Args: + key: An IP address or CIDR notation network string. + + Returns: + True if the exact prefix exists, False otherwise. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + + Example: + >>> ip_trie.has_key("192.168.1.0/24") + True + >>> ip_trie.has_key("192.168.1.0/25") + False + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + return self._get_trie(is_ipv6).has_key(normalized_key) + + def parent(self, key: str) -> str | None: + """ + Get the longest matching prefix for an IP address. + + Args: + key: An IP address or CIDR notation network string. + + Returns: + The longest matching prefix as a string, or None if not found. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + + Example: + >>> ip_trie.parent("192.168.1.100") + '192.168.1.0/24' + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + trie = self._get_trie(is_ipv6) + + if normalized_key not in trie: + return None + + return trie.get_key(normalized_key) + + def children(self, key: str) -> list[str]: + """ + Get all prefixes that are more specific than the given prefix. + + Args: + key: A CIDR notation network string. + + Returns: + A list of all more specific prefixes. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + + Example: + >>> ip_trie.children("192.168.0.0/16") + ['192.168.1.0/24', '192.168.2.0/24'] + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + trie = self._get_trie(is_ipv6) + + try: + return list(trie.children(normalized_key)) + except KeyError: + return [] + + def __len__(self) -> int: + """ + Return the total number of prefixes stored. + + Returns: + The combined count of IPv4 and IPv6 prefixes. + + Example: + >>> len(ip_trie) + 3 + """ + return len(self._ipv4) + len(self._ipv6) + + def __iter__(self) -> Iterator[str]: + """ + Iterate over all prefixes in the trie. + + Yields: + All stored prefixes (IPv4 first, then IPv6). + + Example: + >>> list(ip_trie) + ['192.168.1.0/24', '10.0.0.0/8', '2001:db8::/32'] + """ + yield from self._ipv4 + yield from self._ipv6 + + def keys(self) -> Iterator[str]: + """ + Return an iterator over all prefixes. + + Yields: + All stored prefixes. + """ + return iter(self) + + def values(self) -> Iterator[T]: + """ + Return an iterator over all values. + + Yields: + All stored values. + """ + for key in self: + try: + if ":" in key: + yield self._ipv6[key] + else: + yield self._ipv4[key] + except KeyError: + continue + + def items(self) -> Iterator[tuple[str, T]]: + """ + Return an iterator over all (prefix, value) pairs. + + Yields: + Tuples of (prefix, value). + """ + for key in self: + try: + if ":" in key: + yield key, self._ipv6[key] + else: + yield key, self._ipv4[key] + except KeyError: + continue + + def clear(self) -> None: + """Remove all entries from the trie.""" + self._ipv4 = PyTricia(self.IPV4_BITS) + self._ipv6 = PyTricia(self.IPV6_BITS) + + def __repr__(self) -> str: + """Return a string representation of the IPTrie.""" + ipv4_count = len(self._ipv4) + ipv6_count = len(self._ipv6) + return f"IPTrie(ipv4_prefixes={ipv4_count}, ipv6_prefixes={ipv6_count})" + + def __bool__(self) -> bool: + """Return True if the trie contains any entries.""" + return len(self) > 0 \ No newline at end of file diff --git a/src/ipdata/lolcat.py b/src/ipdata/lolcat.py new file mode 100644 index 0000000..424d034 --- /dev/null +++ b/src/ipdata/lolcat.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python +# +# "THE BEER-WARE LICENSE" (Revision 43~maze) +# +# wrote these files. As long as you retain this notice you +# can do whatever you want with this stuff. If we meet some day, and you think +# this stuff is worth it, you can buy me a beer in return. + +from __future__ import print_function + +import atexit +import math +import os +import random +import re +import sys +import time +from signal import signal, SIGPIPE, SIG_DFL + +PY3 = sys.version_info >= (3,) + +# override default handler so no exceptions on SIGPIPE +signal(SIGPIPE, SIG_DFL) + +# Reset terminal colors at exit +def reset(): + sys.stdout.write("\x1b[0m") + sys.stdout.flush() + + +atexit.register(reset) + + +STRIP_ANSI = re.compile(r"\x1b\[(\d+)(;\d+)?(;\d+)?[m|K]") +COLOR_ANSI = ( + (0x00, 0x00, 0x00), + (0xCD, 0x00, 0x00), + (0x00, 0xCD, 0x00), + (0xCD, 0xCD, 0x00), + (0x00, 0x00, 0xEE), + (0xCD, 0x00, 0xCD), + (0x00, 0xCD, 0xCD), + (0xE5, 0xE5, 0xE5), + (0x7F, 0x7F, 0x7F), + (0xFF, 0x00, 0x00), + (0x00, 0xFF, 0x00), + (0xFF, 0xFF, 0x00), + (0x5C, 0x5C, 0xFF), + (0xFF, 0x00, 0xFF), + (0x00, 0xFF, 0xFF), + (0xFF, 0xFF, 0xFF), +) + + +class stdoutWin: + def __init__(self): + self.output = sys.stdout + self.string = "" + self.i = 0 + + def isatty(self): + return self.output.isatty() + + def write(self, s): + self.string = self.string + s + + def flush(self): + return self.output.flush() + + def prints(self): + string = 'echo|set /p="%s"' % (self.string) + os.system(string) + self.i += 1 + self.string = "" + + def println(self): + print() + self.prints() + + +class LolCat(object): + def __init__(self, mode=256, output=sys.stdout): + self.mode = mode + self.output = output + + def _distance(self, rgb1, rgb2): + return sum(map(lambda c: (c[0] - c[1]) ** 2, zip(rgb1, rgb2))) + + def ansi(self, rgb): + r, g, b = rgb + + if self.mode in (8, 16): + colors = COLOR_ANSI[: self.mode] + matches = [ + (self._distance(c, map(int, rgb)), i) for i, c in enumerate(colors) + ] + matches.sort() + color = matches[0][1] + + return "3%d" % (color,) + else: + gray_possible = True + sep = 2.5 + + while gray_possible: + if r < sep or g < sep or b < sep: + gray = r < sep and g < sep and b < sep + gray_possible = False + + sep += 42.5 + + if gray: + color = 232 + int(float(sum(rgb) / 33.0)) + else: + color = sum( + [16] + + [ + int(6 * float(val) / 256) * mod + for val, mod in zip(rgb, [36, 6, 1]) + ] + ) + + return "38;5;%d" % (color,) + + def wrap(self, *codes): + return "\x1b[%sm" % ("".join(codes),) + + def rainbow(self, freq, i): + r = math.sin(freq * i) * 127 + 128 + g = math.sin(freq * i + 2 * math.pi / 3) * 127 + 128 + b = math.sin(freq * i + 4 * math.pi / 3) * 127 + 128 + return [r, g, b] + + def cat(self, fd, options): + if options.animate: + self.output.write("\x1b[?25l") + + for line in fd: + options.os += 1 + self.println(line, options) + + if options.animate: + self.output.write("\x1b[?25h") + + def println(self, s, options): + s = s.rstrip() + if options.force or self.output.isatty(): + s = STRIP_ANSI.sub("", s) + + if options.animate: + self.println_ani(s, options) + else: + self.println_plain(s, options) + + self.output.write("\n") + self.output.flush() + if os.name == "nt": + self.output.println() + + def println_ani(self, s, options): + if not s: + return + + for i in range(1, options.duration): + self.output.write("\x1b[%dD" % (len(s),)) + self.output.flush() + options.os += options.spread + self.println_plain(s, options) + time.sleep(1.0 / options.speed) + + def println_plain(self, s, options): + for i, c in enumerate(s if PY3 else s.decode(options.charset_py2, "replace")): + rgb = self.rainbow(options.freq, options.os + i / options.spread) + self.output.write( + "".join( + [ + self.wrap(self.ansi(rgb)), + c if PY3 else c.encode(options.charset_py2, "replace"), + ] + ) + ) + if os.name == "nt": + self.output.print() + + +def detect_mode(term_hint="xterm-256color"): + """ + Poor-mans color mode detection. + """ + if "ANSICON" in os.environ: + return 16 + elif os.environ.get("ConEmuANSI", "OFF") == "ON": + return 256 + else: + term = os.environ.get("TERM", term_hint) + if term.endswith("-256color") or term in ("xterm", "screen"): + return 256 + elif term.endswith("-color") or term in ("rxvt",): + return 16 + else: + return 256 # optimistic default + + +def run(): + """Main entry point.""" + import optparse + + parser = optparse.OptionParser(usage=r"%prog [] [file ...]") + parser.add_option( + "-p", "--spread", type="float", default=3.0, help="Rainbow spread" + ) + parser.add_option( + "-F", "--freq", type="float", default=0.1, help="Rainbow frequency" + ) + parser.add_option("-S", "--seed", type="int", default=0, help="Rainbow seed") + parser.add_option( + "-a", + "--animate", + action="store_true", + default=False, + help="Enable psychedelics", + ) + parser.add_option( + "-d", "--duration", type="int", default=12, help="Animation duration" + ) + parser.add_option( + "-s", "--speed", type="float", default=20.0, help="Animation speed" + ) + parser.add_option( + "-f", + "--force", + action="store_true", + default=False, + help="Force colour even when stdout is not a tty", + ) + + parser.add_option( + "-3", action="store_const", dest="mode", const=8, help="Force 3 bit colour mode" + ) + parser.add_option( + "-4", + action="store_const", + dest="mode", + const=16, + help="Force 4 bit colour mode", + ) + parser.add_option( + "-8", + action="store_const", + dest="mode", + const=256, + help="Force 8 bit colour mode", + ) + + parser.add_option( + "-c", + "--charset-py2", + default="utf-8", + help="Manually set a charset to convert from, for python 2.7", + ) + + options, args = parser.parse_args() + options.os = random.randint(0, 256) if options.seed == 0 else options.seed + options.mode = options.mode or detect_mode() + + if os.name == "nt": + lolcat = LolCat(mode=options.mode, output=stdoutWin()) + else: + lolcat = LolCat(mode=options.mode) + + if not args: + args = ["-"] + + for filename in args: + try: + if filename == "-": + lolcat.cat(sys.stdin, options) + else: + with open(filename, "r", errors="backslashreplace") as handle: + lolcat.cat(handle, options) + except IOError as error: + sys.stderr.write(str(error) + "\n") + except KeyboardInterrupt: + sys.stderr.write("\n") + # exit 130 for terminated-by-ctrl-c, from http://tldp.org/LDP/abs/html/exitcodes.html + return 130 + + +if __name__ == "__main__": + sys.exit(run()) diff --git a/src/ipdata/test_geofeeds.py b/src/ipdata/test_geofeeds.py new file mode 100644 index 0000000..d35a89a --- /dev/null +++ b/src/ipdata/test_geofeeds.py @@ -0,0 +1,20 @@ +import pytest + +from .geofeeds import Entry, GeofeedValidationError + + +@pytest.mark.parametrize( + "entry", + [ + "172.32.0.0/55,,,,", + "172.32.0.0/11,,,,", + "172.32.0.0/11,ZZ,,,", + "172.32.0.0/11,US,ZZ-ZZ,,", + "172.32.0.0/11,US,LU-LU,,", + ], +) +@pytest.mark.geofeed +def test_validation_rules(entry): + entry = Entry(*entry.split(",")) + with pytest.raises(GeofeedValidationError): + entry.validate() diff --git a/src/ipdata/test_ipdata.py b/src/ipdata/test_ipdata.py new file mode 100644 index 0000000..a6dd63e --- /dev/null +++ b/src/ipdata/test_ipdata.py @@ -0,0 +1,69 @@ +import ipaddress +import os + +import pytest +from dotenv import load_dotenv +from hypothesis import given, settings +from hypothesis import strategies as st + +from pathlib import Path +import ipdata + +# local testing +pwd = Path(__file__).parent.resolve() +load_dotenv(f"{pwd}/.env") + +# Github CI runs +IPDATA_API_KEY = os.environ.get("IPDATA_API_KEY") +ipdata.api_key = IPDATA_API_KEY + + +@given(st.ip_addresses(network="8.8.8.0/24")) +@settings(deadline=None, max_examples=100) +@pytest.mark.api +def test_lookup_v4(ip): + ip = str(ip) + data = ipdata.lookup(ip) + assert data.ip == ip + + +@given(st.ip_addresses(network="2620:11a:a000::/40")) +@settings(deadline=None, max_examples=100) +@pytest.mark.api +def test_lookup_v6(ip): + ip = str(ip) + data = ipdata.lookup(ip) + + # Capitalize to handle 2620:11A:A000::' == '2620:11a:a000::' + assert data.ip.upper() == ip.upper() + + +@given( + st.lists(st.ip_addresses(network="2620:11a:a000::/40"), min_size=1, max_size=100) +) +@settings(deadline=None, max_examples=100) +@pytest.mark.api +def test_bulk_v6(ips): + ips = [str(ip) for ip in ips] + data = ipdata.bulk(ips) + + # Check we got all results + assert len(data.responses) == len(ips) + + # Capitalize to handle 2620:11A:A000::' == '2620:11a:a000::' + for pair in zip(ips, [response.ip for response in data.responses]): + assert pair[0].upper() == pair[0].upper() + + +@given(st.lists(st.ip_addresses(network="8.8.8.0/24"), min_size=1, max_size=100)) +@settings(deadline=None, max_examples=100) +@pytest.mark.api +def test_bulk_v4(ips): + ips = [str(ip) for ip in ips] + data = ipdata.bulk(ips) + + # Check we got all results + assert len(data.responses) == len(ips) + + for pair in zip(ips, [response.ip for response in data.responses]): + assert pair[0] == pair[0] diff --git a/src/ipdata/test_iptrie.py b/src/ipdata/test_iptrie.py new file mode 100644 index 0000000..4b76292 --- /dev/null +++ b/src/ipdata/test_iptrie.py @@ -0,0 +1,327 @@ +""" +Unit tests for the IPTrie module. + +Run with: pytest test_iptrie.py -v +""" + +import pytest + +from .iptrie import IPTrie, InvalidIPError, KeyNotFoundError, _normalize_ip_key + + +class TestNormalizeIPKey: + """Tests for the _normalize_ip_key helper function.""" + + def test_valid_ipv4_address(self): + key, is_ipv6 = _normalize_ip_key("192.168.1.1") + assert key == "192.168.1.1" + assert is_ipv6 is False + + def test_valid_ipv4_network(self): + key, is_ipv6 = _normalize_ip_key("192.168.1.0/24") + assert key == "192.168.1.0/24" + assert is_ipv6 is False + + def test_ipv4_network_normalization(self): + # Non-strict mode normalizes host bits + key, is_ipv6 = _normalize_ip_key("192.168.1.100/24") + assert key == "192.168.1.0/24" + assert is_ipv6 is False + + def test_valid_ipv6_address(self): + key, is_ipv6 = _normalize_ip_key("2001:db8::1") + assert key == "2001:db8::1" + assert is_ipv6 is True + + def test_valid_ipv6_network(self): + key, is_ipv6 = _normalize_ip_key("2001:db8::/32") + assert key == "2001:db8::/32" + assert is_ipv6 is True + + def test_ipv6_normalization(self): + key, is_ipv6 = _normalize_ip_key("2001:0db8:0000:0000:0000:0000:0000:0001") + assert key == "2001:db8::1" + assert is_ipv6 is True + + def test_whitespace_handling(self): + key, is_ipv6 = _normalize_ip_key(" 192.168.1.1 ") + assert key == "192.168.1.1" + + def test_invalid_ip_raises_error(self): + with pytest.raises(InvalidIPError, match="Invalid IP address"): + _normalize_ip_key("not-an-ip") + + def test_empty_string_raises_error(self): + with pytest.raises(InvalidIPError, match="cannot be empty"): + _normalize_ip_key("") + + def test_non_string_raises_error(self): + with pytest.raises(InvalidIPError, match="must be a string"): + _normalize_ip_key(12345) # type: ignore + + def test_none_raises_error(self): + with pytest.raises(InvalidIPError, match="must be a string"): + _normalize_ip_key(None) # type: ignore + + +class TestIPTrieBasicOperations: + """Tests for basic trie operations.""" + + def test_setitem_and_getitem_ipv4(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.1.0/24"] = "local-network" + assert ip_trie["192.168.1.100"] == "local-network" + + def test_setitem_and_getitem_ipv6(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["2001:db8::/32"] = "documentation" + assert ip_trie["2001:db8::1"] == "documentation" + + def test_getitem_not_found_raises_error(self): + ip_trie: IPTrie[str] = IPTrie() + with pytest.raises(KeyNotFoundError): + _ = ip_trie["192.168.1.1"] + + def test_get_with_default(self): + ip_trie: IPTrie[str] = IPTrie() + assert ip_trie.get("192.168.1.1") is None + assert ip_trie.get("192.168.1.1", "default") == "default" + + def test_get_existing_key(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "class-a" + assert ip_trie.get("10.1.2.3") == "class-a" + + def test_contains_ipv4(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.0.0/16"] = "private" + assert "192.168.1.1" in ip_trie + assert "10.0.0.1" not in ip_trie + + def test_contains_ipv6(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["2001:db8::/32"] = "docs" + assert "2001:db8::1" in ip_trie + assert "2001:db9::1" not in ip_trie + + def test_contains_invalid_key_returns_false(self): + ip_trie: IPTrie[str] = IPTrie() + assert "not-an-ip" not in ip_trie + assert 12345 not in ip_trie # type: ignore + + def test_delitem(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.1.0/24"] = "test" + assert "192.168.1.1" in ip_trie + del ip_trie["192.168.1.0/24"] + assert "192.168.1.1" not in ip_trie + + def test_delitem_not_found_raises_error(self): + ip_trie: IPTrie[str] = IPTrie() + with pytest.raises(KeyNotFoundError): + del ip_trie["192.168.1.0/24"] + + def test_len(self): + ip_trie: IPTrie[str] = IPTrie() + assert len(ip_trie) == 0 + ip_trie["10.0.0.0/8"] = "a" + assert len(ip_trie) == 1 + ip_trie["192.168.0.0/16"] = "b" + assert len(ip_trie) == 2 + ip_trie["2001:db8::/32"] = "c" + assert len(ip_trie) == 3 + + def test_bool_empty(self): + ip_trie: IPTrie[str] = IPTrie() + assert not ip_trie + + def test_bool_non_empty(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "test" + assert ip_trie + + +class TestIPTrieLongestPrefixMatching: + """Tests for longest-prefix matching behavior.""" + + def test_longest_prefix_match_ipv4(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "broad" + ip_trie["10.1.0.0/16"] = "narrower" + ip_trie["10.1.1.0/24"] = "specific" + + assert ip_trie["10.1.1.100"] == "specific" + assert ip_trie["10.1.2.100"] == "narrower" + assert ip_trie["10.2.0.1"] == "broad" + + def test_longest_prefix_match_ipv6(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["2001:db8::/32"] = "broad" + ip_trie["2001:db8:1::/48"] = "specific" + + assert ip_trie["2001:db8:1::1"] == "specific" + assert ip_trie["2001:db8:2::1"] == "broad" + + def test_parent_returns_matching_prefix(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.0.0/16"] = "network" + ip_trie["192.168.1.0/24"] = "subnet" + + assert ip_trie.parent("192.168.1.100") == "192.168.1.0/24" + assert ip_trie.parent("192.168.2.100") == "192.168.0.0/16" + + def test_parent_not_found(self): + ip_trie: IPTrie[str] = IPTrie() + assert ip_trie.parent("192.168.1.1") is None + + +class TestIPTrieHasKey: + """Tests for exact prefix matching.""" + + def test_has_key_exact_match(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.1.0/24"] = "test" + + assert ip_trie.has_key("192.168.1.0/24") is True + assert ip_trie.has_key("192.168.1.0/25") is False + assert ip_trie.has_key("192.168.1.100") is False + + +class TestIPTrieIteration: + """Tests for iteration methods.""" + + def test_iter(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "a" + ip_trie["192.168.0.0/16"] = "b" + ip_trie["2001:db8::/32"] = "c" + + keys = list(ip_trie) + assert len(keys) == 3 + assert "10.0.0.0/8" in keys + assert "192.168.0.0/16" in keys + assert "2001:db8::/32" in keys + + def test_keys(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "a" + keys = list(ip_trie.keys()) + assert keys == ["10.0.0.0/8"] + + def test_values(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "value-a" + ip_trie["192.168.0.0/16"] = "value-b" + values = list(ip_trie.values()) + assert set(values) == {"value-a", "value-b"} + + def test_items(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "value-a" + items = list(ip_trie.items()) + assert ("10.0.0.0/8", "value-a") in items + + +class TestIPTrieChildren: + """Tests for the children method.""" + + def test_children_returns_more_specific_prefixes(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "parent" + ip_trie["10.1.0.0/16"] = "child1" + ip_trie["10.2.0.0/16"] = "child2" + ip_trie["192.168.0.0/16"] = "other" + + children = ip_trie.children("10.0.0.0/8") + assert len(children) == 2 + assert "10.1.0.0/16" in children + assert "10.2.0.0/16" in children + + def test_children_no_children(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "test" + children = ip_trie.children("10.1.0.0/16") + assert children == [] + + +class TestIPTrieClear: + """Tests for the clear method.""" + + def test_clear_removes_all_entries(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "a" + ip_trie["2001:db8::/32"] = "b" + assert len(ip_trie) == 2 + + ip_trie.clear() + assert len(ip_trie) == 0 + assert "10.0.0.1" not in ip_trie + + +class TestIPTrieRepr: + """Tests for string representation.""" + + def test_repr_empty(self): + ip_trie: IPTrie[str] = IPTrie() + assert repr(ip_trie) == "IPTrie(ipv4_prefixes=0, ipv6_prefixes=0)" + + def test_repr_with_entries(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "a" + ip_trie["192.168.0.0/16"] = "b" + ip_trie["2001:db8::/32"] = "c" + assert repr(ip_trie) == "IPTrie(ipv4_prefixes=2, ipv6_prefixes=1)" + + +class TestIPTrieValueTypes: + """Tests for different value types.""" + + def test_dict_values(self): + ip_trie: IPTrie[dict] = IPTrie() + ip_trie["10.0.0.0/8"] = {"name": "class-a", "owner": "acme"} + result = ip_trie["10.1.2.3"] + assert result == {"name": "class-a", "owner": "acme"} + + def test_list_values(self): + ip_trie: IPTrie[list[str]] = IPTrie() + ip_trie["10.0.0.0/8"] = ["tag1", "tag2"] + result = ip_trie["10.1.2.3"] + assert result == ["tag1", "tag2"] + + def test_none_value(self): + ip_trie: IPTrie[None] = IPTrie() + ip_trie["10.0.0.0/8"] = None + assert ip_trie["10.1.2.3"] is None + + +class TestIPTrieEdgeCases: + """Tests for edge cases and special scenarios.""" + + def test_host_address_as_key(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.1.1"] = "single-host" + assert ip_trie["192.168.1.1"] == "single-host" + assert "192.168.1.2" not in ip_trie + + def test_default_routes(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["0.0.0.0/0"] = "default-v4" + ip_trie["::/0"] = "default-v6" + + assert ip_trie["8.8.8.8"] == "default-v4" + assert ip_trie["2001:4860:4860::8888"] == "default-v6" + + def test_overwrite_value(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "original" + ip_trie["10.0.0.0/8"] = "updated" + assert ip_trie["10.1.2.3"] == "updated" + + def test_ipv4_mapped_ipv6(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["::ffff:192.168.1.0/120"] = "mapped" + assert ip_trie["::ffff:192.168.1.1"] == "mapped" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file