mediaparse 0.2.0__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.
- mediaparse/__init__.py +28 -0
- mediaparse/_accept.py +175 -0
- mediaparse/_build.py +69 -0
- mediaparse/_errors.py +21 -0
- mediaparse/_extended.py +41 -0
- mediaparse/_lenient.py +83 -0
- mediaparse/_mediatype.py +86 -0
- mediaparse/_normalize.py +26 -0
- mediaparse/_parse.py +27 -0
- mediaparse/_rfc2231.py +102 -0
- mediaparse/_strict.py +115 -0
- mediaparse/_tokens.py +44 -0
- mediaparse/py.typed +0 -0
- mediaparse-0.2.0.dist-info/METADATA +214 -0
- mediaparse-0.2.0.dist-info/RECORD +17 -0
- mediaparse-0.2.0.dist-info/WHEEL +4 -0
- mediaparse-0.2.0.dist-info/licenses/LICENSE +21 -0
mediaparse/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Parse and build HTTP media types, Content-Type and Accept headers, exactly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._accept import AcceptItem, negotiate, parse_accept, quality
|
|
6
|
+
from ._build import build
|
|
7
|
+
from ._errors import MediaTypeError
|
|
8
|
+
from ._mediatype import MediaType
|
|
9
|
+
from ._normalize import matches, normalize
|
|
10
|
+
from ._parse import parse, parse_media_type
|
|
11
|
+
from ._rfc2231 import decode_extended
|
|
12
|
+
|
|
13
|
+
__version__ = "0.2.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AcceptItem",
|
|
17
|
+
"MediaType",
|
|
18
|
+
"MediaTypeError",
|
|
19
|
+
"build",
|
|
20
|
+
"decode_extended",
|
|
21
|
+
"matches",
|
|
22
|
+
"negotiate",
|
|
23
|
+
"normalize",
|
|
24
|
+
"parse",
|
|
25
|
+
"parse_accept",
|
|
26
|
+
"parse_media_type",
|
|
27
|
+
"quality",
|
|
28
|
+
]
|
mediaparse/_accept.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Read an Accept header and pick a representation (RFC 9110 sections 12.4.2 and 12.5.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable, Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from ._errors import MediaTypeError, show
|
|
10
|
+
from ._lenient import collect_quoted, parse_lenient
|
|
11
|
+
from ._mediatype import MediaType
|
|
12
|
+
from ._parse import parse_media_type
|
|
13
|
+
from ._strict import parse_strict
|
|
14
|
+
from ._tokens import HTTP_WHITESPACE, OWS, ascii_lower
|
|
15
|
+
|
|
16
|
+
# qvalue = ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] )
|
|
17
|
+
_QVALUE = re.compile(r"0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class AcceptItem:
|
|
22
|
+
"""One media range of an Accept header and its weight ``q`` (0 to 1, default 1)."""
|
|
23
|
+
|
|
24
|
+
media_range: MediaType
|
|
25
|
+
q: float = 1.0
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not isinstance(self.media_range, MediaType):
|
|
29
|
+
raise TypeError(
|
|
30
|
+
f"media_range must be a MediaType, got {type(self.media_range).__name__}"
|
|
31
|
+
)
|
|
32
|
+
if isinstance(self.q, bool) or not isinstance(self.q, (int, float)):
|
|
33
|
+
raise TypeError(f"q must be a number, got {type(self.q).__name__}")
|
|
34
|
+
if not 0 <= self.q <= 1:
|
|
35
|
+
raise MediaTypeError(f"q must be between 0 and 1, got {show(self.q)}")
|
|
36
|
+
object.__setattr__(self, "q", float(self.q))
|
|
37
|
+
|
|
38
|
+
def specificity(self) -> tuple[bool, bool, int]:
|
|
39
|
+
"""Precedence of the range: concrete type, concrete subtype, parameter count."""
|
|
40
|
+
rng = self.media_range
|
|
41
|
+
return (rng.type != "*", rng.subtype != "*", len(rng.params))
|
|
42
|
+
|
|
43
|
+
def matches(self, media: MediaType) -> bool:
|
|
44
|
+
"""Return whether the range covers ``media``, parameters included."""
|
|
45
|
+
rng = self.media_range
|
|
46
|
+
if rng.type not in ("*", media.type) or rng.subtype not in ("*", media.subtype):
|
|
47
|
+
return False
|
|
48
|
+
return all(_same(name, value, media.get(name)) for name, value in rng.params)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_accept(header: str, *, strict: bool = False) -> list[AcceptItem]:
|
|
52
|
+
"""Read an Accept header into its media ranges, in header order.
|
|
53
|
+
|
|
54
|
+
Elements are split on commas outside quoted strings and empty elements are ignored.
|
|
55
|
+
The ``q`` parameter is the weight wherever it appears and is removed from the range.
|
|
56
|
+
By default an element that is not a valid media range, or whose ``q`` breaks the
|
|
57
|
+
``qvalue`` grammar, is skipped; with ``strict=True`` it raises :class:`MediaTypeError`
|
|
58
|
+
and each range must match the RFC 9110 grammar exactly.
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(header, str):
|
|
61
|
+
raise TypeError(f"expected str, got {type(header).__name__}")
|
|
62
|
+
items: list[AcceptItem] = []
|
|
63
|
+
for start, element in _elements(header):
|
|
64
|
+
if not element.strip(OWS if strict else HTTP_WHITESPACE):
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
items.append(_item(element, strict))
|
|
68
|
+
except MediaTypeError as error:
|
|
69
|
+
if strict:
|
|
70
|
+
where = None if error.position is None else start + error.position
|
|
71
|
+
raise MediaTypeError(str(error), position=where) from None
|
|
72
|
+
return items
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def quality(accept: str | None, offer: str) -> float:
|
|
76
|
+
"""Return the weight ``accept`` gives ``offer``: the ``q`` of the most specific range
|
|
77
|
+
that covers it (the earliest such range on a tie), 0 if none does. ``None`` means the
|
|
78
|
+
request had no Accept field, which accepts everything with weight 1.
|
|
79
|
+
"""
|
|
80
|
+
media = _offer(offer)
|
|
81
|
+
if accept is None:
|
|
82
|
+
return 1.0
|
|
83
|
+
return _quality(parse_accept(accept), media)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def negotiate(accept: str | None, offers: Iterable[str]) -> str | None:
|
|
87
|
+
"""Return the offer with the highest non-zero weight, or ``None`` if none is acceptable.
|
|
88
|
+
|
|
89
|
+
Offers are concrete media types in the server's order of preference, which breaks ties.
|
|
90
|
+
The chosen offer is returned exactly as given.
|
|
91
|
+
"""
|
|
92
|
+
if isinstance(offers, str):
|
|
93
|
+
raise TypeError("offers must be an iterable of media types, not a single str")
|
|
94
|
+
items = None if accept is None else parse_accept(accept)
|
|
95
|
+
best, best_q = None, 0.0
|
|
96
|
+
for offer in offers:
|
|
97
|
+
media = _offer(offer)
|
|
98
|
+
weight = 1.0 if items is None else _quality(items, media)
|
|
99
|
+
if weight > best_q:
|
|
100
|
+
best, best_q = offer, weight
|
|
101
|
+
return best
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _quality(items: list[AcceptItem], media: MediaType) -> float:
|
|
105
|
+
covering = [item for item in items if item.matches(media)]
|
|
106
|
+
if not covering:
|
|
107
|
+
return 0.0
|
|
108
|
+
top = max(item.specificity() for item in covering)
|
|
109
|
+
return next(item.q for item in covering if item.specificity() == top)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _item(element: str, strict: bool) -> AcceptItem:
|
|
113
|
+
quoted: set[str] = set()
|
|
114
|
+
media = parse_strict(element, quoted) if strict else parse_lenient(element)
|
|
115
|
+
if media.type == "*" and media.subtype != "*":
|
|
116
|
+
raise MediaTypeError(f"invalid media range: {media.essence!r}")
|
|
117
|
+
weight = media.get("q")
|
|
118
|
+
if weight is None and not strict and "q" in _raw_names(element):
|
|
119
|
+
# The browser algorithm dropped a malformed q (q =0, q=, a control character);
|
|
120
|
+
# reading it as the default weight 1 would turn "not acceptable" into "best".
|
|
121
|
+
raise MediaTypeError("invalid weight")
|
|
122
|
+
q = 1.0
|
|
123
|
+
if weight is not None:
|
|
124
|
+
if _QVALUE.fullmatch(weight) is None or "q" in quoted:
|
|
125
|
+
raise MediaTypeError(f"invalid weight q={show(weight)}")
|
|
126
|
+
q = float(weight)
|
|
127
|
+
rest = tuple(pair for pair in media.params if pair[0] != "q")
|
|
128
|
+
return AcceptItem(MediaType(media.type, media.subtype, rest), q)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _elements(header: str) -> Iterator[tuple[int, str]]:
|
|
132
|
+
start = index = 0
|
|
133
|
+
while index < len(header):
|
|
134
|
+
char = header[index]
|
|
135
|
+
if char == '"':
|
|
136
|
+
_, index = collect_quoted(header, index)
|
|
137
|
+
continue
|
|
138
|
+
if char == ",":
|
|
139
|
+
yield start, header[start:index]
|
|
140
|
+
start = index + 1
|
|
141
|
+
index += 1
|
|
142
|
+
yield start, header[start:]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _raw_names(element: str) -> set[str]:
|
|
146
|
+
"""Parameter names as written, trimmed and lower-cased, even where the value is broken."""
|
|
147
|
+
names: set[str] = set()
|
|
148
|
+
index, length = element.find(";"), len(element)
|
|
149
|
+
while 0 <= index < length:
|
|
150
|
+
index += 1
|
|
151
|
+
start = index
|
|
152
|
+
while index < length and element[index] not in ";=":
|
|
153
|
+
index += 1
|
|
154
|
+
names.add(ascii_lower(element[start:index].strip(HTTP_WHITESPACE)))
|
|
155
|
+
while index < length and element[index] != ";":
|
|
156
|
+
if element[index] == '"':
|
|
157
|
+
_, index = collect_quoted(element, index)
|
|
158
|
+
else:
|
|
159
|
+
index += 1
|
|
160
|
+
return names
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _offer(offer: str) -> MediaType:
|
|
164
|
+
media = parse_media_type(offer)
|
|
165
|
+
if "*" in (media.type, media.subtype):
|
|
166
|
+
raise MediaTypeError(f"an offer must be a concrete media type, not {show(offer)}")
|
|
167
|
+
return media
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _same(name: str, wanted: str, actual: str | None) -> bool:
|
|
171
|
+
if actual is None:
|
|
172
|
+
return False
|
|
173
|
+
if name == "charset":
|
|
174
|
+
return ascii_lower(wanted) == ascii_lower(actual)
|
|
175
|
+
return wanted == actual
|
mediaparse/_build.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Build a Content-Type style header from a media type and parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from ._errors import MediaTypeError, show
|
|
8
|
+
from ._extended import encode_extended
|
|
9
|
+
from ._tokens import OWS, ascii_lower, is_quotable, is_token, quote
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build(mimetype: str, params: Mapping[str, str] | None = None, *, extended: bool = False) -> str:
|
|
13
|
+
"""Render ``mimetype`` and ``params`` as a header value such as ``text/html; charset=utf-8``.
|
|
14
|
+
|
|
15
|
+
The media type and parameter names are lower-cased. Each value is written as a bare
|
|
16
|
+
token when it can be and as a quoted string otherwise. A value that no header can carry
|
|
17
|
+
raises :class:`MediaTypeError`: control characters (other than tab) would split or
|
|
18
|
+
corrupt the header, and characters outside ASCII have no agreed byte form. With
|
|
19
|
+
``extended=True`` such a value is written instead as ``name*=UTF-8''...``, the
|
|
20
|
+
percent-encoded form of RFC 8187 and RFC 2231, and names may not end in ``*``. Two
|
|
21
|
+
parameters that would be written under the same name raise :class:`MediaTypeError`.
|
|
22
|
+
"""
|
|
23
|
+
parts = [essence(mimetype)]
|
|
24
|
+
if params is not None and not isinstance(params, Mapping):
|
|
25
|
+
raise TypeError(f"params must be a mapping, got {type(params).__name__}")
|
|
26
|
+
seen: set[str] = set()
|
|
27
|
+
for name, value in (params or {}).items():
|
|
28
|
+
key = _name(name)
|
|
29
|
+
if not isinstance(value, str):
|
|
30
|
+
raise TypeError(f"parameter {show(key)} must be a str, got {type(value).__name__}")
|
|
31
|
+
if extended and key.endswith("*"):
|
|
32
|
+
raise MediaTypeError(f"parameter {show(key)} ends in '*'; extended=True adds it")
|
|
33
|
+
if value.isascii() and all(map(is_quotable, value)):
|
|
34
|
+
field = f"{key}={quote(value)}"
|
|
35
|
+
elif extended:
|
|
36
|
+
key += "*"
|
|
37
|
+
field = f"{key}={encode_extended(value)}"
|
|
38
|
+
else:
|
|
39
|
+
raise MediaTypeError(
|
|
40
|
+
f"parameter {show(key)} holds a control or non-ASCII character; "
|
|
41
|
+
"pass extended=True to percent-encode it"
|
|
42
|
+
)
|
|
43
|
+
if key in seen:
|
|
44
|
+
raise MediaTypeError(f"parameter {show(key)} is given more than once")
|
|
45
|
+
seen.add(key)
|
|
46
|
+
parts.append(field)
|
|
47
|
+
return "; ".join(parts)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def essence(mimetype: str) -> str:
|
|
51
|
+
"""Return the lower-cased ``type/subtype``, or raise if it is not two tokens."""
|
|
52
|
+
if not isinstance(mimetype, str):
|
|
53
|
+
raise TypeError(f"mimetype must be a str, got {type(mimetype).__name__}")
|
|
54
|
+
cleaned = mimetype.strip(OWS)
|
|
55
|
+
main, slash, subtype = cleaned.partition("/")
|
|
56
|
+
if not slash:
|
|
57
|
+
raise MediaTypeError(f"media type has no subtype: {show(mimetype)}")
|
|
58
|
+
if not is_token(main) or not is_token(subtype):
|
|
59
|
+
raise MediaTypeError(f"invalid media type: {show(mimetype)}")
|
|
60
|
+
return ascii_lower(cleaned)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _name(name: str) -> str:
|
|
64
|
+
if not isinstance(name, str):
|
|
65
|
+
raise TypeError(f"parameter names must be str, got {type(name).__name__}")
|
|
66
|
+
key = ascii_lower(name.strip(OWS))
|
|
67
|
+
if not is_token(key):
|
|
68
|
+
raise MediaTypeError(f"invalid parameter name: {show(name)}")
|
|
69
|
+
return key
|
mediaparse/_errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Error type raised when a media type, parameter or Accept header is invalid."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MediaTypeError(ValueError):
|
|
7
|
+
"""Raised when a media type, a parameter or an Accept header is invalid.
|
|
8
|
+
|
|
9
|
+
``position`` is the index in the input of the first offending character when the
|
|
10
|
+
error comes from parsing text, and ``None`` otherwise.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, *, position: int | None = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.position = position
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def show(value: object, limit: int = 80) -> str:
|
|
19
|
+
"""Return ``repr(value)`` cut to ``limit`` characters, so a huge input stays out of messages."""
|
|
20
|
+
text = repr(value)
|
|
21
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
mediaparse/_extended.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Extended parameter values: ``charset'language'percent-encoded`` (RFC 8187, RFC 2231)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._errors import MediaTypeError, show
|
|
6
|
+
|
|
7
|
+
# attr-char from RFC 8187 section 3.2.1: a token character other than "*", "'" and "%".
|
|
8
|
+
ATTR_CHARS = frozenset("!#$&+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
9
|
+
_HEX = "0123456789abcdefABCDEF"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def encode_extended(value: str) -> str:
|
|
13
|
+
"""Return ``UTF-8''`` followed by the value with every non attr-char byte percent-encoded."""
|
|
14
|
+
try:
|
|
15
|
+
data = value.encode("utf-8")
|
|
16
|
+
except UnicodeEncodeError as error:
|
|
17
|
+
raise MediaTypeError(f"value is not encodable as UTF-8: {value!r}") from error
|
|
18
|
+
encoded = "".join(chr(byte) if chr(byte) in ATTR_CHARS else f"%{byte:02X}" for byte in data)
|
|
19
|
+
return f"UTF-8''{encoded}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def percent_decode(text: str) -> bytes:
|
|
23
|
+
"""Decode ``%XX`` escapes; other characters must be ASCII and are kept as they are."""
|
|
24
|
+
out = bytearray()
|
|
25
|
+
index, length = 0, len(text)
|
|
26
|
+
while index < length:
|
|
27
|
+
char = text[index]
|
|
28
|
+
if char == "%":
|
|
29
|
+
pair = text[index + 1 : index + 3]
|
|
30
|
+
if len(pair) != 2 or pair[0] not in _HEX or pair[1] not in _HEX:
|
|
31
|
+
raise MediaTypeError(f"invalid percent escape in {show(text)}", position=index)
|
|
32
|
+
out.append(int(pair, 16))
|
|
33
|
+
index += 3
|
|
34
|
+
continue
|
|
35
|
+
if not char.isascii():
|
|
36
|
+
raise MediaTypeError(
|
|
37
|
+
f"non-ASCII character in encoded value {show(text)}", position=index
|
|
38
|
+
)
|
|
39
|
+
out.append(ord(char))
|
|
40
|
+
index += 1
|
|
41
|
+
return bytes(out)
|
mediaparse/_lenient.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The WHATWG MIME Sniffing "parse a MIME type" algorithm, step by step.
|
|
2
|
+
|
|
3
|
+
https://mimesniff.spec.whatwg.org/#parse-a-mime-type, with "collect an HTTP quoted
|
|
4
|
+
string" from https://fetch.spec.whatwg.org/. Where the algorithm returns failure this
|
|
5
|
+
module raises :class:`MediaTypeError`; everything else it tolerates the way browsers do.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ._errors import MediaTypeError, show
|
|
11
|
+
from ._mediatype import MediaType
|
|
12
|
+
from ._tokens import HTTP_WHITESPACE, ascii_lower, is_quotable, is_token
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_lenient(text: str) -> MediaType:
|
|
16
|
+
"""Parse ``text`` exactly as a browser parses a MIME type."""
|
|
17
|
+
data = text.strip(HTTP_WHITESPACE)
|
|
18
|
+
offset = len(text) - len(text.lstrip(HTTP_WHITESPACE))
|
|
19
|
+
slash = data.find("/")
|
|
20
|
+
main = data if slash < 0 else data[:slash]
|
|
21
|
+
if not is_token(main):
|
|
22
|
+
raise MediaTypeError(f"invalid media type: {show(text)}", position=offset)
|
|
23
|
+
if slash < 0:
|
|
24
|
+
raise MediaTypeError(
|
|
25
|
+
f"media type has no subtype: {show(text)}", position=offset + len(data)
|
|
26
|
+
)
|
|
27
|
+
end = _find(data, ";", slash + 1)
|
|
28
|
+
subtype = data[slash + 1 : end].rstrip(HTTP_WHITESPACE)
|
|
29
|
+
if not is_token(subtype):
|
|
30
|
+
raise MediaTypeError(f"invalid media subtype: {show(text)}", position=offset + slash + 1)
|
|
31
|
+
params: dict[str, str] = {}
|
|
32
|
+
position, length = end, len(data)
|
|
33
|
+
while position < length:
|
|
34
|
+
position += 1 # skip the ";"
|
|
35
|
+
while position < length and data[position] in HTTP_WHITESPACE:
|
|
36
|
+
position += 1
|
|
37
|
+
start = position
|
|
38
|
+
while position < length and data[position] not in ";=":
|
|
39
|
+
position += 1
|
|
40
|
+
name = ascii_lower(data[start:position])
|
|
41
|
+
if position < length:
|
|
42
|
+
if data[position] == ";":
|
|
43
|
+
continue
|
|
44
|
+
position += 1 # skip the "="
|
|
45
|
+
if position >= length:
|
|
46
|
+
break
|
|
47
|
+
if data[position] == '"':
|
|
48
|
+
value, position = collect_quoted(data, position)
|
|
49
|
+
position = _find(data, ";", position)
|
|
50
|
+
else:
|
|
51
|
+
stop = _find(data, ";", position)
|
|
52
|
+
value = data[position:stop].rstrip(HTTP_WHITESPACE)
|
|
53
|
+
position = stop
|
|
54
|
+
if not value:
|
|
55
|
+
continue
|
|
56
|
+
if is_token(name) and all(map(is_quotable, value)) and name not in params:
|
|
57
|
+
params[name] = value
|
|
58
|
+
return MediaType(main, subtype, tuple(params.items()))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def collect_quoted(data: str, position: int) -> tuple[str, int]:
|
|
62
|
+
"""Collect an HTTP quoted string with extract-value set: ``(value, next position)``."""
|
|
63
|
+
chars: list[str] = []
|
|
64
|
+
position += 1 # skip the opening quote
|
|
65
|
+
length = len(data)
|
|
66
|
+
while position < length:
|
|
67
|
+
char = data[position]
|
|
68
|
+
position += 1
|
|
69
|
+
if char == '"':
|
|
70
|
+
break
|
|
71
|
+
if char == "\\":
|
|
72
|
+
if position >= length:
|
|
73
|
+
chars.append("\\")
|
|
74
|
+
break
|
|
75
|
+
char = data[position]
|
|
76
|
+
position += 1
|
|
77
|
+
chars.append(char)
|
|
78
|
+
return "".join(chars), position
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _find(data: str, char: str, start: int) -> int:
|
|
82
|
+
index = data.find(char, start)
|
|
83
|
+
return len(data) if index < 0 else index
|
mediaparse/_mediatype.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The parsed media type value."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ._errors import MediaTypeError, show
|
|
8
|
+
from ._tokens import ascii_lower, is_quotable, is_token, quote
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class MediaType:
|
|
13
|
+
"""A media type: ``type``, ``subtype`` and ordered ``params`` as ``(name, value)`` pairs.
|
|
14
|
+
|
|
15
|
+
Construction checks the parts: type, subtype and parameter names must be tokens and
|
|
16
|
+
are stored in lower case, names must be unique, and values must fit a quoted string.
|
|
17
|
+
``str()`` serializes it the way the WHATWG MIME Sniffing standard does, which is the
|
|
18
|
+
form browsers expose, for example ``text/html;charset=utf-8``. Equality compares the
|
|
19
|
+
parameters in order; use :func:`matches` to ignore order and ``charset`` case. A part
|
|
20
|
+
of the wrong type raises :class:`TypeError`, an invalid one :class:`MediaTypeError`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
type: str
|
|
24
|
+
subtype: str
|
|
25
|
+
params: tuple[tuple[str, str], ...] = ()
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
for label, part in (("type", self.type), ("subtype", self.subtype)):
|
|
29
|
+
_require_str(label, part)
|
|
30
|
+
if not is_token(part):
|
|
31
|
+
raise MediaTypeError(f"invalid media {label}: {show(part)}")
|
|
32
|
+
if not isinstance(self.params, (tuple, list)):
|
|
33
|
+
raise TypeError(f"params must be a tuple of pairs, got {type(self.params).__name__}")
|
|
34
|
+
names: list[str] = []
|
|
35
|
+
pairs = tuple(self.params)
|
|
36
|
+
for pair in pairs:
|
|
37
|
+
if not isinstance(pair, tuple) or len(pair) != 2:
|
|
38
|
+
raise TypeError(f"parameters must be (name, value) tuples, got {show(pair)}")
|
|
39
|
+
name, value = pair
|
|
40
|
+
_require_str("parameter name", name)
|
|
41
|
+
_require_str("parameter value", value)
|
|
42
|
+
if not is_token(name):
|
|
43
|
+
raise MediaTypeError(f"invalid parameter name: {show(name)}")
|
|
44
|
+
if not all(is_quotable(char) for char in value):
|
|
45
|
+
raise MediaTypeError(f"parameter {show(name)} has a value no header can carry")
|
|
46
|
+
names.append(ascii_lower(name))
|
|
47
|
+
if len(set(names)) != len(names):
|
|
48
|
+
raise MediaTypeError("a parameter is given more than once")
|
|
49
|
+
object.__setattr__(self, "type", ascii_lower(self.type))
|
|
50
|
+
object.__setattr__(self, "subtype", ascii_lower(self.subtype))
|
|
51
|
+
lowered = tuple((name, value) for name, (_, value) in zip(names, pairs, strict=True))
|
|
52
|
+
object.__setattr__(self, "params", lowered)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def essence(self) -> str:
|
|
56
|
+
"""``type/subtype`` without parameters."""
|
|
57
|
+
return f"{self.type}/{self.subtype}"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def suffix(self) -> str | None:
|
|
61
|
+
"""The structured syntax suffix of RFC 6838 section 4.2.8 (``json`` for ``+json``)."""
|
|
62
|
+
_, plus, tail = self.subtype.rpartition("+")
|
|
63
|
+
return tail if plus and tail else None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def parameters(self) -> dict[str, str]:
|
|
67
|
+
"""The parameters as a new ordered dict."""
|
|
68
|
+
return dict(self.params)
|
|
69
|
+
|
|
70
|
+
def get(self, name: str, default: str | None = None) -> str | None:
|
|
71
|
+
"""Return a parameter value by case-insensitive name, or ``default``."""
|
|
72
|
+
wanted = ascii_lower(name)
|
|
73
|
+
for key, value in self.params:
|
|
74
|
+
if key == wanted:
|
|
75
|
+
return value
|
|
76
|
+
return default
|
|
77
|
+
|
|
78
|
+
def __str__(self) -> str:
|
|
79
|
+
parts = [self.essence]
|
|
80
|
+
parts.extend(f"{name}={quote(value)}" for name, value in self.params)
|
|
81
|
+
return ";".join(parts)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _require_str(label: str, value: object) -> None:
|
|
85
|
+
if not isinstance(value, str):
|
|
86
|
+
raise TypeError(f"{label} must be a str, got {type(value).__name__}")
|
mediaparse/_normalize.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Canonicalize and compare media type headers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._parse import parse_media_type
|
|
6
|
+
from ._tokens import ascii_lower, quote
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize(header: str, *, strict: bool = False) -> str:
|
|
10
|
+
"""Return a canonical form of the header for display or comparison.
|
|
11
|
+
|
|
12
|
+
The header is read like :func:`parse_media_type`. The media type and parameter names
|
|
13
|
+
are lower-cased, parameters are sorted by name and the ``charset`` value is lower-cased
|
|
14
|
+
(it is case insensitive, RFC 2046 section 4.1.2). Other values keep their case, since
|
|
15
|
+
a filename or boundary is significant. The output uses ``"; "`` between parts.
|
|
16
|
+
"""
|
|
17
|
+
media = parse_media_type(header, strict=strict)
|
|
18
|
+
parts = [media.essence]
|
|
19
|
+
for name, value in sorted(media.params):
|
|
20
|
+
parts.append(f"{name}={quote(ascii_lower(value) if name == 'charset' else value)}")
|
|
21
|
+
return "; ".join(parts)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def matches(first: str, second: str) -> bool:
|
|
25
|
+
"""Return whether two headers denote the same media type and parameters."""
|
|
26
|
+
return normalize(first) == normalize(second)
|
mediaparse/_parse.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Parse a Content-Type style header into a media type and its parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._lenient import parse_lenient
|
|
6
|
+
from ._mediatype import MediaType
|
|
7
|
+
from ._strict import parse_strict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_media_type(text: str, *, strict: bool = False) -> MediaType:
|
|
11
|
+
"""Parse a media type header value into a :class:`MediaType`.
|
|
12
|
+
|
|
13
|
+
By default the WHATWG MIME Sniffing algorithm is used, so the result is what a browser
|
|
14
|
+
would read: whitespace is trimmed, parameters a browser would ignore are skipped and
|
|
15
|
+
the first of a repeated parameter wins. With ``strict=True`` the text must match the
|
|
16
|
+
RFC 9110 grammar exactly and a repeated parameter is an error. Either way a value with
|
|
17
|
+
no valid ``type/subtype`` raises :class:`MediaTypeError`.
|
|
18
|
+
"""
|
|
19
|
+
if not isinstance(text, str):
|
|
20
|
+
raise TypeError(f"expected str, got {type(text).__name__}")
|
|
21
|
+
return parse_strict(text) if strict else parse_lenient(text)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse(header: str, *, strict: bool = False) -> tuple[str, dict[str, str]]:
|
|
25
|
+
"""Split a media type header into ``(essence, params)``, see :func:`parse_media_type`."""
|
|
26
|
+
media = parse_media_type(header, strict=strict)
|
|
27
|
+
return media.essence, media.parameters
|
mediaparse/_rfc2231.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Join RFC 2231 parameter continuations and decode charset-encoded values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
|
|
9
|
+
from ._errors import MediaTypeError, show
|
|
10
|
+
from ._extended import percent_decode
|
|
11
|
+
from ._tokens import ascii_lower
|
|
12
|
+
|
|
13
|
+
# attribute, optional section (no leading zeros, at most nine digits), optional "*" for an
|
|
14
|
+
# encoded value.
|
|
15
|
+
_NAME = re.compile(r"(?P<attr>[^*]+)(?:\*(?P<section>0|[1-9][0-9]{0,8}))?(?P<encoded>\*)?")
|
|
16
|
+
_INITIAL = re.compile(r"(?P<charset>[^']*)'(?P<language>[^']*)'(?P<rest>.*)", re.DOTALL)
|
|
17
|
+
# mime-charset characters from RFC 8187 section 3.2.1.
|
|
18
|
+
_CHARSET = re.compile(r"[A-Za-z0-9!#$%&+\-^_`{}~]+")
|
|
19
|
+
# Python text codecs that are not character sets: escape decoders, host code pages and
|
|
20
|
+
# transforms. A header must not decide to run them.
|
|
21
|
+
_NOT_CHARSETS = frozenset({"mbcs", "oem", "idna", "punycode", "undefined"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def decode_extended(params: Mapping[str, str]) -> dict[str, str]:
|
|
25
|
+
"""Return ``params`` with RFC 2231 continuations joined and encoded values decoded.
|
|
26
|
+
|
|
27
|
+
``title*0*=us-ascii'en'This%20is``, ``title*1=" fun"`` become ``title="This is fun"``.
|
|
28
|
+
Names are compared in ASCII lower case and results keep the order in which each name
|
|
29
|
+
first appears. When both ``name`` and an extended form are present the extended form
|
|
30
|
+
wins, as it is the one that carries the real value. The language tag is dropped.
|
|
31
|
+
Gaps or leading zeros in the sections, a section given twice, a bad percent escape, a
|
|
32
|
+
charset that is not a character set Python can decode, and bytes it cannot decode
|
|
33
|
+
raise :class:`MediaTypeError`.
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(params, Mapping):
|
|
36
|
+
raise TypeError(f"params must be a mapping, got {type(params).__name__}")
|
|
37
|
+
order: dict[str, None] = {}
|
|
38
|
+
plain: dict[str, str] = {}
|
|
39
|
+
pieces: dict[str, dict[int, tuple[bool, str]]] = {}
|
|
40
|
+
for raw, value in params.items():
|
|
41
|
+
if not isinstance(raw, str) or not isinstance(value, str):
|
|
42
|
+
raise TypeError("parameter names and values must be str")
|
|
43
|
+
name = ascii_lower(raw)
|
|
44
|
+
if "*" not in name:
|
|
45
|
+
order.setdefault(name)
|
|
46
|
+
plain[name] = value
|
|
47
|
+
continue
|
|
48
|
+
match = _NAME.fullmatch(name)
|
|
49
|
+
if match is None:
|
|
50
|
+
raise MediaTypeError(f"invalid extended parameter name: {show(raw)}")
|
|
51
|
+
attr, section = match["attr"], match["section"]
|
|
52
|
+
order.setdefault(attr)
|
|
53
|
+
index = -1 if section is None else int(section)
|
|
54
|
+
slot = pieces.setdefault(attr, {})
|
|
55
|
+
if index in slot:
|
|
56
|
+
raise MediaTypeError(f"parameter {show(attr)} section {section} is given twice")
|
|
57
|
+
slot[index] = (match["encoded"] is not None, value)
|
|
58
|
+
return {attr: _join(attr, pieces[attr]) if attr in pieces else plain[attr] for attr in order}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _join(attr: str, slot: dict[int, tuple[bool, str]]) -> str:
|
|
62
|
+
label = show(attr)
|
|
63
|
+
if -1 in slot:
|
|
64
|
+
if len(slot) > 1:
|
|
65
|
+
raise MediaTypeError(f"parameter {label} mixes the unnumbered and numbered forms")
|
|
66
|
+
slot = {0: slot[-1]}
|
|
67
|
+
if sorted(slot) != list(range(len(slot))):
|
|
68
|
+
raise MediaTypeError(f"parameter {label} has missing sections")
|
|
69
|
+
charset = "us-ascii"
|
|
70
|
+
data = bytearray()
|
|
71
|
+
for index in range(len(slot)):
|
|
72
|
+
encoded, value = slot[index]
|
|
73
|
+
if encoded and index == 0:
|
|
74
|
+
initial = _INITIAL.fullmatch(value)
|
|
75
|
+
if initial is None:
|
|
76
|
+
raise MediaTypeError(f"parameter {label} lacks the charset'language' prefix")
|
|
77
|
+
charset = initial["charset"] or "us-ascii"
|
|
78
|
+
value = initial["rest"]
|
|
79
|
+
if encoded:
|
|
80
|
+
data += percent_decode(value)
|
|
81
|
+
elif value.isascii():
|
|
82
|
+
data += value.encode("ascii")
|
|
83
|
+
else:
|
|
84
|
+
raise MediaTypeError(f"parameter {label} has non-ASCII text in a plain section")
|
|
85
|
+
return _decode(bytes(data), charset, label)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _decode(data: bytes, charset: str, label: str) -> str:
|
|
89
|
+
if _CHARSET.fullmatch(charset) is None:
|
|
90
|
+
raise MediaTypeError(f"invalid charset name {show(charset)}")
|
|
91
|
+
try:
|
|
92
|
+
name = codecs.lookup(charset).name
|
|
93
|
+
except LookupError as error:
|
|
94
|
+
raise MediaTypeError(f"unknown charset {show(charset)}") from error
|
|
95
|
+
if name in _NOT_CHARSETS or "escape" in name:
|
|
96
|
+
raise MediaTypeError(f"{show(charset)} is not a character set")
|
|
97
|
+
try:
|
|
98
|
+
return data.decode(charset)
|
|
99
|
+
except LookupError as error:
|
|
100
|
+
raise MediaTypeError(f"{show(charset)} is not a text encoding") from error
|
|
101
|
+
except (UnicodeError, ValueError) as error:
|
|
102
|
+
raise MediaTypeError(f"parameter {label} is not valid {show(charset)}") from error
|
mediaparse/_strict.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The RFC 9110 ``media-type`` grammar, exactly, with the position of the first error.
|
|
2
|
+
|
|
3
|
+
media-type = type "/" subtype parameters ; section 8.3.1
|
|
4
|
+
parameters = *( OWS ";" OWS [ parameter ] ) ; section 5.6.6
|
|
5
|
+
parameter = token "=" ( token / quoted-string ) ; no whitespace around "="
|
|
6
|
+
|
|
7
|
+
Leading and trailing OWS is skipped, since a field parser removes it before the value is
|
|
8
|
+
read (section 5.5). A parameter given twice is an error, as RFC 6838 section 4.3 says.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ._errors import MediaTypeError, show
|
|
14
|
+
from ._mediatype import MediaType
|
|
15
|
+
from ._tokens import OWS, TOKEN_CHARS, ascii_lower
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_strict(text: str, quoted: set[str] | None = None) -> MediaType:
|
|
19
|
+
"""Parse ``text`` against the RFC 9110 grammar, raising on the first deviation.
|
|
20
|
+
|
|
21
|
+
The names of parameters written as quoted strings are added to ``quoted`` if given.
|
|
22
|
+
"""
|
|
23
|
+
reader = _Reader(text)
|
|
24
|
+
reader.skip_ows()
|
|
25
|
+
main = reader.token("media type")
|
|
26
|
+
reader.expect("/")
|
|
27
|
+
subtype = reader.token("media subtype")
|
|
28
|
+
params: dict[str, str] = {}
|
|
29
|
+
while True:
|
|
30
|
+
reader.skip_ows()
|
|
31
|
+
if reader.done():
|
|
32
|
+
break
|
|
33
|
+
reader.expect(";")
|
|
34
|
+
reader.skip_ows()
|
|
35
|
+
if reader.done() or reader.peek() == ";":
|
|
36
|
+
continue
|
|
37
|
+
start = reader.position
|
|
38
|
+
name = ascii_lower(reader.token("parameter name"))
|
|
39
|
+
if name in params:
|
|
40
|
+
raise MediaTypeError(f"parameter {show(name)} is given more than once", position=start)
|
|
41
|
+
reader.expect("=")
|
|
42
|
+
if reader.peek() == '"':
|
|
43
|
+
params[name] = reader.quoted()
|
|
44
|
+
if quoted is not None:
|
|
45
|
+
quoted.add(name)
|
|
46
|
+
else:
|
|
47
|
+
params[name] = reader.token("value")
|
|
48
|
+
return MediaType(main, subtype, tuple(params.items()))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Reader:
|
|
52
|
+
def __init__(self, text: str) -> None:
|
|
53
|
+
self.text = text
|
|
54
|
+
self.position = 0
|
|
55
|
+
|
|
56
|
+
def done(self) -> bool:
|
|
57
|
+
return self.position >= len(self.text)
|
|
58
|
+
|
|
59
|
+
def peek(self) -> str:
|
|
60
|
+
return self.text[self.position : self.position + 1]
|
|
61
|
+
|
|
62
|
+
def fail(self, message: str) -> MediaTypeError:
|
|
63
|
+
found = self.peek()
|
|
64
|
+
where = f"found {found!r}" if found else "found the end"
|
|
65
|
+
return MediaTypeError(
|
|
66
|
+
f"{message} at position {self.position}, {where}: {show(self.text)}",
|
|
67
|
+
position=self.position,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def skip_ows(self) -> None:
|
|
71
|
+
while self.peek() and self.peek() in OWS:
|
|
72
|
+
self.position += 1
|
|
73
|
+
|
|
74
|
+
def expect(self, char: str) -> None:
|
|
75
|
+
if self.peek() != char:
|
|
76
|
+
raise self.fail(f"expected {char!r}")
|
|
77
|
+
self.position += 1
|
|
78
|
+
|
|
79
|
+
def token(self, label: str) -> str:
|
|
80
|
+
start = self.position
|
|
81
|
+
while self.peek() and self.peek() in TOKEN_CHARS:
|
|
82
|
+
self.position += 1
|
|
83
|
+
if self.position == start:
|
|
84
|
+
raise self.fail(f"expected a token for the {label}")
|
|
85
|
+
return self.text[start : self.position]
|
|
86
|
+
|
|
87
|
+
def quoted(self) -> str:
|
|
88
|
+
self.position += 1 # the opening quote
|
|
89
|
+
chars: list[str] = []
|
|
90
|
+
while True:
|
|
91
|
+
char = self.peek()
|
|
92
|
+
if not char:
|
|
93
|
+
raise self.fail("unterminated quoted string")
|
|
94
|
+
if char == '"':
|
|
95
|
+
self.position += 1
|
|
96
|
+
return "".join(chars)
|
|
97
|
+
if char == "\\":
|
|
98
|
+
self.position += 1
|
|
99
|
+
char = self.peek()
|
|
100
|
+
if not (char and _quoted_pair(char)):
|
|
101
|
+
raise self.fail("invalid escape in quoted string")
|
|
102
|
+
elif not _qdtext(char):
|
|
103
|
+
raise self.fail("character not allowed in a quoted string")
|
|
104
|
+
chars.append(char)
|
|
105
|
+
self.position += 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _qdtext(char: str) -> bool:
|
|
109
|
+
# HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
|
|
110
|
+
return char in "\t !" or "#" <= char <= "[" or "]" <= char <= "~" or "\x80" <= char <= "\xff"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _quoted_pair(char: str) -> bool:
|
|
114
|
+
# "\" ( HTAB / SP / VCHAR / obs-text )
|
|
115
|
+
return char in "\t " or "!" <= char <= "~" or "\x80" <= char <= "\xff"
|
mediaparse/_tokens.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Character classes shared by the parsers, the builder and the Accept reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# tchar from RFC 9110 section 5.6.2; WHATWG calls these HTTP token code points.
|
|
6
|
+
TOKEN_CHARS = frozenset(
|
|
7
|
+
"!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
8
|
+
)
|
|
9
|
+
# HTTP whitespace in the WHATWG Fetch standard: tab, LF, CR and space.
|
|
10
|
+
HTTP_WHITESPACE = "\t\n\r "
|
|
11
|
+
# OWS in RFC 9110 section 5.6.3: space and horizontal tab only.
|
|
12
|
+
OWS = " \t"
|
|
13
|
+
|
|
14
|
+
_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ascii_lower(text: str) -> str:
|
|
18
|
+
"""Lower-case A to Z only; ``str.lower`` would also fold non-ASCII letters into ASCII."""
|
|
19
|
+
return text.translate(_LOWER)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_token(value: str) -> bool:
|
|
23
|
+
"""Return whether the value is a non-empty RFC 9110 token."""
|
|
24
|
+
return bool(value) and all(char in TOKEN_CHARS for char in value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_quotable(char: str) -> bool:
|
|
28
|
+
"""Return whether a quoted string can carry the character (tab, 0x20-0x7E, 0x80-0xFF).
|
|
29
|
+
|
|
30
|
+
This is the WHATWG "HTTP quoted-string token code point" set, the same characters that
|
|
31
|
+
RFC 9110 ``qdtext`` and ``quoted-pair`` reach together.
|
|
32
|
+
"""
|
|
33
|
+
return char == "\t" or " " <= char <= "~" or "\x80" <= char <= "\xff"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def quote(value: str) -> str:
|
|
37
|
+
"""Write a parameter value as a token when it is one, as a quoted string otherwise.
|
|
38
|
+
|
|
39
|
+
Only the double quote and the backslash are escaped, as RFC 9110 section 5.6.4 asks.
|
|
40
|
+
"""
|
|
41
|
+
if is_token(value):
|
|
42
|
+
return value
|
|
43
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
44
|
+
return f'"{escaped}"'
|
mediaparse/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mediaparse
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Parse and build HTTP media types, Content-Type and Accept headers, exactly
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/mediaparse
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/mediaparse/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/mediaparse/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: accept,cgi,content-negotiation,content-type,header,http,media-type,mime,rfc2231,rfc9110,whatwg
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: build; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
34
|
+
Requires-Dist: twine; extra == 'dev'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# mediaparse
|
|
38
|
+
|
|
39
|
+
Parse and build HTTP media types, `Content-Type` and `Accept` headers, exactly.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
pip install mediaparse
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from mediaparse import build, matches, normalize, parse, parse_media_type
|
|
51
|
+
|
|
52
|
+
# Read a Content-Type value the way a browser does.
|
|
53
|
+
print(parse("text/html; charset=utf-8"))
|
|
54
|
+
# ('text/html', {'charset': 'utf-8'})
|
|
55
|
+
|
|
56
|
+
# Quoted values keep separators; text after the closing quote is skipped.
|
|
57
|
+
print(parse('multipart/form-data; boundary="a; b=c"'))
|
|
58
|
+
# ('multipart/form-data', {'boundary': 'a; b=c'})
|
|
59
|
+
|
|
60
|
+
# A typed value with the essence, the +suffix and the browser serialization.
|
|
61
|
+
media = parse_media_type("Application/Vnd.API+JSON; Charset=UTF-8")
|
|
62
|
+
print(media.essence, media.suffix, media.get("charset"))
|
|
63
|
+
# application/vnd.api+json json UTF-8
|
|
64
|
+
print(media)
|
|
65
|
+
# application/vnd.api+json;charset=UTF-8
|
|
66
|
+
|
|
67
|
+
# Build a header, quoting only the values that need it.
|
|
68
|
+
print(build("application/json", {"title": "a; b"}))
|
|
69
|
+
# application/json; title="a; b"
|
|
70
|
+
|
|
71
|
+
# Canonical form for display or comparison.
|
|
72
|
+
print(normalize("TEXT/HTML; CharSet=UTF-8"))
|
|
73
|
+
# text/html; charset=utf-8
|
|
74
|
+
print(matches("Text/HTML; charset=UTF-8", "text/html;charset=utf-8"))
|
|
75
|
+
# True
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Lenient or strict
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from mediaparse import MediaTypeError, parse
|
|
82
|
+
|
|
83
|
+
# Default: the WHATWG algorithm. The first of a repeated parameter wins and a parameter
|
|
84
|
+
# with whitespace before "=" is skipped, exactly as browsers do.
|
|
85
|
+
print(parse("text/plain; charset=ascii; charset=utf-8; a =1"))
|
|
86
|
+
# ('text/plain', {'charset': 'ascii'})
|
|
87
|
+
|
|
88
|
+
# strict=True: the RFC 9110 grammar, with the position of the first bad character.
|
|
89
|
+
try:
|
|
90
|
+
parse("text/plain; a =1", strict=True)
|
|
91
|
+
except MediaTypeError as error:
|
|
92
|
+
print(error.position)
|
|
93
|
+
# 13
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Building safely
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from mediaparse import MediaTypeError, build
|
|
100
|
+
|
|
101
|
+
# No value can split the header: control characters are refused.
|
|
102
|
+
try:
|
|
103
|
+
build("text/plain", {"name": "x\r\nSet-Cookie: a=b"})
|
|
104
|
+
except MediaTypeError:
|
|
105
|
+
print("refused")
|
|
106
|
+
# refused
|
|
107
|
+
|
|
108
|
+
# Non-ASCII values travel as RFC 8187 / RFC 2231 extended values on request.
|
|
109
|
+
print(build("text/plain", {"title": "caf\xe9"}, extended=True))
|
|
110
|
+
# text/plain; title*=UTF-8''caf%C3%A9
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Accept and negotiation
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from mediaparse import negotiate, parse_accept, quality
|
|
117
|
+
|
|
118
|
+
accept = "text/*;q=0.3, text/plain;q=0.7, text/plain;format=flowed, */*;q=0.5"
|
|
119
|
+
|
|
120
|
+
# The weight comes from the most specific range that covers the offer.
|
|
121
|
+
print(quality(accept, "text/plain;format=flowed"), quality(accept, "text/html"))
|
|
122
|
+
# 1.0 0.3
|
|
123
|
+
|
|
124
|
+
# Pick the best offer; ties go to the order you list them in.
|
|
125
|
+
print(negotiate(accept, ["image/png", "text/html"]))
|
|
126
|
+
# image/png
|
|
127
|
+
|
|
128
|
+
print([(str(item.media_range), item.q) for item in parse_accept("text/html, */*;q=0.1")])
|
|
129
|
+
# [('text/html', 1.0), ('*/*', 0.1)]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### RFC 2231 parameters
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from mediaparse import decode_extended, parse
|
|
136
|
+
|
|
137
|
+
header = "application/x-stuff; title*0*=utf-8''r%C3%A9sum; title*1=\"e.pdf\""
|
|
138
|
+
print(decode_extended(parse(header)[1]))
|
|
139
|
+
# {'title': 'résume.pdf'}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## API
|
|
143
|
+
|
|
144
|
+
| Name | What it does |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| `parse(header, *, strict=False)` | `(essence, params)` tuple. |
|
|
147
|
+
| `parse_media_type(text, *, strict=False)` | A `MediaType`; raises `MediaTypeError` when there is no valid `type/subtype`. |
|
|
148
|
+
| `MediaType` | Frozen value: `type`, `subtype`, `params` pairs, `essence`, `suffix`, `parameters`, `get()`, `str()`. |
|
|
149
|
+
| `build(mimetype, params=None, *, extended=False)` | Header value with `"; "` between parts and minimal quoting. |
|
|
150
|
+
| `normalize(header, *, strict=False)` | Lower-cased names, sorted parameters, lower-cased `charset`. |
|
|
151
|
+
| `matches(first, second)` | Whether two headers normalize to the same text. |
|
|
152
|
+
| `parse_accept(header, *, strict=False)` | List of `AcceptItem` in header order. |
|
|
153
|
+
| `AcceptItem` | `media_range` (a `MediaType`), `q` (0 to 1), `specificity()`, `matches(media)`. |
|
|
154
|
+
| `quality(accept, offer)` | Weight of an offer under an Accept header; `None` means no header. |
|
|
155
|
+
| `negotiate(accept, offers)` | Best acceptable offer, returned as given, or `None`. |
|
|
156
|
+
| `decode_extended(params)` | Joins RFC 2231 sections and decodes `charset'lang'` values. |
|
|
157
|
+
| `MediaTypeError` | `ValueError` subclass with `position` (or `None`). Wrong argument types raise `TypeError`. |
|
|
158
|
+
|
|
159
|
+
## Rules it follows
|
|
160
|
+
|
|
161
|
+
- **Default parsing** is the WHATWG MIME Sniffing "parse a MIME type" algorithm, checked
|
|
162
|
+
against the web-platform-tests MIME type corpus. Whitespace (tab, LF, CR, space) is
|
|
163
|
+
trimmed, a parameter whose name is not a token or whose value has a character above
|
|
164
|
+
U+00FF or a control character is skipped, empty values are skipped and the first
|
|
165
|
+
duplicate wins. `str(MediaType)` is the WHATWG serialization.
|
|
166
|
+
- **Strict parsing** is the RFC 9110 `media-type` grammar: no whitespace around `=`,
|
|
167
|
+
values are tokens or quoted strings with `qdtext` and `quoted-pair` only, and a repeated
|
|
168
|
+
parameter is an error (RFC 6838 section 4.3). Leading and trailing spaces or tabs are
|
|
169
|
+
allowed because a field parser removes them.
|
|
170
|
+
- **Names** are lower-cased in ASCII only, so a character such as the Kelvin sign never
|
|
171
|
+
turns into a token. Values keep their case; `normalize` and `quality` treat `charset`
|
|
172
|
+
as case insensitive.
|
|
173
|
+
- **Building** never writes a control character or a non-ASCII character into the
|
|
174
|
+
header: by default such a value raises, and with `extended=True` it is percent-encoded
|
|
175
|
+
as `name*=UTF-8''...` (names may not end in `*` then). Two parameters that would be
|
|
176
|
+
written under one name raise. The output is always one valid header field value.
|
|
177
|
+
- **Accept**: elements split on commas outside quoted strings, empty elements are
|
|
178
|
+
ignored, `q` must match the `qvalue` grammar (up to three decimals, at most 1) and may
|
|
179
|
+
appear anywhere among the parameters. In the default mode an invalid element is
|
|
180
|
+
skipped, including one whose `q` is malformed (`q =0`, `q=`), so a broken weight never
|
|
181
|
+
counts as 1. In strict mode only spaces and tabs may surround an element. A missing field (`None`) accepts everything; an empty one accepts nothing; a
|
|
182
|
+
weight of 0 means not acceptable. On equally specific ranges the earliest one counts.
|
|
183
|
+
- **RFC 2231**: sections start at 0 without gaps or leading zeros, encoded and plain
|
|
184
|
+
sections may mix, a character may span sections, and `name*` wins over `name`. The
|
|
185
|
+
language tag is dropped. Names compare in ASCII lower case. The charset must be a
|
|
186
|
+
charset name Python can decode as text; escape decoders, host code pages (`mbcs`,
|
|
187
|
+
`oem`), `idna` and `punycode` are refused.
|
|
188
|
+
|
|
189
|
+
## Why
|
|
190
|
+
|
|
191
|
+
Python 3.13 removed `cgi.parse_header` along with the rest of the `cgi` module. The
|
|
192
|
+
documented replacement, the `email.message.Message` machinery, is parse-oriented with no
|
|
193
|
+
clean builder and mishandles repeated parameters, so projects keep copying a parser by
|
|
194
|
+
hand. `multipart.parse_options_header` is scoped to form data and parse-only, and
|
|
195
|
+
`ietfparse` is a broad multi-RFC toolkit. `mediaparse` is a small, typed,
|
|
196
|
+
zero-dependency library that parses the way browsers do, validates the way the RFC says,
|
|
197
|
+
builds headers that cannot be split, and negotiates `Accept`.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
`python scripts/fetch_upstream.py` downloads the web-platform-tests MIME type files listed
|
|
202
|
+
in `tests/upstream/MANIFEST.json` (they are not part of the repository; their tests skip
|
|
203
|
+
without them). `scripts/check.ps1` runs format, lint, types and tests. Set
|
|
204
|
+
`MEDIAPARSE_E2E=1` to also build the wheel and test the installed package.
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT. See [LICENSE](LICENSE).
|
|
209
|
+
|
|
210
|
+
## Links
|
|
211
|
+
|
|
212
|
+
- Website: https://lianes8studio.com
|
|
213
|
+
- GitHub: https://github.com/JaviLianes8
|
|
214
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
mediaparse/__init__.py,sha256=85WfPSvUEnNJYsXE9uihyfEpTMCRR3Q6nRefcT_G8sA,658
|
|
2
|
+
mediaparse/_accept.py,sha256=zxw3IgCvb-lMMq1L9lkccrpYK5iCH8RnfJEOo8ze36o,6979
|
|
3
|
+
mediaparse/_build.py,sha256=23kV8UpNlRQqfl-whNyAE3dmcevfAvuQW5E8Kt3JVmo,3130
|
|
4
|
+
mediaparse/_errors.py,sha256=96_0LPb7JWlYa-YClHDKZUWHu1gRJlfRMZahaCmYeKI,774
|
|
5
|
+
mediaparse/_extended.py,sha256=3OuiTTAj1Kt-LN7NACLfF0aGTZFahZU5xDkO0nVVC20,1636
|
|
6
|
+
mediaparse/_lenient.py,sha256=-lIvkIT84j2Ob4_Sao0VpwiLyDEdXOCvfzVMx4oUy6c,3137
|
|
7
|
+
mediaparse/_mediatype.py,sha256=zvnVaY6QRhLMthaIn3Q0x-6EOs6zVIjI13JLsmwdBYs,3656
|
|
8
|
+
mediaparse/_normalize.py,sha256=0nM47LftsKMcerBfdXkSC8x1TkJzhJJDfCWfz1naI7I,1084
|
|
9
|
+
mediaparse/_parse.py,sha256=3AlhJIonqp74WVbsOduDlO7Ja3ELdr7OHo3BiiBCwvw,1226
|
|
10
|
+
mediaparse/_rfc2231.py,sha256=WAE1W61K-uSjdryuixwPhsJ9tUN6DyG5sqOLC35bHdM,4650
|
|
11
|
+
mediaparse/_strict.py,sha256=5P9LzW3djLvdcZsP2rSh86TZ4A8ggmN7p-vQpIRvhGk,4074
|
|
12
|
+
mediaparse/_tokens.py,sha256=8ipvBIfR8qmSFbGsMW84U1F1--PDTpFsaUHmipak0lM,1620
|
|
13
|
+
mediaparse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
mediaparse-0.2.0.dist-info/METADATA,sha256=aYz0HIXZjAZGTdfRKzn1VEB3htIr0KuqdXHqEhq03ic,9195
|
|
15
|
+
mediaparse-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
mediaparse-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
|
|
17
|
+
mediaparse-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|