http-fields 0.1.1__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.
- http_fields/__init__.py +193 -0
- http_fields/accept.py +37 -0
- http_fields/acceptcharset.py +48 -0
- http_fields/acceptencoding.py +42 -0
- http_fields/acceptlanguage.py +47 -0
- http_fields/acceptranges.py +35 -0
- http_fields/accesscontrol.py +197 -0
- http_fields/age.py +34 -0
- http_fields/allow.py +46 -0
- http_fields/altsvc.py +70 -0
- http_fields/authbases.py +79 -0
- http_fields/authenticationinfo.py +19 -0
- http_fields/authorization.py +23 -0
- http_fields/cachecontrol.py +119 -0
- http_fields/cachestatus.py +12 -0
- http_fields/connection.py +35 -0
- http_fields/contentdigest.py +19 -0
- http_fields/contentdisposition.py +66 -0
- http_fields/contentencoding.py +45 -0
- http_fields/contentlanguage.py +38 -0
- http_fields/contentlength.py +34 -0
- http_fields/contentlocation.py +24 -0
- http_fields/contentrange.py +74 -0
- http_fields/contenttype.py +104 -0
- http_fields/cookie.py +38 -0
- http_fields/date.py +17 -0
- http_fields/dateheader.py +32 -0
- http_fields/entitytaglist.py +39 -0
- http_fields/etag.py +46 -0
- http_fields/expect.py +52 -0
- http_fields/expires.py +17 -0
- http_fields/forwarded.py +36 -0
- http_fields/fromheader.py +42 -0
- http_fields/header.py +250 -0
- http_fields/host.py +44 -0
- http_fields/ifmatch.py +24 -0
- http_fields/ifmodifiedsince.py +17 -0
- http_fields/ifnonematch.py +24 -0
- http_fields/ifrange.py +49 -0
- http_fields/ifunmodifiedsince.py +17 -0
- http_fields/lastmodified.py +17 -0
- http_fields/link.py +35 -0
- http_fields/location.py +24 -0
- http_fields/maxforwards.py +32 -0
- http_fields/origin.py +42 -0
- http_fields/parsedobjs.py +62 -0
- http_fields/prefer.py +64 -0
- http_fields/priority.py +55 -0
- http_fields/productlistheader.py +33 -0
- http_fields/proxyauthenticate.py +17 -0
- http_fields/proxyauthenticationinfo.py +17 -0
- http_fields/proxyauthorization.py +17 -0
- http_fields/proxystatus.py +12 -0
- http_fields/py.typed +0 -0
- http_fields/range.py +37 -0
- http_fields/referer.py +24 -0
- http_fields/retryafter.py +45 -0
- http_fields/server.py +18 -0
- http_fields/setcookie.py +419 -0
- http_fields/stricttransportsecurity.py +96 -0
- http_fields/structuredfields.py +356 -0
- http_fields/structuredheaders.py +90 -0
- http_fields/te.py +36 -0
- http_fields/trailer.py +44 -0
- http_fields/upgrade.py +35 -0
- http_fields/uriheader.py +36 -0
- http_fields/useragent.py +18 -0
- http_fields/vary.py +40 -0
- http_fields/via.py +35 -0
- http_fields/visitors/__init__.py +1 -0
- http_fields/visitors/rfc3986.py +195 -0
- http_fields/visitors/rfc6265.py +62 -0
- http_fields/visitors/rfc6266.py +234 -0
- http_fields/visitors/rfc7230.py +80 -0
- http_fields/visitors/rfc7239.py +43 -0
- http_fields/visitors/rfc7240.py +77 -0
- http_fields/visitors/rfc7838.py +77 -0
- http_fields/visitors/rfc8288.py +56 -0
- http_fields/visitors/rfc9110/__init__.py +117 -0
- http_fields/visitors/rfc9110/_base.py +71 -0
- http_fields/visitors/rfc9110/accept.py +135 -0
- http_fields/visitors/rfc9110/acceptcharset.py +26 -0
- http_fields/visitors/rfc9110/acceptencoding.py +76 -0
- http_fields/visitors/rfc9110/acceptlanguage.py +55 -0
- http_fields/visitors/rfc9110/acceptranges.py +13 -0
- http_fields/visitors/rfc9110/allow.py +13 -0
- http_fields/visitors/rfc9110/authenticationinfo.py +19 -0
- http_fields/visitors/rfc9110/authorization.py +77 -0
- http_fields/visitors/rfc9110/authparam.py +40 -0
- http_fields/visitors/rfc9110/authscheme.py +17 -0
- http_fields/visitors/rfc9110/challenge.py +57 -0
- http_fields/visitors/rfc9110/comment.py +99 -0
- http_fields/visitors/rfc9110/connection.py +13 -0
- http_fields/visitors/rfc9110/contentlanguage.py +21 -0
- http_fields/visitors/rfc9110/contentlength.py +9 -0
- http_fields/visitors/rfc9110/contentrange.py +70 -0
- http_fields/visitors/rfc9110/contenttype.py +52 -0
- http_fields/visitors/rfc9110/date.py +12 -0
- http_fields/visitors/rfc9110/entitytag.py +66 -0
- http_fields/visitors/rfc9110/etag.py +12 -0
- http_fields/visitors/rfc9110/field.py +15 -0
- http_fields/visitors/rfc9110/host.py +32 -0
- http_fields/visitors/rfc9110/httpdate.py +167 -0
- http_fields/visitors/rfc9110/ifmatch.py +24 -0
- http_fields/visitors/rfc9110/ifmodifiedsince.py +12 -0
- http_fields/visitors/rfc9110/ifnonematch.py +26 -0
- http_fields/visitors/rfc9110/ifunmodifiedsince.py +12 -0
- http_fields/visitors/rfc9110/lastmodified.py +14 -0
- http_fields/visitors/rfc9110/location.py +9 -0
- http_fields/visitors/rfc9110/parameters.py +124 -0
- http_fields/visitors/rfc9110/product.py +39 -0
- http_fields/visitors/rfc9110/quotedstring.py +66 -0
- http_fields/visitors/rfc9110/range.py +100 -0
- http_fields/visitors/rfc9110/rangeunit.py +17 -0
- http_fields/visitors/rfc9110/retryafter.py +20 -0
- http_fields/visitors/rfc9110/server.py +16 -0
- http_fields/visitors/rfc9110/te.py +83 -0
- http_fields/visitors/rfc9110/token.py +18 -0
- http_fields/visitors/rfc9110/token68.py +18 -0
- http_fields/visitors/rfc9110/type.py +17 -0
- http_fields/visitors/rfc9110/upgrade.py +57 -0
- http_fields/visitors/rfc9110/useragent.py +16 -0
- http_fields/visitors/rfc9110/vary.py +10 -0
- http_fields/visitors/rfc9110/via.py +86 -0
- http_fields/visitors/rfc9110/weight.py +40 -0
- http_fields/visitors/rfc9110/wwwauthenticate.py +21 -0
- http_fields/visitors/rfc9111.py +92 -0
- http_fields/wwwauthenticate.py +24 -0
- http_fields-0.1.1.dist-info/METADATA +88 -0
- http_fields-0.1.1.dist-info/RECORD +132 -0
- http_fields-0.1.1.dist-info/WHEEL +4 -0
- http_fields-0.1.1.dist-info/licenses/LICENSE.txt +16 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Content-Type header class."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from abnf import Rule
|
|
7
|
+
from abnf.grammars import rfc9110
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from http_fields.header import Header
|
|
11
|
+
from http_fields.visitors.rfc9110 import ContentTypeVisitor, MediaType, Parameter
|
|
12
|
+
from http_fields.visitors.rfc9110.quotedstring import QuotedString
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _unquote(value: str) -> str:
|
|
16
|
+
"""Return the logical value of a parameter, stripping the surrounding DQUOTEs
|
|
17
|
+
and unescaping quoted-pairs when it is a quoted-string."""
|
|
18
|
+
if not isinstance(value, QuotedString):
|
|
19
|
+
return str(value)
|
|
20
|
+
inner = str(value)[1:-1]
|
|
21
|
+
out: list[str] = []
|
|
22
|
+
i = 0
|
|
23
|
+
while i < len(inner):
|
|
24
|
+
if inner[i] == "\\" and i + 1 < len(inner):
|
|
25
|
+
out.append(inner[i + 1])
|
|
26
|
+
i += 2
|
|
27
|
+
else:
|
|
28
|
+
out.append(inner[i])
|
|
29
|
+
i += 1
|
|
30
|
+
return "".join(out)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ContentType(Header):
|
|
35
|
+
"""Content-Type header, as defined by RFC 9110."""
|
|
36
|
+
|
|
37
|
+
name: ClassVar[str] = "Content-Type"
|
|
38
|
+
rule: ClassVar[Rule] = rfc9110.Rule("Content-Type")
|
|
39
|
+
visitor: ClassVar[ContentTypeVisitor] = ContentTypeVisitor()
|
|
40
|
+
|
|
41
|
+
media_type: MediaType
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def parse(cls, value: str) -> Self:
|
|
45
|
+
return cls(cls.visitor.visit(cls._node(value)))
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def of(
|
|
49
|
+
cls,
|
|
50
|
+
type: str,
|
|
51
|
+
subtype: str,
|
|
52
|
+
*,
|
|
53
|
+
charset: str | None = None,
|
|
54
|
+
boundary: str | None = None,
|
|
55
|
+
params: list[tuple[str, str]] | None = None,
|
|
56
|
+
) -> Self:
|
|
57
|
+
"""Build a Content-Type from its pieces. ``charset``/``boundary`` take precedence
|
|
58
|
+
over any same-named entry in ``params``."""
|
|
59
|
+
all_params: list[Parameter] = [
|
|
60
|
+
p
|
|
61
|
+
for p in (
|
|
62
|
+
Parameter("charset", charset) if charset else None,
|
|
63
|
+
Parameter("boundary", boundary) if boundary else None,
|
|
64
|
+
)
|
|
65
|
+
if p is not None
|
|
66
|
+
]
|
|
67
|
+
for name, val in params or []:
|
|
68
|
+
param = Parameter(name, val)
|
|
69
|
+
if (param.name == "charset" and charset) or (
|
|
70
|
+
param.name == "boundary" and boundary
|
|
71
|
+
):
|
|
72
|
+
continue
|
|
73
|
+
all_params.append(param)
|
|
74
|
+
return cls(MediaType(type, subtype, all_params))
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def type(self) -> str:
|
|
78
|
+
return self.media_type.type
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def subtype(self) -> str:
|
|
82
|
+
return self.media_type.subtype
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def params(self) -> list[Parameter]:
|
|
86
|
+
return list(self.media_type.params)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def charset(self) -> str | None:
|
|
90
|
+
for param in self.media_type.params:
|
|
91
|
+
if param.name == "charset":
|
|
92
|
+
return _unquote(param.value)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def boundary(self) -> str | None:
|
|
97
|
+
for param in self.media_type.params:
|
|
98
|
+
if param.name == "boundary":
|
|
99
|
+
return _unquote(param.value)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def value(self) -> str:
|
|
104
|
+
return str(self.media_type)
|
http_fields/cookie.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Cookie header class."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from abnf import Rule
|
|
7
|
+
from abnf.grammars import rfc6265
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from http_fields.header import Header
|
|
11
|
+
from http_fields.visitors.rfc6265 import CookiePair, CookieStringVisitor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Cookie(Header):
|
|
16
|
+
"""Cookie header, as defined by RFC 6265."""
|
|
17
|
+
|
|
18
|
+
name: ClassVar[str] = "Cookie"
|
|
19
|
+
rule: ClassVar[Rule] = rfc6265.Rule("cookie-string")
|
|
20
|
+
visitor: ClassVar[CookieStringVisitor] = CookieStringVisitor()
|
|
21
|
+
|
|
22
|
+
pairs: tuple[CookiePair, ...]
|
|
23
|
+
|
|
24
|
+
def __init__(self, *pairs: CookiePair | tuple[str, str]) -> None:
|
|
25
|
+
object.__setattr__(
|
|
26
|
+
self,
|
|
27
|
+
"pairs",
|
|
28
|
+
tuple(p if isinstance(p, CookiePair) else CookiePair(*p) for p in pairs),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def parse(cls, value: str) -> Self:
|
|
33
|
+
# cookies are often sent with an invalid trailing semicolon; strip it before parsing.
|
|
34
|
+
return cls(*cls.visitor.visit(cls._node(value.rstrip(";"))))
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def value(self) -> str:
|
|
38
|
+
return "; ".join(f"{pair.name}={pair.value}" for pair in self.pairs)
|
http_fields/date.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Date header class."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from abnf import Rule
|
|
6
|
+
from abnf.grammars import rfc9110
|
|
7
|
+
|
|
8
|
+
from http_fields.dateheader import DateHeader
|
|
9
|
+
from http_fields.visitors.rfc9110 import DateVisitor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Date(DateHeader):
|
|
13
|
+
"""Date header, as defined by RFC 9110."""
|
|
14
|
+
|
|
15
|
+
name: ClassVar[str] = "date"
|
|
16
|
+
rule: ClassVar[Rule] = rfc9110.Rule("Date")
|
|
17
|
+
visitor = DateVisitor()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Base class for headers whose value is a single HTTP-date."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from abnf import NodeVisitor
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from http_fields.header import Header
|
|
11
|
+
from http_fields.visitors.rfc9110 import imf_fixdate
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class DateHeader(Header):
|
|
16
|
+
"""Base for headers whose value is a single HTTP-date (RFC 9110 section 5.6.7).
|
|
17
|
+
|
|
18
|
+
Concrete subclasses supply ``name``, ``rule``, and ``visitor`` as ClassVars; the visitor
|
|
19
|
+
must return a :class:`datetime.datetime`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
visitor: ClassVar[NodeVisitor]
|
|
23
|
+
|
|
24
|
+
date: datetime
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def parse(cls, value: str) -> Self:
|
|
28
|
+
return cls(cls.visitor.visit(cls._node(value)))
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def value(self) -> str:
|
|
32
|
+
return imf_fixdate(self.date)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Base class for headers that are a list of entity-tags or ``*``."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from abnf import NodeVisitor
|
|
7
|
+
from typing_extensions import Self
|
|
8
|
+
|
|
9
|
+
from http_fields.header import Header
|
|
10
|
+
from http_fields.visitors.rfc9110 import EntityTag
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class EntityTagListHeader(Header):
|
|
15
|
+
"""Base for If-Match / If-None-Match: a list of entity-tags, or ``*`` (``wildcard``).
|
|
16
|
+
|
|
17
|
+
Concrete subclasses supply ``name``/``rule``/``visitor`` (the visitor returns either the
|
|
18
|
+
string ``"*"`` or a list of :class:`EntityTag`) and their own ``matches()`` semantics.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
visitor: ClassVar[NodeVisitor]
|
|
22
|
+
|
|
23
|
+
entity_tags: tuple[EntityTag, ...]
|
|
24
|
+
wildcard: bool
|
|
25
|
+
|
|
26
|
+
def __init__(self, *entity_tags: EntityTag, wildcard: bool = False) -> None:
|
|
27
|
+
object.__setattr__(self, "entity_tags", tuple(entity_tags))
|
|
28
|
+
object.__setattr__(self, "wildcard", wildcard)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def parse(cls, value: str) -> Self:
|
|
32
|
+
result = cls.visitor.visit(cls._node(value))
|
|
33
|
+
if result == "*":
|
|
34
|
+
return cls(wildcard=True)
|
|
35
|
+
return cls(*result)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def value(self) -> str:
|
|
39
|
+
return "*" if self.wildcard else ", ".join(str(t) for t in self.entity_tags)
|
http_fields/etag.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""ETag header class."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from abnf import Rule
|
|
7
|
+
from abnf.grammars import rfc9110
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from http_fields.header import Header
|
|
11
|
+
from http_fields.visitors.rfc9110 import EntityTag, ETagVisitor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ETag(Header):
|
|
16
|
+
"""ETag header, as defined by RFC 9110."""
|
|
17
|
+
|
|
18
|
+
name: ClassVar[str] = "ETag"
|
|
19
|
+
rule: ClassVar[Rule] = rfc9110.Rule("ETag")
|
|
20
|
+
visitor: ClassVar[ETagVisitor] = ETagVisitor()
|
|
21
|
+
|
|
22
|
+
entity_tag: EntityTag
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def parse(cls, value: str) -> Self:
|
|
26
|
+
return cls(cls.visitor.visit(cls._node(value)))
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_tag(cls, tag: str, weak: bool = False) -> Self:
|
|
30
|
+
"""Build an ETag from an opaque tag and optional weak flag."""
|
|
31
|
+
return cls(EntityTag(tag, weak=weak))
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def value(self) -> str:
|
|
35
|
+
return str(self.entity_tag)
|
|
36
|
+
|
|
37
|
+
def matches(self, entity_tag: EntityTag | None, weak: bool = False) -> bool:
|
|
38
|
+
"""Return True if this ETag matches ``entity_tag`` per RFC 9110 comparison.
|
|
39
|
+
|
|
40
|
+
Comparison against None is supported and returns False.
|
|
41
|
+
"""
|
|
42
|
+
return (
|
|
43
|
+
self.entity_tag.compare(entity_tag, weak=weak)
|
|
44
|
+
if isinstance(entity_tag, EntityTag)
|
|
45
|
+
else False
|
|
46
|
+
)
|
http_fields/expect.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Expect header class."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from abnf import Node, NodeVisitor, Rule
|
|
7
|
+
from abnf.grammars import rfc9110
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from http_fields.header import Header
|
|
11
|
+
from http_fields.parsedobjs import ParsedStr
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Expectation(ParsedStr):
|
|
15
|
+
"""An RFC 9110 expectation (e.g. ``100-continue``). Self-validating."""
|
|
16
|
+
|
|
17
|
+
parser = rfc9110.Rule("expectation")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExpectVisitor(NodeVisitor):
|
|
21
|
+
@staticmethod
|
|
22
|
+
def visit_expectation(node: Node) -> Expectation:
|
|
23
|
+
# The node is already parsed; skip re-validation.
|
|
24
|
+
return Expectation(node.value, parse=False)
|
|
25
|
+
|
|
26
|
+
def visit_expect(self, node: Node) -> list[Expectation]:
|
|
27
|
+
return [x for x in map(self.visit, node.children) if x is not None]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Expect(Header):
|
|
32
|
+
"""Expect header, as defined by RFC 9110 (in practice almost always ``100-continue``)."""
|
|
33
|
+
|
|
34
|
+
name: ClassVar[str] = "Expect"
|
|
35
|
+
rule: ClassVar[Rule] = rfc9110.Rule("Expect")
|
|
36
|
+
visitor: ClassVar[ExpectVisitor] = ExpectVisitor()
|
|
37
|
+
|
|
38
|
+
expectations: tuple[Expectation, ...]
|
|
39
|
+
|
|
40
|
+
def __init__(self, *expectations: Expectation) -> None:
|
|
41
|
+
# Store already-parsed Expectation values (each validated itself on creation).
|
|
42
|
+
# The base runtime check enforces the field type; build from a string with
|
|
43
|
+
# Expect.parse().
|
|
44
|
+
object.__setattr__(self, "expectations", tuple(expectations))
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def parse(cls, value: str) -> Self:
|
|
48
|
+
return cls(*cls.visitor.visit(cls._node(value)))
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def value(self) -> str:
|
|
52
|
+
return ", ".join(self.expectations)
|
http_fields/expires.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Expires header class."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from abnf import Rule
|
|
6
|
+
from abnf.grammars import rfc9111
|
|
7
|
+
|
|
8
|
+
from http_fields.dateheader import DateHeader
|
|
9
|
+
from http_fields.visitors.rfc9111 import ExpiresVisitor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Expires(DateHeader):
|
|
13
|
+
"""Expires header, as defined by RFC 9111."""
|
|
14
|
+
|
|
15
|
+
name: ClassVar[str] = "expires"
|
|
16
|
+
rule: ClassVar[Rule] = rfc9111.Rule("Expires")
|
|
17
|
+
visitor = ExpiresVisitor()
|
http_fields/forwarded.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Forwarded header class."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from abnf import Rule
|
|
7
|
+
from abnf.grammars import rfc7239
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from http_fields.header import Header
|
|
11
|
+
from http_fields.visitors.rfc7239 import ForwardedElement, ForwardedVisitor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Forwarded(Header):
|
|
16
|
+
"""Forwarded header, as defined by RFC 7239 (the standardized replacement for the de-facto
|
|
17
|
+
``X-Forwarded-*`` headers)."""
|
|
18
|
+
|
|
19
|
+
name: ClassVar[str] = "Forwarded"
|
|
20
|
+
rule: ClassVar[Rule] = rfc7239.Rule("Forwarded")
|
|
21
|
+
visitor: ClassVar[ForwardedVisitor] = ForwardedVisitor()
|
|
22
|
+
|
|
23
|
+
elements: tuple[ForwardedElement, ...]
|
|
24
|
+
|
|
25
|
+
def __init__(self, *elements: ForwardedElement) -> None:
|
|
26
|
+
# Each ForwardedElement self-validates (Param pairs), so the field-type check
|
|
27
|
+
# fully guarantees a safe serialized value -- no re-parse.
|
|
28
|
+
object.__setattr__(self, "elements", tuple(elements))
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def parse(cls, value: str) -> Self:
|
|
32
|
+
return cls(*cls.visitor.visit(cls._node(value)))
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def value(self) -> str:
|
|
36
|
+
return ", ".join(str(element) for element in self.elements)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""From header class."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from abnf import Rule
|
|
7
|
+
from abnf.grammars import rfc9110
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from http_fields.header import Header
|
|
11
|
+
from http_fields.parsedobjs import ParsedStr
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Mailbox(ParsedStr):
|
|
15
|
+
"""A From-header mailbox (RFC 9110 / RFC 5322 addr-spec). Self-validating."""
|
|
16
|
+
|
|
17
|
+
parser = rfc9110.Rule("From")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class From(Header):
|
|
22
|
+
"""From header, as defined by RFC 9110: the mailbox of the user controlling the client.
|
|
23
|
+
|
|
24
|
+
The mailbox is validated against RFC 9110 (RFC 5322 addr-spec) on construction.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: ClassVar[str] = "From"
|
|
28
|
+
rule: ClassVar[Rule] = rfc9110.Rule("From")
|
|
29
|
+
|
|
30
|
+
mailbox: Mailbox
|
|
31
|
+
|
|
32
|
+
def __init__(self, mailbox: str) -> None:
|
|
33
|
+
# The mailbox self-validates as a leaf; build from a string via From(...).
|
|
34
|
+
object.__setattr__(self, "mailbox", Mailbox(mailbox))
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def parse(cls, value: str) -> Self:
|
|
38
|
+
return cls(value)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def value(self) -> str:
|
|
42
|
+
return self.mailbox
|
http_fields/header.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""HTTP header abstract base class and the generic custom-header class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import inspect
|
|
7
|
+
import types
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from functools import cached_property, wraps
|
|
12
|
+
from typing import Any, ClassVar, Union, get_args, get_origin
|
|
13
|
+
|
|
14
|
+
from abnf import Node, ParseError, Rule
|
|
15
|
+
from abnf.grammars import rfc9110
|
|
16
|
+
|
|
17
|
+
from http_fields.visitors.rfc9110 import FieldName, FieldValue
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _matches_type(value: object, hint: object) -> bool:
|
|
21
|
+
"""A small, deterministic runtime type check covering the field-annotation shapes
|
|
22
|
+
headers actually use: a concrete type, ``tuple[X, ...]``/``tuple[X, Y]``, unions
|
|
23
|
+
(``X | Y``, ``Optional``), and ``None``. Every element is checked (no sampling).
|
|
24
|
+
Unrecognised shapes are not blocked."""
|
|
25
|
+
origin = get_origin(hint)
|
|
26
|
+
if origin is tuple:
|
|
27
|
+
args = get_args(hint)
|
|
28
|
+
if not isinstance(value, tuple):
|
|
29
|
+
return False
|
|
30
|
+
if len(args) == 2 and args[1] is Ellipsis: # tuple[X, ...]
|
|
31
|
+
return all(_matches_type(v, args[0]) for v in value)
|
|
32
|
+
return len(value) == len(args) and all(
|
|
33
|
+
_matches_type(v, a) for v, a in zip(value, args, strict=True)
|
|
34
|
+
)
|
|
35
|
+
if origin is Union or origin is types.UnionType:
|
|
36
|
+
return any(_matches_type(value, a) for a in get_args(hint))
|
|
37
|
+
if hint is type(None):
|
|
38
|
+
return value is None
|
|
39
|
+
if isinstance(hint, type):
|
|
40
|
+
return isinstance(value, hint)
|
|
41
|
+
return True # unrecognised annotation shape: don't block
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Header(ABC):
|
|
45
|
+
"""Abstract base for HTTP headers.
|
|
46
|
+
|
|
47
|
+
Known headers are frozen dataclasses whose fields are the structured components of the
|
|
48
|
+
header value. Each sets ``name`` (and usually ``rule``/``visitor``) as a ClassVar,
|
|
49
|
+
implements a ``parse(value)`` classmethod, and exposes a derived ``value`` property.
|
|
50
|
+
Arbitrary headers (e.g. ``X-Request-Id``) are represented by :class:`CustomHeader`.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
encoding: ClassVar[str] = "ISO-8859-1"
|
|
54
|
+
rule: ClassVar[Rule] # abnf rule for the header value; provided by subclasses
|
|
55
|
+
name: ClassVar[str] # field name; a class constant on known headers,
|
|
56
|
+
# overridden by a per-instance property on CustomHeader.
|
|
57
|
+
# Maximum accepted length of a value passed to parse(). Parsing is super-linear
|
|
58
|
+
# in the length for some grammars, so an oversized value is a CPU-exhaustion
|
|
59
|
+
# vector; reject it up front. Override per subclass where larger values are
|
|
60
|
+
# legitimate (e.g. cookie-heavy deployments).
|
|
61
|
+
max_length: ClassVar[int] = 8192
|
|
62
|
+
# Per-class cache of resolved field-annotation hints (populated on first use).
|
|
63
|
+
_field_hints_cache: ClassVar[dict[str, object] | None] = None
|
|
64
|
+
|
|
65
|
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
|
66
|
+
super().__init_subclass__(**kwargs)
|
|
67
|
+
# Headers (and every value-object they hold) are immutable, so the serialized
|
|
68
|
+
# value is stable: cache it per instance with functools.cached_property, so it
|
|
69
|
+
# is computed at most once (str/bytes/asgi_value/__eq__/__hash__ and direct
|
|
70
|
+
# access all share it). We swap the descriptor in here, at class creation,
|
|
71
|
+
# rather than decorating each subclass's `value` -- a cached_property directly
|
|
72
|
+
# overriding the abstract `value` property trips reportIncompatibleMethodOverride,
|
|
73
|
+
# so subclasses keep a plain @property. cached_property writes to the instance
|
|
74
|
+
# __dict__, bypassing the frozen __setattr__. Only the class that *defines*
|
|
75
|
+
# value is wrapped; subclasses inherit the cached descriptor.
|
|
76
|
+
prop = cls.__dict__.get("value")
|
|
77
|
+
if isinstance(prop, property) and prop.fget is not None:
|
|
78
|
+
cached = cached_property(prop.fget)
|
|
79
|
+
cached.__set_name__(cls, "value")
|
|
80
|
+
cls.value = cached # type: ignore[assignment]
|
|
81
|
+
|
|
82
|
+
# Wrap a custom __init__ so field types are checked after construction. The
|
|
83
|
+
# type checker still reads the source __init__ signature for argument
|
|
84
|
+
# checking; this only adds the runtime guard. (A dataclass-generated __init__
|
|
85
|
+
# isn't present yet at __init_subclass__ time and is not wrapped.)
|
|
86
|
+
user_init = cls.__dict__.get("__init__")
|
|
87
|
+
if callable(user_init):
|
|
88
|
+
|
|
89
|
+
@wraps(user_init)
|
|
90
|
+
def checked_init(
|
|
91
|
+
self: Header,
|
|
92
|
+
*args: object,
|
|
93
|
+
_init: Callable[..., object] = user_init,
|
|
94
|
+
**kwargs: object,
|
|
95
|
+
) -> None:
|
|
96
|
+
_init(self, *args, **kwargs)
|
|
97
|
+
self._check_field_types()
|
|
98
|
+
|
|
99
|
+
cls.__init__ = checked_init # type: ignore[assignment]
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def value(self) -> str:
|
|
104
|
+
"""The serialized header field value (without the name)."""
|
|
105
|
+
|
|
106
|
+
def __str__(self) -> str:
|
|
107
|
+
"""Return the full ``Name: value`` header field."""
|
|
108
|
+
return f"{self.name}: {self.value}"
|
|
109
|
+
|
|
110
|
+
def __bytes__(self) -> bytes:
|
|
111
|
+
return str(self).encode(self.encoding)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def asgi_value(self) -> tuple[bytes, bytes]:
|
|
115
|
+
"""Return a ``(name, value)`` pair of latin-1 bytes for an ASGI send dict.
|
|
116
|
+
|
|
117
|
+
ASGI encodes header values as ISO-8859-1 (latin-1); using the class
|
|
118
|
+
encoding keeps this consistent with ``__bytes__`` and accepts obs-text.
|
|
119
|
+
"""
|
|
120
|
+
return (self.name.encode(self.encoding), self.value.encode(self.encoding))
|
|
121
|
+
|
|
122
|
+
def __eq__(self, other: object, /) -> bool:
|
|
123
|
+
if isinstance(other, Header) and type(other) is type(self):
|
|
124
|
+
return (self.name, self.value) == (other.name, other.value)
|
|
125
|
+
return NotImplemented
|
|
126
|
+
|
|
127
|
+
def __hash__(self) -> int:
|
|
128
|
+
return hash((self.name, self.value))
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def _field_hints(cls) -> dict[str, object]:
|
|
132
|
+
"""Resolved ``{field_name: type_hint}`` for this header's dataclass fields,
|
|
133
|
+
cached per class. Annotations are resolved per class across the MRO (so an
|
|
134
|
+
unresolvable annotation on one class doesn't disable the check for others)."""
|
|
135
|
+
cached = cls.__dict__.get("_field_hints_cache")
|
|
136
|
+
if cached is None:
|
|
137
|
+
merged: dict[str, Any] = {}
|
|
138
|
+
for klass in reversed(cls.__mro__): # base -> derived; derived wins
|
|
139
|
+
try:
|
|
140
|
+
merged.update(inspect.get_annotations(klass, eval_str=True))
|
|
141
|
+
except Exception:
|
|
142
|
+
continue
|
|
143
|
+
cached = {
|
|
144
|
+
f.name: merged[f.name]
|
|
145
|
+
for f in dataclasses.fields(cls) # type: ignore[arg-type]
|
|
146
|
+
if f.name in merged
|
|
147
|
+
}
|
|
148
|
+
cls._field_hints_cache = cached
|
|
149
|
+
return cached
|
|
150
|
+
|
|
151
|
+
def __post_init__(self) -> None:
|
|
152
|
+
# Field-type check for headers with a dataclass-generated __init__ (which
|
|
153
|
+
# calls __post_init__). Custom __init__s are wrapped in __init_subclass__
|
|
154
|
+
# instead. A subclass overriding __post_init__ should call super() to keep
|
|
155
|
+
# this guard (or it must validate its fields another way).
|
|
156
|
+
self._check_field_types()
|
|
157
|
+
|
|
158
|
+
def _check_field_types(self) -> None:
|
|
159
|
+
"""Runtime enforcement of the construction contract: every field must match
|
|
160
|
+
its declared type. Because each field type validates itself on creation, a
|
|
161
|
+
type check is a complete guarantee -- no grammar re-parse. Raises TypeError
|
|
162
|
+
(pointing to :meth:`parse`) on a mismatch."""
|
|
163
|
+
for name, hint in type(self)._field_hints().items():
|
|
164
|
+
value = getattr(self, name)
|
|
165
|
+
if not _matches_type(value, hint):
|
|
166
|
+
cls_name = type(self).__name__
|
|
167
|
+
shown = repr(value)
|
|
168
|
+
if len(shown) > 60:
|
|
169
|
+
shown = shown[:57] + "..."
|
|
170
|
+
raise TypeError(
|
|
171
|
+
f"{cls_name}.{name} expects {hint!r}; got {shown}. Use "
|
|
172
|
+
f"{cls_name}.parse() to build a {cls_name} from a string."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def _check_length(cls, value: str) -> None:
|
|
177
|
+
"""Reject an oversized parse() input before the (super-linear) grammar runs."""
|
|
178
|
+
if len(value) > cls.max_length:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"{cls.__name__} value of {len(value)} chars exceeds the maximum "
|
|
181
|
+
f"of {cls.max_length}."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def _node(cls, value: str) -> Node:
|
|
186
|
+
"""Parse ``value`` against ``cls.rule``, translating a ParseError to a ValueError."""
|
|
187
|
+
cls._check_length(value)
|
|
188
|
+
try:
|
|
189
|
+
return cls.rule.parse_all(value)
|
|
190
|
+
except ParseError as exc:
|
|
191
|
+
raise ValueError(f'Invalid {cls.__name__} value "{value}".') from exc
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def _prefixed_node(cls, value: str) -> Node:
|
|
195
|
+
"""Like :meth:`_node`, but for grammar rules that match the whole header line (name
|
|
196
|
+
included); prepend ``"<name>: "`` before parsing."""
|
|
197
|
+
cls._check_length(value)
|
|
198
|
+
try:
|
|
199
|
+
return cls.rule.parse_all(f"{cls.name}: {value}")
|
|
200
|
+
except ParseError as exc:
|
|
201
|
+
raise ValueError(f'Invalid {cls.__name__} value "{value}".') from exc
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def parse(cls, value: str) -> Header:
|
|
205
|
+
"""Parse a header value into an instance. Every concrete known header overrides this
|
|
206
|
+
(CustomHeader carries an arbitrary name and is built via its constructor instead)."""
|
|
207
|
+
raise NotImplementedError
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def create(cls, name: str, value: str) -> Header:
|
|
211
|
+
"""Return a header for ``(name, value)``: a matching known subclass if one is
|
|
212
|
+
registered for ``name``, otherwise a :class:`CustomHeader`."""
|
|
213
|
+
lname = name.lower()
|
|
214
|
+
for subcls in cls.subclass_tree():
|
|
215
|
+
cls_name = getattr(subcls, "name", None)
|
|
216
|
+
if isinstance(cls_name, str) and cls_name.lower() == lname:
|
|
217
|
+
return subcls.parse(value)
|
|
218
|
+
return CustomHeader(name, value)
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def subclass_tree(cls) -> Iterable[type[Header]]:
|
|
222
|
+
"""Yield all Header subclasses, depth-first."""
|
|
223
|
+
for subcls in cls.__subclasses__():
|
|
224
|
+
yield subcls
|
|
225
|
+
yield from subcls.subclass_tree()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True)
|
|
229
|
+
class CustomHeader(Header):
|
|
230
|
+
"""A header with an arbitrary field name, e.g. ``X-Request-Id``.
|
|
231
|
+
|
|
232
|
+
Both name and value are validated against RFC 9110 (``field-name`` / ``field-value``).
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
rule: ClassVar[Rule] = rfc9110.Rule("field-value")
|
|
236
|
+
|
|
237
|
+
field_name: FieldName
|
|
238
|
+
field_value: FieldValue
|
|
239
|
+
|
|
240
|
+
def __init__(self, name: str, value: str) -> None:
|
|
241
|
+
object.__setattr__(self, "field_name", FieldName(name))
|
|
242
|
+
object.__setattr__(self, "field_value", FieldValue(value))
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def name(self) -> str: # pyright: ignore[reportIncompatibleVariableOverride]
|
|
246
|
+
return self.field_name
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def value(self) -> str:
|
|
250
|
+
return self.field_value
|