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.
- mediaparse-0.2.0/.gitattributes +1 -0
- mediaparse-0.2.0/.github/FUNDING.yml +1 -0
- mediaparse-0.2.0/.gitignore +16 -0
- mediaparse-0.2.0/CHANGELOG.md +49 -0
- mediaparse-0.2.0/CONTRIBUTING.md +23 -0
- mediaparse-0.2.0/LICENSE +21 -0
- mediaparse-0.2.0/PKG-INFO +214 -0
- mediaparse-0.2.0/README.md +178 -0
- mediaparse-0.2.0/pyproject.toml +64 -0
- mediaparse-0.2.0/requirements-dev.txt +3 -0
- mediaparse-0.2.0/scripts/check.ps1 +16 -0
- mediaparse-0.2.0/scripts/fetch_upstream.py +37 -0
- mediaparse-0.2.0/scripts/release.ps1 +28 -0
- mediaparse-0.2.0/src/mediaparse/__init__.py +28 -0
- mediaparse-0.2.0/src/mediaparse/_accept.py +175 -0
- mediaparse-0.2.0/src/mediaparse/_build.py +69 -0
- mediaparse-0.2.0/src/mediaparse/_errors.py +21 -0
- mediaparse-0.2.0/src/mediaparse/_extended.py +41 -0
- mediaparse-0.2.0/src/mediaparse/_lenient.py +83 -0
- mediaparse-0.2.0/src/mediaparse/_mediatype.py +86 -0
- mediaparse-0.2.0/src/mediaparse/_normalize.py +26 -0
- mediaparse-0.2.0/src/mediaparse/_parse.py +27 -0
- mediaparse-0.2.0/src/mediaparse/_rfc2231.py +102 -0
- mediaparse-0.2.0/src/mediaparse/_strict.py +115 -0
- mediaparse-0.2.0/src/mediaparse/_tokens.py +44 -0
- mediaparse-0.2.0/src/mediaparse/py.typed +0 -0
- mediaparse-0.2.0/tests/conftest.py +25 -0
- mediaparse-0.2.0/tests/test_accept.py +183 -0
- mediaparse-0.2.0/tests/test_build.py +109 -0
- mediaparse-0.2.0/tests/test_contract.py +41 -0
- mediaparse-0.2.0/tests/test_docs.py +85 -0
- mediaparse-0.2.0/tests/test_e2e_wheel.py +95 -0
- mediaparse-0.2.0/tests/test_mediatype.py +151 -0
- mediaparse-0.2.0/tests/test_normalize.py +52 -0
- mediaparse-0.2.0/tests/test_parse.py +129 -0
- mediaparse-0.2.0/tests/test_review_regressions.py +132 -0
- mediaparse-0.2.0/tests/test_rfc2231.py +100 -0
- mediaparse-0.2.0/tests/test_strict.py +93 -0
- mediaparse-0.2.0/tests/test_typing.py +76 -0
- mediaparse-0.2.0/tests/test_wpt.py +51 -0
- 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.
|
mediaparse-0.2.0/LICENSE
ADDED
|
@@ -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,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 }
|