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.
@@ -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