Skip to content

Commit 67b2b2b

Browse files
Streams (#73)
* Layout refactor * Add typing * Streams and content types * Streams, streams, streams * Streams, streams, streams
1 parent 01cc0ca commit 67b2b2b

File tree

15 files changed

+622
-258
lines changed

15 files changed

+622
-258
lines changed

scripts/unasync

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ unasync.unasync_files(
99
"src/ahttpx/_headers.py",
1010
"src/ahttpx/_models.py",
1111
"src/ahttpx/_pool.py",
12+
"src/ahttpx/_streams.py",
1213
"src/ahttpx/_urlparse.py",
1314
"src/ahttpx/_urls.py"
1415
],

src/ahttpx/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
from ._models import * # Response, Request
55
from ._network import * # NetworkBackend, NetworkStream, timeout
66
from ._pool import * # Connection, ConnectionPool, Transport, open_connection_pool, open_connection
7+
from ._streams import * # ByteStream, IterByteStream, Stream
78
from ._server import * # serve_http, serve_tcp
89
from ._urls import * # QueryParams, URL
910

1011

1112
__all__ = [
13+
"ByteStream",
1214
"Client",
1315
"Connection",
1416
"ConnectionPool",
@@ -18,6 +20,7 @@
1820
"Form",
1921
"Headers",
2022
"HTML",
23+
"IterByteStream",
2124
"JSON",
2225
"MultiPart",
2326
"NetworkBackend",
@@ -29,6 +32,7 @@
2932
"Request",
3033
"serve_http",
3134
"serve_tcp",
35+
"Stream",
3236
"Text",
3337
"timeout",
3438
"Transport",

src/ahttpx/_content.py

Lines changed: 94 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import json
22
import os
3+
import re
34
import pathlib
45
import typing
56
import urllib.parse
67

8+
from ._streams import Stream, ByteStream, IterByteStream
79
from ._headers import Headers
810

911
__all__ = [
@@ -18,13 +20,59 @@
1820
]
1921

2022

23+
def parse_content_type(header: str) -> tuple[str, dict[str, str]]:
24+
# The Content-Type header is described in RFC 2616 'Content-Type'
25+
# https://datatracker.ietf.org/doc/html/rfc2616#section-14.17
26+
27+
# The 'type/subtype; parameter' format is described in RFC 2616 'Media Types'
28+
# https://datatracker.ietf.org/doc/html/rfc2616#section-3.7
29+
30+
# Parameter quoting is described in RFC 2616 'Transfer Codings'
31+
# https://datatracker.ietf.org/doc/html/rfc2616#section-3.6
32+
33+
header = header.strip()
34+
content_type = ''
35+
params = {}
36+
37+
# Match the content type (up to the first semicolon or end)
38+
match = re.match(r'^([^;]+)', header)
39+
if match:
40+
content_type = match.group(1).strip().lower()
41+
rest = header[match.end():]
42+
else:
43+
return '', {}
44+
45+
# Parse parameters, accounting for quoted strings
46+
param_pattern = re.compile(r'''
47+
;\s* # Semicolon + optional whitespace
48+
(?P<key>[^=;\s]+) # Parameter key
49+
= # Equal sign
50+
(?P<value> # Parameter value:
51+
"(?:[^"\\]|\\.)*" # Quoted string with escapes
52+
| # OR
53+
[^;]* # Unquoted string (until semicolon)
54+
)
55+
''', re.VERBOSE)
56+
57+
for match in param_pattern.finditer(rest):
58+
key = match.group('key').lower()
59+
value = match.group('value').strip()
60+
if value.startswith('"') and value.endswith('"'):
61+
# Remove surrounding quotes and unescape
62+
value = re.sub(r'\\(.)', r'\1', value[1:-1])
63+
params[key] = value
64+
65+
return content_type, params
66+
67+
2168
class Content:
22-
def encode(self) -> tuple[Headers, bytes | typing.AsyncIterable[bytes]]:
69+
def encode(self) -> tuple[Stream, str]:
2370
raise NotImplementedError()
2471

2572

2673
class Form(typing.Mapping[str, str], Content):
2774
"""
75+
HTML form data, as an immutable multi-dict.
2876
Form parameters, as a multi-dict.
2977
"""
3078

@@ -62,6 +110,15 @@ def __init__(
62110

63111
self._dict = d
64112

113+
# Content API
114+
115+
def encode(self) -> tuple[Stream, str]:
116+
stream = ByteStream(str(self).encode("ascii"))
117+
content_type = "application/x-www-form-urlencoded"
118+
return (stream, content_type)
119+
120+
# Dict operations
121+
65122
def keys(self) -> typing.KeysView[str]:
66123
return self._dict.keys()
67124

@@ -71,6 +128,13 @@ def values(self) -> typing.ValuesView[str]:
71128
def items(self) -> typing.ItemsView[str, str]:
72129
return {k: v[0] for k, v in self._dict.items()}.items()
73130

131+
def get(self, key: str, default: typing.Any = None) -> typing.Any:
132+
if key in self._dict:
133+
return self._dict[key][0]
134+
return default
135+
136+
# Multi-dict operations
137+
74138
def multi_items(self) -> list[tuple[str, str]]:
75139
multi_items: list[tuple[str, str]] = []
76140
for k, v in self._dict.items():
@@ -80,47 +144,27 @@ def multi_items(self) -> list[tuple[str, str]]:
80144
def multi_dict(self) -> dict[str, list[str]]:
81145
return {k: list(v) for k, v in self._dict.items()}
82146

83-
def get(self, key: str, default: typing.Any = None) -> typing.Any:
84-
if key in self._dict:
85-
return self._dict[key][0]
86-
return default
87-
88147
def get_list(self, key: str) -> list[str]:
89148
return list(self._dict.get(key, []))
90149

91-
def set(self, key: str, value: str) -> "Form":
150+
# Update operations
151+
152+
def copy_set(self, key: str, value: str) -> "Form":
92153
d = self.multi_dict()
93154
d[key] = [value]
94155
return Form(d)
95156

96-
def append(self, key: str, value: str) -> "Form":
157+
def copy_append(self, key: str, value: str) -> "Form":
97158
d = self.multi_dict()
98159
d[key] = d.get(key, []) + [value]
99160
return Form(d)
100161

101-
def remove(self, key: str) -> "Form":
102-
d = {k: list(v) for k, v in self._dict.items() if k != key}
162+
def copy_remove(self, key: str) -> "Form":
163+
d = self.multi_dict()
164+
d.pop(key, None)
103165
return Form(d)
104166

105-
def copy_with(
106-
self,
107-
form: (
108-
typing.Mapping[str, str | typing.Sequence[str]] | typing.Sequence[tuple[str, str]] | None
109-
) = None,
110-
) -> "Form":
111-
d = {k: list(v) for k, v in self._dict.items()}
112-
f = Form(form)
113-
return Form(dict(**d, **f._dict))
114-
115-
def encode(self) -> tuple[Headers, bytes | typing.AsyncIterable[bytes]]:
116-
content = str(self).encode("ascii")
117-
headers = Headers(
118-
{
119-
"Content-Type": "application/x-www-form-urlencoded",
120-
"Content-Length": str(len(content)),
121-
}
122-
)
123-
return (headers, content)
167+
# Accessors & built-ins
124168

125169
def __getitem__(self, key: str) -> str:
126170
return self._dict[key][0]
@@ -257,7 +301,7 @@ def get_list(self, key: str) -> list[File]:
257301
return list(self._dict.get(key, []))
258302

259303
# Content interface
260-
def encode(self) -> tuple[Headers, bytes | typing.AsyncIterable[bytes]]:
304+
def encode(self) -> tuple[Stream, str]:
261305
return MultiPart(files=self).encode()
262306

263307
# Builtins
@@ -292,32 +336,36 @@ class JSON(Content):
292336
def __init__(self, data: typing.Any) -> None:
293337
self._data = data
294338

295-
def encode(self) -> tuple[Headers, bytes | typing.AsyncIterable[bytes]]:
339+
def encode(self) -> tuple[Stream, str]:
296340
content = json.dumps(
297-
self._data, ensure_ascii=False, separators=(",", ":"), allow_nan=False
341+
self._data,
342+
ensure_ascii=False,
343+
separators=(",", ":"),
344+
allow_nan=False
298345
).encode("utf-8")
299-
headers = Headers({"Content-Type": "application/json", "Content-Length": str(len(content))})
300-
return (headers, content)
346+
stream = ByteStream(content)
347+
content_type = "application/json"
348+
return (stream, content_type)
301349

302350

303351
class Text(Content):
304352
def __init__(self, text: str) -> None:
305353
self._text = text
306354

307-
def encode(self) -> tuple[Headers, bytes | typing.AsyncIterable[bytes]]:
308-
content = self._text.encode("utf-8")
309-
headers = Headers({"Content-Type": "text/plain; charset='utf-8", "Content-Length": str(len(content))})
310-
return (headers, content)
355+
def encode(self) -> tuple[Stream, str]:
356+
stream = ByteStream(self._text.encode("utf-8"))
357+
content_type = "text/plain; charset='utf-8'"
358+
return (stream, content_type)
311359

312360

313361
class HTML(Content):
314362
def __init__(self, text: str) -> None:
315363
self._text = text
316364

317-
def encode(self) -> tuple[Headers, bytes | typing.AsyncIterable[bytes]]:
318-
content = self._text.encode("utf-8")
319-
headers = Headers({"Content-Type": "text/html; charset='utf-8", "Content-Length": str(len(content))})
320-
return (headers, content)
365+
def encode(self) -> tuple[Stream, str]:
366+
stream = ByteStream(self._text.encode("utf-8"))
367+
content_type = "text/html; charset='utf-8'"
368+
return (stream, content_type)
321369

322370

323371
class MultiPart(Content):
@@ -349,15 +397,10 @@ def form(self) -> Form:
349397
def files(self) -> Files:
350398
return self._files
351399

352-
def encode(self) -> tuple[Headers, bytes | typing.AsyncIterable[bytes]]:
353-
h = Headers(
354-
{
355-
"Content-Type": f"multipart/form-data; boundary={self._boundary}",
356-
"Transfer-Encoding": "chunked",
357-
}
358-
)
359-
c = self.iter_bytes()
360-
return (h, c)
400+
def encode(self) -> tuple[Stream, str]:
401+
stream = IterByteStream(self.iter_bytes())
402+
content_type = f"multipart/form-data; boundary={self._boundary}"
403+
return (stream, content_type)
361404

362405
async def iter_bytes(self) -> typing.AsyncIterable[bytes]:
363406
for name, value in self._form.multi_items():

0 commit comments

Comments
 (0)