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 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] + "..."
@@ -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
@@ -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__}")
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.