ghost-api-cli 0.2.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.
Files changed (125) hide show
  1. ghost_api/__init__.py +3 -0
  2. ghost_api/__main__.py +5 -0
  3. ghost_api/adopt.py +173 -0
  4. ghost_api/agent/__init__.py +0 -0
  5. ghost_api/agent/orchestrator.py +413 -0
  6. ghost_api/agent/prompts.py +149 -0
  7. ghost_api/agent/tools.py +84 -0
  8. ghost_api/analysis/__init__.py +0 -0
  9. ghost_api/analysis/analyzer.py +130 -0
  10. ghost_api/analysis/detectors.py +188 -0
  11. ghost_api/analysis/exposure.py +80 -0
  12. ghost_api/analysis/graphql.py +67 -0
  13. ghost_api/analysis/models.py +64 -0
  14. ghost_api/analysis/schema.py +109 -0
  15. ghost_api/analysis/url_grouping.py +217 -0
  16. ghost_api/api.py +264 -0
  17. ghost_api/audit.py +78 -0
  18. ghost_api/browser/__init__.py +0 -0
  19. ghost_api/browser/auth_state.py +172 -0
  20. ghost_api/browser/driver.py +273 -0
  21. ghost_api/browser/egress.py +183 -0
  22. ghost_api/browser/gate.py +127 -0
  23. ghost_api/browser/install.py +28 -0
  24. ghost_api/browser/snapshot.py +221 -0
  25. ghost_api/capture/__init__.py +0 -0
  26. ghost_api/capture/filters.py +255 -0
  27. ghost_api/capture/har.py +148 -0
  28. ghost_api/capture/models.py +41 -0
  29. ghost_api/capture/personal.py +188 -0
  30. ghost_api/capture/recorder.py +181 -0
  31. ghost_api/cli/__init__.py +44 -0
  32. ghost_api/cli/_common.py +219 -0
  33. ghost_api/cli/client.py +487 -0
  34. ghost_api/cli/data.py +492 -0
  35. ghost_api/cli/explore.py +379 -0
  36. ghost_api/cli/manage.py +437 -0
  37. ghost_api/config.py +132 -0
  38. ghost_api/drift.py +344 -0
  39. ghost_api/drive.py +211 -0
  40. ghost_api/errors.py +106 -0
  41. ghost_api/events.py +213 -0
  42. ghost_api/exitcodes.py +21 -0
  43. ghost_api/export/__init__.py +71 -0
  44. ghost_api/export/confidence.py +45 -0
  45. ghost_api/export/html.py +41 -0
  46. ghost_api/export/openapi.py +201 -0
  47. ghost_api/export/report.py +227 -0
  48. ghost_api/export/site_data.py +196 -0
  49. ghost_api/export/tabular.py +88 -0
  50. ghost_api/export/viewer/report.html +286 -0
  51. ghost_api/generator/__init__.py +221 -0
  52. ghost_api/generator/cassettes.py +80 -0
  53. ghost_api/generator/models_gen.py +144 -0
  54. ghost_api/generator/naming.py +174 -0
  55. ghost_api/generator/package.py +197 -0
  56. ghost_api/generator/python_gen.py +172 -0
  57. ghost_api/generator/spec.py +486 -0
  58. ghost_api/generator/templates/client.py.j2 +518 -0
  59. ghost_api/generator/templates/client.test.ts.j2 +106 -0
  60. ghost_api/generator/templates/client.ts.j2 +339 -0
  61. ghost_api/generator/templates/models.py.j2 +45 -0
  62. ghost_api/generator/templates/package_readme.md.j2 +111 -0
  63. ghost_api/generator/templates/pyproject.toml.j2 +22 -0
  64. ghost_api/generator/templates/test_client.py.j2 +105 -0
  65. ghost_api/generator/ts_gen.py +232 -0
  66. ghost_api/integrations/__init__.py +0 -0
  67. ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
  68. ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
  69. ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
  70. ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
  71. ghost_api/integrations/setup.py +84 -0
  72. ghost_api/llm/__init__.py +0 -0
  73. ghost_api/llm/doctor.py +60 -0
  74. ghost_api/llm/gateway.py +476 -0
  75. ghost_api/llm/providers.py +124 -0
  76. ghost_api/memory/__init__.py +0 -0
  77. ghost_api/memory/knowledge.py +157 -0
  78. ghost_api/memory/store.py +883 -0
  79. ghost_api/net.py +235 -0
  80. ghost_api/netguard.py +203 -0
  81. ghost_api/pipeline.py +745 -0
  82. ghost_api/probe/__init__.py +0 -0
  83. ghost_api/probe/prober.py +311 -0
  84. ghost_api/probe/replay.py +313 -0
  85. ghost_api/project.py +186 -0
  86. ghost_api/py.typed +0 -0
  87. ghost_api/runtime.py +397 -0
  88. ghost_api/serve/__init__.py +101 -0
  89. ghost_api/serve/explorer_mcp.py +305 -0
  90. ghost_api/serve/mcp.py +121 -0
  91. ghost_api/serve/mock.py +130 -0
  92. ghost_api/serve/rest.py +132 -0
  93. ghost_api/site.py +84 -0
  94. ghost_api/specdiff.py +122 -0
  95. ghost_api/ui/__init__.py +0 -0
  96. ghost_api/ui/keys.py +134 -0
  97. ghost_api/ui/repl.py +453 -0
  98. ghost_api/ui/terminal.py +573 -0
  99. ghost_api/verify/__init__.py +0 -0
  100. ghost_api/verify/classify.py +84 -0
  101. ghost_api/verify/repair.py +40 -0
  102. ghost_api/verify/runner.py +110 -0
  103. ghost_api/verify/verifier.py +291 -0
  104. ghost_api/watch.py +313 -0
  105. ghost_api/web/__init__.py +1 -0
  106. ghost_api/web/server.py +690 -0
  107. ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
  108. ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
  109. ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
  110. ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
  111. ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
  112. ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
  113. ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
  114. ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
  115. ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
  116. ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
  117. ghost_api/web/static/favicon.svg +1 -0
  118. ghost_api/web/static/index.html +26 -0
  119. ghost_api/workflows.py +203 -0
  120. ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
  121. ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
  122. ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
  123. ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
  124. ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  125. ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/net.py ADDED
