tigrcorn-protocols 0.3.16.dev5__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.
Files changed (80) hide show
  1. tigrcorn_protocols/__init__.py +1 -0
  2. tigrcorn_protocols/_compression.py +219 -0
  3. tigrcorn_protocols/connect.py +107 -0
  4. tigrcorn_protocols/content_coding.py +179 -0
  5. tigrcorn_protocols/custom/__init__.py +3 -0
  6. tigrcorn_protocols/custom/adapters.py +18 -0
  7. tigrcorn_protocols/custom/registry.py +15 -0
  8. tigrcorn_protocols/flow/__init__.py +1 -0
  9. tigrcorn_protocols/flow/backpressure.py +17 -0
  10. tigrcorn_protocols/flow/buffers.py +29 -0
  11. tigrcorn_protocols/flow/credits.py +21 -0
  12. tigrcorn_protocols/flow/keepalive.py +85 -0
  13. tigrcorn_protocols/flow/timeouts.py +17 -0
  14. tigrcorn_protocols/flow/watermarks.py +16 -0
  15. tigrcorn_protocols/http1/__init__.py +16 -0
  16. tigrcorn_protocols/http1/keepalive.py +21 -0
  17. tigrcorn_protocols/http1/parser.py +481 -0
  18. tigrcorn_protocols/http1/serializer.py +198 -0
  19. tigrcorn_protocols/http1/state.py +9 -0
  20. tigrcorn_protocols/http2/__init__.py +16 -0
  21. tigrcorn_protocols/http2/codec.py +266 -0
  22. tigrcorn_protocols/http2/flow.py +35 -0
  23. tigrcorn_protocols/http2/handler.py +1303 -0
  24. tigrcorn_protocols/http2/hpack.py +393 -0
  25. tigrcorn_protocols/http2/state.py +226 -0
  26. tigrcorn_protocols/http2/streams.py +76 -0
  27. tigrcorn_protocols/http2/websocket.py +360 -0
  28. tigrcorn_protocols/http3/__init__.py +82 -0
  29. tigrcorn_protocols/http3/codec.py +148 -0
  30. tigrcorn_protocols/http3/handler/__init__.py +3 -0
  31. tigrcorn_protocols/http3/handler/core.py +1823 -0
  32. tigrcorn_protocols/http3/handler/webtransport.py +184 -0
  33. tigrcorn_protocols/http3/handler.py +3 -0
  34. tigrcorn_protocols/http3/qpack.py +843 -0
  35. tigrcorn_protocols/http3/state.py +129 -0
  36. tigrcorn_protocols/http3/streams.py +657 -0
  37. tigrcorn_protocols/http3/websocket.py +360 -0
  38. tigrcorn_protocols/lifespan/__init__.py +3 -0
  39. tigrcorn_protocols/lifespan/driver.py +83 -0
  40. tigrcorn_protocols/py.typed +1 -0
  41. tigrcorn_protocols/rawframed/__init__.py +5 -0
  42. tigrcorn_protocols/rawframed/codec.py +18 -0
  43. tigrcorn_protocols/rawframed/frames.py +28 -0
  44. tigrcorn_protocols/rawframed/handler.py +72 -0
  45. tigrcorn_protocols/rawframed/state.py +9 -0
  46. tigrcorn_protocols/registry.py +22 -0
  47. tigrcorn_protocols/scheduler/__init__.py +17 -0
  48. tigrcorn_protocols/scheduler/cancellation.py +40 -0
  49. tigrcorn_protocols/scheduler/dispatch.py +27 -0
  50. tigrcorn_protocols/scheduler/fairness.py +21 -0
  51. tigrcorn_protocols/scheduler/policy.py +12 -0
  52. tigrcorn_protocols/scheduler/priorities.py +8 -0
  53. tigrcorn_protocols/scheduler/quotas.py +19 -0
  54. tigrcorn_protocols/scheduler/runtime.py +156 -0
  55. tigrcorn_protocols/scheduler/tasks.py +31 -0
  56. tigrcorn_protocols/sessions/__init__.py +1 -0
  57. tigrcorn_protocols/sessions/base.py +16 -0
  58. tigrcorn_protocols/sessions/connection.py +12 -0
  59. tigrcorn_protocols/sessions/limits.py +12 -0
  60. tigrcorn_protocols/sessions/manager.py +31 -0
  61. tigrcorn_protocols/sessions/metadata.py +10 -0
  62. tigrcorn_protocols/sessions/quic.py +14 -0
  63. tigrcorn_protocols/streams/__init__.py +1 -0
  64. tigrcorn_protocols/streams/base.py +13 -0
  65. tigrcorn_protocols/streams/ids.py +5 -0
  66. tigrcorn_protocols/streams/multiplex.py +6 -0
  67. tigrcorn_protocols/streams/registry.py +22 -0
  68. tigrcorn_protocols/streams/singleplex.py +6 -0
  69. tigrcorn_protocols/websocket/__init__.py +1 -0
  70. tigrcorn_protocols/websocket/codec.py +31 -0
  71. tigrcorn_protocols/websocket/extensions.py +324 -0
  72. tigrcorn_protocols/websocket/frames.py +174 -0
  73. tigrcorn_protocols/websocket/handler.py +462 -0
  74. tigrcorn_protocols/websocket/handshake.py +66 -0
  75. tigrcorn_protocols/websocket/state.py +10 -0
  76. tigrcorn_protocols-0.3.16.dev5.dist-info/METADATA +240 -0
  77. tigrcorn_protocols-0.3.16.dev5.dist-info/RECORD +80 -0
  78. tigrcorn_protocols-0.3.16.dev5.dist-info/WHEEL +5 -0
  79. tigrcorn_protocols-0.3.16.dev5.dist-info/licenses/LICENSE +163 -0
  80. tigrcorn_protocols-0.3.16.dev5.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ """Protocol implementations and protocol registries."""
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ from tigrcorn_core.errors import ProtocolError
4
+
5
+ # Shared HPACK/QPACK Huffman tables from RFC 7541 Appendix B.
6
+ HUFFMAN_CODES: tuple[int, ...] = (
7
+ 8184, 8388568, 268435426, 268435427, 268435428, 268435429, 268435430, 268435431,
8
+ 268435432, 16777194, 1073741820, 268435433, 268435434, 1073741821, 268435435, 268435436,
9
+ 268435437, 268435438, 268435439, 268435440, 268435441, 268435442, 1073741822, 268435443,
10
+ 268435444, 268435445, 268435446, 268435447, 268435448, 268435449, 268435450, 268435451,
11
+ 20, 1016, 1017, 4090, 8185, 21, 248, 2042,
12
+ 1018, 1019, 249, 2043, 250, 22, 23, 24,
13
+ 0, 1, 2, 25, 26, 27, 28, 29,
14
+ 30, 31, 92, 251, 32764, 32, 4091, 1020,
15
+ 8186, 33, 93, 94, 95, 96, 97, 98,
16
+ 99, 100, 101, 102, 103, 104, 105, 106,
17
+ 107, 108, 109, 110, 111, 112, 113, 114,
18
+ 252, 115, 253, 8187, 524272, 8188, 16380, 34,
19
+ 32765, 3, 35, 4, 36, 5, 37, 38,
20
+ 39, 6, 116, 117, 40, 41, 42, 7,
21
+ 43, 118, 44, 8, 9, 45, 119, 120,
22
+ 121, 122, 123, 32766, 2044, 16381, 8189, 268435452,
23
+ 1048550, 4194258, 1048551, 1048552, 4194259, 4194260, 4194261, 8388569,
24
+ 4194262, 8388570, 8388571, 8388572, 8388573, 8388574, 16777195, 8388575,
25
+ 16777196, 16777197, 4194263, 8388576, 16777198, 8388577, 8388578, 8388579,
26
+ 8388580, 2097116, 4194264, 8388581, 4194265, 8388582, 8388583, 16777199,
27
+ 4194266, 2097117, 1048553, 4194267, 4194268, 8388584, 8388585, 2097118,
28
+ 8388586, 4194269, 4194270, 16777200, 2097119, 4194271, 8388587, 8388588,
29
+ 2097120, 2097121, 4194272, 2097122, 8388589, 4194273, 8388590, 8388591,
30
+ 1048554, 4194274, 4194275, 4194276, 8388592, 4194277, 4194278, 8388593,
31
+ 67108832, 67108833, 1048555, 524273, 4194279, 8388594, 4194280, 33554412,
32
+ 67108834, 67108835, 67108836, 134217694, 134217695, 67108837, 16777201, 33554413,
33
+ 524274, 2097123, 67108838, 134217696, 134217697, 67108839, 134217698, 16777202,
34
+ 2097124, 2097125, 67108840, 67108841, 268435453, 134217699, 134217700, 134217701,
35
+ 1048556, 16777203, 1048557, 2097126, 4194281, 2097127, 2097128, 8388595,
36
+ 4194282, 4194283, 33554414, 33554415, 16777204, 16777205, 67108842, 8388596,
37
+ 67108843, 134217702, 67108844, 67108845, 134217703, 134217704, 134217705, 134217706,
38
+ 134217707, 268435454, 134217708, 134217709, 134217710, 134217711, 134217712, 67108846,
39
+ 1073741823,
40
+ )
41
+
42
+ HUFFMAN_CODE_LENGTHS: tuple[int, ...] = (
43
+ 13, 23, 28, 28, 28, 28, 28, 28, 28, 24, 30, 28, 28, 30, 28, 28,
44
+ 28, 28, 28, 28, 28, 28, 30, 28, 28, 28, 28, 28, 28, 28, 28, 28,
45
+ 6, 10, 10, 12, 13, 6, 8, 11, 10, 10, 8, 11, 8, 6, 6, 6,
46
+ 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 7, 8, 15, 6, 12, 10,
47
+ 13, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
48
+ 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 13, 19, 13, 14, 6,
49
+ 15, 5, 6, 5, 6, 5, 6, 6, 6, 5, 7, 7, 6, 6, 6, 5,
50
+ 6, 7, 6, 5, 5, 6, 7, 7, 7, 7, 7, 15, 11, 14, 13, 28,
51
+ 20, 22, 20, 20, 22, 22, 22, 23, 22, 23, 23, 23, 23, 23, 24, 23,
52
+ 24, 24, 22, 23, 24, 23, 23, 23, 23, 21, 22, 23, 22, 23, 23, 24,
53
+ 22, 21, 20, 22, 22, 23, 23, 21, 23, 22, 22, 24, 21, 22, 23, 23,
54
+ 21, 21, 22, 21, 23, 22, 23, 23, 20, 22, 22, 22, 23, 22, 22, 23,
55
+ 26, 26, 20, 19, 22, 23, 22, 25, 26, 26, 26, 27, 27, 26, 24, 25,
56
+ 19, 21, 26, 27, 27, 26, 27, 24, 21, 21, 26, 26, 28, 27, 27, 27,
57
+ 20, 24, 20, 21, 22, 21, 21, 23, 22, 22, 25, 25, 24, 24, 26, 23,
58
+ 26, 27, 26, 26, 27, 27, 27, 27, 27, 28, 27, 27, 27, 27, 27, 26,
59
+ 30,
60
+ )
61
+
62
+ EOS_SYMBOL = 256
63
+
64
+ def encode_prefixed_integer(value: int, prefix_bits: int, prefix_mask: int = 0) -> bytes:
65
+ if value < 0:
66
+ raise ValueError("header-compression integers must be non-negative")
67
+ max_prefix = (1 << prefix_bits) - 1
68
+ if value < max_prefix:
69
+ return bytes([prefix_mask | value])
70
+ out = bytearray([prefix_mask | max_prefix])
71
+ value -= max_prefix
72
+ while value >= 128:
73
+ out.append((value & 0x7F) | 0x80)
74
+ value >>= 7
75
+ out.append(value)
76
+ return bytes(out)
77
+
78
+ def decode_prefixed_integer(
79
+ data: bytes,
80
+ offset: int,
81
+ prefix_bits: int,
82
+ *,
83
+ max_octets: int | None = None,
84
+ max_value: int | None = None,
85
+ ) -> tuple[int, int]:
86
+ if offset >= len(data):
87
+ raise ProtocolError("header-compression integer underflow")
88
+ max_prefix = (1 << prefix_bits) - 1
89
+ value = data[offset] & max_prefix
90
+ offset += 1
91
+ if value < max_prefix:
92
+ if max_value is not None and value > max_value:
93
+ raise ProtocolError("header-compression integer exceeds configured maximum")
94
+ return value, offset
95
+ shift = 0
96
+ octets = 0
97
+ while True:
98
+ if offset >= len(data):
99
+ raise ProtocolError("header-compression integer continuation underflow")
100
+ byte = data[offset]
101
+ offset += 1
102
+ octets += 1
103
+ if max_octets is not None and octets > max_octets:
104
+ raise ProtocolError("header-compression integer exceeds configured maximum")
105
+ value += (byte & 0x7F) << shift
106
+ if max_value is not None and value > max_value:
107
+ raise ProtocolError("header-compression integer exceeds configured maximum")
108
+ if not (byte & 0x80):
109
+ return value, offset
110
+ shift += 7
111
+
112
+ def huffman_encode(data: bytes) -> bytes:
113
+ if not data:
114
+ return b""
115
+ final_num = 0
116
+ final_len = 0
117
+ for byte in data:
118
+ code_len = HUFFMAN_CODE_LENGTHS[byte]
119
+ code = HUFFMAN_CODES[byte] & ((1 << code_len) - 1)
120
+ final_num = (final_num << code_len) | code
121
+ final_len += code_len
122
+ pad = (8 - (final_len % 8)) % 8
123
+ final_num = (final_num << pad) | ((1 << pad) - 1)
124
+ total_bytes = (final_len + pad) // 8
125
+ return final_num.to_bytes(total_bytes, "big")
126
+
127
+ class _TrieNode:
128
+ __slots__ = ("zero", "one", "symbol")
129
+ def __init__(self) -> None:
130
+ self.zero: _TrieNode | None = None
131
+ self.one: _TrieNode | None = None
132
+ self.symbol: int | None = None
133
+
134
+ def _build_huffman_tree() -> _TrieNode:
135
+ root = _TrieNode()
136
+ for symbol, (code, length) in enumerate(zip(HUFFMAN_CODES, HUFFMAN_CODE_LENGTHS)):
137
+ node = root
138
+ for shift in range(length - 1, -1, -1):
139
+ bit = (code >> shift) & 1
140
+ if bit:
141
+ if node.one is None:
142
+ node.one = _TrieNode()
143
+ node = node.one
144
+ else:
145
+ if node.zero is None:
146
+ node.zero = _TrieNode()
147
+ node = node.zero
148
+ if node.symbol is not None:
149
+ raise RuntimeError("duplicate Huffman code")
150
+ node.symbol = symbol
151
+ return root
152
+
153
+ _HUFFMAN_ROOT = _build_huffman_tree()
154
+
155
+ def huffman_decode(data: bytes, *, max_output_length: int | None = None) -> bytes:
156
+ if not data:
157
+ return b""
158
+ node = _HUFFMAN_ROOT
159
+ decoded = bytearray()
160
+ trailing_value = 0
161
+ trailing_bits = 0
162
+ for byte in data:
163
+ for shift in range(7, -1, -1):
164
+ bit = (byte >> shift) & 1
165
+ trailing_value = (trailing_value << 1) | bit
166
+ trailing_bits += 1
167
+ next_node = node.one if bit else node.zero
168
+ if next_node is None:
169
+ raise ProtocolError("invalid Huffman string")
170
+ node = next_node
171
+ if node.symbol is None:
172
+ continue
173
+ if node.symbol == EOS_SYMBOL:
174
+ raise ProtocolError("EOS symbol is not permitted in header strings")
175
+ decoded.append(node.symbol)
176
+ if max_output_length is not None and len(decoded) > max_output_length:
177
+ raise ProtocolError("header-compression string exceeds configured maximum")
178
+ node = _HUFFMAN_ROOT
179
+ trailing_value = 0
180
+ trailing_bits = 0
181
+ if node is not _HUFFMAN_ROOT:
182
+ if trailing_bits > 7 or trailing_value != (1 << trailing_bits) - 1:
183
+ raise ProtocolError("incomplete Huffman string")
184
+ return bytes(decoded)
185
+
186
+ def encode_prefixed_string(data: bytes, prefix_bits: int, prefix_mask: int = 0, *, huffman: bool = True) -> bytes:
187
+ payload = data
188
+ huffman_flag = 0
189
+ if huffman and data:
190
+ encoded = huffman_encode(data)
191
+ if len(encoded) < len(data):
192
+ payload = encoded
193
+ huffman_flag = 1 << (prefix_bits - 1)
194
+ return encode_prefixed_integer(len(payload), prefix_bits - 1, prefix_mask | huffman_flag) + payload
195
+
196
+ def decode_prefixed_string(
197
+ data: bytes,
198
+ offset: int,
199
+ prefix_bits: int,
200
+ *,
201
+ max_length: int | None = None,
202
+ max_decoded_length: int | None = None,
203
+ max_integer_octets: int | None = None,
204
+ ) -> tuple[bytes, int]:
205
+ if offset >= len(data):
206
+ raise ProtocolError("header-compression string underflow")
207
+ huffman = bool(data[offset] & (1 << (prefix_bits - 1)))
208
+ length, offset = decode_prefixed_integer(data, offset, prefix_bits - 1, max_octets=max_integer_octets, max_value=max_length)
209
+ if max_length is not None and length > max_length:
210
+ raise ProtocolError("header-compression string exceeds configured maximum")
211
+ end = offset + length
212
+ if end > len(data):
213
+ raise ProtocolError("header-compression string overflow")
214
+ payload = data[offset:end]
215
+ if huffman:
216
+ payload = huffman_decode(payload, max_output_length=max_decoded_length)
217
+ elif max_decoded_length is not None and len(payload) > max_decoded_length:
218
+ raise ProtocolError("header-compression string exceeds configured maximum")
219
+ return payload, end
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import ipaddress
5
+ from contextlib import suppress
6
+
7
+
8
+ def parse_connect_authority(authority: str) -> tuple[str, int]:
9
+ if authority.startswith('['):
10
+ end = authority.find(']')
11
+ if end == -1 or end + 2 > len(authority) or authority[end + 1] != ':':
12
+ raise ValueError('invalid CONNECT authority-form target')
13
+ host = authority[1:end]
14
+ port_text = authority[end + 2:]
15
+ else:
16
+ if authority.count(':') != 1:
17
+ raise ValueError('invalid CONNECT authority-form target')
18
+ host, port_text = authority.rsplit(':', 1)
19
+ port = int(port_text)
20
+ if not host or port <= 0 or port > 65535:
21
+ raise ValueError('invalid CONNECT authority-form target')
22
+ return host, port
23
+
24
+
25
+ async def half_close_tcp_writer(writer: asyncio.StreamWriter) -> None:
26
+ if writer.is_closing():
27
+ return
28
+ if writer.can_write_eof():
29
+ with suppress(Exception):
30
+ writer.write_eof()
31
+ await writer.drain()
32
+ return
33
+ writer.close()
34
+ with suppress(Exception):
35
+ await writer.wait_closed()
36
+
37
+
38
+ async def close_tcp_writer(writer: asyncio.StreamWriter) -> None:
39
+ if writer.is_closing():
40
+ return
41
+ writer.close()
42
+ with suppress(Exception):
43
+ await writer.wait_closed()
44
+
45
+
46
+
47
+ def _split_allow_entry(entry: str) -> tuple[str, str | None]:
48
+ entry = entry.strip()
49
+ if not entry:
50
+ raise ValueError('empty CONNECT allowlist entry')
51
+ if entry.startswith('['):
52
+ if ']:' in entry:
53
+ host, port = entry.rsplit(':', 1)
54
+ return host[1:-1], port
55
+ return entry[1:-1], None
56
+ if '/' in entry:
57
+ if entry.count(':') == 1 and entry.rsplit(':', 1)[1].isdigit():
58
+ network, port = entry.rsplit(':', 1)
59
+ return network, port
60
+ return entry, None
61
+ if entry.count(':') == 1 and entry.rsplit(':', 1)[1].isdigit():
62
+ host, port = entry.rsplit(':', 1)
63
+ return host, port
64
+ return entry, None
65
+
66
+
67
+ def validate_connect_allow_entry(entry: str) -> str:
68
+ host_or_network, port_text = _split_allow_entry(entry)
69
+ if port_text is not None:
70
+ port = int(port_text)
71
+ if port <= 0 or port > 65535:
72
+ raise ValueError('invalid CONNECT allowlist port')
73
+ if '/' in host_or_network:
74
+ ipaddress.ip_network(host_or_network, strict=False)
75
+ elif not host_or_network:
76
+ raise ValueError('empty CONNECT allowlist host')
77
+ return entry
78
+
79
+
80
+ def is_connect_allowed(host: str, port: int, allowlist: list[str] | tuple[str, ...]) -> bool:
81
+ if not allowlist:
82
+ return False
83
+ try:
84
+ address = ipaddress.ip_address(host)
85
+ except ValueError:
86
+ address = None
87
+ normalized_host = host.lower()
88
+ for raw in allowlist:
89
+ try:
90
+ host_or_network, port_text = _split_allow_entry(raw)
91
+ except ValueError:
92
+ continue
93
+ if port_text is not None and int(port_text) != port:
94
+ continue
95
+ if '/' in host_or_network:
96
+ if address is None:
97
+ continue
98
+ try:
99
+ network = ipaddress.ip_network(host_or_network, strict=False)
100
+ except ValueError:
101
+ continue
102
+ if address in network:
103
+ return True
104
+ continue
105
+ if normalized_host == host_or_network.lower():
106
+ return True
107
+ return False
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import zlib
5
+ from dataclasses import dataclass
6
+
7
+ try: # pragma: no cover - optional dependency surface
8
+ import brotli # type: ignore[import-not-found]
9
+ except Exception: # pragma: no cover - optional dependency surface
10
+ brotli = None # type: ignore[assignment]
11
+
12
+ from tigrcorn_protocols.http1.serializer import response_allows_body
13
+ from tigrcorn_core.utils.headers import append_if_missing, get_header
14
+
15
+ _SUPPORTED_ENCODINGS = ('br', 'gzip', 'deflate')
16
+
17
+
18
+ def _available_supported_encodings(supported: tuple[str, ...]) -> tuple[str, ...]:
19
+ available: list[str] = []
20
+ for coding in supported:
21
+ if coding == 'br' and brotli is None:
22
+ continue
23
+ if coding not in available:
24
+ available.append(coding)
25
+ return tuple(available)
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ContentCodingSelection:
30
+ coding: str | None
31
+ identity_acceptable: bool = True
32
+ explicit_identity_forbidden: bool = False
33
+
34
+ @property
35
+ def not_acceptable(self) -> bool:
36
+ return self.coding is None and not self.identity_acceptable
37
+
38
+
39
+
40
+ def _parse_qvalue(raw: str) -> float:
41
+ try:
42
+ value = float(raw)
43
+ except ValueError:
44
+ return 0.0
45
+ if value < 0.0:
46
+ return 0.0
47
+ if value > 1.0:
48
+ return 1.0
49
+ return value
50
+
51
+
52
+
53
+ def select_content_coding(
54
+ request_headers: list[tuple[bytes, bytes]] | tuple[tuple[bytes, bytes], ...],
55
+ *,
56
+ supported: tuple[str, ...] = _SUPPORTED_ENCODINGS,
57
+ ) -> ContentCodingSelection:
58
+ supported = _available_supported_encodings(supported)
59
+ header_value = get_header(request_headers, b'accept-encoding')
60
+ if header_value is None:
61
+ return ContentCodingSelection(coding=None, identity_acceptable=True)
62
+
63
+ identity_q = 1.0
64
+ wildcard_q: float | None = None
65
+ coding_q: dict[str, float] = {}
66
+ order: dict[str, int] = {}
67
+ for index, part in enumerate(header_value.decode('ascii', 'ignore').split(',')):
68
+ token = part.strip()
69
+ if not token:
70
+ continue
71
+ name, *params = [piece.strip() for piece in token.split(';')]
72
+ lower = name.lower()
73
+ q = 1.0
74
+ for param in params:
75
+ if '=' not in param:
76
+ continue
77
+ key, value = param.split('=', 1)
78
+ if key.strip().lower() == 'q':
79
+ q = _parse_qvalue(value.strip())
80
+ if lower == 'identity':
81
+ identity_q = q
82
+ elif lower == '*':
83
+ wildcard_q = q
84
+ else:
85
+ coding_q[lower] = q
86
+ order.setdefault(lower, index)
87
+
88
+ chosen: tuple[float, int, str] | None = None
89
+ for index, encoding in enumerate(supported):
90
+ q = coding_q.get(encoding)
91
+ if q is None:
92
+ q = wildcard_q if wildcard_q is not None else 0.0
93
+ if q <= 0.0:
94
+ continue
95
+ rank = (-q, order.get(encoding, 1000 + index), encoding)
96
+ if chosen is None or rank < chosen:
97
+ chosen = rank
98
+ if chosen is not None:
99
+ return ContentCodingSelection(coding=chosen[2], identity_acceptable=identity_q > 0.0, explicit_identity_forbidden=identity_q <= 0.0)
100
+ return ContentCodingSelection(coding=None, identity_acceptable=identity_q > 0.0, explicit_identity_forbidden=identity_q <= 0.0)
101
+
102
+
103
+
104
+ def encode_content(coding: str, payload: bytes) -> bytes:
105
+ if coding == 'gzip':
106
+ return gzip.compress(payload)
107
+ if coding == 'deflate':
108
+ return zlib.compress(payload)
109
+ if coding == 'br':
110
+ if brotli is None:
111
+ raise RuntimeError('brotli support is not available; install tigrcorn[compression]')
112
+ return brotli.compress(payload)
113
+ raise ValueError(f'unsupported content coding: {coding}')
114
+
115
+
116
+
117
+ def _replace_content_length(headers: list[tuple[bytes, bytes]], payload_length: int) -> list[tuple[bytes, bytes]]:
118
+ filtered = [(name.lower(), value) for name, value in headers if name.lower() not in {b'content-length'}]
119
+ filtered.append((b'content-length', str(payload_length).encode('ascii')))
120
+ return filtered
121
+
122
+
123
+
124
+ def apply_http_content_coding(
125
+ *,
126
+ request_headers: list[tuple[bytes, bytes]] | tuple[tuple[bytes, bytes], ...],
127
+ response_headers: list[tuple[bytes, bytes]],
128
+ body: bytes,
129
+ status: int,
130
+ policy: str = 'allowlist',
131
+ supported: tuple[str, ...] = _SUPPORTED_ENCODINGS,
132
+ ) -> tuple[int, list[tuple[bytes, bytes]], bytes, ContentCodingSelection]:
133
+ normalized_headers = [(bytes(name).lower(), bytes(value)) for name, value in response_headers]
134
+ supported = _available_supported_encodings(tuple(str(item).lower() for item in supported))
135
+ header_value = get_header(request_headers, b'accept-encoding')
136
+ if policy == 'identity-only':
137
+ identity_forbidden = False
138
+ if header_value is not None:
139
+ lowered = header_value.decode('ascii', 'ignore').lower()
140
+ identity_forbidden = 'identity;q=0' in lowered and '*;q=0' in lowered
141
+ selection = ContentCodingSelection(coding=None, identity_acceptable=not identity_forbidden, explicit_identity_forbidden=identity_forbidden)
142
+ else:
143
+ selection = select_content_coding(request_headers, supported=supported)
144
+
145
+ if not response_allows_body(status):
146
+ return status, normalized_headers, body, selection
147
+ if get_header(normalized_headers, b'content-encoding') is not None:
148
+ return status, normalized_headers, body, selection
149
+ if not body:
150
+ return status, normalized_headers, body, selection
151
+
152
+ if selection.not_acceptable:
153
+ headers = _replace_content_length([(b'content-type', b'text/plain; charset=utf-8')], len(b'not acceptable'))
154
+ append_if_missing(headers, b'vary', b'accept-encoding')
155
+ return 406, headers, b'not acceptable', selection
156
+
157
+ if policy == 'strict' and header_value is not None and selection.coding is None:
158
+ headers = _replace_content_length([(b'content-type', b'text/plain; charset=utf-8')], len(b'not acceptable'))
159
+ append_if_missing(headers, b'vary', b'accept-encoding')
160
+ return 406, headers, b'not acceptable', selection
161
+
162
+ if selection.coding is None:
163
+ return status, normalized_headers, body, selection
164
+
165
+ encoded = encode_content(selection.coding, body)
166
+ headers = [(name.lower(), value) for name, value in normalized_headers if name.lower() not in {b'content-length', b'content-encoding'}]
167
+ headers.append((b'content-encoding', selection.coding.encode('ascii')))
168
+ append_if_missing(headers, b'vary', b'accept-encoding')
169
+ headers = _replace_content_length(headers, len(encoded))
170
+ return status, headers, encoded, selection
171
+
172
+
173
+
174
+ __all__ = [
175
+ 'ContentCodingSelection',
176
+ 'apply_http_content_coding',
177
+ 'encode_content',
178
+ 'select_content_coding',
179
+ ]
@@ -0,0 +1,3 @@
1
+ from .registry import CustomProtocolRegistry
2
+
3
+ __all__ = ["CustomProtocolRegistry"]
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from tigrcorn_asgi.events.custom import stream_receive, stream_send
4
+
5
+
6
+ def adapt_scope(scope: dict) -> dict:
7
+ adapted = dict(scope)
8
+ adapted.setdefault('extensions', {})
9
+ adapted['extensions'].setdefault('tigrcorn.custom', {})
10
+ return adapted
11
+
12
+
13
+ def adapt_inbound(payload: bytes, *, more_data: bool = False) -> dict:
14
+ return stream_receive(payload, more_data=more_data)
15
+
16
+
17
+ def adapt_outbound(payload: bytes, *, more_data: bool = False) -> dict:
18
+ return stream_send(payload, more_data=more_data)
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Callable, Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class CustomProtocolRegistry:
9
+ handlers: dict[str, Callable[..., Any]] = field(default_factory=dict)
10
+
11
+ def register(self, name: str, handler: Callable[..., Any]) -> None:
12
+ self.handlers[name] = handler
13
+
14
+ def get(self, name: str):
15
+ return self.handlers[name]
@@ -0,0 +1 @@
1
+ """Flow-control helpers."""
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class BackpressureState:
8
+ paused: bool = False
9
+ high_water: int = 64 * 1024
10
+ low_water: int = 16 * 1024
11
+
12
+ def update(self, buffered: int) -> bool:
13
+ if buffered >= self.high_water:
14
+ self.paused = True
15
+ elif buffered <= self.low_water:
16
+ self.paused = False
17
+ return self.paused
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class BufferLimits:
8
+ read_limit: int = 64 * 1024
9
+ write_limit: int = 64 * 1024
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class ByteBuffer:
14
+ limit: int = 64 * 1024
15
+ data: bytearray = field(default_factory=bytearray)
16
+
17
+ def append(self, payload: bytes) -> None:
18
+ if len(self.data) + len(payload) > self.limit:
19
+ raise BufferError('buffer limit exceeded')
20
+ self.data.extend(payload)
21
+
22
+ def take(self, n: int = -1) -> bytes:
23
+ if n < 0 or n >= len(self.data):
24
+ payload = bytes(self.data)
25
+ self.data.clear()
26
+ return payload
27
+ payload = bytes(self.data[:n])
28
+ del self.data[:n]
29
+ return payload
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class CreditWindow:
8
+ remaining: int
9
+
10
+ def consume(self, n: int) -> None:
11
+ if n < 0:
12
+ raise ValueError('credit consumption must be non-negative')
13
+ self.remaining = max(0, self.remaining - n)
14
+
15
+ def refill(self, n: int) -> None:
16
+ if n < 0:
17
+ raise ValueError('credit refill must be non-negative')
18
+ self.remaining += n
19
+
20
+ def available(self, n: int = 1) -> bool:
21
+ return self.remaining >= n