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,77 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from abnf import Node, NodeVisitor
|
|
4
|
+
|
|
5
|
+
import http_fields.visitors.rfc9110.authparam as authparam
|
|
6
|
+
import http_fields.visitors.rfc9110.token68 as token68
|
|
7
|
+
from http_fields.visitors.rfc9110.authscheme import AuthScheme
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class AuthParamCredentials:
|
|
12
|
+
scheme: AuthScheme
|
|
13
|
+
auth_params: tuple[authparam.AuthParam, ...]
|
|
14
|
+
|
|
15
|
+
def __init__(self, scheme: str, auth_params: list[authparam.AuthParam]):
|
|
16
|
+
object.__setattr__(self, "scheme", AuthScheme(scheme))
|
|
17
|
+
object.__setattr__(self, "auth_params", tuple(auth_params))
|
|
18
|
+
|
|
19
|
+
def __str__(self):
|
|
20
|
+
params = ",".join([str(param) for param in self.auth_params])
|
|
21
|
+
return f"{self.scheme}{' ' + params if params else ''}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class TokenCredentials:
|
|
26
|
+
scheme: AuthScheme
|
|
27
|
+
token: token68.Token68 | None
|
|
28
|
+
|
|
29
|
+
def __init__(self, scheme: str, token: str | None):
|
|
30
|
+
object.__setattr__(self, "scheme", AuthScheme(scheme))
|
|
31
|
+
object.__setattr__(self, "token", token68.Token68(token) if token else None)
|
|
32
|
+
|
|
33
|
+
def __str__(self):
|
|
34
|
+
return f"{self.scheme}{' ' + self.token if self.token else ''}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CredentialsVisitor(NodeVisitor):
|
|
38
|
+
visit_token68 = token68.Token68Visitor()
|
|
39
|
+
visit_auth_param = authparam.AuthParamVisitor()
|
|
40
|
+
|
|
41
|
+
def visit_auth_scheme(self, node: Node):
|
|
42
|
+
return AuthScheme(node.value)
|
|
43
|
+
|
|
44
|
+
def visit_credentials(self, node: Node):
|
|
45
|
+
items = filter(None, map(self.visit, node.children))
|
|
46
|
+
auth_scheme = next(items)
|
|
47
|
+
assert isinstance(auth_scheme, AuthScheme)
|
|
48
|
+
try:
|
|
49
|
+
next_item = next(items)
|
|
50
|
+
except StopIteration:
|
|
51
|
+
return TokenCredentials(scheme=auth_scheme, token=None)
|
|
52
|
+
else:
|
|
53
|
+
if isinstance(next_item, token68.Token68):
|
|
54
|
+
return TokenCredentials(scheme=auth_scheme, token=next_item)
|
|
55
|
+
elif isinstance(next_item, authparam.AuthParam):
|
|
56
|
+
auth_params = [next_item] + list(items)
|
|
57
|
+
return AuthParamCredentials(scheme=auth_scheme, auth_params=auth_params)
|
|
58
|
+
else: # pragma: no cover
|
|
59
|
+
raise AssertionError()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AuthorizationVisitor(NodeVisitor):
|
|
63
|
+
"""NodeVisitor subclass for authorization header value."""
|
|
64
|
+
|
|
65
|
+
visit_credentials = CredentialsVisitor()
|
|
66
|
+
|
|
67
|
+
def visit_authorization(self, node: Node):
|
|
68
|
+
return next(filter(None, map(self.visit, node.children)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ProxyAuthorizationVisitor(NodeVisitor):
|
|
72
|
+
"""NodeVisitor subclass for the Proxy-Authorization header value."""
|
|
73
|
+
|
|
74
|
+
visit_credentials = CredentialsVisitor()
|
|
75
|
+
|
|
76
|
+
def visit_proxy_authorization(self, node: Node):
|
|
77
|
+
return next(filter(None, map(self.visit, node.children)))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from abnf import Node, NodeVisitor
|
|
4
|
+
|
|
5
|
+
import http_fields.visitors.rfc9110.quotedstring as quotedstring
|
|
6
|
+
import http_fields.visitors.rfc9110.token as token
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class AuthParam:
|
|
11
|
+
name: token.Token
|
|
12
|
+
value: token.Token | quotedstring.QuotedString
|
|
13
|
+
|
|
14
|
+
def __init__(self, name: str, value: str):
|
|
15
|
+
object.__setattr__(self, "name", token.Token(name))
|
|
16
|
+
|
|
17
|
+
if name.lower() == "realm":
|
|
18
|
+
# realm value should be quoted. See https://tools.ietf.org/html/rfc7235#section-2.2.
|
|
19
|
+
object.__setattr__(self, "value", quotedstring.QuotedString(value))
|
|
20
|
+
else:
|
|
21
|
+
try:
|
|
22
|
+
object.__setattr__(self, "value", token.Token(value))
|
|
23
|
+
except ValueError:
|
|
24
|
+
object.__setattr__(self, "value", quotedstring.QuotedString(value))
|
|
25
|
+
|
|
26
|
+
def __str__(self):
|
|
27
|
+
return f"{self.name}={self.value}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AuthParamVisitor(NodeVisitor):
|
|
31
|
+
visit_token = token.TokenVisitor()
|
|
32
|
+
visit_quoted_string = quotedstring.QuotedStringVisitor()
|
|
33
|
+
|
|
34
|
+
def visit_auth_param(self, node: Node):
|
|
35
|
+
name: token.Token
|
|
36
|
+
value: token.Token | quotedstring.QuotedString
|
|
37
|
+
name, value = filter(None, map(self.visit, node.children))
|
|
38
|
+
assert isinstance(name, token.Token)
|
|
39
|
+
assert isinstance(value, token.Token | quotedstring.QuotedString)
|
|
40
|
+
return AuthParam(name=name, value=value)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from abnf import Node, NodeVisitor
|
|
2
|
+
from abnf.grammars import rfc9110
|
|
3
|
+
|
|
4
|
+
from http_fields.parsedobjs import CaselessMixin, ParsedStr
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AuthScheme(CaselessMixin, ParsedStr):
|
|
8
|
+
"""Represents an RFC 9110 auth-scheme. Schemes are case-insensitive
|
|
9
|
+
(RFC 9110 section 11.1), so two schemes differing only in case compare equal;
|
|
10
|
+
the original casing is preserved on serialization."""
|
|
11
|
+
|
|
12
|
+
parser = rfc9110.Rule("auth-scheme")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthSchemeVisitor(NodeVisitor):
|
|
16
|
+
def visit_auth_scheme(self, node: Node):
|
|
17
|
+
return AuthScheme(node.value)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from abnf import Node, NodeVisitor
|
|
4
|
+
|
|
5
|
+
import http_fields.visitors.rfc9110.authparam as authparam
|
|
6
|
+
import http_fields.visitors.rfc9110.authscheme as authscheme
|
|
7
|
+
import http_fields.visitors.rfc9110.token68 as token68
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class TokenChallenge:
|
|
12
|
+
scheme: authscheme.AuthScheme
|
|
13
|
+
token: token68.Token68 | None
|
|
14
|
+
|
|
15
|
+
def __init__(self, scheme: str, token: str | None):
|
|
16
|
+
object.__setattr__(self, "scheme", authscheme.AuthScheme(scheme))
|
|
17
|
+
object.__setattr__(self, "token", token68.Token68(token) if token else None)
|
|
18
|
+
|
|
19
|
+
def __str__(self):
|
|
20
|
+
return f"{self.scheme}{' ' + self.token if self.token else ''}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AuthParamChallenge:
|
|
25
|
+
scheme: authscheme.AuthScheme
|
|
26
|
+
auth_params: tuple[authparam.AuthParam, ...]
|
|
27
|
+
|
|
28
|
+
def __init__(self, scheme: str, auth_params: list[authparam.AuthParam]):
|
|
29
|
+
object.__setattr__(self, "scheme", authscheme.AuthScheme(scheme))
|
|
30
|
+
object.__setattr__(self, "auth_params", tuple(auth_params))
|
|
31
|
+
|
|
32
|
+
def __str__(self):
|
|
33
|
+
params = ",".join([str(param) for param in self.auth_params])
|
|
34
|
+
return f"{self.scheme}{' ' + params if params else ''}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ChallengeVisitor(NodeVisitor):
|
|
38
|
+
visit_token68 = token68.Token68Visitor()
|
|
39
|
+
visit_auth_param = authparam.AuthParamVisitor()
|
|
40
|
+
visit_auth_scheme = authscheme.AuthSchemeVisitor()
|
|
41
|
+
|
|
42
|
+
def visit_challenge(self, node: Node):
|
|
43
|
+
items = filter(None, map(self.visit, node.children))
|
|
44
|
+
auth_scheme = next(items)
|
|
45
|
+
assert isinstance(auth_scheme, authscheme.AuthScheme)
|
|
46
|
+
try:
|
|
47
|
+
next_item = next(items)
|
|
48
|
+
except StopIteration:
|
|
49
|
+
return TokenChallenge(scheme=auth_scheme, token=None)
|
|
50
|
+
else:
|
|
51
|
+
if isinstance(next_item, token68.Token68):
|
|
52
|
+
return TokenChallenge(scheme=auth_scheme, token=next_item)
|
|
53
|
+
elif isinstance(next_item, authparam.AuthParam):
|
|
54
|
+
auth_params = [next_item] + list(items)
|
|
55
|
+
return AuthParamChallenge(scheme=auth_scheme, auth_params=auth_params)
|
|
56
|
+
else: # pragma: no cover
|
|
57
|
+
raise AssertionError()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from abnf import Node, NodeVisitor, ParseError
|
|
6
|
+
from abnf.grammars import rfc9110
|
|
7
|
+
|
|
8
|
+
import http_fields.visitors.rfc9110.quotedstring as quotedstring
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _escape_ctext(text: str) -> str:
|
|
12
|
+
# ctext excludes "(", ")" and "\"; these appear in parsed text only because a
|
|
13
|
+
# quoted-pair was unescaped, so re-escape them to keep the comment parseable.
|
|
14
|
+
return "".join("\\" + c if c in "()\\" else c for c in text)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Comment:
|
|
19
|
+
"""An RFC 9110 comment: a parenthesised sequence of text runs and nested comments.
|
|
20
|
+
|
|
21
|
+
Frozen so it stays immutable (and stably hashable) while embedded in a frozen
|
|
22
|
+
User-Agent/Server/Via header. ``__eq__``/``__hash__``/``__repr__`` are generated;
|
|
23
|
+
``__str__`` re-escapes text runs so the serialized comment round-trips.
|
|
24
|
+
|
|
25
|
+
Each string argument is parsed as comment *content* (``*( ctext / quoted-pair /
|
|
26
|
+
comment )``): embedded ``(...)`` become nested Comment objects, ``\\)`` is a
|
|
27
|
+
quoted-pair, and invalid content (CR/LF/NUL or an unescaped/unbalanced parenthesis)
|
|
28
|
+
raises ``ValueError``. So ``Comment(s)`` accepts exactly what the parser produces.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
items: tuple[str | Comment, ...] = ()
|
|
32
|
+
|
|
33
|
+
def __init__(self, *items: str | Comment) -> None:
|
|
34
|
+
parsed: list[str | Comment] = []
|
|
35
|
+
for item in items:
|
|
36
|
+
if isinstance(item, Comment):
|
|
37
|
+
parsed.append(item)
|
|
38
|
+
else:
|
|
39
|
+
parsed.extend(_parse_content(item))
|
|
40
|
+
object.__setattr__(self, "items", tuple(parsed))
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def _from_items(cls, items: tuple[str | Comment, ...]) -> Comment:
|
|
44
|
+
# Build from already-structured, already-unescaped items (the visitor's
|
|
45
|
+
# output), bypassing the string-parsing done by __init__.
|
|
46
|
+
obj = object.__new__(cls)
|
|
47
|
+
object.__setattr__(obj, "items", items)
|
|
48
|
+
return obj
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
return (
|
|
52
|
+
"("
|
|
53
|
+
+ "".join(
|
|
54
|
+
str(item) if isinstance(item, Comment) else _escape_ctext(str(item))
|
|
55
|
+
for item in self.items
|
|
56
|
+
)
|
|
57
|
+
+ ")"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_content(content: str) -> tuple[str | Comment, ...]:
|
|
62
|
+
"""Parse a comment-content string into its (text-run / nested-Comment) items."""
|
|
63
|
+
try:
|
|
64
|
+
node = rfc9110.Rule("comment").parse_all(f"({content})")
|
|
65
|
+
except ParseError as exc:
|
|
66
|
+
raise ValueError(f"Invalid comment content: {content!r}.") from exc
|
|
67
|
+
return _CONTENT_VISITOR.visit(node).items
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CommentVisitor(NodeVisitor):
|
|
71
|
+
visit_quoted_pair = quotedstring.QuotedPairVisitor()
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def visit_ctext(node: Node):
|
|
75
|
+
return node.value
|
|
76
|
+
|
|
77
|
+
def visit_comment(self, node: Node):
|
|
78
|
+
items = filter(None, map(self.visit, node.children))
|
|
79
|
+
comment_items: list[str | Comment] = []
|
|
80
|
+
|
|
81
|
+
text: list[str] = []
|
|
82
|
+
for item in items:
|
|
83
|
+
if isinstance(item, str):
|
|
84
|
+
text.append(item)
|
|
85
|
+
elif isinstance(item, Comment):
|
|
86
|
+
comment_items.append("".join(text))
|
|
87
|
+
text = []
|
|
88
|
+
comment_items.append(item)
|
|
89
|
+
else: # pragma: no cover
|
|
90
|
+
raise AssertionError()
|
|
91
|
+
if text:
|
|
92
|
+
comment_items.append("".join(text))
|
|
93
|
+
# Build directly from the (already-unescaped) items; passing them back
|
|
94
|
+
# through Comment.__init__ would try to re-parse them as content. This is the
|
|
95
|
+
# module's own internal factory (same file), not cross-module private access.
|
|
96
|
+
return Comment._from_items(tuple(comment_items)) # pyright: ignore[reportPrivateUsage]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_CONTENT_VISITOR = CommentVisitor()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from abnf import Node, NodeVisitor
|
|
2
|
+
|
|
3
|
+
from http_fields.visitors.rfc9110.token import Token, TokenVisitor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConnectionVisitor(NodeVisitor):
|
|
7
|
+
visit_token = TokenVisitor()
|
|
8
|
+
|
|
9
|
+
def visit_connection_option(self, node: Node) -> list[Token]:
|
|
10
|
+
return next(filter(None, map(self.visit, node.children)))
|
|
11
|
+
|
|
12
|
+
def visit_connection(self, node: Node) -> list[Token]:
|
|
13
|
+
return list(filter(None, map(self.visit, node.children)))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abnf import Node, NodeVisitor
|
|
2
|
+
from abnf.grammars import rfc9110
|
|
3
|
+
|
|
4
|
+
from http_fields.parsedobjs import CaselessMixin, ParsedStr
|
|
5
|
+
|
|
6
|
+
__all__ = ["ContentLanguageVisitor", "LanguageTag"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LanguageTag(CaselessMixin, ParsedStr):
|
|
10
|
+
"""Represents an RFC 9110 language-tag (BCP 47), matched case-insensitively."""
|
|
11
|
+
|
|
12
|
+
parser = rfc9110.Rule("language-tag")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ContentLanguageVisitor(NodeVisitor):
|
|
16
|
+
@staticmethod
|
|
17
|
+
def visit_language_tag(node: Node) -> str:
|
|
18
|
+
return LanguageTag(node.value, parse=False)
|
|
19
|
+
|
|
20
|
+
def visit_content_language(self, node: Node) -> list[str]:
|
|
21
|
+
return [x for x in map(self.visit, node.children) if x is not None]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from abnf import Node, NodeVisitor
|
|
5
|
+
|
|
6
|
+
from http_fields.parsedobjs import NonNegativeInt
|
|
7
|
+
from http_fields.visitors.rfc9110.rangeunit import RangeUnit, RangeUnitVisitor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class RangeResp:
|
|
12
|
+
first_pos: NonNegativeInt
|
|
13
|
+
last_pos: NonNegativeInt
|
|
14
|
+
complete_length: NonNegativeInt | None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class UnsatisfiedRange:
|
|
19
|
+
complete_length: NonNegativeInt | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ContentRangeVisitor(NodeVisitor):
|
|
23
|
+
visit_range_unit = RangeUnitVisitor()
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def visit_literal(node: Node):
|
|
27
|
+
return "*" if node.value == "*" else None
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def visit_complete_length(node: Node):
|
|
31
|
+
return NonNegativeInt(node.value)
|
|
32
|
+
|
|
33
|
+
def visit_unsatisfied_range(self, node: Node) -> UnsatisfiedRange:
|
|
34
|
+
item = next(x for x in map(self.visit, node.children) if x is not None)
|
|
35
|
+
return UnsatisfiedRange(item if isinstance(item, NonNegativeInt) else None)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def visit_first_pos(node: Node):
|
|
39
|
+
return NonNegativeInt(node.value)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def visit_last_pos(node: Node):
|
|
43
|
+
return NonNegativeInt(node.value)
|
|
44
|
+
|
|
45
|
+
def visit_incl_range(self, node: Node):
|
|
46
|
+
first_pos: NonNegativeInt
|
|
47
|
+
last_pos: NonNegativeInt
|
|
48
|
+
first_pos, last_pos = (
|
|
49
|
+
x for x in map(self.visit, node.children) if x is not None
|
|
50
|
+
)
|
|
51
|
+
return (first_pos, last_pos)
|
|
52
|
+
|
|
53
|
+
def visit_range_resp(self, node: Node):
|
|
54
|
+
incl_range: tuple[NonNegativeInt, NonNegativeInt]
|
|
55
|
+
complete_length: NonNegativeInt | Literal["*"]
|
|
56
|
+
incl_range, complete_length = (
|
|
57
|
+
x for x in map(self.visit, node.children) if x is not None
|
|
58
|
+
)
|
|
59
|
+
return RangeResp(
|
|
60
|
+
*incl_range,
|
|
61
|
+
complete_length=complete_length
|
|
62
|
+
if isinstance(complete_length, NonNegativeInt)
|
|
63
|
+
else None,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def visit_content_range(self, node: Node):
|
|
67
|
+
range_unit: RangeUnit
|
|
68
|
+
range: RangeResp | UnsatisfiedRange
|
|
69
|
+
range_unit, range = (x for x in map(self.visit, node.children) if x is not None)
|
|
70
|
+
return range_unit, range
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from abnf import Node, NodeVisitor
|
|
4
|
+
|
|
5
|
+
import http_fields.visitors.rfc9110.parameters as parameters
|
|
6
|
+
import http_fields.visitors.rfc9110.token as token
|
|
7
|
+
|
|
8
|
+
# NB: do not alias the "type" grammar module as `type` -- it would shadow the
|
|
9
|
+
# builtin, which MediaType's generated (frozen) __setattr__ (`type(self)`) needs.
|
|
10
|
+
import http_fields.visitors.rfc9110.type as type_grammar
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class MediaType:
|
|
15
|
+
type: token.Token
|
|
16
|
+
subtype: token.Token
|
|
17
|
+
params: tuple[parameters.Parameter, ...]
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
type: str,
|
|
22
|
+
subtype: str,
|
|
23
|
+
params: "list[parameters.Parameter] | tuple[parameters.Parameter, ...] | None" = None,
|
|
24
|
+
):
|
|
25
|
+
object.__setattr__(self, "type", token.Token(type))
|
|
26
|
+
object.__setattr__(self, "subtype", token.Token(subtype))
|
|
27
|
+
object.__setattr__(self, "params", tuple(params) if params else ())
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return f"{self.type}/{self.subtype}" + (
|
|
31
|
+
(";" + ";".join(str(p) for p in self.params)) if self.params else ""
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MediaTypeVisitor(NodeVisitor):
|
|
36
|
+
visit_type = type_grammar.TypeVisitor()
|
|
37
|
+
visit_subtype = type_grammar.SubtypeVisitor()
|
|
38
|
+
visit_parameters = parameters.ParametersVisitor()
|
|
39
|
+
|
|
40
|
+
def visit_media_type(self, node: Node) -> MediaType:
|
|
41
|
+
items = filter(None, map(self.visit, node.children))
|
|
42
|
+
type = next(items)
|
|
43
|
+
subtype = next(items)
|
|
44
|
+
params: list[parameters.Parameter] = next(items, [])
|
|
45
|
+
return MediaType(type, subtype, params)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ContentTypeVisitor(NodeVisitor):
|
|
49
|
+
visit_media_type = MediaTypeVisitor()
|
|
50
|
+
|
|
51
|
+
def visit_content_type(self, node: Node):
|
|
52
|
+
return next(filter(None, map(self.visit, node.children)))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from abnf import Node, NodeVisitor
|
|
4
|
+
|
|
5
|
+
import http_fields.visitors.rfc9110.httpdate as httpdate
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DateVisitor(NodeVisitor):
|
|
9
|
+
visit_http_date = httpdate.HttpDateVisitor()
|
|
10
|
+
|
|
11
|
+
def visit_date(self, node: Node) -> datetime:
|
|
12
|
+
return next(filter(None, map(self.visit, node.children)))
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from abnf import Node, NodeVisitor, ParseError
|
|
4
|
+
from abnf.grammars import rfc9110
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class EntityTag:
|
|
9
|
+
"""An RFC 9110 entity-tag. ``__eq__``/``__hash__`` are exact (weak flag + opaque
|
|
10
|
+
tag); use :meth:`compare` for the RFC's weak/strong comparison semantics."""
|
|
11
|
+
|
|
12
|
+
opaque_tag: str
|
|
13
|
+
weak: bool = False
|
|
14
|
+
|
|
15
|
+
def __init__(self, tag: str, weak: bool = False) -> None:
|
|
16
|
+
# Double-quote the tag if it is not already, then validate.
|
|
17
|
+
opaque_tag = (
|
|
18
|
+
f'"{tag}"' if not tag or (tag[0] != '"' and tag[-1] != '"') else tag
|
|
19
|
+
)
|
|
20
|
+
entity_tag = f"W/{opaque_tag}" if weak else opaque_tag
|
|
21
|
+
try:
|
|
22
|
+
rfc9110.Rule("entity-tag").parse_all(entity_tag)
|
|
23
|
+
except ParseError as exc:
|
|
24
|
+
raise ValueError("Invalid character in value.") from exc
|
|
25
|
+
object.__setattr__(self, "opaque_tag", opaque_tag)
|
|
26
|
+
object.__setattr__(self, "weak", weak)
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
return f"W/{self.opaque_tag}" if self.weak else self.opaque_tag
|
|
30
|
+
|
|
31
|
+
def compare(self, __o: object, *, weak: bool) -> bool:
|
|
32
|
+
if not isinstance(__o, self.__class__):
|
|
33
|
+
raise TypeError(
|
|
34
|
+
f"EntityTag is not comparable to an instance of {__o.__class__.__name__}."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if weak:
|
|
38
|
+
return self.opaque_tag == __o.opaque_tag
|
|
39
|
+
else:
|
|
40
|
+
return not (self.weak or __o.weak) and self.opaque_tag == __o.opaque_tag
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EntityTagVisitor(NodeVisitor):
|
|
44
|
+
def visit_entity_tag(self, node: Node) -> EntityTag:
|
|
45
|
+
# Match children by name: an empty opaque-tag ("") and an absent weak
|
|
46
|
+
# flag are both falsy, so a truthiness filter would lose them and shift
|
|
47
|
+
# the positional unpacking.
|
|
48
|
+
weak = False
|
|
49
|
+
tag = ""
|
|
50
|
+
for child in node.children:
|
|
51
|
+
if child.name == "weak":
|
|
52
|
+
weak = self.visit(child)
|
|
53
|
+
elif child.name == "opaque-tag":
|
|
54
|
+
tag = self.visit(child)
|
|
55
|
+
return EntityTag(tag, weak=weak)
|
|
56
|
+
|
|
57
|
+
def visit_opaque_tag(self, node: Node) -> str:
|
|
58
|
+
return "".join(filter(None, map(self.visit, node.children)))
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def visit_weak(node: Node):
|
|
62
|
+
return node.value != ""
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def visit_etagc(node: Node) -> str:
|
|
66
|
+
return node.value
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from abnf import Node, NodeVisitor
|
|
2
|
+
|
|
3
|
+
import http_fields.visitors.rfc9110.entitytag as entitytag
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ETagVisitor(NodeVisitor):
|
|
7
|
+
"""Helper class for parsing ETag header value."""
|
|
8
|
+
|
|
9
|
+
visit_entity_tag = entitytag.EntityTagVisitor()
|
|
10
|
+
|
|
11
|
+
def visit_etag(self, node: Node) -> entitytag.EntityTag:
|
|
12
|
+
return next(filter(None, map(self.visit, node.children)))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from abnf.grammars import rfc9110
|
|
2
|
+
|
|
3
|
+
from http_fields.parsedobjs import CaselessMixin, ParsedStr
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FieldName(CaselessMixin, ParsedStr):
|
|
7
|
+
"""Represents an RFC 9110 field name."""
|
|
8
|
+
|
|
9
|
+
parser = rfc9110.Rule("field-name")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FieldValue(ParsedStr):
|
|
13
|
+
"""Represents an RFC 9110 field value."""
|
|
14
|
+
|
|
15
|
+
parser = rfc9110.Rule("field-value")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from abnf import Node, NodeVisitor
|
|
2
|
+
from abnf.grammars import rfc9110
|
|
3
|
+
|
|
4
|
+
from http_fields.parsedobjs import CaselessMixin, NonNegativeInt, ParsedStr
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Hostname(CaselessMixin, ParsedStr):
|
|
8
|
+
"""An RFC 9110 uri-host. Compares case-insensitively while preserving the original
|
|
9
|
+
text; self-validating."""
|
|
10
|
+
|
|
11
|
+
parser = rfc9110.Rule("uri-host")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HostVisitor(NodeVisitor):
|
|
15
|
+
def visit_host(self, node: Node) -> tuple[Hostname, NonNegativeInt | None]:
|
|
16
|
+
# Match children by name: an empty uri-host ("") is valid (reg-name is
|
|
17
|
+
# *(...)), and the ":" separator visits to None, so positional/truthiness
|
|
18
|
+
# handling would mis-assign the port as the hostname. The node is already
|
|
19
|
+
# parsed, so wrap its text as leaves untrusted-free (parse=False).
|
|
20
|
+
hostname = Hostname("", parse=False)
|
|
21
|
+
port: NonNegativeInt | None = None
|
|
22
|
+
for child in node.children:
|
|
23
|
+
if child.name == "uri-host":
|
|
24
|
+
hostname = Hostname(child.value, parse=False)
|
|
25
|
+
elif child.name == "port":
|
|
26
|
+
port = self.visit(child)
|
|
27
|
+
return (hostname, port)
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def visit_port(node: Node) -> NonNegativeInt | None:
|
|
31
|
+
# port = *DIGIT; an empty port ("example.com:") means no port.
|
|
32
|
+
return NonNegativeInt(node.value) if node.value else None
|