httpquoted 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 (38) hide show
  1. httpquoted-0.2.0/.gitattributes +1 -0
  2. httpquoted-0.2.0/.gitignore +16 -0
  3. httpquoted-0.2.0/CHANGELOG.md +37 -0
  4. httpquoted-0.2.0/CONTRIBUTING.md +19 -0
  5. httpquoted-0.2.0/LICENSE +21 -0
  6. httpquoted-0.2.0/PKG-INFO +210 -0
  7. httpquoted-0.2.0/README.md +173 -0
  8. httpquoted-0.2.0/pyproject.toml +72 -0
  9. httpquoted-0.2.0/requirements-dev.txt +3 -0
  10. httpquoted-0.2.0/scripts/check.ps1 +16 -0
  11. httpquoted-0.2.0/scripts/fetch_upstream.py +44 -0
  12. httpquoted-0.2.0/scripts/release.ps1 +28 -0
  13. httpquoted-0.2.0/src/httpquoted/__init__.py +26 -0
  14. httpquoted-0.2.0/src/httpquoted/__main__.py +9 -0
  15. httpquoted-0.2.0/src/httpquoted/_chars.py +71 -0
  16. httpquoted-0.2.0/src/httpquoted/_cli.py +90 -0
  17. httpquoted-0.2.0/src/httpquoted/_comment.py +42 -0
  18. httpquoted-0.2.0/src/httpquoted/_quote.py +67 -0
  19. httpquoted-0.2.0/src/httpquoted/_scan.py +98 -0
  20. httpquoted-0.2.0/src/httpquoted/_split.py +80 -0
  21. httpquoted-0.2.0/src/httpquoted/_token.py +26 -0
  22. httpquoted-0.2.0/src/httpquoted/py.typed +0 -0
  23. httpquoted-0.2.0/tests/conftest.py +70 -0
  24. httpquoted-0.2.0/tests/test_bytes.py +64 -0
  25. httpquoted-0.2.0/tests/test_cli.py +78 -0
  26. httpquoted-0.2.0/tests/test_comment.py +53 -0
  27. httpquoted-0.2.0/tests/test_contract.py +55 -0
  28. httpquoted-0.2.0/tests/test_docs.py +94 -0
  29. httpquoted-0.2.0/tests/test_e2e_wheel.py +103 -0
  30. httpquoted-0.2.0/tests/test_hostile.py +68 -0
  31. httpquoted-0.2.0/tests/test_properties.py +116 -0
  32. httpquoted-0.2.0/tests/test_quote.py +127 -0
  33. httpquoted-0.2.0/tests/test_review_fixes.py +101 -0
  34. httpquoted-0.2.0/tests/test_scan.py +107 -0
  35. httpquoted-0.2.0/tests/test_sources.py +173 -0
  36. httpquoted-0.2.0/tests/test_split.py +109 -0
  37. httpquoted-0.2.0/tests/test_token.py +35 -0
  38. httpquoted-0.2.0/tests/upstream/MANIFEST.json +29 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -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 reference files, fetched locally by scripts/fetch_upstream.py; only the manifest is tracked.
