diff --git a/.clang-format b/.clang-format
new file mode 100644
index 0000000..a72e2df
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1,5 @@
+BasedOnStyle: LLVM
+IndentWidth: 4
+BinPackArguments: false
+BinPackParameters: false
+IndentCaseLabels: true
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..b51dce3
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+version: 2
+updates:
+ - package-ecosystem: uv
+ directory: "/"
+ schedule:
+ interval: daily
+ open-pull-requests-limit: 10
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: daily
diff --git a/.github/workflows/address-sanitizer.yml b/.github/workflows/address-sanitizer.yml
new file mode 100644
index 0000000..b57d98c
--- /dev/null
+++ b/.github/workflows/address-sanitizer.yml
@@ -0,0 +1,40 @@
+name: Run Address Sanitizer
+on:
+ push:
+ pull_request:
+ schedule:
+ - cron: '13 15 * * SUN'
+
+permissions: {}
+
+jobs:
+ build:
+ name: Address Sanitizer
+ runs-on: ubuntu-latest
+ env:
+ ASAN_OPTIONS: strict_string_checks=1:detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1:detect_invalid_pointer_pairs=2:detect_leaks=0
+ CC: clang
+ CFLAGS: -fsanitize=address -Wall -Wextra -Wpedantic -Wformat=2 -Walloca -Wvla -Wimplicit-fallthrough -Wcast-qual -Wconversion -Wshadow -Wundef -Wstrict-prototypes -Wswitch-enum -fstack-protector -D_FORTIFY_SOURCE=2 -Werror
+ LDFLAGS: -fsanitize=address
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+ persist-credentials: false
+
+ - name: Install the latest version of uv
+ uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # 6.1.0
+
+ - name: Install packages
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install libasan6
+
+ - name: Test
+ run: uv run pytest
+ env:
+ CFLAGS: "-Werror -Wall -Wextra"
+ LD_PRELOAD: libasan.so.6
+ MAXMINDDB_REQUIRE_EXTENSION: 1
+ MM_FORCE_EXT_TESTS: 1
diff --git a/.github/workflows/clang-analyzer.yml b/.github/workflows/clang-analyzer.yml
new file mode 100644
index 0000000..e775bae
--- /dev/null
+++ b/.github/workflows/clang-analyzer.yml
@@ -0,0 +1,34 @@
+name: Clang Static Analysis
+
+on:
+ push:
+ pull_request:
+ schedule:
+ - cron: '3 15 * * SUN'
+
+permissions: {}
+
+jobs:
+ clang-analyzer:
+ name: Clang Static Analysis
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+ persist-credentials: false
+
+ - name: Install clang-tools
+ run: sudo apt install clang-tools
+
+ - name: Install the latest version of uv
+ uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # 6.1.0
+
+ - name: Build and run analyzer
+ # We exclude extension/libmaxminddb/ as libmaxminddb has its own workflow
+ # for this and we are not able to correct any issues with that code here.
+ run: scan-build --exclude extension/libmaxminddb/ --status-bugs uv build
+ env:
+ CFLAGS: "-Werror -Wall -Wextra"
+ MAXMINDDB_REQUIRE_EXTENSION: 1
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..1dad083
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,44 @@
+name: "Code scanning - action"
+
+on:
+ push:
+ branches-ignore:
+ - 'dependabot/**'
+ pull_request:
+ schedule:
+ - cron: '0 18 * * 0'
+
+permissions: {}
+
+jobs:
+ CodeQL-Build:
+
+ runs-on: ubuntu-latest
+
+ permissions:
+ security-events: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+ submodules: true
+ persist-credentials: false
+ - run: git checkout HEAD^2
+ if: ${{ github.event_name == 'pull_request' }}
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: python, cpp
+
+ - name: Install the latest version of uv
+ uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # 6.1.0
+
+ - run: uv build
+ env:
+ MAXMINDDB_REQUIRE_EXTENSION: 1
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..8af7e38
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,81 @@
+name: Build and upload to PyPI
+
+on:
+ workflow_dispatch:
+ pull_request:
+ push:
+ branches:
+ - main
+ release:
+ types:
+ - published
+
+permissions: {}
+
+jobs:
+ build_wheels:
+ name: Build wheels on ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [macos-13, macos-14, ubuntu-24.04-arm, ubuntu-latest, windows-latest]
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+ persist-credentials: false
+
+ - name: Set up QEMU
+ if: runner.os == 'Linux' && runner.arch == 'X64'
+ uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # 3.6.0
+ with:
+ platforms: all
+
+ - name: Build wheels
+ uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # 2.23.3
+ env:
+ CIBW_BUILD_VERBOSITY: 1
+ MAXMINDDB_REQUIRE_EXTENSION: 1
+ # configure cibuildwheel on Linux to build native archs ('auto'),
+ # and to split the remaining architectures between the x86_64 and
+ # ARM runners
+ CIBW_ARCHS_LINUX: ${{ runner.arch == 'X64' && 'auto ppc64le s390x' || 'auto' }}
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: maxminddb-whl-${{ matrix.os }}
+ path: ./wheelhouse/*.whl
+
+ build_sdist:
+ name: Build source distribution
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+ persist-credentials: false
+
+ - name: Build sdist
+ run: pipx run build --sdist
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: maxminddb-sdist
+ path: dist/*.tar.gz
+
+ upload_pypi:
+ needs: [build_wheels, build_sdist]
+ runs-on: ubuntu-latest
+ environment: release
+ permissions:
+ id-token: write
+ if: github.event_name == 'release' && github.event.action == 'published'
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: maxminddb-*
+ path: dist
+ merge-multiple: true
+
+ - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # 1.12.4
diff --git a/.github/workflows/test-libmaxminddb.yml b/.github/workflows/test-libmaxminddb.yml
new file mode 100644
index 0000000..d557116
--- /dev/null
+++ b/.github/workflows/test-libmaxminddb.yml
@@ -0,0 +1,71 @@
+name: Python tests (system libmaxminddb)
+
+on:
+ push:
+ pull_request:
+ schedule:
+ - cron: '3 15 * * SUN'
+
+permissions: {}
+
+jobs:
+ build:
+
+ strategy:
+ matrix:
+ env: [3.9, "3.10", 3.11, 3.12, 3.13]
+ # We don't test on Windows currently due to issues
+ # build libmaxminddb there.
+ os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest]
+
+ name: Python ${{ matrix.env }} on ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ env:
+ MAXMINDDB_REQUIRE_EXTENSION: 1
+ MAXMINDDB_USE_SYSTEM_LIBMAXMINDDB: 1
+ MM_FORCE_EXT_TESTS: 1
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+ persist-credentials: false
+
+ - name: Install the latest version of uv
+ uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # 6.1.0
+
+ - name: Install tox
+ run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv --with tox-gh
+
+ - name: Install Python
+ if: matrix.env != '3.13'
+ run: uv python install --python-preference only-managed ${{ matrix.env }}
+
+ - name: Install libmaxminddb
+ run: sudo apt install libmaxminddb-dev
+ if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm'
+
+ - name: Install libmaxminddb
+ run: brew install libmaxminddb
+ if: matrix.os == 'macos-latest'
+
+ - name: "Work around macos arm64 homebrew directory changes"
+ if: runner.os == 'macOS' && runner.arch == 'ARM64'
+ run: |
+ echo "CFLAGS=-I/opt/homebrew/include" >> "$GITHUB_ENV"
+ echo "LDFLAGS=-L/opt/homebrew/lib" >> "$GITHUB_ENV"
+
+ - name: Build with Werror and Wall
+ run: uv build
+ env:
+ CFLAGS: "${{ env.CFLAGS }} -Werror -Wall -Wextra"
+
+ - name: Setup test suite
+ run: tox run -vv --notest --skip-missing-interpreters false
+ env:
+ TOX_GH_MAJOR_MINOR: ${{ matrix.env }}
+
+ - name: Run test suite
+ run: tox run --skip-pkg-install
+ env:
+ TOX_GH_MAJOR_MINOR: ${{ matrix.env }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..cfd7a49
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,41 @@
+name: Python tests
+
+on:
+ push:
+ pull_request:
+ schedule:
+ - cron: '3 15 * * SUN'
+
+permissions: {}
+
+jobs:
+ test:
+ name: test with ${{ matrix.env }} on ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ env: [3.9, "3.10", 3.11, 3.12, 3.13]
+ os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+ persist-credentials: false
+ - name: Install the latest version of uv
+ uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # 6.1.0
+ - name: Install tox
+ run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv --with tox-gh
+ - name: Install Python
+ if: matrix.env != '3.13'
+ run: uv python install --python-preference only-managed ${{ matrix.env }}
+ - name: Setup test suite
+ run: tox run -vv --notest --skip-missing-interpreters false
+ env:
+ TOX_GH_MAJOR_MINOR: ${{ matrix.env }}
+ - name: Run test suite
+ run: tox run --skip-pkg-install
+ env:
+ MAXMINDDB_REQUIRE_EXTENSION: 1
+ MM_FORCE_EXT_TESTS: 1
+ TOX_GH_MAJOR_MINOR: ${{ matrix.env }}
diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml
new file mode 100644
index 0000000..ccb75f4
--- /dev/null
+++ b/.github/workflows/zizmor.yml
@@ -0,0 +1,34 @@
+name: GitHub Actions Security Analysis with zizmor
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["**"]
+
+permissions: {}
+
+jobs:
+ zizmor:
+ name: zizmor latest via PyPI
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ # required for workflows in private repositories
+ contents: read
+ actions: read
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Install the latest version of uv
+ uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # 6.1.0
+ with:
+ enable-cache: false
+
+ - name: Run zizmor
+ run: uvx zizmor@1.7.0 --format plain .
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 24abebc..d63bdb7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,19 +1,23 @@
*.egg
+*.iml
*.pyc
*.so
*.sw?
*~
+.clangd
.coverage
.eggs
.idea
+.pyre
+.tox
build
+compile_flags.txt
core
dist
docs/_build
+docs/doctrees
docs/html
env/
MANIFEST
maxminddb.egg-info/
-pylint.txt
valgrind-python.supp
-violations.pyflakes.txt
diff --git a/.gitmodules b/.gitmodules
index 9cf24ec..c2a1c59 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,6 @@
[submodule "tests/data"]
path = tests/data
- url = git://github.com/maxmind/MaxMind-DB.git
+ url = https://github.com/maxmind/MaxMind-DB
+[submodule "extension/libmaxminddb"]
+ path = extension/libmaxminddb
+ url = git@github.com:maxmind/libmaxminddb.git
diff --git a/.pylintrc b/.pylintrc
index 19353df..cfe358c 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -1,6 +1,4 @@
-[MESSAGES CONTROL]
-disable=R0201,W0105
-
[BASIC]
no-docstring-rgx=_.*
+extension-pkg-allow-list=maxminddb.extension
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..ade4a33
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,9 @@
+version: 2
+
+build:
+ os: ubuntu-24.04
+ tools:
+ python: "3.13"
+
+sphinx:
+ configuration: docs/conf.py
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 3152957..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,50 +0,0 @@
-language: python
-
-python:
- - 2.6
- - 2.7
- - 3.3
- - 3.4
- - pypy
-
-before_install:
- - git submodule update --init --recursive
- - git clone --recursive git://github.com/maxmind/libmaxminddb
- - cd libmaxminddb
- - ./bootstrap
- - ./configure
- - make
- - sudo make install
- - sudo ldconfig
- - cd ..
- - pip install pylint coveralls
- - if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install unittest2; fi
-
-script:
- - if [[ $TRAVIS_PYTHON_VERSION != 'pypy' ]]; then export MM_FORCE_EXT_TESTS=1; fi
- - CFLAGS="-Werror -Wall -Wextra" python setup.py test
- - if [[ $TRAVIS_PYTHON_VERSION == '2.7' ]]; then pylint --rcfile .pylintrc maxminddb/*.py; fi
-
-after_success:
- - coveralls
-
-notifications:
- email:
- recipients:
- - dev-ci@maxmind.com
- on_success: change
- on_failure: always
-
-env:
- global:
- - secure: "pVcpV/al5Q607TbRzl/sbkdsx5hUjxehaJm6t5tgWrFn45icwdZrPw3JWcpt0R57NhPvXHxcJdm4WBtcGElWoDtR52QOW3yYh+gRw23y1MJg+5qHIbh5R1sOC/fLJ9TzQzvvRH5QQ5bKIe1hRQW9Cpqm7nX5Zhq6SqnAzcG1emE="
-
-addons:
- coverity_scan:
- project:
- name: "maxmind/MaxMind-DB-Reader-python"
- description: "Build submitted via Travis CI"
- notification_email: dev-ci@maxmind.com
- build_command_prepend: "python setup.py clean"
- build_command: "python setup.py build"
- branch_pattern: .*coverity.*
diff --git a/.uncrustify.cfg b/.uncrustify.cfg
deleted file mode 100644
index 46b4428..0000000
--- a/.uncrustify.cfg
+++ /dev/null
@@ -1,78 +0,0 @@
-#
-# based on uncrustify config file for the linux kernel
-#
-
-code_width = 80
-indent_case_brace = 4
-indent_columns = 4
-indent_label = 2 # pos: absolute col, neg: relative column
-indent_with_tabs = 0
-
-#
-# inter-symbol newlines
-#
-nl_brace_else = remove # "} else" vs "} \n else" - cuddle else
-nl_brace_while = remove # "} while" vs "} \n while" - cuddle while
-nl_do_brace = remove # "do {" vs "do \n {"
-nl_else_brace = remove # "else {" vs "else \n {"
-nl_enum_brace = remove # "enum {" vs "enum \n {"
-nl_fcall_brace = remove # "list_for_each() {" vs "list_for_each()\n{"
-nl_fdef_brace = force # "int foo() {" vs "int foo()\n{"
-nl_for_brace = remove # "for () {" vs "for () \n {"
-nl_func_var_def_blk = 0 # don't add newlines after a block of var declarations
-nl_if_brace = remove # "if () {" vs "if () \n {"
-nl_multi_line_define = true
-nl_struct_brace = remove # "struct {" vs "struct \n {"
-nl_switch_brace = remove # "switch () {" vs "switch () \n {"
-nl_union_brace = remove # "union {" vs "union \n {"
-nl_while_brace = remove # "while () {" vs "while () \n {"
-
-
-#
-# Source code modifications
-#
-mod_full_brace_do = force # "do a--; while ();" vs "do { a--; } while ();"
-mod_full_brace_for = force # "for () a--;" vs "for () { a--; }"
-mod_full_brace_if = force # "if (a) a--;" vs "if (a) { a--; }"
-mod_full_brace_nl = 3 # don't remove if more than 3 newlines
-mod_full_brace_while = force # "while (a) a--;" vs "while (a) { a--; }"
-mod_paren_on_return = remove # "return 1;" vs "return (1);"
-
-
-#
-# inter-character spacing options
-#
-sp_after_cast = remove # "(int) a" vs "(int)a"
-sp_after_comma = force
-sp_after_sparen = force # "if () {" vs "if (){"
-sp_arith = force
-sp_assign = force
-sp_assign = force
-sp_before_comma = remove
-sp_before_ptr_star = force # "char *foo" vs "char* foo
-sp_before_sparen = force # "if (" vs "if("
-sp_between_ptr_star = remove # "char * *foo" vs "char **foo"
-sp_bool = force
-sp_compare = force
-sp_func_call_paren = remove # "foo (" vs "foo("
-sp_func_def_paren = remove # "int foo (){" vs "int foo(){"
-sp_func_proto_paren = remove # "int foo ();" vs "int foo();"
-sp_inside_braces = force # "{ 1 }" vs "{1}"
-sp_inside_braces_enum = force # "{ 1 }" vs "{1}"
-sp_inside_braces_struct = force # "{ 1 }" vs "{1}"
-sp_inside_sparen = remove
-sp_paren_brace = force
-sp_sizeof_paren = remove # "sizeof (int)" vs "sizeof(int)"
-
-#
-# Aligning stuff
-#
-align_enum_equ_span = 4 # '=' in enum definition
-align_nl_cont = true
-align_on_tabstop = FALSE # align on tabstops
-align_right_cmt_span = 3
-align_struct_init_span = 1
-align_struct_init_span = 3 # align stuff in a structure init '= { }'
-align_var_def_star_style = 2 # void *foo;
-align_var_struct_span = 0
-align_with_tabs = FALSE # use tabs to align
diff --git a/HISTORY.rst b/HISTORY.rst
index 36f62cd..73c8d7d 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,13 +3,239 @@
History
-------
+2.7.0 (2025-05-05)
+++++++++++++++++++
+
+* IMPORTANT: Python 3.9 or greater is required. If you are using an older
+ version, please use an earlier release.
+* The vendored ``libmaxminddb`` has been updated to 1.12.2.
+* The C extension now checks that the database metadata lookup was
+ successful.
+* A theoretical segmentation fault with the C extension when doing lookups
+ on a corrupt or invalid database was fixed.
+
+2.6.3 (2025-01-09)
+++++++++++++++++++
+
+* The vendored ``libmaxminddb`` has been updated to 1.12.0. This fixes a
+ memory leak when opening a database fails.
+* Binary wheels are now built for Python 3.13.
+
+2.6.2 (2024-06-10)
+++++++++++++++++++
+
+* The vendored ``libmaxminddb`` has been updated to 1.10.0. This fixes a
+ bug that would cause incorrect results on databases that had search
+ trees greater than 4 GB.
+
+2.6.1 (2024-04-12)
+++++++++++++++++++
+
+* This release includes no source code changes. The only changes are to
+ the release workflow.
+* Binary wheels are now built on Linux for aarch64. Pull request by Kevin
+ Park. GitHub #160.
+* Binary wheels are now built on macOS for Apple silicon. Requested by
+ Kevin Park. GitHub #152.
+
+2.6.0 (2024-03-19)
+++++++++++++++++++
+
+* Added type annotations for instance variables on ``Metadata``
+* Updated type stubs for ``maxminddb.extension``.
+* ``setuptools`` is no longer listed as a runtime dependency. Pull request
+ by Lewis Collard. GitHub #155.
+
+2.5.2 (2024-01-09)
+++++++++++++++++++
+
+* The vendored ``libmaxminddb`` version was updated to 1.9.0. This fixes
+ an issue when reading databases with a search tree exceeding 2 GB.
+ Reported by Sami Salonen. GitHub #146.
+
+2.5.1 (2023-11-09)
+++++++++++++++++++
+
+* This is a re-release of 2.5.0 to address missing files from the sdist.
+ Reported by Lumír 'Frenzy' Balhar. GitHub #132.
+
+2.5.0 (2023-11-08)
+++++++++++++++++++
+
+* IMPORTANT: Python 3.8 or greater is required. If you are using an older
+ version, please use an earlier release.
+* Windows is now supported by the C extension.
+* The ``Reader`` class now implements the ``__iter__`` method. This will
+ return an iterator that iterates over all records in the database,
+ excluding repeated aliased of the IPv4 network. Requested by
+ Jean-Baptiste Braun and others. GitHub #23.
+* The multiprocessing test now explicitly uses ``fork``. This allows it
+ to run successfully on macOS. Pull request by Theodore Ni. GitHub #116.
+* A vendored copy of ``libmaxminddb`` will now be used by default when
+ building the extension. If you wish to continue using the system shared
+ library, you may set the ``MAXMINDDB_USE_SYSTEM_LIBMAXMINDDB`` environment
+ variable to a true value when building the extension.
+* The C extension now builds on Python 3.13.
+* The C extension will now be built for PyPy.
+
+2.4.0 (2023-06-28)
+++++++++++++++++++
+
+* Package metadata was migrated from ``setup.py`` to ``setup.cfg``. GitHub
+ #113.
+* The C extension now decrements the reference count on an object
+ containing the database filename after its use in an error message rather
+ than before. Pull request by Lumír 'Frenzy' Balhar. GitHub #114.
+
+2.3.0 (2023-05-09)
+++++++++++++++++++
+
+* IMPORTANT: Python 3.7 or greater is required. If you are using an older
+ version, please use an earlier release.
+* ``distutils`` is no longer used for building the C extension.
+* Missing ``Py_INCREF`` was added to module initialization for the C
+ extension. Pull request by R. Christian McDonald. GitHub #106.
+
+2.2.0 (2021-09-24)
+++++++++++++++++++
+
+* The return type for ``maxminddb.open_database()`` has been simplified
+ to be just the ``Reader`` class as opposed to a union of that with
+ the extension class. This is done by casting the extension to
+ ``Reader``. The extension class has the same public API as the
+ pure Python implementation. This simplifies type checking for users of
+ this library. The ``Reader`` class is exposed as ``maxminddb.Reader``.
+ Pull request by wouter bolsterlee. GitHub #88.
+* ``maxminddb.__all__`` is now set to define a public API. Pull request
+ by wouter bolsterlee. GitHub #88.
+* Fix minor regression in ``repr`` output of ``maxminddb.reader.Metadata``
+ in 2.1.0.
+
+2.1.0 (2021-09-18)
+++++++++++++++++++
+
+* The C extension now correctly supports objects that implement the
+ ``os.PathLike`` interface.
+* When opening a database fails due to an access issue, the correct
+ ``OSError`` subclass will now be thrown.
+* The ``Metadata`` class object is now available from the C extension
+ module as ``maxminddb.extension.Metadata`` rather than
+ ``maxminddb.extension.extension``.
+* Type stubs have been added for ``maxminddb.extension``.
+
+2.0.3 (2020-10-16)
+++++++++++++++++++
+
+* The 2.0.0 release unintentionally required Python to be compiled with
+ ``mmap`` support for the module to work. ``mmap`` is now optional
+ again. Reported by john-heasman-cg. GitHub #76.
+
+2.0.2 (2020-07-28)
+++++++++++++++++++
+
+* Added ``py.typed`` file per PEP 561. Reported by Árni Már Jónsson.
+
+2.0.1 (2020-07-22)
+++++++++++++++++++
+
+* Fix minimum required python version in ``setup.py``. Pull request by
+ Boros Gábor. GitHub #69 & #70.
+
+2.0.0 (2020-07-21)
+++++++++++++++++++
+
+* IMPORTANT: Python 3.6 or greater is required. If you are using an older
+ version, please use a 1.x.x release.
+* Type hints have been added.
+
+1.5.4 (2020-05-05)
+++++++++++++++++++
+
+* 1.5.3 was missing a test database. This release adds the test file.
+ There are no other changes. Reported by Lumír 'Frenzy' Balhar. GitHub #60.
+
+1.5.3 (2020-05-04)
+++++++++++++++++++
+
+* Fix a segfault when decoding a database with a corrupt data section.
+ Reported by Robert Scott. GitHub #58.
+
+1.5.2 (2019-12-20)
+++++++++++++++++++
+
+* Minor performance improvements in the pure Python reader.
+
+1.5.1 (2019-09-27)
+++++++++++++++++++
+
+* Fix a possible segfault due to not correctly incrementing the reference
+ on a returned object.
+
+1.5.0 (2019-09-27)
+++++++++++++++++++
+
+* Python 3.3 and 3.4 are no longer supported.
+* The extension source directory was moved to prevent an ``ImportWarning``
+ when importing the module on Python 2 with ``-Wdefault`` set. Reported by
+ David Szotten and Craig de Stigter. GitHub #31.
+* The ``get`` method now accepts ``ipaddress.IPv4Address`` and
+ ``ipaddress.IPv6Address`` objects in addition to strings. This works with
+ both the pure Python implementation as well as the extension. Based on a
+ pull request #48 by Eric Pruitt. GitHub #50.
+* A new method, ``get_with_prefix_len``, was added. This method returns a
+ tuple containing the record and the prefix length.
+
+1.4.1 (2018-06-22)
+++++++++++++++++++
+
+* Fix test failure on Python 3.7. Reported by Carl George. GitHub #35.
+
+1.4.0 (2018-05-25)
+++++++++++++++++++
+
+* IMPORTANT: Previously, the pure Python reader would allow
+ ``ipaddress.IPv4Address`` and ``ipaddress.IPv6Address`` objects when calling
+ ``.get()``. This would fail with the C extension. The fact that these objects
+ worked at all was an implementation detail and has varied with different
+ releases. This release makes the pure Python implementation consistent
+ with the extension. A ``TypeError`` will now be thrown if you attempt to
+ use these types with either the pure Python implementation or the
+ extension. The IP address passed to ``.get()`` should be a string type.
+* Fix issue where incorrect size was used when unpacking some types with the
+ pure Python reader. Reported by Lee Symes. GitHub #30.
+* You may now pass in the database via a file descriptor rather than a file
+ name when creating a new ``maxminddb.Reader`` object using ``MODE_FD``.
+ This will read the database from the file descriptor into memory. Pull
+ request by nkinkade. GitHub #33.
+
+1.3.0 (2017-03-13)
+++++++++++++++++++
+
+* ``maxminddb.Reader`` and the C extension now support being used in a context
+ manager. Pull request by Joakim Uddholm. GitHub #21 & #28.
+* Provide a more useful error message when ``MODE_MMAP_EXT`` is requested but
+ the C extension is not available.
+
+1.2.3 (2017-01-11)
+++++++++++++++++++
+
+* Improve compatibility with other Python 2 ``ipaddress`` backports. Although
+ ``ipaddress`` is highly recommended, ``py2-ipaddress`` and
+ ``backport_ipaddress`` should now work. Incompatibility reported by
+ John Zadroga on ``geoip2`` GitHub issue #41.
+
+1.2.2 (2016-11-21)
+++++++++++++++++++
+
+* Fix to the classifiers in ``setup.py``. No code changes.
+
1.2.1 (2016-06-10)
++++++++++++++++++
* This module now uses the ``ipaddress`` module for Python 2 rather than the
``ipaddr`` module. Users should notice no behavior change beyond the change
in dependencies.
-* Removed ``requirements.txt` from `MANIFEST.in` in order to stop warning
+* Removed ``requirements.txt`` from ``MANIFEST.in`` in order to stop warning
during installation.
* Added missing test data.
diff --git a/MANIFEST.in b/MANIFEST.in
index 3658f4c..47963f4 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,6 @@
-include HISTORY.rst README.rst LICENSE tests/*.py tests/data/test-data/*.mmdb tests/data/test-data/*.raw
+exclude .* .github/**/* dev-bin/*
+include HISTORY.rst README.rst LICENSE maxminddb/py.typed maxminddb/extension.pyi
+recursive-include extension/libmaxminddb/include *.h
+recursive-include extension/libmaxminddb/src *.c *.h
+recursive-include tests/ *.mmdb *.py *.raw
graft docs/html
diff --git a/README.rst b/README.rst
index 0229224..5d92acb 100644
--- a/README.rst
+++ b/README.rst
@@ -14,42 +14,46 @@ subnets (IPv4 or IPv6).
Installation
------------
-If you want to use the C extension, you must first install `libmaxminddb
-`_ C library installed before
-installing this extension. If the library is not available, the module will
-fall-back to a pure Python implementation.
-
-To install maxminddb, type:
+To install ``maxminddb``, type:
.. code-block:: bash
$ pip install maxminddb
-If you are not able to use pip, you may also use easy_install from the
+If you are not able to install from PyPI, you may also use ``pip`` from the
source directory:
.. code-block:: bash
- $ easy_install .
+ $ python -m pip install .
+
+The installer will attempt to build the C extension. If this fails, the
+module will fall-back to the pure Python implementation.
Usage
-----
To use this module, you must first download or create a MaxMind DB file. We
provide `free GeoLite2 databases
-`_. These files must be
-decompressed with ``gunzip``.
-
-After you have obtained a database and importing the module, call
-``open_database`` with a path to the database as the first argument.
-Optionally, you may pass a mode as the second arguments. The modes are
-exported from ``maxminddb``. Valid modes are:
-
-* MODE_MMAP_EXT - use the C extension with memory map.
-* MODE_MMAP - read from memory map. Pure Python.
-* MODE_FILE - read database as standard file. Pure Python.
-* MODE_MEMORY - load database into memory. Pure Python.
-* MODE_AUTO - try MODE_MMAP_EXT, MODE_MMAP, MODE_FILE in that order. Default.
+`_. These
+files must be decompressed with ``gunzip``.
+
+After you have obtained a database and imported the module, call
+``open_database`` with a path, or file descriptor (in the case of ``MODE_FD``),
+to the database as the first argument. Optionally, you may pass a mode as the
+second argument. The modes are exported from ``maxminddb``. Valid modes are:
+
+* ``MODE_MMAP_EXT`` - use the C extension with memory map.
+* ``MODE_MMAP`` - read from memory map. Pure Python.
+* ``MODE_FILE`` - read database as standard file. Pure Python.
+* ``MODE_MEMORY`` - load database into memory. Pure Python.
+* ``MODE_FD`` - load database into memory from a file descriptor. Pure Python.
+* ``MODE_AUTO`` - try ``MODE_MMAP_EXT``, ``MODE_MMAP``, ``MODE_FILE`` in that
+ order. Default.
+
+**NOTE**: When using ``MODE_FD``, it is the *caller's* responsibility to be
+sure that the file descriptor gets closed properly. The caller may close the
+file descriptor immediately after the ``Reader`` object is created.
The ``open_database`` function returns a ``Reader`` object. To look up an IP
address, use the ``get`` method on this object. The method will return the
@@ -57,6 +61,14 @@ corresponding values for the IP address from the database (e.g., a dictionary
for GeoIP2/GeoLite2 databases). If the database does not contain a record for
that IP address, the method will return ``None``.
+If you wish to also retrieve the prefix length for the record, use the
+``get_with_prefix_len`` method. This returns a tuple containing the record
+followed by the network prefix length associated with the record.
+
+You may also iterate over the whole database. The ``Reader`` class implements
+the ``__iter__`` method that returns an iterator. This iterator yields a
+tuple containing the network and the record.
+
Example
-------
@@ -64,11 +76,16 @@ Example
>>> import maxminddb
>>>
- >>> reader = maxminddb.open_database('GeoLite2-City.mmdb')
- >>> reader.get('1.1.1.1')
+ >>> with maxminddb.open_database('GeoLite2-City.mmdb') as reader:
+ >>>
+ >>> reader.get('152.216.7.110')
{'country': ... }
>>>
- >>> reader.close()
+ >>> reader.get_with_prefix_len('152.216.7.110')
+ ({'country': ... }, 24)
+ >>>
+ >>> for network, record in reader:
+ >>> ...
Exceptions
----------
@@ -80,16 +97,13 @@ invalid IP address or an IPv6 address in an IPv4 database.
Requirements
------------
-This code requires Python 2.6+ or 3.3+. The C extension requires CPython. The
-pure Python implementation has been tested with PyPy.
-
-On Python 2, the `ipaddress module `_ is
-required.
+This code requires Python 3.9+. Older versions are not supported. The C
+extension requires CPython.
Versioning
----------
-The MaxMind DB Python module uses `Semantic Versioning `_.
+The MaxMind DB Python module uses `Semantic Versioning `_.
Support
-------
@@ -98,5 +112,5 @@ Please report all issues with this code using the `GitHub issue tracker
`_
If you are having an issue with a MaxMind service that is not specific to this
-API, please contact `MaxMind support `_ for
+API, please contact `MaxMind support `_ for
assistance.
diff --git a/dev-bin/clang-format-all.sh b/dev-bin/clang-format-all.sh
new file mode 100755
index 0000000..71a1bdf
--- /dev/null
+++ b/dev-bin/clang-format-all.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+
+format="clang-format -i -style=file"
+
+for dir in extension; do
+ c_files=`find $dir -maxdepth 1 -name '*.c'`
+ if [ "$c_files" != "" ]; then
+ $format $dir/*.c;
+ fi
+
+ h_files=`find $dir -maxdepth 1 -name '*.h'`
+ if [ "$h_files" != "" ]; then
+ $format $dir/*.h;
+ fi
+done
diff --git a/dev-bin/release.sh b/dev-bin/release.sh
new file mode 100755
index 0000000..1dae9a2
--- /dev/null
+++ b/dev-bin/release.sh
@@ -0,0 +1,63 @@
+#!/bin/bash
+
+set -eu -o pipefail
+
+changelog=$(cat HISTORY.rst)
+
+regex='
+([0-9]+\.[0-9]+\.[0-9]+) \(([0-9]{4}-[0-9]{2}-[0-9]{2})\)
+\+*
+
+((.|
+)*)
+'
+
+if [[ ! $changelog =~ $regex ]]; then
+ echo "Could not find date line in change log!"
+ exit 1
+fi
+
+version="${BASH_REMATCH[1]}"
+date="${BASH_REMATCH[2]}"
+notes="$(echo "${BASH_REMATCH[3]}" | sed -n -e '/^[0-9]\+\.[0-9]\+\.[0-9]\+/,$!p')"
+
+if [[ "$date" != $(date +"%Y-%m-%d") ]]; then
+ echo "$date is not today!"
+ exit 1
+fi
+
+tag="v$version"
+
+if [ -n "$(git status --porcelain)" ]; then
+ echo ". is not clean." >&2
+ exit 1
+fi
+
+perl -pi -e "s/(?<=__version__ = \").+?(?=\")/$version/gsm" maxminddb/__init__.py
+perl -pi -e "s/(?<=^version = \").+?(?=\")/$version/gsm" pyproject.toml
+
+echo $"Test results:"
+tox
+
+echo $'\nDiff:'
+git diff
+
+echo $'\nRelease notes:'
+echo "$notes"
+
+read -e -p "Commit changes and push to origin? " should_push
+
+if [ "$should_push" != "y" ]; then
+ echo "Aborting"
+ exit 1
+fi
+
+if [ -n "$(git status --porcelain)" ]; then
+ git commit -m "Update for $tag" -a
+fi
+
+git push
+
+gh release create --target "$(git branch --show-current)" -t "$version" -n "$notes" "$tag"
+
+git push --tags
diff --git a/docs/conf.py b/docs/conf.py
index 9ef6736..f08abdf 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
#
# maxminddb documentation build configuration file, created by
# sphinx-quickstart on Tue Apr 9 13:34:57 2013.
@@ -12,45 +11,48 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
-import sys
import os
+import sys
-sys.path.insert(0, os.path.abspath('..'))
+sys.path.insert(0, os.path.abspath(".."))
import maxminddb
-
__version__ = maxminddb.__version__
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
-sys.path.insert(0, os.path.abspath('..'))
+sys.path.insert(0, os.path.abspath(".."))
# -- General configuration -----------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
-#needs_sphinx = '1.0'
+# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
-extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest',
- 'sphinx.ext.intersphinx', 'sphinx.ext.coverage']
+extensions = [
+ "sphinx.ext.autodoc",
+ "sphinx.ext.doctest",
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.coverage",
+]
# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
# The suffix of source filenames.
-source_suffix = '.rst'
+source_suffix = ".rst"
# The encoding of source files.
-#source_encoding = 'utf-8-sig'
+# source_encoding = 'utf-8-sig'
# The master toctree document.
-master_doc = 'index'
+master_doc = "index"
# General information about the project.
-project = 'maxminddb'
-copyright = '2014, MaxMind, Inc.'
+project = "maxminddb"
+copyright = "2013-2025, MaxMind, Inc."
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
@@ -63,128 +65,124 @@
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
-#language = None
+# language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
-#today = ''
+# today = ''
# Else, today_fmt is used as the format for a strftime call.
-#today_fmt = '%B %d, %Y'
+# today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
-exclude_patterns = ['_build']
+exclude_patterns = ["_build"]
# The reST default role (used for this markup: `text`) to use for all documents.
-#default_role = None
+# default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
-#add_function_parentheses = True
+# add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
-#add_module_names = True
+# add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
-#show_authors = False
+# show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
+pygments_style = "sphinx"
# A list of ignored prefixes for module index sorting.
-#modindex_common_prefix = []
-
+# modindex_common_prefix = []
# -- Options for HTML output ---------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
-html_theme = 'sphinxdoc'
+html_theme = "sphinxdoc"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
-#html_theme_options = {}
+# html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
-#html_theme_path = []
+# html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# " v documentation".
-#html_title = None
+# html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
-#html_short_title = None
+# html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
-#html_logo = None
+# html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
-#html_favicon = None
+# html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+# html_static_path = ["_static"]
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
-#html_last_updated_fmt = '%b %d, %Y'
+# html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
-#html_use_smartypants = True
+# html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
-#html_sidebars = {}
+# html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
-#html_additional_pages = {}
+# html_additional_pages = {}
# If false, no module index is generated.
-#html_domain_indices = True
+# html_domain_indices = True
# If false, no index is generated.
-#html_use_index = True
+# html_use_index = True
# If true, the index is split into individual pages for each letter.
-#html_split_index = False
+# html_split_index = False
# If true, links to the reST sources are added to the pages.
-#html_show_sourcelink = True
+# html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-#html_show_sphinx = True
+# html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-#html_show_copyright = True
+# html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
-#html_use_opensearch = ''
+# html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
-#html_file_suffix = None
+# html_file_suffix = None
# Output file base name for HTML help builder.
-htmlhelp_basename = 'maxminddbdoc'
-
+htmlhelp_basename = "maxminddbdoc"
# -- Options for LaTeX output --------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
-
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
-
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
@@ -192,43 +190,37 @@
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
- ('index', 'maxminddb.tex', 'maxminddb Documentation',
- 'Gregory Oschwald', 'manual'),
+ ("index", "maxminddb.tex", "maxminddb Documentation", "Gregory Oschwald", "manual"),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
-#latex_logo = None
+# latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
-#latex_use_parts = False
+# latex_use_parts = False
# If true, show page references after internal links.
-#latex_show_pagerefs = False
+# latex_show_pagerefs = False
# If true, show URL addresses after external links.
-#latex_show_urls = False
+# latex_show_urls = False
# Documents to append as an appendix to all manuals.
-#latex_appendices = []
+# latex_appendices = []
# If false, no module index is generated.
-#latex_domain_indices = True
-
+# latex_domain_indices = True
# -- Options for manual page output --------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
-man_pages = [
- ('index', 'maxminddb', 'maxminddb Documentation',
- ['Gregory Oschwald'], 1)
-]
+man_pages = [("index", "maxminddb", "maxminddb Documentation", ["Gregory Oschwald"], 1)]
# If true, show URL addresses after external links.
-#man_show_urls = False
-
+# man_show_urls = False
# -- Options for Texinfo output ------------------------------------------
@@ -236,20 +228,27 @@
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
- ('index', 'maxminddb', 'maxminddb Documentation',
- 'Gregory Oschwald', 'maxminddb', 'MaxMind DB Reader',
- 'Miscellaneous'),
+ (
+ "index",
+ "maxminddb",
+ "maxminddb Documentation",
+ "Gregory Oschwald",
+ "maxminddb",
+ "MaxMind DB Reader",
+ "Miscellaneous",
+ ),
]
# Documents to append as an appendix to all manuals.
-#texinfo_appendices = []
+# texinfo_appendices = []
# If false, no module index is generated.
-#texinfo_domain_indices = True
+# texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
-#texinfo_show_urls = 'footnote'
-
+# texinfo_show_urls = 'footnote'
# Example configuration for intersphinx: refer to the Python standard library.
-intersphinx_mapping = {'http://docs.python.org/': None}
+intersphinx_mapping = {
+ "python": ("https://python.readthedocs.org/en/latest/", None),
+}
diff --git a/docs/index.rst b/docs/index.rst
index c68c9ec..b062a0d 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -8,6 +8,33 @@
.. include:: ../README.rst
+======
+Module
+======
+
+.. automodule:: maxminddb
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+======
+Errors
+======
+
+.. automodule:: maxminddb.errors
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+===============
+Database Reader
+===============
+
+.. automodule:: maxminddb.reader
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
==================
Indices and tables
==================
@@ -16,6 +43,6 @@ Indices and tables
* :ref:`modindex`
* :ref:`search`
-:copyright: (c) 2014 by MaxMind, Inc.
+:copyright: (c) 2013-2025 by MaxMind, Inc.
:license: Apache License, Version 2.0
diff --git a/examples/benchmark.py b/examples/benchmark.py
old mode 100644
new mode 100755
index 5d21e75..e96310f
--- a/examples/benchmark.py
+++ b/examples/benchmark.py
@@ -1,35 +1,35 @@
#!/usr/bin/python
-# -*- coding: utf-8 -*-
-
-from __future__ import print_function
+"""Basic benchmark for maxminddb."""
import argparse
-import maxminddb
import random
import socket
import struct
import timeit
-parser = argparse.ArgumentParser(description='Benchmark maxminddb.')
-parser.add_argument('--count', default=250000, type=int,
- help='number of lookups')
-parser.add_argument('--mode', default=0, type=int,
- help='reader mode to use')
-parser.add_argument('--file', default='GeoIP2-City.mmdb',
- help='path to mmdb file')
+import maxminddb
+
+parser = argparse.ArgumentParser(description="Benchmark maxminddb.")
+parser.add_argument("--count", default=250000, type=int, help="number of lookups")
+parser.add_argument("--mode", default=0, type=int, help="reader mode to use")
+parser.add_argument("--file", default="GeoIP2-City.mmdb", help="path to mmdb file")
args = parser.parse_args()
+random.seed(0)
reader = maxminddb.open_database(args.file, args.mode)
-def lookup_ip_address():
- ip = socket.inet_ntoa(struct.pack('!L', random.getrandbits(32)))
- record = reader.get(str(ip))
+def lookup_ip_address() -> None:
+ """Look up the IP."""
+ ip = socket.inet_ntoa(struct.pack("!L", random.getrandbits(32)))
+ reader.get(str(ip))
-elapsed = timeit.timeit('lookup_ip_address()',
- setup='from __main__ import lookup_ip_address',
- number=args.count)
+elapsed = timeit.timeit(
+ "lookup_ip_address()",
+ setup="from __main__ import lookup_ip_address",
+ number=args.count,
+)
-print(args.count / elapsed, 'lookups per second')
+print(f"{int(args.count / elapsed):,}", "lookups per second") # noqa: T201
diff --git a/extension/libmaxminddb b/extension/libmaxminddb
new file mode 160000
index 0000000..cba618d
--- /dev/null
+++ b/extension/libmaxminddb
@@ -0,0 +1 @@
+Subproject commit cba618d6581b7dbe83478c798d9e58faeaa6b582
diff --git a/extension/maxminddb.c b/extension/maxminddb.c
new file mode 100644
index 0000000..96aca80
--- /dev/null
+++ b/extension/maxminddb.c
@@ -0,0 +1,1026 @@
+#define PY_SSIZE_T_CLEAN
+#include
+#ifdef MS_WINDOWS
+#include
+#include
+#else
+#include
+#include
+#include
+#include
+#endif
+#include
+#include
+#include
+
+#define __STDC_FORMAT_MACROS
+#include
+
+static PyTypeObject Reader_Type;
+static PyTypeObject ReaderIter_Type;
+static PyTypeObject Metadata_Type;
+static PyObject *MaxMindDB_error;
+static PyObject *ipaddress_ip_network;
+
+// clang-format off
+typedef struct {
+ PyObject_HEAD /* no semicolon */
+ MMDB_s *mmdb;
+ PyObject *closed;
+} Reader_obj;
+
+typedef struct record record;
+struct record{
+ char ip_packed[16];
+ int depth;
+ uint64_t record;
+ uint8_t type;
+ MMDB_entry_s entry;
+ struct record *next;
+};
+
+typedef struct {
+ PyObject_HEAD /* no semicolon */
+ Reader_obj *reader;
+ struct record *next;
+} ReaderIter_obj;
+
+typedef struct {
+ PyObject_HEAD /* no semicolon */
+ PyObject *binary_format_major_version;
+ PyObject *binary_format_minor_version;
+ PyObject *build_epoch;
+ PyObject *database_type;
+ PyObject *description;
+ PyObject *ip_version;
+ PyObject *languages;
+ PyObject *node_count;
+ PyObject *record_size;
+} Metadata_obj;
+// clang-format on
+
+static bool can_read(const char *path);
+static int get_record(PyObject *self, PyObject *args, PyObject **record);
+static bool format_sockaddr(struct sockaddr *addr, char *dst);
+static PyObject *from_entry_data_list(MMDB_entry_data_list_s **entry_data_list);
+static PyObject *from_map(MMDB_entry_data_list_s **entry_data_list);
+static PyObject *from_array(MMDB_entry_data_list_s **entry_data_list);
+static PyObject *from_uint128(const MMDB_entry_data_list_s *entry_data_list);
+static int ip_converter(PyObject *obj, struct sockaddr_storage *ip_address);
+
+#ifdef __GNUC__
+#define UNUSED(x) UNUSED_##x __attribute__((__unused__))
+#else
+#define UNUSED(x) UNUSED_##x
+#endif
+
+static int Reader_init(PyObject *self, PyObject *args, PyObject *kwds) {
+ PyObject *filepath = NULL;
+ int mode = 0;
+
+ static char *kwlist[] = {"database", "mode", NULL};
+ if (!PyArg_ParseTupleAndKeywords(args,
+ kwds,
+ "O&|i",
+ kwlist,
+ PyUnicode_FSConverter,
+ &filepath,
+ &mode)) {
+ return -1;
+ }
+
+ char *filename = PyBytes_AS_STRING(filepath);
+ if (filename == NULL) {
+ return -1;
+ }
+
+ if (mode != 0 && mode != 1) {
+ Py_XDECREF(filepath);
+ PyErr_Format(
+ PyExc_ValueError,
+ "Unsupported open mode (%i). Only "
+ "MODE_AUTO and MODE_MMAP_EXT are supported by this extension.",
+ mode);
+ return -1;
+ }
+
+ if (!can_read(filename)) {
+ PyErr_SetFromErrnoWithFilenameObject(PyExc_OSError, filepath);
+ Py_XDECREF(filepath);
+ return -1;
+ }
+
+ MMDB_s *mmdb = (MMDB_s *)malloc(sizeof(MMDB_s));
+ if (mmdb == NULL) {
+ Py_XDECREF(filepath);
+ PyErr_NoMemory();
+ return -1;
+ }
+
+ Reader_obj *mmdb_obj = (Reader_obj *)self;
+ if (!mmdb_obj) {
+ Py_XDECREF(filepath);
+ free(mmdb);
+ PyErr_NoMemory();
+ return -1;
+ }
+
+ int const status = MMDB_open(filename, MMDB_MODE_MMAP, mmdb);
+
+ if (status != MMDB_SUCCESS) {
+ free(mmdb);
+ PyErr_Format(MaxMindDB_error,
+ "Error opening database file (%s). Is this a valid "
+ "MaxMind DB file?",
+ filename);
+ Py_XDECREF(filepath);
+ return -1;
+ }
+
+ Py_XDECREF(filepath);
+
+ mmdb_obj->mmdb = mmdb;
+ mmdb_obj->closed = Py_False;
+ return 0;
+}
+
+static PyObject *Reader_get(PyObject *self, PyObject *args) {
+ PyObject *record = NULL;
+ if (get_record(self, args, &record) == -1) {
+ return NULL;
+ }
+ return record;
+}
+
+static PyObject *Reader_get_with_prefix_len(PyObject *self, PyObject *args) {
+ PyObject *record = NULL;
+ int prefix_len = get_record(self, args, &record);
+ if (prefix_len == -1) {
+ return NULL;
+ }
+
+ PyObject *tuple = Py_BuildValue("(Oi)", record, prefix_len);
+ Py_DECREF(record);
+
+ return tuple;
+}
+
+static int get_record(PyObject *self, PyObject *args, PyObject **record) {
+ MMDB_s *mmdb = ((Reader_obj *)self)->mmdb;
+ if (mmdb == NULL) {
+ PyErr_SetString(PyExc_ValueError,
+ "Attempt to read from a closed MaxMind DB.");
+ return -1;
+ }
+
+ struct sockaddr_storage ip_address_ss = {0};
+ struct sockaddr *ip_address = (struct sockaddr *)&ip_address_ss;
+ if (!PyArg_ParseTuple(args, "O&", ip_converter, &ip_address_ss)) {
+ return -1;
+ }
+
+ if (!ip_address->sa_family) {
+ PyErr_SetString(PyExc_ValueError, "Error parsing argument");
+ return -1;
+ }
+
+ int mmdb_error = MMDB_SUCCESS;
+ MMDB_lookup_result_s result =
+ MMDB_lookup_sockaddr(mmdb, ip_address, &mmdb_error);
+
+ if (mmdb_error != MMDB_SUCCESS) {
+ PyObject *exception;
+ if (MMDB_IPV6_LOOKUP_IN_IPV4_DATABASE_ERROR == mmdb_error) {
+ exception = PyExc_ValueError;
+ } else {
+ exception = MaxMindDB_error;
+ }
+ char ipstr[INET6_ADDRSTRLEN] = {0};
+ if (format_sockaddr(ip_address, ipstr)) {
+ PyErr_Format(exception,
+ "Error looking up %s. %s",
+ ipstr,
+ MMDB_strerror(mmdb_error));
+ }
+ return -1;
+ }
+
+ int prefix_len = result.netmask;
+ if (ip_address->sa_family == AF_INET && mmdb->metadata.ip_version == 6) {
+ // We return the prefix length given the IPv4 address. If there is
+ // no IPv4 subtree, we return a prefix length of 0.
+ prefix_len = prefix_len >= 96 ? prefix_len - 96 : 0;
+ }
+
+ if (!result.found_entry) {
+ Py_INCREF(Py_None);
+ *record = Py_None;
+ return prefix_len;
+ }
+
+ MMDB_entry_data_list_s *entry_data_list = NULL;
+ int status = MMDB_get_entry_data_list(&result.entry, &entry_data_list);
+ if (status != MMDB_SUCCESS) {
+ char ipstr[INET6_ADDRSTRLEN] = {0};
+ if (format_sockaddr(ip_address, ipstr)) {
+ PyErr_Format(MaxMindDB_error,
+ "Error while looking up data for %s. %s",
+ ipstr,
+ MMDB_strerror(status));
+ }
+ MMDB_free_entry_data_list(entry_data_list);
+ return -1;
+ }
+
+ MMDB_entry_data_list_s *original_entry_data_list = entry_data_list;
+ *record = from_entry_data_list(&entry_data_list);
+ MMDB_free_entry_data_list(original_entry_data_list);
+
+ // from_entry_data_list will return NULL on errors.
+ if (*record == NULL) {
+ return -1;
+ }
+
+ return prefix_len;
+}
+
+static int ip_converter(PyObject *obj, struct sockaddr_storage *ip_address) {
+ if (PyUnicode_Check(obj)) {
+ Py_ssize_t len;
+ const char *ipstr = PyUnicode_AsUTF8AndSize(obj, &len);
+
+ if (!ipstr) {
+ PyErr_SetString(PyExc_TypeError,
+ "argument 1 contains an invalid string");
+ return 0;
+ }
+ if (strlen(ipstr) != (size_t)len) {
+ PyErr_SetString(PyExc_TypeError,
+ "argument 1 contains an embedded null character");
+ return 0;
+ }
+
+ struct addrinfo hints = {
+ .ai_family = AF_UNSPEC,
+ .ai_flags = AI_NUMERICHOST,
+ // We set ai_socktype so that we only get one result back
+ .ai_socktype = SOCK_STREAM};
+
+ struct addrinfo *addresses = NULL;
+ int gai_status = getaddrinfo(ipstr, NULL, &hints, &addresses);
+ if (gai_status) {
+ PyErr_Format(PyExc_ValueError,
+ "'%s' does not appear to be an IPv4 or IPv6 address.",
+ ipstr);
+ return 0;
+ }
+ if (!addresses) {
+ PyErr_SetString(
+ PyExc_RuntimeError,
+ "getaddrinfo was successful but failed to set the addrinfo");
+ return 0;
+ }
+ memcpy(ip_address, addresses->ai_addr, addresses->ai_addrlen);
+ freeaddrinfo(addresses);
+ return 1;
+ }
+ PyObject *packed = PyObject_GetAttrString(obj, "packed");
+ if (!packed) {
+ PyErr_SetString(PyExc_TypeError,
+ "argument 1 must be a string or ipaddress object");
+ return 0;
+ }
+ Py_ssize_t len;
+ char *bytes;
+ int status = PyBytes_AsStringAndSize(packed, &bytes, &len);
+ if (status == -1) {
+ PyErr_SetString(PyExc_TypeError,
+ "argument 1 must be a string or ipaddress object");
+ Py_DECREF(packed);
+ return 0;
+ }
+
+ switch (len) {
+ case 16: {
+ ip_address->ss_family = AF_INET6;
+ struct sockaddr_in6 *sin = (struct sockaddr_in6 *)ip_address;
+ memcpy(sin->sin6_addr.s6_addr, bytes, (size_t)len);
+ Py_DECREF(packed);
+ return 1;
+ }
+ case 4: {
+ ip_address->ss_family = AF_INET;
+ struct sockaddr_in *sin = (struct sockaddr_in *)ip_address;
+ memcpy(&(sin->sin_addr.s_addr), bytes, (size_t)len);
+ Py_DECREF(packed);
+ return 1;
+ }
+ default:
+ PyErr_SetString(
+ PyExc_ValueError,
+ "argument 1 returned an unexpected packed length for address");
+ Py_DECREF(packed);
+ return 0;
+ }
+}
+
+static bool format_sockaddr(struct sockaddr *sa, char *dst) {
+ char *addr;
+ if (sa->sa_family == AF_INET) {
+ struct sockaddr_in *sin = (struct sockaddr_in *)sa;
+ addr = (char *)&sin->sin_addr;
+ } else {
+ struct sockaddr_in6 *sin = (struct sockaddr_in6 *)sa;
+ addr = (char *)&sin->sin6_addr;
+ }
+
+ if (inet_ntop(sa->sa_family, addr, dst, INET6_ADDRSTRLEN)) {
+ return true;
+ }
+ PyErr_SetString(PyExc_RuntimeError, "unable to format IP address");
+ return false;
+}
+
+static PyObject *Reader_metadata(PyObject *self, PyObject *UNUSED(args)) {
+ Reader_obj *mmdb_obj = (Reader_obj *)self;
+
+ if (mmdb_obj->mmdb == NULL) {
+ PyErr_SetString(PyExc_IOError,
+ "Attempt to read from a closed MaxMind DB.");
+ return NULL;
+ }
+
+ MMDB_entry_data_list_s *entry_data_list;
+ int status =
+ MMDB_get_metadata_as_entry_data_list(mmdb_obj->mmdb, &entry_data_list);
+ if (status != MMDB_SUCCESS) {
+ PyErr_Format(MaxMindDB_error,
+ "Error decoding metadata. %s",
+ MMDB_strerror(status));
+ return NULL;
+ }
+ MMDB_entry_data_list_s *original_entry_data_list = entry_data_list;
+
+ PyObject *metadata_dict = from_entry_data_list(&entry_data_list);
+ MMDB_free_entry_data_list(original_entry_data_list);
+ if (metadata_dict == NULL || !PyDict_Check(metadata_dict)) {
+ PyErr_SetString(MaxMindDB_error, "Error decoding metadata.");
+ return NULL;
+ }
+
+ PyObject *args = PyTuple_New(0);
+ if (args == NULL) {
+ Py_DECREF(metadata_dict);
+ return NULL;
+ }
+
+ PyObject *metadata =
+ PyObject_Call((PyObject *)&Metadata_Type, args, metadata_dict);
+
+ Py_DECREF(metadata_dict);
+ return metadata;
+}
+
+static PyObject *Reader_close(PyObject *self, PyObject *UNUSED(args)) {
+ Reader_obj *mmdb_obj = (Reader_obj *)self;
+
+ if (mmdb_obj->mmdb != NULL) {
+ MMDB_close(mmdb_obj->mmdb);
+ free(mmdb_obj->mmdb);
+ mmdb_obj->mmdb = NULL;
+ }
+
+ mmdb_obj->closed = Py_True;
+
+ Py_RETURN_NONE;
+}
+
+static PyObject *Reader__enter__(PyObject *self, PyObject *UNUSED(args)) {
+ Reader_obj *mmdb_obj = (Reader_obj *)self;
+
+ if (mmdb_obj->closed == Py_True) {
+ PyErr_SetString(PyExc_ValueError,
+ "Attempt to reopen a closed MaxMind DB.");
+ return NULL;
+ }
+
+ Py_INCREF(self);
+ return (PyObject *)self;
+}
+
+static PyObject *Reader__exit__(PyObject *self, PyObject *UNUSED(args)) {
+ Reader_close(self, NULL);
+ Py_RETURN_NONE;
+}
+
+static void Reader_dealloc(PyObject *self) {
+ Reader_obj *obj = (Reader_obj *)self;
+ if (obj->mmdb != NULL) {
+ Reader_close(self, NULL);
+ }
+
+ PyObject_Del(self);
+}
+
+static PyObject *Reader_iter(PyObject *obj) {
+ Reader_obj *reader = (Reader_obj *)obj;
+ if (reader->closed == Py_True) {
+ PyErr_SetString(PyExc_ValueError,
+ "Attempt to iterate over a closed MaxMind DB.");
+ return NULL;
+ }
+
+ ReaderIter_obj *ri = PyObject_New(ReaderIter_obj, &ReaderIter_Type);
+ if (ri == NULL) {
+ return NULL;
+ }
+
+ ri->reader = reader;
+ Py_INCREF(reader);
+
+ // Currently, we are always starting from the 0 node with the 0 IP
+ ri->next = calloc(1, sizeof(record));
+ if (ri->next == NULL) {
+ // ReaderIter_dealloc will decrement the reference count on reader
+ Py_DECREF(ri);
+ PyErr_NoMemory();
+ return NULL;
+ }
+
+ return (PyObject *)ri;
+}
+
+static bool is_ipv6(char ip[16]) {
+ char z = 0;
+ for (int i = 0; i < 12; i++) {
+ z |= ip[i];
+ }
+ return z;
+}
+
+static PyObject *ReaderIter_next(PyObject *self) {
+ ReaderIter_obj *ri = (ReaderIter_obj *)self;
+ if (ri->reader->closed == Py_True) {
+ PyErr_SetString(PyExc_ValueError,
+ "Attempt to iterate over a closed MaxMind DB.");
+ return NULL;
+ }
+
+ while (ri->next != NULL) {
+ record *cur = ri->next;
+ ri->next = cur->next;
+
+ switch (cur->type) {
+ case MMDB_RECORD_TYPE_INVALID:
+ PyErr_SetString(MaxMindDB_error,
+ "Invalid record when reading node");
+ free(cur);
+ return NULL;
+ case MMDB_RECORD_TYPE_SEARCH_NODE: {
+ if (cur->record ==
+ ri->reader->mmdb->ipv4_start_node.node_value &&
+ is_ipv6(cur->ip_packed)) {
+ // These are aliased networks. Skip them.
+ break;
+ }
+ MMDB_search_node_s node;
+ int status = MMDB_read_node(
+ ri->reader->mmdb, (uint32_t)cur->record, &node);
+ if (status != MMDB_SUCCESS) {
+ const char *error = MMDB_strerror(status);
+ PyErr_Format(
+ MaxMindDB_error, "Error reading node: %s", error);
+ free(cur);
+ return NULL;
+ }
+ struct record *left = calloc(1, sizeof(record));
+ if (left == NULL) {
+ free(cur);
+ PyErr_NoMemory();
+ return NULL;
+ }
+ memcpy(
+ left->ip_packed, cur->ip_packed, sizeof(left->ip_packed));
+ left->depth = cur->depth + 1;
+ left->record = node.left_record;
+ left->type = node.left_record_type;
+ left->entry = node.left_record_entry;
+
+ struct record *right = left->next = calloc(1, sizeof(record));
+ if (right == NULL) {
+ free(cur);
+ free(left);
+ PyErr_NoMemory();
+ return NULL;
+ }
+ memcpy(
+ right->ip_packed, cur->ip_packed, sizeof(right->ip_packed));
+ right->ip_packed[cur->depth / 8] |= 1 << (7 - cur->depth % 8);
+ right->depth = cur->depth + 1;
+ right->record = node.right_record;
+ right->type = node.right_record_type;
+ right->entry = node.right_record_entry;
+ right->next = ri->next;
+
+ ri->next = left;
+ break;
+ }
+ case MMDB_RECORD_TYPE_EMPTY:
+ break;
+ case MMDB_RECORD_TYPE_DATA: {
+ MMDB_entry_data_list_s *entry_data_list = NULL;
+ int status =
+ MMDB_get_entry_data_list(&cur->entry, &entry_data_list);
+ if (status != MMDB_SUCCESS) {
+ PyErr_Format(
+ MaxMindDB_error,
+ "Error looking up data while iterating over tree: %s",
+ MMDB_strerror(status));
+ MMDB_free_entry_data_list(entry_data_list);
+ free(cur);
+ return NULL;
+ }
+
+ MMDB_entry_data_list_s *original_entry_data_list =
+ entry_data_list;
+ PyObject *record = from_entry_data_list(&entry_data_list);
+ MMDB_free_entry_data_list(original_entry_data_list);
+ if (record == NULL) {
+ free(cur);
+ return NULL;
+ }
+
+ int ip_start = 0;
+ int ip_length = 4;
+ if (ri->reader->mmdb->depth == 128) {
+ if (is_ipv6(cur->ip_packed)) {
+ // IPv6 address
+ ip_length = 16;
+ } else {
+ // IPv4 address in IPv6 tree
+ ip_start = 12;
+ }
+ }
+ PyObject *network_tuple =
+ Py_BuildValue("(y#i)",
+ &(cur->ip_packed[ip_start]),
+ ip_length,
+ cur->depth - ip_start * 8);
+ if (network_tuple == NULL) {
+ Py_DECREF(record);
+ free(cur);
+ return NULL;
+ }
+ PyObject *args = PyTuple_Pack(1, network_tuple);
+ Py_DECREF(network_tuple);
+ if (args == NULL) {
+ Py_DECREF(record);
+ free(cur);
+ return NULL;
+ }
+ PyObject *network =
+ PyObject_CallObject(ipaddress_ip_network, args);
+ Py_DECREF(args);
+ if (network == NULL) {
+ Py_DECREF(record);
+ free(cur);
+ return NULL;
+ }
+
+ PyObject *rv = PyTuple_Pack(2, network, record);
+ Py_DECREF(network);
+ Py_DECREF(record);
+
+ free(cur);
+ return rv;
+ }
+ default:
+ PyErr_Format(
+ MaxMindDB_error, "Unknown record type: %u", cur->type);
+ free(cur);
+ return NULL;
+ }
+ free(cur);
+ }
+ return NULL;
+}
+
+static void ReaderIter_dealloc(PyObject *self) {
+ ReaderIter_obj *ri = (ReaderIter_obj *)self;
+
+ Py_DECREF(ri->reader);
+
+ struct record *next = ri->next;
+ while (next != NULL) {
+ struct record *cur = next;
+ next = cur->next;
+ free(cur);
+ }
+ PyObject_Del(self);
+}
+
+static int Metadata_init(PyObject *self, PyObject *args, PyObject *kwds) {
+
+ PyObject *binary_format_major_version, *binary_format_minor_version,
+ *build_epoch, *database_type, *description, *ip_version, *languages,
+ *node_count, *record_size;
+
+ static char *kwlist[] = {"binary_format_major_version",
+ "binary_format_minor_version",
+ "build_epoch",
+ "database_type",
+ "description",
+ "ip_version",
+ "languages",
+ "node_count",
+ "record_size",
+ NULL};
+
+ if (!PyArg_ParseTupleAndKeywords(args,
+ kwds,
+ "|OOOOOOOOO",
+ kwlist,
+ &binary_format_major_version,
+ &binary_format_minor_version,
+ &build_epoch,
+ &database_type,
+ &description,
+ &ip_version,
+ &languages,
+ &node_count,
+ &record_size)) {
+ return -1;
+ }
+
+ Metadata_obj *obj = (Metadata_obj *)self;
+
+ obj->binary_format_major_version = binary_format_major_version;
+ obj->binary_format_minor_version = binary_format_minor_version;
+ obj->build_epoch = build_epoch;
+ obj->database_type = database_type;
+ obj->description = description;
+ obj->ip_version = ip_version;
+ obj->languages = languages;
+ obj->node_count = node_count;
+ obj->record_size = record_size;
+
+ Py_INCREF(obj->binary_format_major_version);
+ Py_INCREF(obj->binary_format_minor_version);
+ Py_INCREF(obj->build_epoch);
+ Py_INCREF(obj->database_type);
+ Py_INCREF(obj->description);
+ Py_INCREF(obj->ip_version);
+ Py_INCREF(obj->languages);
+ Py_INCREF(obj->node_count);
+ Py_INCREF(obj->record_size);
+
+ return 0;
+}
+
+static void Metadata_dealloc(PyObject *self) {
+ Metadata_obj *obj = (Metadata_obj *)self;
+ Py_DECREF(obj->binary_format_major_version);
+ Py_DECREF(obj->binary_format_minor_version);
+ Py_DECREF(obj->build_epoch);
+ Py_DECREF(obj->database_type);
+ Py_DECREF(obj->description);
+ Py_DECREF(obj->ip_version);
+ Py_DECREF(obj->languages);
+ Py_DECREF(obj->node_count);
+ Py_DECREF(obj->record_size);
+ PyObject_Del(self);
+}
+
+static PyObject *
+from_entry_data_list(MMDB_entry_data_list_s **entry_data_list) {
+ if (entry_data_list == NULL || *entry_data_list == NULL) {
+ PyErr_SetString(MaxMindDB_error,
+ "Error while looking up data. Your database may be "
+ "corrupt or you have found a bug in libmaxminddb.");
+ return NULL;
+ }
+
+ switch ((*entry_data_list)->entry_data.type) {
+ case MMDB_DATA_TYPE_MAP:
+ return from_map(entry_data_list);
+ case MMDB_DATA_TYPE_ARRAY:
+ return from_array(entry_data_list);
+ case MMDB_DATA_TYPE_UTF8_STRING:
+ return PyUnicode_FromStringAndSize(
+ (*entry_data_list)->entry_data.utf8_string,
+ (*entry_data_list)->entry_data.data_size);
+ case MMDB_DATA_TYPE_BYTES:
+ return PyByteArray_FromStringAndSize(
+ (const char *)(*entry_data_list)->entry_data.bytes,
+ (Py_ssize_t)(*entry_data_list)->entry_data.data_size);
+ case MMDB_DATA_TYPE_DOUBLE:
+ return PyFloat_FromDouble(
+ (*entry_data_list)->entry_data.double_value);
+ case MMDB_DATA_TYPE_FLOAT:
+ return PyFloat_FromDouble(
+ (*entry_data_list)->entry_data.float_value);
+ case MMDB_DATA_TYPE_UINT16:
+ return PyLong_FromLong((*entry_data_list)->entry_data.uint16);
+ case MMDB_DATA_TYPE_UINT32:
+ return PyLong_FromLong((*entry_data_list)->entry_data.uint32);
+ case MMDB_DATA_TYPE_BOOLEAN:
+ return PyBool_FromLong((*entry_data_list)->entry_data.boolean);
+ case MMDB_DATA_TYPE_UINT64:
+ return PyLong_FromUnsignedLongLong(
+ (*entry_data_list)->entry_data.uint64);
+ case MMDB_DATA_TYPE_UINT128:
+ return from_uint128(*entry_data_list);
+ case MMDB_DATA_TYPE_INT32:
+ return PyLong_FromLong((*entry_data_list)->entry_data.int32);
+ default:
+ PyErr_Format(MaxMindDB_error,
+ "Invalid data type arguments: %d",
+ (*entry_data_list)->entry_data.type);
+ return NULL;
+ }
+ return NULL;
+}
+
+static PyObject *from_map(MMDB_entry_data_list_s **entry_data_list) {
+ PyObject *py_obj = PyDict_New();
+ if (py_obj == NULL) {
+ PyErr_NoMemory();
+ return NULL;
+ }
+
+ const uint32_t map_size = (*entry_data_list)->entry_data.data_size;
+
+ uint32_t i;
+ for (i = 0; i < map_size && *entry_data_list; i++) {
+ *entry_data_list = (*entry_data_list)->next;
+
+ PyObject *key = PyUnicode_FromStringAndSize(
+ (*entry_data_list)->entry_data.utf8_string,
+ (*entry_data_list)->entry_data.data_size);
+ if (!key) {
+ // PyUnicode_FromStringAndSize will set an appropriate exception
+ // in this case.
+ return NULL;
+ }
+
+ *entry_data_list = (*entry_data_list)->next;
+
+ PyObject *value = from_entry_data_list(entry_data_list);
+ if (value == NULL) {
+ Py_DECREF(key);
+ Py_DECREF(py_obj);
+ return NULL;
+ }
+ PyDict_SetItem(py_obj, key, value);
+ Py_DECREF(value);
+ Py_DECREF(key);
+ }
+
+ return py_obj;
+}
+
+static PyObject *from_array(MMDB_entry_data_list_s **entry_data_list) {
+ const uint32_t size = (*entry_data_list)->entry_data.data_size;
+
+ PyObject *py_obj = PyList_New(size);
+ if (py_obj == NULL) {
+ PyErr_NoMemory();
+ return NULL;
+ }
+
+ uint32_t i;
+ for (i = 0; i < size && *entry_data_list; i++) {
+ *entry_data_list = (*entry_data_list)->next;
+ PyObject *value = from_entry_data_list(entry_data_list);
+ if (value == NULL) {
+ Py_DECREF(py_obj);
+ return NULL;
+ }
+ // PyList_SetItem 'steals' the reference
+ PyList_SetItem(py_obj, i, value);
+ }
+ return py_obj;
+}
+
+static PyObject *from_uint128(const MMDB_entry_data_list_s *entry_data_list) {
+ uint64_t high = 0;
+ uint64_t low = 0;
+#if MMDB_UINT128_IS_BYTE_ARRAY
+ int i;
+ for (i = 0; i < 8; i++) {
+ high = (high << 8) | entry_data_list->entry_data.uint128[i];
+ }
+
+ for (i = 8; i < 16; i++) {
+ low = (low << 8) | entry_data_list->entry_data.uint128[i];
+ }
+#else
+ high = entry_data_list->entry_data.uint128 >> 64;
+ low = (uint64_t)entry_data_list->entry_data.uint128;
+#endif
+
+ char *num_str = malloc(33);
+ if (num_str == NULL) {
+ PyErr_NoMemory();
+ return NULL;
+ }
+
+ snprintf(num_str, 33, "%016" PRIX64 "%016" PRIX64, high, low);
+
+ PyObject *py_obj = PyLong_FromString(num_str, NULL, 16);
+
+ free(num_str);
+ return py_obj;
+}
+
+static bool can_read(const char *path) {
+#ifdef MS_WINDOWS
+ int rv = _access(path, 04);
+#else
+ int rv = access(path, R_OK);
+#endif
+
+ return rv == 0;
+}
+
+static PyMethodDef Reader_methods[] = {
+ {"get",
+ Reader_get,
+ METH_VARARGS,
+ "Return the record for the ip_address in the MaxMind DB"},
+ {"get_with_prefix_len",
+ Reader_get_with_prefix_len,
+ METH_VARARGS,
+ "Return a tuple with the record and the associated prefix length"},
+ {"metadata",
+ Reader_metadata,
+ METH_NOARGS,
+ "Return metadata object for database"},
+ {"close", Reader_close, METH_NOARGS, "Closes database"},
+ {"__exit__",
+ Reader__exit__,
+ METH_VARARGS,
+ "Called when exiting a with-context. Calls close"},
+ {"__enter__",
+ Reader__enter__,
+ METH_NOARGS,
+ "Called when entering a with-context."},
+ {NULL, NULL, 0, NULL}};
+
+static PyMemberDef Reader_members[] = {
+ {"closed", T_OBJECT, offsetof(Reader_obj, closed), READONLY, NULL},
+ {NULL, 0, 0, 0, NULL}};
+
+// clang-format off
+static PyTypeObject Reader_Type = {
+ PyVarObject_HEAD_INIT(NULL, 0)
+ .tp_basicsize = sizeof(Reader_obj),
+ .tp_dealloc = Reader_dealloc,
+ .tp_doc = "Reader object",
+ .tp_flags = Py_TPFLAGS_DEFAULT,
+ .tp_iter = Reader_iter,
+ .tp_methods = Reader_methods,
+ .tp_members = Reader_members,
+ .tp_name = "Reader",
+ .tp_init = Reader_init,
+};
+// clang-format on
+
+static PyMethodDef ReaderIter_methods[] = {{NULL, NULL, 0, NULL}};
+
+// clang-format off
+static PyTypeObject ReaderIter_Type = {
+ PyVarObject_HEAD_INIT(NULL, 0)
+ .tp_basicsize = sizeof(ReaderIter_obj),
+ .tp_dealloc = ReaderIter_dealloc,
+ .tp_doc = "Iterator for Reader object",
+ .tp_flags = Py_TPFLAGS_DEFAULT,
+ .tp_iter = PyObject_SelfIter,
+ .tp_iternext = ReaderIter_next,
+ .tp_methods = ReaderIter_methods,
+ .tp_name = "ReaderIter",
+};
+// clang-format on
+
+static PyMethodDef Metadata_methods[] = {{NULL, NULL, 0, NULL}};
+
+static PyMemberDef Metadata_members[] = {
+ {"binary_format_major_version",
+ T_OBJECT,
+ offsetof(Metadata_obj, binary_format_major_version),
+ READONLY,
+ NULL},
+ {"binary_format_minor_version",
+ T_OBJECT,
+ offsetof(Metadata_obj, binary_format_minor_version),
+ READONLY,
+ NULL},
+ {"build_epoch",
+ T_OBJECT,
+ offsetof(Metadata_obj, build_epoch),
+ READONLY,
+ NULL},
+ {"database_type",
+ T_OBJECT,
+ offsetof(Metadata_obj, database_type),
+ READONLY,
+ NULL},
+ {"description",
+ T_OBJECT,
+ offsetof(Metadata_obj, description),
+ READONLY,
+ NULL},
+ {"ip_version",
+ T_OBJECT,
+ offsetof(Metadata_obj, ip_version),
+ READONLY,
+ NULL},
+ {"languages", T_OBJECT, offsetof(Metadata_obj, languages), READONLY, NULL},
+ {"node_count",
+ T_OBJECT,
+ offsetof(Metadata_obj, node_count),
+ READONLY,
+ NULL},
+ {"record_size",
+ T_OBJECT,
+ offsetof(Metadata_obj, record_size),
+ READONLY,
+ NULL},
+ {NULL, 0, 0, 0, NULL}};
+
+// clang-format off
+static PyTypeObject Metadata_Type = {
+ PyVarObject_HEAD_INIT(NULL, 0)
+ .tp_basicsize = sizeof(Metadata_obj),
+ .tp_dealloc = Metadata_dealloc,
+ .tp_doc = "Metadata object",
+ .tp_flags = Py_TPFLAGS_DEFAULT,
+ .tp_members = Metadata_members,
+ .tp_methods = Metadata_methods,
+ .tp_name = "Metadata",
+ .tp_init = Metadata_init};
+// clang-format on
+
+static PyMethodDef MaxMindDB_methods[] = {{NULL, NULL, 0, NULL}};
+
+static struct PyModuleDef MaxMindDB_module = {
+ PyModuleDef_HEAD_INIT,
+ .m_name = "extension",
+ .m_doc = "This is a C extension to read MaxMind DB file format",
+ .m_methods = MaxMindDB_methods,
+};
+
+PyMODINIT_FUNC PyInit_extension(void) {
+ PyObject *m;
+
+ m = PyModule_Create(&MaxMindDB_module);
+
+ if (!m) {
+ return NULL;
+ }
+
+ Reader_Type.tp_new = PyType_GenericNew;
+ if (PyType_Ready(&Reader_Type)) {
+ return NULL;
+ }
+ Py_INCREF(&Reader_Type);
+ PyModule_AddObject(m, "Reader", (PyObject *)&Reader_Type);
+
+ Metadata_Type.tp_new = PyType_GenericNew;
+ if (PyType_Ready(&Metadata_Type)) {
+ return NULL;
+ }
+ Py_INCREF(&Metadata_Type);
+ PyModule_AddObject(m, "Metadata", (PyObject *)&Metadata_Type);
+
+ PyObject *error_mod = PyImport_ImportModule("maxminddb.errors");
+ if (error_mod == NULL) {
+ return NULL;
+ }
+
+ MaxMindDB_error = PyObject_GetAttrString(error_mod, "InvalidDatabaseError");
+ Py_DECREF(error_mod);
+
+ if (MaxMindDB_error == NULL) {
+ return NULL;
+ }
+ Py_INCREF(MaxMindDB_error);
+
+ PyObject *ipaddress_mod = PyImport_ImportModule("ipaddress");
+ if (ipaddress_mod == NULL) {
+ return NULL;
+ }
+
+ ipaddress_ip_network = PyObject_GetAttrString(ipaddress_mod, "ip_network");
+ Py_DECREF(ipaddress_mod);
+
+ if (ipaddress_ip_network == NULL) {
+ return NULL;
+ }
+ Py_INCREF(ipaddress_ip_network);
+
+ /* We primarily add it to the module for backwards compatibility */
+ PyModule_AddObject(m, "InvalidDatabaseError", MaxMindDB_error);
+
+ return m;
+}
diff --git a/extension/maxminddb_config.h b/extension/maxminddb_config.h
new file mode 100644
index 0000000..880b934
--- /dev/null
+++ b/extension/maxminddb_config.h
@@ -0,0 +1 @@
+/* This is just a placeholder. We configure everything in setup.py. */
diff --git a/maxminddb/__init__.py b/maxminddb/__init__.py
index 7c6008b..4532f6f 100644
--- a/maxminddb/__init__.py
+++ b/maxminddb/__init__.py
@@ -1,46 +1,94 @@
-# pylint:disable=C0111
-import os
+"""Module for reading MaxMind DB files."""
-import maxminddb.reader
+from __future__ import annotations
+
+from typing import IO, TYPE_CHECKING, AnyStr, cast
+
+from .const import (
+ MODE_AUTO,
+ MODE_FD,
+ MODE_FILE,
+ MODE_MEMORY,
+ MODE_MMAP,
+ MODE_MMAP_EXT,
+)
+from .decoder import InvalidDatabaseError
+from .reader import Reader
+
+if TYPE_CHECKING:
+ import os
try:
- import maxminddb.extension
+ from . import extension as _extension
except ImportError:
- maxminddb.extension = None
+ _extension = None # type: ignore[assignment]
-from maxminddb.const import (MODE_AUTO, MODE_MMAP, MODE_MMAP_EXT, MODE_FILE,
- MODE_MEMORY)
-from maxminddb.decoder import InvalidDatabaseError
+__all__ = [
+ "MODE_AUTO",
+ "MODE_FD",
+ "MODE_FILE",
+ "MODE_MEMORY",
+ "MODE_MMAP",
+ "MODE_MMAP_EXT",
+ "InvalidDatabaseError",
+ "Reader",
+ "open_database",
+]
-def open_database(database, mode=MODE_AUTO):
- """Open a Maxmind DB database
+
+def open_database(
+ database: AnyStr | int | os.PathLike | IO,
+ mode: int = MODE_AUTO,
+) -> Reader:
+ """Open a MaxMind DB database.
Arguments:
- database -- A path to a valid MaxMind DB file such as a GeoIP2
- database file.
- mode -- mode to open the database with. Valid mode are:
- * MODE_MMAP_EXT - use the C extension with memory map.
- * MODE_MMAP - read from memory map. Pure Python.
- * MODE_FILE - read database as standard file. Pure Python.
- * MODE_MEMORY - load database into memory. Pure Python.
- * MODE_AUTO - tries MODE_MMAP_EXT, MODE_MMAP, MODE_FILE in that
+ database: A path to a valid MaxMind DB file such as a GeoIP2 database
+ file, or a file descriptor in the case of MODE_FD.
+ mode: mode to open the database with. Valid mode are:
+ * MODE_MMAP_EXT - use the C extension with memory map.
+ * MODE_MMAP - read from memory map. Pure Python.
+ * MODE_FILE - read database as standard file. Pure Python.
+ * MODE_MEMORY - load database into memory. Pure Python.
+ * MODE_FD - the param passed via database is a file descriptor, not
+ a path. This mode implies MODE_MEMORY.
+ * MODE_AUTO - tries MODE_MMAP_EXT, MODE_MMAP, MODE_FILE in that
order. Default mode.
+
"""
- if (mode == MODE_AUTO and maxminddb.extension and
- hasattr(maxminddb.extension, 'Reader')) or mode == MODE_MMAP_EXT:
- return maxminddb.extension.Reader(database)
- elif mode in (MODE_AUTO, MODE_MMAP, MODE_FILE, MODE_MEMORY):
- return maxminddb.reader.Reader(database, mode)
- raise ValueError('Unsupported open mode: {0}'.format(mode))
-
-
-def Reader(database): # pylint: disable=invalid-name
- """This exists for backwards compatibility. Use open_database instead"""
- return open_database(database)
-
-__title__ = 'maxminddb'
-__version__ = '1.2.1'
-__author__ = 'Gregory Oschwald'
-__license__ = 'Apache License, Version 2.0'
-__copyright__ = 'Copyright 2014 Maxmind, Inc.'
+ if mode not in (
+ MODE_AUTO,
+ MODE_FD,
+ MODE_FILE,
+ MODE_MEMORY,
+ MODE_MMAP,
+ MODE_MMAP_EXT,
+ ):
+ msg = f"Unsupported open mode: {mode}"
+ raise ValueError(msg)
+
+ has_extension = _extension and hasattr(_extension, "Reader")
+ use_extension = has_extension if mode == MODE_AUTO else mode == MODE_MMAP_EXT
+
+ if not use_extension:
+ return Reader(database, mode)
+
+ if not has_extension:
+ msg = "MODE_MMAP_EXT requires the maxminddb.extension module to be available"
+ raise ValueError(
+ msg,
+ )
+
+ # The C type exposes the same API as the Python Reader, so for type
+ # checking purposes, pretend it is one. (Ideally this would be a subclass
+ # of, or share a common parent class with, the Python Reader
+ # implementation.)
+ return cast("Reader", _extension.Reader(database, mode))
+
+
+__title__ = "maxminddb"
+__version__ = "2.7.0"
+__author__ = "Gregory Oschwald"
+__license__ = "Apache License, Version 2.0"
+__copyright__ = "Copyright 2013-2025 MaxMind, Inc."
diff --git a/maxminddb/compat.py b/maxminddb/compat.py
deleted file mode 100644
index 8e2a81c..0000000
--- a/maxminddb/compat.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import sys
-
-import ipaddress
-
-# pylint: skip-file
-
-if sys.version_info[0] == 2:
- def compat_ip_address(address):
- if isinstance(address, bytes):
- address = address.decode()
- return ipaddress.ip_address(address)
-
- int_from_byte = ord
-
- FileNotFoundError = IOError
-
- def int_from_bytes(b):
- if b:
- return int(b.encode("hex"), 16)
- return 0
-
- byte_from_int = chr
-else:
- def compat_ip_address(address):
- return ipaddress.ip_address(address)
-
- int_from_byte = lambda x: x
-
- FileNotFoundError = FileNotFoundError
-
- int_from_bytes = lambda x: int.from_bytes(x, 'big')
-
- byte_from_int = lambda x: bytes([x])
diff --git a/maxminddb/const.py b/maxminddb/const.py
index 59ea84b..959078d 100644
--- a/maxminddb/const.py
+++ b/maxminddb/const.py
@@ -1,7 +1,8 @@
-"""Constants used in the API"""
+"""Constants used in the API."""
MODE_AUTO = 0
MODE_MMAP_EXT = 1
MODE_MMAP = 2
MODE_FILE = 4
MODE_MEMORY = 8
+MODE_FD = 16
diff --git a/maxminddb/decoder.py b/maxminddb/decoder.py
index e8f223a..069c3c8 100644
--- a/maxminddb/decoder.py
+++ b/maxminddb/decoder.py
@@ -1,173 +1,207 @@
-"""
-maxminddb.decoder
-~~~~~~~~~~~~~~~~~
+"""Decoder for the MaxMind DB data section."""
-This package contains code for decoding the MaxMind DB data section.
+import struct
+from typing import ClassVar, Union, cast
-"""
-from __future__ import unicode_literals
+try:
+ import mmap
+except ImportError:
+ mmap = None # type: ignore[assignment]
-import struct
-from maxminddb.compat import byte_from_int, int_from_bytes
from maxminddb.errors import InvalidDatabaseError
+from maxminddb.file import FileBuffer
+from maxminddb.types import Record
-class Decoder(object): # pylint: disable=too-few-public-methods
+class Decoder:
+ """Decoder for the data section of the MaxMind DB."""
- """Decoder for the data section of the MaxMind DB"""
-
- def __init__(self, database_buffer, pointer_base=0, pointer_test=False):
- """Created a Decoder for a MaxMind DB
+ def __init__(
+ self,
+ database_buffer: Union[FileBuffer, "mmap.mmap", bytes],
+ pointer_base: int = 0,
+ pointer_test: bool = False, # noqa: FBT001, FBT002
+ ) -> None:
+ """Create a Decoder for a MaxMind DB.
Arguments:
- database_buffer -- an mmap'd MaxMind DB file.
- pointer_base -- the base number to use when decoding a pointer
- pointer_test -- used for internal unit testing of pointer code
+ database_buffer: an mmap'd MaxMind DB file.
+ pointer_base: the base number to use when decoding a pointer
+ pointer_test: used for internal unit testing of pointer code
+
"""
self._pointer_test = pointer_test
self._buffer = database_buffer
self._pointer_base = pointer_base
- def _decode_array(self, size, offset):
+ def _decode_array(self, size: int, offset: int) -> tuple[list[Record], int]:
array = []
for _ in range(size):
(value, offset) = self.decode(offset)
array.append(value)
return array, offset
- def _decode_boolean(self, size, offset):
+ def _decode_boolean(self, size: int, offset: int) -> tuple[bool, int]:
return size != 0, offset
- def _decode_bytes(self, size, offset):
+ def _decode_bytes(self, size: int, offset: int) -> tuple[bytes, int]:
new_offset = offset + size
return self._buffer[offset:new_offset], new_offset
- # pylint: disable=no-self-argument
- # |-> I am open to better ways of doing this as long as it doesn't involve
- # lots of code duplication.
- def _decode_packed_type(type_code, type_size, pad=False):
- # pylint: disable=protected-access, missing-docstring
- def unpack_type(self, size, offset):
- if not pad:
- self._verify_size(size, type_size)
- new_offset = offset + type_size
- packed_bytes = self._buffer[offset:new_offset]
- if pad:
- packed_bytes = packed_bytes.rjust(type_size, b'\x00')
- (value,) = struct.unpack(type_code, packed_bytes)
- return value, new_offset
- return unpack_type
-
- def _decode_map(self, size, offset):
- container = {}
+ def _decode_double(self, size: int, offset: int) -> tuple[float, int]:
+ self._verify_size(size, 8)
+ new_offset = offset + size
+ packed_bytes = self._buffer[offset:new_offset]
+ (value,) = struct.unpack(b"!d", packed_bytes)
+ return value, new_offset
+
+ def _decode_float(self, size: int, offset: int) -> tuple[float, int]:
+ self._verify_size(size, 4)
+ new_offset = offset + size
+ packed_bytes = self._buffer[offset:new_offset]
+ (value,) = struct.unpack(b"!f", packed_bytes)
+ return value, new_offset
+
+ def _decode_int32(self, size: int, offset: int) -> tuple[int, int]:
+ if size == 0:
+ return 0, offset
+ new_offset = offset + size
+ packed_bytes = self._buffer[offset:new_offset]
+
+ if size != 4:
+ packed_bytes = packed_bytes.rjust(4, b"\x00")
+ (value,) = struct.unpack(b"!i", packed_bytes)
+ return value, new_offset
+
+ def _decode_map(self, size: int, offset: int) -> tuple[dict[str, Record], int]:
+ container: dict[str, Record] = {}
for _ in range(size):
(key, offset) = self.decode(offset)
(value, offset) = self.decode(offset)
- container[key] = value
+ container[cast("str", key)] = value
return container, offset
- _pointer_value_offset = {
- 1: 0,
- 2: 2048,
- 3: 526336,
- 4: 0,
- }
+ def _decode_pointer(self, size: int, offset: int) -> tuple[Record, int]:
+ pointer_size = (size >> 3) + 1
- def _decode_pointer(self, size, offset):
- pointer_size = ((size >> 3) & 0x3) + 1
+ buf = self._buffer[offset : offset + pointer_size]
new_offset = offset + pointer_size
- pointer_bytes = self._buffer[offset:new_offset]
- packed = pointer_bytes if pointer_size == 4 else struct.pack(
- b'!c', byte_from_int(size & 0x7)) + pointer_bytes
- unpacked = int_from_bytes(packed)
- pointer = unpacked + self._pointer_base + \
- self._pointer_value_offset[pointer_size]
+
+ if pointer_size == 1:
+ buf = bytes([size & 0x7]) + buf
+ pointer = struct.unpack(b"!H", buf)[0] + self._pointer_base
+ elif pointer_size == 2:
+ buf = b"\x00" + bytes([size & 0x7]) + buf
+ pointer = struct.unpack(b"!I", buf)[0] + 2048 + self._pointer_base
+ elif pointer_size == 3:
+ buf = bytes([size & 0x7]) + buf
+ pointer = struct.unpack(b"!I", buf)[0] + 526336 + self._pointer_base
+ else:
+ pointer = struct.unpack(b"!I", buf)[0] + self._pointer_base
+
if self._pointer_test:
return pointer, new_offset
(value, _) = self.decode(pointer)
return value, new_offset
- def _decode_uint(self, size, offset):
+ def _decode_uint(self, size: int, offset: int) -> tuple[int, int]:
new_offset = offset + size
uint_bytes = self._buffer[offset:new_offset]
- return int_from_bytes(uint_bytes), new_offset
+ return int.from_bytes(uint_bytes, "big"), new_offset
- def _decode_utf8_string(self, size, offset):
+ def _decode_utf8_string(self, size: int, offset: int) -> tuple[str, int]:
new_offset = offset + size
- return self._buffer[offset:new_offset].decode('utf-8'), new_offset
+ return self._buffer[offset:new_offset].decode("utf-8"), new_offset
- _type_decoder = {
+ _type_decoder: ClassVar = {
1: _decode_pointer,
2: _decode_utf8_string,
- 3: _decode_packed_type(b'!d', 8), # double,
+ 3: _decode_double,
4: _decode_bytes,
5: _decode_uint, # uint16
6: _decode_uint, # uint32
7: _decode_map,
- 8: _decode_packed_type(b'!i', 4, pad=True), # int32
+ 8: _decode_int32,
9: _decode_uint, # uint64
10: _decode_uint, # uint128
11: _decode_array,
14: _decode_boolean,
- 15: _decode_packed_type(b'!f', 4), # float,
+ 15: _decode_float,
}
- def decode(self, offset):
- """Decode a section of the data section starting at offset
+ def decode(self, offset: int) -> tuple[Record, int]:
+ """Decode a section of the data section starting at offset.
Arguments:
- offset -- the location of the data structure to decode
+ offset: the location of the data structure to decode
+
"""
new_offset = offset + 1
- (ctrl_byte,) = struct.unpack(b'!B', self._buffer[offset:new_offset])
+ ctrl_byte = self._buffer[offset]
type_num = ctrl_byte >> 5
# Extended type
if not type_num:
(type_num, new_offset) = self._read_extended(new_offset)
- if type_num not in self._type_decoder:
- raise InvalidDatabaseError('Unexpected type number ({type}) '
- 'encountered'.format(type=type_num))
+ try:
+ decoder = self._type_decoder[type_num]
+ except KeyError as ex:
+ msg = f"Unexpected type number ({type_num}) encountered"
+ raise InvalidDatabaseError(
+ msg,
+ ) from ex
- (size, new_offset) = self._size_from_ctrl_byte(
- ctrl_byte, new_offset, type_num)
- return self._type_decoder[type_num](self, size, new_offset)
+ (size, new_offset) = self._size_from_ctrl_byte(ctrl_byte, new_offset, type_num)
+ return decoder(self, size, new_offset)
- def _read_extended(self, offset):
- (next_byte,) = struct.unpack(b'!B', self._buffer[offset:offset + 1])
+ def _read_extended(self, offset: int) -> tuple[int, int]:
+ next_byte = self._buffer[offset]
type_num = next_byte + 7
if type_num < 7:
+ msg = (
+ "Something went horribly wrong in the decoder. An "
+ f"extended type resolved to a type number < 8 ({type_num})"
+ )
raise InvalidDatabaseError(
- 'Something went horribly wrong in the decoder. An '
- 'extended type resolved to a type number < 8 '
- '({type})'.format(type=type_num))
+ msg,
+ )
return type_num, offset + 1
- def _verify_size(self, expected, actual):
+ @staticmethod
+ def _verify_size(expected: int, actual: int) -> None:
if expected != actual:
+ msg = (
+ "The MaxMind DB file's data section contains bad data "
+ "(unknown data type or corrupt data)"
+ )
raise InvalidDatabaseError(
- 'The MaxMind DB file\'s data section contains bad data '
- '(unknown data type or corrupt data)'
+ msg,
)
- def _size_from_ctrl_byte(self, ctrl_byte, offset, type_num):
- size = ctrl_byte & 0x1f
- if type_num == 1:
+ def _size_from_ctrl_byte(
+ self,
+ ctrl_byte: int,
+ offset: int,
+ type_num: int,
+ ) -> tuple[int, int]:
+ size = ctrl_byte & 0x1F
+ if type_num == 1 or size < 29:
return size, offset
- bytes_to_read = 0 if size < 29 else size - 28
-
- new_offset = offset + bytes_to_read
- size_bytes = self._buffer[offset:new_offset]
- # Using unpack rather than int_from_bytes as it is about 200 lookups
- # per second faster here.
if size == 29:
- size = 29 + struct.unpack(b'!B', size_bytes)[0]
- elif size == 30:
- size = 285 + struct.unpack(b'!H', size_bytes)[0]
- elif size > 30:
- size = struct.unpack(
- b'!I', size_bytes.rjust(4, b'\x00'))[0] + 65821
-
+ size = 29 + self._buffer[offset]
+ return size, offset + 1
+
+ # Using unpack rather than int_from_bytes as it is faster
+ # here and below.
+ if size == 30:
+ new_offset = offset + 2
+ size_bytes = self._buffer[offset:new_offset]
+ size = 285 + struct.unpack(b"!H", size_bytes)[0]
+ return size, new_offset
+
+ new_offset = offset + 3
+ size_bytes = self._buffer[offset:new_offset]
+ size = struct.unpack(b"!I", b"\x00" + size_bytes)[0] + 65821
return size, new_offset
diff --git a/maxminddb/errors.py b/maxminddb/errors.py
index f04ff02..b87bc8d 100644
--- a/maxminddb/errors.py
+++ b/maxminddb/errors.py
@@ -1,11 +1,5 @@
-"""
-maxminddb.errors
-~~~~~~~~~~~~~~~~
-
-This module contains custom errors for the MaxMind DB reader
-"""
+"""Typed errors thrown by this library."""
class InvalidDatabaseError(RuntimeError):
-
- """This error is thrown when unexpected data is found in the database."""
+ """An error thrown when unexpected data is found in the database."""
diff --git a/maxminddb/extension.pyi b/maxminddb/extension.pyi
new file mode 100644
index 0000000..ca1b983
--- /dev/null
+++ b/maxminddb/extension.pyi
@@ -0,0 +1,115 @@
+"""C extension database reader and related classes."""
+
+from ipaddress import IPv4Address, IPv6Address
+from os import PathLike
+from typing import IO, Any, AnyStr
+
+from typing_extensions import Self
+
+from maxminddb.types import Record
+
+class Reader:
+ """A C extension implementation of a reader for the MaxMind DB format.
+
+ IP addresses can be looked up using the ``get`` method.
+ """
+
+ closed: bool = ...
+
+ def __init__(
+ self,
+ database: AnyStr | int | PathLike | IO,
+ mode: int = ...,
+ ) -> None:
+ """Reader for the MaxMind DB file format.
+
+ Arguments:
+ database: A path to a valid MaxMind DB file such as a GeoIP2 database
+ file, or a file descriptor in the case of MODE_FD.
+ mode: mode to open the database with. The only supported modes are
+ MODE_AUTO and MODE_MMAP_EXT.
+
+ """
+
+ def close(self) -> None:
+ """Close the MaxMind DB file and returns the resources to the system."""
+
+ def get(self, ip_address: str | IPv6Address | IPv4Address) -> Record | None:
+ """Return the record for the ip_address in the MaxMind DB.
+
+ Arguments:
+ ip_address: an IP address in the standard string notation
+
+ """
+
+ def get_with_prefix_len(
+ self,
+ ip_address: str | IPv6Address | IPv4Address,
+ ) -> tuple[Record | None, int]:
+ """Return a tuple with the record and the associated prefix length.
+
+ Arguments:
+ ip_address: an IP address in the standard string notation
+
+ """
+
+ def metadata(self) -> Metadata:
+ """Return the metadata associated with the MaxMind DB file."""
+
+ def __enter__(self) -> Self: ...
+ def __exit__(self, *args) -> None: ... # noqa: ANN002
+
+class Metadata:
+ """Metadata for the MaxMind DB reader."""
+
+ binary_format_major_version: int
+ """
+ The major version number of the binary format used when creating the
+ database.
+ """
+
+ binary_format_minor_version: int
+ """
+ The minor version number of the binary format used when creating the
+ database.
+ """
+
+ build_epoch: int
+ """
+ The Unix epoch for the build time of the database.
+ """
+
+ database_type: str
+ """
+ A string identifying the database type, e.g., "GeoIP2-City".
+ """
+
+ description: dict[str, str]
+ """
+ A map from locales to text descriptions of the database.
+ """
+
+ ip_version: int
+ """
+ The IP version of the data in a database. A value of "4" means the
+ database only supports IPv4. A database with a value of "6" may support
+ both IPv4 and IPv6 lookups.
+ """
+
+ languages: list[str]
+ """
+ A list of locale codes supported by the database.
+ """
+
+ node_count: int
+ """
+ The number of nodes in the database.
+ """
+
+ record_size: int
+ """
+ The bit size of a record in the search tree.
+ """
+
+ def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
+ """Create new Metadata object. kwargs are key/value pairs from spec."""
diff --git a/maxminddb/extension/maxminddb.c b/maxminddb/extension/maxminddb.c
deleted file mode 100644
index 9e4d45e..0000000
--- a/maxminddb/extension/maxminddb.c
+++ /dev/null
@@ -1,570 +0,0 @@
-#include
-#include
-#include "structmember.h"
-
-#define __STDC_FORMAT_MACROS
-#include
-
-static PyTypeObject Reader_Type;
-static PyTypeObject Metadata_Type;
-static PyObject *MaxMindDB_error;
-
-typedef struct {
- PyObject_HEAD /* no semicolon */
- MMDB_s *mmdb;
-} Reader_obj;
-
-typedef struct {
- PyObject_HEAD /* no semicolon */
- PyObject *binary_format_major_version;
- PyObject *binary_format_minor_version;
- PyObject *build_epoch;
- PyObject *database_type;
- PyObject *description;
- PyObject *ip_version;
- PyObject *languages;
- PyObject *node_count;
- PyObject *record_size;
-} Metadata_obj;
-
-static PyObject *from_entry_data_list(MMDB_entry_data_list_s **entry_data_list);
-static PyObject *from_map(MMDB_entry_data_list_s **entry_data_list);
-static PyObject *from_array(MMDB_entry_data_list_s **entry_data_list);
-static PyObject *from_uint128(const MMDB_entry_data_list_s *entry_data_list);
-
-#if PY_MAJOR_VERSION >= 3
- #define MOD_INIT(name) PyMODINIT_FUNC PyInit_ ## name(void)
- #define RETURN_MOD_INIT(m) return (m)
- #define FILE_NOT_FOUND_ERROR PyExc_FileNotFoundError
-#else
- #define MOD_INIT(name) PyMODINIT_FUNC init ## name(void)
- #define RETURN_MOD_INIT(m) return
- #define PyInt_FromLong PyLong_FromLong
- #define FILE_NOT_FOUND_ERROR PyExc_IOError
-#endif
-
-#ifdef __GNUC__
- # define UNUSED(x) UNUSED_ ## x __attribute__((__unused__))
-#else
- # define UNUSED(x) UNUSED_ ## x
-#endif
-
-static int Reader_init(PyObject *self, PyObject *args, PyObject *kwds)
-{
- char *filename;
- int mode = 0;
-
- static char *kwlist[] = {"database", "mode", NULL};
- if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|i", kwlist, &filename, &mode)) {
- return -1;
- }
-
- if (mode != 0 && mode != 1) {
- PyErr_Format(PyExc_ValueError, "Unsupported open mode (%i). Only "
- "MODE_AUTO and MODE_MMAP_EXT are supported by this extension.",
- mode);
- return -1;
- }
-
- if (0 != access(filename, R_OK)) {
- PyErr_Format(FILE_NOT_FOUND_ERROR,
- "No such file or directory: '%s'",
- filename);
- return -1;
- }
-
- MMDB_s *mmdb = (MMDB_s *)malloc(sizeof(MMDB_s));
- if (NULL == mmdb) {
- PyErr_NoMemory();
- return -1;
- }
-
- Reader_obj *mmdb_obj = (Reader_obj *)self;
- if (!mmdb_obj) {
- free(mmdb);
- PyErr_NoMemory();
- return -1;
- }
-
- uint16_t status = MMDB_open(filename, MMDB_MODE_MMAP, mmdb);
-
- if (MMDB_SUCCESS != status) {
- free(mmdb);
- PyErr_Format(
- MaxMindDB_error,
- "Error opening database file (%s). Is this a valid MaxMind DB file?",
- filename
- );
- return -1;
- }
-
- mmdb_obj->mmdb = mmdb;
- return 0;
-}
-
-static PyObject *Reader_get(PyObject *self, PyObject *args)
-{
- char *ip_address = NULL;
-
- Reader_obj *mmdb_obj = (Reader_obj *)self;
- if (!PyArg_ParseTuple(args, "s", &ip_address)) {
- return NULL;
- }
-
- MMDB_s *mmdb = mmdb_obj->mmdb;
-
- if (NULL == mmdb) {
- PyErr_SetString(PyExc_ValueError,
- "Attempt to read from a closed MaxMind DB.");
- return NULL;
- }
-
- int gai_error = 0;
- int mmdb_error = MMDB_SUCCESS;
- MMDB_lookup_result_s result =
- MMDB_lookup_string(mmdb, ip_address, &gai_error,
- &mmdb_error);
-
- if (0 != gai_error) {
- PyErr_Format(PyExc_ValueError,
- "'%s' does not appear to be an IPv4 or IPv6 address.",
- ip_address);
- return NULL;
- }
-
- if (MMDB_SUCCESS != mmdb_error) {
- PyObject *exception;
- if (MMDB_IPV6_LOOKUP_IN_IPV4_DATABASE_ERROR == mmdb_error) {
- exception = PyExc_ValueError;
- } else {
- exception = MaxMindDB_error;
- }
- PyErr_Format(exception, "Error looking up %s. %s",
- ip_address, MMDB_strerror(mmdb_error));
- return NULL;
- }
-
- if (!result.found_entry) {
- Py_RETURN_NONE;
- }
-
- MMDB_entry_data_list_s *entry_data_list = NULL;
- int status = MMDB_get_entry_data_list(&result.entry, &entry_data_list);
- if (MMDB_SUCCESS != status) {
- PyErr_Format(MaxMindDB_error,
- "Error while looking up data for %s. %s",
- ip_address, MMDB_strerror(status));
- MMDB_free_entry_data_list(entry_data_list);
- return NULL;
- }
-
- MMDB_entry_data_list_s *original_entry_data_list = entry_data_list;
- PyObject *py_obj = from_entry_data_list(&entry_data_list);
- MMDB_free_entry_data_list(original_entry_data_list);
- return py_obj;
-}
-
-static PyObject *Reader_metadata(PyObject *self, PyObject *UNUSED(args))
-{
- Reader_obj *mmdb_obj = (Reader_obj *)self;
-
- if (NULL == mmdb_obj->mmdb) {
- PyErr_SetString(PyExc_IOError,
- "Attempt to read from a closed MaxMind DB.");
- return NULL;
- }
-
- MMDB_entry_data_list_s *entry_data_list;
- MMDB_get_metadata_as_entry_data_list(mmdb_obj->mmdb, &entry_data_list);
- MMDB_entry_data_list_s *original_entry_data_list = entry_data_list;
-
- PyObject *metadata_dict = from_entry_data_list(&entry_data_list);
- MMDB_free_entry_data_list(original_entry_data_list);
- if (NULL == metadata_dict || !PyDict_Check(metadata_dict)) {
- PyErr_SetString(MaxMindDB_error,
- "Error decoding metadata.");
- return NULL;
- }
-
- PyObject *args = PyTuple_New(0);
- if (NULL == args) {
- Py_DECREF(metadata_dict);
- return NULL;
- }
-
- PyObject *metadata = PyObject_Call((PyObject *)&Metadata_Type, args,
- metadata_dict);
-
- Py_DECREF(metadata_dict);
- return metadata;
-}
-
-static PyObject *Reader_close(PyObject *self, PyObject *UNUSED(args))
-{
- Reader_obj *mmdb_obj = (Reader_obj *)self;
-
- if (NULL != mmdb_obj->mmdb) {
- MMDB_close(mmdb_obj->mmdb);
- free(mmdb_obj->mmdb);
- mmdb_obj->mmdb = NULL;
- }
-
- Py_RETURN_NONE;
-}
-
-static void Reader_dealloc(PyObject *self)
-{
- Reader_obj *obj = (Reader_obj *)self;
- if (NULL != obj->mmdb) {
- Reader_close(self, NULL);
- }
-
- PyObject_Del(self);
-}
-
-static int Metadata_init(PyObject *self, PyObject *args, PyObject *kwds)
-{
-
- PyObject
- *binary_format_major_version,
- *binary_format_minor_version,
- *build_epoch,
- *database_type,
- *description,
- *ip_version,
- *languages,
- *node_count,
- *record_size;
-
- static char *kwlist[] = {
- "binary_format_major_version",
- "binary_format_minor_version",
- "build_epoch",
- "database_type",
- "description",
- "ip_version",
- "languages",
- "node_count",
- "record_size",
- NULL
- };
-
- if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOOOO", kwlist,
- &binary_format_major_version,
- &binary_format_minor_version,
- &build_epoch,
- &database_type,
- &description,
- &ip_version,
- &languages,
- &node_count,
- &record_size)) {
- return -1;
- }
-
- Metadata_obj *obj = (Metadata_obj *)self;
-
- obj->binary_format_major_version = binary_format_major_version;
- obj->binary_format_minor_version = binary_format_minor_version;
- obj->build_epoch = build_epoch;
- obj->database_type = database_type;
- obj->description = description;
- obj->ip_version = ip_version;
- obj->languages = languages;
- obj->node_count = node_count;
- obj->record_size = record_size;
-
- Py_INCREF(obj->binary_format_major_version);
- Py_INCREF(obj->binary_format_minor_version);
- Py_INCREF(obj->build_epoch);
- Py_INCREF(obj->database_type);
- Py_INCREF(obj->description);
- Py_INCREF(obj->ip_version);
- Py_INCREF(obj->languages);
- Py_INCREF(obj->node_count);
- Py_INCREF(obj->record_size);
-
- return 0;
-}
-
-static void Metadata_dealloc(PyObject *self)
-{
- Metadata_obj *obj = (Metadata_obj *)self;
- Py_DECREF(obj->binary_format_major_version);
- Py_DECREF(obj->binary_format_minor_version);
- Py_DECREF(obj->build_epoch);
- Py_DECREF(obj->database_type);
- Py_DECREF(obj->description);
- Py_DECREF(obj->ip_version);
- Py_DECREF(obj->languages);
- Py_DECREF(obj->node_count);
- Py_DECREF(obj->record_size);
- PyObject_Del(self);
-}
-
-static PyObject *from_entry_data_list(MMDB_entry_data_list_s **entry_data_list)
-{
- if (NULL == entry_data_list || NULL == *entry_data_list) {
- PyErr_SetString(
- MaxMindDB_error,
- "Error while looking up data. Your database may be corrupt or you have found a bug in libmaxminddb."
- );
- return NULL;
- }
-
- switch ((*entry_data_list)->entry_data.type) {
- case MMDB_DATA_TYPE_MAP:
- return from_map(entry_data_list);
- case MMDB_DATA_TYPE_ARRAY:
- return from_array(entry_data_list);
- case MMDB_DATA_TYPE_UTF8_STRING:
- return PyUnicode_FromStringAndSize(
- (*entry_data_list)->entry_data.utf8_string,
- (*entry_data_list)->entry_data.data_size
- );
- case MMDB_DATA_TYPE_BYTES:
- return PyByteArray_FromStringAndSize(
- (const char *)(*entry_data_list)->entry_data.bytes,
- (Py_ssize_t)(*entry_data_list)->entry_data.data_size);
- case MMDB_DATA_TYPE_DOUBLE:
- return PyFloat_FromDouble((*entry_data_list)->entry_data.double_value);
- case MMDB_DATA_TYPE_FLOAT:
- return PyFloat_FromDouble((*entry_data_list)->entry_data.float_value);
- case MMDB_DATA_TYPE_UINT16:
- return PyLong_FromLong( (*entry_data_list)->entry_data.uint16);
- case MMDB_DATA_TYPE_UINT32:
- return PyLong_FromLong((*entry_data_list)->entry_data.uint32);
- case MMDB_DATA_TYPE_BOOLEAN:
- return PyBool_FromLong((*entry_data_list)->entry_data.boolean);
- case MMDB_DATA_TYPE_UINT64:
- return PyLong_FromUnsignedLongLong(
- (*entry_data_list)->entry_data.uint64);
- case MMDB_DATA_TYPE_UINT128:
- return from_uint128(*entry_data_list);
- case MMDB_DATA_TYPE_INT32:
- return PyLong_FromLong((*entry_data_list)->entry_data.int32);
- default:
- PyErr_Format(MaxMindDB_error,
- "Invalid data type arguments: %d",
- (*entry_data_list)->entry_data.type);
- return NULL;
- }
- return NULL;
-}
-
-static PyObject *from_map(MMDB_entry_data_list_s **entry_data_list)
-{
- PyObject *py_obj = PyDict_New();
- if (NULL == py_obj) {
- PyErr_NoMemory();
- return NULL;
- }
-
- const uint32_t map_size = (*entry_data_list)->entry_data.data_size;
-
- uint i;
- // entry_data_list cannot start out NULL (see from_entry_data_list). We
- // check it in the loop because it may become NULL.
- // coverity[check_after_deref]
- for (i = 0; i < map_size && entry_data_list; i++) {
- *entry_data_list = (*entry_data_list)->next;
-
- PyObject *key = PyUnicode_FromStringAndSize(
- (char *)(*entry_data_list)->entry_data.utf8_string,
- (*entry_data_list)->entry_data.data_size
- );
-
- *entry_data_list = (*entry_data_list)->next;
-
- PyObject *value = from_entry_data_list(entry_data_list);
- if (NULL == value) {
- Py_DECREF(key);
- Py_DECREF(py_obj);
- return NULL;
- }
- PyDict_SetItem(py_obj, key, value);
- Py_DECREF(value);
- Py_DECREF(key);
- }
-
- return py_obj;
-}
-
-static PyObject *from_array(MMDB_entry_data_list_s **entry_data_list)
-{
- const uint32_t size = (*entry_data_list)->entry_data.data_size;
-
- PyObject *py_obj = PyList_New(size);
- if (NULL == py_obj) {
- PyErr_NoMemory();
- return NULL;
- }
-
- uint i;
- // entry_data_list cannot start out NULL (see from_entry_data_list). We
- // check it in the loop because it may become NULL.
- // coverity[check_after_deref]
- for (i = 0; i < size && entry_data_list; i++) {
- *entry_data_list = (*entry_data_list)->next;
- PyObject *value = from_entry_data_list(entry_data_list);
- if (NULL == value) {
- Py_DECREF(py_obj);
- return NULL;
- }
- // PyList_SetItem 'steals' the reference
- PyList_SetItem(py_obj, i, value);
- }
- return py_obj;
-}
-
-static PyObject *from_uint128(const MMDB_entry_data_list_s *entry_data_list)
-{
- uint64_t high = 0;
- uint64_t low = 0;
-#if MMDB_UINT128_IS_BYTE_ARRAY
- int i;
- for (i = 0; i < 8; i++) {
- high = (high << 8) | entry_data_list->entry_data.uint128[i];
- }
-
- for (i = 8; i < 16; i++) {
- low = (low << 8) | entry_data_list->entry_data.uint128[i];
- }
-#else
- high = entry_data_list->entry_data.uint128 >> 64;
- low = (uint64_t)entry_data_list->entry_data.uint128;
-#endif
-
- char *num_str = malloc(33);
- if (NULL == num_str) {
- PyErr_NoMemory();
- return NULL;
- }
-
- snprintf(num_str, 33, "%016" PRIX64 "%016" PRIX64, high, low);
-
- PyObject *py_obj = PyLong_FromString(num_str, NULL, 16);
-
- free(num_str);
- return py_obj;
-}
-
-static PyMethodDef Reader_methods[] = {
- { "get", Reader_get, METH_VARARGS,
- "Get record for IP address" },
- { "metadata", Reader_metadata, METH_NOARGS,
- "Returns metadata object for database" },
- { "close", Reader_close, METH_NOARGS, "Closes database"},
- { NULL, NULL, 0, NULL }
-};
-
-static PyTypeObject Reader_Type = {
- PyVarObject_HEAD_INIT(NULL, 0)
- .tp_basicsize = sizeof(Reader_obj),
- .tp_dealloc = Reader_dealloc,
- .tp_doc = "Reader object",
- .tp_flags = Py_TPFLAGS_DEFAULT,
- .tp_methods = Reader_methods,
- .tp_name = "Reader",
- .tp_init = Reader_init,
-};
-
-static PyMethodDef Metadata_methods[] = {
- { NULL, NULL, 0, NULL }
-};
-
-/* *INDENT-OFF* */
-static PyMemberDef Metadata_members[] = {
- { "binary_format_major_version", T_OBJECT, offsetof(
- Metadata_obj, binary_format_major_version), READONLY, NULL },
- { "binary_format_minor_version", T_OBJECT, offsetof(
- Metadata_obj, binary_format_minor_version), READONLY, NULL },
- { "build_epoch", T_OBJECT, offsetof(Metadata_obj, build_epoch),
- READONLY, NULL },
- { "database_type", T_OBJECT, offsetof(Metadata_obj, database_type),
- READONLY, NULL },
- { "description", T_OBJECT, offsetof(Metadata_obj, description),
- READONLY, NULL },
- { "ip_version", T_OBJECT, offsetof(Metadata_obj, ip_version),
- READONLY, NULL },
- { "languages", T_OBJECT, offsetof(Metadata_obj, languages), READONLY,
- NULL },
- { "node_count", T_OBJECT, offsetof(Metadata_obj, node_count),
- READONLY, NULL },
- { "record_size", T_OBJECT, offsetof(Metadata_obj, record_size),
- READONLY, NULL },
- { NULL, 0, 0, 0, NULL }
-};
-/* *INDENT-ON* */
-
-static PyTypeObject Metadata_Type = {
- PyVarObject_HEAD_INIT(NULL, 0)
- .tp_basicsize = sizeof(Metadata_obj),
- .tp_dealloc = Metadata_dealloc,
- .tp_doc = "Metadata object",
- .tp_flags = Py_TPFLAGS_DEFAULT,
- .tp_members = Metadata_members,
- .tp_methods = Metadata_methods,
- .tp_name = "Metadata",
- .tp_init = Metadata_init
-};
-
-static PyMethodDef MaxMindDB_methods[] = {
- { NULL, NULL, 0, NULL }
-};
-
-
-#if PY_MAJOR_VERSION >= 3
-static struct PyModuleDef MaxMindDB_module = {
- PyModuleDef_HEAD_INIT,
- .m_name = "extension",
- .m_doc = "This is a C extension to read MaxMind DB file format",
- .m_methods = MaxMindDB_methods,
-};
-#endif
-
-MOD_INIT(extension){
- PyObject *m;
-
-#if PY_MAJOR_VERSION >= 3
- m = PyModule_Create(&MaxMindDB_module);
-#else
- m = Py_InitModule("extension", MaxMindDB_methods);
-#endif
-
- if (!m) {
- RETURN_MOD_INIT(NULL);
- }
-
- Reader_Type.tp_new = PyType_GenericNew;
- if (PyType_Ready(&Reader_Type)) {
- RETURN_MOD_INIT(NULL);
- }
- Py_INCREF(&Reader_Type);
- PyModule_AddObject(m, "Reader", (PyObject *)&Reader_Type);
-
- Metadata_Type.tp_new = PyType_GenericNew;
- if (PyType_Ready(&Metadata_Type)) {
- RETURN_MOD_INIT(NULL);
- }
- PyModule_AddObject(m, "extension", (PyObject *)&Metadata_Type);
-
- PyObject* error_mod = PyImport_ImportModule("maxminddb.errors");
- if (error_mod == NULL) {
- RETURN_MOD_INIT(NULL);
- }
-
- MaxMindDB_error = PyObject_GetAttrString(error_mod, "InvalidDatabaseError");
- Py_DECREF(error_mod);
-
- if (MaxMindDB_error == NULL) {
- RETURN_MOD_INIT(NULL);
- }
-
- Py_INCREF(MaxMindDB_error);
-
- /* We primarily add it to the module for backwards compatibility */
- PyModule_AddObject(m, "InvalidDatabaseError", MaxMindDB_error);
-
- RETURN_MOD_INIT(m);
-}
diff --git a/maxminddb/file.py b/maxminddb/file.py
index 2e01e75..1901d9b 100644
--- a/maxminddb/file.py
+++ b/maxminddb/file.py
@@ -1,58 +1,66 @@
"""For internal use only. It provides a slice-like file reader."""
+from __future__ import annotations
+
import os
+from typing import overload
try:
- # pylint: disable=no-name-in-module
from multiprocessing import Lock
except ImportError:
- from threading import Lock
-
+ from threading import Lock # type: ignore[assignment]
-class FileBuffer(object):
- """A slice-able file reader"""
+class FileBuffer:
+ """A slice-able file reader."""
- def __init__(self, database):
- self._handle = open(database, 'rb')
+ def __init__(self, database: str) -> None:
+ """Create FileBuffer."""
+ self._handle = open(database, "rb") # noqa: SIM115
self._size = os.fstat(self._handle.fileno()).st_size
- if not hasattr(os, 'pread'):
+ if not hasattr(os, "pread"):
self._lock = Lock()
- def __getitem__(self, key):
- if isinstance(key, slice):
- return self._read(key.stop - key.start, key.start)
- elif isinstance(key, int):
- return self._read(1, key)
- else:
- raise TypeError("Invalid argument type.")
+ @overload
+ def __getitem__(self, index: int) -> int: ...
+
+ @overload
+ def __getitem__(self, index: slice) -> bytes: ...
+
+ def __getitem__(self, index: slice | int) -> bytes | int:
+ """Get item by index."""
+ if isinstance(index, slice):
+ return self._read(index.stop - index.start, index.start)
+ if isinstance(index, int):
+ return self._read(1, index)[0]
+ msg = "Invalid argument type."
+ raise TypeError(msg)
- def rfind(self, needle, start):
- """Reverse find needle from start"""
+ def rfind(self, needle: bytes, start: int) -> int:
+ """Reverse find needle from start."""
pos = self._read(self._size - start - 1, start).rfind(needle)
if pos == -1:
return pos
return start + pos
- def size(self):
- """Size of file"""
+ def size(self) -> int:
+ """Size of file."""
return self._size
- def close(self):
- """Close file"""
+ def close(self) -> None:
+ """Close file."""
self._handle.close()
- if hasattr(os, 'pread'):
+ if hasattr(os, "pread"): # type: ignore[attr-defined]
- def _read(self, buffersize, offset):
- """read that uses pread"""
- # pylint: disable=no-member
- return os.pread(self._handle.fileno(), buffersize, offset)
+ def _read(self, buffersize: int, offset: int) -> bytes:
+ """Read that uses pread."""
+ return os.pread(self._handle.fileno(), buffersize, offset) # type: ignore[attr-defined]
else:
- def _read(self, buffersize, offset):
- """read with a lock
+ def _read(self, buffersize: int, offset: int) -> bytes:
+ """Read with a lock.
This lock is necessary as after a fork, the different processes
will share the same file table entry, even if we dup the fd, and
diff --git a/maxminddb/py.typed b/maxminddb/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/maxminddb/reader.py b/maxminddb/reader.py
index b45f31e..2dd6a52 100644
--- a/maxminddb/reader.py
+++ b/maxminddb/reader.py
@@ -1,223 +1,380 @@
-"""
-maxminddb.reader
-~~~~~~~~~~~~~~~~
+"""Pure-Python reader for the MaxMind DB file format."""
-This module contains the pure Python database reader and related classes.
-
-"""
-from __future__ import unicode_literals
+from __future__ import annotations
try:
import mmap
except ImportError:
- # pylint: disable=invalid-name
- mmap = None
+ mmap = None # type: ignore[assignment]
+import contextlib
+import ipaddress
import struct
+from ipaddress import IPv4Address, IPv6Address
+from typing import IO, TYPE_CHECKING, Any, AnyStr
-from maxminddb.compat import byte_from_int, int_from_byte, compat_ip_address
-from maxminddb.const import MODE_AUTO, MODE_MMAP, MODE_FILE, MODE_MEMORY
+from maxminddb.const import MODE_AUTO, MODE_FD, MODE_FILE, MODE_MEMORY, MODE_MMAP
from maxminddb.decoder import Decoder
from maxminddb.errors import InvalidDatabaseError
from maxminddb.file import FileBuffer
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+ from os import PathLike
-class Reader(object):
+ from typing_extensions import Self
- """
- Instances of this class provide a reader for the MaxMind DB format. IP
- addresses can be looked up using the ``get`` method.
- """
+ from maxminddb.types import Record
- _DATA_SECTION_SEPARATOR_SIZE = 16
- _METADATA_START_MARKER = b"\xAB\xCD\xEFMaxMind.com"
+_IPV4_MAX_NUM = 2**32
- _ipv4_start = None
- def __init__(self, database, mode=MODE_AUTO):
- """Reader for the MaxMind DB file format
+class Reader:
+ """A pure Python implementation of a reader for the MaxMind DB format.
+
+ IP addresses can be looked up using the ``get`` method.
+ """
+
+ _DATA_SECTION_SEPARATOR_SIZE = 16
+ _METADATA_START_MARKER = b"\xab\xcd\xefMaxMind.com"
+
+ _buffer: bytes | FileBuffer | "mmap.mmap" # noqa: UP037
+ _buffer_size: int
+ closed: bool
+ _decoder: Decoder
+ _metadata: Metadata
+ _ipv4_start: int
+
+ def __init__(
+ self,
+ database: AnyStr | int | PathLike | IO,
+ mode: int = MODE_AUTO,
+ ) -> None:
+ """Reader for the MaxMind DB file format.
Arguments:
- database -- A path to a valid MaxMind DB file such as a GeoIP2
- database file.
- mode -- mode to open the database with. Valid mode are:
- * MODE_MMAP - read from memory map.
- * MODE_FILE - read database as standard file.
- * MODE_MEMORY - load database into memory.
- * MODE_AUTO - tries MODE_MMAP and then MODE_FILE. Default.
+ database: A path to a valid MaxMind DB file such as a GeoIP2 database
+ file, or a file descriptor in the case of MODE_FD.
+ mode: mode to open the database with. Valid mode are:
+ * MODE_MMAP - read from memory map.
+ * MODE_FILE - read database as standard file.
+ * MODE_MEMORY - load database into memory.
+ * MODE_AUTO - tries MODE_MMAP and then MODE_FILE. Default.
+ * MODE_FD - the param passed via database is a file descriptor, not
+ a path. This mode implies MODE_MEMORY.
+
"""
- # pylint: disable=redefined-variable-type
+ filename: Any
if (mode == MODE_AUTO and mmap) or mode == MODE_MMAP:
- with open(database, 'rb') as db_file:
- self._buffer = mmap.mmap(
- db_file.fileno(), 0, access=mmap.ACCESS_READ)
+ with open(database, "rb") as db_file: # type: ignore[arg-type]
+ self._buffer = mmap.mmap(db_file.fileno(), 0, access=mmap.ACCESS_READ)
self._buffer_size = self._buffer.size()
+ filename = database
elif mode in (MODE_AUTO, MODE_FILE):
- self._buffer = FileBuffer(database)
+ self._buffer = FileBuffer(database) # type: ignore[arg-type]
self._buffer_size = self._buffer.size()
+ filename = database
elif mode == MODE_MEMORY:
- with open(database, 'rb') as db_file:
- self._buffer = db_file.read()
- self._buffer_size = len(self._buffer)
+ with open(database, "rb") as db_file: # type: ignore[arg-type]
+ buf = db_file.read()
+ self._buffer = buf
+ self._buffer_size = len(buf)
+ filename = database
+ elif mode == MODE_FD:
+ self._buffer = database.read() # type: ignore[union-attr]
+ self._buffer_size = len(self._buffer) # type: ignore[arg-type]
+ filename = database.name # type: ignore[union-attr]
else:
- raise ValueError('Unsupported open mode ({0}). Only MODE_AUTO, '
- ' MODE_FILE, and MODE_MEMORY are support by the pure Python '
- 'Reader'.format(mode))
-
- metadata_start = self._buffer.rfind(self._METADATA_START_MARKER,
- max(0, self._buffer_size
- - 128 * 1024))
+ msg = (
+ f"Unsupported open mode ({mode}). Only MODE_AUTO, MODE_FILE, "
+ "MODE_MEMORY and MODE_FD are supported by the pure Python "
+ "Reader"
+ )
+ raise ValueError(
+ msg,
+ )
+
+ metadata_start = self._buffer.rfind(
+ self._METADATA_START_MARKER,
+ max(0, self._buffer_size - 128 * 1024),
+ )
if metadata_start == -1:
self.close()
- raise InvalidDatabaseError('Error opening database file ({0}). '
- 'Is this a valid MaxMind DB file?'
- ''.format(database))
+ msg = (
+ f"Error opening database file ({filename}). "
+ "Is this a valid MaxMind DB file?"
+ )
+ raise InvalidDatabaseError(
+ msg,
+ )
metadata_start += len(self._METADATA_START_MARKER)
metadata_decoder = Decoder(self._buffer, metadata_start)
(metadata, _) = metadata_decoder.decode(metadata_start)
- self._metadata = Metadata(
- **metadata) # pylint: disable=bad-option-value
-
- self._decoder = Decoder(self._buffer, self._metadata.search_tree_size
- + self._DATA_SECTION_SEPARATOR_SIZE)
- def metadata(self):
- """Return the metadata associated with the MaxMind DB file"""
+ if not isinstance(metadata, dict):
+ msg = f"Error reading metadata in database file ({filename})."
+ raise InvalidDatabaseError(
+ msg,
+ )
+
+ self._metadata = Metadata(**metadata)
+
+ self._decoder = Decoder(
+ self._buffer,
+ self._metadata.search_tree_size + self._DATA_SECTION_SEPARATOR_SIZE,
+ )
+ self.closed = False
+
+ ipv4_start = 0
+ if self._metadata.ip_version == 6:
+ # We store the IPv4 starting node as an optimization for IPv4 lookups
+ # in IPv6 trees. This allows us to skip over the first 96 nodes in
+ # this case.
+ node = 0
+ for _ in range(96):
+ if node >= self._metadata.node_count:
+ break
+ node = self._read_node(node, 0)
+ ipv4_start = node
+ self._ipv4_start = ipv4_start
+
+ def metadata(self) -> Metadata:
+ """Return the metadata associated with the MaxMind DB file."""
return self._metadata
- def get(self, ip_address):
- """Return the record for the ip_address in the MaxMind DB
-
+ def get(self, ip_address: str | IPv6Address | IPv4Address) -> Record | None:
+ """Return the record for the ip_address in the MaxMind DB.
Arguments:
- ip_address -- an IP address in the standard string notation
+ ip_address: an IP address in the standard string notation
+
"""
+ (record, _) = self.get_with_prefix_len(ip_address)
+ return record
- address = compat_ip_address(ip_address)
+ def get_with_prefix_len(
+ self,
+ ip_address: str | IPv6Address | IPv4Address,
+ ) -> tuple[Record | None, int]:
+ """Return a tuple with the record and the associated prefix length.
- if address.version == 6 and self._metadata.ip_version == 4:
- raise ValueError('Error looking up {0}. You attempted to look up '
- 'an IPv6 address in an IPv4-only database.'.format(
- ip_address))
- pointer = self._find_address_in_tree(address)
+ Arguments:
+ ip_address: an IP address in the standard string notation
- return self._resolve_data_pointer(pointer) if pointer else None
+ """
+ if isinstance(ip_address, str):
+ address = ipaddress.ip_address(ip_address)
+ else:
+ address = ip_address
- def _find_address_in_tree(self, ip_address):
- packed = ip_address.packed
+ try:
+ packed_address = bytearray(address.packed)
+ except AttributeError as ex:
+ msg = "argument 1 must be a string or ipaddress object"
+ raise TypeError(msg) from ex
+ if address.version == 6 and self._metadata.ip_version == 4:
+ msg = (
+ f"Error looking up {ip_address}. You attempted to look up "
+ "an IPv6 address in an IPv4-only database."
+ )
+ raise ValueError(
+ msg,
+ )
+
+ (pointer, prefix_len) = self._find_address_in_tree(packed_address)
+
+ if pointer:
+ return self._resolve_data_pointer(pointer), prefix_len
+ return None, prefix_len
+
+ def __iter__(self) -> Iterator:
+ return self._generate_children(0, 0, 0)
+
+ def _generate_children(self, node: int, depth: int, ip_acc: int) -> Iterator:
+ if ip_acc != 0 and node == self._ipv4_start:
+ # Skip nodes aliased to IPv4
+ return
+
+ node_count = self._metadata.node_count
+ if node > node_count:
+ bits = 128 if self._metadata.ip_version == 6 else 32
+ ip_acc <<= bits - depth
+ if ip_acc <= _IPV4_MAX_NUM and bits == 128:
+ depth -= 96
+ yield (
+ ipaddress.ip_network((ip_acc, depth)),
+ self._resolve_data_pointer(
+ node,
+ ),
+ )
+ elif node < node_count:
+ left = self._read_node(node, 0)
+ ip_acc <<= 1
+ depth += 1
+ yield from self._generate_children(left, depth, ip_acc)
+ right = self._read_node(node, 1)
+ yield from self._generate_children(right, depth, ip_acc | 1)
+
+ def _find_address_in_tree(self, packed: bytearray) -> tuple[int, int]:
bit_count = len(packed) * 8
node = self._start_node(bit_count)
+ node_count = self._metadata.node_count
- for i in range(bit_count):
- if node >= self._metadata.node_count:
- break
- bit = 1 & (int_from_byte(packed[i >> 3]) >> 7 - (i % 8))
+ i = 0
+ while i < bit_count and node < node_count:
+ bit = 1 & (packed[i >> 3] >> 7 - (i % 8))
node = self._read_node(node, bit)
- if node == self._metadata.node_count:
- # Record is empty
- return 0
- elif node > self._metadata.node_count:
- return node
+ i = i + 1
- raise InvalidDatabaseError('Invalid node in search tree')
+ if node == node_count:
+ # Record is empty
+ return 0, i
+ if node > node_count:
+ return node, i
- def _start_node(self, length):
- if self._metadata.ip_version != 6 or length == 128:
- return 0
+ msg = "Invalid node in search tree"
+ raise InvalidDatabaseError(msg)
- # We are looking up an IPv4 address in an IPv6 tree. Skip over the
- # first 96 nodes.
- if self._ipv4_start:
+ def _start_node(self, length: int) -> int:
+ if self._metadata.ip_version == 6 and length == 32:
return self._ipv4_start
+ return 0
- node = 0
- for _ in range(96):
- if node >= self._metadata.node_count:
- break
- node = self._read_node(node, 0)
- self._ipv4_start = node
- return node
-
- def _read_node(self, node_number, index):
+ def _read_node(self, node_number: int, index: int) -> int:
base_offset = node_number * self._metadata.node_byte_size
record_size = self._metadata.record_size
if record_size == 24:
offset = base_offset + index * 3
- node_bytes = b'\x00' + self._buffer[offset:offset + 3]
+ node_bytes = b"\x00" + self._buffer[offset : offset + 3]
elif record_size == 28:
- (middle,) = struct.unpack(
- b'!B', self._buffer[base_offset + 3:base_offset + 4])
+ offset = base_offset + 3 * index
+ node_bytes = bytearray(self._buffer[offset : offset + 4])
if index:
- middle &= 0x0F
+ node_bytes[0] = 0x0F & node_bytes[0]
else:
- middle = (0xF0 & middle) >> 4
- offset = base_offset + index * 4
- node_bytes = byte_from_int(
- middle) + self._buffer[offset:offset + 3]
+ middle = (0xF0 & node_bytes.pop()) >> 4
+ node_bytes.insert(0, middle)
elif record_size == 32:
offset = base_offset + index * 4
- node_bytes = self._buffer[offset:offset + 4]
+ node_bytes = self._buffer[offset : offset + 4]
else:
- raise InvalidDatabaseError(
- 'Unknown record size: {0}'.format(record_size))
- return struct.unpack(b'!I', node_bytes)[0]
+ msg = f"Unknown record size: {record_size}"
+ raise InvalidDatabaseError(msg)
+ return struct.unpack(b"!I", node_bytes)[0]
- def _resolve_data_pointer(self, pointer):
- resolved = pointer - self._metadata.node_count + \
- self._metadata.search_tree_size
+ def _resolve_data_pointer(self, pointer: int) -> Record:
+ resolved = pointer - self._metadata.node_count + self._metadata.search_tree_size
- if resolved > self._buffer_size:
- raise InvalidDatabaseError(
- "The MaxMind DB file's search tree is corrupt")
+ if resolved >= self._buffer_size:
+ msg = "The MaxMind DB file's search tree is corrupt"
+ raise InvalidDatabaseError(msg)
(data, _) = self._decoder.decode(resolved)
return data
- def close(self):
- """Closes the MaxMind DB file and returns the resources to the system"""
- # pylint: disable=unidiomatic-typecheck
- if type(self._buffer) not in (str, bytes):
- self._buffer.close()
+ def close(self) -> None:
+ """Close the MaxMind DB file and returns the resources to the system."""
+ with contextlib.suppress(AttributeError):
+ self._buffer.close() # type: ignore[union-attr]
+
+ self.closed = True
+ def __exit__(self, *_) -> None: # noqa: ANN002
+ self.close()
-class Metadata(object):
+ def __enter__(self) -> Self:
+ if self.closed:
+ msg = "Attempt to reopen a closed MaxMind DB"
+ raise ValueError(msg)
+ return self
- """Metadata for the MaxMind DB reader"""
- # pylint: disable=too-many-instance-attributes
- def __init__(self, **kwargs):
- """Creates new Metadata object. kwargs are key/value pairs from spec"""
+class Metadata:
+ """Metadata for the MaxMind DB reader."""
+
+ binary_format_major_version: int
+ """
+ The major version number of the binary format used when creating the
+ database.
+ """
+
+ binary_format_minor_version: int
+ """
+ The minor version number of the binary format used when creating the
+ database.
+ """
+
+ build_epoch: int
+ """
+ The Unix epoch for the build time of the database.
+ """
+
+ database_type: str
+ """
+ A string identifying the database type, e.g., "GeoIP2-City".
+ """
+
+ description: dict[str, str]
+ """
+ A map from locales to text descriptions of the database.
+ """
+
+ ip_version: int
+ """
+ The IP version of the data in a database. A value of "4" means the
+ database only supports IPv4. A database with a value of "6" may support
+ both IPv4 and IPv6 lookups.
+ """
+
+ languages: list[str]
+ """
+ A list of locale codes supported by the database.
+ """
+
+ node_count: int
+ """
+ The number of nodes in the database.
+ """
+
+ record_size: int
+ """
+ The bit size of a record in the search tree.
+ """
+
+ def __init__(self, **kwargs) -> None:
+ """Create new Metadata object. kwargs are key/value pairs from spec."""
# Although I could just update __dict__, that is less obvious and it
# doesn't work well with static analysis tools and some IDEs
- self.node_count = kwargs['node_count']
- self.record_size = kwargs['record_size']
- self.ip_version = kwargs['ip_version']
- self.database_type = kwargs['database_type']
- self.languages = kwargs['languages']
- self.binary_format_major_version = kwargs[
- 'binary_format_major_version']
- self.binary_format_minor_version = kwargs[
- 'binary_format_minor_version']
- self.build_epoch = kwargs['build_epoch']
- self.description = kwargs['description']
+ self.node_count = kwargs["node_count"]
+ self.record_size = kwargs["record_size"]
+ self.ip_version = kwargs["ip_version"]
+ self.database_type = kwargs["database_type"]
+ self.languages = kwargs["languages"]
+ self.binary_format_major_version = kwargs["binary_format_major_version"]
+ self.binary_format_minor_version = kwargs["binary_format_minor_version"]
+ self.build_epoch = kwargs["build_epoch"]
+ self.description = kwargs["description"]
@property
- def node_byte_size(self):
- """The size of a node in bytes"""
+ def node_byte_size(self) -> int:
+ """The size of a node in bytes.
+
+ :type: int
+ """
return self.record_size // 4
@property
- def search_tree_size(self):
- """The size of the search tree"""
+ def search_tree_size(self) -> int:
+ """The size of the search tree.
+
+ :type: int
+ """
return self.node_count * self.node_byte_size
- def __repr__(self):
- args = ', '.join('%s=%r' % x for x in self.__dict__.items())
- return '{module}.{class_name}({data})'.format(
- module=self.__module__,
- class_name=self.__class__.__name__,
- data=args)
+ def __repr__(self) -> str:
+ args = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
+ return f"{self.__module__}.{self.__class__.__name__}({args})"
diff --git a/maxminddb/types.py b/maxminddb/types.py
new file mode 100644
index 0000000..aefae65
--- /dev/null
+++ b/maxminddb/types.py
@@ -0,0 +1,14 @@
+"""Types representing database records."""
+
+from typing import AnyStr, Union
+
+Primitive = Union[AnyStr, bool, float, int]
+Record = Union[Primitive, "RecordList", "RecordDict"]
+
+
+class RecordList(list[Record]):
+ """RecordList is a type for lists in a database record."""
+
+
+class RecordDict(dict[str, Record]):
+ """RecordDict is a type for dicts in a database record."""
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..365c0f2
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,131 @@
+[project]
+name = "maxminddb"
+version = "2.7.0"
+description = "Reader for the MaxMind DB format"
+authors = [
+ {name = "Gregory Oschwald", email = "goschwald@maxmind.com"},
+]
+requires-python = ">=3.9"
+readme = "README.rst"
+license = "Apache-2.0"
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Environment :: Web Environment",
+ "Intended Audience :: Developers",
+ "Intended Audience :: System Administrators",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Internet",
+ "Topic :: Internet :: Proxy Servers",
+]
+
+[project.urls]
+Homepage = "https://www.maxmind.com/"
+Documentation = "https://maxminddb.readthedocs.org/"
+"Source Code" = "https://github.com/maxmind/MaxMind-DB-Reader-python"
+"Issue Tracker" = "https://github.com/maxmind/MaxMind-DB-Reader-python/issues"
+
+[dependency-groups]
+dev = [
+ "pytest>=8.3.5",
+]
+lint = [
+ "mypy>=1.15.0",
+ "ruff>=0.11.6",
+]
+
+[build-system]
+requires = [
+ "setuptools>=77.0.3",
+ "setuptools-scm",
+ "wheel",
+]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools]
+include-package-data = true
+packages = ["maxminddb"]
+
+[tool.setuptools.package-data]
+maxminddb = ["extension.pyi", "py.typed"]
+
+[tool.ruff.lint]
+select = ["ALL"]
+ignore = [
+ # Skip type annotation on **_
+ "ANN003",
+
+ # Redundant as the formatter handles missing trailing commas.
+ "COM812",
+
+ # documenting magic methods
+ "D105",
+
+ # Conflicts with D211
+ "D203",
+
+ # Conflicts with D212
+ "D213",
+
+ # Magic numbers for HTTP status codes seem ok most of the time.
+ "PLR2004",
+
+ # pytest rules
+ "PT009",
+ "PT027",
+ # Using the built-in open is more appropriate for this library.
+ "PTH123",
+]
+
+[tool.ruff.lint.per-file-ignores]
+"docs/*" = ["ALL"]
+"maxminddb/extension.pyi" = [
+ # This is a stub for extension and having the docs here is useful.
+ "PYI021",
+]
+"setup.py" = ["ALL"]
+"tests/*" = ["ANN201", "D"]
+
+[tool.tox]
+env_list = [
+ "3.9",
+ "3.10",
+ "3.11",
+ "3.12",
+ "3.13",
+ "lint",
+]
+skip_missing_interpreters = false
+
+[tool.tox.env_run_base]
+dependency_groups = [
+ "dev",
+]
+commands = [
+ ["pytest", "tests"],
+]
+
+[tool.tox.env.lint]
+description = "Code linting"
+python = "3.13"
+dependency_groups = [
+ "dev",
+ "lint",
+]
+commands = [
+ ["mypy", "maxminddb", "tests"],
+ ["ruff", "check"],
+ ["ruff", "format", "--check", "--diff", "."],
+]
+
+[tool.tox.gh.python]
+"3.13" = ["3.13", "lint"]
+"3.12" = ["3.12"]
+"3.11" = ["3.11"]
+"3.10" = ["3.10"]
+"3.9" = ["3.9"]
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 0471615..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,4 +0,0 @@
-[aliases]
-build_html = build_sphinx -b html --build-dir docs
-sdist = build_html sdist
-release = sdist upload
diff --git a/setup.py b/setup.py
index 088ad9d..5b5a5d9 100644
--- a/setup.py
+++ b/setup.py
@@ -2,182 +2,175 @@
import re
import sys
-# This import is apparently needed for Nose on Red Hat's Python
-import multiprocessing
-
-from distutils.command.build_ext import build_ext
-from distutils.errors import (CCompilerError, DistutilsExecError,
- DistutilsPlatformError)
+from setuptools import Extension, setup
+from setuptools.command.build_ext import build_ext
+from wheel.bdist_wheel import bdist_wheel
+# These were only added to setuptools in 59.0.1.
try:
- from setuptools import setup, Extension, Feature
+ from setuptools.errors import (
+ CCompilerError,
+ DistutilsExecError,
+ DistutilsPlatformError,
+ )
except ImportError:
- from distutils.core import setup, Extension
- Feature = None
+ from distutils.errors import (
+ CCompilerError,
+ DistutilsExecError,
+ DistutilsPlatformError,
+ )
cmdclass = {}
-PYPY = hasattr(sys, 'pypy_version_info')
-JYTHON = sys.platform.startswith('java')
-requirements = []
-
-if sys.version_info[0] == 2 or (sys.version_info[0] == 3
- and sys.version_info[1] < 3):
- requirements.append('ipaddress')
-
-compile_args = ['-Wall', '-Wextra']
+PYPY = hasattr(sys, "pypy_version_info")
+JYTHON = sys.platform.startswith("java")
-if sys.version_info[0] == 2:
- compile_args.append('-fno-strict-aliasing')
+if os.name == "nt":
+ # Disable unknown pragma warning
+ compile_args = ["-wd4068"]
+ libraries = ["Ws2_32"]
+else:
+ compile_args = ["-Wall", "-Wextra", "-Wno-unknown-pragmas"]
+ libraries = []
-ext_module = [
- Extension(
- 'maxminddb.extension',
- libraries=['maxminddb'],
- sources=['maxminddb/extension/maxminddb.c'],
- extra_compile_args=compile_args,
- )
-]
+if os.getenv("MAXMINDDB_USE_SYSTEM_LIBMAXMINDDB"):
+ ext_module = [
+ Extension(
+ "maxminddb.extension",
+ libraries=["maxminddb", *libraries],
+ sources=["extension/maxminddb.c"],
+ extra_compile_args=compile_args,
+ ),
+ ]
+else:
+ ext_module = [
+ Extension(
+ "maxminddb.extension",
+ libraries=libraries,
+ sources=[
+ "extension/maxminddb.c",
+ "extension/libmaxminddb/src/data-pool.c",
+ "extension/libmaxminddb/src/maxminddb.c",
+ ],
+ define_macros=[
+ ("HAVE_CONFIG_H", 0),
+ ("MMDB_LITTLE_ENDIAN", 1 if sys.byteorder == "little" else 0),
+ # We define these for maximum compatibility. The extension
+ # itself supports all variations currently, but probing to
+ # see what the compiler supports is a bit annoying to do
+ # here, and we aren't using uint128 for much.
+ ("MMDB_UINT128_USING_MODE", 0),
+ ("MMDB_UINT128_IS_BYTE_ARRAY", 1),
+ ("PACKAGE_VERSION", '"maxminddb-python"'),
+ ],
+ include_dirs=[
+ "extension",
+ "extension/libmaxminddb/include",
+ "extension/libmaxminddb/src",
+ ],
+ extra_compile_args=compile_args,
+ ),
+ ]
# Cargo cult code for installing extension with pure Python fallback.
# Taken from SQLAlchemy, but this same basic code exists in many modules.
ext_errors = (CCompilerError, DistutilsExecError, DistutilsPlatformError)
-if sys.platform == 'win32':
- # 2.6's distutils.msvc9compiler can raise an IOError when failing to
- # find the compiler
- ext_errors += (IOError,)
class BuildFailed(Exception):
-
- def __init__(self):
- self.cause = sys.exc_info()[1] # work around py 2/3 different syntax
+ def __init__(self) -> None:
+ self.cause = sys.exc_info()[1]
class ve_build_ext(build_ext):
# This class allows C extension building to fail.
- def run(self):
+ def run(self) -> None:
try:
build_ext.run(self)
except DistutilsPlatformError:
- raise BuildFailed()
+ raise BuildFailed
- def build_extension(self, ext):
+ def build_extension(self, ext) -> None:
try:
build_ext.build_extension(self, ext)
except ext_errors:
- raise BuildFailed()
+ raise BuildFailed
except ValueError:
# this can happen on Windows 64 bit, see Python issue 7511
- if "'path'" in str(sys.exc_info()[1]): # works with both py 2/3
- raise BuildFailed()
+ if "'path'" in str(sys.exc_info()[1]):
+ raise BuildFailed
raise
-cmdclass['build_ext'] = ve_build_ext
-#
+cmdclass["build_ext"] = ve_build_ext
+
ROOT = os.path.dirname(__file__)
-with open(os.path.join(ROOT, 'README.rst'), 'rb') as fd:
- README = fd.read().decode('utf8')
+with open(os.path.join(ROOT, "README.rst"), "rb") as fd:
+ README = fd.read().decode("utf8")
-with open(os.path.join(ROOT, 'maxminddb', '__init__.py'), 'rb') as fd:
- maxminddb_text = fd.read().decode('utf8')
- LICENSE = re.compile(
- r".*__license__ = '(.*?)'", re.S).match(maxminddb_text).group(1)
- VERSION = re.compile(
- r".*__version__ = '(.*?)'", re.S).match(maxminddb_text).group(1)
+with open(os.path.join(ROOT, "maxminddb", "__init__.py"), "rb") as fd:
+ maxminddb_text = fd.read().decode("utf8")
+ VERSION = (
+ re.compile(r".*__version__ = \"(.*?)\"", re.DOTALL)
+ .match(maxminddb_text)
+ .group(1)
+ )
def status_msgs(*msgs):
- print('*' * 75)
+ print("*" * 75)
for msg in msgs:
print(msg)
- print('*' * 75)
+ print("*" * 75)
def find_packages(location):
packages = []
- for pkg in ['maxminddb']:
- for _dir, subdirectories, files in (
- os.walk(os.path.join(location, pkg))):
- if '__init__.py' in files:
- tokens = _dir.split(os.sep)[len(location.split(os.sep)):]
+ for pkg in ["maxminddb"]:
+ for _dir, _subdirectories, files in os.walk(os.path.join(location, pkg)):
+ if "__init__.py" in files:
+ tokens = _dir.split(os.sep)[len(location.split(os.sep)) :]
packages.append(".".join(tokens))
return packages
-def run_setup(with_cext):
+def run_setup(with_cext) -> None:
kwargs = {}
+ loc_cmdclass = cmdclass.copy()
if with_cext:
- if Feature:
- kwargs['features'] = {'extension': Feature(
- "optional C implementation",
- standard=True,
- ext_modules=ext_module
- )}
- else:
- kwargs['ext_modules'] = ext_module
-
- setup(
- name='maxminddb',
- version=VERSION,
- author='Gregory Oschwald',
- author_email='goschwald@maxmind.com',
- description='Reader for the MaxMind DB format',
- long_description=README,
- url='http://www.maxmind.com/',
- packages=find_packages('.'),
- package_data={'': ['LICENSE']},
- package_dir={'maxminddb': 'maxminddb'},
- include_package_data=True,
- install_requires=requirements,
- tests_require=['nose'],
- test_suite='nose.collector',
- license=LICENSE,
- cmdclass=cmdclass,
- classifiers=(
- 'Development Status :: 5 - Production/Stable',
- 'Environment :: Web Environment',
- 'Intended Audience :: Developers',
- 'Intended Audience :: System Administrators',
- 'License :: OSI Approved :: Apache Software License',
- 'Programming Language :: Python :: 2.6',
- 'Programming Language :: Python :: 2.7',
- 'Programming Language :: Python :: 3',
- 'Programming Language :: Python :: 3.3',
- 'Programming Language :: Python :: 3.4',
- 'Programming Language :: Python',
- 'Topic :: Internet :: Proxy Servers',
- 'Topic :: Internet',
- ),
- **kwargs
- )
+ kwargs["ext_modules"] = ext_module
+ loc_cmdclass["bdist_wheel"] = bdist_wheel
+
+ setup(version=VERSION, cmdclass=loc_cmdclass, **kwargs)
-if PYPY or JYTHON:
+
+if JYTHON:
run_setup(False)
status_msgs(
"WARNING: Disabling C extension due to Python platform.",
- "Plain-Python build succeeded."
+ "Plain-Python build succeeded.",
)
else:
try:
run_setup(True)
except BuildFailed as exc:
+ if os.getenv("MAXMINDDB_REQUIRE_EXTENSION"):
+ raise
status_msgs(
exc.cause,
- "WARNING: The C extension could not be compiled, " +
- "speedups are not enabled.",
+ "WARNING: The C extension could not be compiled, "
+ + "speedups are not enabled.",
"Failure information, if any, is above.",
- "Retrying the build without the C extension now."
+ "Retrying the build without the C extension now.",
)
run_setup(False)
status_msgs(
- "WARNING: The C extension could not be compiled, " +
- "speedups are not enabled.",
- "Plain-Python build succeeded."
+ "WARNING: The C extension could not be compiled, "
+ + "speedups are not enabled.",
+ "Plain-Python build succeeded.",
)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/data b/tests/data
index 90c7fb9..a75bfb1 160000
--- a/tests/data
+++ b/tests/data
@@ -1 +1 @@
-Subproject commit 90c7fb95d67ee03ca7fc487fb69f525bcc19a671
+Subproject commit a75bfb17a0e77f576c9eef0cfbf6220909e959e7
diff --git a/tests/decoder_test.py b/tests/decoder_test.py
index a19ad38..b755b5d 100644
--- a/tests/decoder_test.py
+++ b/tests/decoder_test.py
@@ -1,69 +1,59 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-from __future__ import unicode_literals
+from __future__ import annotations
import mmap
-import sys
+import unittest
+from typing import TYPE_CHECKING, Any, ClassVar
-from maxminddb.compat import byte_from_int, int_from_byte
from maxminddb.decoder import Decoder
-if sys.version_info[:2] == (2, 6):
- import unittest2 as unittest
-else:
- import unittest
-
-if sys.version_info[0] == 2:
- unittest.TestCase.assertRaisesRegex = unittest.TestCase.assertRaisesRegexp
- unittest.TestCase.assertRegex = unittest.TestCase.assertRegexpMatches
+if TYPE_CHECKING:
+ from _typeshed import SizedBuffer
class TestDecoder(unittest.TestCase):
-
- def test_arrays(self):
+ def test_arrays(self) -> None:
arrays = {
- b'\x00\x04': [],
- b'\x01\x04\x43\x46\x6f\x6f': ['Foo'],
- b'\x02\x04\x43\x46\x6f\x6f\x43\xe4\xba\xba':
- ['Foo', '人'],
+ b"\x00\x04": [],
+ b"\x01\x04\x43\x46\x6f\x6f": ["Foo"],
+ b"\x02\x04\x43\x46\x6f\x6f\x43\xe4\xba\xba": ["Foo", "人"],
}
- self.validate_type_decoding('arrays', arrays)
+ self.validate_type_decoding("arrays", arrays)
- def test_boolean(self):
+ def test_boolean(self) -> None:
booleans = {
b"\x00\x07": False,
b"\x01\x07": True,
}
- self.validate_type_decoding('booleans', booleans)
+ self.validate_type_decoding("booleans", booleans)
- def test_double(self):
+ def test_double(self) -> None:
doubles = {
b"\x68\x00\x00\x00\x00\x00\x00\x00\x00": 0.0,
- b"\x68\x3F\xE0\x00\x00\x00\x00\x00\x00": 0.5,
- b"\x68\x40\x09\x21\xFB\x54\x44\x2E\xEA": 3.14159265359,
- b"\x68\x40\x5E\xC0\x00\x00\x00\x00\x00": 123.0,
- b"\x68\x41\xD0\x00\x00\x00\x07\xF8\xF4": 1073741824.12457,
- b"\x68\xBF\xE0\x00\x00\x00\x00\x00\x00": -0.5,
- b"\x68\xC0\x09\x21\xFB\x54\x44\x2E\xEA": -3.14159265359,
- b"\x68\xC1\xD0\x00\x00\x00\x07\xF8\xF4": -1073741824.12457,
+ b"\x68\x3f\xe0\x00\x00\x00\x00\x00\x00": 0.5,
+ b"\x68\x40\x09\x21\xfb\x54\x44\x2e\xea": 3.14159265359,
+ b"\x68\x40\x5e\xc0\x00\x00\x00\x00\x00": 123.0,
+ b"\x68\x41\xd0\x00\x00\x00\x07\xf8\xf4": 1073741824.12457,
+ b"\x68\xbf\xe0\x00\x00\x00\x00\x00\x00": -0.5,
+ b"\x68\xc0\x09\x21\xfb\x54\x44\x2e\xea": -3.14159265359,
+ b"\x68\xc1\xd0\x00\x00\x00\x07\xf8\xf4": -1073741824.12457,
}
- self.validate_type_decoding('double', doubles)
+ self.validate_type_decoding("double", doubles)
- def test_float(self):
+ def test_float(self) -> None:
floats = {
b"\x04\x08\x00\x00\x00\x00": 0.0,
- b"\x04\x08\x3F\x80\x00\x00": 1.0,
- b"\x04\x08\x3F\x8C\xCC\xCD": 1.1,
- b"\x04\x08\x40\x48\xF5\xC3": 3.14,
- b"\x04\x08\x46\x1C\x3F\xF6": 9999.99,
- b"\x04\x08\xBF\x80\x00\x00": -1.0,
- b"\x04\x08\xBF\x8C\xCC\xCD": -1.1,
- b"\x04\x08\xC0\x48\xF5\xC3": -3.14,
- b"\x04\x08\xC6\x1C\x3F\xF6": -9999.99}
- self.validate_type_decoding('float', floats)
-
- def test_int32(self):
+ b"\x04\x08\x3f\x80\x00\x00": 1.0,
+ b"\x04\x08\x3f\x8c\xcc\xcd": 1.1,
+ b"\x04\x08\x40\x48\xf5\xc3": 3.14,
+ b"\x04\x08\x46\x1c\x3f\xf6": 9999.99,
+ b"\x04\x08\xbf\x80\x00\x00": -1.0,
+ b"\x04\x08\xbf\x8c\xcc\xcd": -1.1,
+ b"\x04\x08\xc0\x48\xf5\xc3": -3.14,
+ b"\x04\x08\xc6\x1c\x3f\xf6": -9999.99,
+ }
+ self.validate_type_decoding("float", floats)
+
+ def test_int32(self) -> None:
int32 = {
b"\x00\x01": 0,
b"\x04\x01\xff\xff\xff\xff": -1,
@@ -78,75 +68,86 @@ def test_int32(self):
b"\x04\x01\x7f\xff\xff\xff": 2147483647,
b"\x04\x01\x80\x00\x00\x01": -2147483647,
}
- self.validate_type_decoding('int32', int32)
+ self.validate_type_decoding("int32", int32)
- def test_map(self):
+ def test_map(self) -> None:
maps = {
- b'\xe0': {},
- b'\xe1\x42\x65\x6e\x43\x46\x6f\x6f': {'en': 'Foo'},
- b'\xe2\x42\x65\x6e\x43\x46\x6f\x6f\x42\x7a\x68\x43\xe4\xba\xba':
- {'en': 'Foo', 'zh': '人'},
- (b'\xe1\x44\x6e\x61\x6d\x65\xe2\x42\x65\x6e'
- b'\x43\x46\x6f\x6f\x42\x7a\x68\x43\xe4\xba\xba'):
- {'name': {'en': 'Foo', 'zh': '人'}},
- (b'\xe1\x49\x6c\x61\x6e\x67\x75\x61\x67\x65\x73'
- b'\x02\x04\x42\x65\x6e\x42\x7a\x68'):
- {'languages': ['en', 'zh']},
+ b"\xe0": {},
+ b"\xe1\x42\x65\x6e\x43\x46\x6f\x6f": {"en": "Foo"},
+ b"\xe2\x42\x65\x6e\x43\x46\x6f\x6f\x42\x7a\x68\x43\xe4\xba\xba": {
+ "en": "Foo",
+ "zh": "人",
+ },
+ (
+ b"\xe1\x44\x6e\x61\x6d\x65\xe2\x42\x65\x6e"
+ b"\x43\x46\x6f\x6f\x42\x7a\x68\x43\xe4\xba\xba"
+ ): {"name": {"en": "Foo", "zh": "人"}},
+ (
+ b"\xe1\x49\x6c\x61\x6e\x67\x75\x61\x67\x65\x73"
+ b"\x02\x04\x42\x65\x6e\x42\x7a\x68"
+ ): {"languages": ["en", "zh"]},
}
- self.validate_type_decoding('maps', maps)
+ self.validate_type_decoding("maps", maps)
- def test_pointer(self):
+ def test_pointer(self) -> None:
pointers = {
- b'\x20\x00': 0,
- b'\x20\x05': 5,
- b'\x20\x0a': 10,
- b'\x23\xff': 1023,
- b'\x28\x03\xc9': 3017,
- b'\x2f\xf7\xfb': 524283,
- b'\x2f\xff\xff': 526335,
- b'\x37\xf7\xf7\xfe': 134217726,
- b'\x37\xff\xff\xff': 134744063,
- b'\x38\x7f\xff\xff\xff': 2147483647,
- b'\x38\xff\xff\xff\xff': 4294967295,
+ b"\x20\x00": 0,
+ b"\x20\x05": 5,
+ b"\x20\x0a": 10,
+ b"\x23\xff": 1023,
+ b"\x28\x03\xc9": 3017,
+ b"\x2f\xf7\xfb": 524283,
+ b"\x2f\xff\xff": 526335,
+ b"\x37\xf7\xf7\xfe": 134217726,
+ b"\x37\xff\xff\xff": 134744063,
+ b"\x38\x7f\xff\xff\xff": 2147483647,
+ b"\x38\xff\xff\xff\xff": 4294967295,
}
- self.validate_type_decoding('pointers', pointers)
-
- strings = {
- b"\x40": '',
- b"\x41\x31": '1',
- b"\x43\xE4\xBA\xBA": '人',
- (b"\x5b\x31\x32\x33\x34"
+ self.validate_type_decoding("pointers", pointers)
+
+ strings: ClassVar = {
+ b"\x40": "",
+ b"\x41\x31": "1",
+ b"\x43\xe4\xba\xba": "人",
+ (
+ b"\x5b\x31\x32\x33\x34"
b"\x35\x36\x37\x38\x39\x30\x31\x32\x33\x34\x35"
- b"\x36\x37\x38\x39\x30\x31\x32\x33\x34\x35\x36\x37"):
- '123456789012345678901234567',
- (b"\x5c\x31\x32\x33\x34"
+ b"\x36\x37\x38\x39\x30\x31\x32\x33\x34\x35\x36\x37"
+ ): "123456789012345678901234567",
+ (
+ b"\x5c\x31\x32\x33\x34"
b"\x35\x36\x37\x38\x39\x30\x31\x32\x33\x34\x35"
b"\x36\x37\x38\x39\x30\x31\x32\x33\x34\x35\x36"
- b"\x37\x38"): '1234567890123456789012345678',
- (b"\x5d\x00\x31\x32\x33"
+ b"\x37\x38"
+ ): "1234567890123456789012345678",
+ (
+ b"\x5d\x00\x31\x32\x33"
b"\x34\x35\x36\x37\x38\x39\x30\x31\x32\x33\x34"
b"\x35\x36\x37\x38\x39\x30\x31\x32\x33\x34\x35"
- b"\x36\x37\x38\x39"): '12345678901234567890123456789',
- (b"\x5d\x01\x31\x32\x33"
+ b"\x36\x37\x38\x39"
+ ): "12345678901234567890123456789",
+ (
+ b"\x5d\x01\x31\x32\x33"
b"\x34\x35\x36\x37\x38\x39\x30\x31\x32\x33\x34"
b"\x35\x36\x37\x38\x39\x30\x31\x32\x33\x34\x35"
- b"\x36\x37\x38\x39\x30"): '123456789012345678901234567890',
- b'\x5e\x00\xd7' + 500 * b'\x78': 'x' * 500,
- b'\x5e\x06\xb3' + 2000 * b'\x78': 'x' * 2000,
- b'\x5f\x00\x10\x53' + 70000 * b'\x78': 'x' * 70000,
+ b"\x36\x37\x38\x39\x30"
+ ): "123456789012345678901234567890",
+ b"\x5e\x00\xd7" + 500 * b"\x78": "x" * 500,
+ b"\x5e\x06\xb3" + 2000 * b"\x78": "x" * 2000,
+ b"\x5f\x00\x10\x53" + 70000 * b"\x78": "x" * 70000,
}
- def test_string(self):
- self.validate_type_decoding('string', self.strings)
+ def test_string(self) -> None:
+ self.validate_type_decoding("string", self.strings)
- def test_byte(self):
- # Python 2.6 doesn't support dictionary comprehension
- b = dict((byte_from_int(0xc0 ^ int_from_byte(k[0])) + k[1:],
- v.encode('utf-8'))
- for k, v in self.strings.items())
- self.validate_type_decoding('byte', b)
+ def test_byte(self) -> None:
+ b = {
+ bytes([0xC0 ^ k[0]]) + k[1:]: v.encode("utf-8")
+ for k, v in self.strings.items()
+ }
+ self.validate_type_decoding("byte", b)
- def test_uint16(self):
+ def test_uint16(self) -> None:
uint16 = {
b"\xa0": 0,
b"\xa1\xff": 255,
@@ -154,9 +155,9 @@ def test_uint16(self):
b"\xa2\x2a\x78": 10872,
b"\xa2\xff\xff": 65535,
}
- self.validate_type_decoding('uint16', uint16)
+ self.validate_type_decoding("uint16", uint16)
- def test_uint32(self):
+ def test_uint32(self) -> None:
uint32 = {
b"\xc0": 0,
b"\xc1\xff": 255,
@@ -166,66 +167,68 @@ def test_uint32(self):
b"\xc3\xff\xff\xff": 16777215,
b"\xc4\xff\xff\xff\xff": 4294967295,
}
- self.validate_type_decoding('uint32', uint32)
+ self.validate_type_decoding("uint32", uint32)
- def generate_large_uint(self, bits):
- ctrl_byte = b'\x02' if bits == 64 else b'\x03'
+ def generate_large_uint(self, bits: int) -> dict:
+ ctrl_byte = b"\x02" if bits == 64 else b"\x03"
uints = {
- b'\x00' + ctrl_byte: 0,
- b'\x02' + ctrl_byte + b'\x01\xf4': 500,
- b'\x02' + ctrl_byte + b'\x2a\x78': 10872,
+ b"\x00" + ctrl_byte: 0,
+ b"\x02" + ctrl_byte + b"\x01\xf4": 500,
+ b"\x02" + ctrl_byte + b"\x2a\x78": 10872,
}
for power in range(bits // 8 + 1):
expected = 2 ** (8 * power) - 1
- input = byte_from_int(power) + ctrl_byte + (b'\xff' * power)
- uints[input] = expected
+ input_value = bytes([power]) + ctrl_byte + (b"\xff" * power)
+ uints[input_value] = expected
return uints
- def test_uint64(self):
- self.validate_type_decoding('uint64', self.generate_large_uint(64))
-
- def test_uint128(self):
- self.validate_type_decoding('uint128', self.generate_large_uint(128))
+ def test_uint64(self) -> None:
+ self.validate_type_decoding("uint64", self.generate_large_uint(64))
- def validate_type_decoding(self, type, tests):
- for input, expected in tests.items():
- self.check_decoding(type, input, expected)
+ def test_uint128(self) -> None:
+ self.validate_type_decoding("uint128", self.generate_large_uint(128))
- def check_decoding(self, type, input, expected, name=None):
+ def validate_type_decoding(self, data_type: str, tests: dict) -> None:
+ for input_value, expected in tests.items():
+ self.check_decoding(data_type, input_value, expected)
+ def check_decoding(
+ self,
+ data_type: str,
+ input_value: SizedBuffer,
+ expected: Any, # noqa: ANN401
+ name: str | None = None,
+ ) -> None:
name = name or expected
- db = mmap.mmap(-1, len(input))
- db.write(input)
+ db = mmap.mmap(-1, len(input_value))
+ db.write(input_value)
decoder = Decoder(db, pointer_test=True)
- (actual, _,) = decoder.decode(0)
+ (
+ actual,
+ _,
+ ) = decoder.decode(0)
- if type in ('float', 'double'):
- self.assertAlmostEqual(expected, actual, places=3, msg=type)
+ if data_type in ("float", "double"):
+ self.assertAlmostEqual(expected, actual, places=3, msg=data_type)
else:
- self.assertEqual(expected, actual, type)
+ self.assertEqual(expected, actual, data_type)
- def test_real_pointers(self):
- with open('tests/data/test-data/maps-with-pointers.raw', 'r+b') as db_file:
+ def test_real_pointers(self) -> None:
+ with open("tests/data/test-data/maps-with-pointers.raw", "r+b") as db_file:
mm = mmap.mmap(db_file.fileno(), 0)
decoder = Decoder(mm, 0)
- self.assertEqual(({'long_key': 'long_value1'}, 22),
- decoder.decode(0))
+ self.assertEqual(({"long_key": "long_value1"}, 22), decoder.decode(0))
- self.assertEqual(({'long_key': 'long_value2'}, 37),
- decoder.decode(22))
+ self.assertEqual(({"long_key": "long_value2"}, 37), decoder.decode(22))
- self.assertEqual(({'long_key2': 'long_value1'}, 50),
- decoder.decode(37))
+ self.assertEqual(({"long_key2": "long_value1"}, 50), decoder.decode(37))
- self.assertEqual(({'long_key2': 'long_value2'}, 55),
- decoder.decode(50))
+ self.assertEqual(({"long_key2": "long_value2"}, 55), decoder.decode(50))
- self.assertEqual(({'long_key': 'long_value1'}, 57),
- decoder.decode(55))
+ self.assertEqual(({"long_key": "long_value1"}, 57), decoder.decode(55))
- self.assertEqual(({'long_key2': 'long_value2'}, 59),
- decoder.decode(57))
+ self.assertEqual(({"long_key2": "long_value2"}, 59), decoder.decode(57))
mm.close()
diff --git a/tests/reader_test.py b/tests/reader_test.py
index 69e3fc0..0d4b0b2 100644
--- a/tests/reader_test.py
+++ b/tests/reader_test.py
@@ -1,45 +1,72 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
+from __future__ import annotations
-from __future__ import unicode_literals
-
-import logging
+import ipaddress
+import multiprocessing
import os
-import sys
+import pathlib
import threading
-
-from multiprocessing import Process, Pipe
+import unittest
+from typing import TYPE_CHECKING, cast
+from unittest import mock
import maxminddb
try:
import maxminddb.extension
except ImportError:
- maxminddb.extension = None
-
-from maxminddb import open_database, InvalidDatabaseError
-from maxminddb.compat import FileNotFoundError
-from maxminddb.const import (MODE_AUTO, MODE_MMAP_EXT, MODE_MMAP, MODE_FILE,
- MODE_MEMORY)
+ maxminddb.extension = None # type: ignore[assignment]
+
+from maxminddb import InvalidDatabaseError, open_database
+from maxminddb.const import (
+ MODE_AUTO,
+ MODE_FD,
+ MODE_FILE,
+ MODE_MEMORY,
+ MODE_MMAP,
+ MODE_MMAP_EXT,
+)
+
+if TYPE_CHECKING:
+ from maxminddb.reader import Reader
+
+
+def get_reader_from_file_descriptor(filepath: str, mode: int) -> Reader:
+ """Patches open_database() for class TestFDReader()."""
+ if mode == MODE_FD:
+ with open(filepath, "rb") as mmdb_fh:
+ return maxminddb.open_database(mmdb_fh, mode)
+ else:
+ # There are a few cases where mode is statically defined in
+ # BaseTestReader(). In those cases just call an unpatched
+ # open_database() with a string path.
+ return maxminddb.open_database(filepath, mode)
-if sys.version_info[:2] == (2, 6):
- import unittest2 as unittest
-else:
- import unittest
-if sys.version_info[0] == 2:
- unittest.TestCase.assertRaisesRegex = unittest.TestCase.assertRaisesRegexp
- unittest.TestCase.assertRegex = unittest.TestCase.assertRegexpMatches
+class BaseTestReader(unittest.TestCase):
+ mode: int
+ reader_class: type[maxminddb.extension.Reader | maxminddb.reader.Reader]
+ use_ip_objects = False
+ # fork doesn't work on Windows and spawn would involve pickling the reader,
+ # which isn't possible.
+ if os.name != "nt":
+ mp = multiprocessing.get_context("fork")
-class BaseTestReader(object):
+ def ipf(self, ip: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | str:
+ if self.use_ip_objects:
+ return ipaddress.ip_address(ip)
+ return ip
- def test_reader(self):
+ def test_reader(self) -> None:
for record_size in [24, 28, 32]:
for ip_version in [4, 6]:
- file_name = ('tests/data/test-data/MaxMind-DB-test-ipv' +
- str(ip_version) + '-' + str(record_size) +
- '.mmdb')
+ file_name = (
+ "tests/data/test-data/MaxMind-DB-test-ipv"
+ + str(ip_version)
+ + "-"
+ + str(record_size)
+ + ".mmdb"
+ )
reader = open_database(file_name, self.mode)
self._check_metadata(reader, ip_version, record_size)
@@ -50,179 +77,437 @@ def test_reader(self):
self._check_ip_v6(reader, file_name)
reader.close()
- def test_decoder(self):
+ def test_get_with_prefix_len(self) -> None:
+ decoder_record = {
+ "array": [1, 2, 3],
+ "boolean": True,
+ "bytes": b"\x00\x00\x00*",
+ "double": 42.123456,
+ "float": 1.100000023841858,
+ "int32": -268435456,
+ "map": {
+ "mapX": {
+ "arrayX": [7, 8, 9],
+ "utf8_stringX": "hello",
+ },
+ },
+ "uint128": 1329227995784915872903807060280344576,
+ "uint16": 0x64,
+ "uint32": 0x10000000,
+ "uint64": 0x1000000000000000,
+ "utf8_string": "unicode! ☯ - ♫",
+ }
+
+ tests = [
+ {
+ "ip": "1.1.1.1",
+ "file_name": "MaxMind-DB-test-ipv6-32.mmdb",
+ "expected_prefix_len": 8,
+ "expected_record": None,
+ },
+ {
+ "ip": "::1:ffff:ffff",
+ "file_name": "MaxMind-DB-test-ipv6-24.mmdb",
+ "expected_prefix_len": 128,
+ "expected_record": {"ip": "::1:ffff:ffff"},
+ },
+ {
+ "ip": "::2:0:1",
+ "file_name": "MaxMind-DB-test-ipv6-24.mmdb",
+ "expected_prefix_len": 122,
+ "expected_record": {"ip": "::2:0:0"},
+ },
+ {
+ "ip": "1.1.1.1",
+ "file_name": "MaxMind-DB-test-ipv4-24.mmdb",
+ "expected_prefix_len": 32,
+ "expected_record": {"ip": "1.1.1.1"},
+ },
+ {
+ "ip": "1.1.1.3",
+ "file_name": "MaxMind-DB-test-ipv4-24.mmdb",
+ "expected_prefix_len": 31,
+ "expected_record": {"ip": "1.1.1.2"},
+ },
+ {
+ "ip": "1.1.1.3",
+ "file_name": "MaxMind-DB-test-decoder.mmdb",
+ "expected_prefix_len": 24,
+ "expected_record": decoder_record,
+ },
+ {
+ "ip": "::ffff:1.1.1.128",
+ "file_name": "MaxMind-DB-test-decoder.mmdb",
+ "expected_prefix_len": 120,
+ "expected_record": decoder_record,
+ },
+ {
+ "ip": "::1.1.1.128",
+ "file_name": "MaxMind-DB-test-decoder.mmdb",
+ "expected_prefix_len": 120,
+ "expected_record": decoder_record,
+ },
+ {
+ "ip": "200.0.2.1",
+ "file_name": "MaxMind-DB-no-ipv4-search-tree.mmdb",
+ "expected_prefix_len": 0,
+ "expected_record": "::0/64",
+ },
+ {
+ "ip": "::200.0.2.1",
+ "file_name": "MaxMind-DB-no-ipv4-search-tree.mmdb",
+ "expected_prefix_len": 64,
+ "expected_record": "::0/64",
+ },
+ {
+ "ip": "0:0:0:0:ffff:ffff:ffff:ffff",
+ "file_name": "MaxMind-DB-no-ipv4-search-tree.mmdb",
+ "expected_prefix_len": 64,
+ "expected_record": "::0/64",
+ },
+ {
+ "ip": "ef00::",
+ "file_name": "MaxMind-DB-no-ipv4-search-tree.mmdb",
+ "expected_prefix_len": 1,
+ "expected_record": None,
+ },
+ ]
+
+ for test in tests:
+ with open_database(
+ "tests/data/test-data/" + cast("str", test["file_name"]),
+ self.mode,
+ ) as reader:
+ (record, prefix_len) = reader.get_with_prefix_len(
+ cast("str", test["ip"]),
+ )
+
+ self.assertEqual(
+ prefix_len,
+ test["expected_prefix_len"],
+ f"expected prefix_len of {test['expected_prefix_len']}"
+ f" for {test['ip']}"
+ f" in {test['file_name']} but got {prefix_len}",
+ )
+ self.assertEqual(
+ record,
+ test["expected_record"],
+ "expected_record for "
+ + cast("str", test["ip"])
+ + " in "
+ + cast("str", test["file_name"]),
+ )
+
+ def test_iterator(self) -> None:
+ tests = (
+ {
+ "database": "ipv4",
+ "expected": [
+ "1.1.1.1/32",
+ "1.1.1.2/31",
+ "1.1.1.4/30",
+ "1.1.1.8/29",
+ "1.1.1.16/28",
+ "1.1.1.32/32",
+ ],
+ },
+ {
+ "database": "ipv6",
+ "expected": [
+ "::1:ffff:ffff/128",
+ "::2:0:0/122",
+ "::2:0:40/124",
+ "::2:0:50/125",
+ "::2:0:58/127",
+ ],
+ },
+ {
+ "database": "mixed",
+ "expected": [
+ "1.1.1.1/32",
+ "1.1.1.2/31",
+ "1.1.1.4/30",
+ "1.1.1.8/29",
+ "1.1.1.16/28",
+ "1.1.1.32/32",
+ "::1:ffff:ffff/128",
+ "::2:0:0/122",
+ "::2:0:40/124",
+ "::2:0:50/125",
+ "::2:0:58/127",
+ ],
+ },
+ )
+
+ for record_size in [24, 28, 32]:
+ for test in tests:
+ f = (
+ f"tests/data/test-data/MaxMind-DB-test-{test['database']}"
+ f"-{record_size}.mmdb"
+ )
+ reader = open_database(f, self.mode)
+ networks = [str(n) for (n, _) in reader]
+ self.assertEqual(networks, test["expected"], f)
+
+ def test_decoder(self) -> None:
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb', self.mode)
- record = reader.get('::1.1.1.0')
-
- self.assertEqual(record['array'], [1, 2, 3])
- self.assertEqual(record['boolean'], True)
- self.assertEqual(record['bytes'], bytearray(b'\x00\x00\x00*'))
- self.assertEqual(record['double'], 42.123456)
- self.assertAlmostEqual(record['float'], 1.1)
- self.assertEqual(record['int32'], -268435456)
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ self.mode,
+ )
+ record = cast("dict", reader.get(self.ipf("::1.1.1.0")))
+
+ self.assertEqual(record["array"], [1, 2, 3])
+ self.assertEqual(record["boolean"], True)
+ self.assertEqual(record["bytes"], bytearray(b"\x00\x00\x00*"))
+ self.assertEqual(record["double"], 42.123456)
+ self.assertAlmostEqual(record["float"], 1.1)
+ self.assertEqual(record["int32"], -268435456)
self.assertEqual(
{
- 'mapX': {
- 'arrayX': [7, 8, 9],
- 'utf8_stringX': 'hello'
- },
+ "mapX": {"arrayX": [7, 8, 9], "utf8_stringX": "hello"},
},
- record['map']
+ record["map"],
)
- self.assertEqual(record['uint16'], 100)
- self.assertEqual(record['uint32'], 268435456)
- self.assertEqual(record['uint64'], 1152921504606846976)
- self.assertEqual(record['utf8_string'], 'unicode! ☯ - ♫')
+ self.assertEqual(record["uint16"], 100)
+ self.assertEqual(record["uint32"], 268435456)
+ self.assertEqual(record["uint64"], 1152921504606846976)
+ self.assertEqual(record["utf8_string"], "unicode! ☯ - ♫")
- self.assertEqual(
- 1329227995784915872903807060280344576,
- record['uint128']
- )
+ self.assertEqual(1329227995784915872903807060280344576, record["uint128"])
reader.close()
- def test_no_ipv4_search_tree(self):
+ def test_metadata_pointers(self) -> None:
+ with open_database(
+ "tests/data/test-data/MaxMind-DB-test-metadata-pointers.mmdb",
+ self.mode,
+ ) as reader:
+ self.assertEqual(
+ "Lots of pointers in metadata",
+ reader.metadata().database_type,
+ )
+
+ def test_no_ipv4_search_tree(self) -> None:
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-no-ipv4-search-tree.mmdb',
- self.mode)
+ "tests/data/test-data/MaxMind-DB-no-ipv4-search-tree.mmdb",
+ self.mode,
+ )
- self.assertEqual(reader.get('1.1.1.1'), '::0/64')
- self.assertEqual(reader.get('192.1.1.1'), '::0/64')
+ self.assertEqual(reader.get(self.ipf("1.1.1.1")), "::0/64")
+ self.assertEqual(reader.get(self.ipf("192.1.1.1")), "::0/64")
reader.close()
- def test_ipv6_address_in_ipv4_database(self):
+ def test_ipv6_address_in_ipv4_database(self) -> None:
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-ipv4-24.mmdb', self.mode)
- with self.assertRaisesRegex(ValueError,
- 'Error looking up 2001::. '
- 'You attempted to look up an IPv6 address '
- 'in an IPv4-only database'):
- reader.get('2001::')
+ "tests/data/test-data/MaxMind-DB-test-ipv4-24.mmdb",
+ self.mode,
+ )
+ with self.assertRaisesRegex(
+ ValueError,
+ "Error looking up 2001::. "
+ "You attempted to look up an IPv6 address "
+ "in an IPv4-only database",
+ ):
+ reader.get(self.ipf("2001::"))
reader.close()
- def test_broken_database(self):
- reader = open_database('tests/data/test-data/'
- 'GeoIP2-City-Test-Broken-Double-Format.mmdb',
- self.mode)
- with self.assertRaisesRegex(InvalidDatabaseError,
- "The MaxMind DB file's data "
- "section contains bad data \(unknown data "
- "type or corrupt data\)"
- ):
- reader.get('2001:220::')
+ def test_opening_path(self) -> None:
+ with open_database(
+ pathlib.Path("tests/data/test-data/MaxMind-DB-test-decoder.mmdb"),
+ self.mode,
+ ) as reader:
+ self.assertEqual(reader.metadata().database_type, "MaxMind DB Decoder Test")
+
+ def test_no_extension_exception(self) -> None:
+ real_extension = maxminddb._extension # noqa: SLF001
+ maxminddb._extension = None # type: ignore[assignment] # noqa: SLF001
+ with self.assertRaisesRegex(
+ ValueError,
+ "MODE_MMAP_EXT requires the maxminddb.extension module to be available",
+ ):
+ open_database(
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ MODE_MMAP_EXT,
+ )
+ maxminddb._extension = real_extension # noqa: SLF001
+
+ def test_broken_database(self) -> None:
+ reader = open_database(
+ "tests/data/test-data/GeoIP2-City-Test-Broken-Double-Format.mmdb",
+ self.mode,
+ )
+ with self.assertRaisesRegex(
+ InvalidDatabaseError,
+ r"The MaxMind DB file's data "
+ r"section contains bad data \(unknown data "
+ r"type or corrupt data\)",
+ ):
+ reader.get(self.ipf("2001:220::"))
reader.close()
- def test_ip_validation(self):
+ def test_ip_validation(self) -> None:
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb', self.mode)
- self.assertRaisesRegex(ValueError,
- "'not_ip' does not appear to be an IPv4 or "
- "IPv6 address",
- reader.get, ('not_ip'))
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ self.mode,
+ )
+ with self.assertRaisesRegex(
+ ValueError,
+ "'not_ip' does not appear to be an IPv4 or IPv6 address",
+ ):
+ reader.get("not_ip")
reader.close()
- def test_missing_database(self):
- self.assertRaisesRegex(FileNotFoundError,
- "No such file or directory",
- open_database, 'file-does-not-exist.mmdb',
- self.mode)
-
- def test_nondatabase(self):
- self.assertRaisesRegex(InvalidDatabaseError,
- 'Error opening database file \(README.rst\). '
- 'Is this a valid MaxMind DB file\?',
- open_database, 'README.rst', self.mode)
-
- def test_too_many_constructor_args(self):
- cls = self.readerClass[0]
- self.assertRaises(
- TypeError, cls, 'README.md', self.mode, 1)
-
- def test_bad_constructor_mode(self):
- cls = self.readerClass[0]
- self.assertRaisesRegex(
- ValueError, 'Unsupported open mode \(100\)', cls, 'README.md',
- mode=100)
-
- def test_no_constructor_args(self):
- cls = self.readerClass[0]
- self.assertRaisesRegex(
- TypeError, ' 1 required positional argument|\(pos 1\) not found|takes at least 2 arguments',
- cls)
-
- def test_too_many_get_args(self):
+ def test_missing_database(self) -> None:
+ with self.assertRaisesRegex(FileNotFoundError, "No such file or directory"):
+ open_database("file-does-not-exist.mmdb", self.mode)
+
+ def test_nondatabase(self) -> None:
+ with self.assertRaisesRegex(
+ InvalidDatabaseError,
+ r"Error opening database file \(README.rst\). "
+ r"Is this a valid MaxMind DB file\?",
+ ):
+ open_database("README.rst", self.mode)
+
+ # This is from https://github.com/maxmind/MaxMind-DB-Reader-python/issues/58
+ def test_database_with_invalid_utf8_key(self) -> None:
+ reader = open_database(
+ "tests/data/bad-data/maxminddb-python/bad-unicode-in-map-key.mmdb",
+ self.mode,
+ )
+ with self.assertRaises(UnicodeDecodeError):
+ reader.get_with_prefix_len("163.254.149.39")
+
+ def test_too_many_constructor_args(self) -> None:
+ with self.assertRaises(TypeError):
+ self.reader_class("README.md", self.mode, 1) # type: ignore[arg-type,call-arg]
+
+ def test_bad_constructor_mode(self) -> None:
+ with self.assertRaisesRegex(ValueError, r"Unsupported open mode \(100\)"):
+ self.reader_class("README.md", mode=100) # type: ignore[arg-type]
+
+ def test_no_constructor_args(self) -> None:
+ with self.assertRaisesRegex(
+ TypeError,
+ r" 1 required positional argument|"
+ r"\(pos 1\) not found|"
+ r"takes at least 2 arguments|"
+ r"function missing required argument \'database\' \(pos 1\)",
+ ):
+ self.reader_class() # type: ignore[call-arg]
+
+ def test_too_many_get_args(self) -> None:
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb', self.mode
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ self.mode,
)
- self.assertRaises(TypeError, reader.get, ('1.1.1.1', 'blah'))
+ with self.assertRaises(TypeError):
+ reader.get(self.ipf("1.1.1.1"), "blah") # type: ignore[call-arg]
reader.close()
- def test_no_get_args(self):
+ def test_no_get_args(self) -> None:
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb',
- self.mode
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ self.mode,
)
- self.assertRaises(TypeError, reader.get)
+ with self.assertRaises(TypeError):
+ reader.get() # type: ignore[call-arg]
+ reader.close()
+
+ def test_incorrect_get_arg_type(self) -> None:
+ reader = open_database("tests/data/test-data/GeoIP2-City-Test.mmdb", self.mode)
+ with self.assertRaisesRegex(
+ TypeError,
+ "argument 1 must be a string or ipaddress object",
+ ):
+ reader.get(1) # type: ignore[arg-type]
reader.close()
- def test_metadata_args(self):
+ def test_metadata_args(self) -> None:
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb',
- self.mode
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ self.mode,
)
- self.assertRaises(TypeError, reader.metadata, ('blah'))
+ with self.assertRaises(TypeError):
+ reader.metadata("blah") # type: ignore[call-arg]
reader.close()
- def test_metadata_unknown_attribute(self):
+ def test_metadata_unknown_attribute(self) -> None:
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb',
- self.mode
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ self.mode,
)
metadata = reader.metadata()
- with self.assertRaisesRegex(AttributeError,
- "'Metadata' object has no "
- "attribute 'blah'"):
- metadata.blah
+ with self.assertRaisesRegex(
+ AttributeError,
+ "'Metadata' object has no attribute 'blah'",
+ ):
+ metadata.blah # type: ignore[attr-defined] # noqa: B018
reader.close()
- def test_close(self):
+ def test_close(self) -> None:
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb',
- self.mode
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ self.mode,
)
reader.close()
- def test_double_close(self):
+ def test_double_close(self) -> None:
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb',
- self.mode
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ self.mode,
)
reader.close()
- self.assertIsNone(
- reader.close(), 'Double close does not throw an exception')
+ # Check that calling close again doesn't raise an exception
+ reader.close()
- def test_closed_get(self):
- if self.mode == MODE_MEMORY:
+ def test_closed_get(self) -> None:
+ if self.mode in [MODE_MEMORY, MODE_FD]:
return
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb',
- self.mode
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ self.mode,
+ )
+ reader.close()
+ with self.assertRaisesRegex(
+ ValueError,
+ "Attempt to read from a closed MaxMind DB.|closed",
+ ):
+ reader.get(self.ipf("1.1.1.1"))
+
+ def test_with_statement(self) -> None:
+ filename = "tests/data/test-data/MaxMind-DB-test-ipv4-24.mmdb"
+ with open_database(filename, self.mode) as reader:
+ self._check_ip_v4(reader, filename)
+ self.assertEqual(reader.closed, True)
+
+ def test_with_statement_close(self) -> None:
+ filename = "tests/data/test-data/MaxMind-DB-test-ipv4-24.mmdb"
+ reader = open_database(filename, self.mode)
+ reader.close()
+
+ with (
+ self.assertRaisesRegex(
+ ValueError,
+ "Attempt to reopen a closed MaxMind DB",
+ ),
+ reader,
+ ):
+ pass
+
+ def test_closed(self) -> None:
+ reader = open_database(
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ self.mode,
)
+ self.assertEqual(reader.closed, False)
reader.close()
- self.assertRaisesRegex(ValueError,
- 'Attempt to read from a closed MaxMind DB.'
- '|closed',
- reader.get, ('1.1.1.1'))
-
- # XXX - Figure out whether we want to have the same behavior on both the
- # extension and the pure Python reader. If we do, the pure Python
- # reader will need to throw an exception or the extension will need
- # to keep the metadata in memory.
- def test_closed_metadata(self):
+ self.assertEqual(reader.closed, True)
+
+ def test_closed_metadata(self) -> None:
reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb',
- self.mode
+ "tests/data/test-data/MaxMind-DB-test-decoder.mmdb",
+ self.mode,
)
reader.close()
@@ -230,189 +515,220 @@ def test_closed_metadata(self):
# segfault
try:
metadata = reader.metadata()
- except IOError as ex:
- self.assertEqual('Attempt to read from a closed MaxMind DB.',
- str(ex), 'extension throws exception')
+ except OSError as ex:
+ self.assertEqual(
+ "Attempt to read from a closed MaxMind DB.",
+ str(ex),
+ "extension throws exception",
+ )
else:
- self.assertIsNotNone(
- metadata, 'pure Python implementation returns value')
-
- def test_multiprocessing(self):
- self._check_concurrency(Process)
-
- def test_threading(self):
- self._check_concurrency(threading.Thread)
+ self.assertIsNotNone(metadata, "pure Python implementation returns value")
- if sys.version_info[0] == 2:
- def test_byte_ip_on_python2(self):
- reader = open_database(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb', self.mode)
- record = reader.get(b'::1.1.1.0')
-
- def _check_concurrency(self, worker_class):
- reader = open_database(
- 'tests/data/test-data/GeoIP2-Domain-Test.mmdb',
- self.mode
- )
-
- def lookup(pipe):
- try:
- for i in range(32):
- reader.get('65.115.240.{i}'.format(i=i))
- pipe.send(1)
- except:
- pipe.send(0)
- finally:
- if worker_class is Process:
- reader.close()
- pipe.close()
-
- pipes = [Pipe() for _ in range(32)]
- procs = [worker_class(target=lookup, args=(c,)) for (p, c) in pipes]
- for proc in procs:
- proc.start()
- for proc in procs:
- proc.join()
+ if os.name != "nt":
- reader.close()
+ def test_multiprocessing(self):
+ self._check_concurrency(self.mp.Process)
- count = sum([p.recv() for (p, c) in pipes])
+ def test_threading(self):
+ self._check_concurrency(threading.Thread)
- self.assertEqual(count, 32, 'expected number of successful lookups')
+ def _check_concurrency(self, worker_class) -> None: # noqa: ANN001
+ reader = open_database(
+ "tests/data/test-data/GeoIP2-Domain-Test.mmdb",
+ self.mode,
+ )
- def _check_metadata(self, reader, ip_version, record_size):
+ def lookup(pipe) -> None: # noqa: ANN001
+ try:
+ for i in range(32):
+ reader.get(self.ipf(f"65.115.240.{i}"))
+ pipe.send(1)
+ except: # noqa: E722
+ pipe.send(0)
+ finally:
+ if worker_class is self.mp.Process: # type: ignore[attr-defined]
+ reader.close()
+ pipe.close()
+
+ pipes = [self.mp.Pipe() for _ in range(32)]
+ procs = [worker_class(target=lookup, args=(c,)) for (_, c) in pipes]
+ for proc in procs:
+ proc.start()
+ for proc in procs:
+ proc.join()
+
+ reader.close()
+
+ count = sum([p.recv() for (p, _) in pipes])
+
+ self.assertEqual(count, 32, "expected number of successful lookups")
+
+ def _check_metadata(
+ self,
+ reader: Reader,
+ ip_version: int,
+ record_size: int,
+ ) -> None:
metadata = reader.metadata()
- self.assertEqual(
- 2,
- metadata.binary_format_major_version,
- 'major version'
- )
+ self.assertEqual(2, metadata.binary_format_major_version, "major version")
self.assertEqual(metadata.binary_format_minor_version, 0)
self.assertGreater(metadata.build_epoch, 1373571901)
- self.assertEqual(metadata.database_type, 'Test')
+ self.assertEqual(metadata.database_type, "Test")
self.assertEqual(
- {'en': 'Test Database', 'zh': 'Test Database Chinese'},
- metadata.description
+ {"en": "Test Database", "zh": "Test Database Chinese"},
+ metadata.description,
)
self.assertEqual(metadata.ip_version, ip_version)
- self.assertEqual(metadata.languages, ['en', 'zh'])
+ self.assertEqual(metadata.languages, ["en", "zh"])
self.assertGreater(metadata.node_count, 36)
self.assertEqual(metadata.record_size, record_size)
- def _check_ip_v4(self, reader, file_name):
+ def _check_ip_v4(self, reader: Reader, file_name: str) -> None:
for i in range(6):
- address = '1.1.1.' + str(pow(2, i))
+ address = "1.1.1." + str(pow(2, i))
self.assertEqual(
- {'ip': address},
- reader.get(address),
- 'found expected data record for '
- + address + ' in ' + file_name
+ {"ip": address},
+ reader.get(self.ipf(address)),
+ "found expected data record for " + address + " in " + file_name,
)
pairs = {
- '1.1.1.3': '1.1.1.2',
- '1.1.1.5': '1.1.1.4',
- '1.1.1.7': '1.1.1.4',
- '1.1.1.9': '1.1.1.8',
- '1.1.1.15': '1.1.1.8',
- '1.1.1.17': '1.1.1.16',
- '1.1.1.31': '1.1.1.16'
+ "1.1.1.3": "1.1.1.2",
+ "1.1.1.5": "1.1.1.4",
+ "1.1.1.7": "1.1.1.4",
+ "1.1.1.9": "1.1.1.8",
+ "1.1.1.15": "1.1.1.8",
+ "1.1.1.17": "1.1.1.16",
+ "1.1.1.31": "1.1.1.16",
}
for key_address, value_address in pairs.items():
- data = {'ip': value_address}
+ data = {"ip": value_address}
self.assertEqual(
data,
- reader.get(key_address),
- 'found expected data record for ' + key_address + ' in '
- + file_name
+ reader.get(self.ipf(key_address)),
+ "found expected data record for " + key_address + " in " + file_name,
)
- for ip in ['1.1.1.33', '255.254.253.123']:
- self.assertIsNone(reader.get(ip))
+ for ip in ["1.1.1.33", "255.254.253.123"]:
+ self.assertIsNone(reader.get(self.ipf(ip)))
- def _check_ip_v6(self, reader, file_name):
- subnets = ['::1:ffff:ffff', '::2:0:0',
- '::2:0:40', '::2:0:50', '::2:0:58']
+ def _check_ip_v6(self, reader: Reader, file_name: str) -> None:
+ subnets = ["::1:ffff:ffff", "::2:0:0", "::2:0:40", "::2:0:50", "::2:0:58"]
for address in subnets:
self.assertEqual(
- {'ip': address},
- reader.get(address),
- 'found expected data record for ' + address + ' in '
- + file_name
+ {"ip": address},
+ reader.get(self.ipf(address)),
+ "found expected data record for " + address + " in " + file_name,
)
pairs = {
- '::2:0:1': '::2:0:0',
- '::2:0:33': '::2:0:0',
- '::2:0:39': '::2:0:0',
- '::2:0:41': '::2:0:40',
- '::2:0:49': '::2:0:40',
- '::2:0:52': '::2:0:50',
- '::2:0:57': '::2:0:50',
- '::2:0:59': '::2:0:58'
+ "::2:0:1": "::2:0:0",
+ "::2:0:33": "::2:0:0",
+ "::2:0:39": "::2:0:0",
+ "::2:0:41": "::2:0:40",
+ "::2:0:49": "::2:0:40",
+ "::2:0:52": "::2:0:50",
+ "::2:0:57": "::2:0:50",
+ "::2:0:59": "::2:0:58",
}
for key_address, value_address in pairs.items():
self.assertEqual(
- {'ip': value_address},
- reader.get(key_address),
- 'found expected data record for ' + key_address + ' in '
- + file_name
+ {"ip": value_address},
+ reader.get(self.ipf(key_address)),
+ "found expected data record for " + key_address + " in " + file_name,
)
- for ip in ['1.1.1.33', '255.254.253.123', '89fa::']:
- self.assertIsNone(reader.get(ip))
+ for ip in ["1.1.1.33", "255.254.253.123", "89fa::"]:
+ self.assertIsNone(reader.get(self.ipf(ip)))
+
+
+def has_maxminddb_extension() -> bool:
+ return maxminddb.extension is not None and hasattr(
+ maxminddb.extension,
+ "Reader",
+ )
-def has_maxminddb_extension():
- return maxminddb.extension and hasattr(maxminddb.extension, 'Reader')
+@unittest.skipIf(
+ not has_maxminddb_extension() and not os.environ.get("MM_FORCE_EXT_TESTS"),
+ "No C extension module found. Skipping tests",
+)
+class TestExtensionReader(BaseTestReader):
+ mode = MODE_MMAP_EXT
+
+ if has_maxminddb_extension():
+ reader_class = maxminddb.extension.Reader
-@unittest.skipIf(not has_maxminddb_extension()
- and not os.environ.get('MM_FORCE_EXT_TESTS'),
- 'No C extension module found. Skipping tests')
-class TestExtensionReader(BaseTestReader, unittest.TestCase):
+@unittest.skipIf(
+ not has_maxminddb_extension() and not os.environ.get("MM_FORCE_EXT_TESTS"),
+ "No C extension module found. Skipping tests",
+)
+class TestExtensionReaderWithIPObjects(BaseTestReader):
mode = MODE_MMAP_EXT
+ use_ip_objects = True
if has_maxminddb_extension():
- readerClass = [maxminddb.extension.Reader]
+ reader_class = maxminddb.extension.Reader
-class TestAutoReader(BaseTestReader, unittest.TestCase):
+class TestAutoReader(BaseTestReader):
mode = MODE_AUTO
+ reader_class: type[maxminddb.extension.Reader | maxminddb.reader.Reader]
if has_maxminddb_extension():
- readerClass = [maxminddb.extension.Reader]
+ reader_class = maxminddb.extension.Reader
else:
- readerClass = [maxminddb.reader.Reader]
+ reader_class = maxminddb.reader.Reader
+
+
+class TestMMAPReader(BaseTestReader):
+ mode = MODE_MMAP
+ reader_class = maxminddb.reader.Reader
-class TestMMAPReader(BaseTestReader, unittest.TestCase):
+# We want one pure Python test to use IP objects, it doesn't
+# really matter which one.
+class TestMMAPReaderWithIPObjects(BaseTestReader):
mode = MODE_MMAP
- readerClass = [maxminddb.reader.Reader]
+ use_ip_objects = True
+ reader_class = maxminddb.reader.Reader
-class TestFileReader(BaseTestReader, unittest.TestCase):
+class TestFileReader(BaseTestReader):
mode = MODE_FILE
- readerClass = [maxminddb.reader.Reader]
+ reader_class = maxminddb.reader.Reader
-class TestMemoryReader(BaseTestReader, unittest.TestCase):
+class TestMemoryReader(BaseTestReader):
mode = MODE_MEMORY
- readerClass = [maxminddb.reader.Reader]
+ reader_class = maxminddb.reader.Reader
-class TestOldReader(unittest.TestCase):
+class TestFDReader(BaseTestReader):
+ def setUp(self) -> None:
+ self.open_database_patcher = mock.patch(__name__ + ".open_database")
+ self.addCleanup(self.open_database_patcher.stop)
+ self.open_database = self.open_database_patcher.start()
+ self.open_database.side_effect = get_reader_from_file_descriptor
+
+ mode = MODE_FD
+ reader_class = maxminddb.reader.Reader
+
- def test_old_reader(self):
- reader = maxminddb.Reader(
- 'tests/data/test-data/MaxMind-DB-test-decoder.mmdb')
- record = reader.get('::1.1.1.0')
+class TestOldReader(unittest.TestCase):
+ def test_old_reader(self) -> None:
+ reader = maxminddb.Reader("tests/data/test-data/MaxMind-DB-test-decoder.mmdb")
+ record = cast("dict", reader.get("::1.1.1.0"))
- self.assertEqual(record['array'], [1, 2, 3])
+ self.assertEqual(record["array"], [1, 2, 3])
reader.close()
+
+
+del BaseTestReader
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..8fe53d8
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,219 @@
+version = 1
+revision = 2
+requires-python = ">=3.9"
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version == '3.11.*'",
+ "python_full_version < '3.11'",
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload-time = "2024-07-12T22:26:00.161Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
+]
+
+[[package]]
+name = "maxminddb"
+version = "2.7.0"
+source = { editable = "." }
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+]
+lint = [
+ { name = "mypy" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [{ name = "pytest", specifier = ">=8.3.5" }]
+lint = [
+ { name = "mypy", specifier = ">=1.15.0" },
+ { name = "ruff", specifier = ">=0.11.6" },
+]
+
+[[package]]
+name = "mypy"
+version = "1.15.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mypy-extensions" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/f8/65a7ce8d0e09b6329ad0c8d40330d100ea343bd4dd04c4f8ae26462d0a17/mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", size = 10738433, upload-time = "2025-02-05T03:49:29.145Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/95/9c0ecb8eacfe048583706249439ff52105b3f552ea9c4024166c03224270/mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", size = 9861472, upload-time = "2025-02-05T03:49:16.986Z" },
+ { url = "https://files.pythonhosted.org/packages/84/09/9ec95e982e282e20c0d5407bc65031dfd0f0f8ecc66b69538296e06fcbee/mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", size = 11611424, upload-time = "2025-02-05T03:49:46.908Z" },
+ { url = "https://files.pythonhosted.org/packages/78/13/f7d14e55865036a1e6a0a69580c240f43bc1f37407fe9235c0d4ef25ffb0/mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", size = 12365450, upload-time = "2025-02-05T03:50:05.89Z" },
+ { url = "https://files.pythonhosted.org/packages/48/e1/301a73852d40c241e915ac6d7bcd7fedd47d519246db2d7b86b9d7e7a0cb/mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", size = 12551765, upload-time = "2025-02-05T03:49:33.56Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701, upload-time = "2025-02-05T03:49:38.981Z" },
+ { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338, upload-time = "2025-02-05T03:50:17.287Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540, upload-time = "2025-02-05T03:49:51.21Z" },
+ { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051, upload-time = "2025-02-05T03:50:20.885Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751, upload-time = "2025-02-05T03:49:42.408Z" },
+ { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783, upload-time = "2025-02-05T03:49:07.707Z" },
+ { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618, upload-time = "2025-02-05T03:49:54.581Z" },
+ { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981, upload-time = "2025-02-05T03:50:28.25Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175, upload-time = "2025-02-05T03:50:13.411Z" },
+ { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675, upload-time = "2025-02-05T03:50:31.421Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020, upload-time = "2025-02-05T03:48:48.705Z" },
+ { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582, upload-time = "2025-02-05T03:49:03.628Z" },
+ { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614, upload-time = "2025-02-05T03:50:00.313Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload-time = "2025-02-05T03:48:55.789Z" },
+ { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload-time = "2025-02-05T03:48:44.581Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload-time = "2025-02-05T03:49:25.514Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload-time = "2025-02-05T03:49:57.623Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload-time = "2025-02-05T03:48:52.361Z" },
+ { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload-time = "2025-02-05T03:49:11.395Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/fa/79cf41a55b682794abe71372151dbbf856e3008f6767057229e6649d294a/mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078", size = 10737129, upload-time = "2025-02-05T03:50:24.509Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/33/dd8feb2597d648de29e3da0a8bf4e1afbda472964d2a4a0052203a6f3594/mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba", size = 9856335, upload-time = "2025-02-05T03:49:36.398Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/b5/74508959c1b06b96674b364ffeb7ae5802646b32929b7701fc6b18447592/mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5", size = 11611935, upload-time = "2025-02-05T03:49:14.154Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/53/da61b9d9973efcd6507183fdad96606996191657fe79701b2c818714d573/mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b", size = 12365827, upload-time = "2025-02-05T03:48:59.458Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/72/965bd9ee89540c79a25778cc080c7e6ef40aa1eeac4d52cec7eae6eb5228/mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2", size = 12541924, upload-time = "2025-02-05T03:50:03.12Z" },
+ { url = "https://files.pythonhosted.org/packages/46/d0/f41645c2eb263e6c77ada7d76f894c580c9ddb20d77f0c24d34273a4dab2/mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980", size = 9271176, upload-time = "2025-02-05T03:50:10.86Z" },
+ { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433, upload-time = "2023-02-04T12:11:27.157Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "24.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "8.3.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.11.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/11/bcef6784c7e5d200b8a1f5c2ddf53e5da0efec37e6e5a44d163fb97e04ba/ruff-0.11.6.tar.gz", hash = "sha256:bec8bcc3ac228a45ccc811e45f7eb61b950dbf4cf31a67fa89352574b01c7d79", size = 4010053, upload-time = "2025-04-17T13:35:53.905Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/1f/8848b625100ebcc8740c8bac5b5dd8ba97dd4ee210970e98832092c1635b/ruff-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:d84dcbe74cf9356d1bdb4a78cf74fd47c740bf7bdeb7529068f69b08272239a1", size = 10248105, upload-time = "2025-04-17T13:35:14.758Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/47/c44036e70c6cc11e6ee24399c2a1e1f1e99be5152bd7dff0190e4b325b76/ruff-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9bc583628e1096148011a5d51ff3c836f51899e61112e03e5f2b1573a9b726de", size = 11001494, upload-time = "2025-04-17T13:35:18.444Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/5b/170444061650202d84d316e8f112de02d092bff71fafe060d3542f5bc5df/ruff-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2959049faeb5ba5e3b378709e9d1bf0cab06528b306b9dd6ebd2a312127964a", size = 10352151, upload-time = "2025-04-17T13:35:20.563Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/91/f02839fb3787c678e112c8865f2c3e87cfe1744dcc96ff9fc56cfb97dda2/ruff-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63c5d4e30d9d0de7fedbfb3e9e20d134b73a30c1e74b596f40f0629d5c28a193", size = 10541951, upload-time = "2025-04-17T13:35:22.522Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f3/c09933306096ff7a08abede3cc2534d6fcf5529ccd26504c16bf363989b5/ruff-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4b9a4e1439f7d0a091c6763a100cef8fbdc10d68593df6f3cfa5abdd9246e", size = 10079195, upload-time = "2025-04-17T13:35:24.485Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/a87f8933fccbc0d8c653cfbf44bedda69c9582ba09210a309c066794e2ee/ruff-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5edf270223dd622218256569636dc3e708c2cb989242262fe378609eccf1308", size = 11698918, upload-time = "2025-04-17T13:35:26.504Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7d/8eac0bd083ea8a0b55b7e4628428203441ca68cd55e0b67c135a4bc6e309/ruff-0.11.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f55844e818206a9dd31ff27f91385afb538067e2dc0beb05f82c293ab84f7d55", size = 12319426, upload-time = "2025-04-17T13:35:28.452Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/dc/d0c17d875662d0c86fadcf4ca014ab2001f867621b793d5d7eef01b9dcce/ruff-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d8f782286c5ff562e4e00344f954b9320026d8e3fae2ba9e6948443fafd9ffc", size = 11791012, upload-time = "2025-04-17T13:35:30.455Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/f3/81a1aea17f1065449a72509fc7ccc3659cf93148b136ff2a8291c4bc3ef1/ruff-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01c63ba219514271cee955cd0adc26a4083df1956d57847978383b0e50ffd7d2", size = 13949947, upload-time = "2025-04-17T13:35:33.133Z" },
+ { url = "https://files.pythonhosted.org/packages/61/9f/a3e34de425a668284e7024ee6fd41f452f6fa9d817f1f3495b46e5e3a407/ruff-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15adac20ef2ca296dd3d8e2bedc6202ea6de81c091a74661c3666e5c4c223ff6", size = 11471753, upload-time = "2025-04-17T13:35:35.416Z" },
+ { url = "https://files.pythonhosted.org/packages/df/c5/4a57a86d12542c0f6e2744f262257b2aa5a3783098ec14e40f3e4b3a354a/ruff-0.11.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4dd6b09e98144ad7aec026f5588e493c65057d1b387dd937d7787baa531d9bc2", size = 10417121, upload-time = "2025-04-17T13:35:38.224Z" },
+ { url = "https://files.pythonhosted.org/packages/58/3f/a3b4346dff07ef5b862e2ba06d98fcbf71f66f04cf01d375e871382b5e4b/ruff-0.11.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:45b2e1d6c0eed89c248d024ea95074d0e09988d8e7b1dad8d3ab9a67017a5b03", size = 10073829, upload-time = "2025-04-17T13:35:40.255Z" },
+ { url = "https://files.pythonhosted.org/packages/93/cc/7ed02e0b86a649216b845b3ac66ed55d8aa86f5898c5f1691797f408fcb9/ruff-0.11.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd40de4115b2ec4850302f1a1d8067f42e70b4990b68838ccb9ccd9f110c5e8b", size = 11076108, upload-time = "2025-04-17T13:35:42.559Z" },
+ { url = "https://files.pythonhosted.org/packages/39/5e/5b09840fef0eff1a6fa1dea6296c07d09c17cb6fb94ed5593aa591b50460/ruff-0.11.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:77cda2dfbac1ab73aef5e514c4cbfc4ec1fbef4b84a44c736cc26f61b3814cd9", size = 11512366, upload-time = "2025-04-17T13:35:45.702Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/4c/1cd5a84a412d3626335ae69f5f9de2bb554eea0faf46deb1f0cb48534042/ruff-0.11.6-py3-none-win32.whl", hash = "sha256:5151a871554be3036cd6e51d0ec6eef56334d74dfe1702de717a995ee3d5b287", size = 10485900, upload-time = "2025-04-17T13:35:47.695Z" },
+ { url = "https://files.pythonhosted.org/packages/42/46/8997872bc44d43df986491c18d4418f1caff03bc47b7f381261d62c23442/ruff-0.11.6-py3-none-win_amd64.whl", hash = "sha256:cce85721d09c51f3b782c331b0abd07e9d7d5f775840379c640606d3159cae0e", size = 11558592, upload-time = "2025-04-17T13:35:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/6a/65fecd51a9ca19e1477c3879a7fda24f8904174d1275b419422ac00f6eee/ruff-0.11.6-py3-none-win_arm64.whl", hash = "sha256:3567ba0d07fb170b1b48d944715e3294b77f5b7679e8ba258199a250383ccb79", size = 10682766, upload-time = "2025-04-17T13:35:52.014Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" },
+ { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" },
+ { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" },
+ { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" },
+ { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" },
+ { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" },
+ { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" },
+ { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
+ { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
+ { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
+ { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
+ { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.13.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
+]