Skip to main content

httpquoted

Strict, typed and zero-dependency codec for the HTTP quoted-string, comment and token primitives of RFC 9110 section 5.6: safe quoting, strict or lenient parsing, scanners for hand-written parsers and list splitting that respects quotes and comments.

Install

pip install httpquoted

Usage

Quote a value for a header and read it back. Only DQUOTE and backslash are escaped, as section 5.6.4 asks of senders, and a value that is a token can travel bare:

from httpquoted import quote, quote_if_needed, unquote

print(quote('say "hi"'))
print(unquote('"say \\"hi\\""'))
print(quote_if_needed("utf-8"), quote_if_needed("two words"))
# "say \"hi\""
# say "hi"
# utf-8 "two words"

Quoting refuses what a quoted-string cannot carry, so a CR LF never reaches a header. Parsing is lenient by default; strict=True raises with the broken rule and its offset:

from httpquoted import QuotedStringError, quote, unquote

for call in (lambda: quote("a\r\nX-Injected: 1"), lambda: unquote('"a"b"', strict=True)):
    try:
        call()
    except QuotedStringError as error:
        print(error.reason, error.offset)
print(unquote('"a"b"'))
# U+000D is not allowed 1
# text after the closing quote 3
# a"b

Split a list on commas outside quoted-strings, or parameters on semicolons. Fields whose grammar has comments (Via, User-Agent, Server) pass comments=True:

from httpquoted import split_list, unquote

print(split_list('gzip, "a,b", , br'))
print(split_list('text/plain; charset="utf-8"', separator=";"))
print(split_list("1.0 fred (proxy, eu), 1.1 example.net", comments=True))
print([unquote(tag, strict=True) for tag in split_list('"xyzzy", "r2d2xxxx"')])
# ['gzip', '"a,b"', 'br']
# ['text/plain', 'charset="utf-8"']
# ['1.0 fred (proxy, eu)', '1.1 example.net']
# ['xyzzy', 'r2d2xxxx']

Comments nest and have their own escaping. The scanners read one quoted-string or comment inside a larger value and return where it ends:

from httpquoted import quote_comment, scan_comment, scan_quoted_string, unquote_comment

print(quote_comment("Windows NT 10.0; (x64)"))
print(unquote_comment("(a (nested) \\) comment)", strict=True))
text = 'title="a, \\"b\\""; lang=en'
value, end = scan_quoted_string(text, 6, strict=True)
print(value, text[end:])
print(scan_comment("Mozilla/5.0 (X11; Linux) Gecko", 12))
# (Windows NT 10.0; \(x64\))
# a (nested) ) comment
# a, "b" ; lang=en
# ('X11; Linux', 24)

Every function takes str or bytes and returns the same type. Bytes are read as ISO-8859-1, so the obs-text octets 0x80 to 0xFF round-trip unchanged:

from httpquoted import quote, split_list, unquote

print(quote(b"caf\xe9"))
print(unquote(b'"caf\xe9"', strict=True))
print(split_list(b'a, "b,c"'))
# b'"caf\xe9"'
# b'caf\xe9'
# [b'a', b'"b,c"']

Command line

$ httpquoted quote 'say "hi"'
"say \"hi\""
$ httpquoted unquote --strict '"a"b"'
httpquoted: text after the closing quote at offset 3
$ httpquoted split --comments '1.0 fred (proxy, eu), 1.1 example.net'
1.0 fred (proxy, eu)
1.1 example.net

Values come from the arguments, or one per line from standard input. quote takes --comment and --ascii-only; unquote takes --comment and --strict; split takes --separator, --comments and --strict. Standard input and output are read and written as ISO-8859-1, so octets pass through unchanged. A rejected value exits with status 1.

API

