xtr-http-foundation 4.0.2__tar.gz

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 (27) hide show
  1. xtr_http_foundation-4.0.2/LICENSE +22 -0
  2. xtr_http_foundation-4.0.2/PKG-INFO +239 -0
  3. xtr_http_foundation-4.0.2/README.md +216 -0
  4. xtr_http_foundation-4.0.2/pyproject.toml +152 -0
  5. xtr_http_foundation-4.0.2/pyproject.toml.orig +155 -0
  6. xtr_http_foundation-4.0.2/src/xtr_http_foundation/.agents/skills/xtr-http-foundation/SKILL.md +204 -0
  7. xtr_http_foundation-4.0.2/src/xtr_http_foundation/__init__.py +44 -0
  8. xtr_http_foundation-4.0.2/src/xtr_http_foundation/chain_request_matcher.py +55 -0
  9. xtr_http_foundation-4.0.2/src/xtr_http_foundation/exception/__init__.py +12 -0
  10. xtr_http_foundation-4.0.2/src/xtr_http_foundation/exception/http_foundation_error.py +9 -0
  11. xtr_http_foundation-4.0.2/src/xtr_http_foundation/exception/invalid_argument_error.py +18 -0
  12. xtr_http_foundation-4.0.2/src/xtr_http_foundation/ip_utils.py +245 -0
  13. xtr_http_foundation-4.0.2/src/xtr_http_foundation/py.typed +0 -0
  14. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/__init__.py +27 -0
  15. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/_comma_separated.py +32 -0
  16. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/_compiled_regexp.py +37 -0
  17. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/attributes_request_matcher.py +62 -0
  18. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/header_request_matcher.py +50 -0
  19. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/host_request_matcher.py +49 -0
  20. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/ips_request_matcher.py +72 -0
  21. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/is_json_request_matcher.py +53 -0
  22. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/method_request_matcher.py +51 -0
  23. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/path_request_matcher.py +75 -0
  24. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/port_request_matcher.py +57 -0
  25. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/query_parameter_request_matcher.py +59 -0
  26. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/scheme_request_matcher.py +52 -0
  27. xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher_interface.py +36 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+ Copyright (c) 2004-present Fabien Potencier
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,239 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-http-foundation
3
+ Version: 4.0.2
4
+ Summary: Matchers that answer one question each about a request that arrived — method, path, host, port, scheme, headers, query, client address, JSON body — and the helpers that place an IP in a network.
5
+ Keywords: http,request-matcher,ip,subnet,asyncio
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: typing-extensions>=4.4
20
+ Requires-Dist: xtr-http-message-contracts>=4.0,<5
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+
24
+ <div align="center">
25
+
26
+ # xtr-http-foundation
27
+
28
+ **One question each about a request that arrived, composed into one matcher — and the helpers that place an IP in a network.**
29
+
30
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
31
+ <img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
32
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
33
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
34
+
35
+ </div>
36
+
37
+ ---
38
+
39
+ ## Why?
40
+
41
+ "Is this request one of mine?" is asked by a firewall, a webhook endpoint, a rate limiter and a
42
+ router, and each one answers it with its own tangle of `if`s over the method, the path and a
43
+ header or two. Written that way the answer is untestable on its own and subtly different
44
+ everywhere.
45
+
46
+ A matcher answers one such question, and a chain of them answers the whole of it. Alongside
47
+ them live the address helpers the questions about *who* sent a request are built on: whether an
48
+ IP falls in a network, and whether it is one only this network can reach.
49
+
50
+ ## Install
51
+
52
+ ```sh
53
+ uv add xtr-http-foundation
54
+ ```
55
+
56
+ Requires Python 3.11+. The one runtime dependency beyond `typing-extensions` is
57
+ [xtr-http-message-contracts](../xtr-http-message-contracts), because a matcher reads a request
58
+ that arrived and nothing else; the address helpers are built on the standard library's
59
+ `ipaddress`.
60
+
61
+ ## Quick start
62
+
63
+ A matcher answers one question, and `ChainRequestMatcher` answers the whole of it:
64
+
65
+ ```python
66
+ from xtr_http_foundation import (
67
+ ChainRequestMatcher,
68
+ IsJsonRequestMatcher,
69
+ MethodRequestMatcher,
70
+ PathRequestMatcher,
71
+ )
72
+ from xtr_http_message import ServerRequest
73
+
74
+ ours = ChainRequestMatcher(
75
+ [MethodRequestMatcher("POST"), PathRequestMatcher("^/webhook/"), IsJsonRequestMatcher()]
76
+ )
77
+
78
+ arrived = ServerRequest("POST", "/webhook/acme", {"Content-Type": "text/plain"}, b'{"id": 1}')
79
+
80
+ assert await ours.matches(arrived)
81
+ ```
82
+
83
+ `matches()` is awaited on every matcher, including those that only read the head: a rule may be
84
+ about the body, and one matcher has to be swappable for another. The chain answers on the first
85
+ refusal, so a matcher that reads the body is never asked about a request an earlier one already
86
+ turned away — and a chain of nothing claims every request, because nothing constrains it.
87
+
88
+ ## The matchers
89
+
90
+ | Matcher | Takes | Claims a request |
91
+ |---|---|---|
92
+ | `MethodRequestMatcher(methods)` | one method or several | whose method is one of them. The names are read in upper case; the request's own is compared as it arrived |
93
+ | `PathRequestMatcher(regexp)` | one expression | whose path matches it. Searched for rather than describing the whole path, so `/admin` claims anything under it and `^/admin/[0-9]+$` one shape of it |
94
+ | `HostRequestMatcher(regexp)` | one expression | whose host matches it, without regard to case: `\.example\.com$` is a subdomain rule, `^admin\.example\.com$` one host |
95
+ | `PortRequestMatcher(port)` | one port | asked of that port, the scheme's standard one counting where the URI is silent |
96
+ | `SchemeRequestMatcher(schemes)` | one scheme or several | made over one of them — `SchemeRequestMatcher("https")` is how a credential is kept off the clear |
97
+ | `HeaderRequestMatcher(headers)` | header names | carrying every one of them, whatever each holds. Names match the way HTTP matches them |
98
+ | `QueryParameterRequestMatcher(parameters)` | parameter names | whose query *opens* with them, in that order; further parameters after them are fine |
99
+ | `IpsRequestMatcher(ips)` | addresses or CIDR networks | that came from one of them |
100
+ | `AttributesRequestMatcher(regexps)` | each attribute name mapped to an expression | whose every named attribute matches its own |
101
+ | `IsJsonRequestMatcher()` | nothing | whose body reads as JSON |
102
+ | `ChainRequestMatcher(matchers)` | matchers | every one of them claims |
103
+
104
+ Wherever a matcher takes several names, one string is as good as a sequence and any of them may
105
+ itself hold a comma-separated list — `MethodRequestMatcher("POST,PUT")` is
106
+ `MethodRequestMatcher(["POST", "PUT"])`. A matcher naming nothing at all claims every request:
107
+ it constrains nothing.
108
+
109
+ Expressions are read when the matcher is built, so one that does not read as an expression is
110
+ `InvalidArgumentError` there rather than on the first request. They are *searched for* in the
111
+ value, never anchored for you.
112
+
113
+ Three of them are worth a sentence of their own.
114
+
115
+ - **`IsJsonRequestMatcher` reads the body, not the `Content-Type` header.** A sender that
116
+ announces nothing still sent JSON, and one that announces JSON and sent something else did
117
+ not. Any JSON value counts — an object, an array, a number, a string — so this is the rule for
118
+ "a body worth decoding" rather than for a shape. The body is read from its first byte, a
119
+ stream another matcher already read being rewound first, so the order of a chain changes
120
+ nothing; a body nobody can read is simply not claimed.
121
+ - **`IpsRequestMatcher` reads the address the server accepted the connection from**, reported
122
+ among the request's server parameters under `client`. A request that travelled through a proxy
123
+ therefore comes from the proxy: a rule about the original caller belongs where the forwarded
124
+ headers are trusted and resolved.
125
+
126
+ ```python
127
+ from xtr_http_foundation import IpsRequestMatcher
128
+ from xtr_http_message import ServerRequest
129
+
130
+ internal = IpsRequestMatcher("10.0.0.0/8,192.168.1.7")
131
+ arrived = ServerRequest("GET", "/metrics", server_params={"client": ("10.1.2.3", 51234)})
132
+
133
+ assert await internal.matches(arrived)
134
+ ```
135
+
136
+ - **`PathRequestMatcher` matches the path the application itself sees**, free of per cent
137
+ escapes: an application mounted under a path is reached at that path plus its own, the mount
138
+ is reported as the `root_path` server parameter, and a rule is written about the application's
139
+ own path — so moving the mount rewrites no matcher. `/admin/fo o` therefore matches
140
+ `/admin/fo%20o`, and nothing in the expression is a path parameter: `^/{_locale}/login$`
141
+ matches a path holding those very braces.
142
+
143
+ `AttributesRequestMatcher` is the one that asks about a finding rather than about the request as
144
+ it arrived — the named parameters a path matched, the user an authorization header resolved to:
145
+
146
+ ```python
147
+ from xtr_http_foundation import AttributesRequestMatcher
148
+ from xtr_http_message import ServerRequest
149
+
150
+ owners = AttributesRequestMatcher({"role": "^owner$"})
151
+ arrived = ServerRequest("GET", "/orders").with_attribute("role", "owner")
152
+
153
+ assert await owners.matches(arrived)
154
+ ```
155
+
156
+ An attribute the request does not carry, or carries as something that is not text, is never
157
+ claimed: there is nothing to match an expression against.
158
+
159
+ ## Writing one
160
+
161
+ `RequestMatcherInterface` is a `@runtime_checkable` `Protocol` with one member, so a rule of
162
+ your own needs no base class:
163
+
164
+ ```python
165
+ from typing import final
166
+
167
+ from typing_extensions import override
168
+ from xtr_http_foundation import RequestMatcherInterface
169
+ from xtr_http_message_contracts import ServerRequestInterface
170
+
171
+
172
+ @final
173
+ class SignedRequestMatcher(RequestMatcherInterface):
174
+ __slots__ = ()
175
+
176
+ @override
177
+ async def matches(self, request: ServerRequestInterface) -> bool:
178
+ return request.has_header("Webhook-Signature")
179
+ ```
180
+
181
+ ## Addresses
182
+
183
+ `IpUtils` is a namespace rather than something to build: every answer is a function of its
184
+ arguments.
185
+
186
+ ```python
187
+ from xtr_http_foundation import IpUtils
188
+
189
+ IpUtils.check_ip("10.1.2.3", ["192.168.0.0/16", "10.0.0.0/8"]) # True
190
+ IpUtils.check_ip4("10.1.2.3", "10.0.0.0/8") # True
191
+ IpUtils.check_ip6("::1", "::1/128") # True
192
+ IpUtils.is_private_ip("10.1.2.3") # True
193
+ IpUtils.anonymize("192.168.1.42") # "192.168.1.0"
194
+ IpUtils.anonymize("2001:db8::1") # "2001:db8::"
195
+ ```
196
+
197
+ - `check_ip(request_ip, ips)` takes a single address, a network in CIDR notation, or several of
198
+ either, and picks the IPv4 or the IPv6 check by how the address is written.
199
+ - **Text that is no address is no error for the checks**: they answer about a request from a
200
+ stranger, whose address is theirs to misspell, and an address nobody can read is one no
201
+ network holds. A network written wrongly is read as no network at all rather than as a wider
202
+ one: `" 8"`, `"+8"` and `"8.5"` name no prefix length, and an IPv6 zone — the `%eth0` of
203
+ `fe80::1%eth0`, which names an interface of whichever host reads it — is refused rather than
204
+ quietly dropped.
205
+ - `is_private_ip(ip)` is the one that *refuses* unreadable text, with `InvalidArgumentError`:
206
+ its caller is asking whether an address may be reached out to, and text nobody can read is not
207
+ an address that failed the question. It places the address in `IpUtils.PRIVATE_SUBNETS` —
208
+ loopback, private use, link-local, carrier-grade NAT, unique-local, multicast, reserved,
209
+ unspecified, the documentation and benchmarking ranges, and the forms an IPv6 address writes
210
+ an IPv4 one in. That tuple is what
211
+ [xtr-http-client](../xtr-http-client)'s `NoPrivateNetworkHttpClient` guards by default.
212
+ - `anonymize(ip, v4_bytes=1, v6_bytes=8)` zeroes the last bytes, so what is left no longer names
213
+ one machine — for a log or a counter that must not keep an address. It gives the address back
214
+ as it arrived, bracketed if it was and dotted where it carries an IPv4 one, and never gives a
215
+ zone back: that names an interface, which is as much a trace of who was asked as the bytes
216
+ just dropped. Asking for fewer than no bytes, or for more than the address has, is
217
+ `InvalidArgumentError`.
218
+
219
+ ## Errors
220
+
221
+ Everything derives from `HttpFoundationError`. There is one: `InvalidArgumentError`, also a
222
+ `ValueError`, raised for an expression that does not read as one, and by `is_private_ip` and
223
+ `anonymize` for text that is no address.
224
+
225
+ ## Development
226
+
227
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
228
+ `packages/xtr-http-foundation`; run the commands below from there. The
229
+ `python-xtr-http-foundation` repository is a read-only copy, so send issues and pull requests
230
+ to the monorepo.
231
+
232
+ ```sh
233
+ uv sync --all-extras
234
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
235
+ ```
236
+
237
+ ## License
238
+
239
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,216 @@
1
+ <div align="center">
2
+
3
+ # xtr-http-foundation
4
+
5
+ **One question each about a request that arrived, composed into one matcher — and the helpers that place an IP in a network.**
6
+
7
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
8
+ <img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
9
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
10
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## Why?
17
+
18
+ "Is this request one of mine?" is asked by a firewall, a webhook endpoint, a rate limiter and a
19
+ router, and each one answers it with its own tangle of `if`s over the method, the path and a
20
+ header or two. Written that way the answer is untestable on its own and subtly different
21
+ everywhere.
22
+
23
+ A matcher answers one such question, and a chain of them answers the whole of it. Alongside
24
+ them live the address helpers the questions about *who* sent a request are built on: whether an
25
+ IP falls in a network, and whether it is one only this network can reach.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ uv add xtr-http-foundation
31
+ ```
32
+
33
+ Requires Python 3.11+. The one runtime dependency beyond `typing-extensions` is
34
+ [xtr-http-message-contracts](../xtr-http-message-contracts), because a matcher reads a request
35
+ that arrived and nothing else; the address helpers are built on the standard library's
36
+ `ipaddress`.
37
+
38
+ ## Quick start
39
+
40
+ A matcher answers one question, and `ChainRequestMatcher` answers the whole of it:
41
+
42
+ ```python
43
+ from xtr_http_foundation import (
44
+ ChainRequestMatcher,
45
+ IsJsonRequestMatcher,
46
+ MethodRequestMatcher,
47
+ PathRequestMatcher,
48
+ )
49
+ from xtr_http_message import ServerRequest
50
+
51
+ ours = ChainRequestMatcher(
52
+ [MethodRequestMatcher("POST"), PathRequestMatcher("^/webhook/"), IsJsonRequestMatcher()]
53
+ )
54
+
55
+ arrived = ServerRequest("POST", "/webhook/acme", {"Content-Type": "text/plain"}, b'{"id": 1}')
56
+
57
+ assert await ours.matches(arrived)
58
+ ```
59
+
60
+ `matches()` is awaited on every matcher, including those that only read the head: a rule may be
61
+ about the body, and one matcher has to be swappable for another. The chain answers on the first
62
+ refusal, so a matcher that reads the body is never asked about a request an earlier one already
63
+ turned away — and a chain of nothing claims every request, because nothing constrains it.
64
+
65
+ ## The matchers
66
+
67
+ | Matcher | Takes | Claims a request |
68
+ |---|---|---|
69
+ | `MethodRequestMatcher(methods)` | one method or several | whose method is one of them. The names are read in upper case; the request's own is compared as it arrived |
70
+ | `PathRequestMatcher(regexp)` | one expression | whose path matches it. Searched for rather than describing the whole path, so `/admin` claims anything under it and `^/admin/[0-9]+$` one shape of it |
71
+ | `HostRequestMatcher(regexp)` | one expression | whose host matches it, without regard to case: `\.example\.com$` is a subdomain rule, `^admin\.example\.com$` one host |
72
+ | `PortRequestMatcher(port)` | one port | asked of that port, the scheme's standard one counting where the URI is silent |
73
+ | `SchemeRequestMatcher(schemes)` | one scheme or several | made over one of them — `SchemeRequestMatcher("https")` is how a credential is kept off the clear |
74
+ | `HeaderRequestMatcher(headers)` | header names | carrying every one of them, whatever each holds. Names match the way HTTP matches them |
75
+ | `QueryParameterRequestMatcher(parameters)` | parameter names | whose query *opens* with them, in that order; further parameters after them are fine |
76
+ | `IpsRequestMatcher(ips)` | addresses or CIDR networks | that came from one of them |
77
+ | `AttributesRequestMatcher(regexps)` | each attribute name mapped to an expression | whose every named attribute matches its own |
78
+ | `IsJsonRequestMatcher()` | nothing | whose body reads as JSON |
79
+ | `ChainRequestMatcher(matchers)` | matchers | every one of them claims |
80
+
81
+ Wherever a matcher takes several names, one string is as good as a sequence and any of them may
82
+ itself hold a comma-separated list — `MethodRequestMatcher("POST,PUT")` is
83
+ `MethodRequestMatcher(["POST", "PUT"])`. A matcher naming nothing at all claims every request:
84
+ it constrains nothing.
85
+
86
+ Expressions are read when the matcher is built, so one that does not read as an expression is
87
+ `InvalidArgumentError` there rather than on the first request. They are *searched for* in the
88
+ value, never anchored for you.
89
+
90
+ Three of them are worth a sentence of their own.
91
+
92
+ - **`IsJsonRequestMatcher` reads the body, not the `Content-Type` header.** A sender that
93
+ announces nothing still sent JSON, and one that announces JSON and sent something else did
94
+ not. Any JSON value counts — an object, an array, a number, a string — so this is the rule for
95
+ "a body worth decoding" rather than for a shape. The body is read from its first byte, a
96
+ stream another matcher already read being rewound first, so the order of a chain changes
97
+ nothing; a body nobody can read is simply not claimed.
98
+ - **`IpsRequestMatcher` reads the address the server accepted the connection from**, reported
99
+ among the request's server parameters under `client`. A request that travelled through a proxy
100
+ therefore comes from the proxy: a rule about the original caller belongs where the forwarded
101
+ headers are trusted and resolved.
102
+
103
+ ```python
104
+ from xtr_http_foundation import IpsRequestMatcher
105
+ from xtr_http_message import ServerRequest
106
+
107
+ internal = IpsRequestMatcher("10.0.0.0/8,192.168.1.7")
108
+ arrived = ServerRequest("GET", "/metrics", server_params={"client": ("10.1.2.3", 51234)})
109
+
110
+ assert await internal.matches(arrived)
111
+ ```
112
+
113
+ - **`PathRequestMatcher` matches the path the application itself sees**, free of per cent
114
+ escapes: an application mounted under a path is reached at that path plus its own, the mount
115
+ is reported as the `root_path` server parameter, and a rule is written about the application's
116
+ own path — so moving the mount rewrites no matcher. `/admin/fo o` therefore matches
117
+ `/admin/fo%20o`, and nothing in the expression is a path parameter: `^/{_locale}/login$`
118
+ matches a path holding those very braces.
119
+
120
+ `AttributesRequestMatcher` is the one that asks about a finding rather than about the request as
121
+ it arrived — the named parameters a path matched, the user an authorization header resolved to:
122
+
123
+ ```python
124
+ from xtr_http_foundation import AttributesRequestMatcher
125
+ from xtr_http_message import ServerRequest
126
+
127
+ owners = AttributesRequestMatcher({"role": "^owner$"})
128
+ arrived = ServerRequest("GET", "/orders").with_attribute("role", "owner")
129
+
130
+ assert await owners.matches(arrived)
131
+ ```
132
+
133
+ An attribute the request does not carry, or carries as something that is not text, is never
134
+ claimed: there is nothing to match an expression against.
135
+
136
+ ## Writing one
137
+
138
+ `RequestMatcherInterface` is a `@runtime_checkable` `Protocol` with one member, so a rule of
139
+ your own needs no base class:
140
+
141
+ ```python
142
+ from typing import final
143
+
144
+ from typing_extensions import override
145
+ from xtr_http_foundation import RequestMatcherInterface
146
+ from xtr_http_message_contracts import ServerRequestInterface
147
+
148
+
149
+ @final
150
+ class SignedRequestMatcher(RequestMatcherInterface):
151
+ __slots__ = ()
152
+
153
+ @override
154
+ async def matches(self, request: ServerRequestInterface) -> bool:
155
+ return request.has_header("Webhook-Signature")
156
+ ```
157
+
158
+ ## Addresses
159
+
160
+ `IpUtils` is a namespace rather than something to build: every answer is a function of its
161
+ arguments.
162
+
163
+ ```python
164
+ from xtr_http_foundation import IpUtils
165
+
166
+ IpUtils.check_ip("10.1.2.3", ["192.168.0.0/16", "10.0.0.0/8"]) # True
167
+ IpUtils.check_ip4("10.1.2.3", "10.0.0.0/8") # True
168
+ IpUtils.check_ip6("::1", "::1/128") # True
169
+ IpUtils.is_private_ip("10.1.2.3") # True
170
+ IpUtils.anonymize("192.168.1.42") # "192.168.1.0"
171
+ IpUtils.anonymize("2001:db8::1") # "2001:db8::"
172
+ ```
173
+
174
+ - `check_ip(request_ip, ips)` takes a single address, a network in CIDR notation, or several of
175
+ either, and picks the IPv4 or the IPv6 check by how the address is written.
176
+ - **Text that is no address is no error for the checks**: they answer about a request from a
177
+ stranger, whose address is theirs to misspell, and an address nobody can read is one no
178
+ network holds. A network written wrongly is read as no network at all rather than as a wider
179
+ one: `" 8"`, `"+8"` and `"8.5"` name no prefix length, and an IPv6 zone — the `%eth0` of
180
+ `fe80::1%eth0`, which names an interface of whichever host reads it — is refused rather than
181
+ quietly dropped.
182
+ - `is_private_ip(ip)` is the one that *refuses* unreadable text, with `InvalidArgumentError`:
183
+ its caller is asking whether an address may be reached out to, and text nobody can read is not
184
+ an address that failed the question. It places the address in `IpUtils.PRIVATE_SUBNETS` —
185
+ loopback, private use, link-local, carrier-grade NAT, unique-local, multicast, reserved,
186
+ unspecified, the documentation and benchmarking ranges, and the forms an IPv6 address writes
187
+ an IPv4 one in. That tuple is what
188
+ [xtr-http-client](../xtr-http-client)'s `NoPrivateNetworkHttpClient` guards by default.
189
+ - `anonymize(ip, v4_bytes=1, v6_bytes=8)` zeroes the last bytes, so what is left no longer names
190
+ one machine — for a log or a counter that must not keep an address. It gives the address back
191
+ as it arrived, bracketed if it was and dotted where it carries an IPv4 one, and never gives a
192
+ zone back: that names an interface, which is as much a trace of who was asked as the bytes
193
+ just dropped. Asking for fewer than no bytes, or for more than the address has, is
194
+ `InvalidArgumentError`.
195
+
196
+ ## Errors
197
+
198
+ Everything derives from `HttpFoundationError`. There is one: `InvalidArgumentError`, also a
199
+ `ValueError`, raised for an expression that does not read as one, and by `is_private_ip` and
200
+ `anonymize` for text that is no address.
201
+
202
+ ## Development
203
+
204
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
205
+ `packages/xtr-http-foundation`; run the commands below from there. The
206
+ `python-xtr-http-foundation` repository is a read-only copy, so send issues and pull requests
207
+ to the monorepo.
208
+
209
+ ```sh
210
+ uv sync --all-extras
211
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
212
+ ```
213
+
214
+ ## License
215
+
216
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,152 @@
1
+ [project]
2
+ name = "xtr-http-foundation"
3
+ version = "4.0.2"
4
+ description = "Matchers that answer one question each about a request that arrived — method, path, host, port, scheme, headers, query, client address, JSON body — and the helpers that place an IP in a network."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "http",
11
+ "request-matcher",
12
+ "ip",
13
+ "subnet",
14
+ "asyncio",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Framework :: AsyncIO",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Internet :: WWW/HTTP",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "typing-extensions>=4.4",
29
+ "xtr-http-message-contracts>=4.0,<5",
30
+ ]
31
+
32
+ [[project.authors]]
33
+ name = "Xterr"
34
+ email = "me@xterr.dev"
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "basedpyright>=1.21",
39
+ "ruff>=0.8",
40
+ "pytest>=8",
41
+ "pytest-cov>=5",
42
+ "anyio>=4.0",
43
+ "ty>=0.0.83",
44
+ "xtr-http-message>=4.0,<5",
45
+ ]
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.9.18,<0.10.0"]
49
+ build-backend = "uv_build"
50
+
51
+ [tool.basedpyright]
52
+ typeCheckingMode = "all"
53
+ pythonVersion = "3.11"
54
+ pythonPlatform = "All"
55
+ include = [
56
+ "src",
57
+ "tests",
58
+ ]
59
+ exclude = [
60
+ "**/__pycache__",
61
+ "**/.venv",
62
+ "**/build",
63
+ "**/dist",
64
+ ".tmp",
65
+ ]
66
+ reportUnusedCallResult = "warning"
67
+ reportUnnecessaryTypeIgnoreComment = "error"
68
+ reportUnusedVariable = "error"
69
+ reportMissingParameterType = "error"
70
+ reportPrivateUsage = "error"
71
+ reportImplicitStringConcatenation = "none"
72
+
73
+ [[tool.basedpyright.executionEnvironments]]
74
+ root = "tests"
75
+ extraPaths = ["."]
76
+ reportPrivateUsage = "none"
77
+
78
+ [tool.ruff]
79
+ target-version = "py311"
80
+ line-length = 100
81
+ src = [
82
+ "src",
83
+ "tests",
84
+ ]
85
+
86
+ [tool.ruff.lint]
87
+ select = ["ALL"]
88
+ ignore = [
89
+ "COM812",
90
+ "ISC001",
91
+ "D203",
92
+ "D213",
93
+ "CPY001",
94
+ "FBT001",
95
+ "FBT002",
96
+ "TD002",
97
+ "TD003",
98
+ "FIX002",
99
+ "TRY003",
100
+ "EM101",
101
+ "EM102",
102
+ ]
103
+ fixable = ["ALL"]
104
+ unfixable = []
105
+
106
+ [tool.ruff.lint.per-file-ignores]
107
+ "tests/**/*.py" = [
108
+ "S101",
109
+ "ARG",
110
+ "PLR2004",
111
+ "SLF001",
112
+ "D",
113
+ "S104",
114
+ ]
115
+
116
+ [tool.ruff.lint.pydocstyle]
117
+ convention = "google"
118
+
119
+ [tool.ruff.format]
120
+ quote-style = "double"
121
+ indent-style = "space"
122
+ docstring-code-format = true
123
+ docstring-code-line-length = "dynamic"
124
+
125
+ [tool.ty.src]
126
+ include = [
127
+ "src",
128
+ "tests",
129
+ ]
130
+
131
+ [tool.pytest.ini_options]
132
+ minversion = "8.0"
133
+ testpaths = ["tests"]
134
+ addopts = [
135
+ "-ra",
136
+ "--strict-config",
137
+ "--strict-markers",
138
+ ]
139
+ filterwarnings = ["error"]
140
+
141
+ [tool.coverage.run]
142
+ source = ["src"]
143
+ branch = true
144
+
145
+ [tool.coverage.report]
146
+ fail_under = 95
147
+ exclude_lines = [
148
+ "pragma: no cover",
149
+ "if TYPE_CHECKING:",
150
+ "raise NotImplementedError",
151
+ '^\s*\.\.\.$',
152
+ ]