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.
- pathapi/AGENTS.md +2355 -0
- pathapi/README.md +205 -0
- pathapi/__init__.py +62 -0
- pathapi/_commit.py +116 -0
- pathapi/_discard.py +92 -0
- pathapi/_errorpolicy.py +106 -0
- pathapi/_filestat.py +194 -0
- pathapi/_lru.py +138 -0
- pathapi/_names.py +70 -0
- pathapi/_permissions.py +102 -0
- pathapi/_stubs.py +76 -0
- pathapi/_tree.py +136 -0
- pathapi/archive/__init__.py +44 -0
- pathapi/archive/_backend.py +393 -0
- pathapi/archive/_names.py +63 -0
- pathapi/archive/_pack.py +394 -0
- pathapi/archive/_path.py +352 -0
- pathapi/archive/_snapshot.py +269 -0
- pathapi/archive/_tar.py +126 -0
- pathapi/archive/_write.py +359 -0
- pathapi/archive/_zip.py +563 -0
- pathapi/archive/_zipentries.py +272 -0
- pathapi/checksum.py +61 -0
- pathapi/exceptions.py +22 -0
- pathapi/fspath.py +513 -0
- pathapi/ftp/__init__.py +27 -0
- pathapi/ftp/_backend.py +418 -0
- pathapi/ftp/_facts.py +117 -0
- pathapi/ftp/_path.py +582 -0
- pathapi/glob/__init__.py +27 -0
- pathapi/glob/_entries.py +103 -0
- pathapi/glob/_patterns.py +164 -0
- pathapi/glob/_recursion.py +180 -0
- pathapi/glob/_selection.py +303 -0
- pathapi/http/__init__.py +84 -0
- pathapi/http/_body.py +156 -0
- pathapi/http/_builtin.py +553 -0
- pathapi/http/_client.py +211 -0
- pathapi/http/_errors.py +79 -0
- pathapi/http/_follow.py +230 -0
- pathapi/http/_headers.py +233 -0
- pathapi/http/_pool.py +169 -0
- pathapi/http/_proxies.py +142 -0
- pathapi/http/_response.py +255 -0
- pathapi/http/_settings.py +12 -0
- pathapi/http/_status.py +68 -0
- pathapi/http/_transports.py +173 -0
- pathapi/http/testing/__init__.py +80 -0
- pathapi/http/testing/__main__.py +10 -0
- pathapi/http/testing/suite/conftest.py +98 -0
- pathapi/http/testing/suite/test_client_default.py +285 -0
- pathapi/http/testing/suite/test_client_errors.py +181 -0
- pathapi/http/testing/suite/test_client_peer_text.py +86 -0
- pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
- pathapi/http/testing/suite/test_client_redirects.py +231 -0
- pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
- pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
- pathapi/http/testing/suite/test_transport_body.py +302 -0
- pathapi/http/testing/suite/test_transport_send.py +662 -0
- pathapi/http/testing/suite/transport_fakes.py +133 -0
- pathapi/http/testing/suite/transport_guard.py +174 -0
- pathapi/http/testing/suite/transport_wire.py +511 -0
- pathapi/mempath.py +438 -0
- pathapi/path/__init__.py +19 -0
- pathapi/path/_info.py +10 -0
- pathapi/path/_path.py +198 -0
- pathapi/path/_pure.py +522 -0
- pathapi/path/_relation.py +10 -0
- pathapi/path/_transfer.py +10 -0
- pathapi/protocols/__init__.py +35 -0
- pathapi/protocols/_bundles.py +22 -0
- pathapi/protocols/_errors.py +12 -0
- pathapi/protocols/_info.py +65 -0
- pathapi/protocols/_relation.py +191 -0
- pathapi/protocols/_removal.py +69 -0
- pathapi/protocols/_support.py +122 -0
- pathapi/protocols/_transfer.py +100 -0
- pathapi/protocols/checksum.py +80 -0
- pathapi/protocols/fs.py +429 -0
- pathapi/protocols/io.py +224 -0
- pathapi/protocols/links.py +135 -0
- pathapi/protocols/pure.py +31 -0
- pathapi/protocols/resolve.py +97 -0
- pathapi/protocols/transfer.py +656 -0
- pathapi/protocols/tree.py +492 -0
- pathapi/providers.py +48 -0
- pathapi/py.typed +1 -0
- pathapi/sync/__init__.py +8 -0
- pathapi/sync/_compare.py +157 -0
- pathapi/sync/_events.py +97 -0
- pathapi/sync/_links.py +93 -0
- pathapi/sync/_offer.py +33 -0
- pathapi/sync/_paths.py +50 -0
- pathapi/sync/_replace.py +98 -0
- pathapi/sync/_syncer.py +989 -0
- pathapi/testing/__init__.py +96 -0
- pathapi/testing/_contract.py +522 -0
- pathapi/testing/_fixtures.py +83 -0
- pathapi/testing/_read.py +279 -0
- pathapi-0.1.0.dist-info/METADATA +242 -0
- pathapi-0.1.0.dist-info/RECORD +104 -0
- pathapi-0.1.0.dist-info/WHEEL +4 -0
- pathapi-0.1.0.dist-info/entry_points.txt +2 -0
- pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
pathapi/protocols/fs.py
ADDED
|
@@ -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
|
pathapi/protocols/io.py
ADDED
|
@@ -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)
|