From ed1b79fcf7acb2fa6249d43be92025a22bd493cb Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 22:30:28 +0000 Subject: [PATCH 01/14] Import InvalidDatabaseError from maxminddb.errors maxminddb.decoder does not export InvalidDatabaseError. It only imports it. mypy --strict reports the import as an implicit re-export. Co-Authored-By: Claude Opus 5.5 --- maxminddb/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maxminddb/__init__.py b/maxminddb/__init__.py index b3ef0ef6..576dc1d0 100644 --- a/maxminddb/__init__.py +++ b/maxminddb/__init__.py @@ -13,7 +13,7 @@ MODE_MMAP, MODE_MMAP_EXT, ) -from .decoder import InvalidDatabaseError +from .errors import InvalidDatabaseError from .reader import Reader if TYPE_CHECKING: From cfbf57dbe1b30e9bd6c22b648f9ef07b8f2b17f2 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 22:38:44 +0000 Subject: [PATCH 02/14] Export Mode from maxminddb Since 3.0.0, the README has said that the modes are available from maxminddb.Mode. maxminddb did not import Mode, so that attribute raised AttributeError. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 2 ++ maxminddb/__init__.py | 2 ++ tests/reader_test.py | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index be72a238..1f21a45c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -71,6 +71,8 @@ History from the C ``Reader.metadata()`` has the ``UnicodeDecodeError`` as its cause. +* Added ``maxminddb.Mode``, which the README already described. + 3.2.0 (2026-09-10) ++++++++++++++++++ diff --git a/maxminddb/__init__.py b/maxminddb/__init__.py index 576dc1d0..c17b230a 100644 --- a/maxminddb/__init__.py +++ b/maxminddb/__init__.py @@ -12,6 +12,7 @@ MODE_MEMORY, MODE_MMAP, MODE_MMAP_EXT, + Mode, ) from .errors import InvalidDatabaseError from .reader import Reader @@ -33,6 +34,7 @@ "MODE_MMAP", "MODE_MMAP_EXT", "InvalidDatabaseError", + "Mode", "Reader", "open_database", ] diff --git a/tests/reader_test.py b/tests/reader_test.py index 45db4876..ee3bcf7c 100644 --- a/tests/reader_test.py +++ b/tests/reader_test.py @@ -1418,6 +1418,11 @@ class TestExtensionReaderWithIPObjects(BaseTestReader): reader_class = maxminddb.extension.Reader +class TestModule(unittest.TestCase): + def test_mode_is_exported(self) -> None: + self.assertIs(maxminddb.Mode, maxminddb.const.Mode) + + @unittest.skipIf( not has_maxminddb_extension() and not os.environ.get("MM_FORCE_EXT_TESTS"), "No C extension module found. Skipping tests", From 9fe9520fdc6d6ad6417e368c89a7187bd5c9442c Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 22:55:06 +0000 Subject: [PATCH 03/14] Add a release note for the concrete Record types GitHub #464 replaced the AnyStr TypeVar in Primitive with str | bytes. Primitive and Record are no longer generic aliases, so a subscript such as Record[str] now raises TypeError. HISTORY.rst did not mention the change. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index 1f21a45c..36333eee 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -72,6 +72,9 @@ History cause. * Added ``maxminddb.Mode``, which the README already described. +* ``maxminddb.types.Record`` and ``Primitive`` are no longer generic type + aliases. Remove any subscript, such as ``Record[str]``. Pull request by Adam + Hitchcock. GitHub #464. 3.2.0 (2026-09-10) ++++++++++++++++++ From 8e852cee9abe6fedbcdd94b50ed818cd9082a746 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 22:36:18 +0000 Subject: [PATCH 04/14] Add bytearray to Primitive The C extension decodes the MaxMind DB bytes type to bytearray. The pure Python reader decodes it to bytes. MODE_AUTO uses the extension when it is available, so most callers get bytearray. mypy does not treat bytearray as bytes, so it reported an isinstance(value, bytearray) check as unreachable. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 5 +++++ maxminddb/types.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 36333eee..a9fbc65a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -76,6 +76,11 @@ History aliases. Remove any subscript, such as ``Record[str]``. Pull request by Adam Hitchcock. GitHub #464. +* Type hints: + + * ``Primitive`` includes ``bytearray``, which the C extension returns for + the ``bytes`` type. + 3.2.0 (2026-09-10) ++++++++++++++++++ diff --git a/maxminddb/types.py b/maxminddb/types.py index dcb7b61e..eafe1655 100644 --- a/maxminddb/types.py +++ b/maxminddb/types.py @@ -4,7 +4,7 @@ from typing import TypeAlias -Primitive: TypeAlias = str | bytes | bool | float | int +Primitive: TypeAlias = str | bytes | bytearray | bool | float | int RecordList: TypeAlias = list["Record"] """RecordList is a type for lists in a database record.""" From 6240558ebb03db24828c14b937bcbfd65f8756df Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 22:36:32 +0000 Subject: [PATCH 05/14] Add type arguments to Reader.__iter__ __iter__ and _generate_children returned a bare Iterator, so strict type checkers inferred Unknown for the network and record of each item. The extension stub already declares the item type. Use the same type. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 1 + maxminddb/reader.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index a9fbc65a..fcce74bf 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -80,6 +80,7 @@ History * ``Primitive`` includes ``bytearray``, which the C extension returns for the ``bytes`` type. + * ``Reader.__iter__`` declares its item type. 3.2.0 (2026-09-10) ++++++++++++++++++ diff --git a/maxminddb/reader.py b/maxminddb/reader.py index ac2b71ff..9da5913f 100644 --- a/maxminddb/reader.py +++ b/maxminddb/reader.py @@ -229,10 +229,12 @@ def get_with_prefix_len( return self._resolve_data_pointer(pointer), prefix_len return None, prefix_len - def __iter__(self) -> Iterator: + def __iter__(self) -> Iterator[tuple[IPv4Network | IPv6Network, Record]]: return self._iterate(self._generation) - def _iterate(self, generation: int) -> Iterator: + def _iterate( + self, generation: int + ) -> Iterator[tuple[IPv4Network | IPv6Network, Record]]: children = self._generate_children(0, 0, 0) while True: # Check before the walk resumes and reads more nodes, as the C @@ -247,7 +249,12 @@ def _iterate(self, generation: int) -> Iterator: return yield record - def _generate_children(self, node: int, depth: int, ip_acc: int) -> Iterator: + def _generate_children( + self, + node: int, + depth: int, + ip_acc: int, + ) -> Iterator[tuple[IPv4Network | IPv6Network, Record]]: node_count = self._node_count bits = self._address_bits if node > node_count: From 9fc18b1e7ee03095e6a279ad6e5854c1a7f41e59 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 22:36:34 +0000 Subject: [PATCH 06/14] Annotate the __exit__ arguments Both __exit__ methods left their arguments untyped and suppressed the ruff warning. Strict type checkers report the missing annotation. Co-Authored-By: Claude Opus 5.5 --- maxminddb/extension.pyi | 2 +- maxminddb/reader.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/maxminddb/extension.pyi b/maxminddb/extension.pyi index 39ae512f..8d1e5d47 100644 --- a/maxminddb/extension.pyi +++ b/maxminddb/extension.pyi @@ -59,7 +59,7 @@ class Reader: def __iter__(self) -> Iterator[tuple[IPv4Network | IPv6Network, Record]]: ... def __enter__(self) -> Self: ... - def __exit__(self, *args) -> None: ... # noqa: ANN002 + def __exit__(self, *args: object) -> None: ... class Metadata: """Metadata for the MaxMind DB reader.""" diff --git a/maxminddb/reader.py b/maxminddb/reader.py index 9da5913f..f693f5a6 100644 --- a/maxminddb/reader.py +++ b/maxminddb/reader.py @@ -400,7 +400,7 @@ def close(self) -> None: self.closed = True - def __exit__(self, *_) -> None: # noqa: ANN002 + def __exit__(self, *_: object) -> None: self.close() def __enter__(self) -> Self: From 45374edfe27810148208c0e88a87fb4eb5d02fcf Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 22:36:42 +0000 Subject: [PATCH 07/14] Remove an unneeded type: ignore hasattr() never causes an attr-defined error. mypy --strict reports the ignore as unused. The ignore on the os.pread() call stays because Windows has no os.pread. Co-Authored-By: Claude Opus 5.5 --- maxminddb/file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maxminddb/file.py b/maxminddb/file.py index 1901d9ba..1d7945ea 100644 --- a/maxminddb/file.py +++ b/maxminddb/file.py @@ -51,7 +51,7 @@ def close(self) -> None: """Close file.""" self._handle.close() - if hasattr(os, "pread"): # type: ignore[attr-defined] + if hasattr(os, "pread"): def _read(self, buffersize: int, offset: int) -> bytes: """Read that uses pread.""" From 6d225a61ff5004867ea829ccdd5ccdb4c50648a9 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 22:37:12 +0000 Subject: [PATCH 08/14] Add type aliases for the database argument open_database(), Reader.__init__, Reader._load and Reader._load_buffer each spelled out the database argument union. A change to the accepted types had to edit every copy, and nothing caught a missed one. Define StrOrBytesPath and DatabaseSource in maxminddb.types and use them. The extension stub accepts different types, so it keeps its own annotation. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 2 ++ maxminddb/__init__.py | 6 +++--- maxminddb/reader.py | 11 +++++------ maxminddb/types.py | 11 +++++++++-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index fcce74bf..1b1c09d9 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -81,6 +81,8 @@ History * ``Primitive`` includes ``bytearray``, which the C extension returns for the ``bytes`` type. * ``Reader.__iter__`` declares its item type. + * Added the ``StrOrBytesPath`` and ``DatabaseSource`` aliases to + ``maxminddb.types``. 3.2.0 (2026-09-10) ++++++++++++++++++ diff --git a/maxminddb/__init__.py b/maxminddb/__init__.py index c17b230a..2d96923e 100644 --- a/maxminddb/__init__.py +++ b/maxminddb/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from importlib.metadata import version -from typing import IO, TYPE_CHECKING, cast +from typing import TYPE_CHECKING, cast from .const import ( MODE_AUTO, @@ -18,7 +18,7 @@ from .reader import Reader if TYPE_CHECKING: - import os + from .types import DatabaseSource try: from . import extension as _extension @@ -41,7 +41,7 @@ def open_database( - database: str | bytes | int | os.PathLike[str] | os.PathLike[bytes] | IO[bytes], + database: DatabaseSource, mode: int = MODE_AUTO, ) -> Reader: """Open a MaxMind DB database. diff --git a/maxminddb/reader.py b/maxminddb/reader.py index f693f5a6..58802bbb 100644 --- a/maxminddb/reader.py +++ b/maxminddb/reader.py @@ -11,7 +11,7 @@ import ipaddress from dataclasses import dataclass from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network -from typing import IO, TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any from maxminddb.const import MODE_AUTO, MODE_FD, MODE_FILE, MODE_MEMORY, MODE_MMAP from maxminddb.decoder import Decoder @@ -20,11 +20,10 @@ if TYPE_CHECKING: from collections.abc import Iterator - from os import PathLike from typing_extensions import Self - from maxminddb.types import Record, RecordDict + from maxminddb.types import DatabaseSource, Record, RecordDict _IPV4_MAX_NUM = 2**32 _REOPENED = "Attempt to iterate over a reopened MaxMind DB. Create a new iterator." @@ -58,7 +57,7 @@ class Reader: def __init__( self, - database: str | bytes | int | PathLike[str] | PathLike[bytes] | IO[bytes], + database: DatabaseSource, mode: int = MODE_AUTO, ) -> None: """Reader for the MaxMind DB file format. @@ -95,7 +94,7 @@ def __init__( def _load( self, - database: str | bytes | int | PathLike[str] | PathLike[bytes] | IO[bytes], + database: DatabaseSource, mode: int, ) -> None: # TRY301 is suppressed because the handler only closes the buffer and @@ -351,7 +350,7 @@ def _resolve_data_pointer(self, pointer: int) -> Record: def _load_buffer( self, - database: str | bytes | int | PathLike[str] | PathLike[bytes] | IO[bytes], + database: DatabaseSource, mode: int = MODE_AUTO, ) -> str: filename: Any diff --git a/maxminddb/types.py b/maxminddb/types.py index eafe1655..bd731da3 100644 --- a/maxminddb/types.py +++ b/maxminddb/types.py @@ -1,8 +1,9 @@ -"""Types representing database records.""" +"""Types for database records and database arguments.""" from __future__ import annotations -from typing import TypeAlias +import os +from typing import IO, TypeAlias Primitive: TypeAlias = str | bytes | bytearray | bool | float | int @@ -13,3 +14,9 @@ """RecordDict is a type for dicts in a database record.""" Record: TypeAlias = Primitive | RecordList | RecordDict + +StrOrBytesPath: TypeAlias = str | bytes | os.PathLike[str] | os.PathLike[bytes] +"""StrOrBytesPath is a type for a path to a database file.""" + +DatabaseSource: TypeAlias = StrOrBytesPath | int | IO[bytes] +"""DatabaseSource is a type for the database argument of a reader.""" From aeb8f3ac80efe87dd06fde540007390af398c3b2 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 22:59:42 +0000 Subject: [PATCH 09/14] Accept only paths in the extension Reader stub The extension parses the database argument with PyUnicode_FSConverter, so it accepts only str, bytes and os.PathLike. The stub also accepted int and IO[bytes], which raise TypeError. The extension does not support MODE_FD, so the docstring was wrong too. open_database() passes any database argument to the extension in MODE_AUTO and MODE_MMAP_EXT. The narrower stub shows that mismatch. Keep the runtime behavior and explain the type: ignore. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 2 ++ maxminddb/__init__.py | 5 +++-- maxminddb/extension.pyi | 8 +++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 1b1c09d9..e858a6de 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -83,6 +83,8 @@ History * ``Reader.__iter__`` declares its item type. * Added the ``StrOrBytesPath`` and ``DatabaseSource`` aliases to ``maxminddb.types``. + * The ``maxminddb.extension.Reader`` stub accepts only a path, as the + extension does. 3.2.0 (2026-09-10) ++++++++++++++++++ diff --git a/maxminddb/__init__.py b/maxminddb/__init__.py index 2d96923e..49b8576c 100644 --- a/maxminddb/__init__.py +++ b/maxminddb/__init__.py @@ -86,8 +86,9 @@ def open_database( # 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)) + # implementation.) The extension accepts only a path. It raises TypeError + # for a file descriptor or a file object. + return cast("Reader", _extension.Reader(database, mode)) # type: ignore[arg-type] __version__ = version("maxminddb") diff --git a/maxminddb/extension.pyi b/maxminddb/extension.pyi index 8d1e5d47..19c8d4fe 100644 --- a/maxminddb/extension.pyi +++ b/maxminddb/extension.pyi @@ -2,12 +2,10 @@ from collections.abc import Iterator from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network -from os import PathLike -from typing import IO from typing_extensions import Self -from maxminddb.types import Record +from maxminddb.types import Record, StrOrBytesPath class Reader: """A C extension implementation of a reader for the MaxMind DB format. @@ -19,14 +17,14 @@ class Reader: def __init__( self, - database: str | bytes | int | PathLike[str] | PathLike[bytes] | IO[bytes], + database: StrOrBytesPath, mode: int = ..., ) -> None: """Reader for the MaxMind DB file format. Arguments: database: A path to a valid MaxMind DB file such as a GeoIP database - file, or a file descriptor in the case of MODE_FD. + file. mode: mode to open the database with. The only supported modes are MODE_AUTO and MODE_MMAP_EXT. From 92342e53011ec2073dcff539d78ea9647baea80d Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 22:37:57 +0000 Subject: [PATCH 10/14] Accept any binary reader with MODE_FD MODE_FD calls only database.read() and reads database.name when it exists. IO[bytes] requires much more, so a GzipFile and other binary readers did not type-check, although they work at runtime. Accept any object with a read() method that returns bytes. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 3 +++ maxminddb/types.py | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index e858a6de..44636200 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -85,6 +85,9 @@ History ``maxminddb.types``. * The ``maxminddb.extension.Reader`` stub accepts only a path, as the extension does. + * ``MODE_FD`` accepts any object whose ``read()`` method returns ``bytes``, + such as a ``gzip.GzipFile``. ``maxminddb.types.SupportsRead`` describes + this type. 3.2.0 (2026-09-10) ++++++++++++++++++ diff --git a/maxminddb/types.py b/maxminddb/types.py index bd731da3..b14eabb3 100644 --- a/maxminddb/types.py +++ b/maxminddb/types.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import IO, TypeAlias +from typing import Protocol, TypeAlias Primitive: TypeAlias = str | bytes | bytearray | bool | float | int @@ -18,5 +18,13 @@ StrOrBytesPath: TypeAlias = str | bytes | os.PathLike[str] | os.PathLike[bytes] """StrOrBytesPath is a type for a path to a database file.""" -DatabaseSource: TypeAlias = StrOrBytesPath | int | IO[bytes] + +class SupportsRead(Protocol): + """SupportsRead is a type for a binary file object for MODE_FD.""" + + def read(self) -> bytes: + """Return the remaining bytes.""" + + +DatabaseSource: TypeAlias = StrOrBytesPath | int | SupportsRead """DatabaseSource is a type for the database argument of a reader.""" From 0f24bc27ea0f3ea58bb929501d5025ff8d86c3b6 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Sat, 3 Oct 2026 04:30:57 +0000 Subject: [PATCH 11/14] Document maxminddb.types The release notes point users to the type aliases in maxminddb.types, such as Record, DatabaseSource and SupportsRead, but the API docs did not include that module. Add it. Co-Authored-By: Claude Opus 5.5 --- docs/index.rst | 8 ++++++++ maxminddb/types.py | 2 ++ 2 files changed, 10 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index b062a0d9..5eca7cb3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,6 +35,14 @@ Database Reader :undoc-members: :show-inheritance: +===== +Types +===== + +.. automodule:: maxminddb.types + :members: + :undoc-members: + ================== Indices and tables ================== diff --git a/maxminddb/types.py b/maxminddb/types.py index b14eabb3..eee642e1 100644 --- a/maxminddb/types.py +++ b/maxminddb/types.py @@ -6,6 +6,7 @@ from typing import Protocol, TypeAlias Primitive: TypeAlias = str | bytes | bytearray | bool | float | int +"""Primitive is a type for a scalar value in a database record.""" RecordList: TypeAlias = list["Record"] """RecordList is a type for lists in a database record.""" @@ -14,6 +15,7 @@ """RecordDict is a type for dicts in a database record.""" Record: TypeAlias = Primitive | RecordList | RecordDict +"""Record is a type for a value in a database record.""" StrOrBytesPath: TypeAlias = str | bytes | os.PathLike[str] | os.PathLike[bytes] """StrOrBytesPath is a type for a path to a database file.""" From 2a8a5f1606554c40c02a25747a68605311bf4bb4 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 22:42:11 +0000 Subject: [PATCH 12/14] Add static type checks for the public API No test checked the public types, and non-strict mypy accepts a bare generic alias with no error. A TypeVar back in Primitive, a bare Iterator, or a wider stub would pass CI. mypy already checks tests/ in the lint environment. Add assert_type checks under TYPE_CHECKING, so the file costs nothing at runtime. The file enables warn-unused-ignores, so each type: ignore asserts that a call fails the type check. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 5 +++++ tests/typing_test.py | 52 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/typing_test.py diff --git a/pyproject.toml b/pyproject.toml index c51facb6..7123736b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,11 @@ ignore = [ "setup.py" = ["ALL"] "tests/*" = ["ANN201", "D"] +[tool.mypy] +# mypy 2 enables this by default. Without it, mypy 1 accepts bytearray as +# bytes, and the bytearray check in tests/typing_test.py cannot fail. +strict_bytes = true + [tool.pytest.ini_options] # Use importlib mode to prevent pytest from adding the project root to sys.path. # This ensures tests import from the tox virtualenv's installed package (which includes diff --git a/tests/typing_test.py b/tests/typing_test.py new file mode 100644 index 00000000..f7883659 --- /dev/null +++ b/tests/typing_test.py @@ -0,0 +1,52 @@ +# mypy: warn-unused-ignores +"""Static type checks for the public API. + +mypy checks this file in the lint environment. The code does not run. Each +type: ignore marks a line that must fail the type check. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import gzip + import io + from ipaddress import IPv4Network, IPv6Network + from pathlib import Path + + from typing_extensions import assert_type + + import maxminddb + import maxminddb.extension + from maxminddb.types import Primitive, Record + + reader = maxminddb.open_database("GeoIP2-City.mmdb") + assert_type(reader.get("1.1.1.1"), Record | None) + assert_type(reader.get_with_prefix_len("1.1.1.1"), tuple[Record | None, int]) + for network, record in reader: + assert_type(network, IPv4Network | IPv6Network) + assert_type(record, Record) + assert_type(reader.metadata().search_tree_size, int) + with maxminddb.open_database(Path("GeoIP2-City.mmdb")) as path_reader: + assert_type(path_reader, maxminddb.Reader) + + # Record includes the bytearray that the C extension returns for the + # bytes type. + value: Record = bytearray(b"\x00") + + # A TypeVar in Primitive would give Record members of type Any. mypy + # rejects Record[str] even then, so only this check catches it. + def check_not_generic(primitive: Primitive[str]) -> None: # type: ignore[type-arg] + pass + + def check_mode_fd(gzip_file: gzip.GzipFile, text_file: io.TextIOWrapper) -> None: + maxminddb.open_database(gzip_file, maxminddb.Mode.FD) + maxminddb.open_database(text_file, maxminddb.Mode.FD) # type: ignore[arg-type] + + extension_reader = maxminddb.extension.Reader("GeoIP2-City.mmdb") + for network, record in extension_reader: + assert_type(network, IPv4Network | IPv6Network) + assert_type(record, Record) + assert_type(extension_reader.metadata().node_byte_size, int) + maxminddb.extension.Reader(3) # type: ignore[arg-type] From c362576e040b0b31abedb7a748947acae2e96d5d Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Tue, 13 Oct 2026 19:49:56 +0000 Subject: [PATCH 13/14] Keep Record subscripts working at runtime GitHub #464 replaced the AnyStr TypeVar in Primitive with str | bytes. Primitive and Record are no longer generic, so Record[str] raised TypeError at runtime. 3.2.0 accepted it, so code such as cast(Record[str], x), or an annotation on Python before 3.14 without postponed evaluation, failed at import. Keep the concrete alias for type checkers, and use AnyStr only at runtime. Type checkers still reject the subscript. Sphinx documents the runtime value, so the docs give the static types explicitly. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 6 +++--- docs/index.rst | 10 ++++++++++ maxminddb/types.py | 13 +++++++++---- tests/reader_test.py | 7 +++++++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 44636200..9c0d9205 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -72,9 +72,9 @@ History cause. * Added ``maxminddb.Mode``, which the README already described. -* ``maxminddb.types.Record`` and ``Primitive`` are no longer generic type - aliases. Remove any subscript, such as ``Record[str]``. Pull request by Adam - Hitchcock. GitHub #464. +* Type checkers now reject a subscript of ``maxminddb.types.Record`` or + ``Primitive``, such as ``Record[str]``. The subscript still works at + runtime. Pull request by Adam Hitchcock. GitHub #464. * Type hints: diff --git a/docs/index.rst b/docs/index.rst index 5eca7cb3..5764d27c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,6 +42,16 @@ Types .. automodule:: maxminddb.types :members: :undoc-members: + :exclude-members: Primitive, Record + +.. The runtime values of these aliases contain AnyStr, so give the types that + type checkers see. + +.. autodata:: maxminddb.types.Primitive + :annotation: = str | bytes | bytearray | bool | float | int + +.. autodata:: maxminddb.types.Record + :annotation: = Primitive | RecordList | RecordDict ================== Indices and tables diff --git a/maxminddb/types.py b/maxminddb/types.py index eee642e1..c8a3364d 100644 --- a/maxminddb/types.py +++ b/maxminddb/types.py @@ -3,10 +3,15 @@ from __future__ import annotations import os -from typing import Protocol, TypeAlias - -Primitive: TypeAlias = str | bytes | bytearray | bool | float | int -"""Primitive is a type for a scalar value in a database record.""" +from typing import TYPE_CHECKING, AnyStr, Protocol, TypeAlias + +if TYPE_CHECKING: + Primitive: TypeAlias = str | bytes | bytearray | bool | float | int + """Primitive is a type for a scalar value in a database record.""" +else: + # Through AnyStr, a subscript such as Record[str] works at runtime, as in + # 3.2.0. Type checkers reject the subscript. + Primitive: TypeAlias = AnyStr | bytes | bytearray | bool | float | int RecordList: TypeAlias = list["Record"] """RecordList is a type for lists in a database record.""" diff --git a/tests/reader_test.py b/tests/reader_test.py index ee3bcf7c..780681ad 100644 --- a/tests/reader_test.py +++ b/tests/reader_test.py @@ -28,6 +28,7 @@ except ImportError: maxminddb.extension = None # type: ignore[assignment] +import maxminddb.types from maxminddb import InvalidDatabaseError, open_database from maxminddb.const import ( MODE_AUTO, @@ -1422,6 +1423,12 @@ class TestModule(unittest.TestCase): def test_mode_is_exported(self) -> None: self.assertIs(maxminddb.Mode, maxminddb.const.Mode) + def test_record_subscript_works_at_runtime(self) -> None: + # 3.2.0 accepted Record[str] at runtime. typing_test.py checks that + # type checkers reject it. + maxminddb.types.Record[str] # type: ignore[misc, type-arg] + maxminddb.types.Primitive[bytes] # type: ignore[misc, type-arg] + @unittest.skipIf( not has_maxminddb_extension() and not os.environ.get("MM_FORCE_EXT_TESTS"), From 85711f737566ea591bb76ed89072dce521d04b04 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 14 Oct 2026 16:42:04 +0000 Subject: [PATCH 14/14] Use the 3.2.0 Primitive at runtime The runtime Primitive was AnyStr | bytes | bytearray | bool | float | int, so Record[str] included bytes and bytearray. In 3.2.0, it did not. A runtime validator that uses the subscript, such as a pydantic field annotated Record[str], rejected bytes in 3.2.0 and accepted them here. Use the 3.2.0 definition at runtime, and test the subscript's arguments. Type checkers still see the concrete alias. Co-Authored-By: Claude Opus 5.5 --- maxminddb/types.py | 7 ++++--- tests/reader_test.py | 8 ++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/maxminddb/types.py b/maxminddb/types.py index c8a3364d..0dd2b191 100644 --- a/maxminddb/types.py +++ b/maxminddb/types.py @@ -9,9 +9,10 @@ Primitive: TypeAlias = str | bytes | bytearray | bool | float | int """Primitive is a type for a scalar value in a database record.""" else: - # Through AnyStr, a subscript such as Record[str] works at runtime, as in - # 3.2.0. Type checkers reject the subscript. - Primitive: TypeAlias = AnyStr | bytes | bytearray | bool | float | int + # The 3.2.0 definition, so that a subscript such as Record[str] works + # and means the same at runtime. Keep it when the static alias changes. + # Type checkers reject the subscript. + Primitive: TypeAlias = AnyStr | bool | float | int RecordList: TypeAlias = list["Record"] """RecordList is a type for lists in a database record.""" diff --git a/tests/reader_test.py b/tests/reader_test.py index 780681ad..2fd1308e 100644 --- a/tests/reader_test.py +++ b/tests/reader_test.py @@ -18,7 +18,7 @@ import threading import tracemalloc import unittest -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, cast, get_args from unittest import mock import maxminddb @@ -1427,7 +1427,11 @@ def test_record_subscript_works_at_runtime(self) -> None: # 3.2.0 accepted Record[str] at runtime. typing_test.py checks that # type checkers reject it. maxminddb.types.Record[str] # type: ignore[misc, type-arg] - maxminddb.types.Primitive[bytes] # type: ignore[misc, type-arg] + # As in 3.2.0, Primitive[str] does not include bytes. + self.assertEqual( + get_args(maxminddb.types.Primitive[str]), # type: ignore[misc, type-arg] + (str, bool, float, int), + ) @unittest.skipIf(