15
+ tests/upstream/*
16
+ !tests/upstream/MANIFEST.json
@@ -0,0 +1,37 @@
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
+ - `QuotedStringError`, a `ValueError` with `reason` and `offset`.
14
+ - `strict=True` on `unquote` (exactly a token or a quoted-string) and `split_list`
15
+ (field content only, every quoted-string and comment well formed and closed).
16
+ - `scan_quoted_string` and `scan_comment`: read one quoted-string or (nested) comment
17
+ inside a larger value and return its value and end offset.
18
+ - `quote_comment` and `unquote_comment` for the comment syntax of section 5.6.5.
19
+ - `quote_if_needed` and `needs_quoting` for parameter values (section 5.6.6).
20
+ - `split_list` options: `separator` (";" for parameters, `str` or `bytes`) and `comments=True` for Via,
21
+ User-Agent and Server, where a comment may hold commas and a lone DQUOTE.
22
+ - `bytes` support everywhere: bytes in, bytes out, read as ISO-8859-1.
23
+ - `ascii_only=True` on the quoting functions to refuse obs-text.
24
+ - The `httpquoted` command (`quote`, `unquote`, `split`) and `python -m httpquoted`;
25
+ standard input and output are read and written as ISO-8859-1.
26
+
27
+ ### Changed
28
+
29
+ - `quote` raises `QuotedStringError` for CR, LF, NUL, the other controls, DEL and
30
+ characters above U+00FF instead of writing them into the header.
31
+ - Every function raises `TypeError` for input that is neither `str` nor `bytes`;
32
+ `is_token(None)` returned `False` before.
33
+
34
+ ## [0.1.0]
35
+
36
+ - First release: `quote`, `unquote`, `is_token` and `split_list` for the HTTP
37
+ quoted-string primitive (RFC 9110).
@@ -0,0 +1,19 @@
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/httpquoted/__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 `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
17
+ - One logical change per commit, imperative subject line.
18
+
19
+ 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,210 @@
1
+ Metadata-Version: 2.5
2
+ Name: httpquoted
3
+ Version: 0.2.0
4
+ Summary: Strict, typed and zero-dependency codec for HTTP quoted-strings, comments and tokens (RFC 9110): safe quoting, strict parsing, scanners and quote-aware list splitting
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/httpquoted
7
+ Project-URL: Issues, https://github.com/JaviLianes8/httpquoted/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/httpquoted/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: comment,field-value,header,http,list,parser,quote,quoted-string,rfc7230,rfc9110,token,unquote
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Environment :: Console
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Internet :: WWW/HTTP
27
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.10
30
+ Provides-Extra: dev
31
+ Requires-Dist: build; extra == 'dev'
32
+ Requires-Dist: mypy; extra == 'dev'
33
+ Requires-Dist: pytest; extra == 'dev'
34
+ Requires-Dist: ruff; extra == 'dev'
35
+ Requires-Dist: twine; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # httpquoted
39
+
40
+ Strict, typed and zero-dependency codec for the HTTP quoted-string, comment and token
41
+ primitives of RFC 9110 section 5.6: safe quoting, strict or lenient parsing, scanners for
42
+ hand-written parsers and list splitting that respects quotes and comments.
43
+
44
+ ## Install
45
+
46
+ ```
47
+ pip install httpquoted
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ Quote a value for a header and read it back. Only DQUOTE and backslash are escaped, as
53
+ section 5.6.4 asks of senders, and a value that is a token can travel bare:
54
+
55
+ ```python
56
+ from httpquoted import quote, quote_if_needed, unquote
57
+
58
+ print(quote('say "hi"'))
59
+ print(unquote('"say \\"hi\\""'))
60
+ print(quote_if_needed("utf-8"), quote_if_needed("two words"))
61
+ # "say \"hi\""
62
+ # say "hi"
63
+ # utf-8 "two words"
64
+ ```
65
+
66
+ Quoting refuses what a quoted-string cannot carry, so a CR LF never reaches a header.
67
+ Parsing is lenient by default; `strict=True` raises with the broken rule and its offset:
68
+
69
+ ```python
70
+ from httpquoted import QuotedStringError, quote, unquote
71
+
72
+ for call in (lambda: quote("a\r\nX-Injected: 1"), lambda: unquote('"a"b"', strict=True)):
73
+ try:
74
+ call()
75
+ except QuotedStringError as error:
76
+ print(error.reason, error.offset)
77
+ print(unquote('"a"b"'))
78
+ # U+000D is not allowed 1
79
+ # text after the closing quote 3
80
+ # a"b
81
+ ```
82
+
83
+ Split a list on commas outside quoted-strings, or parameters on semicolons. Fields
84
+ whose grammar has comments (Via, User-Agent, Server) pass `comments=True`:
85
+
86
+ ```python
87
+ from httpquoted import split_list, unquote
88
+
89
+ print(split_list('gzip, "a,b", , br'))
90
+ print(split_list('text/plain; charset="utf-8"', separator=";"))
91
+ print(split_list("1.0 fred (proxy, eu), 1.1 example.net", comments=True))
92
+ print([unquote(tag, strict=True) for tag in split_list('"xyzzy", "r2d2xxxx"')])
93
+ # ['gzip', '"a,b"', 'br']
94
+ # ['text/plain', 'charset="utf-8"']
95
+ # ['1.0 fred (proxy, eu)', '1.1 example.net']
96
+ # ['xyzzy', 'r2d2xxxx']
97
+ ```
98
+
99
+ Comments nest and have their own escaping. The scanners read one quoted-string or
100
+ comment inside a larger value and return where it ends:
101
+
102
+ ```python
103
+ from httpquoted import quote_comment, scan_comment, scan_quoted_string, unquote_comment
104
+
105
+ print(quote_comment("Windows NT 10.0; (x64)"))
106
+ print(unquote_comment("(a (nested) \\) comment)", strict=True))
107
+ text = 'title="a, \\"b\\""; lang=en'
108
+ value, end = scan_quoted_string(text, 6, strict=True)
109
+ print(value, text[end:])
110
+ print(scan_comment("Mozilla/5.0 (X11; Linux) Gecko", 12))
111
+ # (Windows NT 10.0; \(x64\))
112
+ # a (nested) ) comment
113
+ # a, "b" ; lang=en
114
+ # ('X11; Linux', 24)
115
+ ```
116
+
117
+ Every function takes `str` or `bytes` and returns the same type. Bytes are read as
118
+ ISO-8859-1, so the obs-text octets 0x80 to 0xFF round-trip unchanged:
119
+
120
+ ```python
121
+ from httpquoted import quote, split_list, unquote
122
+
123
+ print(quote(b"caf\xe9"))
124
+ print(unquote(b'"caf\xe9"', strict=True))
125
+ print(split_list(b'a, "b,c"'))
126
+ # b'"caf\xe9"'
127
+ # b'caf\xe9'
128
+ # [b'a', b'"b,c"']
129
+ ```
130
+
131
+ ## Command line
132
+
133
+ ```
134
+ $ httpquoted quote 'say "hi"'
135
+ "say \"hi\""
136
+ $ httpquoted unquote --strict '"a"b"'
137
+ httpquoted: text after the closing quote at offset 3
138
+ $ httpquoted split --comments '1.0 fred (proxy, eu), 1.1 example.net'
139
+ 1.0 fred (proxy, eu)
140
+ 1.1 example.net
141
+ ```
142
+
143
+ Values come from the arguments, or one per line from standard input. `quote` takes
144
+ `--comment` and `--ascii-only`; `unquote` takes `--comment` and `--strict`; `split`
145
+ takes `--separator`, `--comments` and `--strict`. Standard input and output are read
146
+ and written as ISO-8859-1, so octets pass through unchanged. A rejected value exits with
147
+ status 1.
148
+
149
+ ## API
150
+
151
+ | Name | What it does |
152
+ |---|---|
153
+ | `quote(value, *, ascii_only=False)` | Quoted-string with DQUOTE and backslash escaped; refuses controls, DEL and anything above U+00FF, and obs-text when `ascii_only` |
154
+ | `unquote(value, *, strict=False)` | Strips quotes and resolves quoted-pairs; a bare value comes back unchanged. Strict: exactly a token or a quoted-string |
155
+ | `quote_if_needed(value, *, ascii_only=False)` | A token unchanged, anything else quoted (parameter values, section 5.6.6) |
156
+ | `needs_quoting(value)` | True unless the value is a token |
157
+ | `is_token(value)` | True for a non-empty run of tchar |
158
+ | `quote_comment(value, *, ascii_only=False)` | Comment with "(", ")" and backslash escaped |
159
+ | `unquote_comment(value, *, strict=False)` | Strips the outer parentheses and resolves quoted-pairs; nested comments stay as text |
160
+ | `scan_quoted_string(text, start=0, *, strict=False)` | Value of the quoted-string at `start` and the offset after it |
161
+ | `scan_comment(text, start=0, *, strict=False)` | Value of the (nested) comment at `start` and the offset after it |
162
+ | `split_list(header, *, separator=",", comments=False, strict=False)` | Elements outside quotes (and comments), trimmed, empty ones dropped |
163
+ | `QuotedStringError` | `ValueError` with `reason` and `offset` |
164
+
165
+ Lenient mode never raises on malformed input. The scanners stop at the first closing
166
+ quote or parenthesis and let an unterminated value run to the end, while lenient
167
+ `unquote` and `unquote_comment` keep the 0.1.0 behaviour: they strip the first and last
168
+ character when those are the quotes (or parentheses) and resolve quoted-pairs in between.
169
+
170
+ Strict mode checks the token, quoted-string, quoted-pair and comment rules of RFC 9110
171
+ sections 5.6.2 to 5.6.5 character by character; the tests evaluate that grammar from the
172
+ fetched RFC and compare it with the functions for every code point up to U+010F. Strict
173
+ `split_list` checks field content and every quoted-string and comment it meets, not the
174
+ grammar of each element, which belongs to the field (`"a"b` is one element).
175
+
176
+ The value of a comment is display text: quoted-pairs are resolved and nested comments
177
+ keep their parentheses, so `\)` and a nesting `)` look alike. To parse it again, slice
178
+ the raw comment with the end offset that `scan_comment` returns.
179
+
180
+ obs-text includes U+0080 to U+009F, which some tools treat as line breaks (U+0085);
181
+ section 5.5 asks new fields to use ASCII, which `ascii_only=True` enforces. Every scan
182
+ is linear in the length of the input, including a megabyte of backslashes or nested
183
+ parentheses.
184
+
185
+ ## Why
186
+
187
+ The quoted-string codec of RFC 9110 sits under almost every HTTP header parser, yet no
188
+ small typed library ships it on its own: the stdlib `quopri` handles MIME
189
+ quoted-printable (a different thing), and the logic is vendored privately into
190
+ frameworks and SDKs, often without the checks that stop header injection. Other
191
+ ecosystems expose it directly (Ruby has `Protocol::HTTP::QuotedString`). This is that
192
+ primitive, zero-dependency and typed, for the times you parse or build a header by hand.
193
+
194
+ ## Development
195
+
196
+ `scripts/check.ps1` runs format, lint, `mypy --strict` and the tests. The RFC checks
197
+ read the RFCs from `tests/upstream/`, which is not committed: `python
198
+ scripts/fetch_upstream.py` downloads them and verifies the sha256 of each against
199
+ `tests/upstream/MANIFEST.json`; until then those tests skip. Set `HTTPQUOTED_E2E=1` to
200
+ build the wheel and drive it from a fresh virtual environment.
201
+
202
+ ## License
203
+
204
+ MIT. See [LICENSE](LICENSE).
205
+
206
+ ## Links
207
+
208
+ - Website: https://lianes8studio.com
209
+ - GitHub: https://github.com/JaviLianes8
210
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,173 @@
1
+ # httpquoted
2
+
3
+ Strict, typed and zero-dependency codec for the HTTP quoted-string, comment and token
4
+ primitives of RFC 9110 section 5.6: safe quoting, strict or lenient parsing, scanners for
5
+ hand-written parsers and list splitting that respects quotes and comments.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pip install httpquoted
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Quote a value for a header and read it back. Only DQUOTE and backslash are escaped, as
16
+ section 5.6.4 asks of senders, and a value that is a token can travel bare:
17
+
18
+ ```python
19
+ from httpquoted import quote, quote_if_needed, unquote
20
+
21
+ print(quote('say "hi"'))
22
+ print(unquote('"say \\"hi\\""'))
23
+ print(quote_if_needed("utf-8"), quote_if_needed("two words"))
24
+ # "say \"hi\""
25
+ # say "hi"
26
+ # utf-8 "two words"
27
+ ```
28
+
29
+ Quoting refuses what a quoted-string cannot carry, so a CR LF never reaches a header.
30
+ Parsing is lenient by default; `strict=True` raises with the broken rule and its offset:
31
+
32
+ ```python
33
+ from httpquoted import QuotedStringError, quote, unquote
34
+
35
+ for call in (lambda: quote("a\r\nX-Injected: 1"), lambda: unquote('"a"b"', strict=True)):
36
+ try:
37
+ call()
38
+ except QuotedStringError as error:
39
+ print(error.reason, error.offset)
40
+ print(unquote('"a"b"'))
41
+ # U+000D is not allowed 1
42
+ # text after the closing quote 3
43
+ # a"b
44
+ ```
45
+
46
+ Split a list on commas outside quoted-strings, or parameters on semicolons. Fields
47
+ whose grammar has comments (Via, User-Agent, Server) pass `comments=True`:
48
+
49
+ ```python
50
+ from httpquoted import split_list, unquote
51
+
52
+ print(split_list('gzip, "a,b", , br'))
53
+ print(split_list('text/plain; charset="utf-8"', separator=";"))
54
+ print(split_list("1.0 fred (proxy, eu), 1.1 example.net", comments=True))
55
+ print([unquote(tag, strict=True) for tag in split_list('"xyzzy", "r2d2xxxx"')])
56
+ # ['gzip', '"a,b"', 'br']
57
+ # ['text/plain', 'charset="utf-8"']
58
+ # ['1.0 fred (proxy, eu)', '1.1 example.net']
59
+ # ['xyzzy', 'r2d2xxxx']
60
+ ```
61
+
62
+ Comments nest and have their own escaping. The scanners read one quoted-string or
63
+ comment inside a larger value and return where it ends:
64
+
65
+ ```python
66
+ from httpquoted import quote_comment, scan_comment, scan_quoted_string, unquote_comment
67
+
68
+ print(quote_comment("Windows NT 10.0; (x64)"))
69
+ print(unquote_comment("(a (nested) \\) comment)", strict=True))
70
+ text = 'title="a, \\"b\\""; lang=en'
71
+ value, end = scan_quoted_string(text, 6, strict=True)
72
+ print(value, text[end:])
73
+ print(scan_comment("Mozilla/5.0 (X11; Linux) Gecko", 12))
74
+ # (Windows NT 10.0; \(x64\))
75
+ # a (nested) ) comment
76
+ # a, "b" ; lang=en
77
+ # ('X11; Linux', 24)
78
+ ```
79
+
80
+ Every function takes `str` or `bytes` and returns the same type. Bytes are read as
81
+ ISO-8859-1, so the obs-text octets 0x80 to 0xFF round-trip unchanged:
82
+
83
+ ```python
84
+ from httpquoted import quote, split_list, unquote
85
+
86
+ print(quote(b"caf\xe9"))
87
+ print(unquote(b'"caf\xe9"', strict=True))
88
+ print(split_list(b'a, "b,c"'))
89
+ # b'"caf\xe9"'
90
+ # b'caf\xe9'
91
+ # [b'a', b'"b,c"']
92
+ ```
93
+
94
+ ## Command line
95
+
96
+ ```
97
+ $ httpquoted quote 'say "hi"'
98
+ "say \"hi\""
99
+ $ httpquoted unquote --strict '"a"b"'
100
+ httpquoted: text after the closing quote at offset 3
101
+ $ httpquoted split --comments '1.0 fred (proxy, eu), 1.1 example.net'
102
+ 1.0 fred (proxy, eu)
103
+ 1.1 example.net
104
+ ```
105
+
106
+ Values come from the arguments, or one per line from standard input. `quote` takes
107
+ `--comment` and `--ascii-only`; `unquote` takes `--comment` and `--strict`; `split`
108
+ takes `--separator`, `--comments` and `--strict`. Standard input and output are read
109
+ and written as ISO-8859-1, so octets pass through unchanged. A rejected value exits with
110
+ status 1.
111
+
112
+ ## API
113
+
114
+ | Name | What it does |
115
+ |---|---|
116
+ | `quote(value, *, ascii_only=False)` | Quoted-string with DQUOTE and backslash escaped; refuses controls, DEL and anything above U+00FF, and obs-text when `ascii_only` |
117
+ | `unquote(value, *, strict=False)` | Strips quotes and resolves quoted-pairs; a bare value comes back unchanged. Strict: exactly a token or a quoted-string |
118
+ | `quote_if_needed(value, *, ascii_only=False)` | A token unchanged, anything else quoted (parameter values, section 5.6.6) |
119
+ | `needs_quoting(value)` | True unless the value is a token |
120
+ | `is_token(value)` | True for a non-empty run of tchar |
121
+ | `quote_comment(value, *, ascii_only=False)` | Comment with "(", ")" and backslash escaped |
122
+ | `unquote_comment(value, *, strict=False)` | Strips the outer parentheses and resolves quoted-pairs; nested comments stay as text |
123
+ | `scan_quoted_string(text, start=0, *, strict=False)` | Value of the quoted-string at `start` and the offset after it |
124
+ | `scan_comment(text, start=0, *, strict=False)` | Value of the (nested) comment at `start` and the offset after it |
125
+ | `split_list(header, *, separator=",", comments=False, strict=False)` | Elements outside quotes (and comments), trimmed, empty ones dropped |
126
+ | `QuotedStringError` | `ValueError` with `reason` and `offset` |
127
+
128
+ Lenient mode never raises on malformed input. The scanners stop at the first closing
129
+ quote or parenthesis and let an unterminated value run to the end, while lenient
130
+ `unquote` and `unquote_comment` keep the 0.1.0 behaviour: they strip the first and last
131
+ character when those are the quotes (or parentheses) and resolve quoted-pairs in between.
132
+
133
+ Strict mode checks the token, quoted-string, quoted-pair and comment rules of RFC 9110
134
+ sections 5.6.2 to 5.6.5 character by character; the tests evaluate that grammar from the
135
+ fetched RFC and compare it with the functions for every code point up to U+010F. Strict
136
+ `split_list` checks field content and every quoted-string and comment it meets, not the
137
+ grammar of each element, which belongs to the field (`"a"b` is one element).
138
+
139
+ The value of a comment is display text: quoted-pairs are resolved and nested comments
140
+ keep their parentheses, so `\)` and a nesting `)` look alike. To parse it again, slice
141
+ the raw comment with the end offset that `scan_comment` returns.
142
+
143
+ obs-text includes U+0080 to U+009F, which some tools treat as line breaks (U+0085);
144
+ section 5.5 asks new fields to use ASCII, which `ascii_only=True` enforces. Every scan
145
+ is linear in the length of the input, including a megabyte of backslashes or nested
146
+ parentheses.
147
+
148
+ ## Why
149
+
150
+ The quoted-string codec of RFC 9110 sits under almost every HTTP header parser, yet no
151
+ small typed library ships it on its own: the stdlib `quopri` handles MIME
152
+ quoted-printable (a different thing), and the logic is vendored privately into
153
+ frameworks and SDKs, often without the checks that stop header injection. Other
154
+ ecosystems expose it directly (Ruby has `Protocol::HTTP::QuotedString`). This is that
155
+ primitive, zero-dependency and typed, for the times you parse or build a header by hand.
156
+
157
+ ## Development
158
+
159
+ `scripts/check.ps1` runs format, lint, `mypy --strict` and the tests. The RFC checks
160
+ read the RFCs from `tests/upstream/`, which is not committed: `python
161
+ scripts/fetch_upstream.py` downloads them and verifies the sha256 of each against
162
+ `tests/upstream/MANIFEST.json`; until then those tests skip. Set `HTTPQUOTED_E2E=1` to
163
+ build the wheel and drive it from a fresh virtual environment.
164
+
165
+ ## License
166
+
167
+ MIT. See [LICENSE](LICENSE).
168
+
169
+ ## Links
170
+
171
+ - Website: https://lianes8studio.com
172
+ - GitHub: https://github.com/JaviLianes8
173
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,72 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "httpquoted"
7
+ version = "0.2.0"
8
+ description = "Strict, typed and zero-dependency codec for HTTP quoted-strings, comments and tokens (RFC 9110): safe quoting, strict parsing, scanners and quote-aware list splitting"
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 = ["http", "header", "quoted-string", "quote", "unquote", "comment", "token", "field-value", "list", "parser", "rfc9110", "rfc7230"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Operating System :: OS Independent",
26
+ "Environment :: Console",
27
+ "Topic :: Internet :: WWW/HTTP",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ "Typing :: Typed",
30
+ ]
31
+ dependencies = []
32
+
33
+ [project.optional-dependencies]
34
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
35
+
36
+ [project.scripts]
37
+ httpquoted = "httpquoted._cli:main"
38
+
39
+ [project.urls]
40
+ Homepage = "https://lianes8studio.com"
41
+ Repository = "https://github.com/JaviLianes8/httpquoted"
42
+ Issues = "https://github.com/JaviLianes8/httpquoted/issues"
43
+ Changelog = "https://github.com/JaviLianes8/httpquoted/blob/main/CHANGELOG.md"
44
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
45
+ Funding = "https://buymeacoffee.com/jlianesglrs"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/httpquoted"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ # Third-party reference files are fetched locally and never shipped; only their manifest is.
52
+ exclude = [
53
+ ".github",
54
+ "IMPROVEMENTS-round*.md",
55
+ "tests/upstream/*.txt",
56
+ ]
57
+
58
+ [tool.ruff]
59
+ line-length = 100
60
+ target-version = "py310"
61
+
62
+ [tool.ruff.lint]
63
+ select = ["E", "W", "F", "I", "UP", "B", "SIM", "RUF"]
64
+
65
+ [tool.mypy]
66
+ python_version = "3.10"
67
+ strict = true
68
+
69
+ [tool.pytest.ini_options]
70
+ testpaths = ["tests"]
71
+ pythonpath = ["src"]
72
+ 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 . }
13
+ Invoke-Step "ruff lint" { ruff check . }
14
+ Invoke-Step "mypy" { mypy --strict src tests scripts }
15
+ Invoke-Step "pytest" { pytest }
16
+ Write-Host "gate green"
@@ -0,0 +1,44 @@
1
+ """Fetch the third-party reference 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
+ try:
25
+ with urllib.request.urlopen(entry["url"], timeout=60) as response:
26
+ body = response.read()
27
+ except OSError as error:
28
+ print(f"{name}: cannot fetch {entry['url']}: {error}")
29
+ failed += 1
30
+ continue
31
+ digest = hashlib.sha256(body).hexdigest()
32
+ if len(body) != entry["size"] or digest != entry["sha256"]:
33
+ print(f"{name}: got {len(body)} bytes, sha256 {digest}; manifest differs")
34
+ failed += 1
35
+ continue
36
+ target = FOLDER / name
37
+ target.parent.mkdir(parents=True, exist_ok=True)
38
+ target.write_bytes(body)
39
+ print(f"{name}: ok")
40
+ return 1 if failed else 0
41
+
42
+
43
+ if __name__ == "__main__":
44
+ 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/httpquoted/json" | Out-Null
9
+ } catch {
10
+ if ($_.Exception.Response.StatusCode.value__ -eq 404) { $taken = $false } else { throw }
11
+ }
12
+ if ($taken) { throw "The name 'httpquoted' 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 }
@@ -0,0 +1,26 @@
1
+ """Typed codec for the HTTP quoted-string and comment primitives (RFC 9110)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._chars import QuotedStringError
6
+ from ._comment import quote_comment, unquote_comment
7
+ from ._quote import quote, quote_if_needed, unquote
8
+ from ._scan import scan_comment, scan_quoted_string
9
+ from ._split import split_list
10
+ from ._token import is_token, needs_quoting
11
+
12
+ __version__ = "0.2.0"
13
+
14
+ __all__ = [
15
+ "QuotedStringError",
16
+ "is_token",
17
+ "needs_quoting",
18
+ "quote",
19
+ "quote_comment",
20
+ "quote_if_needed",
21
+ "scan_comment",
22
+ "scan_quoted_string",
23
+ "split_list",
24
+ "unquote",
25
+ "unquote_comment",
26
+ ]