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 +26 -0
- httpquoted/__main__.py +9 -0
- httpquoted/_chars.py +71 -0
- httpquoted/_cli.py +90 -0
- httpquoted/_comment.py +42 -0
- httpquoted/_quote.py +67 -0
- httpquoted/_scan.py +98 -0
- httpquoted/_split.py +80 -0
- httpquoted/_token.py +26 -0
- httpquoted/py.typed +0 -0
- httpquoted-0.2.0.dist-info/METADATA +210 -0
- httpquoted-0.2.0.dist-info/RECORD +15 -0
- httpquoted-0.2.0.dist-info/WHEEL +4 -0
- httpquoted-0.2.0.dist-info/entry_points.txt +2 -0
- httpquoted-0.2.0.dist-info/licenses/LICENSE +21 -0
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
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,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.
|