Skip to content

Commit 0cedf04

Browse files
Streams API
1 parent d44a7df commit 0cedf04

File tree

13 files changed

+197
-261
lines changed

13 files changed

+197
-261
lines changed

docs/streams.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,22 @@ The base `Stream` class. The core of the interface is a subset of Python's `io.I
1010

1111
* `.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.
1212
* `.close()` - Close the stream. Any further operations will raise a `ValueError`.
13-
* `.closed` - *(bool)* Return a boolean indicating if the stream is closed or not.
1413

15-
Additionally, the following are also defined...
14+
Additionally, the following properties are also defined...
1615

17-
* `.rewind()` - Rewind the current position to the start of the stream. Some implementations may raise a `ValueError`. For example file streams are rewindable, while http streams are not.
18-
* `.size` - *(int or None)* Return an integer indicating the size of the stream, or `None` if the size is unknown. When working with HTTP/1.1 streams with a known size will include a `Content-Length: <size>` header, while unsized streams will include a `Content-Encoding: chunked` header and use chunked transfer framing.
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.
1918

2019
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.
2120

2221
For example, encoding some `JSON` data...
2322

2423
```python
2524
>>> data = httpx.JSON({'name': 'zelda', 'score': '478'})
26-
>>> stream, ct_header = data.encode()
25+
>>> stream = data.encode()
2726
>>> stream.read()
2827
b'{"name":"zelda","score":"478"}'
29-
>>> ct_header
28+
>>> stream.content_type
3029
'application/json'
3130
```
3231

@@ -46,8 +45,10 @@ b'{"msg": "Hello, world!"}'
4645

4746
A byte stream returning content from a file.
4847

48+
The standard pattern for instantiating a `FileStream` is to use `File` as a context manager:
49+
4950
```python
50-
>>> with httpx.FileStream.open('upload.json') as s:
51+
>>> with httpx.File('upload.json') as s:
5152
... s.read()
5253
b'{"msg": "Hello, world!"}'
5354
```
@@ -56,9 +57,11 @@ b'{"msg": "Hello, world!"}'
5657

5758
A byte stream returning multipart upload data.
5859

60+
The standard pattern for instantiating a `MultiPartStream` is to use `MultiPart` as a context manager:
61+
5962
```python
6063
>>> files = {'avatar-upload': 'image.png'}
61-
>>> with httpx.MultiPartStream(files=files) as s:
64+
>>> with httpx.MultiPart(files=files) as s:
6265
... s.read()
6366
# ...
6467
```

src/ahttpx/_content.py

Lines changed: 18 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -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.open(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,12 +331,10 @@ def form(self) -> Form:
353331
def files(self) -> Files:
354332
return self._files
355333

356-
def encode(self) -> tuple[Stream, str]:
334+
def encode(self) -> Stream:
357335
form = [(key, value) for key, value in self._form.items()]
358336
files = [(key, file._path) for key, file in self._files.items()]
359-
stream = MultiPartStream(form, files, boundary=self._boundary)
360-
content_type = f"multipart/form-data; boundary={self._boundary}"
361-
return (stream, content_type)
337+
return MultiPartStream(form, files, boundary=self._boundary)
362338

363339
def __repr__(self) -> str:
364340
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/_request.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,7 @@ def __init__(
3434
elif isinstance(content, Stream):
3535
self.stream = content
3636
elif isinstance(content, Content):
37-
assert isinstance(content, Content)
38-
# Eg. Request("POST", "https://www.example.com", content=Form(...))
39-
stream, content_type = content.encode()
40-
self.headers = self.headers.copy_set("Content-Type", content_type)
41-
self.stream = stream
37+
self.stream = content.encode()
4238
else:
4339
raise TypeError(f'Expected `Content | Stream | bytes | None` got {type(content)}')
4440

@@ -54,6 +50,9 @@ def __init__(
5450
elif content_length > 0:
5551
self.headers = self.headers.copy_set("Content-Length", str(content_length))
5652

53+
if self.stream.content_type is not None:
54+
self.headers = self.headers.copy_set("Content-Type", self.stream.content_type)
55+
5756
@property
5857
def body(self) -> bytes:
5958
if not hasattr(self, '_body'):

src/ahttpx/_response.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,7 @@ def __init__(
9494
elif isinstance(content, Stream):
9595
self.stream = content
9696
elif isinstance(content, Content):
97-
# Eg. Response(200, content=HTML(...))
98-
stream, content_type = content.encode()
99-
self.headers = self.headers.copy_set("Content-Type", content_type)
100-
self.stream = stream
97+
self.stream = content.encode()
10198
else:
10299
raise TypeError(f'Expected `Content | Stream | bytes | None` got {type(content)}')
103100

@@ -114,6 +111,9 @@ def __init__(
114111
else:
115112
self.headers = self.headers.copy_set("Content-Length", str(content_length))
116113

114+
if self.stream.content_type is not None:
115+
self.headers = self.headers.copy_set("Content-Type", self.stream.content_type)
116+
117117
@property
118118
def reason_phrase(self):
119119
return _codes.get(self.status_code, "Unknown Status Code")

0 commit comments

Comments
 (0)