mediaparse 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. mediaparse-0.2.0/.gitattributes +1 -0
  2. mediaparse-0.2.0/.github/FUNDING.yml +1 -0
  3. mediaparse-0.2.0/.gitignore +16 -0
  4. mediaparse-0.2.0/CHANGELOG.md +49 -0
  5. mediaparse-0.2.0/CONTRIBUTING.md +23 -0
  6. mediaparse-0.2.0/LICENSE +21 -0
  7. mediaparse-0.2.0/PKG-INFO +214 -0
  8. mediaparse-0.2.0/README.md +178 -0
  9. mediaparse-0.2.0/pyproject.toml +64 -0
  10. mediaparse-0.2.0/requirements-dev.txt +3 -0
  11. mediaparse-0.2.0/scripts/check.ps1 +16 -0
  12. mediaparse-0.2.0/scripts/fetch_upstream.py +37 -0
  13. mediaparse-0.2.0/scripts/release.ps1 +28 -0
  14. mediaparse-0.2.0/src/mediaparse/__init__.py +28 -0
  15. mediaparse-0.2.0/src/mediaparse/_accept.py +175 -0
  16. mediaparse-0.2.0/src/mediaparse/_build.py +69 -0
  17. mediaparse-0.2.0/src/mediaparse/_errors.py +21 -0
  18. mediaparse-0.2.0/src/mediaparse/_extended.py +41 -0
  19. mediaparse-0.2.0/src/mediaparse/_lenient.py +83 -0
  20. mediaparse-0.2.0/src/mediaparse/_mediatype.py +86 -0
  21. mediaparse-0.2.0/src/mediaparse/_normalize.py +26 -0
  22. mediaparse-0.2.0/src/mediaparse/_parse.py +27 -0
  23. mediaparse-0.2.0/src/mediaparse/_rfc2231.py +102 -0
  24. mediaparse-0.2.0/src/mediaparse/_strict.py +115 -0
  25. mediaparse-0.2.0/src/mediaparse/_tokens.py +44 -0
  26. mediaparse-0.2.0/src/mediaparse/py.typed +0 -0
  27. mediaparse-0.2.0/tests/conftest.py +25 -0
  28. mediaparse-0.2.0/tests/test_accept.py +183 -0
  29. mediaparse-0.2.0/tests/test_build.py +109 -0
  30. mediaparse-0.2.0/tests/test_contract.py +41 -0
  31. mediaparse-0.2.0/tests/test_docs.py +85 -0
  32. mediaparse-0.2.0/tests/test_e2e_wheel.py +95 -0
  33. mediaparse-0.2.0/tests/test_mediatype.py +151 -0
  34. mediaparse-0.2.0/tests/test_normalize.py +52 -0
  35. mediaparse-0.2.0/tests/test_parse.py +129 -0
  36. mediaparse-0.2.0/tests/test_review_regressions.py +132 -0
  37. mediaparse-0.2.0/tests/test_rfc2231.py +100 -0
  38. mediaparse-0.2.0/tests/test_strict.py +93 -0
  39. mediaparse-0.2.0/tests/test_typing.py +76 -0
  40. mediaparse-0.2.0/tests/test_wpt.py +51 -0
  41. mediaparse-0.2.0/tests/upstream/MANIFEST.json +20 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1 @@
