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" }, +]