chunkedte 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.
- chunkedte/__init__.py +22 -0
- chunkedte/__main__.py +9 -0
- chunkedte/_cli.py +87 -0
- chunkedte/_decode.py +177 -0
- chunkedte/_encode.py +82 -0
- chunkedte/_errors.py +11 -0
- chunkedte/_framing.py +66 -0
- chunkedte/_grammar.py +85 -0
- chunkedte/_limits.py +33 -0
- chunkedte/py.typed +0 -0
- chunkedte-0.2.0.dist-info/METADATA +198 -0
- chunkedte-0.2.0.dist-info/RECORD +15 -0
- chunkedte-0.2.0.dist-info/WHEEL +4 -0
- chunkedte-0.2.0.dist-info/entry_points.txt +2 -0
- chunkedte-0.2.0.dist-info/licenses/LICENSE +21 -0
chunkedte/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Strict, incremental, typed, zero-dependency codec for HTTP/1.1 chunked transfer coding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._decode import Decoder, decode
|
|
6
|
+
from ._encode import Encoder, encode
|
|
7
|
+
from ._errors import ChunkedError, LimitError
|
|
8
|
+
from ._framing import is_chunked
|
|
9
|
+
from ._limits import Limits
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ChunkedError",
|
|
15
|
+
"Decoder",
|
|
16
|
+
"Encoder",
|
|
17
|
+
"LimitError",
|
|
18
|
+
"Limits",
|
|
19
|
+
"decode",
|
|
20
|
+
"encode",
|
|
21
|
+
"is_chunked",
|
|
22
|
+
]
|
chunkedte/__main__.py
ADDED
chunkedte/_cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Command line front end: ``chunkedte decode`` and ``chunkedte encode`` over files or stdio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import BinaryIO, TextIO
|
|
8
|
+
|
|
9
|
+
from ._decode import Decoder
|
|
10
|
+
from ._encode import DEFAULT_CHUNK_SIZE, Encoder, encode
|
|
11
|
+
from ._errors import ChunkedError
|
|
12
|
+
|
|
13
|
+
_BLOCK = 65536
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="chunkedte", description="Decode or encode HTTP/1.1 chunked transfer coding."
|
|
19
|
+
)
|
|
20
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
decode = commands.add_parser("decode", help="print the body; trailers go to stderr")
|
|
22
|
+
decode.add_argument("file", nargs="?", help="input file (default: stdin)")
|
|
23
|
+
encode = commands.add_parser("encode", help="frame the input as a chunked body")
|
|
24
|
+
encode.add_argument("file", nargs="?", help="input file (default: stdin)")
|
|
25
|
+
encode.add_argument("--chunk-size", type=int, default=DEFAULT_CHUNK_SIZE)
|
|
26
|
+
encode.add_argument(
|
|
27
|
+
"--trailer", action="append", default=[], metavar="NAME:VALUE", help="repeatable"
|
|
28
|
+
)
|
|
29
|
+
return parser
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(
|
|
33
|
+
argv: list[str] | None = None,
|
|
34
|
+
stdin: BinaryIO | None = None,
|
|
35
|
+
stdout: BinaryIO | None = None,
|
|
36
|
+
stderr: TextIO | None = None,
|
|
37
|
+
) -> int:
|
|
38
|
+
"""Run the command line tool and return its exit status."""
|
|
39
|
+
args = _parser().parse_args(argv)
|
|
40
|
+
out = sys.stdout.buffer if stdout is None else stdout
|
|
41
|
+
err = sys.stderr if stderr is None else stderr
|
|
42
|
+
source = (sys.stdin.buffer if stdin is None else stdin) if args.file is None else None
|
|
43
|
+
try:
|
|
44
|
+
if source is None:
|
|
45
|
+
with open(args.file, "rb") as handle:
|
|
46
|
+
return _run(args, handle, out, err)
|
|
47
|
+
return _run(args, source, out, err)
|
|
48
|
+
except (ChunkedError, ValueError, OSError) as error:
|
|
49
|
+
print(f"chunkedte: {error}", file=err)
|
|
50
|
+
return 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run(args: argparse.Namespace, source: BinaryIO, out: BinaryIO, err: TextIO) -> int:
|
|
54
|
+
if args.command == "decode":
|
|
55
|
+
return _decode(source, out, err)
|
|
56
|
+
if args.chunk_size <= 0:
|
|
57
|
+
raise ValueError("--chunk-size must be positive")
|
|
58
|
+
trailers = [_split_trailer(text) for text in args.trailer]
|
|
59
|
+
closing = encode(b"", trailers=trailers) # validates the trailers before any output
|
|
60
|
+
encoder = Encoder()
|
|
61
|
+
while block := source.read(args.chunk_size):
|
|
62
|
+
out.write(encoder.write(block))
|
|
63
|
+
out.write(closing)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _decode(source: BinaryIO, out: BinaryIO, err: TextIO) -> int:
|
|
68
|
+
decoder = Decoder()
|
|
69
|
+
while not decoder.is_complete and (block := source.read(_BLOCK)):
|
|
70
|
+
for piece in decoder.feed(block):
|
|
71
|
+
out.write(piece)
|
|
72
|
+
if not decoder.is_complete:
|
|
73
|
+
raise ChunkedError("incomplete chunked message")
|
|
74
|
+
if decoder.unused_data or source.read(1):
|
|
75
|
+
raise ChunkedError("bytes after the end of the chunked message")
|
|
76
|
+
for name, value in decoder.trailers:
|
|
77
|
+
# Escape obs-text and C1 bytes so any terminal encoding can print them.
|
|
78
|
+
safe = value.encode("latin-1").decode("ascii", "backslashreplace")
|
|
79
|
+
print(f"{name}: {safe}", file=err)
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _split_trailer(text: str) -> tuple[str, str]:
|
|
84
|
+
name, colon, value = text.partition(":")
|
|
85
|
+
if not colon:
|
|
86
|
+
raise ValueError(f"--trailer needs NAME:VALUE, got {text!r}")
|
|
87
|
+
return name, value.strip(" \t")
|
chunkedte/_decode.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Incremental, strict decoder for HTTP/1.1 chunked transfer coding (RFC 9112 section 7.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
|
|
7
|
+
from ._errors import ChunkedError, LimitError
|
|
8
|
+
from ._grammar import SIZE_LINE, parse_field_line
|
|
9
|
+
from ._limits import Limits
|
|
10
|
+
|
|
11
|
+
_CR = 0x0D
|
|
12
|
+
# Sixteen significant hex digits hold any value below 2**64; more is never a real size.
|
|
13
|
+
_MAX_SIZE_DIGITS = 16
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _State(enum.Enum):
|
|
17
|
+
SIZE = enum.auto()
|
|
18
|
+
DATA = enum.auto()
|
|
19
|
+
DATA_CRLF = enum.auto()
|
|
20
|
+
TRAILERS = enum.auto()
|
|
21
|
+
DONE = enum.auto()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Decoder:
|
|
25
|
+
"""Feed chunked bytes in any split and collect the decoded body pieces.
|
|
26
|
+
|
|
27
|
+
Size lines, chunk extensions and trailer fields are checked against the RFC 9112
|
|
28
|
+
grammar; only CRLF ends a line. Extensions are validated and then ignored, trailer
|
|
29
|
+
fields are kept in :attr:`trailers` (keep them apart from the header section, RFC 9110
|
|
30
|
+
section 6.5.2). Bytes after the final CRLF land in :attr:`unused_data`. When
|
|
31
|
+
:meth:`feed` raises, the pieces decoded in that call are dropped, and every later
|
|
32
|
+
:meth:`feed` raises again.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, limits: Limits | None = None) -> None:
|
|
36
|
+
self._limits = Limits() if limits is None else limits
|
|
37
|
+
self._buffer = bytearray()
|
|
38
|
+
self._scanned = 0
|
|
39
|
+
self._state = _State.SIZE
|
|
40
|
+
self._remaining = 0
|
|
41
|
+
self._body_size = 0
|
|
42
|
+
self._extensions_size = 0
|
|
43
|
+
self._trailer_size = 0
|
|
44
|
+
self._trailers: list[tuple[str, str]] = []
|
|
45
|
+
self._error: ChunkedError | None = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_complete(self) -> bool:
|
|
49
|
+
"""Whether the last chunk and the trailer section have been fully consumed."""
|
|
50
|
+
return self._state is _State.DONE
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def trailers(self) -> list[tuple[str, str]]:
|
|
54
|
+
"""Trailer fields received so far as ``(name, value)`` pairs, in arrival order."""
|
|
55
|
+
return list(self._trailers)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def unused_data(self) -> bytes:
|
|
59
|
+
"""Bytes received after the end of the chunked message (for example the next request)."""
|
|
60
|
+
return bytes(self._buffer) if self._state is _State.DONE else b""
|
|
61
|
+
|
|
62
|
+
def feed(self, data: bytes | bytearray | memoryview) -> list[bytes]:
|
|
63
|
+
"""Append ``data`` and return every decoded body piece now available."""
|
|
64
|
+
if self._error is not None:
|
|
65
|
+
raise ChunkedError(f"the decoder already failed: {self._error}")
|
|
66
|
+
self._buffer += data
|
|
67
|
+
out: list[bytes] = []
|
|
68
|
+
try:
|
|
69
|
+
while self._step(out):
|
|
70
|
+
pass
|
|
71
|
+
except ChunkedError as error:
|
|
72
|
+
self._error = error
|
|
73
|
+
raise
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
def _step(self, out: list[bytes]) -> bool:
|
|
77
|
+
if self._state is _State.SIZE:
|
|
78
|
+
return self._read_size()
|
|
79
|
+
if self._state is _State.DATA:
|
|
80
|
+
return self._read_data(out)
|
|
81
|
+
if self._state is _State.DATA_CRLF:
|
|
82
|
+
return self._read_data_crlf()
|
|
83
|
+
if self._state is _State.TRAILERS:
|
|
84
|
+
return self._read_trailer()
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def _read_size(self) -> bool:
|
|
88
|
+
line = self._take_line(self._limits.max_line_size, "chunk-size line")
|
|
89
|
+
if line is None:
|
|
90
|
+
return False
|
|
91
|
+
match = SIZE_LINE.fullmatch(line)
|
|
92
|
+
if match is None:
|
|
93
|
+
raise ChunkedError(f"malformed chunk-size line: {line[:64]!r}")
|
|
94
|
+
digits, extensions = match.group(1, 2)
|
|
95
|
+
self._extensions_size += len(extensions)
|
|
96
|
+
if self._extensions_size > self._limits.max_extensions_size:
|
|
97
|
+
raise LimitError("chunk extensions exceed max_extensions_size")
|
|
98
|
+
if len(digits.lstrip(b"0")) > _MAX_SIZE_DIGITS:
|
|
99
|
+
raise LimitError("chunk size exceeds max_chunk_size")
|
|
100
|
+
size = int(digits, 16)
|
|
101
|
+
if size > self._limits.max_chunk_size:
|
|
102
|
+
raise LimitError("chunk size exceeds max_chunk_size")
|
|
103
|
+
cap = self._limits.max_body_size
|
|
104
|
+
if cap is not None and self._body_size + size > cap:
|
|
105
|
+
raise LimitError("body exceeds max_body_size")
|
|
106
|
+
self._remaining = size
|
|
107
|
+
self._state = _State.DATA if size else _State.TRAILERS
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
def _read_data(self, out: list[bytes]) -> bool:
|
|
111
|
+
if not self._buffer:
|
|
112
|
+
return False
|
|
113
|
+
take = min(self._remaining, len(self._buffer))
|
|
114
|
+
out.append(bytes(self._buffer[:take]))
|
|
115
|
+
del self._buffer[:take]
|
|
116
|
+
self._remaining -= take
|
|
117
|
+
self._body_size += take
|
|
118
|
+
if self._remaining == 0:
|
|
119
|
+
self._state = _State.DATA_CRLF
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
def _read_data_crlf(self) -> bool:
|
|
123
|
+
# Fail on the first wrong byte: any two bytes here is the h11 smuggling bug.
|
|
124
|
+
head = bytes(self._buffer[:2])
|
|
125
|
+
if not b"\r\n".startswith(head):
|
|
126
|
+
raise ChunkedError("chunk data not followed by CRLF")
|
|
127
|
+
if len(head) < 2:
|
|
128
|
+
return False
|
|
129
|
+
del self._buffer[:2]
|
|
130
|
+
self._state = _State.SIZE
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
def _read_trailer(self) -> bool:
|
|
134
|
+
budget = max(self._limits.max_trailer_size - self._trailer_size - 2, 0)
|
|
135
|
+
line = self._take_line(budget, "trailer section")
|
|
136
|
+
if line is None:
|
|
137
|
+
return False
|
|
138
|
+
if not line:
|
|
139
|
+
self._state = _State.DONE
|
|
140
|
+
return False
|
|
141
|
+
self._trailer_size += len(line) + 2
|
|
142
|
+
self._trailers.append(parse_field_line(line))
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
def _take_line(self, limit: int, what: str) -> bytes | None:
|
|
146
|
+
"""Cut one CRLF-terminated line off the buffer, scanning each byte only once."""
|
|
147
|
+
end = self._buffer.find(b"\n", self._scanned)
|
|
148
|
+
if end == -1:
|
|
149
|
+
self._scanned = len(self._buffer)
|
|
150
|
+
if self._scanned > limit + 1:
|
|
151
|
+
raise LimitError(f"{what} exceeds its limit")
|
|
152
|
+
return None
|
|
153
|
+
self._scanned = 0
|
|
154
|
+
if end == 0 or self._buffer[end - 1] != _CR:
|
|
155
|
+
raise ChunkedError(f"bare LF in the {what}")
|
|
156
|
+
if end - 1 > limit:
|
|
157
|
+
raise LimitError(f"{what} exceeds its limit")
|
|
158
|
+
line = bytes(self._buffer[: end - 1])
|
|
159
|
+
del self._buffer[: end + 1]
|
|
160
|
+
if _CR in line:
|
|
161
|
+
raise ChunkedError(f"bare CR in the {what}")
|
|
162
|
+
return line
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def decode(data: bytes | bytearray | memoryview, *, limits: Limits | None = None) -> bytes:
|
|
166
|
+
"""Decode one complete chunked message and return the body.
|
|
167
|
+
|
|
168
|
+
Raises :class:`ChunkedError` if the message is malformed, not terminated, or followed
|
|
169
|
+
by more bytes.
|
|
170
|
+
"""
|
|
171
|
+
decoder = Decoder(limits)
|
|
172
|
+
pieces = decoder.feed(data)
|
|
173
|
+
if not decoder.is_complete:
|
|
174
|
+
raise ChunkedError("incomplete chunked message")
|
|
175
|
+
if decoder.unused_data:
|
|
176
|
+
raise ChunkedError("bytes after the end of the chunked message")
|
|
177
|
+
return b"".join(pieces)
|
chunkedte/_encode.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Encode bytes as an HTTP/1.1 chunked body, in one call or incrementally."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
|
|
7
|
+
from ._errors import ChunkedError
|
|
8
|
+
from ._grammar import format_field_line
|
|
9
|
+
|
|
10
|
+
DEFAULT_CHUNK_SIZE = 8192
|
|
11
|
+
|
|
12
|
+
Trailers = Mapping[str, str] | Iterable[tuple[str, str]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Encoder:
|
|
16
|
+
"""Frame body pieces as chunks as they are produced, then close the message.
|
|
17
|
+
|
|
18
|
+
:meth:`write` turns one piece into one chunk (an empty piece yields nothing, since a
|
|
19
|
+
zero-size chunk would end the message); :meth:`finish` writes the last chunk and the
|
|
20
|
+
optional trailer section. Trailer names must be tokens and values field-values, and
|
|
21
|
+
fields that frame or route a message (Content-Length, Transfer-Encoding, Host, ...)
|
|
22
|
+
are refused as trailers.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._finished = False
|
|
27
|
+
|
|
28
|
+
def write(self, data: bytes | bytearray | memoryview) -> bytes:
|
|
29
|
+
"""Return ``data`` framed as one chunk."""
|
|
30
|
+
if self._finished:
|
|
31
|
+
raise ChunkedError("write after finish")
|
|
32
|
+
piece = _as_bytes(data)
|
|
33
|
+
if not piece:
|
|
34
|
+
return b""
|
|
35
|
+
return b"%x\r\n%b\r\n" % (len(piece), piece)
|
|
36
|
+
|
|
37
|
+
def finish(self, trailers: Trailers | None = None) -> bytes:
|
|
38
|
+
"""Return the last chunk, the trailer section and the closing CRLF."""
|
|
39
|
+
if self._finished:
|
|
40
|
+
raise ChunkedError("finish called twice")
|
|
41
|
+
lines = [format_field_line(name, value) + b"\r\n" for name, value in _pairs(trailers)]
|
|
42
|
+
self._finished = True
|
|
43
|
+
return b"0\r\n" + b"".join(lines) + b"\r\n"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def encode(
|
|
47
|
+
data: bytes | bytearray | memoryview,
|
|
48
|
+
*,
|
|
49
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
50
|
+
trailers: Trailers | None = None,
|
|
51
|
+
) -> bytes:
|
|
52
|
+
"""Frame ``data`` as a complete chunked body split into ``chunk_size`` pieces.
|
|
53
|
+
|
|
54
|
+
The result is ``<hex-size>CRLF<data>CRLF`` chunks, then ``0CRLF``, the trailer fields
|
|
55
|
+
and the closing CRLF. An empty ``data`` yields just the last chunk.
|
|
56
|
+
"""
|
|
57
|
+
if not isinstance(chunk_size, int) or isinstance(chunk_size, bool):
|
|
58
|
+
raise TypeError("chunk_size must be an int")
|
|
59
|
+
if chunk_size <= 0:
|
|
60
|
+
raise ValueError("chunk_size must be positive")
|
|
61
|
+
body = _as_bytes(data)
|
|
62
|
+
encoder = Encoder()
|
|
63
|
+
parts = [encoder.write(body[at : at + chunk_size]) for at in range(0, len(body), chunk_size)]
|
|
64
|
+
parts.append(encoder.finish(trailers))
|
|
65
|
+
return b"".join(parts)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _as_bytes(data: bytes | bytearray | memoryview) -> bytes:
|
|
69
|
+
# bytes(5) would silently build five NUL bytes.
|
|
70
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
71
|
+
raise TypeError(f"expected a bytes-like object, got {type(data).__name__}")
|
|
72
|
+
return bytes(data)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _pairs(trailers: Trailers | None) -> list[tuple[str, str]]:
|
|
76
|
+
if trailers is None:
|
|
77
|
+
return []
|
|
78
|
+
if isinstance(trailers, (str, bytes)):
|
|
79
|
+
raise TypeError("trailers must be a mapping or (name, value) pairs")
|
|
80
|
+
if isinstance(trailers, Mapping):
|
|
81
|
+
return list(trailers.items())
|
|
82
|
+
return [(name, value) for name, value in trailers]
|
chunkedte/_errors.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Errors raised for malformed or oversized chunked messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ChunkedError(ValueError):
|
|
7
|
+
"""A chunked message or a framing field breaks RFC 9112 (answer with 400)."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LimitError(ChunkedError):
|
|
11
|
+
"""A configured :class:`Limits` bound was passed (answer with 413 or 431)."""
|
chunkedte/_framing.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Decide from Transfer-Encoding and Content-Length whether a body is chunked."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ._errors import ChunkedError
|
|
8
|
+
|
|
9
|
+
_TOKEN = r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+"
|
|
10
|
+
_QUOTED = r'"(?:[\t \x21\x23-\x5b\x5d-\x7e\x80-\xff]|\\[\t \x21-\x7e\x80-\xff])*"'
|
|
11
|
+
# RFC 9110 section 10.1.4: transfer-coding = token *( OWS ";" OWS transfer-parameter ).
|
|
12
|
+
_CODING = re.compile(rf"({_TOKEN})((?:[ \t]*;[ \t]*{_TOKEN}[ \t]*=[ \t]*(?:{_TOKEN}|{_QUOTED}))*)")
|
|
13
|
+
# The trailing OWS sits inside the optional group: two adjacent OWS runs backtrack
|
|
14
|
+
# quadratically on a long run of spaces.
|
|
15
|
+
_MEMBER = re.compile(rf"[ \t]*(?:{_CODING.pattern}[ \t]*)?(?:,|\Z)")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_chunked(
|
|
19
|
+
transfer_encoding: str | None,
|
|
20
|
+
content_length: str | None = None,
|
|
21
|
+
*,
|
|
22
|
+
request: bool = True,
|
|
23
|
+
) -> bool:
|
|
24
|
+
"""Whether a message body is framed by the chunked coding (RFC 9112 sections 6.1, 6.3).
|
|
25
|
+
|
|
26
|
+
Pass the field values as received, joining repeated lines with ``", "``, or ``None``
|
|
27
|
+
when a field is absent. Returns ``False`` when there is no Transfer-Encoding, and for a
|
|
28
|
+
response whose final coding is not chunked (it is read until the connection closes).
|
|
29
|
+
Raises :class:`ChunkedError` when both fields are present, when chunked is applied
|
|
30
|
+
twice or carries parameters, when a request's final coding is not chunked, and when the
|
|
31
|
+
field is not a valid list of transfer codings.
|
|
32
|
+
|
|
33
|
+
The caller still applies the rest of RFC 9112 section 6.3: responses to HEAD, 1xx, 204,
|
|
34
|
+
304 and 2xx to CONNECT have no body, and an HTTP/1.0 message with Transfer-Encoding has
|
|
35
|
+
faulty framing (section 6.1).
|
|
36
|
+
"""
|
|
37
|
+
if transfer_encoding is None:
|
|
38
|
+
return False
|
|
39
|
+
if content_length is not None:
|
|
40
|
+
raise ChunkedError("both Transfer-Encoding and Content-Length are present")
|
|
41
|
+
codings = _parse_codings(transfer_encoding)
|
|
42
|
+
if not codings:
|
|
43
|
+
raise ChunkedError("Transfer-Encoding lists no transfer coding")
|
|
44
|
+
chunked = [index for index, (name, _) in enumerate(codings) if name == "chunked"]
|
|
45
|
+
if len(chunked) > 1:
|
|
46
|
+
raise ChunkedError("chunked is applied more than once")
|
|
47
|
+
if chunked and codings[chunked[0]][1]:
|
|
48
|
+
raise ChunkedError("the chunked coding does not take parameters")
|
|
49
|
+
if chunked and chunked[0] == len(codings) - 1:
|
|
50
|
+
return True
|
|
51
|
+
if request:
|
|
52
|
+
raise ChunkedError("chunked is not the final transfer coding of a request")
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_codings(value: str) -> list[tuple[str, str]]:
|
|
57
|
+
codings: list[tuple[str, str]] = []
|
|
58
|
+
position = 0
|
|
59
|
+
while position < len(value):
|
|
60
|
+
match = _MEMBER.match(value, position)
|
|
61
|
+
if match is None or match.end() == position:
|
|
62
|
+
raise ChunkedError(f"malformed Transfer-Encoding: {value[:64]!r}")
|
|
63
|
+
if match.group(1) is not None:
|
|
64
|
+
codings.append((match.group(1).lower(), match.group(2)))
|
|
65
|
+
position = match.end()
|
|
66
|
+
return codings
|
chunkedte/_grammar.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""RFC 9112 and RFC 9110 grammar for size lines and field lines, as byte regexes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ._errors import ChunkedError
|
|
8
|
+
|
|
9
|
+
# RFC 9110 section 5.6.2: tchar is any VCHAR except the delimiters.
|
|
10
|
+
TCHAR = rb"!#$%&'*+\-.^_`|~0-9A-Za-z"
|
|
11
|
+
TOKEN = rb"[" + TCHAR + rb"]+"
|
|
12
|
+
# RFC 9110 section 5.6.4: qdtext and quoted-pair; obs-text is %x80-FF.
|
|
13
|
+
QDTEXT = rb"[\t \x21\x23-\x5b\x5d-\x7e\x80-\xff]"
|
|
14
|
+
QUOTED_PAIR = rb"\\[\t \x21-\x7e\x80-\xff]"
|
|
15
|
+
QUOTED_STRING = rb'"(?:' + QDTEXT + rb"|" + QUOTED_PAIR + rb')*"'
|
|
16
|
+
BWS = rb"[ \t]*"
|
|
17
|
+
# RFC 9112 section 7.1.1: chunk-ext = *( BWS ";" BWS name [ BWS "=" BWS value ] ).
|
|
18
|
+
CHUNK_EXT = (
|
|
19
|
+
rb"(?:" + BWS + rb";" + BWS + TOKEN
|
|
20
|
+
+ rb"(?:" + BWS + rb"=" + BWS + rb"(?:" + TOKEN + rb"|" + QUOTED_STRING + rb"))?)*"
|
|
21
|
+
) # fmt: skip
|
|
22
|
+
SIZE_LINE = re.compile(rb"([0-9A-Fa-f]+)(" + CHUNK_EXT + rb")")
|
|
23
|
+
FIELD_NAME = re.compile(TOKEN + rb":")
|
|
24
|
+
# RFC 9110 section 5.5: field-vchar, SP and HTAB; the ends are checked by stripping OWS.
|
|
25
|
+
FIELD_VALUE = re.compile(rb"[\t \x21-\x7e\x80-\xff]*")
|
|
26
|
+
TOKEN_RE = re.compile(TOKEN)
|
|
27
|
+
|
|
28
|
+
# Fields needed before the content (framing, routing, connection control, authentication,
|
|
29
|
+
# request modifiers, response controls, content format: RFC 9110 section 6.5.1) cannot be
|
|
30
|
+
# sent as trailers, so the encoder refuses them.
|
|
31
|
+
FORBIDDEN_TRAILERS = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"authorization",
|
|
34
|
+
"cache-control",
|
|
35
|
+
"connection",
|
|
36
|
+
"content-encoding",
|
|
37
|
+
"content-length",
|
|
38
|
+
"content-range",
|
|
39
|
+
"content-type",
|
|
40
|
+
"cookie",
|
|
41
|
+
"expect",
|
|
42
|
+
"host",
|
|
43
|
+
"keep-alive",
|
|
44
|
+
"max-forwards",
|
|
45
|
+
"proxy-authenticate",
|
|
46
|
+
"proxy-authorization",
|
|
47
|
+
"range",
|
|
48
|
+
"set-cookie",
|
|
49
|
+
"te",
|
|
50
|
+
"trailer",
|
|
51
|
+
"transfer-encoding",
|
|
52
|
+
"upgrade",
|
|
53
|
+
"www-authenticate",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_field_line(line: bytes) -> tuple[str, str]:
|
|
59
|
+
"""Split one trailer field line into its name and its OWS-trimmed value."""
|
|
60
|
+
if line[:1] in (b" ", b"\t"):
|
|
61
|
+
raise ChunkedError("obsolete line folding in the trailer section")
|
|
62
|
+
match = FIELD_NAME.match(line)
|
|
63
|
+
if match is None:
|
|
64
|
+
raise ChunkedError(f"malformed trailer field line: {line[:64]!r}")
|
|
65
|
+
value = line[match.end() :].strip(b" \t")
|
|
66
|
+
if not FIELD_VALUE.fullmatch(value):
|
|
67
|
+
raise ChunkedError(f"invalid byte in trailer field value: {line[:64]!r}")
|
|
68
|
+
return line[: match.end() - 1].decode("ascii"), value.decode("latin-1")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def format_field_line(name: str, value: str) -> bytes:
|
|
72
|
+
"""Validate one trailer field for sending and return ``name: value`` as bytes."""
|
|
73
|
+
if not isinstance(name, str) or not isinstance(value, str):
|
|
74
|
+
raise TypeError("trailer names and values must be str")
|
|
75
|
+
try:
|
|
76
|
+
raw_name, raw_value = name.encode("ascii"), value.encode("latin-1")
|
|
77
|
+
except UnicodeEncodeError:
|
|
78
|
+
raise ChunkedError(f"trailer field {name!r} is not latin-1 text") from None
|
|
79
|
+
if not TOKEN_RE.fullmatch(raw_name):
|
|
80
|
+
raise ChunkedError(f"trailer name is not a token: {name!r}")
|
|
81
|
+
if name.lower() in FORBIDDEN_TRAILERS:
|
|
82
|
+
raise ChunkedError(f"{name} cannot be sent as a trailer field")
|
|
83
|
+
if not FIELD_VALUE.fullmatch(raw_value) or raw_value != raw_value.strip(b" \t"):
|
|
84
|
+
raise ChunkedError(f"invalid trailer value for {name}: {value!r}")
|
|
85
|
+
return raw_name + b": " + raw_value
|
chunkedte/_limits.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Bounds that keep a decoder's memory and work proportional to what the caller allows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, fields
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Limits:
|
|
10
|
+
"""Upper bounds applied while decoding; passing one raises :class:`LimitError`.
|
|
11
|
+
|
|
12
|
+
``max_line_size`` bounds one chunk-size line (size plus extensions, CRLF excluded),
|
|
13
|
+
``max_extensions_size`` the extension bytes of the whole message, ``max_chunk_size`` the
|
|
14
|
+
value of one chunk-size, ``max_body_size`` the decoded body (``None`` for no bound) and
|
|
15
|
+
``max_trailer_size`` the trailer field lines including their CRLFs.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
max_line_size: int = 4096
|
|
19
|
+
max_extensions_size: int = 16384
|
|
20
|
+
# Below 2**63 so a forwarded size never wraps in a downstream signed 64-bit parser.
|
|
21
|
+
max_chunk_size: int = 2**63 - 1
|
|
22
|
+
max_body_size: int | None = None
|
|
23
|
+
max_trailer_size: int = 16384
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
for field in fields(self):
|
|
27
|
+
value = getattr(self, field.name)
|
|
28
|
+
if value is None and field.name == "max_body_size":
|
|
29
|
+
continue
|
|
30
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
31
|
+
raise TypeError(f"{field.name} must be an int, got {type(value).__name__}")
|
|
32
|
+
if value < 0:
|
|
33
|
+
raise ValueError(f"{field.name} must not be negative")
|
chunkedte/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: chunkedte
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Strict, incremental, typed, zero-dependency codec for HTTP/1.1 chunked transfer coding
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/chunkedte
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/chunkedte/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/chunkedte/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: chunked,codec,http,request-smuggling,rfc9112,trailers,transfer-encoding
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: build; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
34
|
+
Requires-Dist: twine; extra == 'dev'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# chunkedte
|
|
38
|
+
|
|
39
|
+
Strict, incremental, typed, zero-dependency codec for HTTP/1.1 chunked transfer coding
|
|
40
|
+
(RFC 9112 section 7.1).
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
pip install chunkedte
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Python 3.10 or newer. No dependencies.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
Decode a complete message in one call. `decode` raises `ChunkedError` if the message is
|
|
53
|
+
malformed, unfinished, or followed by more bytes:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from chunkedte import ChunkedError, decode
|
|
57
|
+
|
|
58
|
+
print(decode(b"5\r\nhello\r\n0\r\n\r\n"))
|
|
59
|
+
try:
|
|
60
|
+
decode(b"0x5\r\nhello\r\n0\r\n\r\n")
|
|
61
|
+
except ChunkedError as error:
|
|
62
|
+
print(error)
|
|
63
|
+
# b'hello'
|
|
64
|
+
# malformed chunk-size line: b'0x5'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Decode incrementally as bytes arrive off a socket. `Decoder.feed` takes any split and
|
|
68
|
+
returns the body pieces available so far; trailer fields and whatever follows the message
|
|
69
|
+
(a pipelined request, say) are kept apart:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from chunkedte import Decoder
|
|
73
|
+
|
|
74
|
+
decoder = Decoder()
|
|
75
|
+
body = b""
|
|
76
|
+
for part in [b"4\r\nwi", b"ki\r\n5;sig=ab\r\npedia\r\n0\r\nDigest: x\r\n", b"\r\nGET /"]:
|
|
77
|
+
for piece in decoder.feed(part):
|
|
78
|
+
body += piece
|
|
79
|
+
print(body, decoder.is_complete)
|
|
80
|
+
print(decoder.trailers, decoder.unused_data)
|
|
81
|
+
# b'wikipedia' True
|
|
82
|
+
# [('Digest', 'x')] b'GET /'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Bound what a peer can make you buffer. Passing a bound raises `LimitError`, a subclass of
|
|
86
|
+
`ChunkedError`, so a server can answer 413 or 431 instead of 400:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from chunkedte import Decoder, LimitError, Limits
|
|
90
|
+
|
|
91
|
+
decoder = Decoder(Limits(max_body_size=1024, max_trailer_size=4096))
|
|
92
|
+
try:
|
|
93
|
+
decoder.feed(b"10000\r\n")
|
|
94
|
+
except LimitError as error:
|
|
95
|
+
print(error)
|
|
96
|
+
print(Limits())
|
|
97
|
+
# body exceeds max_body_size
|
|
98
|
+
# Limits(max_line_size=4096, max_extensions_size=16384, max_chunk_size=9223372036854775807, max_body_size=None, max_trailer_size=16384)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Encode in one call, or stream with `Encoder` and close with optional trailer fields:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from chunkedte import Encoder, encode
|
|
105
|
+
|
|
106
|
+
print(encode(b"abcd", chunk_size=2))
|
|
107
|
+
encoder = Encoder()
|
|
108
|
+
print(encoder.write(b"hello") + encoder.finish({"X-Checksum": "42"}))
|
|
109
|
+
# b'2\r\nab\r\n2\r\ncd\r\n0\r\n\r\n'
|
|
110
|
+
# b'5\r\nhello\r\n0\r\nX-Checksum: 42\r\n\r\n'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Decide whether a message body is chunked at all from its Transfer-Encoding and
|
|
114
|
+
Content-Length values (`None` when absent). `is_chunked` applies RFC 9112 sections 6.1 and
|
|
115
|
+
6.3 and raises `ChunkedError` on the combinations used for request smuggling:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from chunkedte import ChunkedError, is_chunked
|
|
119
|
+
|
|
120
|
+
print(is_chunked("gzip, chunked"), is_chunked(None, "12"))
|
|
121
|
+
print(is_chunked("gzip", request=False))
|
|
122
|
+
for te, cl in [("chunked", "12"), ("chunked, chunked", None), ("chunked, gzip", None)]:
|
|
123
|
+
try:
|
|
124
|
+
is_chunked(te, cl)
|
|
125
|
+
except ChunkedError as error:
|
|
126
|
+
print(error)
|
|
127
|
+
# True False
|
|
128
|
+
# False
|
|
129
|
+
# both Transfer-Encoding and Content-Length are present
|
|
130
|
+
# chunked is applied more than once
|
|
131
|
+
# chunked is not the final transfer coding of a request
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Command line
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
$ printf '5\r\nhello\r\n0\r\nX-Sum: 1\r\n\r\n' | chunkedte decode
|
|
138
|
+
hello
|
|
139
|
+
X-Sum: 1
|
|
140
|
+
$ printf 'abc' | chunkedte encode --chunk-size 2 --trailer "X-Sum: 1"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`decode` writes the body to stdout and the trailer fields to stderr; `encode` frames stdin
|
|
144
|
+
(or a file) as a chunked body. Both exit with status 1 and a message on bad input; body
|
|
145
|
+
bytes decoded before the error may already be on stdout, so treat the output of a failed
|
|
146
|
+
run as truncated. Trailer bytes outside printable ASCII are written as escapes.
|
|
147
|
+
`python -m chunkedte` works too.
|
|
148
|
+
|
|
149
|
+
## What "strict" means here
|
|
150
|
+
|
|
151
|
+
Every leniency in a chunked parser is a way for two HTTP processors to disagree on where a
|
|
152
|
+
message ends, which is how request smuggling works. So the decoder rejects:
|
|
153
|
+
|
|
154
|
+
- a chunk size that is not only hex digits (`+5`, `0x5`, `5_0`, ` 5`, `5 `);
|
|
155
|
+
- chunk extensions that do not follow the grammar (whitespace is allowed only around `;`
|
|
156
|
+
and `=`, values are tokens or quoted strings);
|
|
157
|
+
- a bare LF or a bare CR anywhere a CRLF is expected, including inside an extension;
|
|
158
|
+
- anything other than CRLF right after the chunk data;
|
|
159
|
+
- trailer lines that are not `name: value`, with whitespace before the colon, with control
|
|
160
|
+
bytes, or folded onto a continuation line (obs-fold).
|
|
161
|
+
|
|
162
|
+
Extensions are validated and then ignored, as RFC 9112 asks. The default `Limits` cap one
|
|
163
|
+
size line at 4 KiB, all extensions of a message at 16 KiB, the trailer section at 16 KiB and
|
|
164
|
+
a chunk size below 2**63 (so a forwarded size never wraps in a 64-bit parser); the body is
|
|
165
|
+
unbounded unless you set `max_body_size`. A line split over many small reads is scanned
|
|
166
|
+
once, so decoding stays linear in the input. When `feed` raises, the pieces decoded in that
|
|
167
|
+
call are dropped with it. Received trailers are returned as they came: RFC 9110 section
|
|
168
|
+
6.5.2 says not to merge them into the header section, so keep them apart (a `Content-Length`
|
|
169
|
+
trailer, say, must never reach your framing logic). The encoder refuses trailer names that
|
|
170
|
+
are not tokens, values with control bytes or edge whitespace, and the fields RFC 9110
|
|
171
|
+
section 6.5.1 needs before the content: framing and routing (Content-Length,
|
|
172
|
+
Transfer-Encoding, Trailer, TE, Host, Connection, Keep-Alive, Upgrade, Max-Forwards),
|
|
173
|
+
authentication (Authorization, Proxy-Authorization, WWW-Authenticate, Proxy-Authenticate,
|
|
174
|
+
Cookie, Set-Cookie), request modifiers and response controls (Expect, Range,
|
|
175
|
+
Cache-Control) and content format (Content-Type, Content-Encoding, Content-Range).
|
|
176
|
+
`is_chunked` leaves the rest of RFC 9112 section 6.3 to you: responses to HEAD, 1xx, 204,
|
|
177
|
+
304 and 2xx to CONNECT carry no body, and an HTTP/1.0 message with Transfer-Encoding has
|
|
178
|
+
faulty framing. There is no lenient mode.
|
|
179
|
+
|
|
180
|
+
## Why
|
|
181
|
+
|
|
182
|
+
Chunked transfer coding is simple to frame and easy to get subtly wrong: the terminating
|
|
183
|
+
`0` chunk, the closing blank line, extensions and trailers are routinely mishandled, and
|
|
184
|
+
the security advisories of the last few years (waitress, h11, aiohttp, netty, Go net/http,
|
|
185
|
+
Node.js) are all variations on a parser accepting bytes another parser reads differently.
|
|
186
|
+
The existing Python options are tied to one HTTP stack (`h11`, `aiohttp`) or to one server.
|
|
187
|
+
This is a standalone, typed, zero-dependency codec with the strict behaviour by default:
|
|
188
|
+
reach for it when you move chunked framing yourself and do not want a whole HTTP stack.
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT. See [LICENSE](LICENSE).
|
|
193
|
+
|
|
194
|
+
## Links
|
|
195
|
+
|
|
196
|
+
- Website: https://lianes8studio.com
|
|
197
|
+
- GitHub: https://github.com/JaviLianes8
|
|
198
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
chunkedte/__init__.py,sha256=eq8oeU3qOWLxiwajxdas9IHaF8Gs4Q-Uw9aM6THAoJA,478
|
|
2
|
+
chunkedte/__main__.py,sha256=bkGcWn079Lh8m4EhrjcooxJWrj2JDycl_NG9cYSbEPA,127
|
|
3
|
+
chunkedte/_cli.py,sha256=1BDahA4XtXMTS81S1XJH1wFdBxdu1qD_cMVTo4TcxqA,3364
|
|
4
|
+
chunkedte/_decode.py,sha256=H2OBxW_6x1Ya-cgzzcoXq1YGz7XD8tXEtO8zTspt02Y,6723
|
|
5
|
+
chunkedte/_encode.py,sha256=WuXHQF6Ck-8wGqjUjOixsk_SOpkTOp3PAVDjBUa_It0,3085
|
|
6
|
+
chunkedte/_errors.py,sha256=AwFBoRQ3p7Qe9_YdFolEDbuidgFIWOfRpj25UBWqLGI,333
|
|
7
|
+
chunkedte/_framing.py,sha256=HPq26FW0eIquFKTrZZ47GIig0rtnnYTEWoXPnYlKEFk,2907
|
|
8
|
+
chunkedte/_grammar.py,sha256=fWOOu-wJ49T7K7U6UnrZ2Q8fGa4yhTHmFGzZOkBWQY8,3345
|
|
9
|
+
chunkedte/_limits.py,sha256=jxYokXDOP4adzn978UUwRuiQsxrZy2fYlrRNOMy5N9w,1378
|
|
10
|
+
chunkedte/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
chunkedte-0.2.0.dist-info/METADATA,sha256=d-IglDkw9nW13VzBmgR85ddTWur5wkfR14P15LDdKSI,7987
|
|
12
|
+
chunkedte-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
13
|
+
chunkedte-0.2.0.dist-info/entry_points.txt,sha256=gqJM4lraYEUzxKaMMTXz1kPb0vHJXXvZWp9LYKgTo80,50
|
|
14
|
+
chunkedte-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
|
|
15
|
+
chunkedte-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.
|