1
+ buy_me_a_coffee: jlianesglrs
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Third-party test files, fetched locally by scripts/fetch_upstream.py; only the manifest is tracked.
15
+ tests/upstream/*
16
+ !tests/upstream/MANIFEST.json
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0]
10
+
11
+ ### Added
12
+
13
+ - `parse_media_type` and the frozen `MediaType` value: `essence`, the RFC 6838 `suffix`,
14
+ ordered `params`, `parameters`, `get()`, and `str()` with the WHATWG serialization.
15
+ - `strict=True` on `parse`, `parse_media_type` and `normalize`: the RFC 9110 grammar
16
+ exactly, a repeated parameter is an error, and `MediaTypeError.position` points at the
17
+ first bad character.
18
+ - `parse_accept`, `AcceptItem`, `quality` and `negotiate` for the Accept header (RFC 9110
19
+ sections 12.4.2 and 12.5.1), checked against the RFC examples.
20
+ - `build(..., extended=True)` writes non-ASCII values as RFC 8187 / RFC 2231 extended
21
+ values, and `decode_extended` joins RFC 2231 continuations and decodes them.
22
+ - Tests against the web-platform-tests MIME type corpus, fetched locally by
23
+ `scripts/fetch_upstream.py`, plus executed README examples, a typing check and an
24
+ installed-wheel test.
25
+
26
+ ### Changed
27
+
28
+ - The default parser is the WHATWG MIME Sniffing algorithm: the first of a repeated
29
+ parameter wins (it was the last), a parameter with whitespace around `=` or a value
30
+ outside the quoted-string range is skipped, and HTTP whitespace (including CR and LF)
31
+ is trimmed.
32
+ - Names are lower-cased in ASCII only.
33
+
34
+ ### Fixed
35
+
36
+ - `build` no longer writes control characters such as CR and LF into a quoted value,
37
+ which let a parameter value split the header, nor characters a header cannot carry.
38
+ A duplicate name after lower-casing is refused instead of written twice.
39
+ - Error messages show at most 80 characters of the input.
40
+
41
+ ## [0.1.0]
42
+
43
+ - First release.
44
+ - `parse` splits a Content-Type style header into a media type and its
45
+ parameters, unescaping quoted values and tolerating malformed input.
46
+ - `build` renders a media type and parameters back, quoting only the values that
47
+ a token may not hold.
48
+ - `normalize` and `matches` canonicalize and compare headers.
49
+ - Fills the gap left by `cgi.parse_header`, removed in Python 3.13.
@@ -0,0 +1,23 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time. This project keeps a small, predictable shape.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -r requirements-dev.txt
9
+ ```
10
+
11
+ ## Before you open a pull request
12
+
13
+ - Keep public API in `src/mediaparse/__init__.py`; internals live in `_`-prefixed modules.
14
+ - Add a test for the behaviour you change. Tests inject time, disk and network; they
15
+ never touch the real thing.
16
+ - Run `python scripts/fetch_upstream.py` once: it downloads the web-platform-tests MIME
17
+ type files listed in `tests/upstream/MANIFEST.json`, which are not committed. Their
18
+ tests skip without them.
19
+ - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes. Set
20
+ `MEDIAPARSE_E2E=1` to also build the wheel and test the installed package.
21
+ - One logical change per commit, imperative subject line.
22
+
23
+ Open an issue first if the change is large or shifts the public API.
@@ -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.
@@ -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,178 @@
1
+ # mediaparse
2
+
3
+ Parse and build HTTP media types, `Content-Type` and `Accept` headers, exactly.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ pip install mediaparse
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from mediaparse import build, matches, normalize, parse, parse_media_type
15
+
16
+ # Read a Content-Type value the way a browser does.
17
+ print(parse("text/html; charset=utf-8"))
18
+ # ('text/html', {'charset': 'utf-8'})
19
+
20
+ # Quoted values keep separators; text after the closing quote is skipped.
21
+ print(parse('multipart/form-data; boundary="a; b=c"'))
22
+ # ('multipart/form-data', {'boundary': 'a; b=c'})
23
+
24
+ # A typed value with the essence, the +suffix and the browser serialization.
25
+ media = parse_media_type("Application/Vnd.API+JSON; Charset=UTF-8")
26
+ print(media.essence, media.suffix, media.get("charset"))
27
+ # application/vnd.api+json json UTF-8
28
+ print(media)
29
+ # application/vnd.api+json;charset=UTF-8
30
+
31
+ # Build a header, quoting only the values that need it.
32
+ print(build("application/json", {"title": "a; b"}))
33
+ # application/json; title="a; b"
34
+
35
+ # Canonical form for display or comparison.
36
+ print(normalize("TEXT/HTML; CharSet=UTF-8"))
37
+ # text/html; charset=utf-8
38
+ print(matches("Text/HTML; charset=UTF-8", "text/html;charset=utf-8"))
39
+ # True
40
+ ```
41
+
42
+ ### Lenient or strict
43
+
44
+ ```python
45
+ from mediaparse import MediaTypeError, parse
46
+
47
+ # Default: the WHATWG algorithm. The first of a repeated parameter wins and a parameter
48
+ # with whitespace before "=" is skipped, exactly as browsers do.
49
+ print(parse("text/plain; charset=ascii; charset=utf-8; a =1"))
50
+ # ('text/plain', {'charset': 'ascii'})
51
+
52
+ # strict=True: the RFC 9110 grammar, with the position of the first bad character.
53
+ try:
54
+ parse("text/plain; a =1", strict=True)
55
+ except MediaTypeError as error:
56
+ print(error.position)
57
+ # 13
58
+ ```
59
+
60
+ ### Building safely
61
+
62
+ ```python
63
+ from mediaparse import MediaTypeError, build
64
+
65
+ # No value can split the header: control characters are refused.
66
+ try:
67
+ build("text/plain", {"name": "x\r\nSet-Cookie: a=b"})
68
+ except MediaTypeError:
69
+ print("refused")
70
+ # refused
71
+
72
+ # Non-ASCII values travel as RFC 8187 / RFC 2231 extended values on request.
73
+ print(build("text/plain", {"title": "caf\xe9"}, extended=True))
74
+ # text/plain; title*=UTF-8''caf%C3%A9
75
+ ```
76
+
77
+ ### Accept and negotiation
78
+
79
+ ```python
80
+ from mediaparse import negotiate, parse_accept, quality
81
+
82
+ accept = "text/*;q=0.3, text/plain;q=0.7, text/plain;format=flowed, */*;q=0.5"
83
+
84
+ # The weight comes from the most specific range that covers the offer.
85
+ print(quality(accept, "text/plain;format=flowed"), quality(accept, "text/html"))
86
+ # 1.0 0.3
87
+
88
+ # Pick the best offer; ties go to the order you list them in.
89
+ print(negotiate(accept, ["image/png", "text/html"]))
90
+ # image/png
91
+
92
+ print([(str(item.media_range), item.q) for item in parse_accept("text/html, */*;q=0.1")])
93
+ # [('text/html', 1.0), ('*/*', 0.1)]
94
+ ```
95
+
96
+ ### RFC 2231 parameters
97
+
98
+ ```python
99
+ from mediaparse import decode_extended, parse
100
+
101
+ header = "application/x-stuff; title*0*=utf-8''r%C3%A9sum; title*1=\"e.pdf\""
102
+ print(decode_extended(parse(header)[1]))
103
+ # {'title': 'résume.pdf'}
104
+ ```
105
+
106
+ ## API
107
+
108
+ | Name | What it does |
109
+ | --- | --- |
110
+ | `parse(header, *, strict=False)` | `(essence, params)` tuple. |
111
+ | `parse_media_type(text, *, strict=False)` | A `MediaType`; raises `MediaTypeError` when there is no valid `type/subtype`. |
112
+ | `MediaType` | Frozen value: `type`, `subtype`, `params` pairs, `essence`, `suffix`, `parameters`, `get()`, `str()`. |
113
+ | `build(mimetype, params=None, *, extended=False)` | Header value with `"; "` between parts and minimal quoting. |
114
+ | `normalize(header, *, strict=False)` | Lower-cased names, sorted parameters, lower-cased `charset`. |
115
+ | `matches(first, second)` | Whether two headers normalize to the same text. |
116
+ | `parse_accept(header, *, strict=False)` | List of `AcceptItem` in header order. |
117
+ | `AcceptItem` | `media_range` (a `MediaType`), `q` (0 to 1), `specificity()`, `matches(media)`. |
118
+ | `quality(accept, offer)` | Weight of an offer under an Accept header; `None` means no header. |
119
+ | `negotiate(accept, offers)` | Best acceptable offer, returned as given, or `None`. |
120
+ | `decode_extended(params)` | Joins RFC 2231 sections and decodes `charset'lang'` values. |
121
+ | `MediaTypeError` | `ValueError` subclass with `position` (or `None`). Wrong argument types raise `TypeError`. |
122
+
123
+ ## Rules it follows
124
+
125
+ - **Default parsing** is the WHATWG MIME Sniffing "parse a MIME type" algorithm, checked
126
+ against the web-platform-tests MIME type corpus. Whitespace (tab, LF, CR, space) is
127
+ trimmed, a parameter whose name is not a token or whose value has a character above
128
+ U+00FF or a control character is skipped, empty values are skipped and the first
129
+ duplicate wins. `str(MediaType)` is the WHATWG serialization.
130
+ - **Strict parsing** is the RFC 9110 `media-type` grammar: no whitespace around `=`,
131
+ values are tokens or quoted strings with `qdtext` and `quoted-pair` only, and a repeated
132
+ parameter is an error (RFC 6838 section 4.3). Leading and trailing spaces or tabs are
133
+ allowed because a field parser removes them.
134
+ - **Names** are lower-cased in ASCII only, so a character such as the Kelvin sign never
135
+ turns into a token. Values keep their case; `normalize` and `quality` treat `charset`
136
+ as case insensitive.
137
+ - **Building** never writes a control character or a non-ASCII character into the
138
+ header: by default such a value raises, and with `extended=True` it is percent-encoded
139
+ as `name*=UTF-8''...` (names may not end in `*` then). Two parameters that would be
140
+ written under one name raise. The output is always one valid header field value.
141
+ - **Accept**: elements split on commas outside quoted strings, empty elements are
142
+ ignored, `q` must match the `qvalue` grammar (up to three decimals, at most 1) and may
143
+ appear anywhere among the parameters. In the default mode an invalid element is
144
+ skipped, including one whose `q` is malformed (`q =0`, `q=`), so a broken weight never
145
+ 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
146
+ weight of 0 means not acceptable. On equally specific ranges the earliest one counts.
147
+ - **RFC 2231**: sections start at 0 without gaps or leading zeros, encoded and plain
148
+ sections may mix, a character may span sections, and `name*` wins over `name`. The
149
+ language tag is dropped. Names compare in ASCII lower case. The charset must be a
150
+ charset name Python can decode as text; escape decoders, host code pages (`mbcs`,
151
+ `oem`), `idna` and `punycode` are refused.
152
+
153
+ ## Why
154
+
155
+ Python 3.13 removed `cgi.parse_header` along with the rest of the `cgi` module. The
156
+ documented replacement, the `email.message.Message` machinery, is parse-oriented with no
157
+ clean builder and mishandles repeated parameters, so projects keep copying a parser by
158
+ hand. `multipart.parse_options_header` is scoped to form data and parse-only, and
159
+ `ietfparse` is a broad multi-RFC toolkit. `mediaparse` is a small, typed,
160
+ zero-dependency library that parses the way browsers do, validates the way the RFC says,
161
+ builds headers that cannot be split, and negotiates `Accept`.
162
+
163
+ ## Development
164
+
165
+ `python scripts/fetch_upstream.py` downloads the web-platform-tests MIME type files listed
166
+ in `tests/upstream/MANIFEST.json` (they are not part of the repository; their tests skip
167
+ without them). `scripts/check.ps1` runs format, lint, types and tests. Set
168
+ `MEDIAPARSE_E2E=1` to also build the wheel and test the installed package.
169
+
170
+ ## License
171
+
172
+ MIT. See [LICENSE](LICENSE).
173
+
174
+ ## Links
175
+
176
+ - Website: https://lianes8studio.com
177
+ - GitHub: https://github.com/JaviLianes8
178
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mediaparse"
7
+ version = "0.2.0"
8
+ description = "Parse and build HTTP media types, Content-Type and Accept headers, exactly"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = ["mime", "media-type", "content-type", "accept", "http", "header", "rfc9110", "whatwg", "rfc2231", "content-negotiation", "cgi"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Internet :: WWW/HTTP",
27
+ "Topic :: Software Development :: Libraries",
28
+ "Typing :: Typed",
29
+ ]
30
+ dependencies = []
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://lianes8studio.com"
37
+ Repository = "https://github.com/JaviLianes8/mediaparse"
38
+ Issues = "https://github.com/JaviLianes8/mediaparse/issues"
39
+ Changelog = "https://github.com/JaviLianes8/mediaparse/blob/main/CHANGELOG.md"
40
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
41
+ Funding = "https://buymeacoffee.com/jlianesglrs"
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ # Third-party test files are fetched locally and never shipped; only their manifest is.
45
+ exclude = ["IMPROVEMENTS-round*.md", "tests/upstream/wpt-*.json"]
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/mediaparse"]
49
+
50
+ [tool.ruff]
51
+ line-length = 100
52
+ target-version = "py310"
53
+
54
+ [tool.ruff.lint]
55
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
56
+
57
+ [tool.mypy]
58
+ python_version = "3.10"
59
+ strict = true
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+ pythonpath = ["src"]
64
+ addopts = "-q"
@@ -0,0 +1,3 @@
1
+ # Development dependencies: the package in editable mode plus its dev extras
2
+ # (ruff, mypy, pytest, build, twine). Install with: pip install -r requirements-dev.txt
3
+ -e .[dev]
@@ -0,0 +1,16 @@
1
+ # Local quality gate. Run before every commit. No CI runs in the cloud.
2
+ # $ErrorActionPreference does NOT stop on a native command's non-zero exit, so each tool's
3
+ # exit code is checked explicitly: a red gate must fail, never pass silently.
4
+ $ErrorActionPreference = "Stop"
5
+
6
+ function Invoke-Step($label, [scriptblock]$cmd) {
7
+ Write-Host "== $label =="
8
+ & $cmd
9
+ if ($LASTEXITCODE -ne 0) { Write-Host "FAILED: $label (exit $LASTEXITCODE)"; exit $LASTEXITCODE }
10
+ }
11
+
12
+ Invoke-Step "ruff format" { ruff format --check src tests }
13
+ Invoke-Step "ruff lint" { ruff check src tests }
14
+ Invoke-Step "mypy" { mypy src tests }
15
+ Invoke-Step "pytest" { pytest }
16
+ Write-Host "gate green"
@@ -0,0 +1,37 @@
1
+ """Fetch the third-party test files listed in tests/upstream/MANIFEST.json.
2
+
3
+ The files are not part of the repository. Their tests skip until this script has fetched
4
+ them; each download is checked against the size and sha256 in the manifest.
5
+
6
+ python scripts/fetch_upstream.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import sys
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ FOLDER = Path(__file__).resolve().parents[1] / "tests" / "upstream"
18
+
19
+
20
+ def main() -> int:
21
+ manifest = json.loads((FOLDER / "MANIFEST.json").read_text(encoding="utf-8"))
22
+ failed = 0
23
+ for name, entry in manifest.items():
24
+ with urllib.request.urlopen(entry["url"], timeout=60) as response:
25
+ body = response.read()
26
+ digest = hashlib.sha256(body).hexdigest()
27
+ if len(body) != entry["size"] or digest != entry["sha256"]:
28
+ print(f"{name}: got {len(body)} bytes, sha256 {digest}; manifest differs")
29
+ failed += 1
30
+ continue
31
+ (FOLDER / name).write_bytes(body)
32
+ print(f"{name}: ok")
33
+ return 1 if failed else 0
34
+
35
+
36
+ if __name__ == "__main__":
37
+ sys.exit(main())
@@ -0,0 +1,28 @@
1
+ # Build and upload to PyPI. Run scripts/check.ps1 first.
2
+ # The name must not already exist on PyPI; the token is read from a secret store, never git.
3
+ $ErrorActionPreference = "Stop"
4
+
5
+ # Works on both Windows PowerShell 5.1 and pwsh 7 (generic catch, no PS7-only types).
6
+ $taken = $true
7
+ try {
8
+ Invoke-WebRequest -UseBasicParsing "https://pypi.org/pypi/mediaparse/json" | Out-Null
9
+ } catch {
10
+ if ($_.Exception.Response.StatusCode.value__ -eq 404) { $taken = $false } else { throw }
11
+ }
12
+ if ($taken) { throw "The name 'mediaparse' already exists on PyPI. Choose another before publishing." }
13
+
14
+ $token = $env:PYPI_TOKEN
15
+ if (-not $token -and (Test-Path "C:\secrets\pypi-token.txt")) {
16
+ $token = (Get-Content "C:\secrets\pypi-token.txt" -Raw).Trim()
17
+ }
18
+ if (-not $token) {
19
+ throw "No PyPI token. Set `$env:PYPI_TOKEN or save it to C:\secrets\pypi-token.txt (never commit it)."
20
+ }
21
+
22
+ Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue
23
+ python -m build
24
+ python -m twine check dist/*
25
+
26
+ $env:TWINE_USERNAME = "__token__"
27
+ $env:TWINE_PASSWORD = $token
28
+ try { python -m twine upload dist/* } finally { Remove-Item Env:\TWINE_PASSWORD -ErrorAction SilentlyContinue }