xtr-http-foundation 4.0.2__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 (26) hide show
  1. xtr_http_foundation/.agents/skills/xtr-http-foundation/SKILL.md +204 -0
  2. xtr_http_foundation/__init__.py +44 -0
  3. xtr_http_foundation/chain_request_matcher.py +55 -0
  4. xtr_http_foundation/exception/__init__.py +12 -0
  5. xtr_http_foundation/exception/http_foundation_error.py +9 -0
  6. xtr_http_foundation/exception/invalid_argument_error.py +18 -0
  7. xtr_http_foundation/ip_utils.py +245 -0
  8. xtr_http_foundation/py.typed +0 -0
  9. xtr_http_foundation/request_matcher/__init__.py +27 -0
  10. xtr_http_foundation/request_matcher/_comma_separated.py +32 -0
  11. xtr_http_foundation/request_matcher/_compiled_regexp.py +37 -0
  12. xtr_http_foundation/request_matcher/attributes_request_matcher.py +62 -0
  13. xtr_http_foundation/request_matcher/header_request_matcher.py +50 -0
  14. xtr_http_foundation/request_matcher/host_request_matcher.py +49 -0
  15. xtr_http_foundation/request_matcher/ips_request_matcher.py +72 -0
  16. xtr_http_foundation/request_matcher/is_json_request_matcher.py +53 -0
  17. xtr_http_foundation/request_matcher/method_request_matcher.py +51 -0
  18. xtr_http_foundation/request_matcher/path_request_matcher.py +75 -0
  19. xtr_http_foundation/request_matcher/port_request_matcher.py +57 -0
  20. xtr_http_foundation/request_matcher/query_parameter_request_matcher.py +59 -0
  21. xtr_http_foundation/request_matcher/scheme_request_matcher.py +52 -0
  22. xtr_http_foundation/request_matcher_interface.py +36 -0
  23. xtr_http_foundation-4.0.2.dist-info/METADATA +239 -0
  24. xtr_http_foundation-4.0.2.dist-info/RECORD +26 -0
  25. xtr_http_foundation-4.0.2.dist-info/WHEEL +4 -0
  26. xtr_http_foundation-4.0.2.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,204 @@
