diff --git a/.gitignore b/.gitignore index c1ac9a6..78e305c 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 0000000..500167e --- /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 diff --git a/HISTORY.rst b/HISTORY.rst index af97be3..ea962a9 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 1e4a0d3..48c821d 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 3b70696..fd77ce2 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 058cd54..23f784f 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 faa8b45..537ee8c 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 7cce9a8..2f026cd 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 826b078..59a2600 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(