Skip to content

Commit 1db2ae3

Browse files
content_types on the Content API, not the Streams API (#124)
1 parent 52c2bea commit 1db2ae3

File tree

12 files changed

+129
-112
lines changed

12 files changed

+129
-112
lines changed

docs/content-types.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ An interface for constructing HTTP content, along with relevant headers.
164164

165165
The following method must be implemented...
166166

167-
* `.encode()` - Returns a tuple of `(httx.Stream, str)`, representing the encoded data and the content type.
167+
* `.encode()` - Returns an `httx.Stream` representing the encoded data.
168+
* `.content_type()` - Returns a `str` indicating the content type.
168169

169170
---
170171

docs/streams.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ The base `Stream` class. The core of the interface is a subset of Python's `io.I
1414
Additionally, the following properties are also defined...
1515

1616
* `.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.
1817

1918
The `Stream` interface and `ContentType` interface are related, with streams being used as the abstraction for the bytewise representation, and content types being used to encapsulate the parsed data structure.
2019

src/ahttpx/_content.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,26 @@
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+
1931

2032
class Content:
2133
def encode(self) -> Stream:
2234
raise NotImplementedError()
2335

36+
def content_type(self) -> str:
37+
raise NotImplementedError()
38+
2439

2540
class Form(typing.Mapping[str, str], Content):
2641
"""
@@ -63,8 +78,10 @@ def __init__(
6378

6479
def encode(self) -> Stream:
6580
content = str(self).encode("ascii")
66-
content_type = "application/x-www-form-urlencoded"
67-
return ByteStream(content, content_type)
81+
return ByteStream(content)
82+
83+
def content_type(self) -> str:
84+
return "application/x-www-form-urlencoded"
6885

6986
# Dict operations
7087

@@ -163,6 +180,13 @@ def size(self) -> int:
163180
def encode(self) -> Stream:
164181
return FileStream(self._path)
165182

183+
def content_type(self) -> str:
184+
_, ext = os.path.splitext(self._path)
185+
ct = _content_types.get(ext, "application/octet-stream")
186+
if ct.startswith('text/'):
187+
ct += "; charset='utf-8'"
188+
return ct
189+
166190
def __lt__(self, other: typing.Any) -> bool:
167191
return isinstance(other, File) and other._path < self._path
168192

@@ -185,6 +209,7 @@ def __init__(
185209
| typing.Sequence[tuple[str, File]]
186210
| None
187211
) = None,
212+
boundary: str = ''
188213
) -> None:
189214
d: dict[str, list[File]] = {}
190215

@@ -198,6 +223,7 @@ def __init__(
198223
d.setdefault(k, []).append(v)
199224

200225
self._dict = d
226+
self._boundary = boundary or os.urandom(16).hex()
201227

202228
# Standard dict interface
203229
def keys(self) -> typing.KeysView[str]:
@@ -231,6 +257,9 @@ def get_list(self, key: str) -> list[File]:
231257
def encode(self) -> Stream:
232258
return MultiPart(files=self).encode()
233259

260+
def content_type(self) -> str:
261+
return f"multipart/form-data; boundary={self._boundary}"
262+
234263
# Builtins
235264
def __getitem__(self, key: str) -> File:
236265
return self._dict[key][0]
@@ -268,8 +297,10 @@ def encode(self) -> Stream:
268297
separators=(",", ":"),
269298
allow_nan=False
270299
).encode("utf-8")
271-
content_type = "application/json"
272-
return ByteStream(content, content_type)
300+
return ByteStream(content)
301+
302+
def content_type(self) -> str:
303+
return "application/json"
273304

274305
def __repr__(self) -> str:
275306
return f"<JSON {self._data!r}>"
@@ -281,8 +312,10 @@ def __init__(self, text: str) -> None:
281312

282313
def encode(self) -> Stream:
283314
content = self._text.encode("utf-8")
284-
content_type = "text/plain; charset='utf-8'"
285-
return ByteStream(content, content_type)
315+
return ByteStream(content)
316+
317+
def content_type(self) -> str:
318+
return "text/plain; charset='utf-8'"
286319

287320
def __repr__(self) -> str:
288321
return f"<Text {self._text!r}>"
@@ -294,8 +327,10 @@ def __init__(self, text: str) -> None:
294327

295328
def encode(self) -> Stream:
296329
content = self._text.encode("utf-8")
297-
content_type = "text/html; charset='utf-8'"
298-
return ByteStream(content, content_type)
330+
return ByteStream(content)
331+
332+
def content_type(self) -> str:
333+
return "text/html; charset='utf-8'"
299334

300335
def __repr__(self) -> str:
301336
return f"<HTML {self._text!r}>"
@@ -336,5 +371,8 @@ def encode(self) -> Stream:
336371
files = [(key, file._path) for key, file in self._files.items()]
337372
return MultiPartStream(form, files, boundary=self._boundary)
338373

374+
def content_type(self) -> str:
375+
return f"multipart/form-data; boundary={self._boundary}"
376+
339377
def __repr__(self) -> str:
340378
return f"<MultiPart form={self._form.multi_items()!r}, files={self._files.multi_items()!r}>"

src/ahttpx/_request.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ def __init__(
3535
elif isinstance(content, Stream):
3636
self.stream = content
3737
elif isinstance(content, Content):
38+
ct = content.content_type()
3839
self.stream = content.encode()
40+
self.headers = self.headers.copy_set("Content-Type", ct)
3941
else:
4042
raise TypeError(f'Expected `Content | Stream | bytes | None` got {type(content)}')
4143

@@ -51,9 +53,6 @@ def __init__(
5153
elif content_length > 0:
5254
self.headers = self.headers.copy_set("Content-Length", str(content_length))
5355

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

src/ahttpx/_response.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ def __init__(
9494
elif isinstance(content, Stream):
9595
self.stream = content
9696
elif isinstance(content, Content):
97+
ct = content.content_type()
9798
self.stream = content.encode()
99+
self.headers = self.headers.copy_set("Content-Type", ct)
98100
else:
99101
raise TypeError(f'Expected `Content | Stream | bytes | None` got {type(content)}')
100102

@@ -111,9 +113,6 @@ def __init__(
111113
else:
112114
self.headers = self.headers.copy_set("Content-Length", str(content_length))
113115

114-
if self.stream.content_type is not None:
115-
self.headers = self.headers.copy_set("Content-Type", self.stream.content_type)
116-
117116
@property
118117
def reason_phrase(self):
119118
return _codes.get(self.status_code, "Unknown Status Code")
@@ -129,9 +128,11 @@ def text(self) -> str:
129128
if not hasattr(self, '_body'):
130129
raise RuntimeError("'.text' cannot be accessed without calling '.read()'")
131130
if not hasattr(self, '_text'):
132-
content_type = self.headers.get('Content-Type', '')
133-
media, opts = parse_opts_header(content_type)
134-
charset = opts.get('charset', 'utf-8')
131+
ct = self.headers.get('Content-Type', '')
132+
media, opts = parse_opts_header(ct)
133+
charset = 'utf-8'
134+
if media.startswith('text/'):
135+
charset = opts.get('charset', 'utf-8')
135136
self._text = self._body.decode(charset)
136137
return self._text
137138

src/ahttpx/_streams.py

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,6 @@
33
import os
44

55

6-
# https://github.com/nginx/nginx/blob/master/conf/mime.types
7-
_content_types = {
8-
".json": "application/json",
9-
".js": "application/javascript",
10-
".html": "text/html",
11-
".css": "text/css",
12-
".png": "image/png",
13-
".jpeg": "image/jpeg",
14-
".jpg": "image/jpeg",
15-
".gif": "image/gif",
16-
}
17-
18-
196
class Stream:
207
async def read(self, size: int=-1) -> bytes:
218
raise NotImplementedError()
@@ -30,10 +17,6 @@ async def close(self) -> None:
3017
def size(self) -> int | None:
3118
return None
3219

33-
@property
34-
def content_type(self) -> str | None:
35-
return None
36-
3720
async def __aenter__(self):
3821
return self
3922

@@ -47,10 +30,9 @@ async def __aexit__(
4730

4831

4932
class ByteStream(Stream):
50-
def __init__(self, data: bytes = b'', content_type: str | None = None, mode='r'):
33+
def __init__(self, data: bytes = b'', mode='r'):
5134
self._buffer = io.BytesIO(data)
5235
self._size = len(data)
53-
self._content_type = content_type
5436
self._allow_write = {'r': False, 'w': True}[mode]
5537

5638
async def read(self, size: int=-1) -> bytes:
@@ -71,10 +53,6 @@ def getvalue(self) -> bytes:
7153
def size(self) -> int | None:
7254
return self._size
7355

74-
@property
75-
def content_type(self) -> str | None:
76-
return self._content_type
77-
7856

7957
class FileStream(Stream):
8058
def __init__(self, path):
@@ -100,14 +78,6 @@ async def close(self) -> None:
10078
def size(self) -> int | None:
10179
return self._size
10280

103-
@property
104-
def content_type(self) -> str | None:
105-
_, ext = os.path.splitext(self._path)
106-
ct = _content_types.get(ext, "application/octet-stream")
107-
if ct is not None and ct.startswith('text/'):
108-
ct += "; charset='utf-8'"
109-
return ct
110-
11181
async def __aenter__(self):
11282
await self.open()
11383
return self
@@ -243,7 +213,3 @@ async def close(self) -> None:
243213
@property
244214
def size(self) -> int | None:
245215
return None
246-
247-
@property
248-
def content_type(self) -> str | None:
249-
return f"multipart/form-data; boundary={self._boundary}"

0 commit comments

Comments
 (0)