kettu 0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kettu-0.1/MANIFEST.in +8 -0
- kettu-0.1/PKG-INFO +15 -0
- kettu-0.1/pyproject.toml +31 -0
- kettu-0.1/requirements.txt +2 -0
- kettu-0.1/setup.cfg +4 -0
- kettu-0.1/src/kettu/__init__.py +0 -0
- kettu-0.1/src/kettu/constants.py +25 -0
- kettu-0.1/src/kettu/cors.py +63 -0
- kettu-0.1/src/kettu/datastructures.py +7 -0
- kettu-0.1/src/kettu/exceptions.py +17 -0
- kettu-0.1/src/kettu/headers/__init__.py +26 -0
- kettu-0.1/src/kettu/headers/authorization.py +11 -0
- kettu-0.1/src/kettu/headers/constants.py +11 -0
- kettu-0.1/src/kettu/headers/content_type.py +198 -0
- kettu-0.1/src/kettu/headers/cookies.py +15 -0
- kettu-0.1/src/kettu/headers/etag.py +72 -0
- kettu-0.1/src/kettu/headers/language.py +133 -0
- kettu-0.1/src/kettu/headers/link.py +148 -0
- kettu-0.1/src/kettu/headers/query.py +60 -0
- kettu-0.1/src/kettu/headers/ranges.py +89 -0
- kettu-0.1/src/kettu/headers/utils.py +100 -0
- kettu-0.1/src/kettu/parsers/__init__.py +0 -0
- kettu-0.1/src/kettu/parsers/multipart.py +72 -0
- kettu-0.1/src/kettu/response.py +170 -0
- kettu-0.1/src/kettu/types.py +14 -0
- kettu-0.1/src/kettu.egg-info/PKG-INFO +15 -0
- kettu-0.1/src/kettu.egg-info/SOURCES.txt +31 -0
- kettu-0.1/src/kettu.egg-info/dependency_links.txt +1 -0
- kettu-0.1/src/kettu.egg-info/requires.txt +10 -0
- kettu-0.1/src/kettu.egg-info/top_level.txt +1 -0
- kettu-0.1/tests/test_http_date.py +22 -0
- kettu-0.1/tests/test_httperrors.py +29 -0
- kettu-0.1/tests/test_response_headers.py +213 -0
kettu-0.1/MANIFEST.in
ADDED
kettu-0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kettu
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Author-email: Souheil Chelfouh <trollfot@gmail.com>
|
|
5
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: biscuits>=0.3.0
|
|
8
|
+
Requires-Dist: frozendict
|
|
9
|
+
Requires-Dist: langcodes
|
|
10
|
+
Requires-Dist: multifruits>=0.1.5
|
|
11
|
+
Requires-Dist: orjson
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest; extra == "test"
|
|
14
|
+
Requires-Dist: pyhamcrest; extra == "test"
|
|
15
|
+
Requires-Dist: webtest; extra == "test"
|
kettu-0.1/pyproject.toml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kettu"
|
|
7
|
+
version = "0.1"
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Souheil Chelfouh", email = "trollfot@gmail.com"},
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
classifiers = ["License :: OSI Approved :: MIT License"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"biscuits >= 0.3.0",
|
|
15
|
+
"frozendict",
|
|
16
|
+
"langcodes",
|
|
17
|
+
"multifruits >= 0.1.5",
|
|
18
|
+
"orjson",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
test = [
|
|
23
|
+
"pytest",
|
|
24
|
+
"pyhamcrest",
|
|
25
|
+
"webtest",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
include = ["kettu"]
|
|
31
|
+
namespaces = false
|
kettu-0.1/setup.cfg
ADDED
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from http import HTTPStatus
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
REDIRECT_STATUSES = frozenset(
|
|
5
|
+
(
|
|
6
|
+
HTTPStatus.MULTIPLE_CHOICES,
|
|
7
|
+
HTTPStatus.MOVED_PERMANENTLY,
|
|
8
|
+
HTTPStatus.FOUND,
|
|
9
|
+
HTTPStatus.SEE_OTHER,
|
|
10
|
+
HTTPStatus.NOT_MODIFIED,
|
|
11
|
+
HTTPStatus.USE_PROXY,
|
|
12
|
+
HTTPStatus.TEMPORARY_REDIRECT,
|
|
13
|
+
HTTPStatus.PERMANENT_REDIRECT,
|
|
14
|
+
)
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
EMPTY_STATUSES = frozenset(
|
|
18
|
+
(
|
|
19
|
+
HTTPStatus.CONTINUE,
|
|
20
|
+
HTTPStatus.SWITCHING_PROTOCOLS,
|
|
21
|
+
HTTPStatus.PROCESSING,
|
|
22
|
+
HTTPStatus.NO_CONTENT,
|
|
23
|
+
HTTPStatus.NOT_MODIFIED,
|
|
24
|
+
)
|
|
25
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from collections.abc import Iterator, Sequence
|
|
3
|
+
from kettu.types import HTTPMethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
Header = tuple[str, str]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CORSPolicy:
|
|
11
|
+
origin: str = "*"
|
|
12
|
+
methods: Sequence[HTTPMethod] | None = None
|
|
13
|
+
allow_headers: Sequence[str] | None = None
|
|
14
|
+
expose_headers: Sequence[str] | None = None
|
|
15
|
+
credentials: bool | None = None
|
|
16
|
+
max_age: int | None = None
|
|
17
|
+
|
|
18
|
+
def headers(self) -> Iterator[Header]:
|
|
19
|
+
yield "Access-Control-Allow-Origin", self.origin
|
|
20
|
+
if self.methods is not None:
|
|
21
|
+
values = ", ".join(self.methods)
|
|
22
|
+
yield "Access-Control-Allow-Methods", values
|
|
23
|
+
if self.allow_headers is not None:
|
|
24
|
+
values = ", ".join(self.allow_headers)
|
|
25
|
+
yield "Access-Control-Allow-Headers", values
|
|
26
|
+
if self.expose_headers is not None:
|
|
27
|
+
values = ", ".join(self.expose_headers)
|
|
28
|
+
yield "Access-Control-Expose-Headers", values
|
|
29
|
+
if self.max_age is not None:
|
|
30
|
+
yield "Access-Control-Max-Age", str(self.max_age)
|
|
31
|
+
if self.credentials:
|
|
32
|
+
yield "Access-Control-Allow-Credentials", "true"
|
|
33
|
+
|
|
34
|
+
def preflight(
|
|
35
|
+
self,
|
|
36
|
+
origin: str | None = None,
|
|
37
|
+
acr_method: str | None = None,
|
|
38
|
+
acr_headers: str | None = None,
|
|
39
|
+
) -> Iterator[Header]:
|
|
40
|
+
if origin:
|
|
41
|
+
if self.origin == "*":
|
|
42
|
+
yield "Access-Control-Allow-Origin", "*"
|
|
43
|
+
elif origin == self.origin:
|
|
44
|
+
yield "Access-Control-Allow-Origin", origin
|
|
45
|
+
yield "Vary", "Origin"
|
|
46
|
+
else:
|
|
47
|
+
yield "Access-Control-Allow-Origin", self.origin
|
|
48
|
+
yield "Vary", "Origin"
|
|
49
|
+
|
|
50
|
+
if self.methods is not None:
|
|
51
|
+
yield "Access-Control-Allow-Methods", ", ".join(self.methods)
|
|
52
|
+
elif acr_method:
|
|
53
|
+
yield "Access-Control-Allow-Methods", acr_method
|
|
54
|
+
|
|
55
|
+
if self.allow_headers is not None:
|
|
56
|
+
values = ", ".join(self.allow_headers)
|
|
57
|
+
yield "Access-Control-Allow-Headers", values
|
|
58
|
+
elif acr_headers:
|
|
59
|
+
yield "Access-Control-Allow-Headers", acr_headers
|
|
60
|
+
|
|
61
|
+
if self.expose_headers is not None:
|
|
62
|
+
values = ", ".join(self.expose_headers)
|
|
63
|
+
yield "Access-Control-Expose-Headers", values
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from http import HTTPStatus
|
|
2
|
+
from kettu.types import HTTPCode
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ParsingException(ValueError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HTTPError(Exception):
|
|
10
|
+
def __init__(self, status: HTTPCode, body: str | bytes | None = None):
|
|
11
|
+
self.status = HTTPStatus(status)
|
|
12
|
+
body = self.status.description if body is None else body
|
|
13
|
+
if isinstance(body, str):
|
|
14
|
+
body = body.encode("utf-8")
|
|
15
|
+
elif not isinstance(body, bytes):
|
|
16
|
+
raise ValueError("Body must be string or bytes.")
|
|
17
|
+
self.body: bytes = body
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .authorization import Authorization
|
|
2
|
+
from .cookies import Cookie, Cookies
|
|
3
|
+
from .query import Query
|
|
4
|
+
from .ranges import Ranges
|
|
5
|
+
from .content_type import ContentType, MediaType, Accept
|
|
6
|
+
from .language import Language, Languages
|
|
7
|
+
from .etag import ETag, ETags
|
|
8
|
+
from .link import Link, Links
|
|
9
|
+
from .utils import parse_list_header, parse_header
|
|
10
|
+
from .utils import parse_http_datetime, parse_host, parse_wsgi_path
|
|
11
|
+
from .utils import encode_uri, serialize_http_datetime
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Authorization",
|
|
16
|
+
"Cookie", "Cookies",
|
|
17
|
+
"Query",
|
|
18
|
+
"Ranges",
|
|
19
|
+
"ContentType", "MediaType", "Accept",
|
|
20
|
+
"Language", "Languages",
|
|
21
|
+
"ETag", "ETags",
|
|
22
|
+
"Link", "Links",
|
|
23
|
+
"parse_list_header", "parse_header",
|
|
24
|
+
"parse_http_datetime", "parse_host", "parse_wsgi_path",
|
|
25
|
+
"encode_uri", "serialize_http_datetime"
|
|
26
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import NamedTuple
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Authorization(NamedTuple):
|
|
5
|
+
scheme: str
|
|
6
|
+
credentials: str
|
|
7
|
+
|
|
8
|
+
@classmethod
|
|
9
|
+
def from_string(cls, value: str):
|
|
10
|
+
scheme, _, credentials = value.strip(' ').partition(' ')
|
|
11
|
+
return cls(scheme.lower(), credentials.strip())
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
WEIGHT = re.compile(r"^(0\.[0-9]{1,3}|1\.0{1,3})$") # 3 decimals.
|
|
5
|
+
WEIGHT_PARAM = re.compile(r"^q=(0\.[0-9]{1,3}|1\.0{1,3})$")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Specificity(int, Enum):
|
|
9
|
+
NONSPECIFIC = 0
|
|
10
|
+
PARTIALLY_SPECIFIC = 1
|
|
11
|
+
SPECIFIC = 2
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from fnmatch import fnmatch
|
|
2
|
+
from typing import Mapping, Any, Sequence
|
|
3
|
+
from frozendict import frozendict
|
|
4
|
+
from kettu.headers.constants import WEIGHT, Specificity
|
|
5
|
+
from kettu.types import MIMEType
|
|
6
|
+
from kettu.headers.utils import parse_header
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ContentType:
|
|
10
|
+
__slots__ = ("mimetype", "options")
|
|
11
|
+
|
|
12
|
+
quality: float
|
|
13
|
+
formatted: str
|
|
14
|
+
mimetype: MIMEType
|
|
15
|
+
options: Mapping[str, str]
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
mimetype: MIMEType,
|
|
20
|
+
options: Mapping[str, str],
|
|
21
|
+
):
|
|
22
|
+
self.mimetype = mimetype
|
|
23
|
+
self.options = options
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def caster(cls, value: "str | ContentType"):
|
|
27
|
+
if isinstance(value, ContentType):
|
|
28
|
+
return value.as_header()
|
|
29
|
+
return cls.from_string(value).as_header()
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_string(cls, value: str):
|
|
33
|
+
mimetype, params = parse_header(value)
|
|
34
|
+
return cls(
|
|
35
|
+
mimetype=mimetype,
|
|
36
|
+
options=frozendict(params)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def as_header(self):
|
|
40
|
+
return self.mimetype + "".join(
|
|
41
|
+
f";{k}={v}" for k, v in sorted(self.options.items())
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def __bool__(self):
|
|
45
|
+
return bool(self.mimetype)
|
|
46
|
+
|
|
47
|
+
def __str__(self):
|
|
48
|
+
return self.formatted
|
|
49
|
+
|
|
50
|
+
def __eq__(self, other: Any) -> bool:
|
|
51
|
+
if isinstance(other, ContentType):
|
|
52
|
+
return (
|
|
53
|
+
self.mimetype == other.mimetype and
|
|
54
|
+
self.options == self.options
|
|
55
|
+
)
|
|
56
|
+
if isinstance(other, str):
|
|
57
|
+
return self.mimetype == other
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MediaType(ContentType):
|
|
62
|
+
__slots__ = (
|
|
63
|
+
"options", "specificity", "maintype", "subtype", "quality")
|
|
64
|
+
|
|
65
|
+
maintype: str
|
|
66
|
+
subtype: str
|
|
67
|
+
specificity: Specificity
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
maintype: str,
|
|
72
|
+
subtype: str,
|
|
73
|
+
options: Mapping[str, str],
|
|
74
|
+
quality: float = 1.0,
|
|
75
|
+
):
|
|
76
|
+
mimetype = maintype + '/' + subtype
|
|
77
|
+
self.quality = quality
|
|
78
|
+
self.maintype = maintype
|
|
79
|
+
self.subtype = subtype
|
|
80
|
+
|
|
81
|
+
if maintype == "*" and subtype == "*":
|
|
82
|
+
self.specificity = Specificity.NONSPECIFIC
|
|
83
|
+
elif subtype == "*":
|
|
84
|
+
self.specificity = Specificity.PARTIALLY_SPECIFIC
|
|
85
|
+
else:
|
|
86
|
+
self.specificity = Specificity.SPECIFIC
|
|
87
|
+
super().__init__(mimetype, options)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_string(cls, value: str):
|
|
91
|
+
mimetype, params = parse_header(value)
|
|
92
|
+
if mimetype == "*":
|
|
93
|
+
maintype = "*"
|
|
94
|
+
subtype = "*"
|
|
95
|
+
elif "/" in mimetype:
|
|
96
|
+
maintype, _, subtype = mimetype.partition("/")
|
|
97
|
+
if not subtype:
|
|
98
|
+
subtype = "*"
|
|
99
|
+
elif maintype == '*' and subtype != '*':
|
|
100
|
+
raise ValueError()
|
|
101
|
+
else:
|
|
102
|
+
maintype = mimetype
|
|
103
|
+
subtype = "*"
|
|
104
|
+
|
|
105
|
+
params = frozendict(params)
|
|
106
|
+
if 'q' in params:
|
|
107
|
+
q = params['q']
|
|
108
|
+
if not WEIGHT.match(q):
|
|
109
|
+
raise ValueError()
|
|
110
|
+
quality = float(q)
|
|
111
|
+
else:
|
|
112
|
+
quality = 1.0
|
|
113
|
+
return cls(
|
|
114
|
+
maintype=maintype,
|
|
115
|
+
subtype=subtype,
|
|
116
|
+
options=params,
|
|
117
|
+
quality=quality
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def caster(cls, value: str):
|
|
122
|
+
return cls.from_string(value).as_header()
|
|
123
|
+
|
|
124
|
+
def as_header(self):
|
|
125
|
+
return self.mimetype + "".join(
|
|
126
|
+
f";{k}={v}" for k, v in sorted(self.options.items())
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def __eq__(self, other: Any) -> bool:
|
|
130
|
+
if isinstance(other, str):
|
|
131
|
+
return self.mimetype == other
|
|
132
|
+
if isinstance(other, ContentType):
|
|
133
|
+
return self.mimetype == self.mimetype
|
|
134
|
+
|
|
135
|
+
def __lt__(self, other: Any) -> bool:
|
|
136
|
+
if isinstance(other, MediaType):
|
|
137
|
+
if self.quality == other.quality:
|
|
138
|
+
return self.specificity > other.specificity
|
|
139
|
+
return self.quality > other.quality
|
|
140
|
+
raise TypeError()
|
|
141
|
+
|
|
142
|
+
def match(self, other: str | ContentType) -> bool:
|
|
143
|
+
if isinstance(other, ContentType):
|
|
144
|
+
other = other.mimetype
|
|
145
|
+
if self.specificity == Specificity.NONSPECIFIC:
|
|
146
|
+
return True
|
|
147
|
+
if self.specificity == Specificity.PARTIALLY_SPECIFIC:
|
|
148
|
+
return fnmatch(other, self.mimetype)
|
|
149
|
+
return self.mimetype == other
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Accept(tuple[MediaType, ...]):
|
|
153
|
+
|
|
154
|
+
def __new__(cls, values: Sequence[MediaType]):
|
|
155
|
+
if values:
|
|
156
|
+
return super().__new__(cls, sorted(values))
|
|
157
|
+
return super().__new__(cls, (MediaType('*', '*', {}),))
|
|
158
|
+
|
|
159
|
+
def as_header(self):
|
|
160
|
+
return ','.join((media.as_header() for media in self))
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def caster(cls, value: str):
|
|
164
|
+
return cls.from_string(value).as_header()
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_string(cls, header: str, keep_null: bool = False):
|
|
168
|
+
if ',' not in header:
|
|
169
|
+
header = header.strip()
|
|
170
|
+
if header:
|
|
171
|
+
media = MediaType.from_string(header)
|
|
172
|
+
if not keep_null and not media.quality:
|
|
173
|
+
raise ValueError()
|
|
174
|
+
return cls((media,))
|
|
175
|
+
|
|
176
|
+
medias = []
|
|
177
|
+
values = header.split(',')
|
|
178
|
+
for value in values:
|
|
179
|
+
value = value.strip()
|
|
180
|
+
if value:
|
|
181
|
+
media = MediaType.from_string(value)
|
|
182
|
+
if not keep_null and not media.quality:
|
|
183
|
+
continue
|
|
184
|
+
medias.append(media)
|
|
185
|
+
if not medias:
|
|
186
|
+
raise ValueError()
|
|
187
|
+
return cls(medias)
|
|
188
|
+
|
|
189
|
+
def negotiate(self, supported: Sequence[str | MediaType]):
|
|
190
|
+
if not self:
|
|
191
|
+
if not supported:
|
|
192
|
+
return None
|
|
193
|
+
return supported[0]
|
|
194
|
+
for accepted in self:
|
|
195
|
+
for candidate in supported:
|
|
196
|
+
if accepted.match(candidate):
|
|
197
|
+
return candidate
|
|
198
|
+
return None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from biscuits import Cookie, parse
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Cookies(dict[str, Cookie]):
|
|
5
|
+
"""A Cookies management class, built on top of biscuits."""
|
|
6
|
+
|
|
7
|
+
def set(self, name: str, *args, **kwargs):
|
|
8
|
+
self[name] = Cookie(name, *args, **kwargs)
|
|
9
|
+
|
|
10
|
+
@staticmethod
|
|
11
|
+
def from_string(value: str) -> "Cookies":
|
|
12
|
+
return parse(value)
|
|
13
|
+
|
|
14
|
+
def as_header(self) -> str:
|
|
15
|
+
return ",".join(str(c) for c in self.values())
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from typing import NamedTuple
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ETag(NamedTuple):
|
|
5
|
+
value: str
|
|
6
|
+
weak: bool = False
|
|
7
|
+
|
|
8
|
+
def __str__(self):
|
|
9
|
+
return self.as_header()
|
|
10
|
+
|
|
11
|
+
def __hash__(self):
|
|
12
|
+
return hash(self.value)
|
|
13
|
+
|
|
14
|
+
def __eq__(self, other):
|
|
15
|
+
if isinstance(other, str):
|
|
16
|
+
return self.value == other
|
|
17
|
+
return tuple.__eq__(self, other)
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_string(cls, value: str) -> 'ETag':
|
|
21
|
+
weak = False
|
|
22
|
+
if value.startswith(('W/', 'w/')):
|
|
23
|
+
weak = True
|
|
24
|
+
value = value[2:]
|
|
25
|
+
|
|
26
|
+
# Etag value SHOULD be quoted.
|
|
27
|
+
return cls(value.strip('"'), weak=weak)
|
|
28
|
+
|
|
29
|
+
def compare(self, other: 'ETag') -> bool:
|
|
30
|
+
return self.value == other.value and not (self.weak or other.weak)
|
|
31
|
+
|
|
32
|
+
def as_header(self) -> str:
|
|
33
|
+
if self.weak:
|
|
34
|
+
return f'W/"{self.value}"'
|
|
35
|
+
return f'"{self.value}"'
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def caster(cls, value: "str | ETag"):
|
|
39
|
+
if isinstance(value, ETag):
|
|
40
|
+
return value.as_header()
|
|
41
|
+
return cls(value).as_header()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ETags(frozenset[ETag]):
|
|
45
|
+
# IfMatch / IfMatchNone
|
|
46
|
+
|
|
47
|
+
def as_header(self) -> str:
|
|
48
|
+
return ','.join((etag.as_header() for etag in self))
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_string(cls, header: str) -> frozenset[ETag]:
|
|
52
|
+
if ',' not in header:
|
|
53
|
+
header = header.strip()
|
|
54
|
+
if header:
|
|
55
|
+
etag = ETag.from_string(header)
|
|
56
|
+
return cls((etag,))
|
|
57
|
+
|
|
58
|
+
etags = []
|
|
59
|
+
values = header.split(',')
|
|
60
|
+
for value in values:
|
|
61
|
+
value = value.strip()
|
|
62
|
+
if value:
|
|
63
|
+
etags.append(ETag.from_string(value))
|
|
64
|
+
if not etags:
|
|
65
|
+
raise ValueError()
|
|
66
|
+
return cls(etags)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def caster(cls, value: "str | ETags"):
|
|
70
|
+
if isinstance(value, ETags):
|
|
71
|
+
return value.as_header()
|
|
72
|
+
return cls.from_string(value).as_header()
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from typing import Any, Union, Sequence
|
|
2
|
+
from langcodes import Language as LangCode
|
|
3
|
+
from kettu.headers.constants import WEIGHT_PARAM, Specificity
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Language:
|
|
7
|
+
__slots__ = ("language", "quality", "specificity")
|
|
8
|
+
|
|
9
|
+
quality: float
|
|
10
|
+
language: LangCode | None
|
|
11
|
+
specificity: Specificity
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
locale: str,
|
|
16
|
+
quality: float = 1.0
|
|
17
|
+
):
|
|
18
|
+
self.quality = quality
|
|
19
|
+
if locale != "*":
|
|
20
|
+
self.language = LangCode.get(locale)
|
|
21
|
+
self.specificity = (
|
|
22
|
+
Specificity.SPECIFIC if (
|
|
23
|
+
self.language.territory or self.language.script
|
|
24
|
+
)
|
|
25
|
+
else Specificity.PARTIALLY_SPECIFIC
|
|
26
|
+
)
|
|
27
|
+
else:
|
|
28
|
+
self.language = None
|
|
29
|
+
self.specificity = Specificity.NONSPECIFIC
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_string(cls, value: str) -> 'Language':
|
|
33
|
+
locale, _, rest = value.partition(';')
|
|
34
|
+
rest = rest.strip()
|
|
35
|
+
if rest:
|
|
36
|
+
matched = WEIGHT_PARAM.match(rest)
|
|
37
|
+
if not matched:
|
|
38
|
+
raise ValueError()
|
|
39
|
+
quality = float(matched.group(1))
|
|
40
|
+
return cls(locale.strip(), quality)
|
|
41
|
+
return cls(locale.strip())
|
|
42
|
+
|
|
43
|
+
def __str__(self):
|
|
44
|
+
if not self.language:
|
|
45
|
+
return "*"
|
|
46
|
+
return self.language.to_tag()
|
|
47
|
+
|
|
48
|
+
def as_header(self):
|
|
49
|
+
return f"{str(self)};q={self.quality}"
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def caster(cls, value: str):
|
|
53
|
+
return cls.from_string(value).as_header()
|
|
54
|
+
|
|
55
|
+
def __lt__(self, other: Any) -> bool:
|
|
56
|
+
if isinstance(other, Language):
|
|
57
|
+
if self.quality == other.quality:
|
|
58
|
+
return self.specificity > other.specificity
|
|
59
|
+
return self.quality > other.quality
|
|
60
|
+
raise TypeError()
|
|
61
|
+
|
|
62
|
+
def __eq__(self, other: Any) -> bool:
|
|
63
|
+
if isinstance(other, Language):
|
|
64
|
+
return self.language == other.language
|
|
65
|
+
if isinstance(other, str):
|
|
66
|
+
return str(self) == other
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def match(self, other: Union[str, 'Language']) -> bool:
|
|
70
|
+
if self.specificity == Specificity.NONSPECIFIC:
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
if isinstance(other, str):
|
|
74
|
+
language = LangCode.get(other)
|
|
75
|
+
else:
|
|
76
|
+
language = other.language
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
self.specificity == Specificity.PARTIALLY_SPECIFIC or
|
|
80
|
+
not language.territory or not language.script
|
|
81
|
+
):
|
|
82
|
+
return language.language == self.language.language
|
|
83
|
+
|
|
84
|
+
return language == self.language
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Languages(tuple[Language, ...]):
|
|
88
|
+
|
|
89
|
+
def __new__(cls, values: Sequence[Language]):
|
|
90
|
+
if values:
|
|
91
|
+
return super().__new__(cls, sorted(values))
|
|
92
|
+
return super().__new__(cls, (Language('*'),))
|
|
93
|
+
|
|
94
|
+
def as_header(self):
|
|
95
|
+
return ','.join((lang.as_header() for lang in self))
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def caster(cls, value: str):
|
|
99
|
+
return cls.from_string(value).as_header()
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_string(cls, header: str, keep_null: bool = False):
|
|
103
|
+
if ',' not in header:
|
|
104
|
+
header = header.strip()
|
|
105
|
+
if header:
|
|
106
|
+
lang = Language.from_string(header)
|
|
107
|
+
if not keep_null and not lang.quality:
|
|
108
|
+
raise ValueError()
|
|
109
|
+
return cls((lang,))
|
|
110
|
+
|
|
111
|
+
langs = []
|
|
112
|
+
values = header.split(',')
|
|
113
|
+
for value in values:
|
|
114
|
+
value = value.strip()
|
|
115
|
+
if value:
|
|
116
|
+
lang = Language.from_string(value)
|
|
117
|
+
if not keep_null and not lang.quality:
|
|
118
|
+
continue
|
|
119
|
+
langs.append(lang)
|
|
120
|
+
if not langs:
|
|
121
|
+
raise ValueError()
|
|
122
|
+
return cls(langs)
|
|
123
|
+
|
|
124
|
+
def negotiate(self, supported: Sequence[str | Language]):
|
|
125
|
+
if not self:
|
|
126
|
+
if not supported:
|
|
127
|
+
return None
|
|
128
|
+
return supported[0]
|
|
129
|
+
for accepted in self:
|
|
130
|
+
for candidate in supported:
|
|
131
|
+
if accepted.match(candidate):
|
|
132
|
+
return candidate
|
|
133
|
+
return None
|