From f033ca44d7648d0a395ba55fdcfa7f422a4eccef Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Nov 2025 15:02:40 -0800 Subject: [PATCH 1/3] Add CLAUDE.md documentation and update .gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds comprehensive documentation for Claude Code to work more effectively in this repository, including architecture details, testing conventions, and development workflows. Also adds .claude/ to .gitignore to exclude Claude Code configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 1 + CLAUDE.md | 410 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index c1ac9a6c..78e305c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *~ *.egg .eggs +.claude/ .idea build .coverage diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..500167e4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,410 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**GeoIP2-python** is MaxMind's official Python client library for: +- **GeoIP2/GeoLite2 Web Services**: Country, City, and Insights endpoints +- **GeoIP2/GeoLite2 Databases**: Local MMDB file reading for various database types (City, Country, ASN, Anonymous IP, Anonymous Plus, ISP, etc.) + +The library provides both web service clients (sync and async) and database readers that return strongly-typed model objects containing geographic, ISP, anonymizer, and other IP-related data. + +**Key Technologies:** +- Python 3.10+ (type hints throughout, uses modern Python features) +- MaxMind DB Reader for binary database files +- Requests library for sync web service client +- aiohttp for async web service client +- pytest for testing +- ruff for linting and formatting +- mypy for static type checking +- uv for dependency management and building + +## Code Architecture + +### Package Structure + +``` +src/geoip2/ +├── models.py # Response models (City, Insights, AnonymousIP, etc.) +├── records.py # Data records (City, Location, Traits, etc.) +├── errors.py # Custom exceptions for error handling +├── database.py # Local MMDB file reader +├── webservice.py # HTTP clients (sync Client and async AsyncClient) +├── _internal.py # Internal base classes and utilities +└── types.py # Type definitions +``` + +### Key Design Patterns + +#### 1. **Model Classes vs Record Classes** + +**Models** (in `models.py`) are top-level responses returned by database lookups or web service calls: +- `Country` - base model with country/continent data +- `City` extends `Country` - adds city, location, postal, subdivisions +- `Insights` extends `City` - adds additional web service fields (web service only) +- `Enterprise` extends `City` - adds enterprise-specific fields +- `AnonymousIP` - anonymous IP lookup results +- `AnonymousPlus` extends `AnonymousIP` - adds additional anonymizer fields +- `ASN`, `ConnectionType`, `Domain`, `ISP` - specialized lookup models + +**Records** (in `records.py`) are contained within models and represent specific data components: +- `PlaceRecord` - abstract base with `names` dict and locale handling +- `City`, `Continent`, `Country`, `RepresentedCountry`, `Subdivision` - geographic records +- `Location`, `Postal`, `Traits`, `MaxMind` - additional data records + +#### 2. **Constructor Pattern** + +Models and records use keyword-only arguments (except for required positional parameters): + +```python +def __init__( + self, + locales: Sequence[str] | None, # positional for records + *, + continent: dict[str, Any] | None = None, + country: dict[str, Any] | None = None, + # ... other keyword-only parameters + **_: Any, # ignore unknown keys +) -> None: +``` + +Key points: +- Use `*` to enforce keyword-only arguments +- Accept `**_: Any` to ignore unknown keys from the API +- Use `| None = None` for optional parameters +- Boolean fields default to `False` if not present + +#### 3. **Serialization with to_dict()** + +All model and record classes inherit from `Model` (in `_internal.py`) which provides `to_dict()`: + +```python +def to_dict(self) -> dict[str, Any]: + # Returns a dict suitable for JSON serialization + # - Skips None values and False booleans + # - Recursively calls to_dict() on nested objects + # - Handles lists/tuples of objects + # - Converts network and ip_address to strings +``` + +The `to_dict()` method replaced the old `raw` attribute in version 5.0.0. + +#### 4. **Locale Handling** + +Records with names use `PlaceRecord` base class: +- `names` dict contains locale code → name mappings +- `name` property returns the first available name based on locale preference +- Default locale is `["en"]` if not specified +- Locales are passed down from models to records + +#### 5. **Property-based Network Calculation** + +For performance reasons, `network` and `ip_address` are properties rather than attributes: + +```python +@property +def network(self) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None: + # Lazy calculation and caching of network from ip_address and prefix_len +``` + +#### 6. **Web Service Only vs Database Models** + +Some models are only used by web services and do **not** need MaxMind DB support: + +**Web Service Only Models**: +- `Insights` - extends City but used only for web service +- Simpler implementation without database parsing logic + +**Database-Supported Models**: +- Models used by both web services and database files +- Must handle MaxMind DB format data structures +- Examples: `City`, `Country`, `AnonymousIP`, `AnonymousPlus`, `ASN`, `ISP` + +## Testing Conventions + +### Running Tests + +```bash +# Install dependencies using uv +uv sync --all-groups + +# Run all tests +uv run pytest + +# Run specific test file +uv run pytest tests/models_test.py + +# Run specific test class or method +uv run pytest tests/models_test.py::TestModels::test_insights_full + +# Run tests with coverage +uv run pytest --cov=geoip2 --cov-report=html +``` + +### Linting and Type Checking + +```bash +# Run all linting checks (mypy, ruff check, ruff format check) +uv run tox -e lint + +# Run mypy type checking +uv run mypy src tests + +# Run ruff linting +uv run ruff check + +# Auto-fix ruff issues +uv run ruff check --fix + +# Check formatting +uv run ruff format --check --diff . + +# Apply formatting +uv run ruff format . +``` + +### Running Tests Across Python Versions + +```bash +# Run tests on all supported Python versions +uv run tox + +# Run on specific Python version +uv run tox -e 3.11 + +# Run lint environment +uv run tox -e lint +``` + +### Test Structure + +Tests are organized by component: +- `tests/database_test.py` - Database reader tests +- `tests/models_test.py` - Response model tests +- `tests/webservice_test.py` - Web service client tests + +### Test Patterns + +When adding new fields to models: +1. Update the test method to include the new field in the `raw` dict +2. Add assertions to verify the field is properly populated +3. Test both presence and absence of the field (null handling) +4. Verify `to_dict()` serialization includes the field correctly + +Example: +```python +def test_anonymous_plus_full(self) -> None: + model = geoip2.models.AnonymousPlus( + "1.2.3.4", + anonymizer_confidence=99, + network_last_seen=datetime.date(2025, 4, 14), + provider_name="FooBar VPN", + is_anonymous=True, + is_anonymous_vpn=True, + # ... other fields + ) + + assert model.anonymizer_confidence == 99 + assert model.network_last_seen == datetime.date(2025, 4, 14) + assert model.provider_name == "FooBar VPN" +``` + +## Working with This Codebase + +### Adding New Fields to Existing Models + +1. **Add the parameter to `__init__`** with proper type hints: + ```python + def __init__( + self, + # ... existing params + *, + field_name: int | None = None, # new field + # ... other params + ) -> None: + ``` + +2. **Assign the field in the constructor**: + ```python + self.field_name = field_name + ``` + +3. **Add class-level type annotation** with docstring: + ```python + field_name: int | None + """Description of the field, its source, and availability.""" + ``` + +4. **Update `to_dict()` if special handling needed** (usually automatic via `_internal.Model`) + +5. **Update tests** to include the new field in test data and assertions + +6. **Update HISTORY.rst** with the change (see CHANGELOG Format below) + +### Adding New Models + +When creating a new model class: + +1. **Determine if web service only or database-supported** +2. **Follow the pattern** from existing similar models +3. **Extend the appropriate base class** (e.g., `Country`, `City`, `SimpleModel`) +4. **Use type hints** for all attributes +5. **Use keyword-only arguments** with `*` separator +6. **Accept `**_: Any`** to ignore unknown API keys +7. **Provide comprehensive docstrings** for all attributes +8. **Add corresponding tests** with full coverage + +### Date Handling + +When a field returns a date string from the API (e.g., "2025-04-14"): + +1. **Parse it to `datetime.date`** in the constructor: + ```python + import datetime + + self.network_last_seen = ( + datetime.date.fromisoformat(network_last_seen) + if network_last_seen + else None + ) + ``` + +2. **Annotate as `datetime.date | None`**: + ```python + network_last_seen: datetime.date | None + ``` + +3. **In `to_dict()`**, dates are automatically converted to ISO format strings by the base class + +### Deprecation Guidelines + +When deprecating fields: + +1. **Add deprecation to docstring** with version and alternative: + ```python + metro_code: int | None + """The metro code of the location. + + .. deprecated:: 5.0.0 + The code values are no longer being maintained. + """ + ``` + +2. **Keep deprecated fields functional** - don't break existing code + +3. **Update HISTORY.rst** with deprecation notices + +4. **Document alternatives** in the deprecation message + +### HISTORY.rst Format + +Always update `HISTORY.rst` for user-facing changes. + +**Important**: Do not add a date to changelog entries until release time. Version numbers are added but without dates. + +Format: +```rst +5.2.0 +++++++++++++++++++ + +* IMPORTANT: Python 3.10 or greater is required. If you are using an older + version, please use an earlier release. +* A new ``field_name`` property has been added to ``geoip2.models.ModelName``. + This field provides information about... +* The ``old_field`` property in ``geoip2.models.ModelName`` has been deprecated. + Please use ``new_field`` instead. +``` + +## Common Pitfalls and Solutions + +### Problem: Incorrect Type Hints +Using wrong type hints can cause mypy errors or allow invalid data. + +**Solution**: Follow these patterns: +- Optional values: `Type | None` (e.g., `int | None`, `str | None`) +- Non-null booleans: `bool` (default to `False` in constructor if not present) +- Sequences: `Sequence[str]` for parameters, `list[T]` for internal lists +- IP addresses: `IPAddress` type alias (from `geoip2.types`) +- IP objects: `IPv4Address | IPv6Address` from `ipaddress` module + +### Problem: Missing to_dict() Serialization +New fields not appearing in serialized output. + +**Solution**: The `to_dict()` method in `_internal.Model` automatically handles most cases: +- Non-None values are included +- False booleans are excluded +- Empty dicts/lists are excluded +- Nested objects with `to_dict()` are recursively serialized + +If you need custom serialization, override `to_dict()` carefully. + +### Problem: Test Failures After Adding Fields +Tests fail because fixtures don't include new fields. + +**Solution**: Update all related tests: +1. Add field to constructor calls in tests +2. Add assertions for the new field +3. Test null case if field is optional +4. Verify `to_dict()` serialization + +### Problem: Constructor Argument Order +Breaking changes when adding required parameters. + +**Solution**: +- Use keyword-only arguments (after `*`) for all optional parameters +- Only add new parameters as optional with defaults +- Never add required positional parameters to existing constructors + +## Code Style Requirements + +- **ruff** enforces all style rules (configured in `pyproject.toml`) +- **Type hints required** for all functions and class attributes +- **Docstrings required** for all public classes, methods, and attributes (Google style) +- **Line length**: 88 characters (Black-compatible) +- No unused imports or variables +- Use modern Python features (3.10+ type union syntax: `X | Y` instead of `Union[X, Y]`) + +## Development Workflow + +### Setup + +```bash +# Install uv if not already installed +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Install all dependencies including dev and lint groups +uv sync --all-groups +``` + +### Before Committing + +```bash +# Format code +uv run ruff format . + +# Check linting +uv run ruff check --fix + +# Type check +uv run mypy src tests + +# Run tests +uv run pytest + +# Or run everything via tox +uv run tox +``` + +### Version Requirements + +- **Python 3.10+** required (as of version 5.2.0) +- Uses modern Python features (match statements, structural pattern matching, `X | Y` union syntax) +- Target compatibility: Python 3.10-3.14 + +## Additional Resources + +- [API Documentation](https://geoip2.readthedocs.org/) +- [GeoIP2 Web Services Docs](https://dev.maxmind.com/geoip/docs/web-services) +- [MaxMind DB Format](https://maxmind.github.io/MaxMind-DB/) +- GitHub Issues: https://github.com/maxmind/GeoIP2-python/issues From 22149c71b5d748a07d2f08f5d36c7208d38e1489 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Nov 2025 15:16:47 -0800 Subject: [PATCH 2/3] Add IP risk and anonymizer fields to Insights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds support for new web service fields providing enhanced VPN/proxy detection and IP risk scoring. A new Anonymizer record consolidates anonymous IP flags with additional fields (anonymizer_confidence, network_last_seen, provider_name). The ip_risk_snapshot field enables risk-based decision making. Anonymous IP flags in Traits are deprecated in favor of the new anonymizer object location. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- HISTORY.rst | 14 +++++ pyproject.toml | 4 +- src/geoip2/_internal.py | 3 + src/geoip2/models.py | 41 +++++++++++++ src/geoip2/records.py | 124 +++++++++++++++++++++++++++++++++++++++ tests/models_test.py | 69 ++++++++++++++++++---- tests/webservice_test.py | 4 +- 7 files changed, 243 insertions(+), 16 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index af97be30..ea962a9b 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -13,6 +13,20 @@ History support. * Setuptools has been replaced with the uv build backend for building the package. +* A new ``anonymizer`` object has been added to ``geoip2.models.Insights``. + This object is a ``geoip2.records.Anonymizer`` and contains the following + fields: ``anonymizer_confidence``, ``network_last_seen``, ``provider_name``, + ``is_anonymous``, ``is_anonymous_vpn``, ``is_hosting_provider``, + ``is_public_proxy``, ``is_residential_proxy``, and ``is_tor_exit_node``. + These provide information about VPN and proxy usage. +* A new ``ip_risk_snapshot`` property has been added to + ``geoip2.records.Traits``. This is a float ranging from 0.01 to 99 that + represents the risk associated with the IP address. A higher score indicates + a higher risk. This field is only available from the Insights end point. +* The following properties on ``geoip2.records.Traits`` have been deprecated: + ``is_anonymous``, ``is_anonymous_vpn``, ``is_hosting_provider``, + ``is_public_proxy``, ``is_residential_proxy``, and ``is_tor_exit_node``. + Please use the ``anonymizer`` object in the ``Insights`` model instead. 5.1.0 (2025-05-05) ++++++++++++++++++ diff --git a/pyproject.toml b/pyproject.toml index 1e4a0d34..48c821d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,9 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "docs/*" = ["ALL"] "src/geoip2/{models,records}.py" = [ "ANN401", "D107", "PLR0913" ] -"tests/*" = ["ANN201", "D"] +# FBT003: We use assertIs with boolean literals to verify values are actual +# booleans (True/False), not just truthy/falsy values +"tests/*" = ["ANN201", "D", "FBT003"] [tool.tox] env_list = [ diff --git a/src/geoip2/_internal.py b/src/geoip2/_internal.py index 3b706960..fd77ce28 100644 --- a/src/geoip2/_internal.py +++ b/src/geoip2/_internal.py @@ -1,5 +1,6 @@ """Internal utilities.""" +import datetime import json from abc import ABCMeta from typing import Any @@ -42,6 +43,8 @@ def to_dict(self) -> dict[str, Any]: # noqa: C901, PLR0912 elif isinstance(value, dict): if value: result[key] = value + elif isinstance(value, datetime.date): + result[key] = value.isoformat() elif value is not None and value is not False: result[key] = value diff --git a/src/geoip2/models.py b/src/geoip2/models.py index 058cd546..23f784f2 100644 --- a/src/geoip2/models.py +++ b/src/geoip2/models.py @@ -149,6 +149,47 @@ def __init__( class Insights(City): """Model for the GeoIP2 Insights web service.""" + anonymizer: geoip2.records.Anonymizer + """Anonymizer object for the requested IP address. This object contains + information about VPN and proxy usage. + """ + + def __init__( + self, + locales: Sequence[str] | None, + *, + anonymizer: dict[str, Any] | None = None, + city: dict[str, Any] | None = None, + continent: dict[str, Any] | None = None, + country: dict[str, Any] | None = None, + location: dict[str, Any] | None = None, + ip_address: IPAddress | None = None, + maxmind: dict[str, Any] | None = None, + postal: dict[str, Any] | None = None, + prefix_len: int | None = None, + registered_country: dict[str, Any] | None = None, + represented_country: dict[str, Any] | None = None, + subdivisions: list[dict[str, Any]] | None = None, + traits: dict[str, Any] | None = None, + **_: Any, + ) -> None: + super().__init__( + locales, + city=city, + continent=continent, + country=country, + location=location, + ip_address=ip_address, + maxmind=maxmind, + postal=postal, + prefix_len=prefix_len, + registered_country=registered_country, + represented_country=represented_country, + subdivisions=subdivisions, + traits=traits, + ) + self.anonymizer = geoip2.records.Anonymizer(**(anonymizer or {})) + class Enterprise(City): """Model for the GeoIP2 Enterprise database.""" diff --git a/src/geoip2/records.py b/src/geoip2/records.py index faa8b45b..537ee8cf 100644 --- a/src/geoip2/records.py +++ b/src/geoip2/records.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime import ipaddress from abc import ABCMeta from ipaddress import IPv4Address, IPv6Address @@ -261,6 +262,98 @@ def __init__(self, *, queries_remaining: int | None = None, **_: Any) -> None: self.queries_remaining = queries_remaining +class Anonymizer(Record): + """Contains data for the anonymizer record associated with an IP address. + + This class contains the anonymizer data associated with an IP address. + + This record is returned by ``insights``. + """ + + anonymizer_confidence: int | None + """A score ranging from 1 to 99 that represents our percent confidence that + the network is currently part of an actively used VPN service. Currently + only values 30 and 99 are provided. This attribute is only available from + the Insights end point. + """ + + network_last_seen: datetime.date | None + """The last day that the network was sighted in our analysis of anonymized + networks. This attribute is only available from the Insights end point. + """ + + provider_name: str | None + """The name of the VPN provider (e.g., NordVPN, SurfShark, etc.) associated + with the network. This attribute is only available from the Insights end + point. + """ + + is_anonymous: bool + """This is true if the IP address belongs to any sort of anonymous network. + This attribute is only available from the Insights end point. + """ + + is_anonymous_vpn: bool + """This is true if the IP address is registered to an anonymous VPN provider. + + If a VPN provider does not register subnets under names associated with + them, we will likely only flag their IP ranges using the + ``is_hosting_provider`` attribute. + + This attribute is only available from the Insights end point. + """ + + is_hosting_provider: bool + """This is true if the IP address belongs to a hosting or VPN provider + (see description of ``is_anonymous_vpn`` attribute). This attribute is only + available from the Insights end point. + """ + + is_public_proxy: bool + """This is true if the IP address belongs to a public proxy. This attribute + is only available from the Insights end point. + """ + + is_residential_proxy: bool + """This is true if the IP address is on a suspected anonymizing network + and belongs to a residential ISP. This attribute is only available from the + Insights end point. + """ + + is_tor_exit_node: bool + """This is true if the IP address is a Tor exit node. This attribute is only + available from the Insights end point. + """ + + def __init__( + self, + *, + anonymizer_confidence: int | None = None, + is_anonymous: bool = False, + is_anonymous_vpn: bool = False, + is_hosting_provider: bool = False, + is_public_proxy: bool = False, + is_residential_proxy: bool = False, + is_tor_exit_node: bool = False, + network_last_seen: str | None = None, + provider_name: str | None = None, + **_: Any, + ) -> None: + self.anonymizer_confidence = anonymizer_confidence + self.is_anonymous = is_anonymous + self.is_anonymous_vpn = is_anonymous_vpn + self.is_hosting_provider = is_hosting_provider + self.is_public_proxy = is_public_proxy + self.is_residential_proxy = is_residential_proxy + self.is_tor_exit_node = is_tor_exit_node + self.network_last_seen = ( + datetime.date.fromisoformat(network_last_seen) + if network_last_seen + else None + ) + self.provider_name = provider_name + + class Postal(Record): """Contains data for the postal record associated with an IP address. @@ -425,10 +518,24 @@ class Traits(Record): from the City Plus and Insights web service end points and the Enterprise database. """ + ip_risk_snapshot: float | None + """The risk associated with the IP address. The value ranges from 0.01 to + 99. A higher score indicates a higher risk. + + Please note that the IP risk score provided in GeoIP products and services + is more static than the IP risk score provided in minFraud and is not + responsive to traffic on your network. If you need realtime IP risk scoring + based on behavioral signals on your own network, please use minFraud. + + This attribute is only available from the Insights end point. + """ _ip_address: IPAddress | None is_anonymous: bool """This is true if the IP address belongs to any sort of anonymous network. This attribute is only available from Insights. + + .. deprecated:: 5.2.0 + Use the ``anonymizer`` object in the ``Insights`` model instead. """ is_anonymous_proxy: bool """This is true if the IP is an anonymous proxy. @@ -447,6 +554,9 @@ class Traits(Record): ``is_hosting_provider`` attribute. This attribute is only available from Insights. + + .. deprecated:: 5.2.0 + Use the ``anonymizer`` object in the ``Insights`` model instead. """ is_anycast: bool """This returns true if the IP address belongs to an @@ -458,6 +568,9 @@ class Traits(Record): """This is true if the IP address belongs to a hosting or VPN provider (see description of ``is_anonymous_vpn`` attribute). This attribute is only available from Insights. + + .. deprecated:: 5.2.0 + Use the ``anonymizer`` object in the ``Insights`` model instead. """ is_legitimate_proxy: bool """This attribute is true if MaxMind believes this IP address to be a @@ -467,11 +580,17 @@ class Traits(Record): is_public_proxy: bool """This is true if the IP address belongs to a public proxy. This attribute is only available from Insights. + + .. deprecated:: 5.2.0 + Use the ``anonymizer`` object in the ``Insights`` model instead. """ is_residential_proxy: bool """This is true if the IP address is on a suspected anonymizing network and belongs to a residential ISP. This attribute is only available from Insights. + + .. deprecated:: 5.2.0 + Use the ``anonymizer`` object in the ``Insights`` model instead. """ is_satellite_provider: bool """This is true if the IP address is from a satellite provider that @@ -486,6 +605,9 @@ class Traits(Record): is_tor_exit_node: bool """This is true if the IP address is a Tor exit node. This attribute is only available from Insights. + + .. deprecated:: 5.2.0 + Use the ``anonymizer`` object in the ``Insights`` model instead. """ isp: str | None """The name of the ISP associated with the IP address. This attribute is @@ -560,6 +682,7 @@ def __init__( autonomous_system_organization: str | None = None, connection_type: str | None = None, domain: str | None = None, + ip_risk_snapshot: float | None = None, is_anonymous: bool = False, is_anonymous_proxy: bool = False, is_anonymous_vpn: bool = False, @@ -586,6 +709,7 @@ def __init__( self.autonomous_system_organization = autonomous_system_organization self.connection_type = connection_type self.domain = domain + self.ip_risk_snapshot = ip_risk_snapshot self.is_anonymous = is_anonymous self.is_anonymous_proxy = is_anonymous_proxy self.is_anonymous_vpn = is_anonymous_vpn diff --git a/tests/models_test.py b/tests/models_test.py index 7cce9a8d..2f026cd9 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -12,8 +12,19 @@ class TestModels(unittest.TestCase): def setUp(self) -> None: self.maxDiff = 20_000 - def test_insights_full(self) -> None: + def test_insights_full(self) -> None: # noqa: PLR0915 raw = { + "anonymizer": { + "anonymizer_confidence": 99, + "is_anonymous": True, + "is_anonymous_vpn": True, + "is_hosting_provider": True, + "is_public_proxy": True, + "is_residential_proxy": True, + "is_tor_exit_node": True, + "network_last_seen": "2025-04-14", + "provider_name": "FooBar VPN", + }, "city": { "confidence": 76, "geoname_id": 9876, @@ -74,6 +85,7 @@ def test_insights_full(self) -> None: "connection_type": "Cable/DSL", "domain": "example.com", "ip_address": "1.2.3.4", + "ip_risk_snapshot": 12.5, "is_anonymous": True, "is_anonymous_proxy": True, "is_anonymous_vpn": True, @@ -212,27 +224,47 @@ def test_insights_full(self) -> None: "Location repr can be eval'd", ) - self.assertIs(model.country.is_in_european_union, False) # noqa: FBT003 + self.assertIs(model.country.is_in_european_union, False) self.assertIs( model.registered_country.is_in_european_union, - False, # noqa: FBT003 + False, ) self.assertIs( model.represented_country.is_in_european_union, - True, # noqa: FBT003 + True, ) - self.assertIs(model.traits.is_anonymous, True) # noqa: FBT003 - self.assertIs(model.traits.is_anonymous_proxy, True) # noqa: FBT003 - self.assertIs(model.traits.is_anonymous_vpn, True) # noqa: FBT003 - self.assertIs(model.traits.is_anycast, True) # noqa: FBT003 - self.assertIs(model.traits.is_hosting_provider, True) # noqa: FBT003 - self.assertIs(model.traits.is_public_proxy, True) # noqa: FBT003 - self.assertIs(model.traits.is_residential_proxy, True) # noqa: FBT003 - self.assertIs(model.traits.is_satellite_provider, True) # noqa: FBT003 - self.assertIs(model.traits.is_tor_exit_node, True) # noqa: FBT003 + self.assertIs(model.traits.is_anonymous, True) + self.assertIs(model.traits.is_anonymous_proxy, True) + self.assertIs(model.traits.is_anonymous_vpn, True) + self.assertIs(model.traits.is_anycast, True) + self.assertIs(model.traits.is_hosting_provider, True) + self.assertIs(model.traits.is_public_proxy, True) + self.assertIs(model.traits.is_residential_proxy, True) + self.assertIs(model.traits.is_satellite_provider, True) + self.assertIs(model.traits.is_tor_exit_node, True) self.assertEqual(model.traits.user_count, 2) self.assertEqual(model.traits.static_ip_score, 1.3) + self.assertEqual(model.traits.ip_risk_snapshot, 12.5) + + # Test anonymizer object + self.assertEqual( + type(model.anonymizer), + geoip2.records.Anonymizer, + "geoip2.records.Anonymizer object", + ) + self.assertEqual(model.anonymizer.anonymizer_confidence, 99) + self.assertIs(model.anonymizer.is_anonymous, True) + self.assertIs(model.anonymizer.is_anonymous_vpn, True) + self.assertIs(model.anonymizer.is_hosting_provider, True) + self.assertIs(model.anonymizer.is_public_proxy, True) + self.assertIs(model.anonymizer.is_residential_proxy, True) + self.assertIs(model.anonymizer.is_tor_exit_node, True) + self.assertEqual( + model.anonymizer.network_last_seen, + __import__("datetime").date(2025, 4, 14), + ) + self.assertEqual(model.anonymizer.provider_name, "FooBar VPN") def test_insights_min(self) -> None: model = geoip2.models.Insights(["en"], traits={"ip_address": "5.6.7.8"}) @@ -271,6 +303,11 @@ def test_insights_min(self) -> None: geoip2.records.Traits, "geoip2.records.Traits object", ) + self.assertEqual( + type(model.anonymizer), + geoip2.records.Anonymizer, + "geoip2.records.Anonymizer object", + ) self.assertEqual( type(model.subdivisions.most_specific), geoip2.records.Subdivision, @@ -281,6 +318,12 @@ def test_insights_min(self) -> None: {}, "Empty names hash returned", ) + # Test that anonymizer fields default correctly + self.assertIsNone(model.anonymizer.anonymizer_confidence) + self.assertIsNone(model.anonymizer.network_last_seen) + self.assertIsNone(model.anonymizer.provider_name) + self.assertFalse(model.anonymizer.is_anonymous) + self.assertFalse(model.anonymizer.is_anonymous_vpn) def test_city_full(self) -> None: raw = { diff --git a/tests/webservice_test.py b/tests/webservice_test.py index 826b0781..59a2600e 100644 --- a/tests/webservice_test.py +++ b/tests/webservice_test.py @@ -100,7 +100,7 @@ def test_country_ok(self) -> None: self.assertEqual(country.country.geoname_id, 1, "country geoname_id is 1") self.assertIs( country.country.is_in_european_union, - False, # noqa: FBT003 + False, "country is_in_european_union is False", ) self.assertEqual(country.country.iso_code, "US", "country iso_code is US") @@ -121,7 +121,7 @@ def test_country_ok(self) -> None: ) self.assertIs( country.registered_country.is_in_european_union, - True, # noqa: FBT003 + True, "registered_country is_in_european_union is True", ) self.assertEqual( From 9338693b37468c495d124fafdb2489ffaa67455b Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 14 Nov 2025 13:14:56 -0800 Subject: [PATCH 3/3] Use correct name for anonymizer confidence --- HISTORY.rst | 2 +- src/geoip2/records.py | 6 +++--- tests/models_test.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index ea962a9b..9f7bcdfc 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -15,7 +15,7 @@ History package. * A new ``anonymizer`` object has been added to ``geoip2.models.Insights``. This object is a ``geoip2.records.Anonymizer`` and contains the following - fields: ``anonymizer_confidence``, ``network_last_seen``, ``provider_name``, + fields: ``confidence``, ``network_last_seen``, ``provider_name``, ``is_anonymous``, ``is_anonymous_vpn``, ``is_hosting_provider``, ``is_public_proxy``, ``is_residential_proxy``, and ``is_tor_exit_node``. These provide information about VPN and proxy usage. diff --git a/src/geoip2/records.py b/src/geoip2/records.py index 537ee8cf..d3b4bd2f 100644 --- a/src/geoip2/records.py +++ b/src/geoip2/records.py @@ -270,7 +270,7 @@ class Anonymizer(Record): This record is returned by ``insights``. """ - anonymizer_confidence: int | None + confidence: int | None """A score ranging from 1 to 99 that represents our percent confidence that the network is currently part of an actively used VPN service. Currently only values 30 and 99 are provided. This attribute is only available from @@ -328,7 +328,7 @@ class Anonymizer(Record): def __init__( self, *, - anonymizer_confidence: int | None = None, + confidence: int | None = None, is_anonymous: bool = False, is_anonymous_vpn: bool = False, is_hosting_provider: bool = False, @@ -339,7 +339,7 @@ def __init__( provider_name: str | None = None, **_: Any, ) -> None: - self.anonymizer_confidence = anonymizer_confidence + self.confidence = confidence self.is_anonymous = is_anonymous self.is_anonymous_vpn = is_anonymous_vpn self.is_hosting_provider = is_hosting_provider diff --git a/tests/models_test.py b/tests/models_test.py index 2f026cd9..772450ec 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -15,7 +15,7 @@ def setUp(self) -> None: def test_insights_full(self) -> None: # noqa: PLR0915 raw = { "anonymizer": { - "anonymizer_confidence": 99, + "confidence": 99, "is_anonymous": True, "is_anonymous_vpn": True, "is_hosting_provider": True, @@ -253,7 +253,7 @@ def test_insights_full(self) -> None: # noqa: PLR0915 geoip2.records.Anonymizer, "geoip2.records.Anonymizer object", ) - self.assertEqual(model.anonymizer.anonymizer_confidence, 99) + self.assertEqual(model.anonymizer.confidence, 99) self.assertIs(model.anonymizer.is_anonymous, True) self.assertIs(model.anonymizer.is_anonymous_vpn, True) self.assertIs(model.anonymizer.is_hosting_provider, True) @@ -319,7 +319,7 @@ def test_insights_min(self) -> None: "Empty names hash returned", ) # Test that anonymizer fields default correctly - self.assertIsNone(model.anonymizer.anonymizer_confidence) + self.assertIsNone(model.anonymizer.confidence) self.assertIsNone(model.anonymizer.network_last_seen) self.assertIsNone(model.anonymizer.provider_name) self.assertFalse(model.anonymizer.is_anonymous)