pathapi 0.1.0__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.
Files changed (104) hide show
  1. pathapi/AGENTS.md +2355 -0
  2. pathapi/README.md +205 -0
  3. pathapi/__init__.py +62 -0
  4. pathapi/_commit.py +116 -0
  5. pathapi/_discard.py +92 -0
  6. pathapi/_errorpolicy.py +106 -0
  7. pathapi/_filestat.py +194 -0
  8. pathapi/_lru.py +138 -0
  9. pathapi/_names.py +70 -0
  10. pathapi/_permissions.py +102 -0
  11. pathapi/_stubs.py +76 -0
  12. pathapi/_tree.py +136 -0
  13. pathapi/archive/__init__.py +44 -0
  14. pathapi/archive/_backend.py +393 -0
  15. pathapi/archive/_names.py +63 -0
  16. pathapi/archive/_pack.py +394 -0
  17. pathapi/archive/_path.py +352 -0
  18. pathapi/archive/_snapshot.py +269 -0
  19. pathapi/archive/_tar.py +126 -0
  20. pathapi/archive/_write.py +359 -0
  21. pathapi/archive/_zip.py +563 -0
  22. pathapi/archive/_zipentries.py +272 -0
  23. pathapi/checksum.py +61 -0
  24. pathapi/exceptions.py +22 -0
  25. pathapi/fspath.py +513 -0
  26. pathapi/ftp/__init__.py +27 -0
  27. pathapi/ftp/_backend.py +418 -0
  28. pathapi/ftp/_facts.py +117 -0
  29. pathapi/ftp/_path.py +582 -0
  30. pathapi/glob/__init__.py +27 -0
  31. pathapi/glob/_entries.py +103 -0
  32. pathapi/glob/_patterns.py +164 -0
  33. pathapi/glob/_recursion.py +180 -0
  34. pathapi/glob/_selection.py +303 -0
  35. pathapi/http/__init__.py +84 -0
  36. pathapi/http/_body.py +156 -0
  37. pathapi/http/_builtin.py +553 -0
  38. pathapi/http/_client.py +211 -0
  39. pathapi/http/_errors.py +79 -0
  40. pathapi/http/_follow.py +230 -0
  41. pathapi/http/_headers.py +233 -0
  42. pathapi/http/_pool.py +169 -0
  43. pathapi/http/_proxies.py +142 -0
  44. pathapi/http/_response.py +255 -0
  45. pathapi/http/_settings.py +12 -0
  46. pathapi/http/_status.py +68 -0
  47. pathapi/http/_transports.py +173 -0
  48. pathapi/http/testing/__init__.py +80 -0
  49. pathapi/http/testing/__main__.py +10 -0
  50. pathapi/http/testing/suite/conftest.py +98 -0
  51. pathapi/http/testing/suite/test_client_default.py +285 -0
  52. pathapi/http/testing/suite/test_client_errors.py +181 -0
  53. pathapi/http/testing/suite/test_client_peer_text.py +86 -0
  54. pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
  55. pathapi/http/testing/suite/test_client_redirects.py +231 -0
  56. pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
  57. pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
  58. pathapi/http/testing/suite/test_transport_body.py +302 -0
  59. pathapi/http/testing/suite/test_transport_send.py +662 -0
  60. pathapi/http/testing/suite/transport_fakes.py +133 -0
  61. pathapi/http/testing/suite/transport_guard.py +174 -0
  62. pathapi/http/testing/suite/transport_wire.py +511 -0
  63. pathapi/mempath.py +438 -0
  64. pathapi/path/__init__.py +19 -0
  65. pathapi/path/_info.py +10 -0
  66. pathapi/path/_path.py +198 -0
  67. pathapi/path/_pure.py +522 -0
  68. pathapi/path/_relation.py +10 -0
  69. pathapi/path/_transfer.py +10 -0
  70. pathapi/protocols/__init__.py +35 -0
  71. pathapi/protocols/_bundles.py +22 -0
  72. pathapi/protocols/_errors.py +12 -0
  73. pathapi/protocols/_info.py +65 -0
  74. pathapi/protocols/_relation.py +191 -0
  75. pathapi/protocols/_removal.py +69 -0
  76. pathapi/protocols/_support.py +122 -0
  77. pathapi/protocols/_transfer.py +100 -0
  78. pathapi/protocols/checksum.py +80 -0
  79. pathapi/protocols/fs.py +429 -0
  80. pathapi/protocols/io.py +224 -0
  81. pathapi/protocols/links.py +135 -0
  82. pathapi/protocols/pure.py +31 -0
  83. pathapi/protocols/resolve.py +97 -0
  84. pathapi/protocols/transfer.py +656 -0
  85. pathapi/protocols/tree.py +492 -0
  86. pathapi/providers.py +48 -0
  87. pathapi/py.typed +1 -0
  88. pathapi/sync/__init__.py +8 -0
  89. pathapi/sync/_compare.py +157 -0
  90. pathapi/sync/_events.py +97 -0
  91. pathapi/sync/_links.py +93 -0
  92. pathapi/sync/_offer.py +33 -0
  93. pathapi/sync/_paths.py +50 -0
  94. pathapi/sync/_replace.py +98 -0
  95. pathapi/sync/_syncer.py +989 -0
  96. pathapi/testing/__init__.py +96 -0
  97. pathapi/testing/_contract.py +522 -0
  98. pathapi/testing/_fixtures.py +83 -0
  99. pathapi/testing/_read.py +279 -0
  100. pathapi-0.1.0.dist-info/METADATA +242 -0
  101. pathapi-0.1.0.dist-info/RECORD +104 -0
  102. pathapi-0.1.0.dist-info/WHEEL +4 -0
  103. pathapi-0.1.0.dist-info/entry_points.txt +2 -0
  104. pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,303 @@
