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 ADDED
@@ -0,0 +1,8 @@
1
+ graft src
2
+ graft examples
3
+
4
+ include *.rst
5
+ include *.txt
6
+ include pyproject.toml
7
+
8
+ global-exclude __pycache__ *.py[cod] *~
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"
@@ -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
@@ -0,0 +1,2 @@
1
+ -e git+https://github.com/WolfSGI/autorouting.git#egg=autorouting
2
+ -e .
kettu-0.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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,7 @@
1
+ from typing import NamedTuple, Any
2
+ from collections.abc import Sequence
3
+
4
+
5
+ class Data(NamedTuple):
6
+ form: Sequence[tuple[str, Any]] | None = None
7
+ json: int | float | str | dict | list | None = None # not too specific
@@ -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