diff --git a/backlot/graphql/body.py b/backlot/graphql/body.py index 8acef7725..d3013a67e 100644 --- a/backlot/graphql/body.py +++ b/backlot/graphql/body.py @@ -9,6 +9,7 @@ from __future__ import annotations +import base64 import json import json.decoder import math @@ -18,6 +19,8 @@ from decimal import Decimal from typing import Any +import brotli as brotli_codec + from backlot.graphql import node_utf8, v8_json # `json.decoder`'s whitespace and string scanner, and `json.scanner.NUMBER_RE` under `re.ASCII`: the @@ -47,9 +50,9 @@ def decode(raw: bytes) -> str: return text[1:] if text.startswith("\ufeff") else text -#: The most bytes Linear's ``body-parser`` reads, counted after :func:`_inflate`: api.linear.app -#: served a body of 10485760 bytes and refused one of 10485761 with :class:`Unread`'s 413 on -#: 2026-10-19 (UTC), each sent plain and gzipped. +#: The most bytes Linear's ``body-parser`` reads, counted after :func:`_inflate` or +#: :func:`_unbrotli`: api.linear.app served a body of 10485760 bytes and refused one of 10485761 +#: with :class:`Unread`'s 413 on 2026-10-19 (UTC), each sent plain and gzipped. LINEAR_LIMIT = 10 * 1024 * 1024 # The charsets iconv-lite names with `utf`, as `encodingExists` reads a name: in lower case, without @@ -93,8 +96,8 @@ def body_parser_text( ``utf-`` is a 415, then an encoding other than ``identity``, ``gzip``, ``deflate`` and ``br``, then an uncompressed body past ``limit`` the 413, which ``raw-body`` answers from ``Content-Length`` before it reads, then a ``utf-`` charset iconv-lite does not know the 415; - then :func:`_inflate` reads a compressed body, and the bytes are decoded in the charset's byte - order (:func:`_byte_order`). + then :func:`_inflate` or :func:`_unbrotli` reads a compressed body, and the bytes are decoded in + the charset's byte order (:func:`_byte_order`), or as UTF-7 (:func:`_utf7`). api.linear.app answered each step on 2026-10-19 (UTC): ``latin1``, ``iso-8859-1``, ``utf8`` and ``utf_16le`` were the charset 415, ``x-gzip``, ``bogus`` and ``gzip, gzip`` the encoding's, a @@ -103,8 +106,7 @@ def body_parser_text( and ``utf-16le``, ``utf-16-le``, ``utf-16be``, ``utf-16`` and ``utf-32`` with and without a byte-order mark, ``utf-32le``, ``GZIP``, ``deflate`` and an empty encoding were served. It decompressed a ``br`` body, which body-parser 1.x refuses with the encoding's 415, so it runs a - 2.x; such a body is read here as it arrives, and a ``utf-7`` or ``utf-7-imap`` one as UTF-8. - Without ``brotli``, ``br`` is the encoding's 415 as on a 1.x.""" + 2.x. Without ``brotli``, ``br`` is the encoding's 415 as on a 1.x.""" charset = _charset(content_type) if not charset.startswith("utf-"): raise Unread(415, f'unsupported charset "{charset.upper()}"') @@ -118,8 +120,12 @@ def body_parser_text( raise Unread(415, f'unsupported charset "{charset.upper()}"') if encoding in ("gzip", "deflate"): raw = _inflate(raw, gzip=encoding == "gzip", limit=limit) - if name in ("utf8", *_ICONV_UTF7): + elif encoding == "br": + raw = _unbrotli(raw, limit) + if name == "utf8": return decode(raw) + if name in _ICONV_UTF7: + return _utf7(raw, imap=name == "utf7imap") width = 2 if name.startswith("utf16") else 4 order = name[5:] or _byte_order(raw, width) # past `utf16` or `utf32` raw = raw[: len(raw) - len(raw) % width] @@ -127,9 +133,7 @@ def body_parser_text( text = raw.decode(f"utf-16-{order}", "surrogatepass") else: points = struct.unpack(f"{'<' if order == 'le' else '>'}{len(raw) // 4}I", raw) - text = "".join(chr(p) if p <= 0x10FFFF else "\ufffd" for p in points) - # The units join into a JavaScript string, where a high surrogate then a low one is a pair. - text = text.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass") + text = _js_string("".join(chr(p) if p <= 0x10FFFF else "\ufffd" for p in points)) return text[1:] if text.startswith("\ufeff") else text @@ -275,6 +279,74 @@ def _byte_order(raw: bytes, width: int) -> str: return "be" if score > 0 else "le" +_BASE64 = frozenset(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/") + + +def _utf7(raw: bytes, *, imap: bool) -> str: + """``raw`` as iconv-lite 0.7's ``utf-7`` or ``utf-7-imap`` decoder reads it in one ``write`` + and its ``end``, from ``encodings/utf7.js``. A ``+`` (``&`` for IMAP) opens a run of base64 + characters, IMAP's also taking ``,`` as ``/``, that the first other byte ends; a ``-`` ending it + is dropped, and one right after the opening byte stands for that byte. Each run is decoded + apart, and a run the body ends in is decoded in two, its characters up to a multiple of eight + and the rest: see :func:`_base64_units`. A byte outside a run is a character when below 0x80 + and U+FFFD otherwise. A byte-order mark is no character where it opens a run, the part of a run + the body ends in that ``end`` decodes, or what ``write`` returns, and is one where it opens what + ``end`` returns, which iconv-lite's ``StripBOMWrapper`` leaves alone: api.linear.app answered so + on 2026-10-27 and 2026-10-28 (UTC), the second time quoting the mark of `+/v/+/w`, a run whose + two marks ``end`` decodes, as the token it stopped at. ``raw-body`` hands the decoder each chunk + as it arrives, so this is a body read in one chunk; one that arrives in pieces reads otherwise + where a piece ends inside a run.""" + shift, alphabet = (0x26, _BASE64 | {0x2C}) if imap else (0x2B, _BASE64) + units: list[int] = [] + in_run, start, i = False, 0, 0 + while i < len(raw): + byte = raw[i] + if not in_run: + if byte == shift: + units += (b if b < 0x80 else 0xFFFD for b in raw[start:i]) + in_run, start = True, i + 1 + elif byte not in alphabet: + if i == start and byte == 0x2D: + units.append(shift) + else: + units += _base64_units(raw[start:i], imap) + in_run = False + if byte != 0x2D: + start = i + continue # the byte is read again outside the run + start = i + 1 + i += 1 + tail: list[int] = [] + if not in_run: + units += (b if b < 0x80 else 0xFFFD for b in raw[start:]) + else: + rest = raw[start:] + whole = len(rest) - len(rest) % 8 + units += _base64_units(rest[:whole], imap) + tail = _base64_units(rest[whole:], imap) + if units[:1] == [0xFEFF]: + units = units[1:] + return _js_string("".join(map(chr, units + tail))) + + +def _js_string(text: str) -> str: + """``text``, UTF-16 units each a character, as the JavaScript string they join into, where a + high surrogate then a low one is a pair.""" + return text.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass") + + +def _base64_units(chars: bytes, imap: bool) -> list[int]: + """The UTF-16 units a run of base64 ``chars`` gives: Node's ``Buffer.from(…, "base64")``, which + drops a last character that makes no byte, then iconv-lite's ``utf16-be`` decoder, which drops a + last odd byte and a byte-order mark at the start of the run.""" + if imap: + chars = chars.replace(b",", b"/") + chars = chars[: len(chars) - (len(chars) % 4 == 1)] + data = base64.b64decode(chars + b"=" * (-len(chars) % 4)) + units = [data[i] << 8 | data[i + 1] for i in range(0, len(data) - 1, 2)] + return units[1:] if units[:1] == [0xFEFF] else units + + def _inflate(raw: bytes, gzip: bool, limit: int = LINEAR_LIMIT) -> bytes: """``raw`` through Node's ``createGunzip`` or ``createInflate``, which ``body-parser`` pipes a body through, and :class:`Unread` where that stream fails or yields more than ``limit`` @@ -297,6 +369,53 @@ def _inflate(raw: bytes, gzip: bool, limit: int = LINEAR_LIMIT) -> bytes: return bytes(out) +def _unbrotli(raw: bytes, limit: int = LINEAR_LIMIT) -> bytes: + """``raw`` through Node's ``createBrotliDecompress``, which ``body-parser`` 2.x pipes a ``br`` + body through, and :class:`Unread` where that stream fails or yields more than ``limit`` bytes. + Node reads one stream and leaves what follows it unread; the messages are zlib's + ``Decompression failed`` for bytes Brotli cannot read and ``unexpected end of file`` for a + stream cut short. Measured on api.linear.app on 2026-10-27 (UTC): a stream then ``xx``, or then + a second stream, was served, no bytes and a stream short of its last three were the end of + file, the query sent plain was the decompression failure, and a stream one byte past + :data:`LINEAR_LIMIT` the 413 where one of that many was served. On 2026-10-29 (UTC) a stream + cut short past :data:`LINEAR_LIMIT`, then bytes Brotli cannot read, was the decompression + failure in 14 of 16 sends over three such bodies, and the 413 in the other two.""" + try: + out, finished = _brotli_read(raw, limit) + except brotli_codec.error: + # Python's decoder refuses bytes after the stream: the stream ends at the shortest prefix + # that finishes it, if any does. + lo, hi = 0, len(raw) + while lo < hi: + mid = (lo + hi) // 2 + try: + out, finished = _brotli_read(raw[:mid], limit) + except brotli_codec.error: + hi = mid + continue + if finished: + hi = mid + else: + lo = mid + 1 + try: + out, finished = _brotli_read(raw[:lo], limit) + except brotli_codec.error: + raise Unread(400, "Decompression failed") from None + if len(out) > limit: + raise Unread(413, "request entity too large") + if not finished: + raise Unread(400, "unexpected end of file") + return out + + +def _brotli_read(raw: bytes, limit: int) -> tuple[bytes, bool]: + """What a Brotli decoder fed ``raw`` gives, stopping once it holds more than ``limit`` bytes, + and whether the stream ended; ``brotli.error`` where the bytes are not Brotli's.""" + decoder = brotli_codec.Decompressor() + out = decoder.process(raw, output_buffer_limit=limit + 1) + return out, decoder.is_finished() + + class Double(float): """A number as the double ``JSON.parse`` reads it, printed as JavaScript's ``String`` prints a number, which is how graphql-js quotes a variable's value; graphql-core quotes a float with diff --git a/pyproject.toml b/pyproject.toml index 15d78ab13..ee716042e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,9 @@ dependencies = [ # (`x-amz-checksum-xxhash64`, `-xxhash3`, `-xxhash128`), which real compares with the body; the # standard library has none of them. "xxhash>=3.0", + # Brotli, which Linear's `body-parser` 2.x decompresses a `Content-Encoding: br` body with + # (`backlot.graphql.body`); the standard library has no Brotli decoder. + "brotli>=1.2", # SigV4a's ECDSA over P-256 (`backlot.sigv4a`), which the standard library has none of either; # `pyjwt[crypto]` above installs it already, and this names the import the S3 router makes. "cryptography>=3.4", diff --git a/tests/test_linear.py b/tests/test_linear.py index c03d15153..90b26749b 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -13,10 +13,12 @@ import hashlib import itertools import json +import random import zlib from datetime import datetime, timezone from urllib.parse import urlparse +import brotli import pytest from graphql import GraphQLError, build_client_schema, is_enum_type, parse, validate @@ -2223,6 +2225,18 @@ def _charset(name: str) -> tuple: _GZIP = (("Content-Encoding", "gzip"),) +_BR = (("Content-Encoding", "br"),) + + +def _br_cut_past_the_limit() -> bytes: + """A ``br`` stream of random hex, cut short where it has given more than :data:`_LIMIT`, then + bytes Brotli cannot read.""" + hex_ = random.Random(0).randbytes(_LIMIT // 2 + 2**15).hex().encode() + padded = b'{"query":"{ __typename }","pad":"' + hex_ + b'"}' + whole = brotli.compress(padded, quality=0) + return whole[: len(whole) * (_LIMIT + 24000) // len(padded)] + b"\xff" * 64 + + _EXPECTED_PROPERTY = _express( 400, b"Expected property name or '}' in JSON at position 1 (line 1 column 2)" ) @@ -2246,6 +2260,21 @@ def _charset(name: str) -> tuple: ) _LINEAR_READS = [ pytest.param(_GZIP, gzip.compress(_TYPENAME), None, id="gzip"), + pytest.param(_BR, brotli.compress(_TYPENAME), None, id="br"), + pytest.param((("Content-Encoding", "BR"),), brotli.compress(_TYPENAME), None, id="BR"), + pytest.param(_BR, _TYPENAME, _express(400, b"Decompression failed"), id="br over a plain body"), + pytest.param(_BR, brotli.compress(_TYPENAME) + b"xx", None, id="br then xx"), + pytest.param(_BR, brotli.compress(_TYPENAME) * 2, None, id="br twice"), + pytest.param(_BR, brotli.compress(_TYPENAME)[:-3], _CUT_SHORT, id="br cut short"), + pytest.param(_BR, b"", _CUT_SHORT, id="br over no bytes"), + pytest.param(_BR, brotli.compress(b""), _NO_KEYS, id="br of no bytes"), + pytest.param(_BR, brotli.compress(b"{"), _EXPECTED_PROPERTY, id="br of {"), + pytest.param( + _BR + _charset("utf-16le"), + brotli.compress(_TYPENAME.decode().encode("utf-16-le")), + None, + id="br of utf-16le", + ), pytest.param((("Content-Encoding", "GZIP"),), gzip.compress(_TYPENAME), None, id="GZIP"), pytest.param((("Content-Encoding", "deflate"),), zlib.compress(_TYPENAME), None, id="deflate"), pytest.param((("Content-Encoding", ""),), _TYPENAME, None, id="an empty encoding"), @@ -2425,6 +2454,153 @@ def _charset(name: str) -> tuple: pytest.param(_charset('"utf-8"'), _TYPENAME, None, id="utf-8 quoted"), pytest.param(_charset("utf-7"), _TYPENAME, None, id="utf-7"), pytest.param(_charset("utf-7-imap"), _TYPENAME, None, id="utf-7-imap"), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x+AGE- }"}', + b'{"errors":[{"message":"Cannot query field \\"xa\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, a run of three characters, closed by -", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x+AGEAYQ }"}', + b'{"errors":[{"message":"Cannot query field \\"xaa\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, a run closed by a space", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x+/v8AYQ- }"}', + b'{"errors":[{"message":"Cannot query field \\"xa\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, a run that opens with a byte-order mark", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x+AGEA/v8AYQ- }"}', + b'{"errors":[{"message":"Syntax Error: Unexpected character: U+00FE.","locations":[{"line":1,"column":5}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_PARSE_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, a byte-order mark inside a run", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x+- }"}', + b'{"errors":[{"message":"Syntax Error: Unexpected character: \\"+\\".","locations":[{"line":1,"column":4}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_PARSE_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, +- for +", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x+AG- }"}', + b'{"errors":[{"message":"Cannot query field \\"x\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, a run of two characters, one byte", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ __typename }"}+AAkACQAJ/v8', + None, + id="utf-7, a run the body ends in, a mark past eight characters", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ __typename }"}+AAkACQAJAP8', + b'{"error":"Unexpected non-whitespace character after JSON at position 29 (line 1 column 30)","details":{},"code":400}', + id="utf-7, a run the body ends in, a character past eight", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x&AGE- }"}', + b'{"errors":[{"message":"Syntax Error: Expected Name, found \\"&\\".","locations":[{"line":1,"column":4}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_PARSE_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, & outside IMAP", + ), + pytest.param( + _charset("utf-7-imap"), + b'{"query":"{ x&AGE,- }"}', + b'{"errors":[{"message":"Cannot query field \\"xa\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7-imap, an IMAP run with a comma", + ), + pytest.param( + _charset("utf-7-imap"), + b'{"query":"{ x&- }"}', + b'{"errors":[{"message":"Syntax Error: Expected Name, found \\"&\\".","locations":[{"line":1,"column":4}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_PARSE_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7-imap, &- for &", + ), + pytest.param( + _charset("utf-7-imap"), + b'{"query":"{ x+AGE- }"}', + b'{"errors":[{"message":"Syntax Error: Unexpected character: \\"+\\".","locations":[{"line":1,"column":4}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_PARSE_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7-imap, + inside IMAP", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x+ }"}', + b'{"errors":[{"message":"Cannot query field \\"x\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, an empty run closed by a space", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x+AGE}"}', + b'{"errors":[{"message":"Cannot query field \\"xa\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, a run closed by }", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x\xe9 }"}', + b'{"errors":[{"message":"Syntax Error: Unexpected character: U+FFFD.","locations":[{"line":1,"column":4}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_PARSE_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, a byte past 0x7f outside a run", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x+2D0- }"}', + b'{"errors":[{"message":"Syntax Error: Invalid character: U+D83D.","locations":[{"line":1,"column":4}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_PARSE_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, a run of a lone high surrogate", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"query($a: Int) { issues(first: $a) { nodes { id } } }","variables":{"a":"+2D3eAA-"}}', + b'{"errors":[{"message":"Variable \\"$a\\" got invalid value \\"\xf0\x9f\x98\x80\\"; Int cannot represent non-integer value: \\"\xf0\x9f\x98\x80\\"","locations":[{"line":1,"column":7}],"extensions":{"code":"BAD_USER_INPUT","type":"graphql error","userError":true}}]}\n', + id="utf-7, a run of a surrogate pair in a variable's value", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x\xe9+AGE- }"}', + b'{"errors":[{"message":"Syntax Error: Unexpected character: U+FFFD.","locations":[{"line":1,"column":4}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_PARSE_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, a byte past 0x7f before a run", + ), + pytest.param( + _charset("utf-7"), + b'{"query":"{ x+AGEAY- }"}', + b'{"errors":[{"message":"Cannot query field \\"xa\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7, a run of five characters", + ), + pytest.param( + _charset("utf-7"), + b'+/v7+/w-{"query":"{ __typename }"}', + b'{"error":"Unexpected token \'\xef\xbb\xbe\', \\"\xef\xbb\xbe\xef\xbb\xbf{\\"query\\"\\"... is not valid JSON","details":{},"code":400}', + id="utf-7, U+FEFE then a byte-order mark opening the body", + ), + pytest.param( + _charset("utf-7-imap"), + b'{"query":"{ x&AGEAY- }"}', + b'{"errors":[{"message":"Cannot query field \\"xa\\" on type \\"Query\\".","locations":[{"line":1,"column":3}],"extensions":{"http":{"status":400,"headers":{}},"code":"GRAPHQL_VALIDATION_FAILED","type":"graphql error","userError":true}}]}\n', + id="utf-7-imap, an IMAP run of five characters", + ), + pytest.param( + _charset("utf-7"), + b'+/v/+/w-{"query":"{ __typename }"}', + None, + id="utf-7, a run of two byte-order marks opening the body", + ), + *( + pytest.param( + _charset(charset), + body, + _express( + 400, b"Unexpected token '\xef\xbb\xbf', \\\"\xef\xbb\xbf\\\" is not valid JSON" + ), + id=f"{charset}, {label}", + ) + for charset, body, label in ( + ("utf-7", b"+/v/+/w", "a run the body ends in holding two marks"), + ("utf-7-imap", b"&/v/+/w", "a run the body ends in holding two marks"), + ("utf-7", b"+AG-+/v/+/w", "a run of no character, then that run"), + ) + ), pytest.param( _charset("utf-16le"), "5".encode("utf-16-le"), @@ -2520,6 +2696,20 @@ def _charset(name: str) -> tuple: pytest.param((), padded_query(_LIMIT + 1), _TOO_LARGE_DECLARED, id="past the limit"), pytest.param(_GZIP, gzip.compress(padded_query(_LIMIT)), None, id="the limit, gzipped"), pytest.param(_GZIP, gzip.compress(padded_query(_LIMIT + 1)), _TOO_LARGE, id="past it, gzipped"), + pytest.param(_BR, brotli.compress(padded_query(_LIMIT)), None, id="the limit, br"), + pytest.param(_BR, brotli.compress(padded_query(_LIMIT + 1)), _TOO_LARGE, id="past it, br"), + pytest.param( + _BR, brotli.compress(padded_query(2 * _LIMIT)), _TOO_LARGE, id="twice the limit, br" + ), + pytest.param( + _BR, brotli.compress(padded_query(_LIMIT + 1)) + b"xx", _TOO_LARGE, id="past it, br then xx" + ), + pytest.param( + _BR, + _br_cut_past_the_limit(), + _express(400, b"Decompression failed"), + id="past it, br cut short then bytes Brotli cannot read", + ), pytest.param( _GZIP, gzip.compress(padded_query(_LIMIT + 2)), _TOO_LARGE, id="past it by two, gzipped" ), diff --git a/tests/test_notion.py b/tests/test_notion.py index 276231db8..26ac8fc3d 100644 --- a/tests/test_notion.py +++ b/tests/test_notion.py @@ -3424,6 +3424,26 @@ def _defined(where: str, keys: list[str]) -> str: 400, _absent("body", "foo", "1"), ), + ( + "utf-7, a run for 1", + "POST", + "/search", + "", + b'{"page_size":+ADE-}', + {"Content-Type": "application/json; charset=utf-7"}, + 200, + None, + ), + ( + "utf-7, a run the body ends in holding two marks", + "POST", + "/search", + "", + b"+/v/+/w", + {"Content-Type": "application/json; charset=utf-7"}, + 400, + _INVALID_JSON, + ), ( "gzip", "POST", diff --git a/uv.lock b/uv.lock index 0df0b9d47..95f8926a6 100644 --- a/uv.lock +++ b/uv.lock @@ -400,6 +400,7 @@ wheels = [ name = "backlot" source = { editable = "." } dependencies = [ + { name = "brotli" }, { name = "cryptography" }, { name = "fastapi" }, { name = "graphql-core" }, @@ -515,6 +516,7 @@ requires-dist = [ { name = "backlot", extras = ["dev", "fsspec", "llamaindex", "mcp", "mirage", "official-sdk"], marker = "extra == 'all'" }, { name = "boto3", marker = "extra == 'llamaindex'", specifier = ">=1.40" }, { name = "boto3", marker = "extra == 'official-sdk'", specifier = ">=1.34" }, + { name = "brotli", specifier = ">=1.2" }, { name = "cryptography", specifier = ">=3.4" }, { name = "fastapi", specifier = ">=0.110" }, { name = "fastmcp", marker = "extra == 'mcp'", specifier = ">=4" }, @@ -679,6 +681,89 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" }, ] +[[package]] +name = "brotli" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/81/1546c983678a9b4e77cb4718eaedd1b2caf24a6cdbf1eb8775938d22aff7/brotli-1.2.1.tar.gz", hash = "sha256:02b2f7b5a83485efe448a44ddf7983def05c3bb1dcf917d4f7154070260e13de", size = 7389447, upload-time = "2026-10-22T14:00:12.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/f5/213512faee9562f069cc0d228a8dce8097d5517e15fdf48b8f05d53e6b55/brotli-1.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:970a3dbce807a3f5c2b8232036052bade01e4bc92ade37d73ea3750418307fa1", size = 863168, upload-time = "2026-10-22T13:57:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5a/7b31d12915cbd1b7bdf5a0ba290159290d7af5904bda55a17770cae89f00/brotli-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59ec359a56f44277ad424fb4b7cf1a0ae2fd855b35ba8f15dcfbcabb3fb51aeb", size = 443278, upload-time = "2026-10-22T13:57:40.157Z" }, + { url = "https://files.pythonhosted.org/packages/66/f3/49499c9b368dcd65d6bb64c799ff790ae8ecca28011b40a884c3473be3d9/brotli-1.2.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f51bca8fcac4266519533698c316f3d23340768a8e57c4796c9f6e0a4f353807", size = 1248964, upload-time = "2026-10-22T13:57:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f7/d98bcbc94b10c17bfc109157612b58481a383003e7df90925ff4d7534aaa/brotli-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b0faa0b9af9367748388345d5b12f6add2a9cf896481e72910e88fbfe14ecd54", size = 1409293, upload-time = "2026-10-22T13:57:42.5Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/910649b3ea76e50885a2f67f238d0aedc5d435627b24150cef2792c3c81b/brotli-1.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eaa5178a4c1fe619eff9cbe93b57919d426332e219870301d4ef5fc09a522ae9", size = 1430285, upload-time = "2026-10-22T13:57:43.725Z" }, + { url = "https://files.pythonhosted.org/packages/71/7e/c1d314406ce47a39180fc66114681d9ac10c2c667e14e4dc1a5e5089f4fb/brotli-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6b3c3d5794212a54434b4856f808f7bdf7ad48eacadaadb5b2346fc9a639365a", size = 1426895, upload-time = "2026-10-22T13:57:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/81/20/cee575e524f9908b406fec81a2505127f65efaa8447655d3061be941e4a0/brotli-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2df1678c2c25db2f669c613fe8b4e587d0dd24c6acf7746bad13d6e87e4b051d", size = 1491281, upload-time = "2026-10-22T13:57:46.218Z" }, + { url = "https://files.pythonhosted.org/packages/58/16/6b86ed3e2e475b9d8418d1e88bc1719197934df74f7b5142f9db5a86b4dd/brotli-1.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c4b68ce579b05c66bb18a16356a4f713bebac2446ee498dd8bb1ecab3da77b89", size = 1283748, upload-time = "2026-10-22T13:57:47.398Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ba/ca87fec021a63ad4f7f5e01d456c8b0d63defb11862feff6f22fd6238507/brotli-1.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f34405292fca45cd77419005c1ea35ba1c03eaff9042afe76d6fe189fe474669", size = 1599415, upload-time = "2026-10-22T13:57:48.577Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fe/6cdf401cc44a2c32d280cb58f032c071f15afd939c2f8579b0a28449065c/brotli-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d47a9f0ba5ee608f3f3a9c942dcd244443068ae5b0d90183dd396dea02236e4", size = 1494419, upload-time = "2026-10-22T13:57:49.783Z" }, + { url = "https://files.pythonhosted.org/packages/67/d4/1c409823928a1417b98d627d6ccf4a43a9dcbfcd448b2982fe3ae75149cc/brotli-1.2.1-cp311-cp311-win32.whl", hash = "sha256:23f0d23995100458d17f3bf5ce1e959fe46df894b472063a74266f7adeb2e72e", size = 333103, upload-time = "2026-10-22T13:57:50.962Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6d/1001898f9e6666027f5357f846d8d7b80e743a3e49703d128cf8ed91abe8/brotli-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:7248c282d7d6aef4ae89363da5fc3df7b9250eb04b252622955296eb5d0ea82f", size = 371026, upload-time = "2026-10-22T13:57:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/10/4d/cad8c72798bf22e442a01a3ce0049c69c5aef7255fb4f3bbca2b5f62ce64/brotli-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:f5fbda9703bce94a976e1a6bbdbbe0af25fb613a593a56091991359928b66c05", size = 380835, upload-time = "2026-10-22T13:57:52.99Z" }, + { url = "https://files.pythonhosted.org/packages/34/5d/dcf82117cbb506cfe0b75a00c34f2f7e850e5d933d80c0e50e2a191f1fee/brotli-1.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3975fb11f63e212b87a087e4a910187b522c6969a4989ca362d6f85f9dc36acd", size = 861663, upload-time = "2026-10-22T13:57:54.196Z" }, + { url = "https://files.pythonhosted.org/packages/98/c3/59d6445882214a47eab34e763aacc8dce18c4e7f9c5e2d9c95df9a7f7a3d/brotli-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de499e2492c8106a5e163d3cf5b6726e2797f9234cfbc6055fbb5f9d8f198f39", size = 442271, upload-time = "2026-10-22T13:57:55.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/7b/55b0c895c17dbe747b63e442d2c3a163cc838d116bf5560414a13cc284c9/brotli-1.2.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1f65a91f3576949793b8720b12a47e7ea06debc970a2da6d54ff8458053e2109", size = 1242808, upload-time = "2026-10-22T13:57:56.675Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/d8f2a81748b60733e8b03afd4bc1b70d3fb74a235b3b13dff0b2978e7f87/brotli-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:875975f91870682cca4371260522ad3f22d636e845b5c5355c52bc045c0f75c8", size = 1402417, upload-time = "2026-10-22T13:57:57.827Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b2/7addcc2c4965d0946e38a416b60af89700d54141845447f03fe4bff0293c/brotli-1.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c3f667dc60a875b12a5ecd7828062ccd97c0fb79c4adc6c8699d23d5ff2af7e1", size = 1424422, upload-time = "2026-10-22T13:57:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cc/b0b5b6b1ff4544bec3a702d5ad2e0badf22db296d16277a6ee59dabaffdc/brotli-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b9dd0173ca5e01d1eb307d3c184fe8c09bdc168ede560889b1018d216d38c61", size = 1420478, upload-time = "2026-10-22T13:58:00.484Z" }, + { url = "https://files.pythonhosted.org/packages/42/dd/1d9bdb8aa35c14ba45ce208768eb977810446b7537d9d418c2bcaae2ed20/brotli-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9307679936e86d7773eadfa444a4a4ee80f6f45230e93379875f4a6897a09a2b", size = 1485966, upload-time = "2026-10-22T13:58:01.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8e/86e64fc403888244e090a7a3d9fb35f6d6bb2219e789fc88064065464a3c/brotli-1.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb5df75d1b1c42d997adbb39eac61f2cdb48efa26304a0cb9c0b757c9bc20732", size = 1279161, upload-time = "2026-10-22T13:58:02.933Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ba/cbfa7d264d4cec16d5951071f1d04028e7024119c65a7bd909e77cafc9e6/brotli-1.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d6ef0035b6ae338a46951c144081c61fb044f4c1eb6d20acc5ba6277f666a4c0", size = 1594070, upload-time = "2026-10-22T13:58:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/7abc13404dde1665d2fe3cde3385e06d5f81aed0242c23979acc3f552afd/brotli-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:338856ae9ff63af4fc3dbbd6184a7276a6ae83df2243562e86e5ee5cf980cce7", size = 1489507, upload-time = "2026-10-22T13:58:05.591Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f6/8eacbc45a0a158897752fae9055a4d4a0cfdb1c8e15680d1815af3936da9/brotli-1.2.1-cp312-cp312-win32.whl", hash = "sha256:96e6966edcfae74ffac2522d2dd1a304450d18807a9791f5ed0a3232192c9437", size = 333017, upload-time = "2026-10-22T13:58:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/3e/82/b7f6c56393f2224ff4108cf1cc255921be1c0ae5962163edbe367c8a55bc/brotli-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:f5342b8256476554b4045db8b7bf4dc4044d0405485f220f0cd80764bc0d7faf", size = 371081, upload-time = "2026-10-22T13:58:08.082Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6d/eff775fb2de444f224aa52f79ba400ff963377436495c3f01ed52c3071dd/brotli-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:b0c32f0660bb21e810ffc1d3d04b6ea682e118b8141c6e12522a3dacadeb70ee", size = 380894, upload-time = "2026-10-22T13:58:09.157Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/d74a573b92ba449960eb088ce87dcb61a9dc6a0c5a7ba8d104ae180e64c3/brotli-1.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a84e0ae14d1efe782a9f143021c4e01d5ed986a140fe5070505cc5978eaf0592", size = 861657, upload-time = "2026-10-22T13:58:10.392Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bd/848b55fbd96fa1ce9f534aa39c930f864dbf224282f4a3808c0d35ea2819/brotli-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5302747f969d5ec925810176f7697712a3fa2ea0fae0c7a85aa4ad9325247fd", size = 442252, upload-time = "2026-10-22T13:58:11.647Z" }, + { url = "https://files.pythonhosted.org/packages/40/41/95d5e6774cb037851001038484bcf98b3af203664fe78d29dd03ece317e7/brotli-1.2.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7ec678c52c3ef5e37482a094d109c04cf9e4a0e2923dd1e0fbddb4bf133a976e", size = 1242773, upload-time = "2026-10-22T13:58:13.182Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/0942abad71d8b2aabd21472efa5be31ab0be31f98271d58767b9797eec82/brotli-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5b458950312830913f837e179ce9221000ab7a20b4e42d7b48c1fcd79140de1", size = 1402440, upload-time = "2026-10-22T13:58:14.513Z" }, + { url = "https://files.pythonhosted.org/packages/a3/53/2f7e666e61a1419d527904b6018a711f10650a851e7767358abf2919625e/brotli-1.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f37ccd159dae35d4914651d57142c8077aa67ab7e3ffef690b00bcb7a7dbd8e6", size = 1424432, upload-time = "2026-10-22T13:58:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/65/85/4c57c3cd2eee42878677b4943086e6b36cefc50214a5447028268045ce63/brotli-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:264fd5b200f6f6170cb551a4b9fcaabf8aa846ddaefe72db4d5f49f9d3743df9", size = 1420480, upload-time = "2026-10-22T13:58:17.007Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f7/65e75560e20d522db1f0c75a1b2612c6ebd542ff9df17b8920cdbe4cde68/brotli-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1329c80daa60752f1d94f507479504921e358e71746c5fce9b55b3d51ac038bc", size = 1485998, upload-time = "2026-10-22T13:58:18.422Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2a/6cc0cfc4170bd72f6fc0aef11984e7c583ecf0a7fec563bb4292c0d50e15/brotli-1.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:77ef5882c5318ffc3dd94c6abc6a8f1ec2af75d66522319f1a250a07d53d0546", size = 1279199, upload-time = "2026-10-22T13:58:19.709Z" }, + { url = "https://files.pythonhosted.org/packages/66/36/97f3eefc9295ce8d87b568433d38e0ca3345b82571341196817c96b2ca50/brotli-1.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6bd29ab84bb8972370cacd310e8a91651f54476d01de1cd1a4cf0bece5eb25e1", size = 1594118, upload-time = "2026-10-22T13:58:20.857Z" }, + { url = "https://files.pythonhosted.org/packages/03/61/687b36bd6b714270ff3d7ff96df6199336abc26c1fa98da276d9776d75b7/brotli-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed8e452f5cb34f59f4861d50d029c6203b5f8f11c13d4186264392a64b4def8d", size = 1489530, upload-time = "2026-10-22T13:58:22Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b4/379b2e5584be70110b154021ffa61b84dc3b476d2c031317f5c3fbcfcde5/brotli-1.2.1-cp313-cp313-win32.whl", hash = "sha256:c788fdab2eeeab671f5ab375ad010ef743f6c95be67f60f878e8876a51f6c671", size = 333014, upload-time = "2026-10-22T13:58:23.112Z" }, + { url = "https://files.pythonhosted.org/packages/96/c2/0c7abb25013043d4bcf7dd768d343bf7dee6ce99ac8c172f7ee4aef1c772/brotli-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:e6c4fa55eab6b5684b33239597876a18a35ce1280b63ab960164032afbb8783f", size = 371072, upload-time = "2026-10-22T13:58:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/81/68/5f9d96203fdce3ff9ba1186ce10220e26483ffa2384cde5430dd980e9057/brotli-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:67f4dceb22ce3a742751cee849644449b169684699aa548e77d771de304e963c", size = 380893, upload-time = "2026-10-22T13:58:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/641dd6072ea8c5e0ef7d1f8dc02149b4b0b77ef68fe12fc08b08320980d5/brotli-1.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b8b2fc7901b31d5885f26521cf2d74feccdc126a272ba1d879b7545bb25468bc", size = 863184, upload-time = "2026-10-22T13:58:27.201Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b8/3c4a50dc10f098a022519042eab84f11ef8c29abb9270fca76086ac392c3/brotli-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:de1bf30d819809a964060356032e6670c9923644666afaaf3d1719d05ddb844e", size = 443276, upload-time = "2026-10-22T13:58:28.47Z" }, + { url = "https://files.pythonhosted.org/packages/b1/a1/5ef931a404de2c61b2cd775e70b1652ff3f4d434f0589da22efbee590f4a/brotli-1.2.1-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ed62c15ce5e8b975b2710a0f7c8bb953cbffb472fccb05055be6afcba9520c19", size = 1243026, upload-time = "2026-10-22T13:58:29.934Z" }, + { url = "https://files.pythonhosted.org/packages/11/95/54e7278ee3fc589a6ac3a9bc191b5412542949411f0b38c56614d0f51282/brotli-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3942e2d365f009e5e7839114fa315e1a2bcfea12b52064a28f1a7281862a2a7", size = 1402517, upload-time = "2026-10-22T13:58:31.231Z" }, + { url = "https://files.pythonhosted.org/packages/4d/db/e065253a23a09d97dc7bd8cb47194a85479d71be35a51b5c575ae5095ebe/brotli-1.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6532d2c58018121a9eb5882656cf862cdbbd853600cb5fbccac59bcff14f66b0", size = 1424572, upload-time = "2026-10-22T13:58:32.468Z" }, + { url = "https://files.pythonhosted.org/packages/c4/07/8948582ce173e8e43cea9fadb8c3c3ea94800fb9814119c9239c1ae6c797/brotli-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a998857af4fcd19920bdf5f30f0f1a23f38472a703d33e49168cd4fd32438377", size = 1420557, upload-time = "2026-10-22T13:58:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d4/65073a06d6356a8f60b4c6ce0c3ade206d1118b8b8705b548f7b72ed549a/brotli-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f352f797b1477c46a20e45f4cbc3a0e258e61963c8ae8f79f84a7bfb4195b37e", size = 1486209, upload-time = "2026-10-22T13:58:35.331Z" }, + { url = "https://files.pythonhosted.org/packages/5a/19/8663beee29665f6fdf520b9c7ea333e0e930952176092be24c4c8bd8f791/brotli-1.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:be371af72af54b6e508b68a7a87768e05b7e13023f6c82a3e3f5e4d0e829e186", size = 1279217, upload-time = "2026-10-22T13:58:36.719Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/3851ec19707a1610412ecf4f057d7c1b5acbb8f8b211494a70e1f451ccf6/brotli-1.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dca1bf90b3a6a7440904f128464eb95bf46ac9da7ad3c85912483dd83128fd95", size = 1594292, upload-time = "2026-10-22T13:58:38.115Z" }, + { url = "https://files.pythonhosted.org/packages/8d/13/6bc9d4cb30fb0a475cd149c2075b9f63b93257a84ff70d0783a403f4b015/brotli-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4a7be59d374c445766fa4ac066a04d19a757365ae00a950053d0ab7388d1039", size = 1489719, upload-time = "2026-10-22T13:58:39.365Z" }, + { url = "https://files.pythonhosted.org/packages/04/da/1ec2e9b4f500693e56e6391f128dd0d304d6d2b7f0d5090e80a802b722f1/brotli-1.2.1-cp314-cp314-win32.whl", hash = "sha256:0a30e5df5ba985b67f85e13dfd7e5a68e186d8d9b5cff7d9a8904832bb413e81", size = 338208, upload-time = "2026-10-22T13:58:40.927Z" }, + { url = "https://files.pythonhosted.org/packages/24/5e/80cd38322263da5e92ea4935423f1d3cfa2509201673dce77b772c47b997/brotli-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:972a2e19dbaab47d0aa77c1d07f71c930bdc53538f2b33decc905e92a4be5cbd", size = 376707, upload-time = "2026-10-22T13:58:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/c5/35/cb35c489fcd0a813bbb7500465af5a7bac3bee7d996f6a211bac9096ef2c/brotli-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:a3dea7e1ac49e29306500a8627fa3eacabcc9e238d22495eea2d8e61de77065b", size = 390280, upload-time = "2026-10-22T13:58:43.256Z" }, + { url = "https://files.pythonhosted.org/packages/a2/72/85ea89c1088673a297e8b652cf0080668812144d5525db6512eadddf9d19/brotli-1.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eea17e3cdc6f9aecffe15fe9a9eeba8e8c049a5d347de1561eed2240cdf35a79", size = 863002, upload-time = "2026-10-22T13:58:44.325Z" }, + { url = "https://files.pythonhosted.org/packages/81/6a/bd4db4ef0c4d083d8a2e5a1d0b20239b186f261e72b14a9a0ec91c078e92/brotli-1.2.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:761db18dc32b24d00fbedca1013089eb3470053cefa8492bd9c07b11102d72bf", size = 443263, upload-time = "2026-10-22T13:58:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/79/1a/08635be453d523474fdfce81f588583a4e8049c75c04df849b5a278bf027/brotli-1.2.1-cp315-cp315-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:035f3bf861328d8b7fcb61f4a976f9913027158e5666116733d59ffc65f2745e", size = 1244379, upload-time = "2026-10-22T13:58:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/3b/94/52f2facc5ae118a68c4f0e715e03c9f854507a359608804d7de2dfe8d603/brotli-1.2.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:aa32b51702a0b6e9fddc0d1dc66cb6ed6af112f1bba0cac2be9039546a862b5c", size = 1404133, upload-time = "2026-10-22T13:58:47.756Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9b/768635c710b684574da55e12bcdbe36419fd8bf6cb97e01c72a4593f9747/brotli-1.2.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:810aea5958d6a2d92593fd27218df528644aeb72cc7f6d401e650752d6c98949", size = 1424875, upload-time = "2026-10-22T13:58:48.912Z" }, + { url = "https://files.pythonhosted.org/packages/46/39/7b7d53d7cea61f35f6b52ea149804523d01e532bf26017eed4351d82a7ad/brotli-1.2.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f00ff4b56caf151365c77988eb85a4a71eb26fe63047d4084d5614e06d65e3cf", size = 1425345, upload-time = "2026-10-22T13:58:50.253Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5e/652c788a4da5c6415c7ae31382626ba870020c9280e0f9be1a6dfec80261/brotli-1.2.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dafaeb38712f99e5c31ad7f02c366f1ddd3792921b94066015f8c49f197163b0", size = 1487925, upload-time = "2026-10-22T13:58:51.593Z" }, + { url = "https://files.pythonhosted.org/packages/85/c9/1b7753d02c106ff70b3d88056586ed7c2fe329d2aa5707cdaebe90372942/brotli-1.2.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:972e34c5cbbfe6c90358bfae38edae1a29d3456e920da5a4664c98f221ee4df8", size = 1276526, upload-time = "2026-10-22T13:58:53.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5f/6ae3e85bfb7a3f4711cea5a1b92d4377fddf1319a666cda213cc83b6c040/brotli-1.2.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:59ca34c7a83d7a3b842247183c426f891a95a8f57db41b7aac92195c51e8a83a", size = 1594681, upload-time = "2026-10-22T13:58:54.571Z" }, + { url = "https://files.pythonhosted.org/packages/8a/52/2808406d83e5462c086d69dbebb892f4ed78d2eb4b6755ea4b9e58357e3b/brotli-1.2.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90bd49511341dd7c40bb3acaa97cd8b9e5df4821a5714b842117e7069c7cc03e", size = 1486968, upload-time = "2026-10-22T13:58:56.008Z" }, + { url = "https://files.pythonhosted.org/packages/a9/53/35042a907c309af8defb423e58692bdc5e3e1fd3d03388892b7614270626/brotli-1.2.1-cp315-cp315-win32.whl", hash = "sha256:1880db6241276e191d3e34a6e4eea708c6629103c396f0ea21e75b0ce63a9a2c", size = 338212, upload-time = "2026-10-22T13:58:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/38/6c/883a32c23603e278ae93d8df31f0573b67382de827e6e05a6e9ee0b51896/brotli-1.2.1-cp315-cp315-win_amd64.whl", hash = "sha256:09a2f112a638aca15a88f298571788795fc45d62f2bca7f3870c1152153ef399", size = 376707, upload-time = "2026-10-22T13:58:58.292Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d5/2c574da9faf130a59c4c685ba1378f2f470b57fb9fa5d3b0f8b4e90579a0/brotli-1.2.1-cp315-cp315-win_arm64.whl", hash = "sha256:91097d66ebb8ca8d23372081a33405a44e2239445614888e38929bd7f5879dae", size = 390278, upload-time = "2026-10-22T13:58:59.512Z" }, + { url = "https://files.pythonhosted.org/packages/7f/eb/803fb4003fc74fb7202eade0832057729837c0929b690e7f691c1778f230/brotli-1.2.1-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:93269f11303fc9a3e3990b57e0533f41949546156d9f0e535571f313032563cc", size = 416747, upload-time = "2026-10-22T13:59:59.612Z" }, + { url = "https://files.pythonhosted.org/packages/60/fc/48f827dda08844d6313a97c6c34b7f0cffc8310bf4f2cd4d98fae6c198fb/brotli-1.2.1-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:87492842770cd772fce25e9cd279913eaea6c55a91a960b6302aedfa2ec7b152", size = 399671, upload-time = "2026-10-22T14:00:01.176Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2b/21aa1de7b7b5739f8d9b55c12100de794c42b10abd1a83da1a99072e2b46/brotli-1.2.1-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f06dd6a30438692d48c0d54de4b659cd55a57c2c2f6591c969c98d0a5523ab07", size = 404664, upload-time = "2026-10-22T14:00:02.357Z" }, + { url = "https://files.pythonhosted.org/packages/f1/22/bce8a2f6af49807728b4d3394a67190032b37170be0a9f870c98b0e1e4a6/brotli-1.2.1-pp311-pypy311_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb6a4d400aa531c73a718b337116fad77ba8dc6df34fa615c808bfc55ffb19ca", size = 398822, upload-time = "2026-10-22T14:00:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/9741f736300c89ef911f1aa950db9f91ad26e2c1c19d9ba4958776aee12e/brotli-1.2.1-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:cfa54e7f5bd5ef482e7f5336f7cf3c30ae608d9767e514dd930b7c30a03fa977", size = 457754, upload-time = "2026-10-22T14:00:04.729Z" }, + { url = "https://files.pythonhosted.org/packages/f5/29/62f7de3d08c53b09fe67b24b024b13ce3ec088fe0dd6940064719e61eba5/brotli-1.2.1-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:5f3da70516b1bd1b2aa183ec4ab5c02dfc6ad5c1184d47bb65ade25e710439b6", size = 416714, upload-time = "2026-10-22T14:00:06.054Z" }, + { url = "https://files.pythonhosted.org/packages/e2/66/3da6c4e928c55fc85f4cdaf0cbd41ebb1639ae70c71f9f26183818460d54/brotli-1.2.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:11aec6ea5c20cad61a33302e06baa48b0243bfa4d173bc2985dabd047e367d08", size = 399657, upload-time = "2026-10-22T14:00:07.095Z" }, + { url = "https://files.pythonhosted.org/packages/85/9d/626bb730a70507151667d950c9214003427b962a3876142c07ba52d96391/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00a50d063aae2e69c2eb28818ccf6dc3e51f2ea5b74e3c2082a47943ee23f399", size = 404727, upload-time = "2026-10-22T14:00:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/bb/34/fb0bbe56d822ee4c75b2383e518bdd579327f18f1cfedf1d779a585df01b/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37de1818ce469b3c37324c89d4d4dbb5e64461d9dc01c16f8abb2a17c465b132", size = 398927, upload-time = "2026-10-22T14:00:09.739Z" }, + { url = "https://files.pythonhosted.org/packages/4d/3f/43ea0a58dcb50c820465a0d62ccbbf6464c2e2cc4ebf582e64225dd67b4b/brotli-1.2.1-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:06a22fcbcae059c0679098232779095fc50a735a3fea02db5c79cab10b84d877", size = 457800, upload-time = "2026-10-22T14:00:10.978Z" }, +] + [[package]] name = "cachetools" version = "7.1.8"