@@ -0,0 +1,235 @@
1
+ """Polite networking for requests the tool sends itself: rate limiting and robots.txt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from urllib.parse import urlsplit, urlunsplit
9
+ from urllib.robotparser import RobotFileParser
10
+
11
+ import httpx
12
+
13
+ from ghost_api.config import TOOL_NAME, tool_user_agent
14
+ from ghost_api.netguard import (
15
+ NetworkPolicy,
16
+ PolicyDenied,
17
+ ResponseTooLarge,
18
+ guarded_client,
19
+ read_limited,
20
+ )
21
+
22
+ MAX_ROBOTS_BYTES = 512 * 1024
23
+
24
+
25
+ class RateLimiter:
26
+ """Blocks so that calls to `wait()` happen at most `rate` times per second (NFR-18)."""
27
+
28
+ def __init__(self, rate: float) -> None:
29
+ if rate <= 0:
30
+ raise ValueError("rate must be greater than 0")
31
+ self.interval = 1.0 / rate
32
+ self._last = float("-inf")
33
+
34
+ def slow_down_to(self, min_interval: float) -> None:
35
+ self.interval = max(self.interval, min_interval)
36
+
37
+ def wait(self) -> None:
38
+ now = time.monotonic()
39
+ delay = self._last + self.interval - now
40
+ if delay > 0:
41
+ time.sleep(delay)
42
+ self._last = time.monotonic()
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class RobotsVerdict:
47
+ allowed: bool
48
+ reason: str
49
+ crawl_delay: float | None = None
50
+
51
+
52
+ def robots_url(url: str) -> str:
53
+ parts = urlsplit(url)
54
+ return urlunsplit((parts.scheme, parts.netloc, "/robots.txt", "", ""))
55
+
56
+
57
+ class RobotsPolicy:
58
+ """robots.txt rules for one host, reusable for every URL the agent visits (NFR-17)."""
59
+
60
+ def __init__(
61
+ self,
62
+ host: str,
63
+ parser: RobotFileParser | None,
64
+ default_allowed: bool,
65
+ reason: str,
66
+ agent: str = TOOL_NAME,
67
+ ) -> None:
68
+ self.host = host
69
+ self._parser = parser
70
+ self._default = default_allowed
71
+ self.reason = reason
72
+ self.agent = agent
73
+
74
+ @classmethod
75
+ def allow_all(cls, host: str, reason: str = "robots.txt check skipped") -> RobotsPolicy:
76
+ return cls(host, None, True, reason)
77
+
78
+ @classmethod
79
+ def from_text(cls, host: str, robots_txt: str, agent: str = TOOL_NAME) -> RobotsPolicy:
80
+ parser = RobotFileParser()
81
+ parser.parse(robots_txt.splitlines())
82
+ return cls(host, parser, True, "robots.txt rules", agent)
83
+
84
+ @property
85
+ def crawl_delay(self) -> float | None:
86
+ if self._parser is None:
87
+ return None
88
+ delay = self._parser.crawl_delay(self.agent)
89
+ return float(delay) if delay is not None else None
90
+
91
+ def allows(self, url: str) -> bool:
92
+ """Rules apply to the host they came from; other hosts are not checked here."""
93
+ if (urlsplit(url).hostname or "").lower() != self.host:
94
+ return True
95
+ if self._parser is None:
96
+ return self._default
97
+ return self._parser.can_fetch(self.agent, url)
98
+
99
+ def verdict(self, url: str) -> RobotsVerdict:
100
+ allowed = self.allows(url)
101
+ if self._parser is None:
102
+ reason = self.reason
103
+ else:
104
+ reason = "allowed by robots.txt" if allowed else "disallowed by robots.txt"
105
+ return RobotsVerdict(allowed, reason, self.crawl_delay)
106
+
107
+
108
+ class MultiHostRobotsPolicy(RobotsPolicy):
109
+ """Lazily fetches and caches robots.txt per host, so subdomains are checked too (Issue 3).
110
+
111
+ The agent may navigate from ``www.example.com`` to ``api.example.com``. A plain
112
+ ``RobotsPolicy`` only knows the rules for one host and returns ``True`` for any other.
113
+ This wrapper fetches and caches each host's robots.txt on first encounter.
114
+
115
+ It exposes the same ``allows(url)`` interface as ``RobotsPolicy`` so it's a drop-in
116
+ replacement wherever the orchestrator or pipeline calls ``robots.allows(url)``.
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ seed: RobotsPolicy,
122
+ limiter: RateLimiter | None = None,
123
+ fetcher: Callable[[str, RateLimiter | None], RobotsPolicy] | None = None,
124
+ ) -> None:
125
+ super().__init__(seed.host, None, True, seed.reason, seed.agent)
126
+ self._cache: dict[str, RobotsPolicy] = {seed.host: seed}
127
+ self._limiter = limiter
128
+ self._fetch = fetcher or fetch_robots
129
+
130
+ def _for(self, url: str) -> RobotsPolicy:
131
+ host = (urlsplit(url).hostname or "").lower()
132
+ if host not in self._cache:
133
+ self._cache[host] = self._fetch(url, self._limiter)
134
+ return self._cache[host]
135
+
136
+ @property
137
+ def crawl_delay(self) -> float | None:
138
+ return self._cache[self.host].crawl_delay
139
+
140
+ def allows(self, url: str) -> bool:
141
+ """Check the correct host's robots.txt, fetching it lazily if needed."""
142
+ return self._for(url).allows(url)
143
+
144
+ def verdict(self, url: str) -> RobotsVerdict:
145
+ return self._for(url).verdict(url)
146
+
147
+
148
+ class DenyPaths(RobotsPolicy):
149
+ """robots.txt plus the project's own rules (ghost-api.toml deny_paths) for one host:
150
+ a denied path is treated exactly like a robots.txt Disallow, everywhere."""
151
+
152
+ def __init__(self, inner: RobotsPolicy, deny: tuple[str, ...]) -> None:
153
+ super().__init__(inner.host, None, True, inner.reason, inner.agent)
154
+ self.inner = inner
155
+ self.deny = deny
156
+
157
+ @property
158
+ def crawl_delay(self) -> float | None:
159
+ return self.inner.crawl_delay
160
+
161
+ def _denied(self, url: str) -> bool:
162
+ parts = urlsplit(url)
163
+ if (parts.hostname or "").lower() != self.host:
164
+ return False
165
+ path = parts.path or "/"
166
+ return any(path == p or path.startswith(p.rstrip("/") + "/") for p in self.deny)
167
+
168
+ def allows(self, url: str) -> bool:
169
+ return not self._denied(url) and self.inner.allows(url)
170
+
171
+ def verdict(self, url: str) -> RobotsVerdict:
172
+ if self._denied(url):
173
+ return RobotsVerdict(False, "denied by the project rules (ghost-api.toml)")
174
+ return self.inner.verdict(url)
175
+
176
+
177
+ def evaluate_robots(robots_txt: str, url: str, agent: str = TOOL_NAME) -> RobotsVerdict:
178
+ """Apply robots.txt rules to a URL. Pure function, so it's easy to test."""
179
+ host = (urlsplit(url).hostname or "").lower()
180
+ return RobotsPolicy.from_text(host, robots_txt, agent).verdict(url)
181
+
182
+
183
+ def fetch_robots(
184
+ url: str,
185
+ limiter: RateLimiter | None = None,
186
+ policy: NetworkPolicy | None = None,
187
+ transport: httpx.BaseTransport | None = None,
188
+ ) -> RobotsPolicy:
189
+ """Fetch robots.txt, following RFC 9309 for missing or unreachable files."""
190
+ host = (urlsplit(url).hostname or "").lower()
191
+ if limiter:
192
+ limiter.wait()
193
+ # Redirects are followed (RFC 9309), but each hop is checked: a public site's
194
+ # robots.txt must not lead Ghost API to a private address.
195
+ client = guarded_client(
196
+ policy or NetworkPolicy.for_target(url),
197
+ follow_redirects=True,
198
+ timeout=10,
199
+ transport=transport,
200
+ )
201
+ try:
202
+ with (
203
+ client,
204
+ client.stream(
205
+ "GET", robots_url(url), headers={"User-Agent": tool_user_agent()}
206
+ ) as resp,
207
+ ):
208
+ text = read_limited(resp, MAX_ROBOTS_BYTES, MAX_ROBOTS_BYTES).text
209
+ except PolicyDenied as exc:
210
+ return RobotsPolicy(host, None, False, f"robots.txt {exc}")
211
+ except ResponseTooLarge:
212
+ # RFC 9309: at least 500 KiB must be parsed; a bigger file is treated as unreadable.
213
+ return RobotsPolicy(host, None, False, "robots.txt is too large to read")
214
+ except httpx.HTTPError as exc:
215
+ # Unreachable robots.txt: assume everything is disallowed.
216
+ return RobotsPolicy(host, None, False, f"robots.txt unreachable ({type(exc).__name__})")
217
+ if resp.status_code in (401, 403):
218
+ # The site refused to show its rules (Wikipedia does this to clients that don't
219
+ # identify themselves). Not knowing the rules is not permission.
220
+ return RobotsPolicy(
221
+ host,
222
+ None,
223
+ False,
224
+ f"robots.txt is forbidden (HTTP {resp.status_code}), so the site's rules can't be "
225
+ "read. Set GHOST_API_CONTACT in .env so the site can identify you",
226
+ )
227
+ if 400 <= resp.status_code < 500:
228
+ return RobotsPolicy(host, None, True, f"no robots.txt (HTTP {resp.status_code})")
229
+ if resp.status_code >= 500:
230
+ return RobotsPolicy(host, None, False, f"robots.txt server error (HTTP {resp.status_code})")
231
+ return RobotsPolicy.from_text(host, text)
232
+
233
+
234
+ def check_robots(url: str, limiter: RateLimiter | None = None) -> RobotsVerdict:
235
+ return fetch_robots(url, limiter).verdict(url)
ghost_api/netguard.py ADDED
@@ -0,0 +1,203 @@
1
+ """Where Ghost API may connect: never to this computer or the local network by accident.
2
+
3
+ A public website can redirect, or make its page call, an address such as 127.0.0.1,
4
+ 192.168.1.1 or the cloud metadata address 169.254.169.254. Ghost API runs on the user's
5
+ machine, so following that would let any website reach the user's private network (SSRF).
6
+
7
+ `NetworkPolicy` decides per destination. Private addresses are blocked unless the user's
8
+ own target is local (the demo shop, tests, a site on the LAN): then the run is in *local
9
+ target mode* and private addresses are allowed, because the user asked for them.
10
+
11
+ The check resolves the host name and looks at every address it resolves to. The browser's
12
+ traffic goes through `browser/egress.py`, which connects to the exact address that was
13
+ checked, so a host name can't be switched to a private address between check and use.
14
+ Direct requests (httpx) are checked on every redirect hop; httpx resolves the name again
15
+ itself, a small window that is documented in SECURITY.md.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ipaddress
21
+ import socket
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+ from urllib.parse import urlsplit
26
+
27
+ import httpcore
28
+ import httpx
29
+
30
+ IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
31
+ Resolver = Callable[[str, int], list[str]]
32
+
33
+ MAX_RESPONSE_BYTES = 5_000_000 # a reply bigger than this (after decompression) is not read
34
+ MAX_COMPRESSED_BYTES = 2_000_000 # bytes on the wire for one reply
35
+
36
+
37
+ class PolicyDenied(Exception):
38
+ """A destination Ghost API refuses to connect to."""
39
+
40
+
41
+ class ResponseTooLarge(Exception):
42
+ """A reply was bigger than the limit; reading stopped (response-too-large)."""
43
+
44
+
45
+ def system_resolver(host: str, port: int) -> list[str]:
46
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
47
+ return list(dict.fromkeys(str(info[4][0]) for info in infos))
48
+
49
+
50
+ def address_problem(ip: IPAddress) -> str | None:
51
+ """Why an address is not on the public internet, or None when it is."""
52
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
53
+ ip = ip.ipv4_mapped
54
+ if ip.is_loopback:
55
+ return "this computer (loopback)"
56
+ if ip.is_link_local:
57
+ return "a link-local address (e.g. cloud metadata)"
58
+ if ip.is_unspecified:
59
+ return "an unspecified address"
60
+ if ip.is_multicast:
61
+ return "a multicast address"
62
+ if ip.is_private or not ip.is_global:
63
+ return "a private-network address"
64
+ return None
65
+
66
+
67
+ def _literal_ip(host: str) -> IPAddress | None:
68
+ try:
69
+ return ipaddress.ip_address(host.strip("[]"))
70
+ except ValueError:
71
+ return None
72
+
73
+
74
+ @dataclass
75
+ class NetworkPolicy:
76
+ """`allow_private`: local target mode. `blocked`: tests replace the address rule."""
77
+
78
+ allow_private: bool = False
79
+ resolver: Resolver = system_resolver
80
+ blocked: Callable[[IPAddress, int], str | None] = field(
81
+ default=lambda ip, _port: address_problem(ip)
82
+ )
83
+ _cache: dict[tuple[str, int], list[str]] = field(default_factory=dict, repr=False)
84
+
85
+ @classmethod
86
+ def for_target(cls, url: str, resolver: Resolver = system_resolver) -> NetworkPolicy:
87
+ """Local target mode when the user's own target is on this machine or the LAN."""
88
+ host = (urlsplit(url).hostname or "").lower()
89
+ local = host == "localhost" or host.endswith(".localhost")
90
+ if not local:
91
+ try:
92
+ addresses = [_literal_ip(host)] if _literal_ip(host) else [
93
+ ipaddress.ip_address(a) for a in resolver(host, 443)
94
+ ] # fmt: skip
95
+ except (OSError, ValueError):
96
+ addresses = []
97
+ local = any(a is not None and address_problem(a) for a in addresses)
98
+ return cls(allow_private=local, resolver=resolver)
99
+
100
+ def resolve(self, host: str, port: int) -> list[str]:
101
+ """The addresses `host` may be reached at; raises PolicyDenied if any is blocked.
102
+
103
+ All addresses must pass: a name that resolves to one public and one private
104
+ address could be steered to the private one."""
105
+ host = host.strip("[]").lower()
106
+ literal = _literal_ip(host)
107
+ if literal is not None:
108
+ addresses = [str(literal)]
109
+ else:
110
+ key = (host, port)
111
+ if key not in self._cache:
112
+ try:
113
+ self._cache[key] = self.resolver(host, port)
114
+ except OSError as exc:
115
+ raise PolicyDenied(f"{host} could not be resolved ({exc})") from exc
116
+ addresses = self._cache[key]
117
+ if not addresses:
118
+ raise PolicyDenied(f"{host} has no address")
119
+ if not self.allow_private:
120
+ for address in addresses:
121
+ problem = self.blocked(ipaddress.ip_address(address), port)
122
+ if problem:
123
+ raise PolicyDenied(
124
+ f"blocked {host}: it points to {problem}. Ghost API does not let a "
125
+ "website reach your computer or local network"
126
+ )
127
+ return addresses
128
+
129
+ def check_url(self, url: str) -> None:
130
+ parts = urlsplit(url)
131
+ if parts.scheme not in ("http", "https") or not parts.hostname:
132
+ raise PolicyDenied(f"blocked {url[:80]}: only http(s) addresses are allowed")
133
+ port = parts.port or (443 if parts.scheme == "https" else 80)
134
+ self.resolve(parts.hostname, port)
135
+
136
+
137
+ class PinnedBackend(httpcore.SyncBackend):
138
+ """Connects to the address the policy checked, not to a second DNS answer, so a host
139
+ name can't be switched to a private address between check and connection (DNS
140
+ rebinding). TLS still uses the host name, so certificates are checked as usual."""
141
+
142
+ def __init__(self, policy: NetworkPolicy) -> None:
143
+ self.policy = policy
144
+
145
+ def connect_tcp(
146
+ self,
147
+ host: str,
148
+ port: int,
149
+ timeout: float | None = None,
150
+ local_address: str | None = None,
151
+ socket_options: Any = None,
152
+ ) -> httpcore.NetworkStream:
153
+ address = self.policy.resolve(host, port)[0] # raises PolicyDenied when blocked
154
+ return super().connect_tcp(address, port, timeout, local_address, socket_options)
155
+
156
+
157
+ def guarded_client(policy: NetworkPolicy, **kwargs: Any) -> httpx.Client:
158
+ """An httpx client that checks every request it sends, redirect hops included, and
159
+ connects only to the addresses it checked."""
160
+
161
+ def check(request: httpx.Request) -> None:
162
+ policy.check_url(str(request.url))
163
+
164
+ if "transport" not in kwargs or kwargs["transport"] is None:
165
+ kwargs.pop("transport", None)
166
+ transport = httpx.HTTPTransport()
167
+ pool = getattr(transport, "_pool", None)
168
+ if pool is not None and hasattr(pool, "_network_backend"):
169
+ pool._network_backend = PinnedBackend(policy)
170
+ kwargs["transport"] = transport
171
+ return httpx.Client(event_hooks={"request": [check]}, **kwargs)
172
+
173
+
174
+ @dataclass
175
+ class LimitedBody:
176
+ content: bytes
177
+ encoding: str | None
178
+
179
+ @property
180
+ def text(self) -> str:
181
+ return self.content.decode(self.encoding or "utf-8", errors="replace")
182
+
183
+
184
+ def read_limited(
185
+ response: httpx.Response,
186
+ max_bytes: int = MAX_RESPONSE_BYTES,
187
+ max_compressed: int = MAX_COMPRESSED_BYTES,
188
+ ) -> LimitedBody:
189
+ """Read a streamed reply, stopping as soon as it is too big (on the wire or unpacked),
190
+ so a huge or highly compressed reply never fills memory."""
191
+ declared = response.headers.get("content-length", "")
192
+ if declared.isdigit() and int(declared) > max_compressed:
193
+ raise ResponseTooLarge(f"response-too-large: {int(declared):,} bytes declared")
194
+ chunks: list[bytes] = []
195
+ size = 0
196
+ for chunk in response.iter_bytes():
197
+ size += len(chunk)
198
+ if size > max_bytes:
199
+ raise ResponseTooLarge(f"response-too-large: over {max_bytes:,} bytes unpacked")
200
+ if response.num_bytes_downloaded > max_compressed:
201
+ raise ResponseTooLarge(f"response-too-large: over {max_compressed:,} bytes")
202
+ chunks.append(chunk)
203
+ return LimitedBody(b"".join(chunks), response.encoding)