1
+ ---
2
+ name: xtr-http-foundation
3
+ description: How to say "is this request one of mine" with xtr-http-foundation — one matcher per question about a request that arrived (method, path, host, port, scheme, a header, a query parameter, an attribute, the address it came from, a JSON body), chained into one, plus the IpUtils helpers that place an IP in a network, name a private one, or anonymize it. Use when a firewall, webhook endpoint, rate limiter, router or middleware has to decide which requests it claims; when replacing a tangle of ifs over the method and path with something testable; when writing a request parser's matcher; and when checking whether an address is inside a subnet, is reachable only from this network, or must be logged without identifying one machine.
4
+ ---
5
+
6
+ # xtr-http-foundation
7
+
8
+ A matcher answers one question about a request that arrived, and a chain of them answers the
9
+ whole of it — so what a piece of code claims is a value it can be handed rather than a
10
+ condition written into it. Beside them live the address helpers those questions are built on.
11
+
12
+ ## Quick reference
13
+
14
+ - Every matcher is a `RequestMatcherInterface` with one member:
15
+ `await matcher.matches(request)` → `bool`. The request is a `ServerRequestInterface`.
16
+ - `matches()` is awaited even on a matcher that only reads the head, so one can be swapped for
17
+ another.
18
+ - `ChainRequestMatcher([...])` claims a request only when all of them do, answering on the
19
+ first refusal. A chain of nothing claims everything.
20
+ - A matcher naming nothing claims everything. One string is as good as a sequence, and any
21
+ entry may hold a comma-separated list.
22
+ - Expressions are compiled when the matcher is built — a bad one is `InvalidArgumentError`
23
+ there — and are *searched for*, never anchored for you.
24
+ - `IpUtils.check_ip`, `check_ip4`, `check_ip6`, `is_private_ip`, `anonymize`,
25
+ `IpUtils.PRIVATE_SUBNETS`. All static; there is nothing to build.
26
+
27
+ ## Claim the requests that are yours
28
+
29
+ ```python
30
+ from xtr_http_foundation import (
31
+ ChainRequestMatcher,
32
+ IsJsonRequestMatcher,
33
+ MethodRequestMatcher,
34
+ PathRequestMatcher,
35
+ )
36
+
37
+ ours = ChainRequestMatcher(
38
+ [MethodRequestMatcher("POST"), PathRequestMatcher("^/webhook/"), IsJsonRequestMatcher()]
39
+ )
40
+
41
+ if await ours.matches(request):
42
+ ...
43
+ ```
44
+
45
+ | Matcher | Takes | Claims a request |
46
+ | --- | --- | --- |
47
+ | `MethodRequestMatcher(methods)` | one method or several | whose method is one of them; names read in upper case, the request's compared as it arrived |
48
+ | `PathRequestMatcher(regexp)` | one expression | whose path matches it |
49
+ | `HostRequestMatcher(regexp)` | one expression | whose host matches it, without regard to case |
50
+ | `PortRequestMatcher(port)` | one port | asked of that port, the scheme's standard one counting where the URI is silent |
51
+ | `SchemeRequestMatcher(schemes)` | one scheme or several | made over one of them |
52
+ | `HeaderRequestMatcher(headers)` | header names | carrying every one, whatever each holds |
53
+ | `QueryParameterRequestMatcher(parameters)` | parameter names | whose query *opens* with them, in that order |
54
+ | `IpsRequestMatcher(ips)` | addresses or CIDR networks | that came from one of them |
55
+ | `AttributesRequestMatcher(regexps)` | attribute name → expression | whose every named attribute matches its own |
56
+ | `IsJsonRequestMatcher()` | nothing | whose body reads as JSON |
57
+ | `ChainRequestMatcher(matchers)` | matchers | every one of them claims |
58
+
59
+ Three behave in a way worth knowing before you rely on them.
60
+
61
+ - **`IsJsonRequestMatcher` reads the body, not the `Content-Type` header.** A sender that
62
+ announces nothing still sent JSON; one that announces JSON and sent something else did not.
63
+ Any JSON value counts, so this is the rule for "a body worth decoding" rather than for a
64
+ shape. The body is read from its first byte — a stream another matcher already read is
65
+ rewound — so the order of a chain changes nothing, and a body that cannot be read is simply
66
+ not claimed.
67
+ - **`IpsRequestMatcher` reads the address the server accepted the connection from**, which the
68
+ request carries among its server parameters under `client`. A request through a proxy comes
69
+ *from the proxy*; a rule about the original caller belongs where the forwarded headers are
70
+ trusted and resolved.
71
+ - **`PathRequestMatcher` matches the path the application itself sees**, with the mount
72
+ (`root_path` among the server parameters) removed and per cent escapes undone — so
73
+ `/admin/fo o` matches `/admin/fo%20o`, and moving the mount rewrites no matcher. Nothing in
74
+ the expression is a path parameter: `^/{_locale}/login$` matches those very braces.
75
+
76
+ ```python
77
+ from xtr_http_foundation import AttributesRequestMatcher, HostRequestMatcher, IpsRequestMatcher
78
+
79
+ internal = IpsRequestMatcher("10.0.0.0/8,192.168.1.7")
80
+ subdomain = HostRequestMatcher(r"\.example\.com$")
81
+ owners = AttributesRequestMatcher({"role": "^owner$"})
82
+ ```
83
+
84
+ An attribute the request does not carry, or carries as something that is not text, is never
85
+ claimed.
86
+
87
+ ## Write a matcher
88
+
89
+ `RequestMatcherInterface` is a `@runtime_checkable` `Protocol`, so no base class is needed:
90
+
91
+ ```python
92
+ from typing import final
93
+
94
+ from typing_extensions import override
95
+ from xtr_http_foundation import RequestMatcherInterface
96
+ from xtr_http_message_contracts import ServerRequestInterface
97
+
98
+
99
+ @final
100
+ class SignedRequestMatcher(RequestMatcherInterface):
101
+ __slots__ = ()
102
+
103
+ @override
104
+ async def matches(self, request: ServerRequestInterface) -> bool:
105
+ return request.has_header("Webhook-Signature")
106
+ ```
107
+
108
+ ## Place an address
109
+
110
+ ```python
111
+ from xtr_http_foundation import IpUtils
112
+
113
+ IpUtils.check_ip("10.1.2.3", ["192.168.0.0/16", "10.0.0.0/8"]) # True
114
+ IpUtils.check_ip4("10.1.2.3", "10.0.0.0/8") # True
115
+ IpUtils.check_ip6("::1", "::1/128") # True
116
+ IpUtils.is_private_ip("10.1.2.3") # True
117
+ IpUtils.anonymize("192.168.1.42") # "192.168.1.0"
118
+ IpUtils.anonymize("2001:db8::1") # "2001:db8::"
119
+ ```
120
+
121
+ - `check_ip` takes a single address, a CIDR network, or several of either, and picks the IPv4 or
122
+ the IPv6 check by how the address is written.
123
+ - **The checks never raise over unreadable text**: they answer about a request from a stranger,
124
+ and an address nobody can read is one no network holds. A network written wrongly is read as
125
+ no network rather than as a wider one — `" 8"`, `"+8"`, `"8.5"` name no prefix length — and an
126
+ IPv6 zone such as `fe80::1%eth0` is refused, since it names an interface of whichever host
127
+ reads it.
128
+ - `is_private_ip` is the one that **does** raise `InvalidArgumentError` for text that is no
129
+ address: its caller is asking whether an address may be reached out to. It places the address
130
+ in `IpUtils.PRIVATE_SUBNETS` — loopback, private use, link-local, carrier-grade NAT,
131
+ unique-local, multicast, reserved, unspecified, the documentation and benchmarking ranges, and
132
+ the forms an IPv6 address writes an IPv4 one in.
133
+ - `anonymize(ip, v4_bytes=1, v6_bytes=8)` zeroes the last bytes and gives the address back as it
134
+ arrived — bracketed if it was, dotted where it carries an IPv4 one, and never with its zone.
135
+ Fewer than no bytes, or more than the address has, is `InvalidArgumentError`.
136
+
137
+ ## Testing
138
+
139
+ A matcher is a value, so a test hands it a request it built and asserts the one answer:
140
+
141
+ ```python
142
+ import pytest
143
+ from xtr_http_foundation import IpsRequestMatcher, MethodRequestMatcher
144
+ from xtr_http_message import ServerRequest
145
+
146
+
147
+ @pytest.mark.anyio
148
+ async def test_it_claims_a_post_from_inside_the_network() -> None:
149
+ matcher = IpsRequestMatcher("10.0.0.0/8")
150
+ arrived = ServerRequest("POST", "/metrics", server_params={"client": ("10.1.2.3", 51234)})
151
+
152
+ assert await matcher.matches(arrived)
153
+ assert await MethodRequestMatcher("POST").matches(arrived)
154
+ ```
155
+
156
+ - Build requests with `ServerRequest(method, uri, headers, body, version, server_params)` from
157
+ [xtr-http-message](https://github.com/xterr/python-xtr/tree/main/packages/xtr-http-message)
158
+ — a dev dependency here, since only tests build a request.
159
+ - Give `server_params={"client": (address, port)}` for `IpsRequestMatcher` and
160
+ `server_params={"root_path": "/mounted"}` for `PathRequestMatcher`; without them each reads
161
+ as nothing supplied.
162
+ - Set what `AttributesRequestMatcher` reads with `request.with_attribute(name, value)`.
163
+ - Pass the body as bytes for `IsJsonRequestMatcher`; nothing has to be rewound by hand.
164
+
165
+ ## Use in an application
166
+
167
+ This package **ships no bundle and no recipe**: a matcher is a value built where the rule is
168
+ written, not a service to configure.
169
+
170
+ 1. **Install** — `uv add xtr-http-foundation`. There are no extras.
171
+ 2. **Activate** — nothing to do. Nothing is registered, so nothing is listed in `BUNDLES`.
172
+ 3. **Configure** — nothing. Build the matchers where the rule belongs, usually in the class
173
+ that asks the question.
174
+ 4. **Environment** and **Ignore** — nothing.
175
+ 5. **Check** — the test above: a matcher is testable on its own, which is the point of it.
176
+ 6. **Remove** — `uv remove xtr-http-foundation`.
177
+
178
+ Packages that *do* wire these for you say so in their own sections: a
179
+ [xtr-webhook](https://github.com/xterr/python-xtr/tree/main/packages/xtr-webhook) parser
180
+ returns one from `get_request_matcher()`, and
181
+ [xtr-http-client](https://github.com/xterr/python-xtr/tree/main/packages/xtr-http-client)'s
182
+ `NoPrivateNetworkHttpClient` guards `IpUtils.PRIVATE_SUBNETS` by default.
183
+
184
+ ## Errors
185
+
186
+ Everything derives from `HttpFoundationError`. There is one: `InvalidArgumentError`, also a
187
+ `ValueError`, raised for an expression that does not read as one, and by `is_private_ip` and
188
+ `anonymize` for text that is no address.
189
+
190
+ ## Do not
191
+
192
+ - Do not check `Content-Type` to decide whether a body is JSON — `IsJsonRequestMatcher` reads
193
+ the body, which is the only thing that settles it.
194
+ - Do not trust `IpsRequestMatcher` for the original caller behind a proxy; resolve the forwarded
195
+ headers first, where they are trusted.
196
+ - Do not expect an expression to be anchored: `PathRequestMatcher("/admin")` claims anything
197
+ containing that path. Write `^/admin/` when that is the rule.
198
+ - Do not write path parameters into a `PathRequestMatcher` expression — `{id}` matches braces.
199
+ - Do not pass host names to `IpsRequestMatcher` or to the `IpUtils` checks: they place
200
+ addresses, and a name would have to be resolved first.
201
+ - Do not call `matches()` without awaiting it — every matcher is async, including the ones that
202
+ only read the head.
203
+ - Do not pass an empty matcher expecting it to refuse everything: a matcher naming nothing
204
+ claims every request.
@@ -0,0 +1,44 @@
1
+ """The request surface an incoming HTTP request is matched and measured against.
2
+
3
+ A matcher answers one question about a request that arrived — its method, path, host, port,
4
+ scheme, a header, a query parameter, an attribute, the address it came from, or whether its
5
+ body is JSON — and a chain of them answers the whole of it, so what a piece of code claims is
6
+ a value it can be handed rather than a condition written into it.
7
+
8
+ Beside them are the address helpers those questions are built on: whether an IP falls inside a
9
+ network, and whether it is one only this network can reach.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .chain_request_matcher import ChainRequestMatcher
15
+ from .ip_utils import IpUtils
16
+ from .request_matcher import (
17
+ AttributesRequestMatcher,
18
+ HeaderRequestMatcher,
19
+ HostRequestMatcher,
20
+ IpsRequestMatcher,
21
+ IsJsonRequestMatcher,
22
+ MethodRequestMatcher,
23
+ PathRequestMatcher,
24
+ PortRequestMatcher,
25
+ QueryParameterRequestMatcher,
26
+ SchemeRequestMatcher,
27
+ )
28
+ from .request_matcher_interface import RequestMatcherInterface
29
+
30
+ __all__ = [
31
+ "AttributesRequestMatcher",
32
+ "ChainRequestMatcher",
33
+ "HeaderRequestMatcher",
34
+ "HostRequestMatcher",
35
+ "IpUtils",
36
+ "IpsRequestMatcher",
37
+ "IsJsonRequestMatcher",
38
+ "MethodRequestMatcher",
39
+ "PathRequestMatcher",
40
+ "PortRequestMatcher",
41
+ "QueryParameterRequestMatcher",
42
+ "RequestMatcherInterface",
43
+ "SchemeRequestMatcher",
44
+ ]
@@ -0,0 +1,55 @@
1
+ """A matcher that claims a request only when every matcher it holds does."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from typing_extensions import override
8
+
9
+ from .request_matcher_interface import RequestMatcherInterface
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterable
13
+
14
+ from xtr_http_message_contracts import ServerRequestInterface
15
+
16
+ __all__ = ["ChainRequestMatcher"]
17
+
18
+
19
+ @final
20
+ class ChainRequestMatcher(RequestMatcherInterface):
21
+ """Claims a request only when every matcher it chains claims it.
22
+
23
+ What a piece of code will look at is usually more than one thing — a
24
+ ``POST``, carrying JSON, from inside this network — and this is how those
25
+ are said together without any matcher learning about the others.
26
+
27
+ A chain of nothing claims every request: nothing constrains it, so there is
28
+ nothing for it to refuse.
29
+ """
30
+
31
+ __slots__ = ("_matchers",)
32
+
33
+ def __init__(self, matchers: Iterable[RequestMatcherInterface]) -> None:
34
+ """Record the matchers that must all claim a request.
35
+
36
+ Args:
37
+ matchers: The matchers to chain. Read once and kept, so a chain
38
+ built over a generator answers the same on every request, and
39
+ a sequence the caller goes on to change cannot alter what the
40
+ chain claims.
41
+ """
42
+ self._matchers: tuple[RequestMatcherInterface, ...] = tuple(matchers)
43
+
44
+ @override
45
+ async def matches(self, request: ServerRequestInterface) -> bool:
46
+ """Tell whether every chained matcher claims ``request``.
47
+
48
+ The first refusal is the answer, so a matcher that reads the body is
49
+ never asked for a request an earlier one already turned away.
50
+ """
51
+ for matcher in self._matchers:
52
+ if not await matcher.matches(request):
53
+ return False
54
+
55
+ return True
@@ -0,0 +1,12 @@
1
+ """Every error this library raises.
2
+
3
+ All of them derive from :class:`HttpFoundationError`, so one ``except`` catches anything
4
+ this library refuses, and a narrower one handles a single cause.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .http_foundation_error import HttpFoundationError
10
+ from .invalid_argument_error import InvalidArgumentError
11
+
12
+ __all__ = ["HttpFoundationError", "InvalidArgumentError"]
@@ -0,0 +1,9 @@
1
+ """The error every other error of this package derives from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["HttpFoundationError"]
6
+
7
+
8
+ class HttpFoundationError(Exception):
9
+ """Something asked of this library that it cannot do — catch this to catch them all."""
@@ -0,0 +1,18 @@
1
+ """A value handed to this library cannot be used."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .http_foundation_error import HttpFoundationError
6
+
7
+ __all__ = ["InvalidArgumentError"]
8
+
9
+
10
+ class InvalidArgumentError(HttpFoundationError, ValueError):
11
+ """A value handed to this library cannot be used: text that is not an address.
12
+
13
+ Also a :class:`ValueError`, so code that already guards against a bad value
14
+ catches it without knowing this library exists. It says a caller passed
15
+ something it cannot have meant, which is why the checks that *decide* about a
16
+ request — whether an address falls in a network — answer ``False`` instead of
17
+ raising: an address that does not parse is simply not in the network.
18
+ """
@@ -0,0 +1,245 @@
1
+ """Placing an IP address inside a network, and hiding where it came from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network
7
+ from typing import TYPE_CHECKING, Final, final
8
+
9
+ from .exception import InvalidArgumentError
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+
14
+ __all__ = ["IpUtils"]
15
+
16
+ _V4_BYTES: Final = 4
17
+ _V6_BYTES: Final = 16
18
+ _V4_BITS: Final = 32
19
+ _V6_BITS: Final = 128
20
+
21
+ # How an IPv6 address says it carries an IPv4 one: "::ffff:1.2.3.4" is the form in use,
22
+ # "::1.2.3.4" the one that came before it. What identifies an address as either is the
23
+ # bytes in front, so anonymizing one means dropping bytes from its four, not its sixteen.
24
+ _V4_MAPPED: Final = b"\x00" * 10 + b"\xff\xff"
25
+ _V4_COMPATIBLE: Final = b"\x00" * 12
26
+
27
+
28
+ def _as_ipv4(text: str) -> IPv4Address | None:
29
+ """The IPv4 address ``text`` writes, or ``None`` when it writes none."""
30
+ try:
31
+ return IPv4Address(text)
32
+ except ValueError:
33
+ return None
34
+
35
+
36
+ def _as_ipv6(text: str) -> IPv6Address | None:
37
+ """The IPv6 address ``text`` writes, or ``None`` when it writes none.
38
+
39
+ A zone — the ``%eth0`` of ``fe80::1%eth0`` — names an interface of whichever host
40
+ reads it, so the same text means a different address on the next machine. Nothing can
41
+ be decided from one, and it is refused here rather than quietly dropped.
42
+ """
43
+ if "%" in text:
44
+ return None
45
+ try:
46
+ return IPv6Address(text)
47
+ except ValueError:
48
+ return None
49
+
50
+
51
+ def _prefix_length(text: str, longest: int) -> int | None:
52
+ """The CIDR prefix length ``text`` writes, or ``None`` when it writes none.
53
+
54
+ Only ASCII digits count, so ``" 8"``, ``"+8"``, ``"8e0"`` and ``"8.5"`` name no prefix
55
+ at all rather than the 8 a lenient reader would take from each of them — a network
56
+ written wrongly must not silently become a wider one.
57
+ """
58
+ if not (text.isascii() and text.isdigit()):
59
+ return None
60
+ length = int(text)
61
+ return length if length <= longest else None
62
+
63
+
64
+ def _masked(packed: bytes, mask: bytes) -> bytes:
65
+ """``packed`` with every byte the mask does not keep zeroed."""
66
+ return bytes(byte & keep for byte, keep in zip(packed, mask, strict=True))
67
+
68
+
69
+ @final
70
+ class IpUtils:
71
+ """Where an address sits: in a network, in a private one, or hidden by its last bytes.
72
+
73
+ A namespace rather than something to build — every answer here is a function of its
74
+ arguments. Text that is not an address is no error for the checks: they answer about a
75
+ request from a stranger, whose address is theirs to misspell, and an address nobody
76
+ can read is one no network holds.
77
+ """
78
+
79
+ __slots__ = ()
80
+
81
+ #: The networks an address in them can only be reached from inside this network.
82
+ PRIVATE_SUBNETS: Final[tuple[str, ...]] = (
83
+ "127.0.0.0/8", # loopback
84
+ "10.0.0.0/8", # private use
85
+ "192.168.0.0/16", # private use
86
+ "192.0.0.0/24", # assigned to protocols rather than to hosts
87
+ "192.0.2.0/24", # reserved for documentation
88
+ "198.51.100.0/24", # reserved for documentation
89
+ "203.0.113.0/24", # reserved for documentation
90
+ "172.16.0.0/12", # private use
91
+ "169.254.0.0/16", # link local
92
+ "192.88.99.0/24", # 6to4 relay anycast, withdrawn
93
+ "198.18.0.0/15", # reserved for benchmarking
94
+ "0.0.0.0/8", # this network
95
+ "240.0.0.0/4", # reserved
96
+ "100.64.0.0/10", # shared address space, between a provider and its subscribers
97
+ "224.0.0.0/4", # multicast
98
+ "::1/128", # loopback
99
+ "fc00::/7", # unique local
100
+ "fe80::/10", # link local
101
+ "::ffff:0:0/96", # an IPv4 address written as an IPv6 one
102
+ "::/128", # unspecified
103
+ "::/96", # an IPv4 address written as an IPv6 one, in the earlier form
104
+ "100::/64", # discard only
105
+ "2002::/16", # 6to4
106
+ "2001::/32", # Teredo tunnelling
107
+ "2001:db8::/32", # reserved for documentation
108
+ "2001:0002::/48", # reserved for benchmarking
109
+ "64:ff9b::/96", # the well-known prefix translating IPv4 to IPv6
110
+ "64:ff9b:1::/48", # the same translation, for local use
111
+ "ff00::/8", # multicast
112
+ )
113
+
114
+ @staticmethod
115
+ def check_ip(request_ip: str, ips: str | Sequence[str]) -> bool:
116
+ """Whether ``request_ip`` falls in any of ``ips``.
117
+
118
+ Args:
119
+ request_ip: the address to place, written as IPv4 or IPv6.
120
+ ips: a network in CIDR notation, a single address, or several of either.
121
+
122
+ Returns:
123
+ Whether any of them holds the address; ``False`` for text that is no address.
124
+ """
125
+ networks = (ips,) if isinstance(ips, str) else ips
126
+ # More than one colon is what only an IPv6 address has — "::1" has two, while an
127
+ # IPv4 address carrying a port would have one.
128
+ check = IpUtils.check_ip6 if request_ip.count(":") > 1 else IpUtils.check_ip4
129
+ return any(check(request_ip, network) for network in networks)
130
+
131
+ @staticmethod
132
+ def check_ip4(request_ip: str, ip: str) -> bool:
133
+ """Whether ``request_ip`` falls in the IPv4 network or address ``ip``.
134
+
135
+ Args:
136
+ request_ip: the address to place, written as IPv4.
137
+ ip: a network in CIDR notation, or a single address, which stands for itself.
138
+
139
+ Returns:
140
+ Whether the network holds the address; ``False`` when either is no IPv4 one.
141
+ """
142
+ address = _as_ipv4(request_ip)
143
+ if address is None:
144
+ return False
145
+ network_text, separator, prefix_text = ip.partition("/")
146
+ network_address = _as_ipv4(network_text)
147
+ if network_address is None:
148
+ return False
149
+ prefix = _prefix_length(prefix_text, _V4_BITS) if separator else _V4_BITS
150
+ if prefix is None:
151
+ return False
152
+ return address in IPv4Network((network_address, prefix), strict=False)
153
+
154
+ @staticmethod
155
+ def check_ip6(request_ip: str, ip: str) -> bool:
156
+ """Whether ``request_ip`` falls in the IPv6 network or address ``ip``.
157
+
158
+ Args:
159
+ request_ip: the address to place, written as IPv6.
160
+ ip: a network in CIDR notation, or a single address, which stands for itself.
161
+
162
+ Returns:
163
+ Whether the network holds the address; ``False`` when either is no IPv6 one.
164
+ """
165
+ address = _as_ipv6(request_ip)
166
+ if address is None:
167
+ return False
168
+ network_text, separator, prefix_text = ip.partition("/")
169
+ network_address = _as_ipv6(network_text)
170
+ if network_address is None:
171
+ return False
172
+ prefix = _prefix_length(prefix_text, _V6_BITS) if separator else _V6_BITS
173
+ if prefix is None:
174
+ return False
175
+ return address in IPv6Network((network_address, prefix), strict=False)
176
+
177
+ @staticmethod
178
+ def anonymize(ip: str, v4_bytes: int = 1, v6_bytes: int = 8) -> str:
179
+ """``ip`` with its last bytes zeroed, so what is left no longer names one machine.
180
+
181
+ Args:
182
+ ip: the address to hide, bracketed or carrying a zone if that is how it came.
183
+ v4_bytes: how many of an IPv4 address's four bytes to drop.
184
+ v6_bytes: how many of an IPv6 address's sixteen bytes to drop.
185
+
186
+ Returns:
187
+ The address written as it arrived: bracketed if it was, and dotted where it
188
+ carries an IPv4 one. A zone is never given back — it names an interface, which
189
+ is as much a trace of who was asked as the bytes just dropped.
190
+
191
+ Raises:
192
+ InvalidArgumentError: when asked for fewer than no bytes, for more than the
193
+ address has, or for text that is no address at all.
194
+ """
195
+ if v4_bytes < 0 or v6_bytes < 0:
196
+ raise InvalidArgumentError("Cannot anonymize less than 0 bytes.")
197
+ if v4_bytes > _V4_BYTES or v6_bytes > _V6_BYTES:
198
+ raise InvalidArgumentError(
199
+ "Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6."
200
+ )
201
+
202
+ text = ip.partition("%")[0]
203
+ wrapped = text.startswith("[") and text.endswith("]")
204
+ if wrapped:
205
+ text = text[1:-1]
206
+ address: IPv4Address | IPv6Address | None = _as_ipv4(text) or _as_ipv6(text)
207
+ if address is None:
208
+ raise InvalidArgumentError(f'"{ip}" is not a valid IP address.')
209
+
210
+ tail = b"\xff" * (_V4_BYTES - v4_bytes) + b"\x00" * v4_bytes
211
+ packed = address.packed
212
+ if isinstance(address, IPv4Address):
213
+ anonymized = str(IPv4Address(_masked(packed, tail)))
214
+ else:
215
+ if packed.startswith(_V4_MAPPED):
216
+ mask = _V4_MAPPED + tail
217
+ elif packed.startswith(_V4_COMPATIBLE):
218
+ mask = _V4_COMPATIBLE + tail
219
+ else:
220
+ mask = b"\xff" * (_V6_BYTES - v6_bytes) + b"\x00" * v6_bytes
221
+ # `ipaddress` writes every group as hexadecimal, which would turn an address
222
+ # carrying an IPv4 one into "::ffff:7bea:eb00" — the same bytes, no longer
223
+ # recognisable as the address that went in. `inet_ntop` keeps the dotted form.
224
+ anonymized = socket.inet_ntop(socket.AF_INET6, _masked(packed, mask))
225
+ return f"[{anonymized}]" if wrapped else anonymized
226
+
227
+ @staticmethod
228
+ def is_private_ip(ip: str) -> bool:
229
+ """Whether ``ip`` is an address only this network can reach.
230
+
231
+ Args:
232
+ ip: the address to place, written as IPv4 or IPv6.
233
+
234
+ Returns:
235
+ Whether it falls in any of :attr:`PRIVATE_SUBNETS`.
236
+
237
+ Raises:
238
+ InvalidArgumentError: when ``ip`` is not an address written plainly. This one
239
+ refuses where the checks above answer, because its caller is asking
240
+ whether an address may be reached out to, and text nobody can read is not
241
+ an address that failed the question — it is a question nobody can answer.
242
+ """
243
+ if _as_ipv4(ip) is None and _as_ipv6(ip) is None:
244
+ raise InvalidArgumentError(f'"{ip}" is not a valid IP address.')
245
+ return IpUtils.check_ip(ip, IpUtils.PRIVATE_SUBNETS)
File without changes
@@ -0,0 +1,27 @@
1
+ """One matcher per question worth asking about a request that arrived."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .attributes_request_matcher import AttributesRequestMatcher
6
+ from .header_request_matcher import HeaderRequestMatcher
7
+ from .host_request_matcher import HostRequestMatcher
8
+ from .ips_request_matcher import IpsRequestMatcher
9
+ from .is_json_request_matcher import IsJsonRequestMatcher
10
+ from .method_request_matcher import MethodRequestMatcher
11
+ from .path_request_matcher import PathRequestMatcher
12
+ from .port_request_matcher import PortRequestMatcher
13
+ from .query_parameter_request_matcher import QueryParameterRequestMatcher
14
+ from .scheme_request_matcher import SchemeRequestMatcher
15
+
16
+ __all__ = [
17
+ "AttributesRequestMatcher",
18
+ "HeaderRequestMatcher",
19
+ "HostRequestMatcher",
20
+ "IpsRequestMatcher",
21
+ "IsJsonRequestMatcher",
22
+ "MethodRequestMatcher",
23
+ "PathRequestMatcher",
24
+ "PortRequestMatcher",
25
+ "QueryParameterRequestMatcher",
26
+ "SchemeRequestMatcher",
27
+ ]
@@ -0,0 +1,32 @@
1
+ """Reading one name, or several, out of what a matcher was given."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING, Final
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Sequence
10
+
11
+ __all__ = ["comma_separated"]
12
+
13
+ _SEPARATOR: Final = re.compile(r"\s*,\s*")
14
+
15
+
16
+ def comma_separated(given: Sequence[str] | str) -> tuple[str, ...]:
17
+ """Return every name ``given`` holds, however they were written.
18
+
19
+ A matcher takes one name or a list of them, and each of those may itself
20
+ hold several separated by commas — so a name read from configuration, where
21
+ a list is awkward to write, reaches the matcher the same way a list does.
22
+ Whitespace around a comma belongs to the separator rather than to a name.
23
+
24
+ Args:
25
+ given: One name, or several; any of them may be a comma-separated list.
26
+
27
+ Returns:
28
+ The names, in the order they were written.
29
+ """
30
+ values = (given,) if isinstance(given, str) else given
31
+
32
+ return tuple(name for value in values for name in _SEPARATOR.split(value))