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,429 @@
1
+ from __future__ import annotations
2
+
3
+ import abc as _abc
4
+ import errno as _errno
5
+ import stat as _stat
6
+ import time as _time
7
+ import typing as _ty
8
+
9
+ from .. import _permissions, _stubs
10
+ from ._errors import _os_error
11
+ from ._info import _PathInfo
12
+
13
+ __all__ = ["Chmod", "Chown", "Create", "FileStatLike", "Owned", "Stat", "Utime"]
14
+
15
+
16
+ class FileStatLike(_ty.Protocol):
17
+ """Minimum properties stat like object should provide"""
18
+
19
+ __slots__ = ()
20
+
21
+ @property
22
+ @_abc.abstractmethod
23
+ def st_mode(self) -> int: ...
24
+ @property
25
+ @_abc.abstractmethod
26
+ def st_size(self) -> int: ...
27
+ @property
28
+ @_abc.abstractmethod
29
+ def st_mtime(self) -> float: ...
30
+
31
+
32
+ class Stat(_ty.Protocol):
33
+ """Any object that can implement Stat and utilities functions based on it"""
34
+
35
+ __slots__ = ()
36
+
37
+ __primitives__: _ty.ClassVar[tuple[object, ...]] = ("stat",)
38
+
39
+ @_stubs.primitive
40
+ def stat(self, *, follow_symlinks=True) -> FileStatLike:
41
+ """Return the stat of the path (a stub: `NotImplementedError`)."""
42
+ return _stubs._unimplemented("stat")
43
+
44
+ def lstat(self) -> FileStatLike:
45
+ """
46
+ Like stat(), except if the path points to a symlink, the symlink's
47
+ status information is returned, rather than its target's.
48
+ """
49
+ return self.stat(follow_symlinks=False)
50
+
51
+ def _st_mode(self, *, follow_symlinks=True):
52
+ """
53
+ Utility function only for internal use if this object,
54
+ not required nor to be expected in any implementations of the protocol
55
+ """
56
+ try:
57
+ # follow_symlinks must be forwarded -- it wasn't, so is_symlink()
58
+ # (which calls this with follow_symlinks=False) always resolved
59
+ # through the symlink instead of stat'ing it directly.
60
+ return self.stat(follow_symlinks=follow_symlinks).st_mode
61
+ except (OSError, ValueError):
62
+ # Every OSError (PermissionError included) and ValueError reads as "no such entry"
63
+ # on every version: pathlib's rule from 3.13; 3.9-3.12 re-raise errors outside
64
+ # ENOENT/ENOTDIR/EBADF/ELOOP (EACCES say), so there the generic exists() differs.
65
+ return None
66
+
67
+ # Convenience functions for querying the stat results
68
+ def exists(self, *, follow_symlinks=True):
69
+ """
70
+ Whether this path exists.
71
+ """
72
+ return self._st_mode(follow_symlinks=follow_symlinks) is not None
73
+
74
+ def is_dir(self, *, follow_symlinks=True):
75
+ """
76
+ Whether this path is a directory. `follow_symlinks=False` reports a
77
+ symlink to a directory as not one (3.13 parity).
78
+ """
79
+ return _stat.S_ISDIR(self._st_mode(follow_symlinks=follow_symlinks) or 0)
80
+
81
+ def is_file(self, *, follow_symlinks=True):
82
+ """
83
+ Whether this path is a regular file (also True for symlinks pointing
84
+ to regular files unless `follow_symlinks=False`, 3.13 parity).
85
+ """
86
+ return _stat.S_ISREG(self._st_mode(follow_symlinks=follow_symlinks) or 0)
87
+
88
+ def is_symlink(self):
89
+ """
90
+ Whether this path is a symbolic link.
91
+ """
92
+ return _stat.S_ISLNK(self._st_mode(follow_symlinks=False) or 0)
93
+
94
+ def is_block_device(self):
95
+ """
96
+ Whether this path is a block device.
97
+ """
98
+ return _stat.S_ISBLK(self._st_mode() or 0)
99
+
100
+ def is_char_device(self):
101
+ """
102
+ Whether this path is a character device.
103
+ """
104
+ return _stat.S_ISCHR(self._st_mode() or 0)
105
+
106
+ def is_fifo(self):
107
+ """
108
+ Whether this path is a FIFO.
109
+ """
110
+ return _stat.S_ISFIFO(self._st_mode() or 0)
111
+
112
+ def is_socket(self):
113
+ """
114
+ Whether this path is a socket.
115
+ """
116
+ return _stat.S_ISSOCK(self._st_mode() or 0)
117
+
118
+ @property
119
+ def info(self) -> "_PathInfo":
120
+ """What this path is, as an object shaped like `pathlib.types.PathInfo`
121
+ (Python 3.14): `exists()`, `is_dir()`, `is_file()` (each with
122
+ `follow_symlinks=True`) and `is_symlink()`. It asks `stat()` at most
123
+ once with and once without following links and remembers the answers
124
+ on that object only: a new `info` is a new question. `LocalPath` has
125
+ pathlib's own on 3.14."""
126
+ return _PathInfo(self)
127
+
128
+ def is_junction(self) -> bool:
129
+ """Whether this path is a Windows junction (pathlib 3.12 parity).
130
+
131
+ A junction is NOT a symlink: it is a second NAME for a directory,
132
+ the Windows equivalent of a Linux bind mount. `is_symlink()` is
133
+ False for one, `readlink()` does not describe it, and a
134
+ non-following stat reports a plain directory -- which is exactly why
135
+ a symlink check cannot protect a recursive walk from it.
136
+
137
+ Default False; `LocalPath` answers for real. A remote
138
+ backend has no such concept unless it says otherwise.
139
+ """
140
+ return False
141
+
142
+ def is_mount(self) -> bool:
143
+ """Whether this path is a mount point (pathlib parity).
144
+
145
+ The POSIX half of the same idea: a bind mount makes one tree visible
146
+ under a second name, with no link anywhere to say so. Default False;
147
+ `LocalPath` answers for real.
148
+ """
149
+ return False
150
+
151
+ def is_dir_binding(self) -> bool:
152
+ """Whether this directory is another tree's second NAME rather than
153
+ part of this one: a Windows junction or a mount point (bind or
154
+ otherwise).
155
+
156
+ The distinction that matters to anything walking a tree. A symlink
157
+ announces itself -- `is_symlink()` is True and every walker here
158
+ already declines to follow one. A binding does not: it looks like an
159
+ ordinary directory, and its contents belong to whoever mounted or
160
+ junctioned it. `rm(recursive=True)` removes the binding itself
161
+ rather than the contents behind it.
162
+
163
+ An implementation that cannot answer (stdlib's `is_mount()` raises
164
+ on Windows before 3.12) counts as "not a binding" rather than
165
+ breaking the operation that asked.
166
+ """
167
+ for test in (self.is_junction, self.is_mount):
168
+ try:
169
+ if test():
170
+ return True
171
+ except (NotImplementedError, OSError, ValueError):
172
+ continue
173
+ return False
174
+
175
+
176
+ class Owned(Stat, _ty.Protocol):
177
+ """Reading who owns a path. `owner()` and `group()` map the `st_uid` and
178
+ `st_gid` of `stat()` through the primitives `_user_name()` and
179
+ `_group_name()`; a class that has `owner()` and `group()` from somewhere
180
+ else (`pathlib`) needs neither."""
181
+
182
+ __slots__ = ()
183
+
184
+ __primitives__: _ty.ClassVar[tuple[object, ...]] = (
185
+ ("_user_name", "owner"),
186
+ ("_group_name", "group"),
187
+ )
188
+
189
+ def owner(self, *, follow_symlinks: bool = True) -> str:
190
+ """The name of the user that owns the path. `NotImplementedError`
191
+ when `stat()` carries no `st_uid` or the class does not map user ids
192
+ to names (`_user_name()`)."""
193
+ uid = getattr(self.stat(follow_symlinks=follow_symlinks), "st_uid", None)
194
+ if uid is None:
195
+ raise NotImplementedError("owner() requires stat() to provide st_uid")
196
+ return self._user_name(uid)
197
+
198
+ def group(self, *, follow_symlinks: bool = True) -> str:
199
+ """The name of the group that owns the path. `NotImplementedError`
200
+ when `stat()` carries no `st_gid` or the class does not map group ids
201
+ to names (`_group_name()`)."""
202
+ gid = getattr(self.stat(follow_symlinks=follow_symlinks), "st_gid", None)
203
+ if gid is None:
204
+ raise NotImplementedError("group() requires stat() to provide st_gid")
205
+ return self._group_name(gid)
206
+
207
+ @_stubs.primitive
208
+ def _user_name(self, uid: int) -> str:
209
+ """Backend primitive behind `owner()`: the name of user `uid` (a
210
+ stub: `NotImplementedError`)."""
211
+ return _stubs._unimplemented("_user_name")
212
+
213
+ @_stubs.primitive
214
+ def _group_name(self, gid: int) -> str:
215
+ """Backend primitive behind `group()`: the name of group `gid` (a
216
+ stub: `NotImplementedError`)."""
217
+ return _stubs._unimplemented("_group_name")
218
+
219
+
220
+ class Chown(_ty.Protocol):
221
+ """Changing who owns a path: `_chown()` is the primitive and `chown()`
222
+ normalizes its arguments."""
223
+
224
+ __slots__ = ()
225
+
226
+ __primitives__: _ty.ClassVar[tuple[object, ...]] = (("_chown", "chown"),)
227
+
228
+ @_stubs.primitive
229
+ def _chown(
230
+ self,
231
+ uid: int | str | None,
232
+ gid: int | str | None,
233
+ *,
234
+ follow_symlinks: bool = True,
235
+ ) -> None:
236
+ """Backend primitive behind `chown()`.
237
+
238
+ Receives an already-canonical `(uid, gid)` pair: `int` to set, or
239
+ `None` for "leave unchanged" (a `str` is a user/group *name*).
240
+ Convert only to this backend's own wire spelling -- `-1` for
241
+ `os.chown`, an omitted attr field for SFTP `setstat`. Normalizing
242
+ the caller's input is `chown()`'s job, not the primitive's.
243
+ """
244
+ return _stubs._unimplemented("_chown")
245
+
246
+ def chown(
247
+ self,
248
+ uid: int | str | None = None,
249
+ gid: int | str | None = None,
250
+ *,
251
+ follow_symlinks: bool = True,
252
+ ) -> None:
253
+ """Change the owner and/or group of the path.
254
+
255
+ Extension: `pathlib.Path` has `owner()`/`group()` **readers** but no
256
+ writer (the stdlib equivalent is `shutil.chown`/`os.chown`), so
257
+ ownership was the one attribute `stat()` could report -- via
258
+ `st_uid`/`st_gid` -- and nothing could write back.
259
+
260
+ `None` (the default) leaves a field unchanged, which is what lets a
261
+ caller set the group without knowing the owner. `-1` is accepted as
262
+ an alias for `None`, matching `os.chown`'s own sentinel. An `int` is
263
+ a uid/gid; a `str` is a user/group name, passed through for backends
264
+ that can resolve one.
265
+
266
+ Backends implement `_chown()` and receive an already-canonical pair
267
+ (see `providers.parse_owner()`), so the "unchanged" semantics cannot drift
268
+ between schemes.
269
+ """
270
+ uid, gid = _permissions.parse_owner(uid, gid)
271
+ if uid is None and gid is None:
272
+ # Nothing to change -- don't spend a round trip (or make a
273
+ # backend decide what an all-sentinel call means).
274
+ return
275
+ return self._chown(uid, gid, follow_symlinks=follow_symlinks)
276
+
277
+
278
+ class Utime(_ty.Protocol):
279
+ """Setting the access and modification times of a path."""
280
+
281
+ __slots__ = ()
282
+
283
+ __primitives__: _ty.ClassVar[tuple[object, ...]] = ("_utime",)
284
+
285
+ @_stubs.primitive
286
+ def _utime(self, *, atime: float | None = None, mtime: float | None = None) -> None:
287
+ """Backend primitive: set the access and/or modification time of the
288
+ entry, in seconds since the epoch. `None` leaves that time alone. A
289
+ backend that keeps no such time (or no atime, or whole seconds only)
290
+ sets what it can and ignores the rest; one that cannot set a time at
291
+ all raises `NotImplementedError`, which is the default. `touch()` of an
292
+ existing file and `PathSyncer(preserve_mtime=True)` call it."""
293
+ return _stubs._unimplemented("_utime")
294
+
295
+
296
+ class Chmod(Chown, Utime, _ty.Protocol):
297
+ """Changing the permissions of a path. It also inherits `Chown` and
298
+ `Utime`, which it documented before they were separate protocols, so
299
+ `chown()`, `_chown()` and `_utime()` stay reachable from it;
300
+ `supports(x, Chmod)` asks for `chmod()` only."""
301
+
302
+ __slots__ = ()
303
+
304
+ __primitives__: _ty.ClassVar[tuple[object, ...]] = ("chmod",)
305
+
306
+ @_stubs.primitive
307
+ def chmod(self, mode: int | str, *, follow_symlinks: bool = True):
308
+ """
309
+ Change the permissions of the path, like os.chmod().
310
+
311
+ Extension: `mode` may be a `str`, which is parsed as **octal**
312
+ (`"0755"`, `"755"` and `0o755` all mean the same thing) -- see
313
+ `providers.parse_mode()` for why the base is never left implicit.
314
+
315
+ Every implementation normalizes its argument through
316
+ `providers.parse_mode()` on entry. Unlike `symlink_to`/`chown`, `chmod` is
317
+ overridden directly by each backend (each has real per-scheme logic
318
+ -- version shims, capability gates, a `SITE CHMOD` command), so
319
+ there is no single wrapper to hang the conversion on; the shared
320
+ helper is what keeps the base from drifting between them.
321
+ """
322
+ return _stubs._unimplemented("chmod")
323
+
324
+ def lchmod(self, mode: int | str):
325
+ """
326
+ Like chmod(), except if the path points to a symlink, the symlink's
327
+ permissions are changed, rather than its target's.
328
+ """
329
+ self.chmod(mode, follow_symlinks=False)
330
+
331
+
332
+ class Create(Stat, Chmod, Utime, _ty.Protocol):
333
+ """Creating a directory or a file: `mkdir()` and `touch()` on top of the
334
+ `_mkdir()` primitive. `touch()` also uses `open()` (`BinaryOpen`) and
335
+ `unlink()` (`Remove`)."""
336
+
337
+ __slots__ = ()
338
+
339
+ __primitives__: _ty.ClassVar[tuple[object, ...]] = (("_mkdir", "mkdir"),)
340
+
341
+ def touch(self, mode=None, exist_ok=True):
342
+ """
343
+ Create this file, if it doesn't exist.
344
+
345
+ Raises FileExistsError if exist_ok is False and the file already
346
+ exists (pathlib parity). An existing file is never truncated.
347
+
348
+ Differences from `pathlib.Path.touch`, which creates through
349
+ `os.open()` (so the process umask applies) and bumps an existing
350
+ file's mtime:
351
+
352
+ - `mode` is applied with `chmod()` only when passed explicitly, and
353
+ then verbatim: a remote server's umask is unknowable. A backend
354
+ that cannot chmod raises `NotImplementedError` and the file it
355
+ created is removed again, so a mode that was asked for is never
356
+ silently dropped. The default (`None`) leaves a new file with the
357
+ permissions the backend gives it, instead of chmod'ing it to a
358
+ world-writable 0o666.
359
+ - An existing file's access and modification times are set to the
360
+ current time through `_utime()`; a backend that cannot set a time
361
+ leaves the file as it is, since rewriting the content to bump it
362
+ is not a touch. `LocalPath` uses pathlib's own touch.
363
+ """
364
+ # stat() directly, not exists(): exists() reads *any* OSError (a
365
+ # timeout, a dropped connection) as "missing", which let a transient
366
+ # failure fall through to a truncating open("w").
367
+ try:
368
+ self.stat()
369
+ except FileNotFoundError:
370
+ pass
371
+ else:
372
+ if not exist_ok:
373
+ raise _os_error(FileExistsError, _errno.EEXIST, self)
374
+ now = _time.time()
375
+ try:
376
+ self._utime(atime=now, mtime=now)
377
+ except NotImplementedError:
378
+ pass
379
+ return
380
+ try:
381
+ with self.open("x"):
382
+ ...
383
+ except FileExistsError:
384
+ # Created since the stat() above: an existing file, not ours to
385
+ # truncate.
386
+ if not exist_ok:
387
+ raise
388
+ return
389
+ except NotImplementedError:
390
+ # _open() doesn't support "x" (optional per the mode contract),
391
+ # so the stat() above is the only guard before the truncating
392
+ # "w" -- a small TOCTOU window.
393
+ with self.open("w"):
394
+ ...
395
+ if mode is not None:
396
+ try:
397
+ self.chmod(mode)
398
+ except NotImplementedError:
399
+ # The caller asked for a mode this backend cannot apply: leave
400
+ # no file that has other permissions than the ones asked for.
401
+ try:
402
+ self.unlink()
403
+ except (OSError, NotImplementedError):
404
+ pass
405
+ raise
406
+
407
+ @_stubs.primitive
408
+ def _mkdir(self, mode: int):
409
+ """Backend primitive: create this directory (a stub:
410
+ `NotImplementedError`). `mkdir()` calls it."""
411
+ return _stubs._unimplemented("_mkdir")
412
+
413
+ def mkdir(self, mode=0o777, parents=False, exist_ok=False):
414
+ """
415
+ Create a new directory at this given path.
416
+ """
417
+ try:
418
+ self._mkdir(mode)
419
+ except FileNotFoundError:
420
+ if not parents or self.parent == self:
421
+ raise
422
+ # Parents may already exist (e.g. a sibling branch created them)
423
+ # -- mirror CPython: exist_ok=True for parents, original
424
+ # exist_ok only for the final retry of self.
425
+ self.parent.mkdir(parents=True, exist_ok=True)
426
+ self.mkdir(mode, parents=False, exist_ok=exist_ok)
427
+ except FileExistsError:
428
+ if not exist_ok or not self.is_dir():
429
+ raise
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import io as _io
4
+ import shutil as _shutil
5
+ import typing as _ty
6
+
7
+ from .. import _discard, _stubs
8
+
9
+ __all__ = ["BinaryOpen"]
10
+
11
+ #: `_copy_stream(total_size=)` default: ask `stat()` for the size.
12
+ _ASK_STAT = object()
13
+
14
+
15
+ def _canonical_mode(mode, buffering, encoding, errors, newline) -> str:
16
+ """`open()`'s mode validation (CPython `_io.open`), returning the mode
17
+ as kind + optional "+" + "b" for binary. A mode such as "rt" is
18
+ canonicalized before it reaches `_open()`, and an invalid one ("zz", "rw")
19
+ raises ValueError, as `open()` does."""
20
+ if not isinstance(mode, str):
21
+ raise TypeError(
22
+ f"open() argument 'mode' must be str, not {type(mode).__name__}"
23
+ )
24
+ modes = set(mode)
25
+ if modes - set("axrwb+t") or len(mode) > len(modes):
26
+ raise ValueError(f"invalid mode: {mode!r}")
27
+ kinds = [kind for kind in "xrwa" if kind in modes]
28
+ text = "t" in modes
29
+ binary = "b" in modes
30
+ if text and binary:
31
+ raise ValueError("can't have text and binary mode at once")
32
+ if len(kinds) != 1:
33
+ raise ValueError(
34
+ "must have exactly one of create/read/write/append mode"
35
+ if kinds
36
+ else "Must have exactly one of create/read/write/append "
37
+ "mode and at most one plus"
38
+ )
39
+ if binary:
40
+ if encoding is not None:
41
+ raise ValueError("binary mode doesn't take an encoding argument")
42
+ if errors is not None:
43
+ raise ValueError("binary mode doesn't take an errors argument")
44
+ if newline is not None:
45
+ raise ValueError("binary mode doesn't take a newline argument")
46
+ elif buffering == 0:
47
+ raise ValueError("can't have unbuffered text I/O")
48
+ return kinds[0] + ("+" if "+" in modes else "") + ("b" if binary else "")
49
+
50
+
51
+ class BinaryOpen(_ty.Protocol):
52
+ """Protocol for objects that support open->io.IoBase"""
53
+
54
+ __slots__ = ()
55
+
56
+ __primitives__: _ty.ClassVar[tuple[object, ...]] = (("_open", "open"),)
57
+
58
+ @_stubs.primitive
59
+ def _open(
60
+ self,
61
+ mode="r",
62
+ buffering=-1,
63
+ ) -> _io.IOBase:
64
+ """
65
+ All operations should be binary
66
+ To be used only by open() to obtain binary stream to provide implementations for all methods
67
+ """
68
+ return _stubs._unimplemented("_open")
69
+
70
+ def open(
71
+ self,
72
+ mode="r",
73
+ buffering=-1,
74
+ encoding: str = None,
75
+ errors: str = None,
76
+ newline: str = None,
77
+ ) -> _io.IOBase:
78
+ """
79
+ Open the a handle to an object that implement io.IOBase
80
+
81
+ The mode is validated like `open()` (ValueError for an invalid mode,
82
+ or an encoding/errors/newline with a binary one), and `_open()`
83
+ receives it canonical: one of "r"/"w"/"x"/"a", plus "+" if given --
84
+ never "b" or "t", so "rt"/"wt" reach every backend as "r"/"w".
85
+
86
+ A stream that commits on close inherits `providers.DiscardOnError`, so
87
+ that its own `__exit__` calls `discard()` before closing it when the
88
+ `with` block ended in an exception; binary mode returns the stream
89
+ `_open()` gave it as it is. In text mode the wrapper `open()` makes
90
+ does the same for any stream that has a callable `discard()`.
91
+ """
92
+ mode = _canonical_mode(mode, buffering, encoding, errors, newline)
93
+ fh = self._open(mode.replace("b", ""), buffering)
94
+ if "b" in mode:
95
+ return fh
96
+ try:
97
+ # io.text_encoding is 3.10+; on 3.9 pass encoding through
98
+ # as-is (None means locale default, same effective behavior).
99
+ encoding = getattr(_io, "text_encoding", lambda e, stacklevel=1: e)(
100
+ encoding
101
+ )
102
+ # buffering=1 is line buffering in text mode, as for open().
103
+ discard = getattr(fh, "discard", None)
104
+ if callable(discard):
105
+ return _discard.DiscardingTextIO(
106
+ fh,
107
+ discard,
108
+ encoding,
109
+ errors,
110
+ newline,
111
+ line_buffering=buffering == 1,
112
+ )
113
+ return _io.TextIOWrapper(
114
+ fh, encoding, errors, newline, line_buffering=buffering == 1
115
+ )
116
+ except BaseException:
117
+ # An unknown encoding (LookupError) or newline (ValueError) fails after the backend
118
+ # handle is open; io.open closes its raw file then, and so must we: a leaked write
119
+ # handle could commit an empty upload at GC time. A stream that commits on close
120
+ # discards first, so the failed call does not create or replace the file.
121
+ _discard.abandon(fh)
122
+ raise
123
+
124
+ def read_bytes(self) -> bytes:
125
+ """
126
+ Open in bytes mode, read it, and close the file.
127
+ """
128
+ with self.open(mode="rb") as f:
129
+ return f.read()
130
+
131
+ def read_text(
132
+ self, encoding: str = None, errors: str = None, newline: str = None
133
+ ) -> str:
134
+ """
135
+ Open in text mode, read it, and close the file. (newline= is 3.13
136
+ parity.)
137
+ """
138
+ with self.open(
139
+ mode="r", encoding=encoding, errors=errors, newline=newline
140
+ ) as f:
141
+ return f.read()
142
+
143
+ def write_bytes(self, data: bytes):
144
+ """
145
+ Open in bytes mode, write to it, and close the file.
146
+ """
147
+ # type-check for the buffer interface before truncating the file
148
+ view = memoryview(data)
149
+ with self.open(mode="wb") as f:
150
+ return f.write(view)
151
+
152
+ def write_text(
153
+ self, data: str, encoding: str = None, errors: str = None, newline: str = None
154
+ ):
155
+ """
156
+ Open in text mode, write to it, and close the file.
157
+ """
158
+ if not isinstance(data, str):
159
+ raise TypeError("data must be str, not %s" % data.__class__.__name__)
160
+ with self.open(
161
+ mode="w", encoding=encoding, errors=errors, newline=newline
162
+ ) as f:
163
+ return f.write(data)
164
+
165
+ def copy(
166
+ self,
167
+ target: "BinaryOpen",
168
+ *,
169
+ progress: "_ty.Callable[[int, _ty.Optional[int]], None]" = None,
170
+ chunk_size: int = _shutil.COPY_BUFSIZE,
171
+ ):
172
+ """Copy the binary content from this object to a target object.
173
+
174
+ `progress`, when given, is called after each chunk is written as
175
+ `progress(bytes_copied, total_size)`: `bytes_copied` increases
176
+ monotonically and equals `total_size` (if known) after the final
177
+ call; an empty file gets exactly one call, `progress(0, total_size)`.
178
+ `total_size` is this object's `stat().st_size` when `self` also
179
+ implements the `Stat` protocol and `stat()` succeeds,
180
+ otherwise `None` -- `BinaryOpen` alone has no size concept.
181
+ `chunk_size` controls how many bytes are read per iteration
182
+ (default: `shutil.COPY_BUFSIZE`). With `progress=None` (the
183
+ default), behavior and bytes-on-wire are identical to before this
184
+ was added (a plain `shutil.copyfileobj`).
185
+ """
186
+ # The source is opened first: opening the target "wb" truncates or
187
+ # creates it, and doing that before knowing the source is readable
188
+ # turned a missing source into an emptied destination.
189
+ with self.open("rb") as input, target.open("wb") as output:
190
+ self._copy_stream(input, output, progress=progress, chunk_size=chunk_size)
191
+
192
+ def _copy_stream(
193
+ self,
194
+ input: _io.IOBase,
195
+ output: _io.IOBase,
196
+ *,
197
+ progress: "_ty.Callable[[int, _ty.Optional[int]], None]" = None,
198
+ chunk_size: int = _shutil.COPY_BUFSIZE,
199
+ total_size: "object" = _ASK_STAT,
200
+ ):
201
+ """Stream an already-open binary `input` into `output` (see `copy()`
202
+ for the `progress` contract). `total_size` is the size to report, or
203
+ None when unknown; by default `self.stat()` supplies it."""
204
+ if progress is None:
205
+ _shutil.copyfileobj(input, output, chunk_size)
206
+ return
207
+
208
+ if total_size is _ASK_STAT:
209
+ try:
210
+ stat = self.stat()
211
+ total_size = stat.st_size if getattr(stat, "size_known", True) else None
212
+ except (AttributeError, NotImplementedError, OSError):
213
+ total_size = None
214
+
215
+ copied = 0
216
+ while chunk := input.read(chunk_size):
217
+ output.write(chunk)
218
+ copied += len(chunk)
219
+ progress(copied, total_size)
220
+ if not copied:
221
+ # An empty file writes no chunk, but it is still a finished copy:
222
+ # report it once so a caller waiting for bytes_copied ==
223
+ # total_size is not left hanging on a zero-byte file.
224
+ progress(0, total_size)