Skip to content

Commit 6d98efa

Browse files
Stream interface (#118)
* Add docs for streams * Don't expose 'tell()' interface * Stream, ByteStream, FileStream, MultiPartStream * Update docs * Streams using standard File I/O interface * Streams API * Remove temporary file * Context managed responses / simple Transport API * Docs
1 parent aab4574 commit 6d98efa

29 files changed

+834
-604
lines changed

docs/connections.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,6 @@ async with await ahttpx.open_connection("http://127.0.0.1:8080") as conn:
242242

243243
---
244244

245-
<span class="link-prev">← [Content Types](content-types.md)</span>
245+
<span class="link-prev">← [Streams](streams.md)</span>
246246
<span class="link-next">[Low Level Networking](networking.md) →</span>
247247
<span>&nbsp;</span>

docs/content-types.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,5 +169,5 @@ The following method must be implemented...
169169
---
170170

171171
<span class="link-prev">← [Headers](headers.md)</span>
172-
<span class="link-next">[Connections](connections.md) →</span>
172+
<span class="link-next">[Streams](streams.md) →</span>
173173
<span>&nbsp;</span>

docs/streams.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Streams
2+
3+
Streams provide a minimal file-like interface for reading bytes from a data source. They are used as the abstraction for reading the body of a request or response.
4+
5+
The interfaces here are simplified versions of Python's standard I/O operations.
6+
7+
## Stream
8+
9+
The base `Stream` class. The core of the interface is a subset of Python's `io.IOBase`...
10+
11+
* `.read(size=-1)` - *(bytes)* Return the bytes from the data stream. If the `size` argument is omitted or negative then the entire stream will be read. If `size` is an positive integer then the call returns at most `size` bytes. A return value of `b''` indicates the end of the stream has been reached.
12+
* `.close()` - Close the stream. Any further operations will raise a `ValueError`.
13+
14+
Additionally, the following properties are also defined...
15+
16+
* `.size` - *(int or None)* Return an integer indicating the size of the stream, or `None` if the size is unknown. When working with HTTP this is used to either set a `Content-Length: <size>` header, or a `Content-Encoding: chunked` header.
17+
* `.content_type` - *(str or None)* Return a string indicating the content type of the data, or `None` if the content type is unknown. When working with HTTP this is used to optionally set a `Content-Type` header.
18+
19+
The `Stream` interface and `ContentType` interface are closely related, with streams being used as the abstraction for the bytewise representation, and content types being used to encapsulate the parsed data structure.
20+
21+
For example, encoding some `JSON` data...
22+
23+
```python
24+
>>> data = httpx.JSON({'name': 'zelda', 'score': '478'})
25+
>>> stream = data.encode()
26+
>>> stream.read()
27+
b'{"name":"zelda","score":"478"}'
28+
>>> stream.content_type
29+
'application/json'
30+
```
31+
32+
---
33+
34+
## ByteStream
35+
36+
A byte stream returning fixed byte content. Similar to Python's `io.BytesIO` class.
37+
38+
```python
39+
>>> s = httpx.ByteStream(b'{"msg": "Hello, world!"}')
40+
>>> s.read()
41+
b'{"msg": "Hello, world!"}'
42+
```
43+
44+
## FileStream
45+
46+
A byte stream returning content from a file.
47+
48+
The standard pattern for instantiating a `FileStream` is to use `File` as a context manager:
49+
50+
```python
51+
>>> with httpx.File('upload.json') as s:
52+
... s.read()
53+
b'{"msg": "Hello, world!"}'
54+
```
55+
56+
## MultiPartStream
57+
58+
A byte stream returning multipart upload data.
59+
60+
The standard pattern for instantiating a `MultiPartStream` is to use `MultiPart` as a context manager:
61+
62+
```python
63+
>>> files = {'avatar-upload': 'image.png'}
64+
>>> with httpx.MultiPart(files=files) as s:
65+
... s.read()
66+
# ...
67+
```
68+
69+
## HTTPStream
70+
71+
A byte stream returning unparsed content from an HTTP request or response.
72+
73+
```python
74+
>>> with httpx.Client() as cli:
75+
... r = cli.get('https://www.example.com/')
76+
... r.stream.read()
77+
# ...
78+
```
79+
80+
## GZipStream
81+
82+
...
83+
84+
---
85+
86+
<span class="link-prev">← [Content Types](content-types.md)</span>
87+
<span class="link-next">[Connections](connections.md) →</span>
88+
<span>&nbsp;</span>

scripts/unasync

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ unasync.unasync_files(
1616
"src/ahttpx/_streams.py",
1717
"src/ahttpx/_urlencode.py",
1818
"src/ahttpx/_urlparse.py",
19-
"src/ahttpx/_urls.py"
19+
"src/ahttpx/_urls.py",
2020
],
2121
rules = [
2222
unasync.Rule(

src/ahttpx/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from ._quickstart import * # get, post, put, patch, delete
88
from ._response import * # Response
99
from ._request import * # Request
10-
from ._streams import * # ByteStream, IterByteStream, FileStream, Stream
10+
from ._streams import * # ByteStream, FileStream, HTTPStream, Stream
1111
from ._server import * # serve_http
1212
from ._urlencode import * # quote, unquote, urldecode, urlencode
1313
from ._urls import * # QueryParams, URL
@@ -29,7 +29,7 @@
2929
"get",
3030
"Headers",
3131
"HTML",
32-
"IterByteStream",
32+
"HTTPStream",
3333
"JSON",
3434
"MultiPart",
3535
"NetworkBackend",

src/ahttpx/_client.py

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import contextlib
21
import types
32
import typing
43

@@ -54,21 +53,19 @@ async def request(
5453
content: Content | Stream | bytes | None = None,
5554
) -> Response:
5655
request = self.build_request(method, url, headers=headers, content=content)
57-
async with self.via.send(request) as response:
56+
async with await self.via.send(request) as response:
5857
await response.read()
5958
return response
6059

61-
@contextlib.asynccontextmanager
6260
async def stream(
6361
self,
6462
method: str,
6563
url: URL | str,
6664
headers: Headers | typing.Mapping[str, str] | None = None,
6765
content: Content | Stream | bytes | None = None,
68-
) -> typing.AsyncIterator[Response]:
66+
) -> Response:
6967
request = self.build_request(method, url, headers=headers, content=content)
70-
async with self.via.send(request) as response:
71-
yield response
68+
return await self.via.send(request)
7269

7370
async def get(
7471
self,
@@ -139,21 +136,21 @@ def is_redirect(self, response: Response) -> bool:
139136
def build_redirect_request(self, request: Request, response: Response) -> Request:
140137
raise NotImplementedError()
141138

142-
@contextlib.asynccontextmanager
143-
async def send(self, request: Request) -> typing.AsyncIterator[Response]:
139+
async def send(self, request: Request) -> Response:
144140
while True:
145-
async with self._transport.send(request) as response:
146-
if not self.is_redirect(response):
147-
yield response
148-
return
141+
response = await self._transport.send(request)
142+
143+
if not self.is_redirect(response):
144+
return response
149145

150-
# If we have a redirect, then we read the body of the response.
151-
# Ensures that the HTTP connection is available for a new
152-
# request/response cycle.
153-
await response.read()
146+
# If we have a redirect, then we read the body of the response.
147+
# Ensures that the HTTP connection is available for a new
148+
# request/response cycle.
149+
await response.read()
150+
await response.close()
154151

155152
# We've made a request-response and now need to issue a redirect request.
156153
request = self.build_redirect_request(request, response)
157154

158-
async def aclose(self):
155+
async def close(self):
159156
pass

src/ahttpx/_content.py

Lines changed: 21 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
import typing
44

5-
from ._streams import Stream, ByteStream, FileStream, IterByteStream
5+
from ._streams import Stream, ByteStream, FileStream, MultiPartStream
66
from ._urlencode import urldecode, urlencode
77

88
__all__ = [
@@ -16,21 +16,9 @@
1616
"HTML",
1717
]
1818

19-
# https://github.com/nginx/nginx/blob/master/conf/mime.types
20-
_content_types = {
21-
".json": "application/json",
22-
".js": "application/javascript",
23-
".html": "text/html",
24-
".css": "text/css",
25-
".png": "image/png",
26-
".jpeg": "image/jpeg",
27-
".jpg": "image/jpeg",
28-
".gif": "image/gif",
29-
}
30-
3119

3220
class Content:
33-
def encode(self) -> tuple[Stream, str]:
21+
def encode(self) -> Stream:
3422
raise NotImplementedError()
3523

3624

@@ -73,11 +61,11 @@ def __init__(
7361

7462
# Content API
7563

76-
def encode(self) -> tuple[Stream, str]:
77-
stream = ByteStream(str(self).encode("ascii"))
64+
def encode(self) -> Stream:
65+
content = str(self).encode("ascii")
7866
content_type = "application/x-www-form-urlencoded"
79-
return (stream, content_type)
80-
67+
return ByteStream(content, content_type)
68+
8169
# Dict operations
8270

8371
def keys(self) -> typing.KeysView[str]:
@@ -172,17 +160,8 @@ def name(self) -> str:
172160
def size(self) -> int:
173161
return os.path.getsize(self._path)
174162

175-
def content_type(self) -> str:
176-
_, ext = os.path.splitext(self._path)
177-
ct = _content_types.get(ext, "application/octet-stream")
178-
if ct.startswith('text/'):
179-
ct += "; charset='utf-8'"
180-
return ct
181-
182-
def encode(self) -> tuple[Stream, str]:
183-
stream = FileStream(self._path)
184-
content_type = self.content_type()
185-
return (stream, content_type)
163+
def encode(self) -> Stream:
164+
return FileStream(self._path)
186165

187166
def __lt__(self, other: typing.Any) -> bool:
188167
return isinstance(other, File) and other._path < self._path
@@ -249,7 +228,7 @@ def get_list(self, key: str) -> list[File]:
249228
return list(self._dict.get(key, []))
250229

251230
# Content interface
252-
def encode(self) -> tuple[Stream, str]:
231+
def encode(self) -> Stream:
253232
return MultiPart(files=self).encode()
254233

255234
# Builtins
@@ -282,16 +261,15 @@ class JSON(Content):
282261
def __init__(self, data: typing.Any) -> None:
283262
self._data = data
284263

285-
def encode(self) -> tuple[Stream, str]:
264+
def encode(self) -> Stream:
286265
content = json.dumps(
287266
self._data,
288267
ensure_ascii=False,
289268
separators=(",", ":"),
290269
allow_nan=False
291270
).encode("utf-8")
292-
stream = ByteStream(content)
293271
content_type = "application/json"
294-
return (stream, content_type)
272+
return ByteStream(content, content_type)
295273

296274
def __repr__(self) -> str:
297275
return f"<JSON {self._data!r}>"
@@ -301,10 +279,10 @@ class Text(Content):
301279
def __init__(self, text: str) -> None:
302280
self._text = text
303281

304-
def encode(self) -> tuple[Stream, str]:
305-
stream = ByteStream(self._text.encode("utf-8"))
282+
def encode(self) -> Stream:
283+
content = self._text.encode("utf-8")
306284
content_type = "text/plain; charset='utf-8'"
307-
return (stream, content_type)
285+
return ByteStream(content, content_type)
308286

309287
def __repr__(self) -> str:
310288
return f"<Text {self._text!r}>"
@@ -314,10 +292,10 @@ class HTML(Content):
314292
def __init__(self, text: str) -> None:
315293
self._text = text
316294

317-
def encode(self) -> tuple[Stream, str]:
318-
stream = ByteStream(self._text.encode("utf-8"))
295+
def encode(self) -> Stream:
296+
content = self._text.encode("utf-8")
319297
content_type = "text/html; charset='utf-8'"
320-
return (stream, content_type)
298+
return ByteStream(content, content_type)
321299

322300
def __repr__(self) -> str:
323301
return f"<HTML {self._text!r}>"
@@ -353,37 +331,10 @@ def form(self) -> Form:
353331
def files(self) -> Files:
354332
return self._files
355333

356-
def encode(self) -> tuple[Stream, str]:
357-
stream = IterByteStream(self.iter_bytes())
358-
content_type = f"multipart/form-data; boundary={self._boundary}"
359-
return (stream, content_type)
360-
361-
async def iter_bytes(self) -> typing.AsyncIterator[bytes]:
362-
for key, value in self._form.multi_items():
363-
# See https://html.spec.whatwg.org/ - LF, CR, and " must be percent escaped.
364-
name = key.translate({10: "%0A", 13: "%0D", 34: "%22"})
365-
yield (
366-
f"--{self._boundary}\r\n"
367-
f'Content-Disposition: form-data; name="{name}"\r\n'
368-
f"\r\n"
369-
f"{value}\r\n"
370-
).encode("utf-8")
371-
372-
for key, file in self._files.multi_items():
373-
# See https://html.spec.whatwg.org/ - LF, CR, and " must be percent escaped.
374-
name = key.translate({10: "%0A", 13: "%0D", 34: "%22"})
375-
filename = file.name().translate({10: "%0A", 13: "%0D", 34: "%22"})
376-
stream, _ = file.encode()
377-
yield (
378-
f"--{self._boundary}\r\n"
379-
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
380-
f"\r\n"
381-
).encode("utf-8")
382-
async for buffer in stream:
383-
yield buffer
384-
yield "\r\n".encode("utf-8")
385-
386-
yield f"--{self._boundary}--\r\n".encode("utf-8")
334+
def encode(self) -> Stream:
335+
form = [(key, value) for key, value in self._form.items()]
336+
files = [(key, file._path) for key, file in self._files.items()]
337+
return MultiPartStream(form, files, boundary=self._boundary)
387338

388339
def __repr__(self) -> str:
389340
return f"<MultiPart form={self._form.multi_items()!r}, files={self._files.multi_items()!r}>"

src/ahttpx/_headers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@
2323

2424
def headername(name: str) -> str:
2525
if name.strip(VALID_HEADER_CHARS) or not name:
26-
raise ValueError("Invalid HTTP header name {key!r}.")
26+
raise ValueError(f"Invalid HTTP header name {name!r}.")
2727
return name
2828

2929

3030
def headervalue(value: str) -> str:
3131
value = value.strip(" ")
3232
if not value or not value.isascii() or not value.isprintable():
33-
raise ValueError("Invalid HTTP header value {key!r}.")
33+
raise ValueError(f"Invalid HTTP header value {value!r}.")
3434
return value
3535

3636

src/ahttpx/_network.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ async def start_tls(self, ctx: ssl.SSLContext, hostname: str | None = None) -> N
3131
self._tls = True
3232

3333
async def close(self) -> None:
34-
self._writer.close()
35-
await self._writer.wait_closed()
36-
self._closed = True
34+
if not self._closed:
35+
self._writer.close()
36+
await self._writer.wait_closed()
37+
self._closed = True
3738

3839
def __repr__(self):
3940
description = ""

0 commit comments

Comments
 (0)