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,74 @@
1
+ """
2
+ Official Ipregistry integration for FastAPI.
3
+
4
+ Dependency-driven IP geolocation and threat detection for every request,
5
+ built on the asynchronous Ipregistry Python client.
6
+
7
+ Copyright 2026 Ipregistry (https://ipregistry.co).
8
+
9
+ Licensed under the Apache License, Version 2.0 (the "License");
10
+ you may not use this file except in compliance with the License.
11
+ You may obtain a copy of the License at
12
+
13
+ https://www.apache.org/licenses/LICENSE-2.0
14
+
15
+ Unless required by applicable law or agreed to in writing, software
16
+ distributed under the License is distributed on an "AS IS" BASIS,
17
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18
+ See the License for the specific language governing permissions and
19
+ limitations under the License.
20
+ """
21
+
22
+ from ipregistry import (
23
+ ApiError as ApiError,
24
+ ApiResponse as ApiResponse,
25
+ Carrier as Carrier,
26
+ ClientError as ClientError,
27
+ Company as Company,
28
+ Connection as Connection,
29
+ Country as Country,
30
+ Currency as Currency,
31
+ ErrorCode as ErrorCode,
32
+ IpInfo as IpInfo,
33
+ IpregistryError as IpregistryError,
34
+ IpregistryLookupError as IpregistryLookupError,
35
+ Location as Location,
36
+ RequesterIpInfo as RequesterIpInfo,
37
+ Security as Security,
38
+ TimeZone as TimeZone,
39
+ UserAgent as UserAgent,
40
+ )
41
+
42
+ from .blocking import (
43
+ Blocker as Blocker,
44
+ CountryBlocker as CountryBlocker,
45
+ ThreatBlocker as ThreatBlocker,
46
+ block_countries as block_countries,
47
+ block_threats as block_threats,
48
+ )
49
+ from .dependencies import (
50
+ ClientIpDep as ClientIpDep,
51
+ IpInfoDep as IpInfoDep,
52
+ IpregistryDep as IpregistryDep,
53
+ get_client_ip as get_client_ip,
54
+ get_ip_info as get_ip_info,
55
+ get_ipregistry as get_ipregistry,
56
+ )
57
+ from .exceptions import MissingApiKeyError as MissingApiKeyError
58
+ from .guards import is_bot as is_bot, is_eu as is_eu, is_threat as is_threat
59
+ from .ip import (
60
+ IpExtractor as IpExtractor,
61
+ IpSource as IpSource,
62
+ anonymize_ip as anonymize_ip,
63
+ build_ip_extractor as build_ip_extractor,
64
+ is_public_ip as is_public_ip,
65
+ )
66
+ from .lifespan import add_ipregistry as add_ipregistry, ipregistry_lifespan as ipregistry_lifespan
67
+ from .middleware import IpregistryMiddleware as IpregistryMiddleware
68
+ from .service import Ipregistry as Ipregistry
69
+ from .settings import (
70
+ DEFAULT_BASE_URL as DEFAULT_BASE_URL,
71
+ EU_BASE_URL as EU_BASE_URL,
72
+ IpregistrySettings as IpregistrySettings,
73
+ )
74
+ from .version import __version__ as __version__
@@ -0,0 +1,145 @@
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 re
20
+ from typing import Iterable, Literal, Union
21
+
22
+ from fastapi import HTTPException
23
+ from fastapi.requests import HTTPConnection
24
+ from ipregistry import IpInfo
25
+
26
+ from .dependencies import SERVICE_UNAVAILABLE_DETAIL, IpregistryDep
27
+ from .guards import is_threat
28
+
29
+ _COUNTRY_CODE_PATTERN = re.compile(r"^[A-Za-z]{2}$")
30
+
31
+
32
+ class Blocker:
33
+ """Base for blocking rules, usable both as a FastAPI dependency and as a
34
+ middleware action."""
35
+
36
+ async def __call__(self, connection: HTTPConnection, service: IpregistryDep) -> None:
37
+ info = await service.for_request(connection)
38
+ if info is None and not service.settings.fail_open and service.request_error(connection) is not None:
39
+ raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_DETAIL)
40
+ self.check(info)
41
+
42
+ def check(self, info: IpInfo | None) -> None:
43
+ """Raise HTTPException when the request must be blocked."""
44
+ raise NotImplementedError
45
+
46
+
47
+ class CountryBlocker(Blocker):
48
+ def __init__(
49
+ self,
50
+ countries: Iterable[str],
51
+ mode: Literal["block", "allow"],
52
+ unknown: Literal["allow", "block"],
53
+ status_code: int,
54
+ detail: str,
55
+ ):
56
+ codes = set()
57
+ for country in countries:
58
+ if not isinstance(country, str) or not _COUNTRY_CODE_PATTERN.match(country):
59
+ raise ValueError(f"Invalid ISO 3166-1 alpha-2 country code: {country!r}")
60
+ codes.add(country.upper())
61
+ if not codes:
62
+ raise ValueError("At least one country code is required")
63
+ if mode not in ("block", "allow"):
64
+ raise ValueError(f"Invalid mode {mode!r}: expected 'block' or 'allow'")
65
+ if unknown not in ("allow", "block"):
66
+ raise ValueError(f"Invalid unknown policy {unknown!r}: expected 'allow' or 'block'")
67
+ self.countries = frozenset(codes)
68
+ self.mode = mode
69
+ self.unknown = unknown
70
+ self.status_code = status_code
71
+ self.detail = detail
72
+
73
+ def check(self, info: IpInfo | None) -> None:
74
+ location = info.location if info is not None else None
75
+ country = location.country if location is not None else None
76
+ code = country.code if country is not None else None
77
+ if code is None:
78
+ if self.unknown == "block":
79
+ raise HTTPException(status_code=self.status_code, detail=self.detail)
80
+ return
81
+ blocked = code in self.countries if self.mode == "block" else code not in self.countries
82
+ if blocked:
83
+ raise HTTPException(status_code=self.status_code, detail=self.detail)
84
+
85
+
86
+ def block_countries(
87
+ *countries: Union[str, Iterable[str]],
88
+ mode: Literal["block", "allow"] = "block",
89
+ unknown: Literal["allow", "block"] = "allow",
90
+ status_code: int = 451,
91
+ detail: str = "Access restricted in your region.",
92
+ ) -> CountryBlocker:
93
+ """Block (or, with mode='allow', restrict to) visitors from the given
94
+ ISO 3166-1 alpha-2 countries.
95
+
96
+ Use as a route dependency (dependencies=[Depends(block_countries("XX"))])
97
+ or as a middleware action. Requests whose country cannot be determined are
98
+ allowed unless unknown='block'. Responds with 451 Unavailable For Legal
99
+ Reasons by default. Country codes are validated eagerly, at declaration time.
100
+ """
101
+ if len(countries) == 1 and not isinstance(countries[0], str):
102
+ countries = tuple(countries[0])
103
+ return CountryBlocker(countries, mode, unknown, status_code, detail)
104
+
105
+
106
+ class ThreatBlocker(Blocker):
107
+ def __init__(self, signals: dict[str, bool], status_code: int, detail: str):
108
+ self.signals = signals
109
+ self.status_code = status_code
110
+ self.detail = detail
111
+
112
+ def check(self, info: IpInfo | None) -> None:
113
+ if is_threat(info, **self.signals):
114
+ raise HTTPException(status_code=self.status_code, detail=self.detail)
115
+
116
+
117
+ def block_threats(
118
+ *,
119
+ proxy: bool = False,
120
+ tor: bool = False,
121
+ vpn: bool = False,
122
+ relay: bool = False,
123
+ anonymous: bool = False,
124
+ cloud_provider: bool = False,
125
+ bogon: bool = False,
126
+ status_code: int = 403,
127
+ detail: str = "Access denied.",
128
+ ) -> ThreatBlocker:
129
+ """Block visitors flagged as threats by Ipregistry.
130
+
131
+ Known threats, attackers and abusers are always blocked. Anonymization and
132
+ infrastructure signals (proxy, tor, vpn, relay, anonymous, cloud_provider,
133
+ bogon) are opt-in. Requests without security data are allowed (fail open).
134
+ Use as a route dependency or as a middleware action.
135
+ """
136
+ signals = {
137
+ "proxy": proxy,
138
+ "tor": tor,
139
+ "vpn": vpn,
140
+ "relay": relay,
141
+ "anonymous": anonymous,
142
+ "cloud_provider": cloud_provider,
143
+ "bogon": bogon,
144
+ }
145
+ return ThreatBlocker(signals, status_code, detail)
@@ -0,0 +1,72 @@
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 Annotated, Optional
20
+
21
+ from fastapi import Depends, HTTPException
22
+ from fastapi.requests import HTTPConnection
23
+ from ipregistry import IpInfo
24
+
25
+ from .service import Ipregistry
26
+
27
+ SERVICE_UNAVAILABLE_DETAIL = "IP intelligence is temporarily unavailable."
28
+
29
+
30
+ async def get_ipregistry(connection: HTTPConnection) -> Ipregistry:
31
+ """Provide the application's Ipregistry service.
32
+
33
+ Uses the service attached with add_ipregistry; otherwise lazily creates one
34
+ from the environment and stores it on the application state.
35
+ """
36
+ service = getattr(connection.app.state, "ipregistry", None)
37
+ if service is None:
38
+ service = Ipregistry()
39
+ connection.app.state.ipregistry = service
40
+ return service
41
+
42
+
43
+ IpregistryDep = Annotated[Ipregistry, Depends(get_ipregistry)]
44
+ """The Ipregistry service, for explicit lookups and access to the raw client."""
45
+
46
+
47
+ async def get_ip_info(connection: HTTPConnection, service: IpregistryDep) -> Optional[IpInfo]:
48
+ """Provide IP intelligence for the request's client, in both HTTP and
49
+ WebSocket endpoints.
50
+
51
+ A single lookup is performed per request and shared by every consumer.
52
+ Returns None when no public client IP is available or, with fail_open
53
+ enabled (the default), when the lookup fails. With fail_open disabled a
54
+ lookup failure responds with 503.
55
+ """
56
+ info = await service.for_request(connection)
57
+ if info is None and not service.settings.fail_open and service.request_error(connection) is not None:
58
+ raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_DETAIL)
59
+ return info
60
+
61
+
62
+ IpInfoDep = Annotated[Optional[IpInfo], Depends(get_ip_info)]
63
+ """IP intelligence for the request's client, or None when unavailable."""
64
+
65
+
66
+ async def get_client_ip(connection: HTTPConnection, service: IpregistryDep) -> Optional[str]:
67
+ """Provide the client IP resolved by the configured IP source, or None."""
68
+ return service.client_ip(connection)
69
+
70
+
71
+ ClientIpDep = Annotated[Optional[str], Depends(get_client_ip)]
72
+ """The resolved public client IP address, or None."""
@@ -0,0 +1,32 @@
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 ipregistry import IpregistryError
18
+
19
+
20
+ class MissingApiKeyError(IpregistryError):
21
+ """Raised when an Ipregistry client is built without an API key.
22
+
23
+ Set the IPREGISTRY_API_KEY environment variable or pass
24
+ IpregistrySettings(api_key=...) explicitly.
25
+ """
26
+
27
+ def __init__(self, message: str | None = None):
28
+ super().__init__(
29
+ message
30
+ or "No Ipregistry API key configured. Set the IPREGISTRY_API_KEY environment "
31
+ "variable or pass IpregistrySettings(api_key=...) explicitly."
32
+ )
@@ -0,0 +1,81 @@
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 Union
20
+
21
+ from fastapi.requests import HTTPConnection
22
+ from ipregistry import IpInfo, UserAgents
23
+
24
+
25
+ def is_eu(info: IpInfo | None, *, assume_eu: bool = False) -> bool:
26
+ """Return True when the IP is located in the European Union.
27
+
28
+ Useful for GDPR consent flows. When the EU membership is unknown (no data),
29
+ returns assume_eu, so pass assume_eu=True to err on the side of showing a
30
+ consent banner.
31
+ """
32
+ in_eu = info.location.in_eu if info is not None and info.location is not None else None
33
+ return assume_eu if in_eu is None else in_eu
34
+
35
+
36
+ def is_threat(
37
+ info: IpInfo | None,
38
+ *,
39
+ proxy: bool = False,
40
+ tor: bool = False,
41
+ vpn: bool = False,
42
+ relay: bool = False,
43
+ anonymous: bool = False,
44
+ cloud_provider: bool = False,
45
+ bogon: bool = False,
46
+ ) -> bool:
47
+ """Return True when the IP is a known threat.
48
+
49
+ Known threats, attackers and abusers are always flagged. Anonymization
50
+ signals (proxy, tor, vpn, relay, anonymous) and infrastructure signals
51
+ (cloud_provider, bogon) are opt-in since they are not inherently malicious.
52
+ Returns False when no security data is available (fail open).
53
+ """
54
+ security = info.security if info is not None else None
55
+ if security is None:
56
+ return False
57
+ signals = [security.is_threat, security.is_attacker, security.is_abuser]
58
+ if proxy:
59
+ signals.append(security.is_proxy)
60
+ if tor:
61
+ signals.extend((security.is_tor, security.is_tor_exit))
62
+ if vpn:
63
+ signals.append(security.is_vpn)
64
+ if relay:
65
+ signals.append(security.is_relay)
66
+ if anonymous:
67
+ signals.append(security.is_anonymous)
68
+ if cloud_provider:
69
+ signals.append(security.is_cloud_provider)
70
+ if bogon:
71
+ signals.append(security.is_bogon)
72
+ return any(bool(signal) for signal in signals)
73
+
74
+
75
+ def is_bot(subject: Union[HTTPConnection, str, None]) -> bool:
76
+ """Return True when the request or User-Agent string looks like a bot or
77
+ crawler. Heuristic string check; no API call and no credit consumed."""
78
+ if subject is None:
79
+ return False
80
+ header = subject if isinstance(subject, str) else subject.headers.get("user-agent", "")
81
+ return bool(header) and UserAgents.is_bot(header)
@@ -0,0 +1,153 @@
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
+ from typing import Callable, Optional, Union
21
+
22
+ from fastapi.requests import HTTPConnection
23
+
24
+ IpExtractor = Callable[[HTTPConnection], Optional[str]]
25
+ """A callable that extracts the client IP address from an HTTP or WebSocket connection, or None."""
26
+
27
+ IpSource = Union[str, IpExtractor]
28
+ """An IP source: 'client', a trusted-proxy preset name, 'header:<name>' or a callable."""
29
+
30
+ PRESET_HEADERS: dict[str, tuple[str, ...]] = {
31
+ "auto": ("x-real-ip", "x-forwarded-for"),
32
+ "cloudflare": ("cf-connecting-ip",),
33
+ "nginx": ("x-real-ip", "x-forwarded-for"),
34
+ "forwarded-for": ("x-forwarded-for",),
35
+ }
36
+ """Headers consulted, in order, for each trusted-proxy preset."""
37
+
38
+
39
+ def sanitize_ip(value: str | None) -> str | None:
40
+ """Normalize a raw header or address value to a bare IP string.
41
+
42
+ Strips whitespace, IPv6 brackets (with an optional port, e.g. '[::1]:8080'),
43
+ an IPv4 port suffix (e.g. '1.2.3.4:443') and IPv6 zone identifiers ('%eth0').
44
+ Returns None when nothing is left.
45
+ """
46
+ if value is None:
47
+ return None
48
+ value = value.strip()
49
+ if value.startswith("["):
50
+ end = value.find("]")
51
+ if end == -1:
52
+ return None
53
+ value = value[1:end]
54
+ elif value.count(":") == 1 and "." in value:
55
+ value = value.split(":", 1)[0]
56
+ zone = value.find("%")
57
+ if zone != -1:
58
+ value = value[:zone]
59
+ return value or None
60
+
61
+
62
+ def is_valid_ip(value: str) -> bool:
63
+ """Return True when the value is a valid IPv4 or IPv6 address."""
64
+ try:
65
+ ipaddress.ip_address(value)
66
+ except ValueError:
67
+ return False
68
+ return True
69
+
70
+
71
+ def is_public_ip(value: str) -> bool:
72
+ """Return True when the value is a valid, publicly routable IP address.
73
+
74
+ Private, loopback, link-local, carrier-grade NAT and other reserved ranges
75
+ return False: the Ipregistry API rejects them with RESERVED_IP_ADDRESS, so
76
+ lookups for such addresses are skipped.
77
+ """
78
+ try:
79
+ address = ipaddress.ip_address(value)
80
+ except ValueError:
81
+ return False
82
+ if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
83
+ address = address.ipv4_mapped
84
+ return address.is_global and not address.is_multicast
85
+
86
+
87
+ def anonymize_ip(value: str) -> str:
88
+ """Mask an IP address for log output; this library never logs full client IPs."""
89
+ try:
90
+ address = ipaddress.ip_address(value)
91
+ except ValueError:
92
+ return "<invalid>"
93
+ if isinstance(address, ipaddress.IPv4Address):
94
+ octets = str(address).split(".")
95
+ return ".".join(octets[:3]) + ".x"
96
+ groups = address.exploded.split(":")
97
+ return ":".join(groups[:3]) + "::x"
98
+
99
+
100
+ def build_ip_extractor(source: IpSource = "client") -> IpExtractor:
101
+ """Build an IP extractor from an IP source specification.
102
+
103
+ Accepts 'client' (the ASGI client address), a trusted-proxy preset name
104
+ ('auto', 'cloudflare', 'nginx', 'forwarded-for'), 'header:<name>' for a
105
+ custom header, or a callable taking a connection and returning an IP or None.
106
+
107
+ Only configure headers set by a proxy you control: client-supplied headers
108
+ are trivially spoofable.
109
+ """
110
+ if callable(source):
111
+ return source
112
+
113
+ normalized = source.strip().lower()
114
+ if normalized == "client":
115
+ return _extract_from_client
116
+
117
+ if normalized.startswith("header:"):
118
+ header = normalized.split(":", 1)[1].strip()
119
+ if not header:
120
+ raise ValueError("Invalid IP source: 'header:' requires a header name, e.g. 'header:x-client-ip'")
121
+ headers: tuple[str, ...] = (header,)
122
+ elif normalized in PRESET_HEADERS:
123
+ headers = PRESET_HEADERS[normalized]
124
+ else:
125
+ presets = ", ".join(sorted(PRESET_HEADERS))
126
+ raise ValueError(
127
+ f"Unknown IP source {source!r}: expected 'client', a preset ({presets}), "
128
+ "'header:<name>' or a callable"
129
+ )
130
+
131
+ def extract(connection: HTTPConnection) -> str | None:
132
+ return _extract_from_headers(connection, headers)
133
+
134
+ return extract
135
+
136
+
137
+ def _extract_from_client(connection: HTTPConnection) -> str | None:
138
+ client = connection.client
139
+ if client is None or not client.host:
140
+ return None
141
+ ip = sanitize_ip(client.host)
142
+ return ip if ip is not None and is_valid_ip(ip) else None
143
+
144
+
145
+ def _extract_from_headers(connection: HTTPConnection, headers: tuple[str, ...]) -> str | None:
146
+ for name in headers:
147
+ raw = connection.headers.get(name)
148
+ if not raw:
149
+ continue
150
+ ip = sanitize_ip(raw.split(",", 1)[0])
151
+ if ip is not None and is_valid_ip(ip):
152
+ return ip
153
+ return None
@@ -0,0 +1,65 @@
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 contextlib import asynccontextmanager
20
+ from typing import AsyncIterator
21
+
22
+ from fastapi import FastAPI
23
+ from ipregistry import AsyncIpregistryClient
24
+
25
+ from .ip import IpSource
26
+ from .service import Ipregistry
27
+ from .settings import IpregistrySettings
28
+
29
+
30
+ def add_ipregistry(
31
+ app: FastAPI,
32
+ settings: IpregistrySettings | None = None,
33
+ *,
34
+ client: AsyncIpregistryClient | None = None,
35
+ ip_extractor: IpSource | None = None,
36
+ service: Ipregistry | None = None,
37
+ ) -> Ipregistry:
38
+ """Attach an Ipregistry service to the application state.
39
+
40
+ The service is picked up by the dependencies (IpInfoDep, IpregistryDep, ...)
41
+ and the middleware. Pass a pre-built service (e.g. an IpregistryFake in
42
+ tests) or let one be created from the given settings and environment.
43
+ """
44
+ if service is None:
45
+ service = Ipregistry(settings, client=client, ip_extractor=ip_extractor)
46
+ app.state.ipregistry = service
47
+ return service
48
+
49
+
50
+ @asynccontextmanager
51
+ async def ipregistry_lifespan(app: FastAPI) -> AsyncIterator[None]:
52
+ """Application lifespan managing the Ipregistry service lifecycle.
53
+
54
+ Use directly (FastAPI(lifespan=ipregistry_lifespan)) or compose it inside
55
+ your own lifespan with an AsyncExitStack. Creates a service from the
56
+ environment unless one was already attached with add_ipregistry, and
57
+ releases its HTTP resources on shutdown.
58
+ """
59
+ service = getattr(app.state, "ipregistry", None)
60
+ if service is None:
61
+ service = add_ipregistry(app)
62
+ try:
63
+ yield
64
+ finally:
65
+ await service.aclose()