1
+ """Selecting the paths a pattern names: `glob()` and `select()`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys as _sys
6
+ import typing as _ty
7
+
8
+ from ._entries import (
9
+ _Globable,
10
+ _child,
11
+ _entry_is_dir,
12
+ _is_hidden,
13
+ _names_a_directory,
14
+ _report_unlistable,
15
+ _scan,
16
+ )
17
+ from ._patterns import (
18
+ RECURSIVE,
19
+ WILDCARD_PATTERN,
20
+ _PARTIAL_DOUBLESTAR_ALLOWED,
21
+ _TRAILING_SLASH_SELECTS_DIRS,
22
+ _collapse_recursive,
23
+ compile_pattern,
24
+ )
25
+ from ._recursion import _recurse, _recurse_dirs
26
+
27
+ # CPython 3.13 made a trailing "**" select files as well as directories;
28
+ # earlier versions select directories only. glob() follows the running
29
+ # interpreter so LocalPath keeps matching the pathlib it runs next to.
30
+ _DOUBLESTAR_SELECTS_FILES = _sys.version_info >= (3, 13)
31
+
32
+ # How a literal component is checked; `native=False` pins both to the current pathlib's.
33
+ #: Before 3.13 a literal that is not the last component must be a directory; from
34
+ #: 3.13 it is joined unchecked and only the whole path is tested.
35
+ _INTERMEDIATE_LITERAL_IS_CHECKED = _sys.version_info < (3, 13)
36
+ #: From 3.12 the last literal is tested without following a link (a dangling symlink
37
+ #: is selected); before that, with `exists()`.
38
+ _FINAL_LITERAL_IS_NOT_FOLLOWED = _sys.version_info >= (3, 12)
39
+
40
+ #: Followed when `native=True`. 3.12 selects every literal component (but "..") from
41
+ #: the directory listing, as a wildcard: a literal typed in another case yields the
42
+ #: stored name where the filesystem ignores case. Other versions join it as typed.
43
+ _LITERALS_ARE_LISTED = _sys.version_info[:2] == (3, 12)
44
+ #: From 3.13 a final literal with a trailing separator is tested as `lexists(name +
45
+ #: os.sep)`. What that answers for a link is the platform's: Windows does not follow a
46
+ #: directory link there, macOS follows a link to a file.
47
+ _DIRONLY_LITERAL_IS_LEXISTS = _sys.version_info >= (3, 13)
48
+
49
+ #: Components that name a directory relative to the one before them.
50
+ _SPECIAL_PARTS = (".", "..")
51
+
52
+
53
+ def glob(
54
+ path: _Globable,
55
+ *,
56
+ dironly: bool = False,
57
+ root_dir: _Globable | None = None,
58
+ recursive: bool | None = False,
59
+ include_hidden: bool = False,
60
+ case_sensitive: bool | None = None,
61
+ native: bool = True,
62
+ on_error: "_ty.Callable[[OSError], None] | None" = None,
63
+ bound_loops: bool = False,
64
+ recurse_symlinks: bool = False,
65
+ ) -> _ty.Iterator[_Globable]:
66
+ """Return an iterator which yields the paths matching a pathname pattern.
67
+
68
+ `path` is the pattern itself, as a path (e.g. `LocalPath("/x/**/*.py")`).
69
+ The pattern may contain simple shell-style wildcards a la fnmatch. Like
70
+ the stdlib `glob` module, and unlike `Path.glob()`, hidden entries (names
71
+ starting with a dot) are not matched by wildcards and not descended into
72
+ by "**" unless `include_hidden` is true.
73
+
74
+ If recursive is true, the pattern '**' will match zero or more
75
+ directories and subdirectories; as the last component it also matches
76
+ files on Python 3.13+ or with `native=False`, and directories only
77
+ otherwise. `recursive=None` decides
78
+ from the pattern itself, as `Path.glob()` does: a "**" component enables
79
+ it. `native=` follows the running interpreter's rules, or applies one
80
+ rule on every version -- see `parse_pattern()`. `recurse_symlinks=True`
81
+ lets "**" descend through symlinks to directories, see `select()`.
82
+
83
+ The pattern is validated when this is called and the paths are selected
84
+ lazily. The path's anchor (a drive, an extended-length drive prefix) is
85
+ never a wildcard, whatever characters it holds.
86
+ """
87
+ segments = list(path.segments)
88
+ if len(segments) > 1 and segments[-1] == "":
89
+ segments.pop()
90
+ if native and not _TRAILING_SLASH_SELECTS_DIRS:
91
+ pass # pathlib ignores a trailing separator before 3.11
92
+ else:
93
+ dironly = True
94
+ if recursive is None:
95
+ recursive = RECURSIVE in segments
96
+ if native and not _PARTIAL_DOUBLESTAR_ALLOWED:
97
+ for segment in segments:
98
+ if RECURSIVE in segment and segment != RECURSIVE:
99
+ raise ValueError(
100
+ "Invalid pattern: '**' can only be an entire path component"
101
+ )
102
+ anchor = getattr(path, "anchor", "")
103
+ searched = 1 if segments and anchor and segments[0] == anchor else 0
104
+ first_wildcard = next(
105
+ (
106
+ i
107
+ for i, seg in enumerate(segments)
108
+ if i >= searched and WILDCARD_PATTERN.search(seg)
109
+ ),
110
+ max(len(segments) - 1, 0),
111
+ )
112
+ base = path.with_segments(*segments[:first_wildcard])
113
+ if root_dir is not None:
114
+ base = root_dir / base
115
+ parts = [part for part in segments[first_wildcard:] if part not in ("", ".")]
116
+ return _expand(
117
+ base,
118
+ parts,
119
+ dironly=dironly,
120
+ recursive=recursive,
121
+ include_hidden=include_hidden,
122
+ case_sensitive=case_sensitive,
123
+ native=native,
124
+ on_error=on_error,
125
+ bound_loops=bound_loops,
126
+ recurse_symlinks=recurse_symlinks,
127
+ )
128
+
129
+
130
+ def _expand(base, parts, *, dironly, **options) -> _ty.Iterator[_Globable]:
131
+ if not parts:
132
+ if base.is_dir() if dironly else base.exists():
133
+ yield base
134
+ return
135
+ yield from select(base, parts, dironly=dironly, **options)
136
+
137
+
138
+ def select(
139
+ base: _Globable,
140
+ parts: _ty.Sequence[str],
141
+ *,
142
+ dironly: bool = False,
143
+ recursive: bool = True,
144
+ include_hidden: bool = True,
145
+ case_sensitive: bool | None = None,
146
+ native: bool = True,
147
+ on_error: "_ty.Callable[[OSError], None] | None" = None,
148
+ bound_loops: bool = False,
149
+ recurse_symlinks: bool = False,
150
+ ) -> _ty.Iterator[_Globable]:
151
+ """Yield the paths under `base` matching the pattern components `parts`
152
+ (see `parse_pattern()`); the engine behind `Path.glob()`.
153
+
154
+ Directory listings go through `_scandir()`; an `OSError` from listing a
155
+ missing or non-directory path selects nothing. "**" (when `recursive`)
156
+ decides recursion from the listing's non-following stat, so it never
157
+ descends into a directory symlink and always terminates. A trailing "**"
158
+ also selects files on Python 3.13+ and directories only before, which is
159
+ the third rule `native=False` pins: it then selects files on every
160
+ version (3.13's rule, the one current pathlib applies).
161
+
162
+ With `recurse_symlinks=True` "**" also descends through a symlink to a
163
+ directory, as pathlib 3.13 does, and ends where that would loop: a link
164
+ to a directory the descent is already inside (`st_dev`/`st_ino`) is an
165
+ entry but is not entered, and so is any link beyond the 40th in one
166
+ descent where the stat has no identity.
167
+ """
168
+ # An explicit `case_sensitive` asks for names to be compared, so a literal
169
+ # is looked for in the listing and yields the name as stored, as pathlib
170
+ # does when it is given. "." and ".." are never names to find.
171
+ explicit_case = case_sensitive is not None or (native and _LITERALS_ARE_LISTED)
172
+ if case_sensitive is None:
173
+ case_sensitive = getattr(base, "_is_case_sensitive", True)
174
+ if recursive:
175
+ parts = _collapse_recursive(parts)
176
+ steps: _ty.List[_ty.Tuple[str, _ty.Any]] = []
177
+ for part in parts:
178
+ if recursive and part == RECURSIVE:
179
+ steps.append((part, None))
180
+ elif part in _SPECIAL_PARTS:
181
+ steps.append((part, False))
182
+ elif explicit_case or WILDCARD_PATTERN.search(part):
183
+ steps.append((part, compile_pattern(part, case_sensitive)))
184
+ else:
185
+ steps.append((part, False))
186
+ opts = _Options(
187
+ dironly,
188
+ include_hidden,
189
+ _DOUBLESTAR_SELECTS_FILES if native else True,
190
+ on_error,
191
+ bound_loops,
192
+ _INTERMEDIATE_LITERAL_IS_CHECKED and native,
193
+ _FINAL_LITERAL_IS_NOT_FOLLOWED or not native,
194
+ recurse_symlinks,
195
+ _DIRONLY_LITERAL_IS_LEXISTS and native,
196
+ )
197
+ selected = _select(base, steps, 0, opts, None)
198
+ if sum(1 for _, kind in steps if kind is None) < 2:
199
+ yield from selected
200
+ return
201
+ # Two separate "**" can reach one path along several splits.
202
+ seen = set()
203
+ for path in selected:
204
+ if path not in seen:
205
+ seen.add(path)
206
+ yield path
207
+
208
+
209
+ class _Options(_ty.NamedTuple):
210
+ dironly: bool
211
+ include_hidden: bool
212
+ doublestar_selects_files: bool = _DOUBLESTAR_SELECTS_FILES
213
+ on_error: "_ty.Callable[[OSError], None] | None" = None
214
+ bound_loops: bool = False
215
+ check_intermediate_literal: bool = _INTERMEDIATE_LITERAL_IS_CHECKED
216
+ unfollowed_final_literal: bool = _FINAL_LITERAL_IS_NOT_FOLLOWED
217
+ recurse_symlinks: bool = False
218
+ dironly_literal_is_lexists: bool = False
219
+
220
+
221
+ def _select(
222
+ path: _Globable,
223
+ steps: _ty.Sequence[_ty.Tuple[str, _ty.Any]],
224
+ index: int,
225
+ opts: _Options,
226
+ is_dir: bool | None,
227
+ entries: "_ty.Sequence | None" = None,
228
+ ) -> _ty.Iterator[_Globable]:
229
+ """Select from `path` with `steps[index:]`. `entries` is `path`'s listing
230
+ when the caller took it already (see `_scan()`)."""
231
+ part, kind = steps[index]
232
+ last = index == len(steps) - 1
233
+
234
+ if kind is None: # "**"
235
+ if is_dir is None and not path.is_dir():
236
+ _report_unlistable(path, opts.on_error)
237
+ return
238
+ if last:
239
+ with_files = opts.doublestar_selects_files and not opts.dironly
240
+ yield from _recurse(
241
+ path,
242
+ opts.include_hidden,
243
+ with_files,
244
+ opts.on_error,
245
+ opts.bound_loops,
246
+ opts.recurse_symlinks,
247
+ )
248
+ return
249
+ for directory, listing in _recurse_dirs(
250
+ path,
251
+ opts.include_hidden,
252
+ opts.on_error,
253
+ opts.bound_loops,
254
+ opts.recurse_symlinks,
255
+ ):
256
+ yield from _select(directory, steps, index + 1, opts, True, listing)
257
+ return
258
+
259
+ if kind is False: # literal component: no listing needed
260
+ if (
261
+ index == 0
262
+ and part == ".."
263
+ and opts.check_intermediate_literal
264
+ and not path.is_dir()
265
+ ):
266
+ # pathlib selects nothing from a base that is not a directory,
267
+ # even where the system would resolve "base/.." by its text.
268
+ _report_unlistable(path, opts.on_error)
269
+ return
270
+ child = _child(path, part, None)
271
+ if not last:
272
+ if (
273
+ opts.check_intermediate_literal
274
+ and part not in _SPECIAL_PARTS
275
+ and not child.is_dir()
276
+ ):
277
+ _report_unlistable(child, opts.on_error)
278
+ return
279
+ yield from _select(child, steps, index + 1, opts, None)
280
+ elif opts.dironly:
281
+ if _names_a_directory(child, opts.dironly_literal_is_lexists):
282
+ yield child
283
+ elif (
284
+ child.exists(follow_symlinks=False)
285
+ if opts.unfollowed_final_literal
286
+ else child.exists()
287
+ ):
288
+ yield child
289
+ return
290
+
291
+ need_dir = opts.dironly or not last
292
+ skip_hidden = not opts.include_hidden and not part.startswith(".")
293
+ for child, stat in _scan(path, opts.on_error) if entries is None else entries:
294
+ if skip_hidden and _is_hidden(child):
295
+ continue
296
+ if not kind.match(child.name):
297
+ continue
298
+ if need_dir and not _entry_is_dir(child, stat, follow_symlinks=True):
299
+ continue
300
+ if last:
301
+ yield child
302
+ else:
303
+ yield from _select(child, steps, index + 1, opts, True)
@@ -0,0 +1,84 @@
1
+ """HTTP requests on the standard library, with a replaceable transport.
2
+
3
+ `HTTPClient` sends a request through a transport (an object with `send()`:
4
+ `HTTPTransport` on `http.client`, or one another distribution provides) and
5
+ follows redirects under one rule for credentials: `Authorization` goes to the
6
+ origin it was given for and to no other scheme, host or port. A reply comes
7
+ back as an `HTTPResponse`. `translate_errors()` maps a failed request to the
8
+ `OSError` types `pathlib` raises.
9
+
10
+ Importing this package loads `http.client` and `ssl`; `import pathapi`
11
+ does not. Nothing here needs a third-party library.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys as _sys
17
+ import types as _types
18
+
19
+ from . import _settings
20
+ from ._builtin import HTTPTransport
21
+ from ._client import DEFAULT_TIMEOUT, HTTPClient
22
+ from ._errors import os_error, server_size, translate_errors
23
+ from ._follow import is_redirect, split_userinfo
24
+ from ._headers import Headers, peer_text
25
+ from ._response import HTTPResponse
26
+ from ._status import (
27
+ HTTPStatusError,
28
+ drop_body,
29
+ json_body,
30
+ raise_for_status,
31
+ read_limited,
32
+ )
33
+ from ._transports import (
34
+ TRANSPORT_ENV,
35
+ TRANSPORT_GROUP,
36
+ PreparedRequest,
37
+ default_transport,
38
+ get_transport,
39
+ prepare_request,
40
+ transport_names,
41
+ )
42
+
43
+ __all__ = [
44
+ "DEFAULT_TIMEOUT",
45
+ "HTTPClient",
46
+ "HTTPResponse",
47
+ "HTTPStatusError",
48
+ "HTTPTransport",
49
+ "Headers",
50
+ "MAX_REPLY_BYTES",
51
+ "PreparedRequest",
52
+ "TRANSPORT_ENV",
53
+ "TRANSPORT_GROUP",
54
+ "default_transport",
55
+ "drop_body",
56
+ "get_transport",
57
+ "is_redirect",
58
+ "json_body",
59
+ "os_error",
60
+ "peer_text",
61
+ "prepare_request",
62
+ "raise_for_status",
63
+ "read_limited",
64
+ "server_size",
65
+ "split_userinfo",
66
+ "translate_errors",
67
+ "transport_names",
68
+ ]
69
+
70
+
71
+ class _Module(_types.ModuleType):
72
+ """The module, with `MAX_REPLY_BYTES` read from and written to where it
73
+ takes effect."""
74
+
75
+ @property
76
+ def MAX_REPLY_BYTES(self) -> int:
77
+ return _settings.MAX_REPLY_BYTES
78
+
79
+ @MAX_REPLY_BYTES.setter
80
+ def MAX_REPLY_BYTES(self, value: int) -> None:
81
+ _settings.MAX_REPLY_BYTES = value
82
+
83
+
84
+ _sys.modules[__name__].__class__ = _Module
pathapi/http/_body.py ADDED
@@ -0,0 +1,156 @@
1
+ """How a request body is framed: the length it is sent under, and a body that
2
+ sends exactly that many bytes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import errno as _errno
7
+ import os as _os
8
+ import stat as _stat
9
+ import typing as _ty
10
+
11
+ _STEP = 65536
12
+
13
+
14
+ def body_length(body) -> "int | None":
15
+ """The bytes `body` will send from now on, when that is known without
16
+ reading it, else `None`: a bytes-like object, a list or tuple of bytes, a
17
+ file that is seekable (from `tell()` to its end, the position restored) or
18
+ is a regular file (its size less `tell()`). An iterator, a generator and a
19
+ stream that cannot report its size have no length."""
20
+ if body is None:
21
+ return 0
22
+ if isinstance(body, (bytes, bytearray, memoryview)):
23
+ return memoryview(body).nbytes
24
+ if isinstance(body, (list, tuple)):
25
+ if all(isinstance(piece, (bytes, bytearray)) for piece in body):
26
+ return sum(len(piece) for piece in body)
27
+ return None
28
+ if hasattr(body, "read"):
29
+ return _file_length(body)
30
+ return None
31
+
32
+
33
+ def _file_length(file) -> "int | None":
34
+ try:
35
+ seekable = file.seekable()
36
+ except (AttributeError, ValueError, OSError):
37
+ seekable = False
38
+ if seekable:
39
+ here = file.tell()
40
+ try:
41
+ end = file.seek(0, 2)
42
+ if end is None:
43
+ end = file.tell()
44
+ finally:
45
+ file.seek(here)
46
+ return max(0, end - here)
47
+ try:
48
+ info = _os.fstat(file.fileno())
49
+ if _stat.S_ISREG(info.st_mode):
50
+ return max(0, info.st_size - file.tell())
51
+ except (AttributeError, ValueError, OSError):
52
+ pass
53
+ return None
54
+
55
+
56
+ def declared_length(pairs: "_ty.Iterable[tuple[str, str]]") -> "int | None":
57
+ """The `Content-Length` the caller set, or `None`; `ValueError` for one
58
+ that is repeated or is not a count of bytes."""
59
+ found = [value for name, value in pairs if name.lower() == "content-length"]
60
+ if not found:
61
+ return None
62
+ value = found[0].strip()
63
+ if len(found) > 1 or not (value.isascii() and value.isdigit()):
64
+ raise ValueError("the Content-Length header is not one count of bytes")
65
+ return int(value)
66
+
67
+
68
+ class Sized:
69
+ """A body sent under a `Content-Length`: exactly `length` bytes of `source`
70
+ (a binary file or an iterable of bytes). It never reads or sends more, so a
71
+ file that grew since it was measured cannot overrun the length, and a
72
+ source that ends first is `OSError(EIO)`, not a shorter upload."""
73
+
74
+ def __init__(self, source, length: int):
75
+ self._length = length
76
+ self._left = length
77
+ if hasattr(source, "read"):
78
+ self._read = source.read
79
+ else:
80
+ self._pieces = iter(source)
81
+ self._pending = b""
82
+ self._read = self._read_pieces
83
+
84
+ def __len__(self) -> int:
85
+ return self._length
86
+
87
+ def __iter__(self) -> "_ty.Iterator[bytes]":
88
+ while True:
89
+ chunk = self.read(_STEP)
90
+ if not chunk:
91
+ return
92
+ yield chunk
93
+
94
+ def read(self, amount: "int | None" = -1) -> bytes:
95
+ if self._left <= 0:
96
+ return b""
97
+ want = self._left if amount is None or amount < 0 else min(amount, self._left)
98
+ chunk = self._read(want)
99
+ if isinstance(chunk, str):
100
+ raise TypeError("the request body is bytes, not text")
101
+ if not chunk:
102
+ raise OSError(
103
+ _errno.EIO, "the request body ended before its Content-Length"
104
+ )
105
+ chunk = bytes(chunk[:want])
106
+ self._left -= len(chunk)
107
+ return chunk
108
+
109
+ def _read_pieces(self, want: int) -> bytes:
110
+ while not self._pending:
111
+ try:
112
+ self._pending = bytes(next(self._pieces))
113
+ except StopIteration:
114
+ return b""
115
+ chunk, self._pending = self._pending[:want], self._pending[want:]
116
+ return chunk
117
+
118
+
119
+ def _chunks(file) -> "_ty.Iterator[bytes]":
120
+ while True:
121
+ chunk = file.read(_STEP)
122
+ if not chunk:
123
+ return
124
+ yield chunk
125
+
126
+
127
+ def prepare_body(body, pairs: "list[tuple[str, str]]"):
128
+ """`(value, length)`: what to hand the HTTP layer as the body, and the
129
+ `Content-Length` to add (`None` when the request already names one, the
130
+ layer adds its own, or none is needed). A length the caller set is
131
+ trusted, so nothing is chunked and exactly that many bytes are sent;
132
+ otherwise a body whose length is known is sent under it and any other is
133
+ left for the layer to send chunked."""
134
+ if body is None:
135
+ return None, None
136
+ declared = declared_length(pairs)
137
+ known = body_length(body)
138
+ if isinstance(body, (bytes, bytearray, memoryview)):
139
+ if declared is None or declared == known:
140
+ return body, None
141
+ if declared > known:
142
+ raise ValueError("the Content-Length header is larger than the body")
143
+ return bytes(memoryview(body)[:declared]), None
144
+ if declared is not None:
145
+ if known is not None and declared > known:
146
+ raise ValueError("the Content-Length header is larger than the body")
147
+ length, add = declared, None
148
+ elif any(name.lower() == "transfer-encoding" for name, _ in pairs):
149
+ return body, None # the caller frames it
150
+ else:
151
+ length, add = known, known
152
+ if length is None:
153
+ return (_chunks(body) if hasattr(body, "read") else body), None
154
+ if length == 0:
155
+ return b"", add
156
+ return Sized(body, length), add