ipregistry-fastapi 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ """
2
+ Copyright 2026 Ipregistry (https://ipregistry.co).
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Callable, Sequence, Union
20
+
21
+ from fastapi import HTTPException, Request
22
+ from fastapi.responses import PlainTextResponse
23
+ from ipregistry import UserAgents
24
+
25
+ from .blocking import Blocker
26
+ from .dependencies import SERVICE_UNAVAILABLE_DETAIL
27
+ from .service import Ipregistry
28
+ from .settings import IpregistrySettings
29
+
30
+ STATIC_ASSET_SUFFIXES = (
31
+ ".avif", ".css", ".gif", ".ico", ".jpeg", ".jpg", ".js", ".map", ".mjs",
32
+ ".otf", ".png", ".svg", ".ttf", ".txt", ".webmanifest", ".webp", ".woff", ".woff2",
33
+ )
34
+
35
+
36
+ class IpregistryMiddleware:
37
+ """Pure ASGI middleware enriching every HTTP request with IP intelligence.
38
+
39
+ Performs a single cached lookup per request, stores the result on
40
+ request.state.ipregistry (shared with the IpInfoDep dependency), and
41
+ optionally applies blocking actions before the request reaches the router:
42
+
43
+ app.add_middleware(
44
+ IpregistryMiddleware,
45
+ actions=[block_countries("XX"), block_threats(tor=True)],
46
+ )
47
+
48
+ Prefer route dependencies for per-endpoint rules; use the middleware to
49
+ enforce app-wide policies or to eagerly enrich every request.
50
+
51
+ Parameters:
52
+ service: the Ipregistry service to use. Defaults to the one attached to
53
+ the application state, else one built from settings / the environment.
54
+ settings: settings used when building a service lazily.
55
+ fields / hostname: per-lookup overrides forwarded to the lookup.
56
+ actions: blocking rules (block_countries, block_threats, or any Blocker)
57
+ evaluated in order; the first match returns its error response.
58
+ fail_closed: when True, respond with 503 if the lookup fails; an int
59
+ customizes the status code. Defaults to False (fail open).
60
+ skip_static_assets: skip lookups for common static file extensions.
61
+ skip_bots: skip lookups when the User-Agent looks like a bot or crawler.
62
+ skip: custom predicate receiving the request; return True to skip.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ app,
68
+ *,
69
+ service: Ipregistry | None = None,
70
+ settings: IpregistrySettings | None = None,
71
+ fields: str | None = None,
72
+ hostname: bool | None = None,
73
+ actions: Sequence[Blocker] = (),
74
+ fail_closed: Union[bool, int] = False,
75
+ skip_static_assets: bool = True,
76
+ skip_bots: bool = False,
77
+ skip: Callable[[Request], bool] | None = None,
78
+ ):
79
+ self.app = app
80
+ self._service = service
81
+ self._settings = settings
82
+ self._fields = fields
83
+ self._hostname = hostname
84
+ self._actions = tuple(actions)
85
+ self._fail_closed = fail_closed
86
+ self._skip_static_assets = skip_static_assets
87
+ self._skip_bots = skip_bots
88
+ self._skip = skip
89
+
90
+ async def __call__(self, scope, receive, send) -> None:
91
+ if scope["type"] != "http":
92
+ await self.app(scope, receive, send)
93
+ return
94
+
95
+ scope.setdefault("state", {})
96
+ request = Request(scope, receive)
97
+
98
+ if not self._should_skip(request):
99
+ service = self._resolve_service(scope)
100
+ info = await service.for_request(request, fields=self._fields, hostname=self._hostname)
101
+
102
+ if info is None and self._fail_closed and service.request_error(request) is not None:
103
+ status_code = 503 if self._fail_closed is True else int(self._fail_closed)
104
+ response = PlainTextResponse(SERVICE_UNAVAILABLE_DETAIL, status_code=status_code)
105
+ await response(scope, receive, send)
106
+ return
107
+
108
+ for action in self._actions:
109
+ try:
110
+ action.check(info)
111
+ except HTTPException as exception:
112
+ response = PlainTextResponse(
113
+ str(exception.detail),
114
+ status_code=exception.status_code,
115
+ headers=exception.headers,
116
+ )
117
+ await response(scope, receive, send)
118
+ return
119
+
120
+ await self.app(scope, receive, send)
121
+
122
+ def _resolve_service(self, scope) -> Ipregistry:
123
+ if self._service is not None:
124
+ return self._service
125
+ app = scope.get("app")
126
+ if app is not None:
127
+ existing = getattr(app.state, "ipregistry", None)
128
+ if existing is not None:
129
+ self._service = existing
130
+ return existing
131
+ service = Ipregistry(self._settings)
132
+ if app is not None:
133
+ app.state.ipregistry = service
134
+ self._service = service
135
+ return service
136
+
137
+ def _should_skip(self, request: Request) -> bool:
138
+ if self._skip_static_assets and request.url.path.lower().endswith(STATIC_ASSET_SUFFIXES):
139
+ return True
140
+ if self._skip_bots:
141
+ user_agent = request.headers.get("user-agent", "")
142
+ if user_agent and UserAgents.is_bot(user_agent):
143
+ return True
144
+ return self._skip is not None and self._skip(request)
File without changes
@@ -0,0 +1,202 @@
1
+ """
2
+ Copyright 2026 Ipregistry (https://ipregistry.co).
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import Any, Iterable, Union
21
+
22
+ from fastapi.requests import HTTPConnection
23
+ from ipregistry import (
24
+ AsyncIpregistryClient,
25
+ InMemoryCache,
26
+ IpInfo,
27
+ IpregistryConfig,
28
+ IpregistryError,
29
+ IpregistryLookupError,
30
+ NoCache,
31
+ RequesterIpInfo,
32
+ UserAgent,
33
+ )
34
+
35
+ from .exceptions import MissingApiKeyError
36
+ from .ip import IpExtractor, IpSource, anonymize_ip, build_ip_extractor, is_public_ip
37
+ from .settings import IpregistrySettings
38
+ from .version import __version__
39
+
40
+ logger = logging.getLogger("ipregistry_fastapi")
41
+
42
+ STATE_KEY = "ipregistry"
43
+ """Attribute under which the looked up IpInfo is memoized on request.state."""
44
+
45
+ ERROR_STATE_KEY = "ipregistry_error"
46
+ """Attribute under which a lookup failure is recorded on request.state."""
47
+
48
+
49
+ class Ipregistry:
50
+ """Request-aware Ipregistry service built on the asynchronous Ipregistry client.
51
+
52
+ Explicit lookups (lookup, lookup_batch, lookup_origin, parse_user_agent) raise
53
+ on failure. The request-aware for_request never raises: it memoizes a single
54
+ lookup per request on request.state and fails open, recording the error under
55
+ request.state.ipregistry_error.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ settings: IpregistrySettings | None = None,
61
+ *,
62
+ client: AsyncIpregistryClient | None = None,
63
+ ip_extractor: IpSource | None = None,
64
+ ):
65
+ self._settings = settings if settings is not None else IpregistrySettings()
66
+ self._ip_extractor: IpExtractor = build_ip_extractor(
67
+ ip_extractor if ip_extractor is not None else self._settings.ip_source
68
+ )
69
+ if client is None:
70
+ if not self._settings.api_key:
71
+ raise MissingApiKeyError()
72
+ config = IpregistryConfig(
73
+ key=self._settings.api_key,
74
+ base_url=self._settings.resolved_base_url,
75
+ timeout=self._settings.timeout,
76
+ retry_max_attempts=self._settings.retries + 1,
77
+ retry_interval=self._settings.retry_interval,
78
+ retry_on_server_error=self._settings.retry_on_server_error,
79
+ retry_on_too_many_requests=self._settings.retry_on_too_many_requests,
80
+ user_agent=f"IpregistryFastAPI/{__version__}",
81
+ )
82
+ cache = (
83
+ InMemoryCache(maxsize=self._settings.cache_max_size, ttl=self._settings.cache_ttl)
84
+ if self._settings.cache_enabled
85
+ else NoCache()
86
+ )
87
+ client = AsyncIpregistryClient(config, cache=cache)
88
+ self._client = client
89
+
90
+ @property
91
+ def client(self) -> AsyncIpregistryClient:
92
+ """The underlying asynchronous Ipregistry client, for advanced use cases
93
+ such as ASN lookups or credits/throttling introspection."""
94
+ return self._client
95
+
96
+ @property
97
+ def settings(self) -> IpregistrySettings:
98
+ return self._settings
99
+
100
+ async def aclose(self) -> None:
101
+ """Release the underlying HTTP resources."""
102
+ await self._client.aclose()
103
+
104
+ async def __aenter__(self) -> "Ipregistry":
105
+ return self
106
+
107
+ async def __aexit__(self, exc_type, exc_value, traceback) -> None:
108
+ await self.aclose()
109
+
110
+ async def lookup(
111
+ self,
112
+ ip: str,
113
+ *,
114
+ fields: str | None = None,
115
+ hostname: bool | None = None,
116
+ **params: Any,
117
+ ) -> IpInfo:
118
+ """Look up IP intelligence for the given address. Raises ApiError or
119
+ ClientError on failure."""
120
+ response = await self._client.lookup_ip(ip, **self._options(fields, hostname, params))
121
+ return response.data
122
+
123
+ async def lookup_batch(
124
+ self,
125
+ ips: Iterable[str],
126
+ *,
127
+ fields: str | None = None,
128
+ hostname: bool | None = None,
129
+ **params: Any,
130
+ ) -> list[Union[IpInfo, IpregistryLookupError]]:
131
+ """Look up multiple addresses in one round trip. The result preserves
132
+ input order; failed entries are IpregistryLookupError instances."""
133
+ response = await self._client.batch_lookup_ips(list(ips), **self._options(fields, hostname, params))
134
+ return response.data
135
+
136
+ async def lookup_origin(
137
+ self,
138
+ *,
139
+ fields: str | None = None,
140
+ hostname: bool | None = None,
141
+ **params: Any,
142
+ ) -> RequesterIpInfo:
143
+ """Look up IP intelligence for the machine executing this code."""
144
+ response = await self._client.origin_lookup_ip(**self._options(fields, hostname, params))
145
+ return response.data
146
+
147
+ async def parse_user_agent(self, user_agent: str, **params: Any) -> UserAgent:
148
+ """Parse a User-Agent header value through the Ipregistry API."""
149
+ response = await self._client.parse_user_agent(user_agent, **params)
150
+ return response.data
151
+
152
+ def client_ip(self, connection: HTTPConnection) -> str | None:
153
+ """Resolve the client IP for a request using the configured IP source.
154
+
155
+ Returns the configured development_ip when the extracted address is
156
+ missing or not publicly routable, and None when no usable address
157
+ remains (private and reserved addresses are never sent to the API).
158
+ """
159
+ ip = self._ip_extractor(connection)
160
+ if ip is not None and is_public_ip(ip):
161
+ return ip
162
+ return self._settings.development_ip
163
+
164
+ async def for_request(
165
+ self,
166
+ connection: HTTPConnection,
167
+ *,
168
+ fields: str | None = None,
169
+ hostname: bool | None = None,
170
+ ) -> IpInfo | None:
171
+ """Look up IP intelligence for the request's client, memoized on
172
+ request.state.ipregistry. Never raises: on failure the request proceeds
173
+ without data and the error is stored on request.state.ipregistry_error."""
174
+ state = connection.state
175
+ if hasattr(state, STATE_KEY):
176
+ return getattr(state, STATE_KEY)
177
+
178
+ info: IpInfo | None = None
179
+ ip = self.client_ip(connection)
180
+ if ip is not None:
181
+ try:
182
+ info = await self.lookup(ip, fields=fields, hostname=hostname)
183
+ except IpregistryError as error:
184
+ setattr(state, ERROR_STATE_KEY, error)
185
+ logger.warning("Ipregistry lookup failed for %s: %s", anonymize_ip(ip), error)
186
+ setattr(state, STATE_KEY, info)
187
+ return info
188
+
189
+ @staticmethod
190
+ def request_error(connection: HTTPConnection) -> IpregistryError | None:
191
+ """The error recorded by for_request for this request, if any."""
192
+ return getattr(connection.state, ERROR_STATE_KEY, None)
193
+
194
+ def _options(self, fields: str | None, hostname: bool | None, params: dict[str, Any]) -> dict[str, Any]:
195
+ options = dict(params)
196
+ resolved_fields = fields if fields is not None else self._settings.fields
197
+ if resolved_fields:
198
+ options["fields"] = resolved_fields
199
+ resolved_hostname = hostname if hostname is not None else self._settings.hostname
200
+ if resolved_hostname:
201
+ options["hostname"] = True
202
+ return options
@@ -0,0 +1,128 @@
1
+ """
2
+ Copyright 2026 Ipregistry (https://ipregistry.co).
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ipaddress
20
+
21
+ from pydantic import field_validator
22
+ from pydantic_settings import BaseSettings, SettingsConfigDict
23
+
24
+ DEFAULT_BASE_URL = "https://api.ipregistry.co"
25
+ EU_BASE_URL = "https://eu.api.ipregistry.co"
26
+
27
+
28
+ class IpregistrySettings(BaseSettings):
29
+ """Ipregistry configuration, loaded from IPREGISTRY_* environment variables.
30
+
31
+ Every field can also be set programmatically:
32
+ IpregistrySettings(api_key="...", base_url="eu").
33
+ """
34
+
35
+ model_config = SettingsConfigDict(env_prefix="IPREGISTRY_", case_sensitive=False, extra="ignore")
36
+
37
+ api_key: str = ""
38
+ """Your Ipregistry API key. Required unless a pre-configured client is injected."""
39
+
40
+ base_url: str | None = None
41
+ """API base URL. None uses the default endpoint; the shorthand 'eu' selects the
42
+ EU-only endpoint (https://eu.api.ipregistry.co); any other value is used verbatim."""
43
+
44
+ fields: str | None = None
45
+ """Default field selection applied to lookups (e.g. 'location,security'), reducing
46
+ payload size and latency. None returns the full response."""
47
+
48
+ hostname: bool = False
49
+ """Whether to resolve the hostname associated with looked up IP addresses."""
50
+
51
+ timeout: float = 5.0
52
+ """Timeout in seconds for API requests. Kept low by default since lookups run on
53
+ the request path."""
54
+
55
+ retries: int = 1
56
+ """Number of retry attempts after a failed request (0 disables retries)."""
57
+
58
+ retry_interval: float = 0.5
59
+ """Base delay in seconds between retries; grows exponentially with each attempt."""
60
+
61
+ retry_on_server_error: bool = True
62
+ """Whether to retry requests failing with a 5xx status."""
63
+
64
+ retry_on_too_many_requests: bool = False
65
+ """Whether to retry requests failing with a 429 status, honoring Retry-After."""
66
+
67
+ cache_enabled: bool = True
68
+ """Whether to cache lookup responses in memory."""
69
+
70
+ cache_max_size: int = 2048
71
+ """Maximum number of cached lookup responses."""
72
+
73
+ cache_ttl: int = 600
74
+ """Time-to-live in seconds for cached lookup responses."""
75
+
76
+ development_ip: str | None = None
77
+ """Fallback IP address used when the client IP is private or missing, e.g. when
78
+ developing on localhost. Must be a valid IP address."""
79
+
80
+ fail_open: bool = True
81
+ """When True (default), requests proceed without IP data if a lookup fails.
82
+ When False, request-aware helpers respond with 503 on lookup failure."""
83
+
84
+ ip_source: str = "client"
85
+ """How to determine the client IP: 'client' (the ASGI client address, honoring
86
+ your server's proxy-headers configuration), a trusted-proxy preset ('auto',
87
+ 'cloudflare', 'nginx', 'forwarded-for'), or 'header:<name>' for a custom header."""
88
+
89
+ @field_validator("ip_source")
90
+ @classmethod
91
+ def _validate_ip_source(cls, value: str) -> str:
92
+ from .ip import build_ip_extractor
93
+
94
+ build_ip_extractor(value) # raises ValueError on unknown sources
95
+ return value
96
+
97
+ @field_validator("development_ip")
98
+ @classmethod
99
+ def _validate_development_ip(cls, value: str | None) -> str | None:
100
+ if value is None or value == "":
101
+ return None
102
+ try:
103
+ ipaddress.ip_address(value)
104
+ except ValueError as error:
105
+ raise ValueError(f"development_ip is not a valid IP address: {value!r}") from error
106
+ return value
107
+
108
+ @field_validator("timeout", "retry_interval")
109
+ @classmethod
110
+ def _validate_positive(cls, value: float) -> float:
111
+ if value <= 0:
112
+ raise ValueError("must be greater than 0")
113
+ return value
114
+
115
+ @field_validator("retries", "cache_max_size", "cache_ttl")
116
+ @classmethod
117
+ def _validate_non_negative(cls, value: int) -> int:
118
+ if value < 0:
119
+ raise ValueError("must not be negative")
120
+ return value
121
+
122
+ @property
123
+ def resolved_base_url(self) -> str:
124
+ if not self.base_url:
125
+ return DEFAULT_BASE_URL
126
+ if self.base_url.strip().lower() == "eu":
127
+ return EU_BASE_URL
128
+ return self.base_url
@@ -0,0 +1,186 @@
1
+ """
2
+ Copyright 2026 Ipregistry (https://ipregistry.co).
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Iterable, Mapping, Union
20
+
21
+ from fastapi.requests import HTTPConnection
22
+ from ipregistry import ApiError, IpInfo, IpregistryError, IpregistryLookupError, RequesterIpInfo, UserAgent
23
+
24
+ from .ip import IpSource, is_valid_ip
25
+ from .service import Ipregistry
26
+ from .settings import IpregistrySettings
27
+
28
+ ResponseStub = Union[Mapping[str, Any], IpInfo, Exception]
29
+
30
+ WILDCARD = "*"
31
+ ORIGIN = "origin"
32
+
33
+
34
+ class _UnusedClient:
35
+ """Placeholder ensuring an IpregistryFake never reaches the network."""
36
+
37
+ def __getattr__(self, name: str):
38
+ raise AssertionError("IpregistryFake performs no real API calls")
39
+
40
+
41
+ class IpregistryFake(Ipregistry):
42
+ """Drop-in Ipregistry service for tests: no network, canned responses.
43
+
44
+ Responses are keyed by IP address; '*' matches any address and 'origin'
45
+ serves origin lookups. A value may be an API-shaped dict (the 'ip' field is
46
+ filled in automatically), an IpInfo instance, or an exception to raise:
47
+
48
+ fake = IpregistryFake({
49
+ "66.165.2.7": {"location": {"country": {"code": "US"}}},
50
+ "*": {"security": {"is_threat": False}},
51
+ })
52
+ add_ipregistry(app, service=fake)
53
+
54
+ Unlike the real service, private and loopback client addresses are used
55
+ verbatim so requests made through TestClient resolve out of the box.
56
+ Lookups without a matching stub raise an ApiError (which request-aware
57
+ helpers turn into a fail-open None).
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ responses: Mapping[str, ResponseStub] | None = None,
63
+ *,
64
+ settings: IpregistrySettings | None = None,
65
+ ip_extractor: IpSource | None = None,
66
+ ):
67
+ super().__init__(
68
+ settings if settings is not None else IpregistrySettings(api_key="fake"),
69
+ client=_UnusedClient(), # type: ignore[arg-type]
70
+ ip_extractor=ip_extractor,
71
+ )
72
+ self._responses: dict[str, ResponseStub] = {}
73
+ self.lookups: list[str] = []
74
+ self.origin_lookups: int = 0
75
+ self.parsed_user_agents: list[str] = []
76
+ if responses:
77
+ self.stub(responses)
78
+
79
+ def stub(self, responses: Mapping[str, ResponseStub]) -> "IpregistryFake":
80
+ """Register additional canned responses, keyed by IP ('*', 'origin')."""
81
+ self._responses.update(responses)
82
+ return self
83
+
84
+ def reset(self) -> "IpregistryFake":
85
+ """Clear all recorded lookups."""
86
+ self.lookups.clear()
87
+ self.origin_lookups = 0
88
+ self.parsed_user_agents.clear()
89
+ return self
90
+
91
+ async def aclose(self) -> None:
92
+ pass
93
+
94
+ async def lookup(self, ip: str, *, fields=None, hostname=None, **params: Any) -> IpInfo:
95
+ self.lookups.append(ip)
96
+ stub = self._resolve_stub(ip)
97
+ if isinstance(stub, Exception):
98
+ raise stub
99
+ if isinstance(stub, IpInfo):
100
+ return stub
101
+ data = dict(stub)
102
+ data.setdefault("ip", ip if is_valid_ip(ip) else None)
103
+ return IpInfo(**data)
104
+
105
+ async def lookup_batch(self, ips: Iterable[str], *, fields=None, hostname=None, **params: Any):
106
+ results: list[Union[IpInfo, IpregistryLookupError]] = []
107
+ for ip in ips:
108
+ try:
109
+ results.append(await self.lookup(ip, fields=fields, hostname=hostname, **params))
110
+ except IpregistryLookupError as error:
111
+ results.append(error)
112
+ except IpregistryError as error:
113
+ results.append(IpregistryLookupError({"code": "UNKNOWN", "message": str(error), "resolution": ""}))
114
+ return results
115
+
116
+ async def lookup_origin(self, *, fields=None, hostname=None, **params: Any) -> RequesterIpInfo:
117
+ self.origin_lookups += 1
118
+ stub = self._responses.get(ORIGIN)
119
+ if stub is None:
120
+ raise self._missing(ORIGIN)
121
+ if isinstance(stub, Exception):
122
+ raise stub
123
+ if isinstance(stub, RequesterIpInfo):
124
+ return stub
125
+ if isinstance(stub, IpInfo):
126
+ return RequesterIpInfo(**stub.model_dump())
127
+ return RequesterIpInfo(**dict(stub))
128
+
129
+ async def parse_user_agent(self, user_agent: str, **params: Any) -> UserAgent:
130
+ self.parsed_user_agents.append(user_agent)
131
+ stub = self._responses.get(user_agent)
132
+ if isinstance(stub, Exception):
133
+ raise stub
134
+ if isinstance(stub, Mapping):
135
+ data = dict(stub)
136
+ data.setdefault("header", user_agent)
137
+ return UserAgent(**data)
138
+ return UserAgent(header=user_agent)
139
+
140
+ def client_ip(self, connection: HTTPConnection) -> str | None:
141
+ ip = self._ip_extractor(connection)
142
+ if ip:
143
+ return ip
144
+ if connection.client is not None and connection.client.host:
145
+ return connection.client.host
146
+ return self.settings.development_ip
147
+
148
+ # Assertions
149
+
150
+ def assert_looked_up(self, ip: str) -> None:
151
+ assert ip in self.lookups, f"Expected a lookup for {ip!r}; recorded lookups: {self.lookups!r}"
152
+
153
+ def assert_not_looked_up(self, ip: str) -> None:
154
+ assert ip not in self.lookups, f"Did not expect a lookup for {ip!r}"
155
+
156
+ def assert_looked_up_times(self, ip: str, times: int) -> None:
157
+ count = self.lookups.count(ip)
158
+ assert count == times, f"Expected {times} lookup(s) for {ip!r}, recorded {count}"
159
+
160
+ def assert_nothing_looked_up(self) -> None:
161
+ assert not self.lookups and self.origin_lookups == 0, (
162
+ f"Expected no lookups; recorded lookups: {self.lookups!r}, origin lookups: {self.origin_lookups}"
163
+ )
164
+
165
+ def assert_origin_looked_up(self) -> None:
166
+ assert self.origin_lookups > 0, "Expected at least one origin lookup"
167
+
168
+ def assert_user_agents_parsed(self, *user_agents: str) -> None:
169
+ for user_agent in user_agents:
170
+ assert user_agent in self.parsed_user_agents, (
171
+ f"Expected {user_agent!r} to be parsed; recorded: {self.parsed_user_agents!r}"
172
+ )
173
+
174
+ def _resolve_stub(self, ip: str) -> ResponseStub:
175
+ stub = self._responses.get(ip, self._responses.get(WILDCARD))
176
+ if stub is None:
177
+ raise self._missing(ip)
178
+ return stub
179
+
180
+ @staticmethod
181
+ def _missing(key: str) -> ApiError:
182
+ return ApiError(
183
+ "FAKE_RESPONSE_MISSING",
184
+ f"No fake response registered for {key!r}.",
185
+ "Register a response with IpregistryFake({...}) or a '*' wildcard entry.",
186
+ )
@@ -0,0 +1,17 @@
1
+ """
2
+ Copyright 2026 Ipregistry (https://ipregistry.co).
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ """
16
+
17
+ __version__ = "1.0.0"