xtr-http-message 4.0.2__py3-none-any.whl
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.
- xtr_http_message/.agents/skills/xtr-http-message/SKILL.md +296 -0
- xtr_http_message/__init__.py +85 -0
- xtr_http_message/_ascii.py +26 -0
- xtr_http_message/_message_trait.py +354 -0
- xtr_http_message/_request_trait.py +164 -0
- xtr_http_message/exception/__init__.py +24 -0
- xtr_http_message/exception/http_message_error.py +14 -0
- xtr_http_message/exception/invalid_argument_error.py +17 -0
- xtr_http_message/exception/stream_error.py +17 -0
- xtr_http_message/exception/uploaded_file_error.py +17 -0
- xtr_http_message/factory/__init__.py +20 -0
- xtr_http_message/factory/http_factory.py +235 -0
- xtr_http_message/py.typed +0 -0
- xtr_http_message/request.py +74 -0
- xtr_http_message/response.py +196 -0
- xtr_http_message/server_request.py +350 -0
- xtr_http_message/stream.py +404 -0
- xtr_http_message/uploaded_file.py +270 -0
- xtr_http_message/uri.py +660 -0
- xtr_http_message-4.0.2.dist-info/METADATA +280 -0
- xtr_http_message-4.0.2.dist-info/RECORD +23 -0
- xtr_http_message-4.0.2.dist-info/WHEEL +4 -0
- xtr_http_message-4.0.2.dist-info/licenses/LICENSE +23 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""A message body: a cursor over bytes, wrapping an open binary file object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
from typing import TYPE_CHECKING, final
|
|
8
|
+
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
from xtr_http_message_contracts import StreamInterface
|
|
11
|
+
|
|
12
|
+
from .exception import StreamError
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import BinaryIO
|
|
16
|
+
|
|
17
|
+
__all__ = ["Stream"]
|
|
18
|
+
|
|
19
|
+
_READ_WRITE = "rb+"
|
|
20
|
+
_READ = "rb"
|
|
21
|
+
_WRITE = "wb"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _attribute(obj: object, name: str) -> object:
|
|
25
|
+
"""Return an attribute of a file object, or ``None`` when it has none.
|
|
26
|
+
|
|
27
|
+
File objects differ in what they carry: one opened from a path has a
|
|
28
|
+
``name``, one over a bytes buffer has neither that nor a ``mode``. Asking
|
|
29
|
+
for either has to be allowed to come back empty.
|
|
30
|
+
"""
|
|
31
|
+
return getattr(obj, name, None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _mode(stream: BinaryIO, readable: bool, writable: bool) -> str:
|
|
35
|
+
"""Return how a file object was opened, as a mode string.
|
|
36
|
+
|
|
37
|
+
A file opened from a path reports its own mode. A buffer over bytes has
|
|
38
|
+
none to report, so the mode is read back off what it will let us do.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
stream: The file object.
|
|
42
|
+
readable: Whether the stream reads, as the stream itself sees it.
|
|
43
|
+
writable: Whether the stream writes, as the stream itself sees it.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The mode the file object was opened in, or one that says what it
|
|
47
|
+
allows.
|
|
48
|
+
"""
|
|
49
|
+
mode = _attribute(stream, "mode")
|
|
50
|
+
if isinstance(mode, str):
|
|
51
|
+
return mode
|
|
52
|
+
if readable:
|
|
53
|
+
return _READ_WRITE if writable else _READ
|
|
54
|
+
return _WRITE
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _file_name(stream: BinaryIO) -> str | None:
|
|
58
|
+
"""Return the path a file object was opened on, when it has one.
|
|
59
|
+
|
|
60
|
+
A file object opened on a descriptor reports that number rather than a
|
|
61
|
+
path, and one over a bytes buffer reports nothing; both read as ``None``.
|
|
62
|
+
"""
|
|
63
|
+
name = _attribute(stream, "name")
|
|
64
|
+
return name if isinstance(name, str) else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@final
|
|
68
|
+
class Stream(StreamInterface):
|
|
69
|
+
"""A stream over an open binary file object.
|
|
70
|
+
|
|
71
|
+
Build one with :meth:`create` rather than by hand: that is the entry point
|
|
72
|
+
that takes the payload in any of the three shapes a body arrives in —
|
|
73
|
+
bytes, an open file object, or a stream that is already one of these.
|
|
74
|
+
|
|
75
|
+
The payload is bytes throughout. A body may be an image or a compressed
|
|
76
|
+
archive, so there is no encoding to guess at, and
|
|
77
|
+
``await stream.get_contents()`` is what reads the whole of one.
|
|
78
|
+
|
|
79
|
+
Moving bytes is awaited: :meth:`read`, :meth:`write`,
|
|
80
|
+
:meth:`get_contents`, :meth:`seek`, :meth:`rewind` and :meth:`close` do
|
|
81
|
+
input and output. The methods that only report what the stream already
|
|
82
|
+
holds cost no ``await``.
|
|
83
|
+
|
|
84
|
+
Unlike the messages that carry it, a stream is not immutable: reading,
|
|
85
|
+
writing and seeking all move its cursor in place.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
__slots__ = ("_at_end", "_size", "_stream")
|
|
89
|
+
|
|
90
|
+
def __init__(self, body: BinaryIO) -> None:
|
|
91
|
+
"""Wrap an open binary file object.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
body: The file object to read from and write to. The stream takes
|
|
95
|
+
it over: closing the stream closes it.
|
|
96
|
+
"""
|
|
97
|
+
self._stream: BinaryIO | None = body
|
|
98
|
+
self._size: int | None = None
|
|
99
|
+
self._at_end: bool = False
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def create(cls, body: bytes | BinaryIO | StreamInterface = b"") -> StreamInterface:
|
|
103
|
+
"""Return a stream over ``body``, whatever shape it arrived in.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
body: The payload. Bytes are held in memory; an open binary file
|
|
107
|
+
object is taken over as it is; a stream that is already one
|
|
108
|
+
comes straight back, since wrapping it again would only add a
|
|
109
|
+
cursor over a cursor.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The stream.
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(body, bytes):
|
|
115
|
+
return cls(io.BytesIO(body))
|
|
116
|
+
if isinstance(body, StreamInterface):
|
|
117
|
+
return body
|
|
118
|
+
return cls(body)
|
|
119
|
+
|
|
120
|
+
@override
|
|
121
|
+
async def close(self) -> None:
|
|
122
|
+
"""Close the file object and let go of it."""
|
|
123
|
+
if self._stream is not None:
|
|
124
|
+
self._stream.close()
|
|
125
|
+
_ = self.detach()
|
|
126
|
+
|
|
127
|
+
@override
|
|
128
|
+
def detach(self) -> BinaryIO | None:
|
|
129
|
+
"""Hand the file object out and leave the stream unusable.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The file object the stream wrapped, or ``None`` when it has
|
|
133
|
+
already been handed out.
|
|
134
|
+
"""
|
|
135
|
+
detached = self._stream
|
|
136
|
+
self._stream = None
|
|
137
|
+
self._size = None
|
|
138
|
+
self._at_end = False
|
|
139
|
+
return detached
|
|
140
|
+
|
|
141
|
+
@override
|
|
142
|
+
def get_size(self) -> int | None:
|
|
143
|
+
"""Return the size of the stream in bytes, when that is knowable.
|
|
144
|
+
|
|
145
|
+
The size is measured by seeking to the end and back, so it is always
|
|
146
|
+
current; it is then remembered until the next write.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The size in bytes, or ``None`` when the stream is detached or
|
|
150
|
+
cannot be seeked over.
|
|
151
|
+
"""
|
|
152
|
+
if self._size is not None:
|
|
153
|
+
return self._size
|
|
154
|
+
|
|
155
|
+
stream = self._stream
|
|
156
|
+
if stream is None or not self.is_seekable():
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
here = stream.tell()
|
|
160
|
+
self._size = stream.seek(0, os.SEEK_END)
|
|
161
|
+
_ = stream.seek(here, os.SEEK_SET)
|
|
162
|
+
|
|
163
|
+
return self._size
|
|
164
|
+
|
|
165
|
+
@override
|
|
166
|
+
def tell(self) -> int:
|
|
167
|
+
"""Return where the cursor currently sits.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The cursor's offset in bytes from the beginning of the stream.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
StreamError: When the stream is detached, or the position cannot
|
|
174
|
+
be read.
|
|
175
|
+
"""
|
|
176
|
+
stream = self._require_stream()
|
|
177
|
+
try:
|
|
178
|
+
return stream.tell()
|
|
179
|
+
except (OSError, ValueError) as error:
|
|
180
|
+
message = f"Unable to determine stream position: {error}"
|
|
181
|
+
raise StreamError(message) from error
|
|
182
|
+
|
|
183
|
+
@override
|
|
184
|
+
def eof(self) -> bool:
|
|
185
|
+
"""Tell whether reading has reached the end of the stream.
|
|
186
|
+
|
|
187
|
+
It reports that a read *found* the end, not merely that the cursor
|
|
188
|
+
sits there: a stream just written to is at its end but has not reached
|
|
189
|
+
it. A seek puts that back to not-yet.
|
|
190
|
+
|
|
191
|
+
A read coming back with less than was asked for is not in itself the
|
|
192
|
+
end either — a pipe or a socket answers with whatever has arrived —
|
|
193
|
+
unless the cursor can be asked, and has reached the size.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
``True`` once a read has reached the end, and for a detached
|
|
197
|
+
stream.
|
|
198
|
+
"""
|
|
199
|
+
return self._stream is None or self._at_end
|
|
200
|
+
|
|
201
|
+
@override
|
|
202
|
+
def is_seekable(self) -> bool:
|
|
203
|
+
"""Tell whether the cursor can be moved.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
``True`` when the file object seeks and the stream still holds it.
|
|
207
|
+
"""
|
|
208
|
+
stream = self._stream
|
|
209
|
+
|
|
210
|
+
return stream is not None and not stream.closed and stream.seekable()
|
|
211
|
+
|
|
212
|
+
@override
|
|
213
|
+
async def seek(self, offset: int, whence: int = os.SEEK_SET) -> None:
|
|
214
|
+
"""Move the cursor to a position in the stream.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
offset: How far to move, in bytes.
|
|
218
|
+
whence: What ``offset`` counts from: ``os.SEEK_SET``,
|
|
219
|
+
``os.SEEK_CUR`` or ``os.SEEK_END``.
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
StreamError: When the stream is detached, is not seekable, or the
|
|
223
|
+
cursor cannot be moved there.
|
|
224
|
+
"""
|
|
225
|
+
stream = self._require_stream()
|
|
226
|
+
if not self.is_seekable():
|
|
227
|
+
raise StreamError("Stream is not seekable")
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
_ = stream.seek(offset, whence)
|
|
231
|
+
except (OSError, ValueError) as error:
|
|
232
|
+
message = f'Unable to seek to stream position "{offset}" with whence {whence}'
|
|
233
|
+
raise StreamError(message) from error
|
|
234
|
+
|
|
235
|
+
self._at_end = False
|
|
236
|
+
|
|
237
|
+
@override
|
|
238
|
+
async def rewind(self) -> None:
|
|
239
|
+
"""Move the cursor back to the beginning of the stream.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
StreamError: When the stream is detached or not seekable.
|
|
243
|
+
"""
|
|
244
|
+
await self.seek(0)
|
|
245
|
+
|
|
246
|
+
@override
|
|
247
|
+
def is_writable(self) -> bool:
|
|
248
|
+
"""Tell whether data can be written to the stream.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
``True`` when the file object accepts writes and the stream still
|
|
252
|
+
holds it.
|
|
253
|
+
"""
|
|
254
|
+
stream = self._stream
|
|
255
|
+
|
|
256
|
+
return stream is not None and not stream.closed and stream.writable()
|
|
257
|
+
|
|
258
|
+
@override
|
|
259
|
+
async def write(self, data: bytes) -> int:
|
|
260
|
+
"""Write bytes at the cursor.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
data: What to write.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
How many bytes were written.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
StreamError: When the stream is detached, is not writable, or the
|
|
270
|
+
write fails.
|
|
271
|
+
"""
|
|
272
|
+
stream = self._require_stream()
|
|
273
|
+
if not self.is_writable():
|
|
274
|
+
raise StreamError("Cannot write to a non-writable stream")
|
|
275
|
+
|
|
276
|
+
# Whatever the size was, it is not that any more.
|
|
277
|
+
self._size = None
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
return stream.write(data)
|
|
281
|
+
except (OSError, ValueError) as error:
|
|
282
|
+
message = f"Unable to write to stream: {error}"
|
|
283
|
+
raise StreamError(message) from error
|
|
284
|
+
|
|
285
|
+
@override
|
|
286
|
+
def is_readable(self) -> bool:
|
|
287
|
+
"""Tell whether data can be read from the stream.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
``True`` when the file object serves reads and the stream still
|
|
291
|
+
holds it.
|
|
292
|
+
"""
|
|
293
|
+
stream = self._stream
|
|
294
|
+
|
|
295
|
+
return stream is not None and not stream.closed and stream.readable()
|
|
296
|
+
|
|
297
|
+
@override
|
|
298
|
+
async def read(self, length: int) -> bytes:
|
|
299
|
+
"""Read up to ``length`` bytes from the cursor.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
length: The most to read.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
What was read, or empty bytes at the end of the stream.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
StreamError: When the stream is detached, is not readable, or the
|
|
309
|
+
read fails.
|
|
310
|
+
"""
|
|
311
|
+
stream = self._require_stream()
|
|
312
|
+
if not self.is_readable():
|
|
313
|
+
raise StreamError("Cannot read from non-readable stream")
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
chunk = stream.read(length)
|
|
317
|
+
except (OSError, ValueError) as error:
|
|
318
|
+
message = f"Unable to read from stream: {error}"
|
|
319
|
+
raise StreamError(message) from error
|
|
320
|
+
|
|
321
|
+
if chunk == b"":
|
|
322
|
+
self._at_end = True
|
|
323
|
+
elif len(chunk) < length and self.is_seekable():
|
|
324
|
+
# A read of a pipe, a socket or any unbuffered source comes back
|
|
325
|
+
# with whatever has arrived, which says nothing about the end. A
|
|
326
|
+
# stream whose cursor moves can be asked instead: the end is where
|
|
327
|
+
# the cursor has reached the size.
|
|
328
|
+
self._at_end = self.tell() == self.get_size()
|
|
329
|
+
|
|
330
|
+
return chunk
|
|
331
|
+
|
|
332
|
+
@override
|
|
333
|
+
async def get_contents(self) -> bytes:
|
|
334
|
+
"""Read the whole of the stream, from the first byte to the last.
|
|
335
|
+
|
|
336
|
+
Rewinds first when the cursor can be moved, then reads to the end. A
|
|
337
|
+
stream that cannot be rewound has nothing to rewind to, so that one is
|
|
338
|
+
read from wherever its cursor sits; :meth:`read` is the way to take a
|
|
339
|
+
measured amount from the current position instead.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Every byte in the stream.
|
|
343
|
+
|
|
344
|
+
Raises:
|
|
345
|
+
StreamError: When the stream is detached, cannot be rewound after
|
|
346
|
+
saying it can, or cannot be read. An unreadable body is
|
|
347
|
+
reported rather than answered with empty bytes, which would be
|
|
348
|
+
indistinguishable from an empty one.
|
|
349
|
+
"""
|
|
350
|
+
stream = self._require_stream()
|
|
351
|
+
if self.is_seekable():
|
|
352
|
+
await self.seek(0)
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
contents = stream.read()
|
|
356
|
+
except (OSError, ValueError) as error:
|
|
357
|
+
message = f"Unable to read stream contents: {error}"
|
|
358
|
+
raise StreamError(message) from error
|
|
359
|
+
|
|
360
|
+
self._at_end = True
|
|
361
|
+
|
|
362
|
+
return contents
|
|
363
|
+
|
|
364
|
+
@override
|
|
365
|
+
def get_metadata(self, key: str | None = None) -> object:
|
|
366
|
+
"""Return what the stream knows about itself.
|
|
367
|
+
|
|
368
|
+
Three entries: ``mode``, how the file object was opened; ``seekable``,
|
|
369
|
+
whether its cursor moves; and ``uri``, the path it was opened on when
|
|
370
|
+
it has one.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
key: One entry to look up. ``None`` asks for all of them.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
A fresh mapping of every entry when ``key`` is ``None``, empty for
|
|
377
|
+
a detached stream; otherwise the value under ``key``, or ``None``
|
|
378
|
+
when there is no such entry.
|
|
379
|
+
"""
|
|
380
|
+
stream = self._stream
|
|
381
|
+
if stream is None:
|
|
382
|
+
return None if key is not None else {}
|
|
383
|
+
|
|
384
|
+
metadata: dict[str, object] = {
|
|
385
|
+
"mode": _mode(stream, readable=self.is_readable(), writable=self.is_writable()),
|
|
386
|
+
"seekable": self.is_seekable(),
|
|
387
|
+
"uri": _file_name(stream),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return metadata if key is None else metadata.get(key)
|
|
391
|
+
|
|
392
|
+
def _require_stream(self) -> BinaryIO:
|
|
393
|
+
"""Return the file object, refusing to work once it has been handed out.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
The file object the stream wraps.
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
StreamError: When the stream is detached.
|
|
400
|
+
"""
|
|
401
|
+
if self._stream is None:
|
|
402
|
+
raise StreamError("Stream is detached")
|
|
403
|
+
|
|
404
|
+
return self._stream
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""A file that came in with a request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Literal, final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
from xtr_http_message_contracts import StreamInterface, UploadedFileInterface, UploadError
|
|
10
|
+
|
|
11
|
+
from .exception import InvalidArgumentError, UploadedFileError
|
|
12
|
+
from .stream import Stream
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import BinaryIO
|
|
16
|
+
|
|
17
|
+
__all__ = ["UploadedFile"]
|
|
18
|
+
|
|
19
|
+
# How much of a stream-backed upload is held in memory while it is copied.
|
|
20
|
+
_CHUNK = 1_048_576
|
|
21
|
+
|
|
22
|
+
_NOT_AN_ERROR = "Upload file error status must be one of the UploadError values"
|
|
23
|
+
_NOTHING_ARRIVED = "Cannot retrieve stream due to upload error"
|
|
24
|
+
_ALREADY_MOVED = "Cannot retrieve stream after it has already been moved"
|
|
25
|
+
_NO_PATH = "Invalid path provided for move operation; must be a non-empty string"
|
|
26
|
+
_NO_SOURCE = "Invalid stream or file provided for UploadedFile"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@final
|
|
30
|
+
class UploadedFile(UploadedFileInterface):
|
|
31
|
+
"""One uploaded file: what arrived, how much of it, and from where.
|
|
32
|
+
|
|
33
|
+
Two kinds arrive. One is already a stream — a form parser handed it over —
|
|
34
|
+
and comes back as that same stream. The other is a path on disk, which is
|
|
35
|
+
opened only when the file is read, so that a request carrying twenty files
|
|
36
|
+
nobody looks at opens none of them.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
|
|
41
|
+
from xtr_http_message import Stream, UploadedFile
|
|
42
|
+
|
|
43
|
+
upload = UploadedFile(Stream.create(b"hello"), 5, 0, "greeting.txt", "text/plain")
|
|
44
|
+
assert asyncio.run(upload.get_stream().get_contents()) == b"hello"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
An upload that did not go through carries nothing: the error says why, and
|
|
48
|
+
reading or moving it is refused. Moving it is what consumes it, so a
|
|
49
|
+
second move is refused too.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
__slots__ = (
|
|
53
|
+
"_client_filename",
|
|
54
|
+
"_client_media_type",
|
|
55
|
+
"_error",
|
|
56
|
+
"_moved",
|
|
57
|
+
"_size",
|
|
58
|
+
"_source",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
stream_or_file: StreamInterface | str | BinaryIO,
|
|
64
|
+
size: int | None,
|
|
65
|
+
error: int,
|
|
66
|
+
client_filename: str | None = None,
|
|
67
|
+
client_media_type: str | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Record an uploaded file.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
stream_or_file: What arrived: a stream over the contents, a path
|
|
73
|
+
on disk, or an open binary file object. Ignored when ``error``
|
|
74
|
+
says the upload did not go through.
|
|
75
|
+
size: The size in bytes as the file arrived, or ``None`` when it
|
|
76
|
+
was not counted.
|
|
77
|
+
error: How the upload went, as one of
|
|
78
|
+
:class:`~xtr_http_message_contracts.upload_error.UploadError`.
|
|
79
|
+
client_filename: The file name the client sent, if any. Do not
|
|
80
|
+
trust it.
|
|
81
|
+
client_media_type: The media type the client sent, if any. Do not
|
|
82
|
+
trust that either.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
InvalidArgumentError: When ``error`` is not one of the upload
|
|
86
|
+
outcomes, or when the upload went through with nothing to
|
|
87
|
+
read it from.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
self._error: UploadError = UploadError(error)
|
|
91
|
+
except ValueError as invalid:
|
|
92
|
+
raise InvalidArgumentError(_NOT_AN_ERROR) from invalid
|
|
93
|
+
|
|
94
|
+
self._size: int | None = size
|
|
95
|
+
self._client_filename: str | None = client_filename
|
|
96
|
+
self._client_media_type: str | None = client_media_type
|
|
97
|
+
self._moved: bool = False
|
|
98
|
+
self._source: StreamInterface | str | None = None
|
|
99
|
+
|
|
100
|
+
if self._error != UploadError.OK:
|
|
101
|
+
# Nothing arrived, so there is nothing to keep: the error is the
|
|
102
|
+
# whole of what this file has to say.
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if isinstance(stream_or_file, str):
|
|
106
|
+
if stream_or_file == "":
|
|
107
|
+
raise InvalidArgumentError(_NO_SOURCE)
|
|
108
|
+
self._source = stream_or_file
|
|
109
|
+
elif isinstance(stream_or_file, StreamInterface):
|
|
110
|
+
self._source = stream_or_file
|
|
111
|
+
else:
|
|
112
|
+
self._source = Stream.create(stream_or_file)
|
|
113
|
+
|
|
114
|
+
@override
|
|
115
|
+
def get_stream(self) -> StreamInterface:
|
|
116
|
+
"""Return the contents of the file as a stream.
|
|
117
|
+
|
|
118
|
+
A file backed by a path is opened here, every time it is asked for; a
|
|
119
|
+
file that arrived as a stream comes back as that same stream.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The contents of the uploaded file.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
UploadedFileError: When the upload did not go through, when the
|
|
126
|
+
file has already been moved, or when the path it is on cannot
|
|
127
|
+
be opened.
|
|
128
|
+
"""
|
|
129
|
+
source = self._active_source()
|
|
130
|
+
if not isinstance(source, str):
|
|
131
|
+
return source
|
|
132
|
+
|
|
133
|
+
return Stream.create(_open(source, "rb"))
|
|
134
|
+
|
|
135
|
+
@override
|
|
136
|
+
async def move_to(self, target_path: str) -> None:
|
|
137
|
+
"""Move the file to ``target_path``, consuming it.
|
|
138
|
+
|
|
139
|
+
A file on disk is moved as it is. One that arrived as a stream is
|
|
140
|
+
copied a mebibyte at a time, so that a large upload never has to fit
|
|
141
|
+
in memory.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
target_path: Where to move the file to, absolute or relative.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
InvalidArgumentError: When the target path is empty.
|
|
148
|
+
UploadedFileError: When the upload did not go through, when the
|
|
149
|
+
file has already been moved, or when the move fails.
|
|
150
|
+
"""
|
|
151
|
+
source = self._active_source()
|
|
152
|
+
|
|
153
|
+
if target_path == "":
|
|
154
|
+
raise InvalidArgumentError(_NO_PATH)
|
|
155
|
+
|
|
156
|
+
if isinstance(source, str):
|
|
157
|
+
try:
|
|
158
|
+
# One rename call, and the file system has no awaitable form
|
|
159
|
+
# here: the move is awaited so that a stream-backed upload can
|
|
160
|
+
# be copied chunk by chunk, not so that a path is reached
|
|
161
|
+
# through a thread.
|
|
162
|
+
_ = Path(source).replace(target_path) # noqa: ASYNC240
|
|
163
|
+
except OSError as failure:
|
|
164
|
+
message = f'Uploaded file could not be moved to "{target_path}": {failure}'
|
|
165
|
+
raise UploadedFileError(message) from failure
|
|
166
|
+
else:
|
|
167
|
+
await _copy_into(source, target_path)
|
|
168
|
+
|
|
169
|
+
self._moved = True
|
|
170
|
+
|
|
171
|
+
@override
|
|
172
|
+
def get_size(self) -> int | None:
|
|
173
|
+
"""Return the size of the file in bytes.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
The size recorded as the file arrived, or ``None`` when it was not
|
|
177
|
+
counted.
|
|
178
|
+
"""
|
|
179
|
+
return self._size
|
|
180
|
+
|
|
181
|
+
@override
|
|
182
|
+
def get_error(self) -> int:
|
|
183
|
+
"""Return how the upload went.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
The outcome, which is
|
|
187
|
+
:attr:`~xtr_http_message_contracts.upload_error.UploadError.OK`
|
|
188
|
+
when the file arrived whole.
|
|
189
|
+
"""
|
|
190
|
+
return self._error
|
|
191
|
+
|
|
192
|
+
@override
|
|
193
|
+
def get_client_filename(self) -> str | None:
|
|
194
|
+
"""Return the file name the client sent.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The name the client sent, or ``None`` when it sent none. Do not
|
|
198
|
+
trust it: a client may well have chosen it to break something.
|
|
199
|
+
"""
|
|
200
|
+
return self._client_filename
|
|
201
|
+
|
|
202
|
+
@override
|
|
203
|
+
def get_client_media_type(self) -> str | None:
|
|
204
|
+
"""Return the media type the client sent.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
The media type the client sent, or ``None`` when it sent none. Do
|
|
208
|
+
not trust it either, and for the same reason.
|
|
209
|
+
"""
|
|
210
|
+
return self._client_media_type
|
|
211
|
+
|
|
212
|
+
def _active_source(self) -> StreamInterface | str:
|
|
213
|
+
"""Return what the file is to be read from, refusing it when there is none.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
The stream the file arrived as, or the path it is on.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
UploadedFileError: When the upload did not go through — the
|
|
220
|
+
constructor keeps a source only for one that did — or when the
|
|
221
|
+
file has already been moved away.
|
|
222
|
+
"""
|
|
223
|
+
source = self._source
|
|
224
|
+
if source is None:
|
|
225
|
+
raise UploadedFileError(_NOTHING_ARRIVED)
|
|
226
|
+
|
|
227
|
+
if self._moved:
|
|
228
|
+
raise UploadedFileError(_ALREADY_MOVED)
|
|
229
|
+
|
|
230
|
+
return source
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _open(path: str, mode: Literal["rb", "wb"]) -> BinaryIO:
|
|
234
|
+
"""Open a path for an uploaded file, saying which path failed when it will not open.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
path: The path to open.
|
|
238
|
+
mode: How to open it.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
The open file object.
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
UploadedFileError: When the path cannot be opened.
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
return Path(path).open(mode)
|
|
248
|
+
except OSError as failure:
|
|
249
|
+
message = f'The file "{path}" cannot be opened: {failure}'
|
|
250
|
+
raise UploadedFileError(message) from failure
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def _copy_into(source: StreamInterface, target_path: str) -> None:
|
|
254
|
+
"""Copy a stream into a file, a mebibyte at a time.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
source: The stream to copy, rewound first when it can be.
|
|
258
|
+
target_path: The path to write it to.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
UploadedFileError: When the target path cannot be opened.
|
|
262
|
+
"""
|
|
263
|
+
if source.is_seekable():
|
|
264
|
+
await source.rewind()
|
|
265
|
+
|
|
266
|
+
with _open(target_path, "wb") as file:
|
|
267
|
+
destination = Stream(file)
|
|
268
|
+
while not source.eof():
|
|
269
|
+
if await destination.write(await source.read(_CHUNK)) == 0:
|
|
270
|
+
break
|