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.
- httpquoted-0.2.0/.gitattributes +1 -0
- httpquoted-0.2.0/.gitignore +16 -0
- httpquoted-0.2.0/CHANGELOG.md +37 -0
- httpquoted-0.2.0/CONTRIBUTING.md +19 -0
- httpquoted-0.2.0/LICENSE +21 -0
- httpquoted-0.2.0/PKG-INFO +210 -0
- httpquoted-0.2.0/README.md +173 -0
- httpquoted-0.2.0/pyproject.toml +72 -0
- httpquoted-0.2.0/requirements-dev.txt +3 -0
- httpquoted-0.2.0/scripts/check.ps1 +16 -0
- httpquoted-0.2.0/scripts/fetch_upstream.py +44 -0
- httpquoted-0.2.0/scripts/release.ps1 +28 -0
- httpquoted-0.2.0/src/httpquoted/__init__.py +26 -0
- httpquoted-0.2.0/src/httpquoted/__main__.py +9 -0
- httpquoted-0.2.0/src/httpquoted/_chars.py +71 -0
- httpquoted-0.2.0/src/httpquoted/_cli.py +90 -0
- httpquoted-0.2.0/src/httpquoted/_comment.py +42 -0
- httpquoted-0.2.0/src/httpquoted/_quote.py +67 -0
- httpquoted-0.2.0/src/httpquoted/_scan.py +98 -0
- httpquoted-0.2.0/src/httpquoted/_split.py +80 -0
- httpquoted-0.2.0/src/httpquoted/_token.py +26 -0
- httpquoted-0.2.0/src/httpquoted/py.typed +0 -0
- httpquoted-0.2.0/tests/conftest.py +70 -0
- httpquoted-0.2.0/tests/test_bytes.py +64 -0
- httpquoted-0.2.0/tests/test_cli.py +78 -0
- httpquoted-0.2.0/tests/test_comment.py +53 -0
- httpquoted-0.2.0/tests/test_contract.py +55 -0
- httpquoted-0.2.0/tests/test_docs.py +94 -0
- httpquoted-0.2.0/tests/test_e2e_wheel.py +103 -0
- httpquoted-0.2.0/tests/test_hostile.py +68 -0
- httpquoted-0.2.0/tests/test_properties.py +116 -0
- httpquoted-0.2.0/tests/test_quote.py +127 -0
- httpquoted-0.2.0/tests/test_review_fixes.py +101 -0
- httpquoted-0.2.0/tests/test_scan.py +107 -0
- httpquoted-0.2.0/tests/test_sources.py +173 -0
- httpquoted-0.2.0/tests/test_split.py +109 -0
- httpquoted-0.2.0/tests/test_token.py +35 -0
- 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.
|
httpquoted-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,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,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
|
+
]
|