without-http 0.0.4__tar.gz → 0.0.5__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: without-http
3
- Version: 0.0.4
3
+ Version: 0.0.5
4
4
  Summary: A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets.
5
5
  Author: Josh Karpel
6
6
  Author-email: Josh Karpel <josh.karpel@gmail.com>
@@ -16,8 +16,8 @@ Classifier: Topic :: Internet :: WWW/HTTP
16
16
  Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
17
17
  Classifier: Topic :: Software Development :: Libraries
18
18
  Classifier: Typing :: Typed
19
- Requires-Dist: without-core==0.0.4
20
- Requires-Dist: without-asgi==0.0.4
19
+ Requires-Dist: without-core==0.0.5
20
+ Requires-Dist: without-asgi==0.0.5
21
21
  Requires-Dist: h11>=0.16
22
22
  Requires-Dist: h2>=4.1
23
23
  Requires-Dist: wsproto>=1.2
@@ -29,7 +29,7 @@ Description-Content-Type: text/markdown
29
29
  # without-http
30
30
 
31
31
  A sans-IO-backed ASGI **server** and **HTTP client** for `without`. Where
32
- [`without-asgi`](../without-asgi) is the *app* side of the ASGI boundary (it turns
32
+ [`without-asgi`](../without_asgi) is the *app* side of the ASGI boundary (it turns
33
33
  a server's `receive`/`send` into typed streams), `without-http` is the *server*
34
34
  side: it owns the socket and the HTTP wire protocol, and drives any ASGI app via
35
35
  `app(scope, receive, send)`.
@@ -1,7 +1,7 @@
1
1
  # without-http
2
2
 
3
3
  A sans-IO-backed ASGI **server** and **HTTP client** for `without`. Where
4
- [`without-asgi`](../without-asgi) is the *app* side of the ASGI boundary (it turns
4
+ [`without-asgi`](../without_asgi) is the *app* side of the ASGI boundary (it turns
5
5
  a server's `receive`/`send` into typed streams), `without-http` is the *server*
6
6
  side: it owns the socket and the HTTP wire protocol, and drives any ASGI app via
7
7
  `app(scope, receive, send)`.
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "without-http"
7
- version = "0.0.4"
7
+ version = "0.0.5"
8
8
  description = "A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -23,8 +23,8 @@ classifiers = [
23
23
  "Typing :: Typed",
24
24
  ]
25
25
  dependencies = [
26
- "without-core==0.0.4",
27
- "without-asgi==0.0.4",
26
+ "without-core==0.0.5",
27
+ "without-asgi==0.0.5",
28
28
  "h11>=0.16",
29
29
  "h2>=4.1",
30
30
  "wsproto>=1.2",
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "without-http"
7
- version = "0.0.4"
7
+ version = "0.0.5"
8
8
  description = "A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,8 +26,8 @@ classifiers = [
26
26
  "Typing :: Typed",
27
27
  ]
28
28
  dependencies = [
29
- "without-core==0.0.4",
30
- "without-asgi==0.0.4",
29
+ "without-core==0.0.5",
30
+ "without-asgi==0.0.5",
31
31
  "h11>=0.16",
32
32
  "h2>=4.1",
33
33
  "wsproto>=1.2",
@@ -1,4 +1,5 @@
1
1
  from without_http.client import DEFAULT_DECOMPRESSORS
2
+ from without_http.client import GZIP_CONTAINER
2
3
  from without_http.client import USER_AGENT
3
4
  from without_http.client import Client
4
5
  from without_http.client import ClientMiddleware
@@ -13,10 +14,12 @@ from without_http.client import Resolve
13
14
  from without_http.client import ResponseBody
14
15
  from without_http.client import ResponseHead
15
16
  from without_http.client import ResponseTrailers
17
+ from without_http.client import StreamingCompressor
16
18
  from without_http.client import add_headers
17
19
  from without_http.client import basic_auth
18
20
  from without_http.client import bearer_auth
19
21
  from without_http.client import brotli_compress
22
+ from without_http.client import brotli_compressor
20
23
  from without_http.client import compressing
21
24
  from without_http.client import cookies
22
25
  from without_http.client import deadline
@@ -24,12 +27,14 @@ from without_http.client import decompress
24
27
  from without_http.client import default_headers
25
28
  from without_http.client import follow_redirects
26
29
  from without_http.client import gzip_compress
30
+ from without_http.client import gzip_compressor
27
31
  from without_http.client import request
28
32
  from without_http.client import stack
29
33
  from without_http.client import tcp_connect
30
34
  from without_http.client import user_agent
31
35
  from without_http.client import wrap
32
36
  from without_http.client import zstd_compress
37
+ from without_http.client import zstd_compressor
33
38
  from without_http.h2_wire import early_hint_headers
34
39
  from without_http.h2_wire import request_headers
35
40
  from without_http.h2_wire import response_headers
@@ -64,6 +69,7 @@ from without_http.ws_wire import ws_events_from_outbound
64
69
  __all__ = [
65
70
  "ALPN_PROTOCOLS",
66
71
  "DEFAULT_DECOMPRESSORS",
72
+ "GZIP_CONTAINER",
67
73
  "USER_AGENT",
68
74
  "Client",
69
75
  "ClientMiddleware",
@@ -85,12 +91,14 @@ __all__ = [
85
91
  "ResponseTrailers",
86
92
  "Server",
87
93
  "SocketOptions",
94
+ "StreamingCompressor",
88
95
  "Timeout",
89
96
  "WriteTimeout",
90
97
  "add_headers",
91
98
  "basic_auth",
92
99
  "bearer_auth",
93
100
  "brotli_compress",
101
+ "brotli_compressor",
94
102
  "compressing",
95
103
  "cookies",
96
104
  "deadline",
@@ -101,6 +109,7 @@ __all__ = [
101
109
  "extensions_with_tls",
102
110
  "follow_redirects",
103
111
  "gzip_compress",
112
+ "gzip_compressor",
104
113
  "h11_events_from_outbound",
105
114
  "inbound_from_event",
106
115
  "is_websocket_upgrade",
@@ -124,4 +133,5 @@ __all__ = [
124
133
  "wrap",
125
134
  "ws_events_from_outbound",
126
135
  "zstd_compress",
136
+ "zstd_compressor",
127
137
  ]
@@ -23,6 +23,7 @@ from datetime import datetime
23
23
  from datetime import timedelta
24
24
  from email.utils import parsedate_to_datetime
25
25
  from importlib import metadata
26
+ from types import MappingProxyType
26
27
  from typing import NamedTuple
27
28
  from typing import Protocol
28
29
  from typing import Self
@@ -45,6 +46,12 @@ from without import stack
45
46
  from without_asgi import Content
46
47
  from without_asgi import RawHeaders
47
48
  from without_asgi import StreamingContent
49
+ from without_asgi.compression import GZIP_CONTAINER
50
+ from without_asgi.compression import Compressor
51
+ from without_asgi.compression import StreamingCompressor
52
+ from without_asgi.compression import brotli_compressor
53
+ from without_asgi.compression import gzip_compressor
54
+ from without_asgi.compression import zstd_compressor
48
55
  from without_asgi.headers import merge
49
56
 
50
57
  from without_http.h2_wire import request_headers
@@ -1615,26 +1622,28 @@ def follow_redirects(max_hops: int = 5) -> ClientMiddleware:
1615
1622
 
1616
1623
 
1617
1624
  _REQUEST_FRAMING_HEADERS = frozenset({b"content-length", b"transfer-encoding"})
1618
- _GZIP_CONTAINER = zlib.MAX_WBITS | 16 # the wbits offset that selects the gzip wrapper around DEFLATE
1619
-
1620
-
1621
- class Compressor(Protocol):
1622
- """
1623
- The incremental compressor shape `compressing` drives: feed chunks through
1624
- `compress`, then `flush` ends the stream. `zlib.compressobj` and
1625
- `zstd.ZstdCompressor` satisfy it as-is; a third-party codec plugs in with
1626
- whatever thin adapter its own surface needs.
1627
- """
1628
-
1629
- def compress(self, data: bytes, /) -> bytes: ...
1630
- def flush(self) -> bytes: ...
1631
1625
 
1632
1626
 
1633
1627
  async def _compressed(body: Stream[bytes], compressor: Compressor) -> AsyncIterator[bytes]:
1628
+ # A codec decides for itself what any one `compress` call returns, and fed the
1629
+ # pieces a streamed body arrives in it usually returns nothing, holding the whole
1630
+ # upload until the stream ends. Ending a block per chunk is what releases it; a
1631
+ # codec with no way to do that (a plain `Compressor`) has to hold it, since unlike
1632
+ # a negotiated response there is no unencoded answer available to a caller who
1633
+ # asked for this coding by name.
1634
+ flush_block: Callable[[], bytes] = (
1635
+ compressor.flush_block if isinstance(compressor, StreamingCompressor) else lambda: b""
1636
+ )
1637
+ # One chunk of lookahead, so the last one is known to be last and rides out on the
1638
+ # flush that ends the stream rather than paying for a block of its own. A body that
1639
+ # arrives whole is one chunk, so it still encodes to exactly the bytes it would
1640
+ # have without any of this.
1641
+ held: bytes | None = None
1634
1642
  async for chunk in body:
1635
- if compressed := compressor.compress(chunk):
1643
+ if held and (compressed := compressor.compress(held) + flush_block()):
1636
1644
  yield compressed
1637
- yield compressor.flush()
1645
+ held = chunk
1646
+ yield compressor.compress(held or b"") + compressor.flush()
1638
1647
 
1639
1648
 
1640
1649
  def compressing(coding: bytes, make_compressor: Callable[[], Compressor]) -> ClientMiddleware:
@@ -1648,11 +1657,21 @@ def compressing(coding: bytes, make_compressor: Callable[[], Compressor]) -> Cli
1648
1657
  reimplemented.
1649
1658
 
1650
1659
  That rest: the lazy body `Stream[bytes]` is wrapped in the incremental compressor,
1651
- so a streamed upload compresses chunk by chunk and nothing buffers; and the
1652
- framing follows the rewrite, `content-length` no longer describes the body, so it
1653
- is dropped and the compressed stream goes out `transfer-encoding: chunked` (over
1654
- HTTP/2 the framing header is dropped with the other hop-by-hop headers and the
1655
- body rides DATA frames as usual).
1660
+ so a streamed upload compresses chunk by chunk and holds no more than one of them;
1661
+ and the framing follows the rewrite, `content-length` no longer describes the body,
1662
+ so it is dropped and the compressed stream goes out `transfer-encoding: chunked`
1663
+ (over HTTP/2 the framing header is dropped with the other hop-by-hop headers and
1664
+ the body rides DATA frames as usual).
1665
+
1666
+ Holding no more than a chunk is a demand on the codec, not just on the loop: what
1667
+ `compress` returns is the codec's choice, and fed a chunk at a time zlib and zstd
1668
+ return almost nothing until the stream ends, which would buffer the whole upload
1669
+ inside the codec while looking like it streamed. `make_compressor` should therefore
1670
+ produce a `StreamingCompressor`, as `gzip_compressor`, `zstd_compressor`, and
1671
+ `brotli_compressor` all do; a plain `Compressor` still encodes correctly and still
1672
+ buffers, because a coding named by the caller has no unencoded answer to fall back
1673
+ on the way a negotiated response does. A body that arrives whole is one chunk
1674
+ either way, and encodes to the same bytes under both.
1656
1675
 
1657
1676
  Two kinds of request pass through untouched: one already carrying a
1658
1677
  `content-encoding` (the body is already encoded; re-compressing would corrupt it),
@@ -1694,7 +1713,7 @@ def gzip_compress(level: int = zlib.Z_DEFAULT_COMPRESSION) -> ClientMiddleware:
1694
1713
  codings, and `compressing` is the shared mechanism for any coding beyond those;
1695
1714
  the response-side counterpart to all of them is `decompress`.
1696
1715
  """
1697
- return compressing(b"gzip", lambda: zlib.compressobj(level, zlib.DEFLATED, _GZIP_CONTAINER))
1716
+ return compressing(b"gzip", lambda: gzip_compressor(level))
1698
1717
 
1699
1718
 
1700
1719
  def zstd_compress(level: int | None = None) -> ClientMiddleware:
@@ -1708,33 +1727,7 @@ def zstd_compress(level: int | None = None) -> ClientMiddleware:
1708
1727
 
1709
1728
  `level` is zstd's compression level, defaulting to the library's own default.
1710
1729
  """
1711
- return compressing(b"zstd", lambda: zstd.ZstdCompressor(level))
1712
-
1713
-
1714
- class _RawBrotliCompressor(Protocol):
1715
- """The slice of `brotli.Compressor` the adapter drives (the bindings ship no types)."""
1716
-
1717
- def process(self, data: bytes, /) -> bytes: ...
1718
- def finish(self) -> bytes: ...
1719
-
1720
-
1721
- @dataclass(slots=True, eq=False)
1722
- class _BrotliCompressor:
1723
- """
1724
- Adapt `brotli.Compressor` to the `Compressor` shape.
1725
-
1726
- Brotli's bindings spell the incremental surface `process`/`finish` (their `flush`
1727
- is a mid-stream flush that keeps the stream open), so the adapter maps `compress`
1728
- to `process` and `flush` to `finish`.
1729
- """
1730
-
1731
- _raw: _RawBrotliCompressor
1732
-
1733
- def compress(self, data: bytes, /) -> bytes:
1734
- return self._raw.process(data)
1735
-
1736
- def flush(self) -> bytes:
1737
- return self._raw.finish()
1730
+ return compressing(b"zstd", lambda: zstd_compressor(level))
1738
1731
 
1739
1732
 
1740
1733
  def brotli_compress(quality: int = 11) -> ClientMiddleware:
@@ -1744,9 +1737,12 @@ def brotli_compress(quality: int = 11) -> ClientMiddleware:
1744
1737
  `gzip_compress`'s sibling over brotli ([Google's own bindings](https://github.com/google/brotli),
1745
1738
  a bundled dependency since the stdlib has no brotli): everything there holds here,
1746
1739
  and only the coding differs. `quality` is brotli's compression quality (0-11),
1747
- defaulting to the library's own default (11, the maximum).
1740
+ defaulting to the bindings' own default of 11, the maximum, because a client
1741
+ compressing an upload it holds whole is the case that ratio is worth paying for.
1742
+ The server-side `compress` table defaults lower, since it encodes per response;
1743
+ see `without_asgi.compression.brotli_compressor`, the shared codec behind both.
1748
1744
  """
1749
- return compressing(b"br", lambda: _BrotliCompressor(brotli.Compressor(quality=quality)))
1745
+ return compressing(b"br", lambda: brotli_compressor(quality))
1750
1746
 
1751
1747
 
1752
1748
  class Decompressor(Protocol):
@@ -1767,7 +1763,7 @@ class Decompressor(Protocol):
1767
1763
 
1768
1764
 
1769
1765
  def _gzip_decompressor() -> Decompressor:
1770
- return zlib.decompressobj(wbits=_GZIP_CONTAINER)
1766
+ return zlib.decompressobj(wbits=GZIP_CONTAINER)
1771
1767
 
1772
1768
 
1773
1769
  class _RawBrotliDecompressor(Protocol):
@@ -1802,12 +1798,16 @@ def _brotli_decompressor() -> Decompressor:
1802
1798
 
1803
1799
 
1804
1800
  # The codings decoded out of the box: gzip and zstd from the stdlib, brotli from the
1805
- # bundled bindings. `decompress`'s default table.
1806
- DEFAULT_DECOMPRESSORS: Mapping[bytes, Callable[[], Decompressor]] = {
1807
- b"br": _brotli_decompressor,
1808
- b"gzip": _gzip_decompressor,
1809
- b"zstd": zstd.ZstdDecompressor,
1810
- }
1801
+ # bundled bindings. `decompress`'s default table, and the mirror of the server-side
1802
+ # `DEFAULT_COMPRESSORS`, a proxy for the same two reasons: nothing can mutate the table
1803
+ # every other caller starts from, and `|` extends it into a fresh `dict`.
1804
+ DEFAULT_DECOMPRESSORS: MappingProxyType[bytes, Callable[[], Decompressor]] = MappingProxyType(
1805
+ {
1806
+ b"br": _brotli_decompressor,
1807
+ b"gzip": _gzip_decompressor,
1808
+ b"zstd": zstd.ZstdDecompressor,
1809
+ }
1810
+ )
1811
1811
 
1812
1812
  _CONTENT_CODING_HEADERS = frozenset({b"content-encoding", b"content-length"})
1813
1813
 
@@ -1859,7 +1859,7 @@ def decompress(
1859
1859
  the bundled bindings). The offer is *derived from its keys*, so what is
1860
1860
  advertised and what is decoded cannot disagree; register a coding this package
1861
1861
  does not ship by extending the table
1862
- (`decompress({**DEFAULT_DECOMPRESSORS, b"lzma": make_lzma})`) with any factory
1862
+ (`decompress(DEFAULT_DECOMPRESSORS | {b"lzma": make_lzma})`) with any factory
1863
1863
  whose product satisfies `Decompressor`. The mapping is snapshotted, keys
1864
1864
  lowercased, when the middleware is built.
1865
1865