├── tests ├── __init__.py ├── database_test.py ├── webservice_test.py └── models_test.py ├── src └── geoip2 │ ├── py.typed │ ├── __init__.py │ ├── types.py │ ├── _internal.py │ ├── errors.py │ ├── database.py │ ├── models.py │ └── webservice.py ├── .gitmodules ├── MANIFEST.in ├── .readthedocs.yaml ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── zizmor.yml │ ├── release.yml │ ├── test.yml │ └── codeql-analysis.yml ├── examples └── benchmark.py ├── docs ├── index.rst ├── Makefile └── conf.py ├── dev-bin └── release.sh ├── pyproject.toml ├── LICENSE ├── CLAUDE.md ├── HISTORY.rst └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/geoip2/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/data"] 2 | path = tests/data 3 | url = https://github.com/maxmind/MaxMind-DB 4 | -------------------------------------------------------------------------------- /src/geoip2/__init__.py: -------------------------------------------------------------------------------- 1 | """geoip2 client library.""" 2 | 3 | from importlib.metadata import version 4 | 5 | __version__ = version("geoip2") 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude .* .github/**/* dev-bin/* 2 | include HISTORY.rst README.rst LICENSE geoip2/py.typed tests/*.py tests/data/test-data/*.mmdb 3 | graft docs/html 4 | -------------------------------------------------------------------------------- /src/geoip2/types.py: -------------------------------------------------------------------------------- 1 | """Provides types used internally.""" 2 | 3 | from ipaddress import IPv4Address, IPv6Address 4 | 5 | IPAddress = str | IPv6Address | IPv4Address 6 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-24.04 5 | tools: 6 | python: "3.13" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.egg 3 | .eggs 4 | .claude/ 5 | .idea 6 | build 7 | .coverage 8 | dist 9 | docs/_build 10 | docs/doctrees/ 11 | docs/html 12 | env/ 13 | geoip2.egg-info/ 14 | MANIFEST 15 | .mypy_cache/ 16 | *.pyc 17 | .pyre 18 | .pytype 19 | *.swp 20 | .tox 21 | /venv 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: uv 4 | directory: / 5 | schedule: 6 | interval: daily 7 | time: '14:00' 8 | open-pull-requests-limit: 10 9 | cooldown: 10 | default-days: 7 11 | - package-ecosystem: github-actions 12 | directory: / 13 | schedule: 14 | interval: daily 15 | time: '14:00' 16 | cooldown: 17 | default-days: 7 18 | -------------------------------------------------------------------------------- /.github/workflows/zizmor.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Actions Security Analysis with zizmor 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["**"] 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | zizmor: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | security-events: write 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v6 19 | with: 20 | persist-credentials: false 21 | 22 | - name: Run zizmor 23 | uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0 24 | -------------------------------------------------------------------------------- /examples/benchmark.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | """Simple benchmarking script.""" 3 | 4 | import argparse 5 | import contextlib 6 | import random 7 | import socket 8 | import struct 9 | import timeit 10 | 11 | import geoip2.database 12 | import geoip2.errors 13 | 14 | parser = argparse.ArgumentParser(description="Benchmark maxminddb.") 15 | parser.add_argument("--count", default=250000, type=int, help="number of lookups") 16 | parser.add_argument("--mode", default=0, type=int, help="reader mode to use") 17 | parser.add_argument("--file", default="GeoIP2-City.mmdb", help="path to mmdb file") 18 | 19 | args = parser.parse_args() 20 | 21 | reader = geoip2.database.Reader(args.file, mode=args.mode) 22 | 23 | 24 | def lookup_ip_address() -> None: 25 | """Look up IP address.""" 26 | ip = socket.inet_ntoa(struct.pack("!L", random.getrandbits(32))) 27 | with contextlib.suppress(geoip2.errors.AddressNotFoundError): 28 | reader.city(str(ip)) 29 | 30 | 31 | elapsed = timeit.timeit( 32 | "lookup_ip_address()", 33 | setup="from __main__ import lookup_ip_address", 34 | number=args.count, 35 | ) 36 | 37 | print(args.count / elapsed, "lookups per second") # noqa: T201 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and upload to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | release: 10 | types: 11 | - published 12 | 13 | permissions: {} 14 | 15 | jobs: 16 | build: 17 | name: Build source distribution 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v6 21 | with: 22 | submodules: true 23 | persist-credentials: false 24 | 25 | - name: Install the latest version of uv 26 | uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # 7.1.6 27 | 28 | - name: Build 29 | run: uv build 30 | 31 | - uses: actions/upload-artifact@v6 32 | with: 33 | path: | 34 | dist/*.tar.gz 35 | dist/*.whl 36 | 37 | upload_pypi: 38 | needs: build 39 | runs-on: ubuntu-latest 40 | environment: release 41 | permissions: 42 | id-token: write 43 | if: github.event_name == 'release' && github.event.action == 'published' 44 | steps: 45 | - uses: actions/download-artifact@v7 46 | with: 47 | name: artifact 48 | path: dist 49 | 50 | - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # 1.13.0 51 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '3 15 * * SUN' 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | test: 13 | name: test with ${{ matrix.env }} on ${{ matrix.os }} 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | env: ["3.10", 3.11, 3.12, 3.13, 3.14] 19 | os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] 20 | steps: 21 | - uses: actions/checkout@v6 22 | with: 23 | submodules: true 24 | persist-credentials: false 25 | - name: Install the latest version of uv 26 | uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # 7.1.6 27 | - name: Install tox 28 | run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv --with tox-gh 29 | - name: Install Python 30 | if: matrix.env != '3.13' 31 | run: uv python install --python-preference only-managed ${{ matrix.env }} 32 | - name: Setup test suite 33 | run: tox run -vv --notest --skip-missing-interpreters false 34 | env: 35 | TOX_GH_MAJOR_MINOR: ${{ matrix.env }} 36 | - name: Run test suite 37 | run: tox run --skip-pkg-install 38 | env: 39 | TOX_GH_MAJOR_MINOR: ${{ matrix.env }} 40 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. geoip2 documentation master file, created by 2 | sphinx-quickstart on Tue Apr 9 13:34:57 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. toctree:: 7 | :maxdepth: 3 8 | 9 | .. include:: ../README.rst 10 | 11 | .. automodule:: geoip2 12 | :members: 13 | :inherited-members: 14 | :show-inheritance: 15 | 16 | =============== 17 | Database Reader 18 | =============== 19 | 20 | .. automodule:: geoip2.database 21 | :members: 22 | :inherited-members: 23 | :show-inheritance: 24 | 25 | ================== 26 | WebServices Client 27 | ================== 28 | 29 | .. automodule:: geoip2.webservice 30 | :members: 31 | :inherited-members: 32 | :show-inheritance: 33 | 34 | ====== 35 | Models 36 | ====== 37 | 38 | .. automodule:: geoip2.models 39 | :members: 40 | :inherited-members: 41 | :show-inheritance: 42 | 43 | ======= 44 | Records 45 | ======= 46 | 47 | .. automodule:: geoip2.records 48 | :members: 49 | :inherited-members: 50 | :show-inheritance: 51 | 52 | ====== 53 | Errors 54 | ====== 55 | 56 | .. automodule:: geoip2.errors 57 | :members: 58 | :inherited-members: 59 | :show-inheritance: 60 | 61 | ================== 62 | Indices and tables 63 | ================== 64 | 65 | * :ref:`genindex` 66 | * :ref:`modindex` 67 | * :ref:`search` 68 | 69 | :copyright: (c) 2013-2025 by MaxMind, Inc. 70 | :license: Apache License, Version 2.0 71 | 72 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Code scanning - action" 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - 'dependabot/**' 7 | pull_request: 8 | schedule: 9 | - cron: '0 11 * * 2' 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | CodeQL-Build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | permissions: 19 | security-events: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v6 24 | with: 25 | # We must fetch at least the immediate parents so that if this is 26 | # a pull request then we can checkout the head. 27 | fetch-depth: 2 28 | persist-credentials: false 29 | 30 | # If this run was triggered by a pull request event, then checkout 31 | # the head of the pull request instead of the merge commit. 32 | - run: git checkout HEAD^2 33 | if: ${{ github.event_name == 'pull_request' }} 34 | 35 | # Initializes the CodeQL tools for scanning. 36 | - name: Initialize CodeQL 37 | uses: github/codeql-action/init@v4 38 | # Override language selection by uncommenting this and choosing your languages 39 | # with: 40 | # languages: go, javascript, csharp, python, cpp, java 41 | 42 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 43 | # If this step fails, then you should remove it and run the build manually (see below) 44 | - name: Autobuild 45 | uses: github/codeql-action/autobuild@v4 46 | 47 | # ℹ️ Command-line programs to run using the OS shell. 48 | # 📚 https://git.io/JvXDl 49 | 50 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 51 | # and modify them (or add more) to build your code if your project 52 | # uses a compiled language 53 | 54 | #- run: | 55 | # make bootstrap 56 | # make release 57 | 58 | - name: Perform CodeQL Analysis 59 | uses: github/codeql-action/analyze@v4 60 | -------------------------------------------------------------------------------- /src/geoip2/_internal.py: -------------------------------------------------------------------------------- 1 | """Internal utilities.""" 2 | 3 | import datetime 4 | import json 5 | from abc import ABCMeta 6 | from typing import Any 7 | 8 | 9 | class Model(metaclass=ABCMeta): # noqa: B024 10 | """Shared methods for MaxMind model classes.""" 11 | 12 | def __eq__(self, other: object) -> bool: 13 | return isinstance(other, self.__class__) and self.to_dict() == other.to_dict() 14 | 15 | def __ne__(self, other: object) -> bool: 16 | return not self.__eq__(other) 17 | 18 | def __hash__(self) -> int: 19 | # This is not particularly efficient, but I don't expect it to be used much. 20 | return hash(json.dumps(self.to_dict(), sort_keys=True)) 21 | 22 | def to_dict(self) -> dict[str, Any]: # noqa: C901, PLR0912 23 | """Return a dict of the object suitable for serialization.""" 24 | result = {} 25 | for key, value in self.__dict__.items(): 26 | if key.startswith("_"): 27 | continue 28 | if hasattr(value, "to_dict") and callable(value.to_dict): 29 | if d := value.to_dict(): 30 | result[key] = d 31 | elif isinstance(value, (list, tuple)): 32 | ls = [] 33 | for e in value: 34 | if hasattr(e, "to_dict") and callable(e.to_dict): 35 | if e := e.to_dict(): 36 | ls.append(e) 37 | elif e is not None: 38 | ls.append(e) 39 | if ls: 40 | result[key] = ls 41 | # We only have dicts of strings currently. Do not bother with 42 | # the general case. 43 | elif isinstance(value, dict): 44 | if value: 45 | result[key] = value 46 | elif isinstance(value, datetime.date): 47 | result[key] = value.isoformat() 48 | elif value is not None and value is not False: 49 | result[key] = value 50 | 51 | # network and ip_address are properties for performance reasons 52 | if hasattr(self, "ip_address") and self.ip_address is not None: 53 | result["ip_address"] = str(self.ip_address) 54 | if hasattr(self, "network") and self.network is not None: 55 | result["network"] = str(self.network) 56 | 57 | return result 58 | -------------------------------------------------------------------------------- /dev-bin/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu -o pipefail 4 | 5 | # Pre-flight checks - verify all required tools are available and configured 6 | # before making any changes to the repository 7 | 8 | check_command() { 9 | if ! command -v "$1" &>/dev/null; then 10 | echo "Error: $1 is not installed or not in PATH" 11 | exit 1 12 | fi 13 | } 14 | 15 | # Verify gh CLI is authenticated 16 | if ! gh auth status &>/dev/null; then 17 | echo "Error: gh CLI is not authenticated. Run 'gh auth login' first." 18 | exit 1 19 | fi 20 | 21 | # Verify we can access this repository via gh 22 | if ! gh repo view --json name &>/dev/null; then 23 | echo "Error: Cannot access repository via gh. Check your authentication and repository access." 24 | exit 1 25 | fi 26 | 27 | # Verify git can connect to the remote (catches SSH key issues, etc.) 28 | if ! git ls-remote origin &>/dev/null; then 29 | echo "Error: Cannot connect to git remote. Check your git credentials/SSH keys." 30 | exit 1 31 | fi 32 | 33 | check_command perl 34 | check_command uv 35 | 36 | # Check that we're not on the main branch 37 | current_branch=$(git branch --show-current) 38 | if [ "$current_branch" = "main" ]; then 39 | echo "Error: Releases should not be done directly on the main branch." 40 | echo "Please create a release branch and run this script from there." 41 | exit 1 42 | fi 43 | 44 | # Fetch latest changes and check that we're not behind origin/main 45 | echo "Fetching from origin..." 46 | git fetch origin 47 | 48 | if ! git merge-base --is-ancestor origin/main HEAD; then 49 | echo "Error: Current branch is behind origin/main." 50 | echo "Please merge or rebase with origin/main before releasing." 51 | exit 1 52 | fi 53 | 54 | changelog=$(cat HISTORY.rst) 55 | 56 | regex=' 57 | ([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?) \(([0-9]{4}-[0-9]{2}-[0-9]{2})\) 58 | \+* 59 | 60 | ((.| 61 | )*) 62 | ' 63 | 64 | if [[ ! $changelog =~ $regex ]]; then 65 | echo "Could not find date line in change log!" 66 | exit 1 67 | fi 68 | 69 | version="${BASH_REMATCH[1]}" 70 | date="${BASH_REMATCH[3]}" 71 | notes="$(echo "${BASH_REMATCH[4]}" | sed -n -E '/^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?/,$!p')" 72 | 73 | if [[ "$date" != "$(date +"%Y-%m-%d")" ]]; then 74 | echo "$date is not today!" 75 | exit 1 76 | fi 77 | 78 | tag="v$version" 79 | 80 | if [ -n "$(git status --porcelain)" ]; then 81 | echo ". is not clean." >&2 82 | exit 1 83 | fi 84 | 85 | perl -pi -e "s/(?<=^version = \").+?(?=\")/$version/gsm" pyproject.toml 86 | 87 | echo $"Test results:" 88 | uv run tox 89 | 90 | echo $'\nDiff:' 91 | git diff 92 | 93 | echo $'\nRelease notes:' 94 | echo "$notes" 95 | 96 | read -r -e -p "Commit changes and push to origin? " should_push 97 | 98 | if [ "$should_push" != "y" ]; then 99 | echo "Aborting" 100 | exit 1 101 | fi 102 | 103 | git commit -m "Update for $tag" -a 104 | 105 | git push 106 | 107 | gh release create --target "$(git branch --show-current)" -t "$version" -n "$notes" "$tag" 108 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "geoip2" 3 | version = "5.2.0" 4 | description = "MaxMind GeoIP2 API" 5 | authors = [ 6 | {name = "Gregory Oschwald", email = "goschwald@maxmind.com"}, 7 | ] 8 | dependencies = [ 9 | "aiohttp>=3.6.2,<4.0.0", 10 | "maxminddb>=3.0.0,<4.0.0", 11 | "requests>=2.24.0,<3.0.0", 12 | ] 13 | requires-python = ">=3.10" 14 | readme = "README.rst" 15 | license = "Apache-2.0" 16 | license-files = ["LICENSE"] 17 | classifiers = [ 18 | "Development Status :: 5 - Production/Stable", 19 | "Environment :: Web Environment", 20 | "Intended Audience :: Developers", 21 | "Intended Audience :: System Administrators", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Programming Language :: Python :: 3.14", 29 | "Topic :: Internet", 30 | "Topic :: Internet :: Proxy Servers", 31 | ] 32 | 33 | [dependency-groups] 34 | dev = [ 35 | "pytest>=8.3.5", 36 | "pytest-httpserver>=1.0.10", 37 | "tox-uv>=1.29.0", 38 | "types-requests>=2.32.0.20250328", 39 | ] 40 | lint = [ 41 | "mypy>=1.15.0", 42 | "ruff>=0.11.6", 43 | ] 44 | 45 | [build-system] 46 | requires = ["uv_build>=0.9.13,<0.10.0"] 47 | build-backend = "uv_build" 48 | 49 | [tool.uv.build-backend] 50 | source-include = [ 51 | "HISTORY.rst", 52 | "README.rst", 53 | "LICENSE", 54 | "docs/html", 55 | "examples/*.py", 56 | "tests/*.py", 57 | "tests/data/test-data/*.mmdb" 58 | ] 59 | 60 | [project.urls] 61 | Homepage = "https://www.maxmind.com/" 62 | Documentation = "https://geoip2.readthedocs.org/" 63 | "Source Code" = "https://github.com/maxmind/GeoIP2-python" 64 | "Issue Tracker" = "https://github.com/maxmind/GeoIP2-python/issues" 65 | 66 | [tool.ruff.lint] 67 | select = ["ALL"] 68 | ignore = [ 69 | # Redundant as the formatter handles missing trailing commas. 70 | "COM812", 71 | 72 | # documenting magic methods 73 | "D105", 74 | 75 | # Conflicts with D211 76 | "D203", 77 | 78 | # Conflicts with D212 79 | "D213", 80 | 81 | # Magic numbers for HTTP status codes seem ok most of the time. 82 | "PLR2004", 83 | 84 | # pytest rules 85 | "PT009", 86 | "PT027", 87 | ] 88 | 89 | [tool.ruff.lint.per-file-ignores] 90 | "docs/*" = ["ALL"] 91 | "src/geoip2/{models,records}.py" = [ "ANN401", "D107", "PLR0913" ] 92 | # FBT003: We use assertIs with boolean literals to verify values are actual 93 | # booleans (True/False), not just truthy/falsy values 94 | "tests/*" = ["ANN201", "D", "FBT003"] 95 | 96 | [tool.tox] 97 | env_list = [ 98 | "3.10", 99 | "3.11", 100 | "3.12", 101 | "3.13", 102 | "3.14", 103 | "lint", 104 | ] 105 | skip_missing_interpreters = false 106 | 107 | [tool.tox.env_run_base] 108 | dependency_groups = [ 109 | "dev", 110 | ] 111 | commands = [ 112 | ["pytest", "tests"], 113 | ] 114 | 115 | [tool.tox.env.lint] 116 | description = "Code linting" 117 | python = "3.14" 118 | dependency_groups = [ 119 | "dev", 120 | "lint", 121 | ] 122 | commands = [ 123 | ["mypy", "src", "tests"], 124 | ["ruff", "check"], 125 | ["ruff", "format", "--check", "--diff", "."], 126 | ] 127 | 128 | [tool.tox.gh.python] 129 | "3.14" = ["3.14", "lint"] 130 | "3.13" = ["3.13"] 131 | "3.12" = ["3.12"] 132 | "3.11" = ["3.11"] 133 | "3.10" = ["3.10"] 134 | -------------------------------------------------------------------------------- /src/geoip2/errors.py: -------------------------------------------------------------------------------- 1 | """Typed errors thrown by this library.""" 2 | 3 | from __future__ import annotations 4 | 5 | import ipaddress 6 | 7 | 8 | class GeoIP2Error(RuntimeError): 9 | """There was a generic error in GeoIP2. 10 | 11 | This class represents a generic error. It extends :py:exc:`RuntimeError` 12 | and does not add any additional attributes. 13 | 14 | """ 15 | 16 | 17 | class AddressNotFoundError(GeoIP2Error): 18 | """The address you were looking up was not found.""" 19 | 20 | ip_address: str | None 21 | """The IP address used in the lookup. This is only available for database 22 | lookups. 23 | """ 24 | _prefix_len: int | None 25 | 26 | def __init__( 27 | self, 28 | message: str, 29 | ip_address: str | None = None, 30 | prefix_len: int | None = None, 31 | ) -> None: 32 | """Initialize self. 33 | 34 | Arguments: 35 | message: A message describing the error. 36 | ip_address: The IP address that was not found. 37 | prefix_len: The prefix length for the network associated with 38 | the IP address. 39 | 40 | """ 41 | super().__init__(message) 42 | self.ip_address = ip_address 43 | self._prefix_len = prefix_len 44 | 45 | @property 46 | def network(self) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None: 47 | """The network associated with the error. 48 | 49 | In particular, this is the largest network where no address would be 50 | found. This is only available for database lookups. 51 | """ 52 | if self.ip_address is None or self._prefix_len is None: 53 | return None 54 | return ipaddress.ip_network( 55 | f"{self.ip_address}/{self._prefix_len}", 56 | strict=False, 57 | ) 58 | 59 | 60 | class AuthenticationError(GeoIP2Error): 61 | """There was a problem authenticating the request.""" 62 | 63 | 64 | class HTTPError(GeoIP2Error): 65 | """There was an error when making your HTTP request. 66 | 67 | This class represents an HTTP transport error. It extends 68 | :py:exc:`GeoIP2Error` and adds attributes of its own. 69 | 70 | """ 71 | 72 | http_status: int | None 73 | """The HTTP status code returned""" 74 | uri: str | None 75 | """The URI queried""" 76 | decoded_content: str | None 77 | """The decoded response content""" 78 | 79 | def __init__( 80 | self, 81 | message: str, 82 | http_status: int | None = None, 83 | uri: str | None = None, 84 | decoded_content: str | None = None, 85 | ) -> None: 86 | """Initialize self. 87 | 88 | Arguments: 89 | message: A descriptive message for the error. 90 | http_status: The HTTP status code associated with the error, if any. 91 | uri: The URI that was being accessed when the error occurred. 92 | decoded_content: The decoded HTTP response body, if available. 93 | 94 | """ 95 | super().__init__(message) 96 | self.http_status = http_status 97 | self.uri = uri 98 | self.decoded_content = decoded_content 99 | 100 | 101 | class InvalidRequestError(GeoIP2Error): 102 | """The request was invalid.""" 103 | 104 | 105 | class OutOfQueriesError(GeoIP2Error): 106 | """Your account is out of funds for the service queried.""" 107 | 108 | 109 | class PermissionRequiredError(GeoIP2Error): 110 | """Your account does not have permission to access this service.""" 111 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/geoip2.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/geoip2.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/geoip2" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/geoip2" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # geoip2 documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Apr 9 13:34:57 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import os 15 | import sys 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | sys.path.insert(0, os.path.abspath("..")) 21 | import geoip2 22 | 23 | # -- General configuration ----------------------------------------------- 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | # needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be extensions 29 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = [ 31 | "sphinx.ext.autodoc", 32 | "sphinx.ext.doctest", 33 | "sphinx.ext.intersphinx", 34 | "sphinx.ext.coverage", 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ["_templates"] 39 | 40 | # The suffix of source filenames. 41 | source_suffix = ".rst" 42 | 43 | # The encoding of source files. 44 | # source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = "index" 48 | 49 | # General information about the project. 50 | project = "geoip2" 51 | copyright = "2013-2025, MaxMind, Inc." 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = geoip2.__version__ 59 | # The full version, including alpha/beta/rc tags. 60 | release = geoip2.__version__ 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # language = None 65 | 66 | # There are two options for replacing |today|: either, you set today to some 67 | # non-false value, then it is used: 68 | # today = '' 69 | # Else, today_fmt is used as the format for a strftime call. 70 | # today_fmt = '%B %d, %Y' 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | exclude_patterns = ["_build"] 75 | 76 | # The reST default role (used for this markup: `text`) to use for all documents. 77 | # default_role = None 78 | 79 | # If true, '()' will be appended to :func: etc. cross-reference text. 80 | # add_function_parentheses = True 81 | 82 | # If true, the current module name will be prepended to all description 83 | # unit titles (such as .. function::). 84 | # add_module_names = True 85 | 86 | # If true, sectionauthor and moduleauthor directives will be shown in the 87 | # output. They are ignored by default. 88 | # show_authors = False 89 | 90 | # The name of the Pygments (syntax highlighting) style to use. 91 | pygments_style = "sphinx" 92 | 93 | # A list of ignored prefixes for module index sorting. 94 | # modindex_common_prefix = [] 95 | 96 | # -- Options for HTML output --------------------------------------------- 97 | 98 | # The theme to use for HTML and HTML Help pages. See the documentation for 99 | # a list of builtin themes. 100 | html_theme = "sphinxdoc" 101 | 102 | # Theme options are theme-specific and customize the look and feel of a theme 103 | # further. For a list of options available for each theme, see the 104 | # documentation. 105 | # html_theme_options = {} 106 | 107 | # Add any paths that contain custom themes here, relative to this directory. 108 | # html_theme_path = [] 109 | 110 | # The name for this set of Sphinx documents. If None, it defaults to 111 | # " v documentation". 112 | # html_title = None 113 | 114 | # A shorter title for the navigation bar. Default is the same as html_title. 115 | # html_short_title = None 116 | 117 | # The name of an image file (relative to this directory) to place at the top 118 | # of the sidebar. 119 | # html_logo = None 120 | 121 | # The name of an image file (within the static path) to use as favicon of the 122 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 123 | # pixels large. 124 | # html_favicon = None 125 | 126 | # Add any paths that contain custom static files (such as style sheets) here, 127 | # relative to this directory. They are copied after the builtin static files, 128 | # so a file named "default.css" will overwrite the builtin "default.css". 129 | # html_static_path = ["_static"] 130 | 131 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 132 | # using the given strftime format. 133 | # html_last_updated_fmt = '%b %d, %Y' 134 | 135 | # If true, SmartyPants will be used to convert quotes and dashes to 136 | # typographically correct entities. 137 | # html_use_smartypants = True 138 | 139 | # Custom sidebar templates, maps document names to template names. 140 | # html_sidebars = {} 141 | 142 | # Additional templates that should be rendered to pages, maps page names to 143 | # template names. 144 | # html_additional_pages = {} 145 | 146 | # If false, no module index is generated. 147 | # html_domain_indices = True 148 | 149 | # If false, no index is generated. 150 | # html_use_index = True 151 | 152 | # If true, the index is split into individual pages for each letter. 153 | # html_split_index = False 154 | 155 | # If true, links to the reST sources are added to the pages. 156 | # html_show_sourcelink = True 157 | 158 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 159 | # html_show_sphinx = True 160 | 161 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 162 | # html_show_copyright = True 163 | 164 | # If true, an OpenSearch description file will be output, and all pages will 165 | # contain a tag referring to it. The value of this option must be the 166 | # base URL from which the finished HTML is served. 167 | # html_use_opensearch = '' 168 | 169 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 170 | # html_file_suffix = None 171 | 172 | # Output file base name for HTML help builder. 173 | htmlhelp_basename = "geoip2doc" 174 | 175 | # -- Options for LaTeX output -------------------------------------------- 176 | 177 | latex_elements = { 178 | # The paper size ('letterpaper' or 'a4paper'). 179 | #'papersize': 'letterpaper', 180 | # The font size ('10pt', '11pt' or '12pt'). 181 | #'pointsize': '10pt', 182 | # Additional stuff for the LaTeX preamble. 183 | #'preamble': '', 184 | } 185 | 186 | # Grouping the document tree into LaTeX files. List of tuples 187 | # (source start file, target name, title, author, documentclass [howto/manual]). 188 | latex_documents = [ 189 | ("index", "geoip2.tex", "geoip2 Documentation", "Gregory Oschwald", "manual"), 190 | ] 191 | 192 | # The name of an image file (relative to this directory) to place at the top of 193 | # the title page. 194 | # latex_logo = None 195 | 196 | # For "manual" documents, if this is true, then toplevel headings are parts, 197 | # not chapters. 198 | # latex_use_parts = False 199 | 200 | # If true, show page references after internal links. 201 | # latex_show_pagerefs = False 202 | 203 | # If true, show URL addresses after external links. 204 | # latex_show_urls = False 205 | 206 | # Documents to append as an appendix to all manuals. 207 | # latex_appendices = [] 208 | 209 | # If false, no module index is generated. 210 | # latex_domain_indices = True 211 | 212 | # -- Options for manual page output -------------------------------------- 213 | 214 | # One entry per manual page. List of tuples 215 | # (source start file, name, description, authors, manual section). 216 | man_pages = [("index", "geoip2", "geoip2 Documentation", ["Gregory Oschwald"], 1)] 217 | 218 | # If true, show URL addresses after external links. 219 | # man_show_urls = False 220 | 221 | # -- Options for Texinfo output ------------------------------------------ 222 | 223 | # Grouping the document tree into Texinfo files. List of tuples 224 | # (source start file, target name, title, author, 225 | # dir menu entry, description, category) 226 | texinfo_documents = [ 227 | ( 228 | "index", 229 | "geoip2", 230 | "geoip2 Documentation", 231 | "Gregory Oschwald", 232 | "geoip2", 233 | "One line description of project.", 234 | "Miscellaneous", 235 | ), 236 | ] 237 | 238 | # Documents to append as an appendix to all manuals. 239 | # texinfo_appendices = [] 240 | 241 | # If false, no module index is generated. 242 | # texinfo_domain_indices = True 243 | 244 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 245 | # texinfo_show_urls = 'footnote' 246 | 247 | # Example configuration for intersphinx: refer to the Python standard library. 248 | intersphinx_mapping = { 249 | "python": ("https://python.readthedocs.org/en/latest/", None), 250 | } 251 | -------------------------------------------------------------------------------- /src/geoip2/database.py: -------------------------------------------------------------------------------- 1 | """The database reader for MaxMind MMDB files.""" 2 | 3 | from __future__ import annotations 4 | 5 | import inspect 6 | from typing import IO, TYPE_CHECKING, AnyStr, cast 7 | 8 | import maxminddb 9 | from maxminddb import ( 10 | MODE_AUTO, 11 | MODE_FD, 12 | MODE_FILE, 13 | MODE_MEMORY, 14 | MODE_MMAP, 15 | MODE_MMAP_EXT, 16 | InvalidDatabaseError, 17 | ) 18 | 19 | import geoip2 20 | import geoip2.errors 21 | import geoip2.models 22 | 23 | if TYPE_CHECKING: 24 | import os 25 | from collections.abc import Sequence 26 | 27 | from typing_extensions import Self 28 | 29 | from geoip2.models import ( 30 | ASN, 31 | ISP, 32 | AnonymousIP, 33 | AnonymousPlus, 34 | City, 35 | ConnectionType, 36 | Country, 37 | Domain, 38 | Enterprise, 39 | ) 40 | from geoip2.types import IPAddress 41 | 42 | __all__ = [ 43 | "MODE_AUTO", 44 | "MODE_FD", 45 | "MODE_FILE", 46 | "MODE_MEMORY", 47 | "MODE_MMAP", 48 | "MODE_MMAP_EXT", 49 | "Reader", 50 | ] 51 | 52 | 53 | class Reader: 54 | """GeoIP2 database Reader object. 55 | 56 | Instances of this class provide a reader for the GeoIP2 database format. 57 | IP addresses can be looked up using the ``country`` and ``city`` methods. 58 | 59 | The basic API for this class is the same for every database. First, you 60 | create a reader object, specifying a file name or file descriptor. 61 | You then call the method corresponding to the specific database, passing 62 | it the IP address you want to look up. 63 | 64 | If the request succeeds, the method call will return a model class for the 65 | method you called. This model in turn contains multiple record classes, 66 | each of which represents part of the data returned by the database. If the 67 | database does not contain the requested information, the attributes on the 68 | record class will have a ``None`` value. 69 | 70 | If the address is not in the database, an 71 | ``geoip2.errors.AddressNotFoundError`` exception will be thrown. If the 72 | database is corrupt or invalid, a ``maxminddb.InvalidDatabaseError`` will 73 | be thrown. 74 | """ 75 | 76 | def __init__( 77 | self, 78 | fileish: ( 79 | AnyStr | int | os.PathLike[str] | os.PathLike[bytes] | IO[str] | IO[bytes] 80 | ), 81 | locales: Sequence[str] | None = None, 82 | mode: int = MODE_AUTO, 83 | ) -> None: 84 | """Create GeoIP2 Reader. 85 | 86 | :param fileish: A path to the GeoIP2 database or an existing file 87 | descriptor pointing to the database. Note that a file descriptor 88 | is only valid when mode is MODE_FD. 89 | :param locales: This is list of locale codes. This argument will be 90 | passed on to record classes to use when their name properties are 91 | called. The default value is ['en']. 92 | 93 | The order of the locales is significant. When a record class has 94 | multiple names (country, city, etc.), its name property will return 95 | the name in the first locale that has one. 96 | 97 | Note that the only locale which is always present in the GeoIP2 98 | data is "en". If you do not include this locale, the name property 99 | may end up returning None even when the record has an English name. 100 | 101 | Currently, the valid locale codes are: 102 | 103 | * de -- German 104 | * en -- English names may still include accented characters if that 105 | is the accepted spelling in English. In other words, English does 106 | not mean ASCII. 107 | * es -- Spanish 108 | * fr -- French 109 | * ja -- Japanese 110 | * pt-BR -- Brazilian Portuguese 111 | * ru -- Russian 112 | * zh-CN -- Simplified Chinese. 113 | :param mode: The mode to open the database with. Valid mode are: 114 | * MODE_MMAP_EXT - use the C extension with memory map. 115 | * MODE_MMAP - read from memory map. Pure Python. 116 | * MODE_FILE - read database as standard file. Pure Python. 117 | * MODE_MEMORY - load database into memory. Pure Python. 118 | * MODE_FD - the param passed via fileish is a file descriptor, not a 119 | path. This mode implies MODE_MEMORY. Pure Python. 120 | * MODE_AUTO - try MODE_MMAP_EXT, MODE_MMAP, MODE_FILE in that order. 121 | Default. 122 | 123 | """ 124 | if locales is None: 125 | locales = ["en"] 126 | self._db_reader = maxminddb.open_database(fileish, mode) 127 | self._db_type = self._db_reader.metadata().database_type 128 | self._locales = locales 129 | 130 | def __enter__(self) -> Self: 131 | return self 132 | 133 | def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: 134 | self.close() 135 | 136 | def country(self, ip_address: IPAddress) -> Country: 137 | """Get the Country object for the IP address. 138 | 139 | :param ip_address: IPv4 or IPv6 address as a string. 140 | 141 | :returns: :py:class:`geoip2.models.Country` object 142 | 143 | """ 144 | return cast( 145 | "Country", 146 | self._model_for(geoip2.models.Country, "Country", ip_address), 147 | ) 148 | 149 | def city(self, ip_address: IPAddress) -> City: 150 | """Get the City object for the IP address. 151 | 152 | :param ip_address: IPv4 or IPv6 address as a string. 153 | 154 | :returns: :py:class:`geoip2.models.City` object 155 | 156 | """ 157 | return cast("City", self._model_for(geoip2.models.City, "City", ip_address)) 158 | 159 | def anonymous_ip(self, ip_address: IPAddress) -> AnonymousIP: 160 | """Get the AnonymousIP object for the IP address. 161 | 162 | :param ip_address: IPv4 or IPv6 address as a string. 163 | 164 | :returns: :py:class:`geoip2.models.AnonymousIP` object 165 | 166 | """ 167 | return cast( 168 | "AnonymousIP", 169 | self._flat_model_for( 170 | geoip2.models.AnonymousIP, 171 | "GeoIP2-Anonymous-IP", 172 | ip_address, 173 | ), 174 | ) 175 | 176 | def anonymous_plus(self, ip_address: IPAddress) -> AnonymousPlus: 177 | """Get the AnonymousPlus object for the IP address. 178 | 179 | :param ip_address: IPv4 or IPv6 address as a string. 180 | 181 | :returns: :py:class:`geoip2.models.AnonymousPlus` object 182 | 183 | """ 184 | return cast( 185 | "AnonymousPlus", 186 | self._flat_model_for( 187 | geoip2.models.AnonymousPlus, 188 | "GeoIP-Anonymous-Plus", 189 | ip_address, 190 | ), 191 | ) 192 | 193 | def asn(self, ip_address: IPAddress) -> ASN: 194 | """Get the ASN object for the IP address. 195 | 196 | :param ip_address: IPv4 or IPv6 address as a string. 197 | 198 | :returns: :py:class:`geoip2.models.ASN` object 199 | 200 | """ 201 | return cast( 202 | "ASN", 203 | self._flat_model_for(geoip2.models.ASN, "GeoLite2-ASN", ip_address), 204 | ) 205 | 206 | def connection_type(self, ip_address: IPAddress) -> ConnectionType: 207 | """Get the ConnectionType object for the IP address. 208 | 209 | :param ip_address: IPv4 or IPv6 address as a string. 210 | 211 | :returns: :py:class:`geoip2.models.ConnectionType` object 212 | 213 | """ 214 | return cast( 215 | "ConnectionType", 216 | self._flat_model_for( 217 | geoip2.models.ConnectionType, 218 | "GeoIP2-Connection-Type", 219 | ip_address, 220 | ), 221 | ) 222 | 223 | def domain(self, ip_address: IPAddress) -> Domain: 224 | """Get the Domain object for the IP address. 225 | 226 | :param ip_address: IPv4 or IPv6 address as a string. 227 | 228 | :returns: :py:class:`geoip2.models.Domain` object 229 | 230 | """ 231 | return cast( 232 | "Domain", 233 | self._flat_model_for(geoip2.models.Domain, "GeoIP2-Domain", ip_address), 234 | ) 235 | 236 | def enterprise(self, ip_address: IPAddress) -> Enterprise: 237 | """Get the Enterprise object for the IP address. 238 | 239 | :param ip_address: IPv4 or IPv6 address as a string. 240 | 241 | :returns: :py:class:`geoip2.models.Enterprise` object 242 | 243 | """ 244 | return cast( 245 | "Enterprise", 246 | self._model_for(geoip2.models.Enterprise, "Enterprise", ip_address), 247 | ) 248 | 249 | def isp(self, ip_address: IPAddress) -> ISP: 250 | """Get the ISP object for the IP address. 251 | 252 | :param ip_address: IPv4 or IPv6 address as a string. 253 | 254 | :returns: :py:class:`geoip2.models.ISP` object 255 | 256 | """ 257 | return cast( 258 | "ISP", 259 | self._flat_model_for(geoip2.models.ISP, "GeoIP2-ISP", ip_address), 260 | ) 261 | 262 | def _get(self, database_type: str, ip_address: IPAddress) -> tuple[dict, int]: 263 | if database_type not in self._db_type: 264 | caller = inspect.stack()[2][3] 265 | msg = ( 266 | f"The {caller} method cannot be used with the {self._db_type} database" 267 | ) 268 | raise TypeError( 269 | msg, 270 | ) 271 | (record, prefix_len) = self._db_reader.get_with_prefix_len(ip_address) 272 | if record is None: 273 | msg = f"The address {ip_address} is not in the database." 274 | raise geoip2.errors.AddressNotFoundError( 275 | msg, 276 | str(ip_address), 277 | prefix_len, 278 | ) 279 | if not isinstance(record, dict): 280 | msg = f"Expected record to be a dict but was f{type(record)}" 281 | raise InvalidDatabaseError(msg) 282 | return record, prefix_len 283 | 284 | def _model_for( 285 | self, 286 | model_class: type[City | Country | Enterprise], 287 | types: str, 288 | ip_address: IPAddress, 289 | ) -> City | Country | Enterprise: 290 | (record, prefix_len) = self._get(types, ip_address) 291 | return model_class( 292 | self._locales, 293 | ip_address=ip_address, 294 | prefix_len=prefix_len, 295 | **record, 296 | ) 297 | 298 | def _flat_model_for( 299 | self, 300 | model_class: type[Domain | ISP | ConnectionType | ASN | AnonymousIP], 301 | types: str, 302 | ip_address: IPAddress, 303 | ) -> ConnectionType | ISP | AnonymousIP | Domain | ASN: 304 | (record, prefix_len) = self._get(types, ip_address) 305 | return model_class(ip_address, prefix_len=prefix_len, **record) 306 | 307 | def metadata( 308 | self, 309 | ) -> maxminddb.reader.Metadata: 310 | """Get the metadata for the open database. 311 | 312 | :returns: :py:class:`maxminddb.reader.Metadata` object 313 | """ 314 | return self._db_reader.metadata() 315 | 316 | def close(self) -> None: 317 | """Close the GeoIP2 database.""" 318 | self._db_reader.close() 319 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | https://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | https://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /tests/database_test.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import ipaddress 3 | import sys 4 | import unittest 5 | from unittest.mock import MagicMock, patch 6 | 7 | sys.path.append("..") 8 | 9 | import maxminddb 10 | 11 | import geoip2.database 12 | import geoip2.errors 13 | 14 | try: 15 | import maxminddb.extension 16 | except ImportError: 17 | maxminddb.extension = None # type: ignore[assignment] 18 | 19 | 20 | class TestReader(unittest.TestCase): 21 | def test_language_list(self) -> None: 22 | reader = geoip2.database.Reader( 23 | "tests/data/test-data/GeoIP2-Country-Test.mmdb", 24 | ["xx", "ru", "pt-BR", "es", "en"], 25 | ) 26 | record = reader.country("81.2.69.160") 27 | 28 | self.assertEqual(record.country.name, "Великобритания") 29 | reader.close() 30 | 31 | def test_unknown_address(self) -> None: 32 | reader = geoip2.database.Reader("tests/data/test-data/GeoIP2-City-Test.mmdb") 33 | with self.assertRaisesRegex( 34 | geoip2.errors.AddressNotFoundError, 35 | "The address 10.10.10.10 is not in the database.", 36 | ): 37 | reader.city("10.10.10.10") 38 | reader.close() 39 | 40 | def test_unknown_address_network(self) -> None: 41 | reader = geoip2.database.Reader("tests/data/test-data/GeoIP2-City-Test.mmdb") 42 | try: 43 | reader.city("10.10.10.10") 44 | self.fail("Expected AddressNotFoundError") 45 | except geoip2.errors.AddressNotFoundError as e: 46 | self.assertEqual(e.network, ipaddress.ip_network("10.0.0.0/8")) 47 | except Exception as e: # noqa: BLE001 48 | self.fail(f"Expected AddressNotFoundError, got {type(e)}: {e!s}") 49 | finally: 50 | reader.close() 51 | 52 | def test_wrong_database(self) -> None: 53 | reader = geoip2.database.Reader("tests/data/test-data/GeoIP2-City-Test.mmdb") 54 | with self.assertRaisesRegex( 55 | TypeError, 56 | "The country method cannot be used with the GeoIP2-City database", 57 | ): 58 | reader.country("1.1.1.1") 59 | reader.close() 60 | 61 | def test_invalid_address(self) -> None: 62 | reader = geoip2.database.Reader("tests/data/test-data/GeoIP2-City-Test.mmdb") 63 | with self.assertRaisesRegex( 64 | ValueError, 65 | "u?'invalid' does not appear to be an IPv4 or IPv6 address", 66 | ): 67 | reader.city("invalid") 68 | reader.close() 69 | 70 | def test_anonymous_ip(self) -> None: 71 | reader = geoip2.database.Reader( 72 | "tests/data/test-data/GeoIP2-Anonymous-IP-Test.mmdb", 73 | ) 74 | ip_address = "1.2.0.1" 75 | 76 | record = reader.anonymous_ip(ip_address) 77 | self.assertEqual(record.is_anonymous, True) 78 | self.assertEqual(record.is_anonymous_vpn, True) 79 | self.assertEqual(record.is_hosting_provider, False) 80 | self.assertEqual(record.is_public_proxy, False) 81 | self.assertEqual(record.is_residential_proxy, False) 82 | self.assertEqual(record.is_tor_exit_node, False) 83 | self.assertEqual(record.ip_address, ipaddress.ip_address(ip_address)) 84 | self.assertEqual(record.network, ipaddress.ip_network("1.2.0.0/16")) 85 | reader.close() 86 | 87 | def test_anonymous_plus(self) -> None: 88 | with geoip2.database.Reader( 89 | "tests/data/test-data/GeoIP-Anonymous-Plus-Test.mmdb", 90 | ) as reader: 91 | ip_address = "1.2.0.1" 92 | 93 | record = reader.anonymous_plus(ip_address) 94 | 95 | self.assertEqual(record.anonymizer_confidence, 30) 96 | self.assertEqual(record.is_anonymous, True) 97 | self.assertEqual(record.is_anonymous_vpn, True) 98 | self.assertEqual(record.is_hosting_provider, False) 99 | self.assertEqual(record.is_public_proxy, False) 100 | self.assertEqual(record.is_residential_proxy, False) 101 | self.assertEqual(record.is_tor_exit_node, False) 102 | self.assertEqual(record.ip_address, ipaddress.ip_address(ip_address)) 103 | self.assertEqual(record.network, ipaddress.ip_network("1.2.0.1/32")) 104 | self.assertEqual(record.network_last_seen, datetime.date(2025, 4, 14)) 105 | self.assertEqual(record.provider_name, "foo") 106 | 107 | def test_anonymous_ip_all_set(self) -> None: 108 | reader = geoip2.database.Reader( 109 | "tests/data/test-data/GeoIP2-Anonymous-IP-Test.mmdb", 110 | ) 111 | ip_address = "81.2.69.1" 112 | 113 | record = reader.anonymous_ip(ip_address) 114 | self.assertEqual(record.is_anonymous, True) 115 | self.assertEqual(record.is_anonymous_vpn, True) 116 | self.assertEqual(record.is_hosting_provider, True) 117 | self.assertEqual(record.is_public_proxy, True) 118 | self.assertEqual(record.is_residential_proxy, True) 119 | self.assertEqual(record.is_tor_exit_node, True) 120 | self.assertEqual(record.ip_address, ipaddress.ip_address(ip_address)) 121 | self.assertEqual(record.network, ipaddress.ip_network("81.2.69.0/24")) 122 | reader.close() 123 | 124 | def test_asn(self) -> None: 125 | reader = geoip2.database.Reader("tests/data/test-data/GeoLite2-ASN-Test.mmdb") 126 | 127 | ip_address = "1.128.0.0" 128 | record = reader.asn(ip_address) 129 | 130 | self.assertEqual( 131 | record, 132 | eval(repr(record)), # noqa: S307 133 | "ASN repr can be eval'd", 134 | ) 135 | 136 | self.assertEqual(record.autonomous_system_number, 1221) 137 | self.assertEqual(record.autonomous_system_organization, "Telstra Pty Ltd") 138 | self.assertEqual(record.ip_address, ipaddress.ip_address(ip_address)) 139 | self.assertEqual(record.network, ipaddress.ip_network("1.128.0.0/11")) 140 | 141 | self.assertRegex( 142 | str(record), 143 | r"geoip2.models.ASN\(.*1\.128\.0\.0.*\)", 144 | "str representation is correct", 145 | ) 146 | 147 | reader.close() 148 | 149 | def test_city(self) -> None: 150 | reader = geoip2.database.Reader("tests/data/test-data/GeoIP2-City-Test.mmdb") 151 | record = reader.city("81.2.69.160") 152 | 153 | self.assertEqual( 154 | record.country.name, 155 | "United Kingdom", 156 | "The default locale is en", 157 | ) 158 | self.assertEqual(record.country.is_in_european_union, False) 159 | self.assertEqual( 160 | record.location.accuracy_radius, 161 | 100, 162 | "The accuracy_radius is populated", 163 | ) 164 | self.assertEqual(record.registered_country.is_in_european_union, False) 165 | self.assertFalse(record.traits.is_anycast) 166 | 167 | record = reader.city("214.1.1.0") 168 | self.assertTrue(record.traits.is_anycast) 169 | 170 | reader.close() 171 | 172 | def test_connection_type(self) -> None: 173 | reader = geoip2.database.Reader( 174 | "tests/data/test-data/GeoIP2-Connection-Type-Test.mmdb", 175 | ) 176 | ip_address = "1.0.1.0" 177 | 178 | record = reader.connection_type(ip_address) 179 | 180 | self.assertEqual( 181 | record, 182 | eval(repr(record)), # noqa: S307 183 | "ConnectionType repr can be eval'd", 184 | ) 185 | 186 | self.assertEqual(record.connection_type, "Cellular") 187 | self.assertEqual(record.ip_address, ipaddress.ip_address(ip_address)) 188 | self.assertEqual(record.network, ipaddress.ip_network("1.0.1.0/24")) 189 | 190 | self.assertRegex( 191 | str(record), 192 | r"ConnectionType\(.*Cellular.*\)", 193 | "ConnectionType str representation is reasonable", 194 | ) 195 | 196 | reader.close() 197 | 198 | def test_country(self) -> None: 199 | reader = geoip2.database.Reader("tests/data/test-data/GeoIP2-Country-Test.mmdb") 200 | record = reader.country("81.2.69.160") 201 | self.assertEqual( 202 | record.traits.ip_address, 203 | ipaddress.ip_address("81.2.69.160"), 204 | "IP address is added to model", 205 | ) 206 | self.assertEqual(record.traits.network, ipaddress.ip_network("81.2.69.160/27")) 207 | self.assertEqual(record.country.is_in_european_union, False) 208 | self.assertEqual(record.registered_country.is_in_european_union, False) 209 | self.assertFalse(record.traits.is_anycast) 210 | 211 | record = reader.country("214.1.1.0") 212 | self.assertTrue(record.traits.is_anycast) 213 | 214 | reader.close() 215 | 216 | def test_domain(self) -> None: 217 | reader = geoip2.database.Reader("tests/data/test-data/GeoIP2-Domain-Test.mmdb") 218 | 219 | ip_address = "1.2.0.0" 220 | record = reader.domain(ip_address) 221 | 222 | self.assertEqual( 223 | record, 224 | eval(repr(record)), # noqa: S307 225 | "Domain repr can be eval'd", 226 | ) 227 | 228 | self.assertEqual(record.domain, "maxmind.com") 229 | self.assertEqual(record.ip_address, ipaddress.ip_address(ip_address)) 230 | self.assertEqual(record.network, ipaddress.ip_network("1.2.0.0/16")) 231 | 232 | self.assertRegex( 233 | str(record), 234 | r"Domain\(.*maxmind.com.*\)", 235 | "Domain str representation is reasonable", 236 | ) 237 | 238 | reader.close() 239 | 240 | def test_enterprise(self) -> None: 241 | with geoip2.database.Reader( 242 | "tests/data/test-data/GeoIP2-Enterprise-Test.mmdb", 243 | ) as reader: 244 | ip_address = "74.209.24.0" 245 | record = reader.enterprise(ip_address) 246 | self.assertEqual(record.city.confidence, 11) 247 | self.assertEqual(record.country.confidence, 99) 248 | self.assertEqual(record.country.geoname_id, 6252001) 249 | self.assertEqual(record.country.is_in_european_union, False) 250 | self.assertEqual(record.location.accuracy_radius, 27) 251 | self.assertEqual(record.registered_country.is_in_european_union, False) 252 | self.assertEqual(record.traits.connection_type, "Cable/DSL") 253 | self.assertTrue(record.traits.is_legitimate_proxy) 254 | self.assertEqual(record.traits.ip_address, ipaddress.ip_address(ip_address)) 255 | self.assertEqual( 256 | record.traits.network, 257 | ipaddress.ip_network("74.209.16.0/20"), 258 | ) 259 | self.assertFalse(record.traits.is_anycast) 260 | 261 | record = reader.enterprise("149.101.100.0") 262 | self.assertEqual(record.traits.mobile_country_code, "310") 263 | self.assertEqual(record.traits.mobile_network_code, "004") 264 | 265 | record = reader.enterprise("214.1.1.0") 266 | self.assertTrue(record.traits.is_anycast) 267 | 268 | def test_isp(self) -> None: 269 | with geoip2.database.Reader( 270 | "tests/data/test-data/GeoIP2-ISP-Test.mmdb", 271 | ) as reader: 272 | ip_address = "1.128.0.0" 273 | record = reader.isp(ip_address) 274 | self.assertEqual( 275 | record, 276 | eval(repr(record)), # noqa: S307 277 | "ISP repr can be eval'd", 278 | ) 279 | 280 | self.assertEqual(record.autonomous_system_number, 1221) 281 | self.assertEqual(record.autonomous_system_organization, "Telstra Pty Ltd") 282 | self.assertEqual(record.isp, "Telstra Internet") 283 | self.assertEqual(record.organization, "Telstra Internet") 284 | self.assertEqual(record.ip_address, ipaddress.ip_address(ip_address)) 285 | self.assertEqual(record.network, ipaddress.ip_network("1.128.0.0/11")) 286 | 287 | self.assertRegex( 288 | str(record), 289 | r"ISP\(.*Telstra.*\)", 290 | "ISP str representation is reasonable", 291 | ) 292 | 293 | record = reader.isp("149.101.100.0") 294 | 295 | self.assertEqual(record.mobile_country_code, "310") 296 | self.assertEqual(record.mobile_network_code, "004") 297 | 298 | def test_context_manager(self) -> None: 299 | with geoip2.database.Reader( 300 | "tests/data/test-data/GeoIP2-Country-Test.mmdb", 301 | ) as reader: 302 | record = reader.country("81.2.69.160") 303 | self.assertEqual( 304 | record.traits.ip_address, 305 | ipaddress.ip_address("81.2.69.160"), 306 | ) 307 | 308 | @patch("maxminddb.open_database") 309 | def test_modes(self, mock_open: MagicMock) -> None: 310 | mock_open.return_value = MagicMock() 311 | 312 | path = "tests/data/test-data/GeoIP2-Country-Test.mmdb" 313 | with geoip2.database.Reader( 314 | path, 315 | mode=geoip2.database.MODE_MMAP_EXT, 316 | ): 317 | mock_open.assert_called_once_with(path, geoip2.database.MODE_MMAP_EXT) 318 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | **GeoIP2-python** is MaxMind's official Python client library for: 8 | - **GeoIP2/GeoLite2 Web Services**: Country, City, and Insights endpoints 9 | - **GeoIP2/GeoLite2 Databases**: Local MMDB file reading for various database types (City, Country, ASN, Anonymous IP, Anonymous Plus, ISP, etc.) 10 | 11 | The library provides both web service clients (sync and async) and database readers that return strongly-typed model objects containing geographic, ISP, anonymizer, and other IP-related data. 12 | 13 | **Key Technologies:** 14 | - Python 3.10+ (type hints throughout, uses modern Python features) 15 | - MaxMind DB Reader for binary database files 16 | - Requests library for sync web service client 17 | - aiohttp for async web service client 18 | - pytest for testing 19 | - ruff for linting and formatting 20 | - mypy for static type checking 21 | - uv for dependency management and building 22 | 23 | ## Code Architecture 24 | 25 | ### Package Structure 26 | 27 | ``` 28 | src/geoip2/ 29 | ├── models.py # Response models (City, Insights, AnonymousIP, etc.) 30 | ├── records.py # Data records (City, Location, Traits, etc.) 31 | ├── errors.py # Custom exceptions for error handling 32 | ├── database.py # Local MMDB file reader 33 | ├── webservice.py # HTTP clients (sync Client and async AsyncClient) 34 | ├── _internal.py # Internal base classes and utilities 35 | └── types.py # Type definitions 36 | ``` 37 | 38 | ### Key Design Patterns 39 | 40 | #### 1. **Model Classes vs Record Classes** 41 | 42 | **Models** (in `models.py`) are top-level responses returned by database lookups or web service calls: 43 | - `Country` - base model with country/continent data 44 | - `City` extends `Country` - adds city, location, postal, subdivisions 45 | - `Insights` extends `City` - adds additional web service fields (web service only) 46 | - `Enterprise` extends `City` - adds enterprise-specific fields 47 | - `AnonymousIP` - anonymous IP lookup results 48 | - `AnonymousPlus` extends `AnonymousIP` - adds additional anonymizer fields 49 | - `ASN`, `ConnectionType`, `Domain`, `ISP` - specialized lookup models 50 | 51 | **Records** (in `records.py`) are contained within models and represent specific data components: 52 | - `PlaceRecord` - abstract base with `names` dict and locale handling 53 | - `City`, `Continent`, `Country`, `RepresentedCountry`, `Subdivision` - geographic records 54 | - `Location`, `Postal`, `Traits`, `MaxMind` - additional data records 55 | 56 | #### 2. **Constructor Pattern** 57 | 58 | Models and records use keyword-only arguments (except for required positional parameters): 59 | 60 | ```python 61 | def __init__( 62 | self, 63 | locales: Sequence[str] | None, # positional for records 64 | *, 65 | continent: dict[str, Any] | None = None, 66 | country: dict[str, Any] | None = None, 67 | # ... other keyword-only parameters 68 | **_: Any, # ignore unknown keys 69 | ) -> None: 70 | ``` 71 | 72 | Key points: 73 | - Use `*` to enforce keyword-only arguments 74 | - Accept `**_: Any` to ignore unknown keys from the API 75 | - Use `| None = None` for optional parameters 76 | - Boolean fields default to `False` if not present 77 | 78 | #### 3. **Serialization with to_dict()** 79 | 80 | All model and record classes inherit from `Model` (in `_internal.py`) which provides `to_dict()`: 81 | 82 | ```python 83 | def to_dict(self) -> dict[str, Any]: 84 | # Returns a dict suitable for JSON serialization 85 | # - Skips None values and False booleans 86 | # - Recursively calls to_dict() on nested objects 87 | # - Handles lists/tuples of objects 88 | # - Converts network and ip_address to strings 89 | ``` 90 | 91 | The `to_dict()` method replaced the old `raw` attribute in version 5.0.0. 92 | 93 | #### 4. **Locale Handling** 94 | 95 | Records with names use `PlaceRecord` base class: 96 | - `names` dict contains locale code → name mappings 97 | - `name` property returns the first available name based on locale preference 98 | - Default locale is `["en"]` if not specified 99 | - Locales are passed down from models to records 100 | 101 | #### 5. **Property-based Network Calculation** 102 | 103 | For performance reasons, `network` and `ip_address` are properties rather than attributes: 104 | 105 | ```python 106 | @property 107 | def network(self) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None: 108 | # Lazy calculation and caching of network from ip_address and prefix_len 109 | ``` 110 | 111 | #### 6. **Web Service Only vs Database Models** 112 | 113 | Some models are only used by web services and do **not** need MaxMind DB support: 114 | 115 | **Web Service Only Models**: 116 | - `Insights` - extends City but used only for web service 117 | - Simpler implementation without database parsing logic 118 | 119 | **Database-Supported Models**: 120 | - Models used by both web services and database files 121 | - Must handle MaxMind DB format data structures 122 | - Examples: `City`, `Country`, `AnonymousIP`, `AnonymousPlus`, `ASN`, `ISP` 123 | 124 | ## Testing Conventions 125 | 126 | ### Running Tests 127 | 128 | ```bash 129 | # Install dependencies using uv 130 | uv sync --all-groups 131 | 132 | # Run all tests 133 | uv run pytest 134 | 135 | # Run specific test file 136 | uv run pytest tests/models_test.py 137 | 138 | # Run specific test class or method 139 | uv run pytest tests/models_test.py::TestModels::test_insights_full 140 | 141 | # Run tests with coverage 142 | uv run pytest --cov=geoip2 --cov-report=html 143 | ``` 144 | 145 | ### Linting and Type Checking 146 | 147 | ```bash 148 | # Run all linting checks (mypy, ruff check, ruff format check) 149 | uv run tox -e lint 150 | 151 | # Run mypy type checking 152 | uv run mypy src tests 153 | 154 | # Run ruff linting 155 | uv run ruff check 156 | 157 | # Auto-fix ruff issues 158 | uv run ruff check --fix 159 | 160 | # Check formatting 161 | uv run ruff format --check --diff . 162 | 163 | # Apply formatting 164 | uv run ruff format . 165 | ``` 166 | 167 | ### Running Tests Across Python Versions 168 | 169 | ```bash 170 | # Run tests on all supported Python versions 171 | uv run tox 172 | 173 | # Run on specific Python version 174 | uv run tox -e 3.11 175 | 176 | # Run lint environment 177 | uv run tox -e lint 178 | ``` 179 | 180 | ### Test Structure 181 | 182 | Tests are organized by component: 183 | - `tests/database_test.py` - Database reader tests 184 | - `tests/models_test.py` - Response model tests 185 | - `tests/webservice_test.py` - Web service client tests 186 | 187 | ### Test Patterns 188 | 189 | When adding new fields to models: 190 | 1. Update the test method to include the new field in the `raw` dict 191 | 2. Add assertions to verify the field is properly populated 192 | 3. Test both presence and absence of the field (null handling) 193 | 4. Verify `to_dict()` serialization includes the field correctly 194 | 195 | Example: 196 | ```python 197 | def test_anonymous_plus_full(self) -> None: 198 | model = geoip2.models.AnonymousPlus( 199 | "1.2.3.4", 200 | anonymizer_confidence=99, 201 | network_last_seen=datetime.date(2025, 4, 14), 202 | provider_name="FooBar VPN", 203 | is_anonymous=True, 204 | is_anonymous_vpn=True, 205 | # ... other fields 206 | ) 207 | 208 | assert model.anonymizer_confidence == 99 209 | assert model.network_last_seen == datetime.date(2025, 4, 14) 210 | assert model.provider_name == "FooBar VPN" 211 | ``` 212 | 213 | ## Working with This Codebase 214 | 215 | ### Adding New Fields to Existing Models 216 | 217 | 1. **Add the parameter to `__init__`** with proper type hints: 218 | ```python 219 | def __init__( 220 | self, 221 | # ... existing params 222 | *, 223 | field_name: int | None = None, # new field 224 | # ... other params 225 | ) -> None: 226 | ``` 227 | 228 | 2. **Assign the field in the constructor**: 229 | ```python 230 | self.field_name = field_name 231 | ``` 232 | 233 | 3. **Add class-level type annotation** with docstring: 234 | ```python 235 | field_name: int | None 236 | """Description of the field, its source, and availability.""" 237 | ``` 238 | 239 | 4. **Update `to_dict()` if special handling needed** (usually automatic via `_internal.Model`) 240 | 241 | 5. **Update tests** to include the new field in test data and assertions 242 | 243 | 6. **Update HISTORY.rst** with the change (see CHANGELOG Format below) 244 | 245 | ### Adding New Models 246 | 247 | When creating a new model class: 248 | 249 | 1. **Determine if web service only or database-supported** 250 | 2. **Follow the pattern** from existing similar models 251 | 3. **Extend the appropriate base class** (e.g., `Country`, `City`, `SimpleModel`) 252 | 4. **Use type hints** for all attributes 253 | 5. **Use keyword-only arguments** with `*` separator 254 | 6. **Accept `**_: Any`** to ignore unknown API keys 255 | 7. **Provide comprehensive docstrings** for all attributes 256 | 8. **Add corresponding tests** with full coverage 257 | 258 | ### Date Handling 259 | 260 | When a field returns a date string from the API (e.g., "2025-04-14"): 261 | 262 | 1. **Parse it to `datetime.date`** in the constructor: 263 | ```python 264 | import datetime 265 | 266 | self.network_last_seen = ( 267 | datetime.date.fromisoformat(network_last_seen) 268 | if network_last_seen 269 | else None 270 | ) 271 | ``` 272 | 273 | 2. **Annotate as `datetime.date | None`**: 274 | ```python 275 | network_last_seen: datetime.date | None 276 | ``` 277 | 278 | 3. **In `to_dict()`**, dates are automatically converted to ISO format strings by the base class 279 | 280 | ### Deprecation Guidelines 281 | 282 | When deprecating fields: 283 | 284 | 1. **Add deprecation to docstring** with version and alternative: 285 | ```python 286 | metro_code: int | None 287 | """The metro code of the location. 288 | 289 | .. deprecated:: 5.0.0 290 | The code values are no longer being maintained. 291 | """ 292 | ``` 293 | 294 | 2. **Keep deprecated fields functional** - don't break existing code 295 | 296 | 3. **Update HISTORY.rst** with deprecation notices 297 | 298 | 4. **Document alternatives** in the deprecation message 299 | 300 | ### HISTORY.rst Format 301 | 302 | Always update `HISTORY.rst` for user-facing changes. 303 | 304 | **Important**: Do not add a date to changelog entries until release time. Version numbers are added but without dates. 305 | 306 | Format: 307 | ```rst 308 | 5.2.0 309 | ++++++++++++++++++ 310 | 311 | * IMPORTANT: Python 3.10 or greater is required. If you are using an older 312 | version, please use an earlier release. 313 | * A new ``field_name`` property has been added to ``geoip2.models.ModelName``. 314 | This field provides information about... 315 | * The ``old_field`` property in ``geoip2.models.ModelName`` has been deprecated. 316 | Please use ``new_field`` instead. 317 | ``` 318 | 319 | ## Common Pitfalls and Solutions 320 | 321 | ### Problem: Incorrect Type Hints 322 | Using wrong type hints can cause mypy errors or allow invalid data. 323 | 324 | **Solution**: Follow these patterns: 325 | - Optional values: `Type | None` (e.g., `int | None`, `str | None`) 326 | - Non-null booleans: `bool` (default to `False` in constructor if not present) 327 | - Sequences: `Sequence[str]` for parameters, `list[T]` for internal lists 328 | - IP addresses: `IPAddress` type alias (from `geoip2.types`) 329 | - IP objects: `IPv4Address | IPv6Address` from `ipaddress` module 330 | 331 | ### Problem: Missing to_dict() Serialization 332 | New fields not appearing in serialized output. 333 | 334 | **Solution**: The `to_dict()` method in `_internal.Model` automatically handles most cases: 335 | - Non-None values are included 336 | - False booleans are excluded 337 | - Empty dicts/lists are excluded 338 | - Nested objects with `to_dict()` are recursively serialized 339 | 340 | If you need custom serialization, override `to_dict()` carefully. 341 | 342 | ### Problem: Test Failures After Adding Fields 343 | Tests fail because fixtures don't include new fields. 344 | 345 | **Solution**: Update all related tests: 346 | 1. Add field to constructor calls in tests 347 | 2. Add assertions for the new field 348 | 3. Test null case if field is optional 349 | 4. Verify `to_dict()` serialization 350 | 351 | ### Problem: Constructor Argument Order 352 | Breaking changes when adding required parameters. 353 | 354 | **Solution**: 355 | - Use keyword-only arguments (after `*`) for all optional parameters 356 | - Only add new parameters as optional with defaults 357 | - Never add required positional parameters to existing constructors 358 | 359 | ## Code Style Requirements 360 | 361 | - **ruff** enforces all style rules (configured in `pyproject.toml`) 362 | - **Type hints required** for all functions and class attributes 363 | - **Docstrings required** for all public classes, methods, and attributes (Google style) 364 | - **Line length**: 88 characters (Black-compatible) 365 | - No unused imports or variables 366 | - Use modern Python features (3.10+ type union syntax: `X | Y` instead of `Union[X, Y]`) 367 | 368 | ## Development Workflow 369 | 370 | ### Setup 371 | 372 | ```bash 373 | # Install uv if not already installed 374 | curl -LsSf https://astral.sh/uv/install.sh | sh 375 | 376 | # Install all dependencies including dev and lint groups 377 | uv sync --all-groups 378 | ``` 379 | 380 | ### Before Committing 381 | 382 | ```bash 383 | # Format code 384 | uv run ruff format . 385 | 386 | # Check linting 387 | uv run ruff check --fix 388 | 389 | # Type check 390 | uv run mypy src tests 391 | 392 | # Run tests 393 | uv run pytest 394 | 395 | # Or run everything via tox 396 | uv run tox 397 | ``` 398 | 399 | ### Version Requirements 400 | 401 | - **Python 3.10+** required (as of version 5.2.0) 402 | - Uses modern Python features (match statements, structural pattern matching, `X | Y` union syntax) 403 | - Target compatibility: Python 3.10-3.14 404 | 405 | ## Additional Resources 406 | 407 | - [API Documentation](https://geoip2.readthedocs.org/) 408 | - [GeoIP2 Web Services Docs](https://dev.maxmind.com/geoip/docs/web-services) 409 | - [MaxMind DB Format](https://maxmind.github.io/MaxMind-DB/) 410 | - GitHub Issues: https://github.com/maxmind/GeoIP2-python/issues 411 | -------------------------------------------------------------------------------- /tests/webservice_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import copy 5 | import ipaddress 6 | import sys 7 | import unittest 8 | from abc import ABC, abstractmethod 9 | from collections import defaultdict 10 | from typing import TYPE_CHECKING, ClassVar, cast 11 | 12 | import pytest 13 | import pytest_httpserver 14 | from pytest_httpserver import HeaderValueMatcher 15 | 16 | sys.path.append("..") 17 | import geoip2 18 | from geoip2.errors import ( 19 | AddressNotFoundError, 20 | AuthenticationError, 21 | GeoIP2Error, 22 | HTTPError, 23 | InvalidRequestError, 24 | OutOfQueriesError, 25 | PermissionRequiredError, 26 | ) 27 | from geoip2.webservice import AsyncClient, Client 28 | 29 | if TYPE_CHECKING: 30 | from collections.abc import Callable 31 | 32 | 33 | class TestBaseClient(unittest.TestCase, ABC): 34 | client: AsyncClient | Client 35 | client_class: Callable[[int, str], AsyncClient | Client] 36 | 37 | country: ClassVar = { 38 | "continent": {"code": "NA", "geoname_id": 42, "names": {"en": "North America"}}, 39 | "country": { 40 | "geoname_id": 1, 41 | "iso_code": "US", 42 | "names": {"en": "United States of America"}, 43 | }, 44 | "maxmind": {"queries_remaining": 11}, 45 | "registered_country": { 46 | "geoname_id": 2, 47 | "is_in_european_union": True, 48 | "iso_code": "DE", 49 | "names": {"en": "Germany"}, 50 | }, 51 | "traits": { 52 | "ip_address": "1.2.3.4", 53 | "is_anycast": True, 54 | "network": "1.2.3.0/24", 55 | }, 56 | } 57 | 58 | # this is not a comprehensive representation of the 59 | # JSON from the server 60 | insights = cast("dict", copy.deepcopy(country)) 61 | insights["traits"]["user_count"] = 2 62 | insights["traits"]["static_ip_score"] = 1.3 63 | 64 | @abstractmethod 65 | def run_client(self, v): ... # noqa: ANN001 66 | 67 | def _content_type(self, endpoint: str) -> str: 68 | return ( 69 | "application/vnd.maxmind.com-" 70 | + endpoint 71 | + "+json; charset=UTF-8; version=1.0" 72 | ) 73 | 74 | @pytest.fixture(autouse=True) 75 | def setup_httpserver(self, httpserver: pytest_httpserver.HTTPServer) -> None: 76 | self.httpserver = httpserver 77 | 78 | def test_country_ok(self) -> None: 79 | self.httpserver.expect_request( 80 | "/geoip/v2.1/country/1.2.3.4", 81 | method="GET", 82 | ).respond_with_json( 83 | self.country, 84 | status=200, 85 | content_type=self._content_type("country"), 86 | ) 87 | country = self.run_client(self.client.country("1.2.3.4")) 88 | self.assertEqual( 89 | type(country), 90 | geoip2.models.Country, 91 | "return value of client.country", 92 | ) 93 | self.assertEqual(country.continent.geoname_id, 42, "continent geoname_id is 42") 94 | self.assertEqual(country.continent.code, "NA", "continent code is NA") 95 | self.assertEqual( 96 | country.continent.name, 97 | "North America", 98 | "continent name is North America", 99 | ) 100 | self.assertEqual(country.country.geoname_id, 1, "country geoname_id is 1") 101 | self.assertIs( 102 | country.country.is_in_european_union, 103 | False, 104 | "country is_in_european_union is False", 105 | ) 106 | self.assertEqual(country.country.iso_code, "US", "country iso_code is US") 107 | self.assertEqual( 108 | country.country.names, 109 | {"en": "United States of America"}, 110 | "country names", 111 | ) 112 | self.assertEqual( 113 | country.country.name, 114 | "United States of America", 115 | "country name is United States of America", 116 | ) 117 | self.assertEqual( 118 | country.maxmind.queries_remaining, 119 | 11, 120 | "queries_remaining is 11", 121 | ) 122 | self.assertIs( 123 | country.registered_country.is_in_european_union, 124 | True, 125 | "registered_country is_in_european_union is True", 126 | ) 127 | self.assertEqual( 128 | country.traits.network, 129 | ipaddress.ip_network("1.2.3.0/24"), 130 | "network", 131 | ) 132 | self.assertTrue(country.traits.is_anycast) 133 | self.assertEqual(country.to_dict(), self.country, "raw response is correct") 134 | 135 | def test_me(self) -> None: 136 | self.httpserver.expect_request( 137 | "/geoip/v2.1/country/me", 138 | method="GET", 139 | ).respond_with_json( 140 | self.country, 141 | status=200, 142 | content_type=self._content_type("country"), 143 | ) 144 | implicit_me = self.run_client(self.client.country()) 145 | self.assertEqual( 146 | type(implicit_me), 147 | geoip2.models.Country, 148 | "country() returns Country object", 149 | ) 150 | explicit_me = self.run_client(self.client.country()) 151 | self.assertEqual( 152 | type(explicit_me), 153 | geoip2.models.Country, 154 | "country('me') returns Country object", 155 | ) 156 | 157 | def test_200_error(self) -> None: 158 | self.httpserver.expect_request( 159 | "/geoip/v2.1/country/1.1.1.1", 160 | method="GET", 161 | ).respond_with_data( 162 | "", 163 | status=200, 164 | content_type=self._content_type("country"), 165 | ) 166 | 167 | with self.assertRaisesRegex( 168 | GeoIP2Error, 169 | "could not decode the response as JSON", 170 | ): 171 | self.run_client(self.client.country("1.1.1.1")) 172 | 173 | def test_bad_ip_address(self) -> None: 174 | with self.assertRaisesRegex( 175 | ValueError, 176 | "'1.2.3' does not appear to be an IPv4 or IPv6 address", 177 | ): 178 | self.run_client(self.client.country("1.2.3")) 179 | 180 | def test_no_body_error(self) -> None: 181 | self.httpserver.expect_request( 182 | "/geoip/v2.1/country/1.2.3.7", 183 | method="GET", 184 | ).respond_with_data( 185 | "", 186 | status=400, 187 | content_type=self._content_type("country"), 188 | ) 189 | with self.assertRaisesRegex( 190 | HTTPError, 191 | "Received a 400 error for .* with no body", 192 | ): 193 | self.run_client(self.client.country("1.2.3.7")) 194 | 195 | def test_weird_body_error(self) -> None: 196 | self.httpserver.expect_request( 197 | "/geoip/v2.1/country/1.2.3.8", 198 | method="GET", 199 | ).respond_with_json( 200 | {"wierd": 42}, 201 | status=400, 202 | content_type=self._content_type("country"), 203 | ) 204 | 205 | with self.assertRaisesRegex( 206 | HTTPError, 207 | "Response contains JSON but it does not specify code or error keys", 208 | ): 209 | self.run_client(self.client.country("1.2.3.8")) 210 | 211 | def test_bad_body_error(self) -> None: 212 | self.httpserver.expect_request( 213 | "/geoip/v2.1/country/1.2.3.9", 214 | method="GET", 215 | ).respond_with_data( 216 | "bad body", 217 | status=400, 218 | content_type=self._content_type("country"), 219 | ) 220 | with self.assertRaisesRegex( 221 | HTTPError, 222 | "it did not include the expected JSON body", 223 | ): 224 | self.run_client(self.client.country("1.2.3.9")) 225 | 226 | def test_500_error(self) -> None: 227 | self.httpserver.expect_request( 228 | "/geoip/v2.1/country/1.2.3.10", 229 | method="GET", 230 | ).respond_with_data( 231 | "", 232 | status=500, 233 | content_type=self._content_type("country"), 234 | ) 235 | with self.assertRaisesRegex(HTTPError, r"Received a server error \(500\) for"): 236 | self.run_client(self.client.country("1.2.3.10")) 237 | 238 | def test_300_error(self) -> None: 239 | self.httpserver.expect_request( 240 | "/geoip/v2.1/country/1.2.3.11", 241 | method="GET", 242 | ).respond_with_data( 243 | "", 244 | status=300, 245 | content_type=self._content_type("country"), 246 | ) 247 | with self.assertRaisesRegex( 248 | HTTPError, 249 | r"Received a very surprising HTTP status \(300\) for", 250 | ): 251 | self.run_client(self.client.country("1.2.3.11")) 252 | 253 | def test_ip_address_required(self) -> None: 254 | self._test_error(400, "IP_ADDRESS_REQUIRED", InvalidRequestError) 255 | 256 | def test_ip_address_not_found(self) -> None: 257 | self._test_error(404, "IP_ADDRESS_NOT_FOUND", AddressNotFoundError) 258 | 259 | def test_ip_address_reserved(self) -> None: 260 | self._test_error(400, "IP_ADDRESS_RESERVED", AddressNotFoundError) 261 | 262 | def test_permission_required(self) -> None: 263 | self._test_error(403, "PERMISSION_REQUIRED", PermissionRequiredError) 264 | 265 | def test_auth_invalid(self) -> None: 266 | self._test_error(400, "AUTHORIZATION_INVALID", AuthenticationError) 267 | 268 | def test_license_key_required(self) -> None: 269 | self._test_error(401, "LICENSE_KEY_REQUIRED", AuthenticationError) 270 | 271 | def test_account_id_required(self) -> None: 272 | self._test_error(401, "ACCOUNT_ID_REQUIRED", AuthenticationError) 273 | 274 | def test_user_id_required(self) -> None: 275 | self._test_error(401, "USER_ID_REQUIRED", AuthenticationError) 276 | 277 | def test_account_id_unknown(self) -> None: 278 | self._test_error(401, "ACCOUNT_ID_UNKNOWN", AuthenticationError) 279 | 280 | def test_user_id_unknown(self) -> None: 281 | self._test_error(401, "USER_ID_UNKNOWN", AuthenticationError) 282 | 283 | def test_out_of_queries_error(self) -> None: 284 | self._test_error(402, "OUT_OF_QUERIES", OutOfQueriesError) 285 | 286 | def _test_error( 287 | self, 288 | status: int, 289 | error_code: str, 290 | error_class: type[Exception], 291 | ) -> None: 292 | msg = "Some error message" 293 | body = {"error": msg, "code": error_code} 294 | self.httpserver.expect_request( 295 | "/geoip/v2.1/country/1.2.3.18", 296 | method="GET", 297 | ).respond_with_json( 298 | body, 299 | status=status, 300 | content_type=self._content_type("country"), 301 | ) 302 | with pytest.raises(error_class, match=msg): 303 | self.run_client(self.client.country("1.2.3.18")) 304 | 305 | def test_unknown_error(self) -> None: 306 | msg = "Unknown error type" 307 | ip = "1.2.3.19" 308 | body = {"error": msg, "code": "UNKNOWN_TYPE"} 309 | self.httpserver.expect_request( 310 | "/geoip/v2.1/country/" + ip, 311 | method="GET", 312 | ).respond_with_json( 313 | body, 314 | status=400, 315 | content_type=self._content_type("country"), 316 | ) 317 | with pytest.raises(InvalidRequestError, match=msg): 318 | self.run_client(self.client.country(ip)) 319 | 320 | def test_request(self) -> None: 321 | def user_agent_compare(actual: str, _: str) -> bool: 322 | if actual is None: 323 | return False 324 | return actual.startswith("GeoIP2-Python-Client/") 325 | 326 | self.httpserver.expect_request( 327 | "/geoip/v2.1/country/1.2.3.4", 328 | method="GET", 329 | headers={ 330 | "Accept": "application/json", 331 | "Authorization": "Basic NDI6YWJjZGVmMTIzNDU2", 332 | "User-Agent": "GeoIP2-Python-Client/", 333 | }, 334 | header_value_matcher=HeaderValueMatcher( 335 | defaultdict( 336 | lambda: HeaderValueMatcher.default_header_value_matcher, 337 | {"User-Agent": user_agent_compare}, # type: ignore[dict-item] 338 | ), 339 | ), 340 | ).respond_with_json( 341 | self.country, 342 | status=200, 343 | content_type=self._content_type("country"), 344 | ) 345 | self.run_client(self.client.country("1.2.3.4")) 346 | 347 | def test_city_ok(self) -> None: 348 | self.httpserver.expect_request( 349 | "/geoip/v2.1/city/1.2.3.4", 350 | method="GET", 351 | ).respond_with_json( 352 | self.country, 353 | status=200, 354 | content_type=self._content_type("city"), 355 | ) 356 | city = self.run_client(self.client.city("1.2.3.4")) 357 | self.assertEqual(type(city), geoip2.models.City, "return value of client.city") 358 | self.assertEqual( 359 | city.traits.network, 360 | ipaddress.ip_network("1.2.3.0/24"), 361 | "network", 362 | ) 363 | self.assertTrue(city.traits.is_anycast) 364 | 365 | def test_insights_ok(self) -> None: 366 | self.httpserver.expect_request( 367 | "/geoip/v2.1/insights/1.2.3.4", 368 | method="GET", 369 | ).respond_with_json( 370 | self.insights, 371 | status=200, 372 | content_type=self._content_type("insights"), 373 | ) 374 | insights = self.run_client(self.client.insights("1.2.3.4")) 375 | self.assertEqual( 376 | type(insights), 377 | geoip2.models.Insights, 378 | "return value of client.insights", 379 | ) 380 | self.assertEqual( 381 | insights.traits.network, 382 | ipaddress.ip_network("1.2.3.0/24"), 383 | "network", 384 | ) 385 | self.assertTrue(insights.traits.is_anycast) 386 | self.assertEqual(insights.traits.static_ip_score, 1.3, "static_ip_score is 1.3") 387 | self.assertEqual(insights.traits.user_count, 2, "user_count is 2") 388 | 389 | def test_named_constructor_args(self) -> None: 390 | account_id = 47 391 | key = "1234567890ab" 392 | client = self.client_class(account_id, key) 393 | self.assertEqual(client._account_id, str(account_id)) # noqa: SLF001 394 | self.assertEqual(client._license_key, key) # noqa: SLF001 395 | 396 | def test_missing_constructor_args(self) -> None: 397 | with self.assertRaises(TypeError): 398 | self.client_class(license_key="1234567890ab") # type: ignore[call-arg] 399 | 400 | with self.assertRaises(TypeError): 401 | self.client_class("47") # type: ignore[call-arg,arg-type,misc] 402 | 403 | 404 | class TestClient(TestBaseClient): 405 | client: Client 406 | 407 | def setUp(self) -> None: 408 | self.client_class = Client 409 | self.client = Client(42, "abcdef123456") 410 | self.client._base_uri = self.httpserver.url_for("/geoip/v2.1") # noqa: SLF001 411 | self.maxDiff = 20_000 412 | 413 | def run_client(self, v): # noqa: ANN001 414 | return v 415 | 416 | 417 | class TestAsyncClient(TestBaseClient): 418 | client: AsyncClient 419 | 420 | def setUp(self) -> None: 421 | self._loop = asyncio.new_event_loop() 422 | self.client_class = AsyncClient 423 | self.client = AsyncClient(42, "abcdef123456") 424 | self.client._base_uri = self.httpserver.url_for("/geoip/v2.1") # noqa: SLF001 425 | self.maxDiff = 20_000 426 | 427 | def tearDown(self) -> None: 428 | self._loop.run_until_complete(self.client.close()) 429 | self._loop.close() 430 | 431 | def run_client(self, v): # noqa: ANN001 432 | return self._loop.run_until_complete(v) 433 | 434 | 435 | del TestBaseClient 436 | 437 | 438 | if __name__ == "__main__": 439 | unittest.main() 440 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | 2 | .. :changelog: 3 | 4 | History 5 | ------- 6 | 7 | 5.3.0 8 | ++++++++++++++++++ 9 | 10 | * The version is now retrieved from package metadata at runtime using 11 | ``importlib.metadata``. This reduces the chance of version inconsistencies 12 | during releases. 13 | 14 | 5.2.0 (2025-11-20) 15 | ++++++++++++++++++ 16 | 17 | * IMPORTANT: Python 3.10 or greater is required. If you are using an older 18 | version, please use an earlier release. 19 | * `maxminddb` has been upgraded to 3.0.0. This includes free-threading 20 | support. 21 | * Setuptools has been replaced with the uv build backend for building the 22 | package. 23 | * A new ``anonymizer`` object has been added to ``geoip2.models.Insights``. 24 | This object is a ``geoip2.records.Anonymizer`` and contains the following 25 | fields: ``confidence``, ``network_last_seen``, ``provider_name``, 26 | ``is_anonymous``, ``is_anonymous_vpn``, ``is_hosting_provider``, 27 | ``is_public_proxy``, ``is_residential_proxy``, and ``is_tor_exit_node``. 28 | These provide information about VPN and proxy usage. 29 | * A new ``ip_risk_snapshot`` property has been added to 30 | ``geoip2.records.Traits``. This is a float ranging from 0.01 to 99 that 31 | represents the risk associated with the IP address. A higher score indicates 32 | a higher risk. This field is only available from the Insights end point. 33 | * The following properties on ``geoip2.records.Traits`` have been deprecated: 34 | ``is_anonymous``, ``is_anonymous_vpn``, ``is_hosting_provider``, 35 | ``is_public_proxy``, ``is_residential_proxy``, and ``is_tor_exit_node``. 36 | Please use the ``anonymizer`` object in the ``Insights`` model instead. 37 | 38 | 5.1.0 (2025-05-05) 39 | ++++++++++++++++++ 40 | 41 | * Support for the GeoIP Anonymous Plus database has been added. To do a lookup 42 | in this database, use the ``anonymous_plus`` method on ``Reader``. 43 | * Reorganized module documentation to improve language-server support. 44 | 45 | 5.0.1 (2025-01-28) 46 | ++++++++++++++++++ 47 | 48 | * Allow ``ip_address`` in the ``Traits`` record to be ``None`` again. The 49 | primary use case for this is from the ``minfraud`` package. 50 | 51 | 5.0.0 (2025-01-28) 52 | ++++++++++++++++++ 53 | 54 | * BREAKING: The ``raw`` attribute on the model classes has been replaced 55 | with a ``to_dict()`` method. This can be used to get a representation of 56 | the object that is suitable for serialization. 57 | * BREAKING: The ``ip_address`` property on the model classes now always returns 58 | a ``ipaddress.IPv4Address`` or ``ipaddress.IPv6Address``. 59 | * BREAKING: The model and record classes now require all arguments other than 60 | ``locales`` and ``ip_address`` to be keyword arguments. 61 | * BREAKING: ``geoip2.mixins`` has been made internal. This normally would not 62 | have been used by external code. 63 | * IMPORTANT: Python 3.9 or greater is required. If you are using an older 64 | version, please use an earlier release. 65 | * ``metro_code`` on ``geoip2.record.Location`` has been deprecated. The 66 | code values are no longer being maintained. 67 | * The type hinting for the optional ``locales`` keyword argument now allows 68 | any sequence of strings rather than only list of strings. 69 | 70 | 4.8.1 (2024-11-18) 71 | ++++++++++++++++++ 72 | 73 | * ``setuptools`` was incorrectly listed as a runtime dependency. This has 74 | been removed. Pull request by Mathieu Dupuy. GitHub #174. 75 | 76 | 4.8.0 (2023-12-05) 77 | ++++++++++++++++++ 78 | 79 | * IMPORTANT: Python 3.8 or greater is required. If you are using an older 80 | version, please use an earlier release. 81 | * The ``is_anycast`` attribute was added to ``geoip2.record.Traits``. 82 | This returns ``True`` if the IP address belongs to an 83 | `anycast network `_. 84 | This is available for the GeoIP2 Country, City Plus, and Insights web services 85 | and the GeoIP2 Country, City, and Enterprise databases. 86 | 87 | 4.7.0 (2023-05-09) 88 | ++++++++++++++++++ 89 | 90 | * IMPORTANT: Python 3.7 or greater is required. If you are using an older 91 | version, please use an earlier release. 92 | 93 | 4.6.0 (2022-06-21) 94 | ++++++++++++++++++ 95 | 96 | * The ``AddressNotFoundError`` class now has an ``ip_address`` attribute 97 | with the lookup address and ``network`` property for the empty network 98 | in the database containing the IP address. These are only available 99 | when using a database, not the web service. Pull request by illes. 100 | GitHub #130. 101 | 102 | 4.5.0 (2021-11-18) 103 | ++++++++++++++++++ 104 | 105 | * Support for mobile country code (MCC) and mobile network codes (MNC) was 106 | added for the GeoIP2 ISP and Enterprise databases as well as the GeoIP2 107 | City and Insights web services. ``mobile_country_code`` and 108 | ``mobile_network_code`` attributes were added to ``geoip2.model.ISP`` 109 | for the GeoIP2 ISP database and ``geoip2.record.Traits`` for the 110 | Enterprise database and the GeoIP2 City and Insights web services. 111 | We expect this data to be available by late January, 2022. 112 | 113 | 4.4.0 (2021-09-24) 114 | ++++++++++++++++++ 115 | 116 | * The public API on ``geoip2.database`` is now explicitly defined by 117 | setting ``__all__``. 118 | * The return type of the ``metadata()`` method on ``Reader`` is now 119 | ``maxminddb.reader.Metadata`` rather than a union type. 120 | 121 | 4.3.0 (2021-09-20) 122 | ++++++++++++++++++ 123 | 124 | * Previously, the ``py.typed`` file was not being added to the source 125 | distribution. It is now explicitly specified in the manifest. 126 | * The type hints for the database file in the ``Reader`` constructor have 127 | been expanded to match those specified by ``maxmindb.open_database``. In 128 | particular, ``os.PathLike`` and ``IO`` have been added. 129 | * Corrected the type hint for the ``metadata()`` method on ``Reader``. It 130 | will return a ``maxminddb.extension.Metadata`` if the C extension is being 131 | used. 132 | 133 | 4.2.0 (2021-05-12) 134 | ++++++++++++++++++ 135 | 136 | * You may now set a proxy to use when making web service requests by passing 137 | the ``proxy`` parameter to the ``AsyncClient`` or ``Client`` constructor. 138 | 139 | 4.1.0 (2020-09-25) 140 | ++++++++++++++++++ 141 | 142 | * Added the ``is_residential_proxy`` attribute to ``geoip2.model.AnonymousIP`` 143 | and ``geoip2.record.Traits``. 144 | * ``HTTPError`` now provides the decoded response content in the 145 | ``decoded_content`` attribute. Requested by Oleg Serbokryl. GitHub #95. 146 | 147 | 4.0.2 (2020-07-28) 148 | ++++++++++++++++++ 149 | 150 | * Added ``py.typed`` file per PEP 561. Reported by Árni Már Jónsson. 151 | 152 | 4.0.1 (2020-07-21) 153 | ++++++++++++++++++ 154 | 155 | * Re-release to fix bad reStructuredText in ``README.md``. No substantive 156 | changes. 157 | 158 | 4.0.0 (2020-07-21) 159 | ++++++++++++++++++ 160 | 161 | * IMPORTANT: Python 2.7 and 3.5 support has been dropped. Python 3.6 or greater 162 | is required. 163 | * Asyncio support has been added for web service requests. To make async 164 | requests, use ``geoip.webservice.AsyncClient``. 165 | * ``geoip.webservice.Client`` now provides a ``close()`` method and associated 166 | context managers to be used in ``with`` statements. 167 | * Type hints have been added. 168 | * The attributes ``postal_code`` and ``postal_confidence`` have been removed 169 | from ``geoip2.record.Location``. These would previously always be ``None``. 170 | * ``user_id`` is no longer supported as a named argument for the constructor 171 | on ``geoip2.webservice.Client``. Use ``account_id`` or a positional 172 | parameter instead. 173 | * For both ``Client`` and ``AsyncClient`` requests, the default timeout is 174 | now 60 seconds. 175 | 176 | 3.0.0 (2019-12-20) 177 | ++++++++++++++++++ 178 | 179 | * BREAKING CHANGE: The ``geoip2.record.*`` classes have been refactored to 180 | improve performance. This refactoring may break classes that inherit from 181 | them. The public API should otherwise be compatible. 182 | * The ``network`` attribute was added to ``geoip2.record.Traits``, 183 | ``geoip2.model.AnonymousIP``, ``geoip2.model.ASN``, 184 | ``geoip2.model.ConnectionType``, ``geoip2.model.Domain``, 185 | and ``geoip2.model.ISP``. This is an ``ipaddress.IPv4Network`` or an 186 | ``ipaddress.IPv6Network``. This is the largest network where all of the 187 | fields besides ``ip_address`` have the same value. GitHub #79. 188 | * Python 3.3 and 3.4 are no longer supported. 189 | * Updated documentation of anonymizer attributes - ``is_anonymous_vpn`` and 190 | ``is_hosting_provider`` - to be more descriptive. 191 | * Added support for the ``user_count`` trait for the GeoIP2 Precision webservice. 192 | * Added the ``static_ip_score`` attribute to ``geoip2.record.Traits`` for 193 | GeoIP2 Precision Insights. This is a float which indicates how static or dynamic 194 | an IP address is. 195 | 196 | 2.9.0 (2018-05-25) 197 | ++++++++++++++++++ 198 | 199 | * You may now pass in the database via a file descriptor rather than a file 200 | name when creating a new ``geoip2.database.Reader`` object using ``MODE_FD``. 201 | This will read the database from the file descriptor into memory. Pull 202 | request by nkinkade. GitHub #53. 203 | 204 | 2.8.0 (2018-04-10) 205 | ++++++++++++++++++ 206 | 207 | * Python 2.6 support has been dropped. Python 2.7+ or 3.3+ is now required. 208 | * Renamed user ID to account ID in the code and added support for the new 209 | ``ACCOUNT_ID_REQUIRED`` AND ``ACCOUNT_ID_UNKNOWN`` error codes. 210 | 211 | 2.7.0 (2018-01-18) 212 | ++++++++++++++++++ 213 | 214 | * The ``is_in_european_union`` attribute was added to 215 | ``geoip2.record.Country`` and ``geoip2.record.RepresentedCountry``. This 216 | attribute is ``True`` if the country is a member state of the European 217 | Union. 218 | 219 | 2.6.0 (2017-10-27) 220 | ++++++++++++++++++ 221 | 222 | * The following new anonymizer attributes were added to ``geoip2.record.Traits`` 223 | for use with GeoIP2 Precision Insights: ``is_anonymous``, 224 | ``is_anonymous_vpn``, ``is_hosting_provider``, ``is_public_proxy``, and 225 | ``is_tor_exit_node``. 226 | 227 | 2.5.0 (2017-05-08) 228 | ++++++++++++++++++ 229 | 230 | * Added support for GeoLite2 ASN database. 231 | * Corrected documentation of errors raised when using the database reader. 232 | Reported by Radek Holý. GitHub #42. 233 | 234 | 2.4.2 (2016-12-08) 235 | ++++++++++++++++++ 236 | 237 | * Recent releases of ``requests`` (2.12.2 and 2.12.3) require that the 238 | username for basic authentication be a string or bytes. The documentation 239 | for this module uses an integer for the ``user_id``, which will break with 240 | these ``requests`` versions. The ``user_id`` is now converted to bytes 241 | before being passed to ``requests``. 242 | 243 | 2.4.1 (2016-11-21) 244 | ++++++++++++++++++ 245 | 246 | * Updated documentation to clarify what the accuracy radius refers to. 247 | * Fixed classifiers in ``setup.py``. 248 | 249 | 2.4.0 (2016-06-10) 250 | ++++++++++++++++++ 251 | 252 | * This module now uses ``ipaddress`` on Python 2 rather than ``ipaddr`` to 253 | validate IP addresses before sending them to the web service. 254 | * Added handling of additional error codes that the web service may return. 255 | * PEP 257 documentation fixes. 256 | * Updated documentation to reflect that the accuracy radius is now included 257 | in City. 258 | * Previously, the source distribution was missing some tests and test 259 | databases. This has been corrected. Reported by Lumir Balhar. 260 | 261 | 2.3.0 (2016-04-15) 262 | ++++++++++++++++++ 263 | 264 | * Added support for the GeoIP2 Enterprise database. 265 | * ``geoip2.database.Reader`` now supports being used in a ``with`` statement 266 | (PEP 343). (PR from Nguyễn Hồng Quân. GitHub #29) 267 | 268 | 2.2.0 (2015-06-29) 269 | ++++++++++++++++++ 270 | 271 | * The ``geoip2.records.Location`` class has been updated to add attributes for 272 | the ``average_income`` and ``population_density`` fields provided by the 273 | Insights web service. 274 | * The ``is_anonymous_proxy`` and ``is_satellite_provider`` properties on 275 | ``geoip2.records.Traits`` have been deprecated. Please use our `GeoIP2 276 | Anonymous IP database 277 | `_ 278 | to determine whether an IP address is used by an anonymizing service. 279 | 280 | 2.1.0 (2014-12-09) 281 | ++++++++++++++++++ 282 | 283 | * The reader now supports pure Python file and memory modes. If you are not 284 | using the C extension and your Python does not provide the ``mmap`` module, 285 | the file mode will be used by default. You can explicitly set the mode using 286 | the ``mode`` keyword argument with the ``MODE_AUTO``, ``MODE_MMAP``, 287 | ``MODE_MMAP_EXT``, ``MODE_FILE``, and ``MODE_MEMORY`` constants exported by 288 | ``geoip2.database``. 289 | 290 | 2.0.2 (2014-10-28) 291 | ++++++++++++++++++ 292 | 293 | * Added support for the GeoIP2 Anonymous IP database. The 294 | ``geoip2.database.Reader`` class now has an ``anonymous_ip()`` method which 295 | returns a ``geoip2.models.AnonymousIP`` object. 296 | * Added ``__repr__`` and ``__eq__`` methods to the model and record classes 297 | to aid in debugging and using the library from a REPL. 298 | 299 | 2.0.1 (2014-10-17) 300 | ++++++++++++++++++ 301 | 302 | * The constructor for ``geoip2.webservice.Client`` now takes an optional 303 | ``timeout`` parameter. (PR from arturro. GitHub #15) 304 | 305 | 2.0.0 (2014-09-22) 306 | ++++++++++++++++++ 307 | 308 | * First production release. 309 | 310 | 0.7.0 (2014-09-15) 311 | ++++++++++++++++++ 312 | 313 | * BREAKING CHANGES: 314 | - The deprecated ``city_isp_org()`` and ``omni()`` methods 315 | have been removed. 316 | - The ``geoip2.database.Reader`` lookup methods (e.g., ``city()``, 317 | ``isp()``) now raise a ``TypeError`` if they are used with a database that 318 | does not match the method. In particular, doing a ``city()`` lookup on a 319 | GeoIP2 Country database will result in an error and vice versa. 320 | * A ``metadata()`` method has been added to the ``geoip2.database.Reader`` 321 | class. This returns a ``maxminddb.reader.Metadata`` object with information 322 | about the database. 323 | 324 | 0.6.0 (2014-07-22) 325 | ++++++++++++++++++ 326 | 327 | * The web service client API has been updated for the v2.1 release of the web 328 | service. In particular, the ``city_isp_org`` and ``omni`` methods on 329 | ``geoip2.webservice.Client`` should be considered deprecated. The ``city`` 330 | method now provides all of the data formerly provided by ``city_isp_org``, 331 | and the ``omni`` method has been replaced by the ``insights`` method. 332 | **Note:** In v2.1 of the web service, ``accuracy_radius``, 333 | ``autonomous_system_number``, and all of the ``confidence`` values were 334 | changed from unicode to integers. This may affect how you use these values 335 | from this API. 336 | * Support was added for the GeoIP2 Connection Type, Domain, and ISP databases. 337 | 338 | 0.5.1 (2014-03-28) 339 | ++++++++++++++++++ 340 | 341 | * Switched to Apache 2.0 license. 342 | 343 | 0.5.0 (2014-02-11) 344 | ++++++++++++++++++ 345 | 346 | * Fixed missing import statements for geoip2.errors and geoip2.models. 347 | (Gustavo J. A. M. Carneiro) 348 | * Minor documentation and code cleanup 349 | * Added requirement for maxminddb v0.3.0, which includes a pure Python 350 | database reader. Removed the ``extras_require`` for maxminddb. 351 | 352 | 0.4.2 (2013-12-20) 353 | ++++++++++++++++++ 354 | 355 | * Added missing geoip2.models import to geoip.database. 356 | * Documentation updates. 357 | 358 | 0.4.1 (2013-10-25) 359 | ++++++++++++++++++ 360 | 361 | * Read in ``README.rst`` as UTF-8 in ``setup.py``. 362 | 363 | 0.4.0 (2013-10-21) 364 | ++++++++++++++++++ 365 | 366 | * API CHANGE: Changed the ``languages`` keyword argument to ``locales`` on the 367 | constructors for ``geoip.webservice.Client`` and ``geoip.database.Reader``. 368 | 369 | 0.3.1 (2013-10-15) 370 | ++++++++++++++++++ 371 | 372 | * Fixed packaging issue with extras_require. 373 | 374 | 0.3.0 (2013-10-15) 375 | ++++++++++++++++++ 376 | 377 | * IMPORTANT: ``geoip.webservices`` was renamed ``geoip.webservice`` as it 378 | contains only one class. 379 | * Added GeoIP2 database reader using ``maxminddb``. This does not work with 380 | PyPy as it relies on a C extension. 381 | * Added more specific exceptions for web service client. 382 | 383 | 0.2.2 (2013-06-20) 384 | ++++++++++++++++++ 385 | 386 | * Fixed a bug in the model objects that prevented ``longitude`` and 387 | ``metro_code`` from being used. 388 | 389 | 0.2.1 (2013-06-10) 390 | ++++++++++++++++++ 391 | 392 | * First official beta release. 393 | * Documentation updates and corrections. 394 | 395 | 0.2.0 (2013-05-29) 396 | ++++++++++++++++++ 397 | 398 | * Support for Python 3.2 was dropped. 399 | * The methods to call the web service on the ``Client`` object now validate 400 | the IP addresses before calling the web service. This requires the 401 | ``ipaddr`` module on Python 2.x. 402 | * We now support more languages. The new languages are de, es, fr, and pt-BR. 403 | * The REST API now returns a record with data about your account. There is 404 | a new geoip.records.MaxMind class for this data. 405 | * Rename model.continent.continent_code to model.continent.code. 406 | * Documentation updates. 407 | 408 | 0.1.1 (2013-05-14) 409 | ++++++++++++++++++ 410 | 411 | * Documentation and packaging updates 412 | 413 | 0.1.0 (2013-05-13) 414 | ++++++++++++++++++ 415 | 416 | * Initial release 417 | -------------------------------------------------------------------------------- /src/geoip2/models.py: -------------------------------------------------------------------------------- 1 | """The models for response from th GeoIP2 web service and databases. 2 | 3 | The only difference between the City and Insights model classes is which 4 | fields in each record may be populated. See 5 | https://dev.maxmind.com/geoip/docs/web-services?lang=en for more details. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import datetime 11 | import ipaddress 12 | from abc import ABCMeta 13 | from ipaddress import IPv4Address, IPv6Address 14 | from typing import TYPE_CHECKING, Any 15 | 16 | import geoip2.records 17 | from geoip2._internal import Model 18 | 19 | if TYPE_CHECKING: 20 | from collections.abc import Sequence 21 | 22 | from geoip2.types import IPAddress 23 | 24 | 25 | class Country(Model): 26 | """Model for the Country web service and Country database.""" 27 | 28 | continent: geoip2.records.Continent 29 | """Continent object for the requested IP address.""" 30 | 31 | country: geoip2.records.Country 32 | """Country object for the requested IP address. This record represents the 33 | country where MaxMind believes the IP is located. 34 | """ 35 | 36 | maxmind: geoip2.records.MaxMind 37 | """Information related to your MaxMind account.""" 38 | 39 | registered_country: geoip2.records.Country 40 | """The registered country object for the requested IP address. This record 41 | represents the country where the ISP has registered a given IP block in 42 | and may differ from the user's country. 43 | """ 44 | 45 | represented_country: geoip2.records.RepresentedCountry 46 | """Object for the country represented by the users of the IP address 47 | when that country is different than the country in ``country``. For 48 | instance, the country represented by an overseas military base. 49 | """ 50 | 51 | traits: geoip2.records.Traits 52 | """Object with the traits of the requested IP address.""" 53 | 54 | def __init__( 55 | self, 56 | locales: Sequence[str] | None, 57 | *, 58 | continent: dict[str, Any] | None = None, 59 | country: dict[str, Any] | None = None, 60 | ip_address: IPAddress | None = None, 61 | maxmind: dict[str, Any] | None = None, 62 | prefix_len: int | None = None, 63 | registered_country: dict[str, Any] | None = None, 64 | represented_country: dict[str, Any] | None = None, 65 | traits: dict[str, Any] | None = None, 66 | **_: Any, 67 | ) -> None: 68 | self._locales = locales 69 | self.continent = geoip2.records.Continent(locales, **(continent or {})) 70 | self.country = geoip2.records.Country(locales, **(country or {})) 71 | self.registered_country = geoip2.records.Country( 72 | locales, 73 | **(registered_country or {}), 74 | ) 75 | self.represented_country = geoip2.records.RepresentedCountry( 76 | locales, 77 | **(represented_country or {}), 78 | ) 79 | 80 | self.maxmind = geoip2.records.MaxMind(**(maxmind or {})) 81 | 82 | traits = traits or {} 83 | if ip_address is not None: 84 | traits["ip_address"] = ip_address 85 | if prefix_len is not None: 86 | traits["prefix_len"] = prefix_len 87 | 88 | self.traits = geoip2.records.Traits(**traits) 89 | 90 | def __repr__(self) -> str: 91 | return ( 92 | f"{self.__module__}.{self.__class__.__name__}({self._locales!r}, " 93 | f"{', '.join(f'{k}={v!r}' for k, v in self.to_dict().items())})" 94 | ) 95 | 96 | 97 | class City(Country): 98 | """Model for the City Plus web service and the City database.""" 99 | 100 | city: geoip2.records.City 101 | """City object for the requested IP address.""" 102 | 103 | location: geoip2.records.Location 104 | """Location object for the requested IP address.""" 105 | 106 | postal: geoip2.records.Postal 107 | """Postal object for the requested IP address.""" 108 | 109 | subdivisions: geoip2.records.Subdivisions 110 | """Object (tuple) representing the subdivisions of the country to which 111 | the location of the requested IP address belongs. 112 | """ 113 | 114 | def __init__( 115 | self, 116 | locales: Sequence[str] | None, 117 | *, 118 | city: dict[str, Any] | None = None, 119 | continent: dict[str, Any] | None = None, 120 | country: dict[str, Any] | None = None, 121 | location: dict[str, Any] | None = None, 122 | ip_address: IPAddress | None = None, 123 | maxmind: dict[str, Any] | None = None, 124 | postal: dict[str, Any] | None = None, 125 | prefix_len: int | None = None, 126 | registered_country: dict[str, Any] | None = None, 127 | represented_country: dict[str, Any] | None = None, 128 | subdivisions: list[dict[str, Any]] | None = None, 129 | traits: dict[str, Any] | None = None, 130 | **_: Any, 131 | ) -> None: 132 | super().__init__( 133 | locales, 134 | continent=continent, 135 | country=country, 136 | ip_address=ip_address, 137 | maxmind=maxmind, 138 | prefix_len=prefix_len, 139 | registered_country=registered_country, 140 | represented_country=represented_country, 141 | traits=traits, 142 | ) 143 | self.city = geoip2.records.City(locales, **(city or {})) 144 | self.location = geoip2.records.Location(**(location or {})) 145 | self.postal = geoip2.records.Postal(**(postal or {})) 146 | self.subdivisions = geoip2.records.Subdivisions(locales, *(subdivisions or [])) 147 | 148 | 149 | class Insights(City): 150 | """Model for the GeoIP2 Insights web service.""" 151 | 152 | anonymizer: geoip2.records.Anonymizer 153 | """Anonymizer object for the requested IP address. This object contains 154 | information about VPN and proxy usage. 155 | """ 156 | 157 | def __init__( 158 | self, 159 | locales: Sequence[str] | None, 160 | *, 161 | anonymizer: dict[str, Any] | None = None, 162 | city: dict[str, Any] | None = None, 163 | continent: dict[str, Any] | None = None, 164 | country: dict[str, Any] | None = None, 165 | location: dict[str, Any] | None = None, 166 | ip_address: IPAddress | None = None, 167 | maxmind: dict[str, Any] | None = None, 168 | postal: dict[str, Any] | None = None, 169 | prefix_len: int | None = None, 170 | registered_country: dict[str, Any] | None = None, 171 | represented_country: dict[str, Any] | None = None, 172 | subdivisions: list[dict[str, Any]] | None = None, 173 | traits: dict[str, Any] | None = None, 174 | **_: Any, 175 | ) -> None: 176 | super().__init__( 177 | locales, 178 | city=city, 179 | continent=continent, 180 | country=country, 181 | location=location, 182 | ip_address=ip_address, 183 | maxmind=maxmind, 184 | postal=postal, 185 | prefix_len=prefix_len, 186 | registered_country=registered_country, 187 | represented_country=represented_country, 188 | subdivisions=subdivisions, 189 | traits=traits, 190 | ) 191 | self.anonymizer = geoip2.records.Anonymizer(**(anonymizer or {})) 192 | 193 | 194 | class Enterprise(City): 195 | """Model for the GeoIP2 Enterprise database.""" 196 | 197 | 198 | class SimpleModel(Model, metaclass=ABCMeta): 199 | """Provides basic methods for non-location models.""" 200 | 201 | _ip_address: IPAddress 202 | _network: ipaddress.IPv4Network | ipaddress.IPv6Network | None 203 | _prefix_len: int | None 204 | 205 | def __init__( 206 | self, 207 | ip_address: IPAddress, 208 | network: str | None, 209 | prefix_len: int | None, 210 | ) -> None: 211 | if network: 212 | self._network = ipaddress.ip_network(network, strict=False) 213 | self._prefix_len = self._network.prefixlen 214 | else: 215 | # This case is for MMDB lookups where performance is paramount. 216 | # This is why we don't generate the network unless .network is 217 | # used. 218 | self._network = None 219 | self._prefix_len = prefix_len 220 | self._ip_address = ip_address 221 | 222 | def __repr__(self) -> str: 223 | d = self.to_dict() 224 | d.pop("ip_address", None) 225 | return ( 226 | f"{self.__module__}.{self.__class__.__name__}(" 227 | + repr(str(self._ip_address)) 228 | + ", " 229 | + ", ".join(f"{k}={v!r}" for k, v in d.items()) 230 | + ")" 231 | ) 232 | 233 | @property 234 | def ip_address(self) -> IPv4Address | IPv6Address: 235 | """The IP address for the record.""" 236 | if not isinstance(self._ip_address, (IPv4Address, IPv6Address)): 237 | self._ip_address = ipaddress.ip_address(self._ip_address) 238 | return self._ip_address 239 | 240 | @property 241 | def network(self) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None: 242 | """The network associated with the record. 243 | 244 | In particular, this is the largest network where all of the fields besides 245 | ``ip_address`` have the same value. 246 | """ 247 | # This code is duplicated for performance reasons 248 | network = self._network 249 | if network is not None: 250 | return network 251 | 252 | ip_address = self.ip_address 253 | prefix_len = self._prefix_len 254 | if ip_address is None or prefix_len is None: 255 | return None 256 | network = ipaddress.ip_network(f"{ip_address}/{prefix_len}", strict=False) 257 | self._network = network 258 | return network 259 | 260 | 261 | class AnonymousIP(SimpleModel): 262 | """Model class for the GeoIP2 Anonymous IP.""" 263 | 264 | is_anonymous: bool 265 | """This is true if the IP address belongs to any sort of anonymous network.""" 266 | 267 | is_anonymous_vpn: bool 268 | """This is true if the IP address is registered to an anonymous VPN 269 | provider. 270 | 271 | If a VPN provider does not register subnets under names associated with 272 | them, we will likely only flag their IP ranges using the 273 | ``is_hosting_provider`` attribute. 274 | """ 275 | 276 | is_hosting_provider: bool 277 | """This is true if the IP address belongs to a hosting or VPN provider 278 | (see description of ``is_anonymous_vpn`` attribute). 279 | """ 280 | 281 | is_public_proxy: bool 282 | """This is true if the IP address belongs to a public proxy.""" 283 | 284 | is_residential_proxy: bool 285 | """This is true if the IP address is on a suspected anonymizing network 286 | and belongs to a residential ISP. 287 | """ 288 | 289 | is_tor_exit_node: bool 290 | """This is true if the IP address is a Tor exit node.""" 291 | 292 | def __init__( 293 | self, 294 | ip_address: IPAddress, 295 | *, 296 | is_anonymous: bool = False, 297 | is_anonymous_vpn: bool = False, 298 | is_hosting_provider: bool = False, 299 | is_public_proxy: bool = False, 300 | is_residential_proxy: bool = False, 301 | is_tor_exit_node: bool = False, 302 | network: str | None = None, 303 | prefix_len: int | None = None, 304 | **_: Any, 305 | ) -> None: 306 | super().__init__(ip_address, network, prefix_len) 307 | self.is_anonymous = is_anonymous 308 | self.is_anonymous_vpn = is_anonymous_vpn 309 | self.is_hosting_provider = is_hosting_provider 310 | self.is_public_proxy = is_public_proxy 311 | self.is_residential_proxy = is_residential_proxy 312 | self.is_tor_exit_node = is_tor_exit_node 313 | 314 | 315 | class AnonymousPlus(AnonymousIP): 316 | """Model class for the GeoIP Anonymous Plus.""" 317 | 318 | anonymizer_confidence: int | None 319 | """A score ranging from 1 to 99 that is our percent confidence that the 320 | network is currently part of an actively used VPN service. 321 | """ 322 | 323 | network_last_seen: datetime.date | None 324 | """The last day that the network was sighted in our analysis of anonymized 325 | networks. 326 | """ 327 | 328 | provider_name: str | None 329 | """The name of the VPN provider (e.g., NordVPN, SurfShark, etc.) associated 330 | with the network. 331 | """ 332 | 333 | def __init__( 334 | self, 335 | ip_address: IPAddress, 336 | *, 337 | anonymizer_confidence: int | None = None, 338 | is_anonymous: bool = False, 339 | is_anonymous_vpn: bool = False, 340 | is_hosting_provider: bool = False, 341 | is_public_proxy: bool = False, 342 | is_residential_proxy: bool = False, 343 | is_tor_exit_node: bool = False, 344 | network: str | None = None, 345 | network_last_seen: str | None = None, 346 | prefix_len: int | None = None, 347 | provider_name: str | None = None, 348 | **_: Any, 349 | ) -> None: 350 | super().__init__( 351 | is_anonymous=is_anonymous, 352 | is_anonymous_vpn=is_anonymous_vpn, 353 | is_hosting_provider=is_hosting_provider, 354 | is_public_proxy=is_public_proxy, 355 | is_residential_proxy=is_residential_proxy, 356 | is_tor_exit_node=is_tor_exit_node, 357 | ip_address=ip_address, 358 | network=network, 359 | prefix_len=prefix_len, 360 | ) 361 | self.anonymizer_confidence = anonymizer_confidence 362 | if network_last_seen is not None: 363 | self.network_last_seen = datetime.date.fromisoformat(network_last_seen) 364 | self.provider_name = provider_name 365 | 366 | 367 | class ASN(SimpleModel): 368 | """Model class for the GeoLite2 ASN.""" 369 | 370 | autonomous_system_number: int | None 371 | """The autonomous system number associated with the IP address.""" 372 | 373 | autonomous_system_organization: str | None 374 | """The organization associated with the registered autonomous system number 375 | for the IP address. 376 | """ 377 | 378 | def __init__( 379 | self, 380 | ip_address: IPAddress, 381 | *, 382 | autonomous_system_number: int | None = None, 383 | autonomous_system_organization: str | None = None, 384 | network: str | None = None, 385 | prefix_len: int | None = None, 386 | **_: Any, 387 | ) -> None: 388 | super().__init__(ip_address, network, prefix_len) 389 | self.autonomous_system_number = autonomous_system_number 390 | self.autonomous_system_organization = autonomous_system_organization 391 | 392 | 393 | class ConnectionType(SimpleModel): 394 | """Model class for the GeoIP2 Connection-Type.""" 395 | 396 | connection_type: str | None 397 | """The connection type may take the following values: 398 | 399 | - Dialup 400 | - Cable/DSL 401 | - Corporate 402 | - Cellular 403 | - Satellite 404 | 405 | Additional values may be added in the future. 406 | """ 407 | 408 | def __init__( 409 | self, 410 | ip_address: IPAddress, 411 | *, 412 | connection_type: str | None = None, 413 | network: str | None = None, 414 | prefix_len: int | None = None, 415 | **_: Any, 416 | ) -> None: 417 | super().__init__(ip_address, network, prefix_len) 418 | self.connection_type = connection_type 419 | 420 | 421 | class Domain(SimpleModel): 422 | """Model class for the GeoIP2 Domain.""" 423 | 424 | domain: str | None 425 | """The domain associated with the IP address.""" 426 | 427 | def __init__( 428 | self, 429 | ip_address: IPAddress, 430 | *, 431 | domain: str | None = None, 432 | network: str | None = None, 433 | prefix_len: int | None = None, 434 | **_: Any, 435 | ) -> None: 436 | super().__init__(ip_address, network, prefix_len) 437 | self.domain = domain 438 | 439 | 440 | class ISP(ASN): 441 | """Model class for the GeoIP2 ISP.""" 442 | 443 | isp: str | None 444 | """The name of the ISP associated with the IP address.""" 445 | 446 | mobile_country_code: str | None 447 | """The `mobile country code (MCC) 448 | `_ associated with the 449 | IP address and ISP. 450 | """ 451 | 452 | mobile_network_code: str | None 453 | """The `mobile network code (MNC) 454 | `_ associated with the 455 | IP address and ISP. 456 | """ 457 | 458 | organization: str | None 459 | """The name of the organization associated with the IP address.""" 460 | 461 | def __init__( 462 | self, 463 | ip_address: IPAddress, 464 | *, 465 | autonomous_system_number: int | None = None, 466 | autonomous_system_organization: str | None = None, 467 | isp: str | None = None, 468 | mobile_country_code: str | None = None, 469 | mobile_network_code: str | None = None, 470 | organization: str | None = None, 471 | network: str | None = None, 472 | prefix_len: int | None = None, 473 | **_: Any, 474 | ) -> None: 475 | super().__init__( 476 | autonomous_system_number=autonomous_system_number, 477 | autonomous_system_organization=autonomous_system_organization, 478 | ip_address=ip_address, 479 | network=network, 480 | prefix_len=prefix_len, 481 | ) 482 | self.isp = isp 483 | self.mobile_country_code = mobile_country_code 484 | self.mobile_network_code = mobile_network_code 485 | self.organization = organization 486 | -------------------------------------------------------------------------------- /src/geoip2/webservice.py: -------------------------------------------------------------------------------- 1 | """Client for GeoIP2 and GeoLite2 web services. 2 | 3 | The web services are Country, City Plus, and Insights. Each service returns a 4 | different set of data about an IP address, with Country returning the least 5 | data and Insights the most. 6 | 7 | Each service is represented by a different model class, and these model 8 | classes in turn contain multiple record classes. The record classes have 9 | attributes which contain data about the IP address. 10 | 11 | If the service does not return a particular piece of data for an IP address, 12 | the associated attribute is not populated. 13 | 14 | The service may not return any information for an entire record, in which 15 | case all of the attributes for that record class will be empty. 16 | 17 | SSL 18 | --- 19 | 20 | Requests to the web service are always made with SSL. 21 | 22 | """ 23 | 24 | from __future__ import annotations 25 | 26 | import ipaddress 27 | import json 28 | from typing import TYPE_CHECKING, cast 29 | 30 | import aiohttp 31 | import aiohttp.http 32 | import requests 33 | import requests.utils 34 | 35 | import geoip2 36 | import geoip2.models 37 | from geoip2.errors import ( 38 | AddressNotFoundError, 39 | AuthenticationError, 40 | GeoIP2Error, 41 | HTTPError, 42 | InvalidRequestError, 43 | OutOfQueriesError, 44 | PermissionRequiredError, 45 | ) 46 | 47 | if TYPE_CHECKING: 48 | from collections.abc import Sequence 49 | 50 | from typing_extensions import Self 51 | 52 | from geoip2.models import City, Country, Insights 53 | from geoip2.types import IPAddress 54 | 55 | _AIOHTTP_UA = ( 56 | f"GeoIP2-Python-Client/{geoip2.__version__} {aiohttp.http.SERVER_SOFTWARE}" 57 | ) 58 | 59 | _REQUEST_UA = ( 60 | f"GeoIP2-Python-Client/{geoip2.__version__} {requests.utils.default_user_agent()}" 61 | ) 62 | 63 | 64 | class BaseClient: 65 | """Base class for AsyncClient and Client.""" 66 | 67 | _account_id: str 68 | _host: str 69 | _license_key: str 70 | _locales: Sequence[str] 71 | _timeout: float 72 | 73 | def __init__( 74 | self, 75 | account_id: int, 76 | license_key: str, 77 | host: str, 78 | locales: Sequence[str] | None, 79 | timeout: float, 80 | ) -> None: 81 | """Construct a Client.""" 82 | if locales is None: 83 | locales = ["en"] 84 | 85 | self._locales = locales 86 | # requests 2.12.2 requires that the username passed to auth be bytes 87 | # or a string, with the former being preferred. 88 | self._account_id = ( 89 | account_id if isinstance(account_id, bytes) else str(account_id) 90 | ) 91 | self._license_key = license_key 92 | self._base_uri = f"https://{host}/geoip/v2.1" 93 | self._timeout = timeout 94 | 95 | def _uri(self, path: str, ip_address: IPAddress) -> str: 96 | if ip_address != "me": 97 | ip_address = ipaddress.ip_address(ip_address) 98 | return "/".join([self._base_uri, path, str(ip_address)]) 99 | 100 | @staticmethod 101 | def _handle_success(body: str, uri: str) -> dict: 102 | try: 103 | return json.loads(body) 104 | except ValueError as ex: 105 | raise GeoIP2Error( 106 | f"Received a 200 response for {uri}" 107 | " but could not decode the response as " 108 | "JSON: " + ", ".join(ex.args), 109 | 200, 110 | uri, 111 | ) from ex 112 | 113 | def _exception_for_error( 114 | self, 115 | status: int, 116 | content_type: str, 117 | body: str, 118 | uri: str, 119 | ) -> GeoIP2Error: 120 | if 400 <= status < 500: 121 | return self._exception_for_4xx_status(status, content_type, body, uri) 122 | if 500 <= status < 600: 123 | return self._exception_for_5xx_status(status, uri, body) 124 | return self._exception_for_non_200_status(status, uri, body) 125 | 126 | def _exception_for_4xx_status( 127 | self, 128 | status: int, 129 | content_type: str, 130 | body: str, 131 | uri: str, 132 | ) -> GeoIP2Error: 133 | if not body: 134 | return HTTPError( 135 | f"Received a {status} error for {uri} with no body.", 136 | status, 137 | uri, 138 | body, 139 | ) 140 | if content_type.find("json") == -1: 141 | return HTTPError( 142 | f"Received a {status} for {uri} with the following body: {body}", 143 | status, 144 | uri, 145 | body, 146 | ) 147 | try: 148 | decoded_body = json.loads(body) 149 | except ValueError as ex: 150 | return HTTPError( 151 | ( 152 | f"Received a {status} error for {uri} but it did not include " 153 | f"the expected JSON body: {', '.join(ex.args)}" 154 | ), 155 | status, 156 | uri, 157 | body, 158 | ) 159 | 160 | if "code" in decoded_body and "error" in decoded_body: 161 | return self._exception_for_web_service_error( 162 | decoded_body.get("error"), 163 | decoded_body.get("code"), 164 | status, 165 | uri, 166 | ) 167 | return HTTPError( 168 | "Response contains JSON but it does not specify code or error keys", 169 | status, 170 | uri, 171 | body, 172 | ) 173 | 174 | @staticmethod 175 | def _exception_for_web_service_error( 176 | message: str, 177 | code: str, 178 | status: int, 179 | uri: str, 180 | ) -> ( 181 | AuthenticationError 182 | | AddressNotFoundError 183 | | PermissionRequiredError 184 | | OutOfQueriesError 185 | | InvalidRequestError 186 | ): 187 | if code in ("IP_ADDRESS_NOT_FOUND", "IP_ADDRESS_RESERVED"): 188 | return AddressNotFoundError(message) 189 | if code in ( 190 | "ACCOUNT_ID_REQUIRED", 191 | "ACCOUNT_ID_UNKNOWN", 192 | "AUTHORIZATION_INVALID", 193 | "LICENSE_KEY_REQUIRED", 194 | "USER_ID_REQUIRED", 195 | "USER_ID_UNKNOWN", 196 | ): 197 | return AuthenticationError(message) 198 | if code in ("INSUFFICIENT_FUNDS", "OUT_OF_QUERIES"): 199 | return OutOfQueriesError(message) 200 | if code == "PERMISSION_REQUIRED": 201 | return PermissionRequiredError(message) 202 | 203 | return InvalidRequestError(message, code, status, uri) 204 | 205 | @staticmethod 206 | def _exception_for_5xx_status( 207 | status: int, 208 | uri: str, 209 | body: str | None, 210 | ) -> HTTPError: 211 | return HTTPError( 212 | f"Received a server error ({status}) for {uri}", 213 | status, 214 | uri, 215 | body, 216 | ) 217 | 218 | @staticmethod 219 | def _exception_for_non_200_status( 220 | status: int, 221 | uri: str, 222 | body: str | None, 223 | ) -> HTTPError: 224 | return HTTPError( 225 | f"Received a very surprising HTTP status ({status}) for {uri}", 226 | status, 227 | uri, 228 | body, 229 | ) 230 | 231 | 232 | class AsyncClient(BaseClient): 233 | """An async GeoIP2 client. 234 | 235 | It accepts the following required arguments: 236 | 237 | :param account_id: Your MaxMind account ID. 238 | :param license_key: Your MaxMind license key. 239 | 240 | Go to https://www.maxmind.com/en/my_license_key to see your MaxMind 241 | account ID and license key. 242 | 243 | The following keyword arguments are also accepted: 244 | 245 | :param host: The hostname to make a request against. This defaults to 246 | "geoip.maxmind.com". To use the GeoLite2 web service instead of the 247 | GeoIP2 web service, set this to "geolite.info". To use the Sandbox 248 | GeoIP2 web service instead of the production GeoIP2 web service, set 249 | this to "sandbox.maxmind.com". The sandbox allows you to experiment 250 | with the API without affecting your production data. 251 | :param locales: This is list of locale codes. This argument will be 252 | passed on to record classes to use when their name properties are 253 | called. The default value is ['en']. 254 | 255 | The order of the locales is significant. When a record class has 256 | multiple names (country, city, etc.), its name property will return 257 | the name in the first locale that has one. 258 | 259 | Note that the only locale which is always present in the GeoIP2 260 | data is "en". If you do not include this locale, the name property 261 | may end up returning None even when the record has an English name. 262 | 263 | Currently, the valid locale codes are: 264 | 265 | * de -- German 266 | * en -- English names may still include accented characters if that is 267 | the accepted spelling in English. In other words, English does not 268 | mean ASCII. 269 | * es -- Spanish 270 | * fr -- French 271 | * ja -- Japanese 272 | * pt-BR -- Brazilian Portuguese 273 | * ru -- Russian 274 | * zh-CN -- Simplified Chinese. 275 | :param timeout: The timeout in seconds to use when waiting on the request. 276 | This sets both the connect timeout and the read timeout. The default is 277 | 60. 278 | :param proxy: The URL of an HTTP proxy to use. It may optionally include 279 | a basic auth username and password, e.g., 280 | ``http://username:password@host:port``. 281 | 282 | """ 283 | 284 | _existing_session: aiohttp.ClientSession 285 | _proxy: str | None 286 | 287 | def __init__( # noqa: PLR0913 288 | self, 289 | account_id: int, 290 | license_key: str, 291 | host: str = "geoip.maxmind.com", 292 | locales: Sequence[str] | None = None, 293 | timeout: float = 60, 294 | proxy: str | None = None, 295 | ) -> None: 296 | """Initialize AsyncClient.""" 297 | super().__init__( 298 | account_id, 299 | license_key, 300 | host, 301 | locales, 302 | timeout, 303 | ) 304 | self._proxy = proxy 305 | 306 | async def city(self, ip_address: IPAddress = "me") -> City: 307 | """Call City Plus endpoint with the specified IP. 308 | 309 | :param ip_address: IPv4 or IPv6 address as a string. If no 310 | address is provided, the address that the web service is 311 | called from will be used. 312 | 313 | :returns: :py:class:`geoip2.models.City` object 314 | 315 | """ 316 | return cast( 317 | "City", 318 | await self._response_for("city", geoip2.models.City, ip_address), 319 | ) 320 | 321 | async def country(self, ip_address: IPAddress = "me") -> Country: 322 | """Call the GeoIP2 Country endpoint with the specified IP. 323 | 324 | :param ip_address: IPv4 or IPv6 address as a string. If no address 325 | is provided, the address that the web service is called from will 326 | be used. 327 | 328 | :returns: :py:class:`geoip2.models.Country` object 329 | 330 | """ 331 | return cast( 332 | "Country", 333 | await self._response_for("country", geoip2.models.Country, ip_address), 334 | ) 335 | 336 | async def insights(self, ip_address: IPAddress = "me") -> Insights: 337 | """Call the Insights endpoint with the specified IP. 338 | 339 | Insights is only supported by the GeoIP2 web service. The GeoLite2 web 340 | service does not support it. 341 | 342 | :param ip_address: IPv4 or IPv6 address as a string. If no address 343 | is provided, the address that the web service is called from will 344 | be used. 345 | 346 | :returns: :py:class:`geoip2.models.Insights` object 347 | 348 | """ 349 | return cast( 350 | "Insights", 351 | await self._response_for("insights", geoip2.models.Insights, ip_address), 352 | ) 353 | 354 | async def _session(self) -> aiohttp.ClientSession: 355 | if not hasattr(self, "_existing_session"): 356 | self._existing_session = aiohttp.ClientSession( 357 | auth=aiohttp.BasicAuth(self._account_id, self._license_key), 358 | headers={"Accept": "application/json", "User-Agent": _AIOHTTP_UA}, 359 | timeout=aiohttp.ClientTimeout(total=self._timeout), 360 | ) 361 | 362 | return self._existing_session 363 | 364 | async def _response_for( 365 | self, 366 | path: str, 367 | model_class: type[City | Country | Insights], 368 | ip_address: IPAddress, 369 | ) -> Country | City | Insights: 370 | uri = self._uri(path, ip_address) 371 | session = await self._session() 372 | async with await session.get(uri, proxy=self._proxy) as response: 373 | status = response.status 374 | content_type = response.content_type 375 | body = await response.text() 376 | if status != 200: 377 | raise self._exception_for_error(status, content_type, body, uri) 378 | decoded_body = self._handle_success(body, uri) 379 | return model_class(self._locales, **decoded_body) 380 | 381 | async def close(self) -> None: 382 | """Close underlying session. 383 | 384 | This will close the session and any associated connections. 385 | """ 386 | if hasattr(self, "_existing_session"): 387 | await self._existing_session.close() 388 | 389 | async def __aenter__(self) -> Self: 390 | return self 391 | 392 | async def __aexit__( 393 | self, 394 | exc_type: object, 395 | exc_value: object, 396 | traceback: object, 397 | ) -> None: 398 | await self.close() 399 | 400 | 401 | class Client(BaseClient): 402 | """A synchronous GeoIP2 client. 403 | 404 | It accepts the following required arguments: 405 | 406 | :param account_id: Your MaxMind account ID. 407 | :param license_key: Your MaxMind license key. 408 | 409 | Go to https://www.maxmind.com/en/my_license_key to see your MaxMind 410 | account ID and license key. 411 | 412 | The following keyword arguments are also accepted: 413 | 414 | :param host: The hostname to make a request against. This defaults to 415 | "geoip.maxmind.com". To use the GeoLite2 web service instead of the 416 | GeoIP2 web service, set this to "geolite.info". To use the Sandbox 417 | GeoIP2 web service instead of the production GeoIP2 web service, set 418 | this to "sandbox.maxmind.com". The sandbox allows you to experiment 419 | with the API without affecting your production data. 420 | :param locales: This is list of locale codes. This argument will be 421 | passed on to record classes to use when their name properties are 422 | called. The default value is ['en']. 423 | 424 | The order of the locales is significant. When a record class has 425 | multiple names (country, city, etc.), its name property will return 426 | the name in the first locale that has one. 427 | 428 | Note that the only locale which is always present in the GeoIP2 429 | data is "en". If you do not include this locale, the name property 430 | may end up returning None even when the record has an English name. 431 | 432 | Currently, the valid locale codes are: 433 | 434 | * de -- German 435 | * en -- English names may still include accented characters if that is 436 | the accepted spelling in English. In other words, English does not 437 | mean ASCII. 438 | * es -- Spanish 439 | * fr -- French 440 | * ja -- Japanese 441 | * pt-BR -- Brazilian Portuguese 442 | * ru -- Russian 443 | * zh-CN -- Simplified Chinese. 444 | :param timeout: The timeout in seconds to use when waiting on the request. 445 | This sets both the connect timeout and the read timeout. The default is 446 | 60. 447 | :param proxy: The URL of an HTTP proxy to use. It may optionally include 448 | a basic auth username and password, e.g., 449 | ``http://username:password@host:port``. 450 | 451 | 452 | """ 453 | 454 | _session: requests.Session 455 | _proxies: dict[str, str] | None 456 | 457 | def __init__( # noqa: PLR0913 458 | self, 459 | account_id: int, 460 | license_key: str, 461 | host: str = "geoip.maxmind.com", 462 | locales: Sequence[str] | None = None, 463 | timeout: float = 60, 464 | proxy: str | None = None, 465 | ) -> None: 466 | """Initialize Client.""" 467 | super().__init__(account_id, license_key, host, locales, timeout) 468 | self._session = requests.Session() 469 | self._session.auth = (self._account_id, self._license_key) 470 | self._session.headers["Accept"] = "application/json" 471 | self._session.headers["User-Agent"] = _REQUEST_UA 472 | if proxy is None: 473 | self._proxies = None 474 | else: 475 | self._proxies = {"https": proxy} 476 | 477 | def city(self, ip_address: IPAddress = "me") -> City: 478 | """Call City Plus endpoint with the specified IP. 479 | 480 | :param ip_address: IPv4 or IPv6 address as a string. If no 481 | address is provided, the address that the web service is 482 | called from will be used. 483 | 484 | :returns: :py:class:`geoip2.models.City` object 485 | 486 | """ 487 | return cast("City", self._response_for("city", geoip2.models.City, ip_address)) 488 | 489 | def country(self, ip_address: IPAddress = "me") -> Country: 490 | """Call the GeoIP2 Country endpoint with the specified IP. 491 | 492 | :param ip_address: IPv4 or IPv6 address as a string. If no address 493 | is provided, the address that the web service is called from will 494 | be used. 495 | 496 | :returns: :py:class:`geoip2.models.Country` object 497 | 498 | """ 499 | return cast( 500 | "Country", 501 | self._response_for("country", geoip2.models.Country, ip_address), 502 | ) 503 | 504 | def insights(self, ip_address: IPAddress = "me") -> Insights: 505 | """Call the Insights endpoint with the specified IP. 506 | 507 | Insights is only supported by the GeoIP2 web service. The GeoLite2 web 508 | service does not support it. 509 | 510 | :param ip_address: IPv4 or IPv6 address as a string. If no address 511 | is provided, the address that the web service is called from will 512 | be used. 513 | 514 | :returns: :py:class:`geoip2.models.Insights` object 515 | 516 | """ 517 | return cast( 518 | "Insights", 519 | self._response_for("insights", geoip2.models.Insights, ip_address), 520 | ) 521 | 522 | def _response_for( 523 | self, 524 | path: str, 525 | model_class: type[City | Country | Insights], 526 | ip_address: IPAddress, 527 | ) -> Country | City | Insights: 528 | uri = self._uri(path, ip_address) 529 | response = self._session.get(uri, proxies=self._proxies, timeout=self._timeout) 530 | status = response.status_code 531 | content_type = response.headers["Content-Type"] 532 | body = response.text 533 | if status != 200: 534 | raise self._exception_for_error(status, content_type, body, uri) 535 | decoded_body = self._handle_success(body, uri) 536 | return model_class(self._locales, **decoded_body) 537 | 538 | def close(self) -> None: 539 | """Close underlying session. 540 | 541 | This will close the session and any associated connections. 542 | """ 543 | self._session.close() 544 | 545 | def __enter__(self) -> Self: 546 | return self 547 | 548 | def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: 549 | self.close() 550 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | MaxMind GeoIP2 Python API 3 | ========================= 4 | 5 | Description 6 | ----------- 7 | 8 | This package provides an API for the GeoIP2 and GeoLite2 `web services 9 | `_ and `databases 10 | `_. 11 | 12 | Installation 13 | ------------ 14 | 15 | To install the ``geoip2`` module, type: 16 | 17 | .. code-block:: bash 18 | 19 | $ pip install geoip2 20 | 21 | If you are not able to install from PyPI, you may also use ``pip`` from the 22 | source directory: 23 | 24 | .. code-block:: bash 25 | 26 | $ python -m pip install . 27 | 28 | Database Reader Extension 29 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 30 | 31 | If you wish to use the C extension for the database reader, you must first 32 | install the `libmaxminddb C API `_. 33 | Please `see the instructions distributed with it 34 | `_. 35 | 36 | IP Geolocation Usage 37 | -------------------- 38 | 39 | IP geolocation is inherently imprecise. Locations are often near the center of 40 | the population. Any location provided by a GeoIP2 database or web service 41 | should not be used to identify a particular address or household. 42 | 43 | Web Service Usage 44 | ----------------- 45 | 46 | To use this API, you first construct either a ``geoip2.webservice.Client`` or 47 | ``geoip2.webservice.AsyncClient``, passing your MaxMind ``account_id`` and 48 | ``license_key`` to the constructor. To use the GeoLite2 web service instead of 49 | the GeoIP2 web service, set the optional ``host`` keyword argument to 50 | ``geolite.info``. To use the Sandbox GeoIP2 web service instead of the 51 | production GeoIP2 web service, set the optional ``host`` keyword argument to 52 | ``sandbox.maxmind.com``. 53 | 54 | After doing this, you may call the method corresponding to request type 55 | (e.g., ``city`` or ``country``), passing it the IP address you want to look up. 56 | 57 | If the request succeeds, the method call will return a model class for the 58 | endpoint you called. This model in turn contains multiple record classes, 59 | each of which represents part of the data returned by the web service. 60 | 61 | If the request fails, the client class throws an exception. 62 | 63 | Sync Web Service Example 64 | ------------------------ 65 | 66 | .. code-block:: pycon 67 | 68 | >>> import geoip2.webservice 69 | >>> 70 | >>> # This creates a Client object that can be reused across requests. 71 | >>> # Replace "42" with your account ID and "license_key" with your license 72 | >>> # key. Set the "host" keyword argument to "geolite.info" to use the 73 | >>> # GeoLite2 web service instead of the GeoIP2 web service. Set the 74 | >>> # "host" keyword argument to "sandbox.maxmind.com" to use the Sandbox 75 | >>> # GeoIP2 web service instead of the production GeoIP2 web service. 76 | >>> with geoip2.webservice.Client(42, 'license_key') as client: 77 | >>> 78 | >>> # Replace "city" with the method corresponding to the web service 79 | >>> # that you are using, i.e., "country", "city", or "insights". Please 80 | >>> # note that Insights is not supported by the GeoLite2 web service. 81 | >>> response = client.city('203.0.113.0') 82 | >>> 83 | >>> response.country.iso_code 84 | 'US' 85 | >>> response.country.name 86 | 'United States' 87 | >>> response.country.names['zh-CN'] 88 | u'美国' 89 | >>> 90 | >>> response.subdivisions.most_specific.name 91 | 'Minnesota' 92 | >>> response.subdivisions.most_specific.iso_code 93 | 'MN' 94 | >>> 95 | >>> response.city.name 96 | 'Minneapolis' 97 | >>> 98 | >>> response.postal.code 99 | '55455' 100 | >>> 101 | >>> response.location.latitude 102 | 44.9733 103 | >>> response.location.longitude 104 | -93.2323 105 | >>> 106 | >>> response.traits.network 107 | IPv4Network('203.0.113.0/32') 108 | 109 | Async Web Service Example 110 | ------------------------- 111 | 112 | .. code-block:: pycon 113 | 114 | >>> import asyncio 115 | >>> 116 | >>> import geoip2.webservice 117 | >>> 118 | >>> async def main(): 119 | >>> # This creates an AsyncClient object that can be reused across 120 | >>> # requests on the running event loop. If you are using multiple event 121 | >>> # loops, you must ensure the object is not used on another loop. 122 | >>> # 123 | >>> # Replace "42" with your account ID and "license_key" with your license 124 | >>> # key. Set the "host" keyword argument to "geolite.info" to use the 125 | >>> # GeoLite2 web service instead of the GeoIP2 web service. Set the 126 | >>> # "host" keyword argument to "sandbox.maxmind.com" to use the Sandbox 127 | >>> # GeoIP2 web service instead of the production GeoIP2 web service. 128 | >>> async with geoip2.webservice.AsyncClient(42, 'license_key') as client: 129 | >>> 130 | >>> # Replace "city" with the method corresponding to the web service 131 | >>> # that you are using, i.e., "country", "city", or "insights". Please 132 | >>> # note that Insights is not supported by the GeoLite2 web service. 133 | >>> response = await client.city('203.0.113.0') 134 | >>> 135 | >>> response.country.iso_code 136 | 'US' 137 | >>> response.country.name 138 | 'United States' 139 | >>> response.country.names['zh-CN'] 140 | u'美国' 141 | >>> 142 | >>> response.subdivisions.most_specific.name 143 | 'Minnesota' 144 | >>> response.subdivisions.most_specific.iso_code 145 | 'MN' 146 | >>> 147 | >>> response.city.name 148 | 'Minneapolis' 149 | >>> 150 | >>> response.postal.code 151 | '55455' 152 | >>> 153 | >>> response.location.latitude 154 | 44.9733 155 | >>> response.location.longitude 156 | -93.2323 157 | >>> 158 | >>> response.traits.network 159 | IPv4Network('203.0.113.0/32') 160 | >>> 161 | >>> asyncio.run(main()) 162 | 163 | Web Service Client Exceptions 164 | ----------------------------- 165 | 166 | For details on the possible errors returned by the web service itself, see 167 | https://dev.maxmind.com/geoip/docs/web-services?lang=en for the GeoIP2 web 168 | service docs. 169 | 170 | If the web service returns an explicit error document, this is thrown as a 171 | ``AddressNotFoundError``, ``AuthenticationError``, ``InvalidRequestError``, or 172 | ``OutOfQueriesError`` as appropriate. These all subclass ``GeoIP2Error``. 173 | 174 | If some other sort of error occurs, this is thrown as an ``HTTPError``. This 175 | is thrown when some sort of unanticipated error occurs, such as the web 176 | service returning a 500 or an invalid error document. If the web service 177 | returns any status code besides 200, 4xx, or 5xx, this also becomes an 178 | ``HTTPError``. 179 | 180 | Finally, if the web service returns a 200 but the body is invalid, the client 181 | throws a ``GeoIP2Error``. 182 | 183 | Database Usage 184 | -------------- 185 | 186 | To use the database API, you first construct a ``geoip2.database.Reader`` using 187 | the path to the file as the first argument. After doing this, you may call the 188 | method corresponding to database type (e.g., ``city`` or ``country``), passing it 189 | the IP address you want to look up. 190 | 191 | If the lookup succeeds, the method call will return a model class for the 192 | database method you called. This model in turn contains multiple record classes, 193 | each of which represents part of the data for the record. 194 | 195 | If the request fails, the reader class throws an exception. 196 | 197 | Database Example 198 | ---------------- 199 | 200 | City Database 201 | ^^^^^^^^^^^^^ 202 | 203 | .. code-block:: pycon 204 | 205 | >>> import geoip2.database 206 | >>> 207 | >>> # This creates a Reader object. You should use the same object 208 | >>> # across multiple requests as creation of it is expensive. 209 | >>> with geoip2.database.Reader('/path/to/GeoLite2-City.mmdb') as reader: 210 | >>> 211 | >>> # Replace "city" with the method corresponding to the database 212 | >>> # that you are using, e.g., "country". 213 | >>> response = reader.city('203.0.113.0') 214 | >>> 215 | >>> response.country.iso_code 216 | 'US' 217 | >>> response.country.name 218 | 'United States' 219 | >>> response.country.names['zh-CN'] 220 | u'美国' 221 | >>> 222 | >>> response.subdivisions.most_specific.name 223 | 'Minnesota' 224 | >>> response.subdivisions.most_specific.iso_code 225 | 'MN' 226 | >>> 227 | >>> response.city.name 228 | 'Minneapolis' 229 | >>> 230 | >>> response.postal.code 231 | '55455' 232 | >>> 233 | >>> response.location.latitude 234 | 44.9733 235 | >>> response.location.longitude 236 | -93.2323 237 | >>> 238 | >>> response.traits.network 239 | IPv4Network('203.0.113.0/24') 240 | 241 | Anonymous IP Database 242 | ^^^^^^^^^^^^^^^^^^^^^ 243 | 244 | .. code-block:: pycon 245 | 246 | >>> import geoip2.database 247 | >>> 248 | >>> # This creates a Reader object. You should use the same object 249 | >>> # across multiple requests as creation of it is expensive. 250 | >>> with geoip2.database.Reader('/path/to/GeoIP2-Anonymous-IP.mmdb') as reader: 251 | >>> 252 | >>> response = reader.anonymous_ip('203.0.113.0') 253 | >>> 254 | >>> response.is_anonymous 255 | True 256 | >>> response.is_anonymous_vpn 257 | False 258 | >>> response.is_hosting_provider 259 | False 260 | >>> response.is_public_proxy 261 | False 262 | >>> response.is_residential_proxy 263 | False 264 | >>> response.is_tor_exit_node 265 | True 266 | >>> response.ip_address 267 | '203.0.113.0' 268 | >>> response.network 269 | IPv4Network('203.0.113.0/24') 270 | 271 | Anonymous Plus Database 272 | ^^^^^^^^^^^^^^^^^^^^^^^ 273 | 274 | .. code-block:: pycon 275 | 276 | >>> import geoip2.database 277 | >>> 278 | >>> # This creates a Reader object. You should use the same object 279 | >>> # across multiple requests as creation of it is expensive. 280 | >>> with geoip2.database.Reader('/path/to/GeoIP-Anonymous-Plus.mmdb') as reader: 281 | >>> 282 | >>> response = reader.anonymous_plus('203.0.113.0') 283 | >>> 284 | >>> response.anonymizer_confidence 285 | 30 286 | >>> response.is_anonymous 287 | True 288 | >>> response.is_anonymous_vpn 289 | True 290 | >>> response.is_hosting_provider 291 | False 292 | >>> response.is_public_proxy 293 | False 294 | >>> response.is_residential_proxy 295 | False 296 | >>> response.is_tor_exit_node 297 | False 298 | >>> response.ip_address 299 | '203.0.113.0' 300 | >>> response.network 301 | IPv4Network('203.0.113.0/24') 302 | >>> response.network_last_seen 303 | datetime.date(2025, 4, 18) 304 | >>> response.provider_name 305 | FooBar VPNs 306 | 307 | ASN Database 308 | ^^^^^^^^^^^^ 309 | 310 | .. code-block:: pycon 311 | 312 | >>> import geoip2.database 313 | >>> 314 | >>> # This creates a Reader object. You should use the same object 315 | >>> # across multiple requests as creation of it is expensive. 316 | >>> with geoip2.database.Reader('/path/to/GeoLite2-ASN.mmdb') as reader: 317 | >>> response = reader.asn('203.0.113.0') 318 | >>> response.autonomous_system_number 319 | 1221 320 | >>> response.autonomous_system_organization 321 | 'Telstra Pty Ltd' 322 | >>> response.ip_address 323 | '203.0.113.0' 324 | >>> response.network 325 | IPv4Network('203.0.113.0/24') 326 | 327 | Connection-Type Database 328 | ^^^^^^^^^^^^^^^^^^^^^^^^ 329 | 330 | .. code-block:: pycon 331 | 332 | >>> import geoip2.database 333 | >>> 334 | >>> # This creates a Reader object. You should use the same object 335 | >>> # across multiple requests as creation of it is expensive. 336 | >>> with geoip2.database.Reader('/path/to/GeoIP2-Connection-Type.mmdb') as reader: 337 | >>> response = reader.connection_type('203.0.113.0') 338 | >>> response.connection_type 339 | 'Corporate' 340 | >>> response.ip_address 341 | '203.0.113.0' 342 | >>> response.network 343 | IPv4Network('203.0.113.0/24') 344 | 345 | 346 | Domain Database 347 | ^^^^^^^^^^^^^^^ 348 | 349 | .. code-block:: pycon 350 | 351 | >>> import geoip2.database 352 | >>> 353 | >>> # This creates a Reader object. You should use the same object 354 | >>> # across multiple requests as creation of it is expensive. 355 | >>> with geoip2.database.Reader('/path/to/GeoIP2-Domain.mmdb') as reader: 356 | >>> response = reader.domain('203.0.113.0') 357 | >>> response.domain 358 | 'umn.edu' 359 | >>> response.ip_address 360 | '203.0.113.0' 361 | 362 | Enterprise Database 363 | ^^^^^^^^^^^^^^^^^^^ 364 | 365 | .. code-block:: pycon 366 | 367 | >>> import geoip2.database 368 | >>> 369 | >>> # This creates a Reader object. You should use the same object 370 | >>> # across multiple requests as creation of it is expensive. 371 | >>> with geoip2.database.Reader('/path/to/GeoIP2-Enterprise.mmdb') as reader: 372 | >>> 373 | >>> # Use the .enterprise method to do a lookup in the Enterprise database 374 | >>> response = reader.enterprise('203.0.113.0') 375 | >>> 376 | >>> response.country.confidence 377 | 99 378 | >>> response.country.iso_code 379 | 'US' 380 | >>> response.country.name 381 | 'United States' 382 | >>> response.country.names['zh-CN'] 383 | u'美国' 384 | >>> 385 | >>> response.subdivisions.most_specific.name 386 | 'Minnesota' 387 | >>> response.subdivisions.most_specific.iso_code 388 | 'MN' 389 | >>> response.subdivisions.most_specific.confidence 390 | 77 391 | >>> 392 | >>> response.city.name 393 | 'Minneapolis' 394 | >>> response.country.confidence 395 | 11 396 | >>> 397 | >>> response.postal.code 398 | '55455' 399 | >>> 400 | >>> response.location.accuracy_radius 401 | 50 402 | >>> response.location.latitude 403 | 44.9733 404 | >>> response.location.longitude 405 | -93.2323 406 | >>> 407 | >>> response.traits.network 408 | IPv4Network('203.0.113.0/24') 409 | 410 | 411 | ISP Database 412 | ^^^^^^^^^^^^ 413 | 414 | .. code-block:: pycon 415 | 416 | >>> import geoip2.database 417 | >>> 418 | >>> # This creates a Reader object. You should use the same object 419 | >>> # across multiple requests as creation of it is expensive. 420 | >>> with geoip2.database.Reader('/path/to/GeoIP2-ISP.mmdb') as reader: 421 | >>> response = reader.isp('203.0.113.0') 422 | >>> response.autonomous_system_number 423 | 1221 424 | >>> response.autonomous_system_organization 425 | 'Telstra Pty Ltd' 426 | >>> response.isp 427 | 'Telstra Internet' 428 | >>> response.organization 429 | 'Telstra Internet' 430 | >>> response.ip_address 431 | '203.0.113.0' 432 | >>> response.network 433 | IPv4Network('203.0.113.0/24') 434 | 435 | Database Reader Exceptions 436 | -------------------------- 437 | 438 | If the database file does not exist or is not readable, the constructor will 439 | raise a ``FileNotFoundError`` or a ``PermissionError``. If the IP address passed 440 | to a method is invalid, a ``ValueError`` will be raised. If the file is invalid 441 | or there is a bug in the reader, a ``maxminddb.InvalidDatabaseError`` will be 442 | raised with a description of the problem. If an IP address is not in the 443 | database, a ``AddressNotFoundError`` will be raised. 444 | 445 | ``AddressNotFoundError`` references the largest subnet where no address would be 446 | found. This can be used to efficiently enumerate entire subnets: 447 | 448 | .. code-block:: python 449 | 450 | import geoip2.database 451 | import geoip2.errors 452 | import ipaddress 453 | 454 | # This creates a Reader object. You should use the same object 455 | # across multiple requests as creation of it is expensive. 456 | with geoip2.database.Reader('/path/to/GeoLite2-ASN.mmdb') as reader: 457 | network = ipaddress.ip_network("192.128.0.0/15") 458 | 459 | ip_address = network[0] 460 | while ip_address in network: 461 | try: 462 | response = reader.asn(ip_address) 463 | response_network = response.network 464 | except geoip2.errors.AddressNotFoundError as e: 465 | response = None 466 | response_network = e.network 467 | print(f"{response_network}: {response!r}") 468 | ip_address = response_network[-1] + 1 # move to next subnet 469 | 470 | Values to use for Database or Dictionary Keys 471 | --------------------------------------------- 472 | 473 | **We strongly discourage you from using a value from any ``names`` property as 474 | a key in a database or dictionaries.** 475 | 476 | These names may change between releases. Instead we recommend using one of the 477 | following: 478 | 479 | * ``geoip2.records.City`` - ``city.geoname_id`` 480 | * ``geoip2.records.Continent`` - ``continent.code`` or ``continent.geoname_id`` 481 | * ``geoip2.records.Country`` and ``geoip2.records.RepresentedCountry`` - ``country.iso_code`` or ``country.geoname_id`` 482 | * ``geoip2.records.subdivision`` - ``subdivision.iso_code`` or ``subdivision.geoname_id`` 483 | 484 | What data is returned? 485 | ---------------------- 486 | 487 | While many of the models contain the same basic records, the attributes which 488 | can be populated vary between web service endpoints or databases. In 489 | addition, while a model may offer a particular piece of data, MaxMind does not 490 | always have every piece of data for any given IP address. 491 | 492 | Because of these factors, it is possible for any request to return a record 493 | where some or all of the attributes are unpopulated. 494 | 495 | The only piece of data which is always returned is the ``ip_address`` 496 | attribute in the ``geoip2.records.Traits`` record. 497 | 498 | Integration with GeoNames 499 | ------------------------- 500 | 501 | `GeoNames `_ offers web services and downloadable 502 | databases with data on geographical features around the world, including 503 | populated places. They offer both free and paid premium data. Each feature is 504 | uniquely identified by a ``geoname_id``, which is an integer. 505 | 506 | Many of the records returned by the GeoIP web services and databases include a 507 | ``geoname_id`` field. This is the ID of a geographical feature (city, region, 508 | country, etc.) in the GeoNames database. 509 | 510 | Some of the data that MaxMind provides is also sourced from GeoNames. We 511 | source things like place names, ISO codes, and other similar data from the 512 | GeoNames premium data set. 513 | 514 | Reporting Data Problems 515 | ----------------------- 516 | 517 | If the problem you find is that an IP address is incorrectly mapped, please 518 | `submit your correction to MaxMind `_. 519 | 520 | If you find some other sort of mistake, like an incorrect spelling, please 521 | check the `GeoNames site `_ first. Once you've 522 | searched for a place and found it on the GeoNames map view, there are a 523 | number of links you can use to correct data ("move", "edit", "alternate 524 | names", etc.). Once the correction is part of the GeoNames data set, it 525 | will be automatically incorporated into future MaxMind releases. 526 | 527 | If you are a paying MaxMind customer and you're not sure where to submit a 528 | correction, please `contact MaxMind support 529 | `_ for help. 530 | 531 | Versioning 532 | ---------- 533 | 534 | The GeoIP2 Python API uses `Semantic Versioning `_. 535 | 536 | Support 537 | ------- 538 | 539 | Please report all issues with this code using the `GitHub issue tracker 540 | `_ 541 | 542 | If you are having an issue with a MaxMind service that is not specific to the 543 | client API, please contact `MaxMind support 544 | `_ for assistance. 545 | -------------------------------------------------------------------------------- /tests/models_test.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import sys 3 | import unittest 4 | from typing import ClassVar 5 | 6 | sys.path.append("..") 7 | 8 | import geoip2.models 9 | 10 | 11 | class TestModels(unittest.TestCase): 12 | def setUp(self) -> None: 13 | self.maxDiff = 20_000 14 | 15 | def test_insights_full(self) -> None: # noqa: PLR0915 16 | raw = { 17 | "anonymizer": { 18 | "confidence": 99, 19 | "is_anonymous": True, 20 | "is_anonymous_vpn": True, 21 | "is_hosting_provider": True, 22 | "is_public_proxy": True, 23 | "is_residential_proxy": True, 24 | "is_tor_exit_node": True, 25 | "network_last_seen": "2025-04-14", 26 | "provider_name": "FooBar VPN", 27 | }, 28 | "city": { 29 | "confidence": 76, 30 | "geoname_id": 9876, 31 | "names": {"en": "Minneapolis"}, 32 | }, 33 | "continent": { 34 | "code": "NA", 35 | "geoname_id": 42, 36 | "names": {"en": "North America"}, 37 | }, 38 | "country": { 39 | "confidence": 99, 40 | "geoname_id": 1, 41 | "iso_code": "US", 42 | "names": {"en": "United States of America"}, 43 | }, 44 | "location": { 45 | "average_income": 24626, 46 | "accuracy_radius": 1500, 47 | "latitude": 44.98, 48 | "longitude": 93.2636, 49 | "metro_code": 765, 50 | "population_density": 1341, 51 | "time_zone": "America/Chicago", 52 | }, 53 | "postal": { 54 | "code": "55401", 55 | "confidence": 33, 56 | }, 57 | "subdivisions": [ 58 | { 59 | "confidence": 88, 60 | "geoname_id": 574635, 61 | "iso_code": "MN", 62 | "names": {"en": "Minnesota"}, 63 | }, 64 | { 65 | "geoname_id": 123, 66 | "iso_code": "HP", 67 | "names": {"en": "Hennepin"}, 68 | }, 69 | ], 70 | "registered_country": { 71 | "geoname_id": 2, 72 | "iso_code": "CA", 73 | "names": {"en": "Canada"}, 74 | }, 75 | "represented_country": { 76 | "geoname_id": 3, 77 | "is_in_european_union": True, 78 | "iso_code": "GB", 79 | "names": {"en": "United Kingdom"}, 80 | "type": "military", 81 | }, 82 | "traits": { 83 | "autonomous_system_number": 1234, 84 | "autonomous_system_organization": "AS Organization", 85 | "connection_type": "Cable/DSL", 86 | "domain": "example.com", 87 | "ip_address": "1.2.3.4", 88 | "ip_risk_snapshot": 12.5, 89 | "is_anonymous": True, 90 | "is_anonymous_proxy": True, 91 | "is_anonymous_vpn": True, 92 | "is_anycast": True, 93 | "is_hosting_provider": True, 94 | "is_public_proxy": True, 95 | "is_residential_proxy": True, 96 | "is_satellite_provider": True, 97 | "is_tor_exit_node": True, 98 | "isp": "Comcast", 99 | "organization": "Blorg", 100 | "static_ip_score": 1.3, 101 | "user_count": 2, 102 | "user_type": "college", 103 | }, 104 | } 105 | 106 | model = geoip2.models.Insights(["en"], **raw) # type: ignore[arg-type] 107 | self.assertEqual( 108 | type(model), 109 | geoip2.models.Insights, 110 | "geoip2.models.Insights object", 111 | ) 112 | self.assertEqual( 113 | type(model.city), 114 | geoip2.records.City, 115 | "geoip2.records.City object", 116 | ) 117 | self.assertEqual( 118 | type(model.continent), 119 | geoip2.records.Continent, 120 | "geoip2.records.Continent object", 121 | ) 122 | self.assertEqual( 123 | type(model.country), 124 | geoip2.records.Country, 125 | "geoip2.records.Country object", 126 | ) 127 | self.assertEqual( 128 | type(model.registered_country), 129 | geoip2.records.Country, 130 | "geoip2.records.Country object", 131 | ) 132 | self.assertEqual( 133 | type(model.represented_country), 134 | geoip2.records.RepresentedCountry, 135 | "geoip2.records.RepresentedCountry object", 136 | ) 137 | self.assertEqual( 138 | type(model.location), 139 | geoip2.records.Location, 140 | "geoip2.records.Location object", 141 | ) 142 | self.assertEqual( 143 | type(model.subdivisions[0]), 144 | geoip2.records.Subdivision, 145 | "geoip2.records.Subdivision object", 146 | ) 147 | self.assertEqual( 148 | type(model.traits), 149 | geoip2.records.Traits, 150 | "geoip2.records.Traits object", 151 | ) 152 | self.assertEqual(model.to_dict(), raw, "to_dict() method matches raw input") 153 | self.assertEqual( 154 | model.subdivisions[0].iso_code, 155 | "MN", 156 | "div 1 has correct iso_code", 157 | ) 158 | self.assertEqual( 159 | model.subdivisions[0].confidence, 160 | 88, 161 | "div 1 has correct confidence", 162 | ) 163 | self.assertEqual( 164 | model.subdivisions[0].geoname_id, 165 | 574635, 166 | "div 1 has correct geoname_id", 167 | ) 168 | self.assertEqual( 169 | model.subdivisions[0].names, 170 | {"en": "Minnesota"}, 171 | "div 1 names are correct", 172 | ) 173 | self.assertEqual( 174 | model.subdivisions[1].name, 175 | "Hennepin", 176 | "div 2 has correct name", 177 | ) 178 | self.assertEqual( 179 | model.subdivisions.most_specific.iso_code, 180 | "HP", 181 | "subdivisions.most_specific returns HP", 182 | ) 183 | self.assertEqual( 184 | model.represented_country.name, 185 | "United Kingdom", 186 | "represented_country name is correct", 187 | ) 188 | self.assertEqual( 189 | model.represented_country.type, 190 | "military", 191 | "represented_country type is correct", 192 | ) 193 | self.assertEqual(model.location.average_income, 24626, "correct average_income") 194 | self.assertEqual(model.location.latitude, 44.98, "correct latitude") 195 | self.assertEqual(model.location.longitude, 93.2636, "correct longitude") 196 | self.assertEqual(model.location.metro_code, 765, "correct metro_code") 197 | self.assertEqual( 198 | model.location.population_density, 199 | 1341, 200 | "correct population_density", 201 | ) 202 | 203 | self.assertRegex( 204 | str(model), 205 | r"^geoip2.models.Insights\(\[.*en.*\]\, .*geoname_id.*\)", 206 | "Insights str representation looks reasonable", 207 | ) 208 | 209 | self.assertEqual( 210 | model, 211 | eval(repr(model)), # noqa: S307 212 | "Insights repr can be eval'd", 213 | ) 214 | 215 | self.assertRegex( 216 | str(model.location), 217 | r"^geoip2.records.Location\(.*longitude=.*\)", 218 | "Location str representation is reasonable", 219 | ) 220 | 221 | self.assertEqual( 222 | model.location, 223 | eval(repr(model.location)), # noqa: S307 224 | "Location repr can be eval'd", 225 | ) 226 | 227 | self.assertIs(model.country.is_in_european_union, False) 228 | self.assertIs( 229 | model.registered_country.is_in_european_union, 230 | False, 231 | ) 232 | self.assertIs( 233 | model.represented_country.is_in_european_union, 234 | True, 235 | ) 236 | 237 | self.assertIs(model.traits.is_anonymous, True) 238 | self.assertIs(model.traits.is_anonymous_proxy, True) 239 | self.assertIs(model.traits.is_anonymous_vpn, True) 240 | self.assertIs(model.traits.is_anycast, True) 241 | self.assertIs(model.traits.is_hosting_provider, True) 242 | self.assertIs(model.traits.is_public_proxy, True) 243 | self.assertIs(model.traits.is_residential_proxy, True) 244 | self.assertIs(model.traits.is_satellite_provider, True) 245 | self.assertIs(model.traits.is_tor_exit_node, True) 246 | self.assertEqual(model.traits.user_count, 2) 247 | self.assertEqual(model.traits.static_ip_score, 1.3) 248 | self.assertEqual(model.traits.ip_risk_snapshot, 12.5) 249 | 250 | # Test anonymizer object 251 | self.assertEqual( 252 | type(model.anonymizer), 253 | geoip2.records.Anonymizer, 254 | "geoip2.records.Anonymizer object", 255 | ) 256 | self.assertEqual(model.anonymizer.confidence, 99) 257 | self.assertIs(model.anonymizer.is_anonymous, True) 258 | self.assertIs(model.anonymizer.is_anonymous_vpn, True) 259 | self.assertIs(model.anonymizer.is_hosting_provider, True) 260 | self.assertIs(model.anonymizer.is_public_proxy, True) 261 | self.assertIs(model.anonymizer.is_residential_proxy, True) 262 | self.assertIs(model.anonymizer.is_tor_exit_node, True) 263 | self.assertEqual( 264 | model.anonymizer.network_last_seen, 265 | __import__("datetime").date(2025, 4, 14), 266 | ) 267 | self.assertEqual(model.anonymizer.provider_name, "FooBar VPN") 268 | 269 | def test_insights_min(self) -> None: 270 | model = geoip2.models.Insights(["en"], traits={"ip_address": "5.6.7.8"}) 271 | self.assertEqual( 272 | type(model), 273 | geoip2.models.Insights, 274 | "geoip2.models.Insights object", 275 | ) 276 | self.assertEqual( 277 | type(model.city), 278 | geoip2.records.City, 279 | "geoip2.records.City object", 280 | ) 281 | self.assertEqual( 282 | type(model.continent), 283 | geoip2.records.Continent, 284 | "geoip2.records.Continent object", 285 | ) 286 | self.assertEqual( 287 | type(model.country), 288 | geoip2.records.Country, 289 | "geoip2.records.Country object", 290 | ) 291 | self.assertEqual( 292 | type(model.registered_country), 293 | geoip2.records.Country, 294 | "geoip2.records.Country object", 295 | ) 296 | self.assertEqual( 297 | type(model.location), 298 | geoip2.records.Location, 299 | "geoip2.records.Location object", 300 | ) 301 | self.assertEqual( 302 | type(model.traits), 303 | geoip2.records.Traits, 304 | "geoip2.records.Traits object", 305 | ) 306 | self.assertEqual( 307 | type(model.anonymizer), 308 | geoip2.records.Anonymizer, 309 | "geoip2.records.Anonymizer object", 310 | ) 311 | self.assertEqual( 312 | type(model.subdivisions.most_specific), 313 | geoip2.records.Subdivision, 314 | "geoip2.records.Subdivision object returned even when none are available.", 315 | ) 316 | self.assertEqual( 317 | model.subdivisions.most_specific.names, 318 | {}, 319 | "Empty names hash returned", 320 | ) 321 | # Test that anonymizer fields default correctly 322 | self.assertIsNone(model.anonymizer.confidence) 323 | self.assertIsNone(model.anonymizer.network_last_seen) 324 | self.assertIsNone(model.anonymizer.provider_name) 325 | self.assertFalse(model.anonymizer.is_anonymous) 326 | self.assertFalse(model.anonymizer.is_anonymous_vpn) 327 | 328 | def test_city_full(self) -> None: 329 | raw = { 330 | "continent": { 331 | "code": "NA", 332 | "geoname_id": 42, 333 | "names": {"en": "North America"}, 334 | }, 335 | "country": { 336 | "geoname_id": 1, 337 | "iso_code": "US", 338 | "names": {"en": "United States of America"}, 339 | }, 340 | "registered_country": { 341 | "geoname_id": 2, 342 | "iso_code": "CA", 343 | "names": {"en": "Canada"}, 344 | }, 345 | "traits": { 346 | "ip_address": "1.2.3.4", 347 | "is_satellite_provider": True, 348 | }, 349 | } 350 | model = geoip2.models.City(["en"], **raw) # type: ignore[arg-type] 351 | self.assertEqual(type(model), geoip2.models.City, "geoip2.models.City object") 352 | self.assertEqual( 353 | type(model.city), 354 | geoip2.records.City, 355 | "geoip2.records.City object", 356 | ) 357 | self.assertEqual( 358 | type(model.continent), 359 | geoip2.records.Continent, 360 | "geoip2.records.Continent object", 361 | ) 362 | self.assertEqual( 363 | type(model.country), 364 | geoip2.records.Country, 365 | "geoip2.records.Country object", 366 | ) 367 | self.assertEqual( 368 | type(model.registered_country), 369 | geoip2.records.Country, 370 | "geoip2.records.Country object", 371 | ) 372 | self.assertEqual( 373 | type(model.location), 374 | geoip2.records.Location, 375 | "geoip2.records.Location object", 376 | ) 377 | self.assertEqual( 378 | type(model.traits), 379 | geoip2.records.Traits, 380 | "geoip2.records.Traits object", 381 | ) 382 | self.assertEqual( 383 | model.to_dict(), 384 | raw, 385 | "to_dict method output matches raw input", 386 | ) 387 | self.assertEqual(model.continent.geoname_id, 42, "continent geoname_id is 42") 388 | self.assertEqual(model.continent.code, "NA", "continent code is NA") 389 | self.assertEqual( 390 | model.continent.names, 391 | {"en": "North America"}, 392 | "continent names is correct", 393 | ) 394 | self.assertEqual( 395 | model.continent.name, 396 | "North America", 397 | "continent name is correct", 398 | ) 399 | self.assertEqual(model.country.geoname_id, 1, "country geoname_id is 1") 400 | self.assertEqual(model.country.iso_code, "US", "country iso_code is US") 401 | self.assertEqual( 402 | model.country.names, 403 | {"en": "United States of America"}, 404 | "country names is correct", 405 | ) 406 | self.assertEqual( 407 | model.country.name, 408 | "United States of America", 409 | "country name is correct", 410 | ) 411 | self.assertEqual(model.country.confidence, None, "country confidence is None") 412 | self.assertEqual( 413 | model.registered_country.iso_code, 414 | "CA", 415 | "registered_country iso_code is CA", 416 | ) 417 | self.assertEqual( 418 | model.registered_country.names, 419 | {"en": "Canada"}, 420 | "registered_country names is correct", 421 | ) 422 | self.assertEqual( 423 | model.registered_country.name, 424 | "Canada", 425 | "registered_country name is correct", 426 | ) 427 | self.assertEqual( 428 | model.traits.is_anonymous_proxy, 429 | False, 430 | "traits is_anonymous_proxy returns False by default", 431 | ) 432 | self.assertEqual( 433 | model.traits.is_anycast, 434 | False, 435 | "traits is_anycast returns False by default", 436 | ) 437 | self.assertEqual( 438 | model.traits.is_satellite_provider, 439 | True, 440 | "traits is_setellite_provider is True", 441 | ) 442 | self.assertEqual(model.to_dict(), raw, "to_dict method matches raw input") 443 | 444 | self.assertRegex( 445 | str(model), 446 | r"^geoip2.models.City\(\[.*en.*\], .*geoname_id.*\)", 447 | ) 448 | 449 | self.assertFalse(model is True, "__eq__ does not blow up on weird input") 450 | 451 | def test_unknown_keys(self) -> None: 452 | model = geoip2.models.City( 453 | ["en"], 454 | city={"invalid": 0}, 455 | continent={ 456 | "invalid": 0, 457 | "names": {"invalid": 0}, 458 | }, 459 | country={ 460 | "invalid": 0, 461 | "names": {"invalid": 0}, 462 | }, 463 | location={"invalid": 0}, 464 | postal={"invalid": 0}, 465 | subdivisions=[ 466 | { 467 | "invalid": 0, 468 | "names": { 469 | "invalid": 0, 470 | }, 471 | }, 472 | ], 473 | registered_country={ 474 | "invalid": 0, 475 | "names": { 476 | "invalid": 0, 477 | }, 478 | }, 479 | represented_country={ 480 | "invalid": 0, 481 | "names": { 482 | "invalid": 0, 483 | }, 484 | }, 485 | traits={"ip_address": "1.2.3.4", "invalid": "blah"}, 486 | unk_base={"blah": 1}, 487 | ) 488 | with self.assertRaises(AttributeError): 489 | model.unk_base # type: ignore[attr-defined] # noqa: B018 490 | with self.assertRaises(AttributeError): 491 | model.traits.invalid # type: ignore[attr-defined] # noqa: B018 492 | self.assertEqual( 493 | model.traits.ip_address, 494 | ipaddress.ip_address("1.2.3.4"), 495 | "correct ip", 496 | ) 497 | 498 | 499 | class TestNames(unittest.TestCase): 500 | raw: ClassVar[dict] = { 501 | "continent": { 502 | "code": "NA", 503 | "geoname_id": 42, 504 | "names": { 505 | "de": "Nordamerika", 506 | "en": "North America", 507 | "es": "América del Norte", 508 | "ja": "北アメリカ", 509 | "pt-BR": "América do Norte", 510 | "ru": "Северная Америка", 511 | "zh-CN": "北美洲", 512 | }, 513 | }, 514 | "country": { 515 | "geoname_id": 1, 516 | "iso_code": "US", 517 | "names": { 518 | "en": "United States of America", 519 | "fr": "États-Unis", 520 | "zh-CN": "美国", 521 | }, 522 | }, 523 | "traits": { 524 | "ip_address": "1.2.3.4", 525 | }, 526 | } 527 | 528 | def test_names(self) -> None: 529 | model = geoip2.models.Country(["sq", "ar"], **self.raw) 530 | self.assertEqual( 531 | model.continent.names, 532 | self.raw["continent"]["names"], 533 | "Correct names dict for continent", 534 | ) 535 | self.assertEqual( 536 | model.country.names, 537 | self.raw["country"]["names"], 538 | "Correct names dict for country", 539 | ) 540 | 541 | def test_three_locales(self) -> None: 542 | model = geoip2.models.Country(locales=["fr", "zh-CN", "en"], **self.raw) 543 | self.assertEqual( 544 | model.continent.name, 545 | "北美洲", 546 | "continent name is in Chinese (no French available)", 547 | ) 548 | self.assertEqual(model.country.name, "États-Unis", "country name is in French") 549 | 550 | def test_two_locales(self) -> None: 551 | model = geoip2.models.Country(locales=["ak", "fr"], **self.raw) 552 | self.assertEqual( 553 | model.continent.name, 554 | None, 555 | "continent name is undef (no Akan or French available)", 556 | ) 557 | self.assertEqual(model.country.name, "États-Unis", "country name is in French") 558 | 559 | def test_unknown_locale(self) -> None: 560 | model = geoip2.models.Country(locales=["aa"], **self.raw) 561 | self.assertEqual( 562 | model.continent.name, 563 | None, 564 | "continent name is undef (no Afar available)", 565 | ) 566 | self.assertEqual( 567 | model.country.name, 568 | None, 569 | "country name is in None (no Afar available)", 570 | ) 571 | 572 | def test_german(self) -> None: 573 | model = geoip2.models.Country(locales=["de"], **self.raw) 574 | self.assertEqual( 575 | model.continent.name, 576 | "Nordamerika", 577 | "Correct german name for continent", 578 | ) 579 | 580 | 581 | if __name__ == "__main__": 582 | unittest.main() 583 | --------------------------------------------------------------------------------