Name What it does
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
unquote(value, *, strict=False) Strips quotes and resolves quoted-pairs; a bare value comes back unchanged. Strict: exactly a token or a quoted-string
quote_if_needed(value, *, ascii_only=False) A token unchanged, anything else quoted (parameter values, section 5.6.6)
needs_quoting(value) True unless the value is a token
is_token(value) True for a non-empty run of tchar
quote_comment(value, *, ascii_only=False) Comment with "(", ")" and backslash escaped
unquote_comment(value, *, strict=False) Strips the outer parentheses and resolves quoted-pairs; nested comments stay as text
scan_quoted_string(text, start=0, *, strict=False) Value of the quoted-string at start and the offset after it
scan_comment(text, start=0, *, strict=False) Value of the (nested) comment at start and the offset after it
split_list(header, *, separator=",", comments=False, strict=False) Elements outside quotes (and comments), trimmed, empty ones dropped
QuotedStringError ValueError with reason and offset

Lenient mode never raises on malformed input. The scanners stop at the first closing quote or parenthesis and let an unterminated value run to the end, while lenient unquote and unquote_comment keep the 0.1.0 behaviour: they strip the first and last character when those are the quotes (or parentheses) and resolve quoted-pairs in between.

Strict mode checks the token, quoted-string, quoted-pair and comment rules of RFC 9110 sections 5.6.2 to 5.6.5 character by character; the tests evaluate that grammar from the fetched RFC and compare it with the functions for every code point up to U+010F. Strict split_list checks field content and every quoted-string and comment it meets, not the grammar of each element, which belongs to the field ("a"b is one element).

The value of a comment is display text: quoted-pairs are resolved and nested comments keep their parentheses, so \) and a nesting ) look alike. To parse it again, slice the raw comment with the end offset that scan_comment returns.

obs-text includes U+0080 to U+009F, which some tools treat as line breaks (U+0085); section 5.5 asks new fields to use ASCII, which ascii_only=True enforces. Every scan is linear in the length of the input, including a megabyte of backslashes or nested parentheses.

Why

The quoted-string codec of RFC 9110 sits under almost every HTTP header parser, yet no small typed library ships it on its own: the stdlib quopri handles MIME quoted-printable (a different thing), and the logic is vendored privately into frameworks and SDKs, often without the checks that stop header injection. Other ecosystems expose it directly (Ruby has Protocol::HTTP::QuotedString). This is that primitive, zero-dependency and typed, for the times you parse or build a header by hand.

Development

scripts/check.ps1 runs format, lint, mypy --strict and the tests. The RFC checks read the RFCs from tests/upstream/, which is not committed: python scripts/fetch_upstream.py downloads them and verifies the sha256 of each against tests/upstream/MANIFEST.json; until then those tests skip. Set HTTPQUOTED_E2E=1 to build the wheel and drive it from a fresh virtual environment.

License

MIT. See LICENSE.

Metadata

Release files for httpquoted 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for httpquoted 0.2.0
File Size Uploaded
httpquoted-0.2.0.tar.gz 27.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for httpquoted 0.2.0
File Interpreter ABI Platform
httpquoted-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 42.5 kB

Release files / httpquoted-0.2.0.tar.gz

Download URL httpquoted-0.2.0.tar.gz
Size 27.4 kB
Tags Source
SHA-256 checksum
How to use checksums
2b6563746911b2e4283262041db43083969df50f49317418cf96b429d5a59cdc
BLAKE2b-256 checksum
How to use checksums
6292dd58359bc4bc1a909a1af6463df80ee4fcef8f75f1f4f34e0868fdfdc81d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.2

Release files / httpquoted-0.2.0-py3-none-any.whl

Download URL httpquoted-0.2.0-py3-none-any.whl
Size 15.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
bb45a588a0b21fa269f1d7c47d99ed8c228512717a5da438b41455accf6df2ca
BLAKE2b-256 checksum
How to use checksums
828ea7864901aa00aa05df6d37fa1d9c8dc22d92542ce39f4ba56531f9bfdbf8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.2

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page