httpquoted 0.2.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
httpquoted/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Run the command line with `python -m httpquoted`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ sys.exit(main())
httpquoted/_chars.py ADDED
@@ -0,0 +1,71 @@
1
+ """Character classes of RFC 9110 section 5.6, the error type and the bytes bridge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import string
7
+ from typing import AnyStr
8
+
9
+ # tchar: any VCHAR except the delimiters DQUOTE and "(),/:;<=>?@[\]{}" (section 5.6.2).
10
+ TCHAR = frozenset(string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~")
11
+ VCHAR = frozenset(chr(code) for code in range(0x21, 0x7F))
12
+ OBS_TEXT = frozenset(chr(code) for code in range(0x80, 0x100))
13
+ # What may follow a backslash, and what a sender may put between the quotes once DQUOTE
14
+ # and backslash are escaped: HTAB, SP, VCHAR and obs-text.
15
+ FIELD_TEXT = frozenset("\t ") | VCHAR | OBS_TEXT
16
+ QDTEXT = FIELD_TEXT - frozenset('"\\')
17
+ CTEXT = FIELD_TEXT - frozenset("()\\")
18
+
19
+ # Runs of plain text. A single character class with `*` never backtracks, so every scan
20
+ # stays linear however hostile the input is.
21
+ QDTEXT_RUN = re.compile(r"[\t\x20\x21\x23-\x5b\x5d-\x7e\x80-\xff]*")
22
+ CTEXT_RUN = re.compile(r"[\t\x20-\x27\x2a-\x5b\x5d-\x7e\x80-\xff]*")
23
+ LOOSE_QUOTED_RUN = re.compile(r'[^"\\]*')
24
+ LOOSE_COMMENT_RUN = re.compile(r"[^()\\]*")
25
+ TOKEN_RE = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
26
+ NOT_FIELD_TEXT = re.compile(r"[^\t\x20-\x7e\x80-\xff]")
27
+ NOT_ASCII_TEXT = re.compile(r"[^\t\x20-\x7e]")
28
+
29
+
30
+ class QuotedStringError(ValueError):
31
+ """A value breaks the quoted-string, comment or token grammar of RFC 9110.
32
+
33
+ `reason` names the broken rule and `offset` is the index of the offending character
34
+ in the text that was being read or written.
35
+ """
36
+
37
+ def __init__(self, reason: str, offset: int) -> None:
38
+ super().__init__(f"{reason} at offset {offset}")
39
+ self.reason = reason
40
+ self.offset = offset
41
+
42
+
43
+ def describe(char: str) -> str:
44
+ """A readable name for a character in an error message."""
45
+ return f"U+{ord(char):04X}"
46
+
47
+
48
+ def check_field_text(value: str, *, ascii_only: bool = False) -> None:
49
+ """Raise unless every character may appear in a field value written by a sender."""
50
+ bad = (NOT_ASCII_TEXT if ascii_only else NOT_FIELD_TEXT).search(value)
51
+ if bad is None:
52
+ return
53
+ char = bad.group()
54
+ kind = "is obs-text" if char in OBS_TEXT else "is not allowed"
55
+ raise QuotedStringError(f"{describe(char)} {kind}", bad.start())
56
+
57
+
58
+ def as_text(value: str | bytes) -> str:
59
+ """Read bytes as ISO-8859-1, where every octet maps to the code point of the same value."""
60
+ if isinstance(value, bytes):
61
+ return value.decode("latin-1")
62
+ if isinstance(value, str):
63
+ return value
64
+ raise TypeError(f"expected str or bytes, got {type(value).__name__}")
65
+
66
+
67
+ def like(text: str, original: AnyStr) -> AnyStr:
68
+ """Return `text` in the type of `original`: bytes in, bytes out."""
69
+ if isinstance(original, bytes):
70
+ return text.encode("latin-1")
71
+ return text
httpquoted/_cli.py ADDED
@@ -0,0 +1,90 @@
1
+ """Command line: quote, unquote or split header values from arguments or standard input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+
9
+ from ._chars import QuotedStringError
10
+ from ._comment import quote_comment, unquote_comment
11
+ from ._quote import quote, unquote
12
+ from ._split import check_separator, split_list
13
+
14
+
15
+ def _parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="httpquoted",
18
+ description="Quote, unquote or split HTTP field values (RFC 9110 section 5.6). "
19
+ "Values come from the arguments, or one per line from standard input.",
20
+ )
21
+ commands = parser.add_subparsers(dest="command", required=True)
22
+ for name, text in (
23
+ ("quote", "write each value as a quoted-string"),
24
+ ("unquote", "read each quoted-string or token back"),
25
+ ):
26
+ command = commands.add_parser(name, help=text)
27
+ command.add_argument("--comment", action="store_true", help="use (comment) syntax")
28
+ if name == "quote":
29
+ command.add_argument("--ascii-only", action="store_true", help="refuse obs-text")
30
+ else:
31
+ command.add_argument("--strict", action="store_true", help="reject malformed input")
32
+ command.add_argument("values", nargs="*")
33
+ split = commands.add_parser("split", help="print each list element on its own line")
34
+ split.add_argument("--separator", default=",", help='"," (default) or ";" for parameters')
35
+ split.add_argument("--comments", action="store_true", help="skip (comments) as a unit")
36
+ split.add_argument("--strict", action="store_true", help="reject malformed input")
37
+ split.add_argument("values", nargs="*")
38
+ return parser
39
+
40
+
41
+ def _convert(args: argparse.Namespace, value: str) -> list[str]:
42
+ if args.command == "quote":
43
+ encode = quote_comment if args.comment else quote
44
+ return [encode(value, ascii_only=args.ascii_only)]
45
+ if args.command == "unquote":
46
+ decode = unquote_comment if args.comment else unquote
47
+ return [decode(value, strict=args.strict)]
48
+ return split_list(value, separator=args.separator, comments=args.comments, strict=args.strict)
49
+
50
+
51
+ def _read_lines() -> list[str]:
52
+ # Field values are octets: read standard input as ISO-8859-1, never the locale codec.
53
+ buffer = getattr(sys.stdin, "buffer", None)
54
+ data = buffer.read().decode("latin-1") if buffer is not None else sys.stdin.read()
55
+ lines = [line.removesuffix("\r") for line in data.split("\n")]
56
+ return lines[:-1] if lines and not lines[-1] else lines
57
+
58
+
59
+ def _write_line(line: str) -> None:
60
+ buffer = getattr(sys.stdout, "buffer", None)
61
+ if buffer is None:
62
+ sys.stdout.write(line + "\n")
63
+ return
64
+ sys.stdout.flush()
65
+ buffer.write(line.encode("latin-1") + b"\n")
66
+ buffer.flush()
67
+
68
+
69
+ def main(argv: Sequence[str] | None = None) -> int:
70
+ """Entry point of the `httpquoted` command; returns the exit status."""
71
+ parser = _parser()
72
+ args = parser.parse_args(argv)
73
+ if args.command == "split":
74
+ try:
75
+ check_separator(args.separator)
76
+ except ValueError as error:
77
+ parser.error(str(error))
78
+ values = args.values or _read_lines()
79
+ try:
80
+ for value in values:
81
+ for line in _convert(args, value):
82
+ _write_line(line)
83
+ except QuotedStringError as error:
84
+ print(f"httpquoted: {error}", file=sys.stderr)
85
+ return 1
86
+ except UnicodeEncodeError as error:
87
+ char = error.object[error.start]
88
+ print(f"httpquoted: U+{ord(char):04X} is not an ISO-8859-1 octet", file=sys.stderr)
89
+ return 1
90
+ return 0
httpquoted/_comment.py ADDED
@@ -0,0 +1,42 @@
1
+ """Quote and unquote HTTP comments (RFC 9110 section 5.6.5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import AnyStr
6
+
7
+ from ._chars import QuotedStringError, as_text, check_field_text, like
8
+ from ._quote import _unescape
9
+ from ._scan import scan_comment
10
+
11
+
12
+ def quote_comment(value: AnyStr, *, ascii_only: bool = False) -> AnyStr:
13
+ """Wrap a value in parentheses, escaping parentheses and backslash.
14
+
15
+ Every "(", ")" and backslash in the value becomes a quoted-pair, the only ones
16
+ section 5.6.4 lets a sender write in a comment, so the result reads back with
17
+ :func:`unquote_comment` as the same text and never opens a nested comment. The
18
+ characters refused are the ones :func:`quote` refuses.
19
+ """
20
+ text = as_text(value)
21
+ check_field_text(text, ascii_only=ascii_only)
22
+ escaped = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
23
+ return like(f"({escaped})", value)
24
+
25
+
26
+ def unquote_comment(value: AnyStr, *, strict: bool = False) -> AnyStr:
27
+ """Strip the outer parentheses of a comment and resolve its quoted-pairs.
28
+
29
+ Nested comments stay in the result as text, parentheses included. Lenient by
30
+ default: a value not wrapped in parentheses comes back unchanged. With
31
+ `strict=True` the value must be exactly one comment, balanced and made of ctext,
32
+ quoted-pairs and nested comments; anything else raises `QuotedStringError`.
33
+ """
34
+ text = as_text(value)
35
+ if strict:
36
+ result, end = scan_comment(text, strict=True)
37
+ if end != len(text):
38
+ raise QuotedStringError("text after the closing parenthesis", end)
39
+ return like(result, value)
40
+ if len(text) >= 2 and text[0] == "(" and text[-1] == ")":
41
+ return like(_unescape(text[1:-1]), value)
42
+ return value
httpquoted/_quote.py ADDED
@@ -0,0 +1,67 @@
1
+ """Quote and unquote HTTP quoted-string field values (RFC 9110 section 5.6.4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import AnyStr
7
+
8
+ from ._chars import TOKEN_RE, QuotedStringError, as_text, check_field_text, like
9
+ from ._scan import scan_quoted_string
10
+
11
+ _QUOTED_PAIR = re.compile(r"\\(.)", re.DOTALL)
12
+
13
+
14
+ def quote(value: AnyStr, *, ascii_only: bool = False) -> AnyStr:
15
+ """Wrap a value in a quoted-string, escaping backslash and double quote.
16
+
17
+ Only those two characters become quoted-pairs, as section 5.6.4 asks of senders,
18
+ so the result parses back to the original with :func:`unquote`. A character a
19
+ quoted-string cannot carry (CR, LF, NUL and the other controls, DEL, anything above
20
+ U+00FF) raises `QuotedStringError` instead of reaching the header; `ascii_only=True`
21
+ also refuses obs-text (U+0080 to U+00FF). Accepts `str` or `bytes`.
22
+ """
23
+ text = as_text(value)
24
+ check_field_text(text, ascii_only=ascii_only)
25
+ escaped = text.replace("\\", "\\\\").replace('"', '\\"')
26
+ return like(f'"{escaped}"', value)
27
+
28
+
29
+ def quote_if_needed(value: AnyStr, *, ascii_only: bool = False) -> AnyStr:
30
+ """Return a token unchanged and quote anything else (a parameter value, 5.6.6)."""
31
+ if TOKEN_RE.fullmatch(as_text(value)):
32
+ return value
33
+ return quote(value, ascii_only=ascii_only)
34
+
35
+
36
+ def unquote(value: AnyStr, *, strict: bool = False) -> AnyStr:
37
+ """Strip the surrounding quotes and resolve quoted-pairs.
38
+
39
+ Lenient by default: a value that is not wrapped in double quotes is returned
40
+ unchanged, so a bare token passes through untouched, and the inside of a quoted
41
+ value is unescaped without further checks. With `strict=True` the value must be
42
+ exactly a token or a quoted-string (the `token / quoted-string` choice of a
43
+ parameter value); anything else raises `QuotedStringError`.
44
+ """
45
+ text = as_text(value)
46
+ if strict:
47
+ return like(_strict_unquote(text), value)
48
+ if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
49
+ return like(_unescape(text[1:-1]), value)
50
+ return value
51
+
52
+
53
+ def _strict_unquote(text: str) -> str:
54
+ if text.startswith('"'):
55
+ result, end = scan_quoted_string(text, strict=True)
56
+ if end != len(text):
57
+ raise QuotedStringError("text after the closing quote", end)
58
+ return result
59
+ if TOKEN_RE.fullmatch(text):
60
+ return text
61
+ bad = TOKEN_RE.match(text)
62
+ raise QuotedStringError("not a token or a quoted-string", bad.end() if bad else 0)
63
+
64
+
65
+ def _unescape(inner: str) -> str:
66
+ # A trailing lone backslash has nothing to quote and is kept as it is.
67
+ return _QUOTED_PAIR.sub(r"\1", inner)
httpquoted/_scan.py ADDED
@@ -0,0 +1,98 @@
1
+ """Scan one quoted-string or comment inside a larger field value (RFC 9110 5.6.4, 5.6.5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import AnyStr
7
+
8
+ from ._chars import (
9
+ CTEXT_RUN,
10
+ FIELD_TEXT,
11
+ LOOSE_COMMENT_RUN,
12
+ LOOSE_QUOTED_RUN,
13
+ QDTEXT_RUN,
14
+ QuotedStringError,
15
+ as_text,
16
+ describe,
17
+ like,
18
+ )
19
+
20
+
21
+ def scan_quoted_string(text: AnyStr, start: int = 0, *, strict: bool = False) -> tuple[AnyStr, int]:
22
+ """Read the quoted-string that opens at `start` and return its value and end offset.
23
+
24
+ The value has its quoted-pairs resolved; the offset points just past the closing
25
+ DQUOTE. Lenient mode accepts any character between the quotes and lets an
26
+ unterminated string run to the end of the text. `strict=True` raises
27
+ `QuotedStringError` for a character that is not qdtext, a bad quoted-pair or a
28
+ missing closing quote.
29
+ """
30
+ source = as_text(text)
31
+ _expect(source, start, '"', "quoted-string")
32
+ value, end = _scan(source, start, QDTEXT_RUN if strict else LOOSE_QUOTED_RUN, strict, 0)
33
+ return like(value, text), end
34
+
35
+
36
+ def scan_comment(text: AnyStr, start: int = 0, *, strict: bool = False) -> tuple[AnyStr, int]:
37
+ """Read the comment that opens at `start` and return its value and end offset.
38
+
39
+ Comments nest: the value keeps the parentheses of inner comments and has every
40
+ quoted-pair resolved, and the offset points just past the matching ")". Inside a
41
+ comment DQUOTE is plain text. Strict and lenient modes work as in
42
+ `scan_quoted_string`; nesting depth is only limited by the length of the text.
43
+ """
44
+ source = as_text(text)
45
+ _expect(source, start, "(", "comment")
46
+ value, end = _scan(source, start, CTEXT_RUN if strict else LOOSE_COMMENT_RUN, strict, 1)
47
+ return like(value, text), end
48
+
49
+
50
+ def _expect(source: str, start: int, opener: str, rule: str) -> None:
51
+ if isinstance(start, bool) or not isinstance(start, int):
52
+ raise TypeError(f"start must be an int, got {type(start).__name__}")
53
+ if not 0 <= start < len(source) or source[start] != opener:
54
+ raise QuotedStringError(f"a {rule} must start with {opener}", start)
55
+
56
+
57
+ def _scan(
58
+ source: str, start: int, run: re.Pattern[str], strict: bool, depth: int
59
+ ) -> tuple[str, int]:
60
+ """Shared loop: depth 0 reads a quoted-string, depth 1 and more a nested comment."""
61
+ rule = "comment" if depth else "quoted-string"
62
+ closer = ")" if depth else '"'
63
+ parts: list[str] = []
64
+ pos = start + 1
65
+ length = len(source)
66
+ while True:
67
+ match = run.match(source, pos)
68
+ assert match is not None # a pattern ending in `*` always matches
69
+ parts.append(match.group())
70
+ pos = match.end()
71
+ if pos >= length:
72
+ if strict:
73
+ raise QuotedStringError(f"unterminated {rule}", start)
74
+ return "".join(parts), length
75
+ char = source[pos]
76
+ if char == "\\":
77
+ if pos + 1 >= length:
78
+ if strict:
79
+ raise QuotedStringError("a quoted-pair needs a character", pos)
80
+ parts.append(char)
81
+ return "".join(parts), length
82
+ escaped = source[pos + 1]
83
+ if strict and escaped not in FIELD_TEXT:
84
+ raise QuotedStringError(f"{describe(escaped)} cannot be quoted", pos + 1)
85
+ parts.append(escaped)
86
+ pos += 2
87
+ elif char == closer and depth <= 1:
88
+ return "".join(parts), pos + 1
89
+ elif char == closer:
90
+ depth -= 1
91
+ parts.append(char)
92
+ pos += 1
93
+ elif char == "(" and depth:
94
+ depth += 1
95
+ parts.append(char)
96
+ pos += 1
97
+ else:
98
+ raise QuotedStringError(f"{describe(char)} is not allowed in a {rule}", pos)
httpquoted/_split.py ADDED
@@ -0,0 +1,80 @@
1
+ """Split a delimited header value while respecting quoted-strings and comments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import AnyStr
7
+
8
+ from ._chars import FIELD_TEXT, NOT_FIELD_TEXT, QuotedStringError, as_text, describe, like
9
+ from ._scan import scan_comment, scan_quoted_string
10
+
11
+ _RESERVED = frozenset('"\\()')
12
+
13
+
14
+ def split_list(
15
+ header: AnyStr,
16
+ *,
17
+ separator: str | bytes = ",",
18
+ comments: bool = False,
19
+ strict: bool = False,
20
+ ) -> list[AnyStr]:
21
+ """Split a list-style header on separators that sit outside quoted-strings.
22
+
23
+ A separator inside double quotes is part of the value, and a quoted-pair never ends
24
+ the quotes. With `comments=True` a parenthesised comment (in Via, User-Agent or
25
+ Server) is skipped as a unit too, nested or not, and a DQUOTE inside it is plain
26
+ text. Each element is trimmed of surrounding spaces and tabs, and empty elements
27
+ are dropped, as section 5.6.1.2 asks of recipients. `separator` is "," for lists
28
+ and ";" for parameters, as `str` or `bytes`. Elements keep their quotes.
29
+
30
+ Lenient by default: an unterminated quoted-string or comment runs to the end. With
31
+ `strict=True`, `QuotedStringError` is raised for a character that is not field
32
+ content (CR, LF, NUL, other controls, DEL), a quoted-string or comment that breaks
33
+ its grammar or never ends, and a stray ")" when `comments=True`. The grammar of
34
+ each element belongs to the field and is left to the caller: `"a"b` is one element.
35
+ """
36
+ separator = check_separator(separator)
37
+ text = as_text(header)
38
+ if strict:
39
+ bad = NOT_FIELD_TEXT.search(text)
40
+ if bad is not None:
41
+ raise QuotedStringError(f"{describe(bad.group())} is not allowed", bad.start())
42
+ stops = re.compile("[" + re.escape(separator + '"' + ("()" if comments else "")) + "]")
43
+ elements: list[AnyStr] = []
44
+ begin = pos = 0
45
+ length = len(text)
46
+ while pos < length:
47
+ found = stops.search(text, pos)
48
+ if found is None:
49
+ break
50
+ pos = found.start()
51
+ char = text[pos]
52
+ if char == separator:
53
+ _keep(text[begin:pos], header, elements)
54
+ begin = pos = pos + 1
55
+ elif char == '"':
56
+ pos = scan_quoted_string(text, pos, strict=strict)[1]
57
+ elif char == "(":
58
+ pos = scan_comment(text, pos, strict=strict)[1]
59
+ elif strict:
60
+ raise QuotedStringError("unbalanced )", pos)
61
+ else:
62
+ pos += 1
63
+ _keep(text[begin:], header, elements)
64
+ return elements
65
+
66
+
67
+ def check_separator(separator: str | bytes) -> str:
68
+ """Return the separator as text, or raise ValueError if it cannot split a field value."""
69
+ sep = as_text(separator)
70
+ if len(sep) != 1 or sep in _RESERVED or sep not in FIELD_TEXT:
71
+ raise ValueError(
72
+ "separator must be one field character other than DQUOTE, backslash, ( or )"
73
+ )
74
+ return sep
75
+
76
+
77
+ def _keep(element: str, header: AnyStr, elements: list[AnyStr]) -> None:
78
+ element = element.strip(" \t")
79
+ if element:
80
+ elements.append(like(element, header))
httpquoted/_token.py ADDED
@@ -0,0 +1,26 @@
1
+ """Recognise an HTTP token (RFC 9110 section 5.6.2, the tchar production)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import AnyStr
6
+
7
+ from ._chars import TOKEN_RE, as_text
8
+
9
+
10
+ def is_token(value: AnyStr) -> bool:
11
+ """Report whether a value is a non-empty run of token characters.
12
+
13
+ A token needs at least one character and may contain only tchar: letters,
14
+ digits and a fixed set of symbols. Anything else, including an empty string,
15
+ whitespace or a delimiter, is not a token. Accepts `str` or `bytes`.
16
+ """
17
+ return TOKEN_RE.fullmatch(as_text(value)) is not None
18
+
19
+
20
+ def needs_quoting(value: AnyStr) -> bool:
21
+ """Report whether a parameter value must be sent as a quoted-string.
22
+
23
+ RFC 9110 section 5.6.6 lets a value that is a token travel bare or quoted with the
24
+ same meaning; anything else, the empty value included, needs the quotes.
25
+ """
26
+ return not is_token(value)
httpquoted/py.typed ADDED
File without changes
@@ -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,15 @@
1
+ httpquoted/__init__.py,sha256=aT8OJxvht21t8dFmVKX0ABmzoSRPOL4BhhjY7nY8C0o,646
2
+ httpquoted/__main__.py,sha256=eAlCbqOjLh4dsdmEXJVPd1CCEm6cYQXxyNvc2yINFbA,146
3
+ httpquoted/_chars.py,sha256=Xk7Xvhru7UgC_NOonv-evnVqkgTYr9_-YYCz90R2_a8,2795
4
+ httpquoted/_cli.py,sha256=jnOmRSopl3qK4FPTKUEMQUCxQPaL3cVGDheDmK5EvJQ,3653
5
+ httpquoted/_comment.py,sha256=N5sC0eiIng6i0XhEEp8YEGBzeaL1kA6NsrQvCCM-1tY,1768
6
+ httpquoted/_quote.py,sha256=PbpSUEdSVDyP1SxTfTXJOuRaAlNxPpwsTSulhs_e4RE,2670
7
+ httpquoted/_scan.py,sha256=yPIrIb-t0KiSouYo5XfLWXIhueOYwldmB1jAzVumcgA,3782
8
+ httpquoted/_split.py,sha256=zzw5KfM3bDGJpQuvsX3-6VgdUFfizazc99rKH_7bZR0,3109
9
+ httpquoted/_token.py,sha256=SR3FoNKY_dBxG8LdRcAq1_IqToUB5-FhETc5sL5S8F4,909
10
+ httpquoted/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ httpquoted-0.2.0.dist-info/METADATA,sha256=YlfqMlQExKucJDzcB8u91nhqB5RYkyAit2acvlGMK2k,8898
12
+ httpquoted-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
13
+ httpquoted-0.2.0.dist-info/entry_points.txt,sha256=J_OiTaAI7jN25NLc7pP7ScTQ3iV2K_lliM5t85s5x2I,52
14
+ httpquoted-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
15
+ httpquoted-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ httpquoted = httpquoted._cli:main
@@ -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.