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/_filestat.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import stat as _stat
|
|
4
|
+
import typing as _ty
|
|
5
|
+
|
|
6
|
+
from .protocols import FileStatLike as _FStat
|
|
7
|
+
|
|
8
|
+
if _ty.TYPE_CHECKING:
|
|
9
|
+
from os import stat_result as _os_stat
|
|
10
|
+
|
|
11
|
+
from .path import Path
|
|
12
|
+
|
|
13
|
+
__all__ = ["FileStat"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sizeof_fmt(num: _ty.Union[int, float]) -> str:
|
|
17
|
+
for unit in ("", "K", "M", "G", "T", "P", "E", "Z"):
|
|
18
|
+
if abs(num) < 1024:
|
|
19
|
+
if unit:
|
|
20
|
+
return "%3.1f%s" % (num, unit)
|
|
21
|
+
else:
|
|
22
|
+
return str(int(num))
|
|
23
|
+
num /= 1024.0
|
|
24
|
+
return "%.1f%s" % (num, "Y")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FileStat(_FStat):
|
|
28
|
+
"""Concrete, slotted `FileStatLike` for backends without a real
|
|
29
|
+
`os.stat_result` (e.g. `MemPath`, an HTTP index). `from_path()` builds one
|
|
30
|
+
from any object with a `stat()` method, or passes a `FileStat` through
|
|
31
|
+
unchanged."""
|
|
32
|
+
|
|
33
|
+
#: The stat fields proper -- what `items()` reports and `from_stat()`
|
|
34
|
+
#: copies from a foreign stat object.
|
|
35
|
+
_FIELDS = (
|
|
36
|
+
"st_mode",
|
|
37
|
+
"st_nlink",
|
|
38
|
+
"st_uid",
|
|
39
|
+
"st_gid",
|
|
40
|
+
"st_size",
|
|
41
|
+
"st_atime",
|
|
42
|
+
"st_mtime",
|
|
43
|
+
"st_ctime",
|
|
44
|
+
)
|
|
45
|
+
__slots__ = _FIELDS + ("mode_known", "size_known")
|
|
46
|
+
|
|
47
|
+
st_mode: int
|
|
48
|
+
st_nlink: int
|
|
49
|
+
st_uid: int
|
|
50
|
+
st_gid: int
|
|
51
|
+
st_size: int
|
|
52
|
+
st_atime: float
|
|
53
|
+
st_mtime: float
|
|
54
|
+
st_ctime: float
|
|
55
|
+
|
|
56
|
+
#: Whether `st_mode` was reported by the backend (True) or is a placeholder from
|
|
57
|
+
#: `is_dir` (False: `S_IFREG | 0o444`, `S_IFDIR | 0o555`). A consumer that would
|
|
58
|
+
#: write the mode (`copy(preserve_metadata=True)`) skips a placeholder.
|
|
59
|
+
mode_known: bool
|
|
60
|
+
|
|
61
|
+
#: False when the listing knew the entry's type but not its size and time: `st_size`
|
|
62
|
+
#: and `st_mtime` are placeholders (0) and only the type answers. A consumer that
|
|
63
|
+
#: needs a size or time asks the path itself, as for a stat that is `None`.
|
|
64
|
+
size_known: bool
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
st_mode: int | None = None,
|
|
69
|
+
st_size: int = 0,
|
|
70
|
+
st_mtime: float = 0,
|
|
71
|
+
is_dir: bool = False,
|
|
72
|
+
size_known: bool = True,
|
|
73
|
+
):
|
|
74
|
+
self.size_known = size_known
|
|
75
|
+
self.mode_known = bool(st_mode)
|
|
76
|
+
if st_mode and not _stat.S_IFMT(st_mode):
|
|
77
|
+
# Permission bits alone: the type comes from `is_dir`.
|
|
78
|
+
st_mode |= _stat.S_IFDIR if is_dir else _stat.S_IFREG
|
|
79
|
+
self.st_mode = st_mode or (
|
|
80
|
+
_stat.S_IFDIR | 0o555 if is_dir else _stat.S_IFREG | 0o444
|
|
81
|
+
)
|
|
82
|
+
self.st_nlink = 1
|
|
83
|
+
self.st_uid = 0
|
|
84
|
+
self.st_gid = 0
|
|
85
|
+
self.st_size = st_size
|
|
86
|
+
self.st_atime = 0
|
|
87
|
+
self.st_mtime = st_mtime
|
|
88
|
+
self.st_ctime = 0
|
|
89
|
+
|
|
90
|
+
def settime(self, value):
|
|
91
|
+
self.st_atime = self.st_mtime = self.st_ctime = value
|
|
92
|
+
|
|
93
|
+
def setmode(self, value, isdir=None):
|
|
94
|
+
if isdir is None:
|
|
95
|
+
isdir = _stat.S_ISDIR(self.st_mode)
|
|
96
|
+
if isdir:
|
|
97
|
+
self.st_mode = _stat.S_IFDIR | value
|
|
98
|
+
else:
|
|
99
|
+
self.st_mode = _stat.S_IFREG | value
|
|
100
|
+
self.mode_known = True
|
|
101
|
+
|
|
102
|
+
def __getitem__(self, key):
|
|
103
|
+
return getattr(self, key)
|
|
104
|
+
|
|
105
|
+
def items(self):
|
|
106
|
+
for key in self._FIELDS:
|
|
107
|
+
yield key, getattr(self, key)
|
|
108
|
+
|
|
109
|
+
def __repr__(self):
|
|
110
|
+
return "<%s mode=%o, size=%s, mtime=%d>" % (
|
|
111
|
+
type(self).__name__,
|
|
112
|
+
self.st_mode,
|
|
113
|
+
sizeof_fmt(self.st_size),
|
|
114
|
+
self.st_mtime,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def __str__(self):
|
|
118
|
+
props = [f"{k}={v}" for k, v in self.items()]
|
|
119
|
+
return "<%s %s>" % (type(self).__name__, ", ".join(props))
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_stat(cls, stat: _ty.Any) -> "FileStat":
|
|
123
|
+
"""Copy any stat-like object's (`os.stat_result`, paramiko's
|
|
124
|
+
`SFTPAttributes`, ...) recognized fields into a fresh `FileStat`,
|
|
125
|
+
so downstream code (e.g. `.is_dir()`) can rely on a uniform type.
|
|
126
|
+
Passes an already-`FileStat` through unchanged. The copied mode
|
|
127
|
+
counts as backend-reported (`mode_known`) unless the source says
|
|
128
|
+
otherwise or carries no mode at all (missing, `None` or `0`).
|
|
129
|
+
|
|
130
|
+
A missing or `None` field becomes `0`: paramiko's `SFTPAttributes`
|
|
131
|
+
leaves every field the server did not send as `None`, which made
|
|
132
|
+
`is_dir()` and `repr()` raise TypeError."""
|
|
133
|
+
if isinstance(stat, FileStat):
|
|
134
|
+
return stat
|
|
135
|
+
result = FileStat.__new__(FileStat)
|
|
136
|
+
for prop in FileStat._FIELDS:
|
|
137
|
+
value = getattr(stat, prop, None)
|
|
138
|
+
setattr(result, prop, 0 if value is None else value)
|
|
139
|
+
result.mode_known = bool(getattr(stat, "mode_known", True)) and bool(
|
|
140
|
+
result.st_mode
|
|
141
|
+
)
|
|
142
|
+
result.size_known = bool(getattr(stat, "size_known", True))
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_path(cls, path: "Path", *, follow_symlink=True):
|
|
147
|
+
try:
|
|
148
|
+
stat: _os_stat = path.stat(follow_symlinks=follow_symlink)
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
return None
|
|
151
|
+
return cls.from_stat(stat)
|
|
152
|
+
|
|
153
|
+
def is_dir(self):
|
|
154
|
+
"""
|
|
155
|
+
Whether this path is a directory.
|
|
156
|
+
"""
|
|
157
|
+
return _stat.S_ISDIR(self.st_mode)
|
|
158
|
+
|
|
159
|
+
def is_file(self):
|
|
160
|
+
"""
|
|
161
|
+
Whether this path is a regular file (also True for symlinks pointing
|
|
162
|
+
to regular files).
|
|
163
|
+
"""
|
|
164
|
+
return _stat.S_ISREG(self.st_mode)
|
|
165
|
+
|
|
166
|
+
def is_symlink(self):
|
|
167
|
+
"""
|
|
168
|
+
Whether this path is a symbolic link.
|
|
169
|
+
"""
|
|
170
|
+
return _stat.S_ISLNK(self.st_mode)
|
|
171
|
+
|
|
172
|
+
def is_block_device(self):
|
|
173
|
+
"""
|
|
174
|
+
Whether this path is a block device.
|
|
175
|
+
"""
|
|
176
|
+
return _stat.S_ISBLK(self.st_mode)
|
|
177
|
+
|
|
178
|
+
def is_char_device(self):
|
|
179
|
+
"""
|
|
180
|
+
Whether this path is a character device.
|
|
181
|
+
"""
|
|
182
|
+
return _stat.S_ISCHR(self.st_mode)
|
|
183
|
+
|
|
184
|
+
def is_fifo(self):
|
|
185
|
+
"""
|
|
186
|
+
Whether this path is a FIFO.
|
|
187
|
+
"""
|
|
188
|
+
return _stat.S_ISFIFO(self.st_mode)
|
|
189
|
+
|
|
190
|
+
def is_socket(self):
|
|
191
|
+
"""
|
|
192
|
+
Whether this path is a socket.
|
|
193
|
+
"""
|
|
194
|
+
return _stat.S_ISSOCK(self.st_mode)
|
pathapi/_lru.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""A thread-safe memoizing LRU cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import typing as _ty
|
|
7
|
+
from threading import RLock
|
|
8
|
+
|
|
9
|
+
__all__ = ["LRU"]
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
ParamSpec = _ty.ParamSpec # 3.10+
|
|
13
|
+
except AttributeError:
|
|
14
|
+
try:
|
|
15
|
+
from typing_extensions import ParamSpec
|
|
16
|
+
except ImportError:
|
|
17
|
+
|
|
18
|
+
class ParamSpec(_ty.TypeVar, _root=True):
|
|
19
|
+
"""Minimal `ParamSpec` stand-in for 3.9 without `typing_extensions`.
|
|
20
|
+
|
|
21
|
+
Only the `.args` / `.kwargs` attributes are needed: they appear in
|
|
22
|
+
annotations that must merely *evaluate*, and a plain `TypeVar` has
|
|
23
|
+
neither. `typing_extensions` is not a runtime dependency, so the
|
|
24
|
+
fallback keeps a bare 3.9 install importable.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def args(self):
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def kwargs(self):
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
K = ParamSpec("K")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
V = _ty.TypeVar("V")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LRU(_ty.Generic[K, V]):
|
|
43
|
+
"""Thread-safe memoizing LRU cache over a function, callable like the
|
|
44
|
+
function itself; `invalidate(*args)` evicts and recomputes an entry,
|
|
45
|
+
`discard(*args)` evicts without recomputing.
|
|
46
|
+
|
|
47
|
+
`on_evict(key, value)`, when given, is called for every value the cache
|
|
48
|
+
drops -- LRU overflow, a `maxsize` shrink, `invalidate()`/`discard()`,
|
|
49
|
+
and the losing result of two concurrent misses on one key -- so a cache
|
|
50
|
+
of live resources (connections) can close them instead of leaking them.
|
|
51
|
+
It runs outside the lock, and an exception it raises is suppressed:
|
|
52
|
+
eviction is a side effect of an unrelated lookup, which must not fail
|
|
53
|
+
because a stale resource would not close cleanly.
|
|
54
|
+
|
|
55
|
+
The computation itself runs outside the lock, so concurrent misses on
|
|
56
|
+
one key may each call `func`; the first stored result wins and is
|
|
57
|
+
returned to every caller, and the others are passed to `on_evict`.
|
|
58
|
+
|
|
59
|
+
`maxsize` follows `functools.lru_cache`: `None` is unbounded, and `0` (or
|
|
60
|
+
less) stores nothing, so `func` runs on every call and its result is
|
|
61
|
+
returned without reaching `on_evict`.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
func: _ty.Callable[K, V],
|
|
67
|
+
maxsize: int | None = 128,
|
|
68
|
+
on_evict: "_ty.Callable[[tuple, V], object] | None" = None,
|
|
69
|
+
):
|
|
70
|
+
self.cache = collections.OrderedDict()
|
|
71
|
+
self.func = func
|
|
72
|
+
self._maxsize = maxsize
|
|
73
|
+
self.lock = RLock()
|
|
74
|
+
self.on_evict = on_evict
|
|
75
|
+
|
|
76
|
+
def _dispose(self, evicted: "list[tuple[tuple, V]]") -> None:
|
|
77
|
+
on_evict = self.on_evict
|
|
78
|
+
if on_evict is None:
|
|
79
|
+
return
|
|
80
|
+
for key, value in evicted:
|
|
81
|
+
try:
|
|
82
|
+
on_evict(key, value)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def maxsize(self):
|
|
88
|
+
return self._maxsize
|
|
89
|
+
|
|
90
|
+
@maxsize.setter
|
|
91
|
+
def maxsize(self, maxsize: int | None):
|
|
92
|
+
cache = self.cache
|
|
93
|
+
evicted = []
|
|
94
|
+
with self.lock:
|
|
95
|
+
self._maxsize = maxsize
|
|
96
|
+
while maxsize is not None and len(cache) > max(maxsize, 0):
|
|
97
|
+
evicted.append(cache.popitem(last=False))
|
|
98
|
+
self._dispose(evicted)
|
|
99
|
+
|
|
100
|
+
def __call__(self, *args: K.args) -> V:
|
|
101
|
+
cache = self.cache
|
|
102
|
+
with self.lock:
|
|
103
|
+
if args in cache:
|
|
104
|
+
cache.move_to_end(args)
|
|
105
|
+
return cache[args]
|
|
106
|
+
result = self.func(*args)
|
|
107
|
+
if self._maxsize is not None and self._maxsize <= 0:
|
|
108
|
+
return result
|
|
109
|
+
evicted = []
|
|
110
|
+
with self.lock:
|
|
111
|
+
if args in cache:
|
|
112
|
+
# A concurrent miss stored its result first: keep that one,
|
|
113
|
+
# so every caller shares a single value.
|
|
114
|
+
existing = cache[args]
|
|
115
|
+
cache.move_to_end(args)
|
|
116
|
+
if existing is not result:
|
|
117
|
+
evicted.append((args, result))
|
|
118
|
+
result = existing
|
|
119
|
+
else:
|
|
120
|
+
cache[args] = result
|
|
121
|
+
if self._maxsize is not None and len(cache) > self._maxsize:
|
|
122
|
+
evicted.append(cache.popitem(last=False))
|
|
123
|
+
self._dispose(evicted)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
def discard(self, *args: K.args) -> bool:
|
|
127
|
+
"""Drop the entry for `args` (passing it to `on_evict`) without
|
|
128
|
+
recomputing it. Returns whether an entry was present."""
|
|
129
|
+
with self.lock:
|
|
130
|
+
if args not in self.cache:
|
|
131
|
+
return False
|
|
132
|
+
value = self.cache.pop(args)
|
|
133
|
+
self._dispose([(args, value)])
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
def invalidate(self, *args: K.args) -> V:
|
|
137
|
+
self.discard(*args)
|
|
138
|
+
return self(*args)
|
pathapi/_names.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Whether a name from a listing can be joined onto a directory safely."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import PureWindowsPath as _PureWindowsPath
|
|
6
|
+
|
|
7
|
+
__all__ = ["is_safe_child_name", "is_windows_flavoured"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_windows_flavoured(path: object) -> bool:
|
|
11
|
+
"""Whether names joined onto `path` are interpreted with Windows rules.
|
|
12
|
+
|
|
13
|
+
True for a `pathlib.PureWindowsPath` (so `LocalPath` on Windows) and for
|
|
14
|
+
a path whose `filepath` is one (a `file:` URI path on Windows). Everything else --
|
|
15
|
+
`MemPath`, remote schemes, POSIX local paths -- treats `\\` and `:` as
|
|
16
|
+
ordinary filename characters.
|
|
17
|
+
"""
|
|
18
|
+
if isinstance(path, _PureWindowsPath):
|
|
19
|
+
return True
|
|
20
|
+
try:
|
|
21
|
+
filepath = getattr(path, "filepath", None)
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
return isinstance(filepath, _PureWindowsPath)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_WINDOWS_DEVICE_NAMES = frozenset(
|
|
28
|
+
["CON", "PRN", "AUX", "NUL"]
|
|
29
|
+
+ [f"COM{n}" for n in range(1, 10)]
|
|
30
|
+
+ [f"LPT{n}" for n in range(1, 10)]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _windows_rewrites(name: str) -> bool:
|
|
35
|
+
"""Whether Windows would store the non-empty `name` under another name or
|
|
36
|
+
hand it to a device. It drops a trailing dot or space, and older versions
|
|
37
|
+
open a reserved device name whatever follows its first dot (`nul.txt`)."""
|
|
38
|
+
return (
|
|
39
|
+
name[-1] in " ."
|
|
40
|
+
or name.partition(".")[0].rstrip(" ").upper() in _WINDOWS_DEVICE_NAMES
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_safe_name(name: object, windows: bool, rewrites: bool) -> bool:
|
|
45
|
+
if not isinstance(name, str) or name in ("", ".", "..") or "/" in name:
|
|
46
|
+
return False
|
|
47
|
+
if "\0" in name:
|
|
48
|
+
return False
|
|
49
|
+
if windows:
|
|
50
|
+
if "\\" in name or ":" in name:
|
|
51
|
+
return False
|
|
52
|
+
if rewrites and _windows_rewrites(name):
|
|
53
|
+
return False
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_safe_child_name(name: object, *, windows: bool = False) -> bool:
|
|
58
|
+
"""Whether `name` is a single path component that stays inside its parent.
|
|
59
|
+
|
|
60
|
+
Use this before joining a name that came from somewhere untrusted (a
|
|
61
|
+
remote listing, an archive member, a sync source) onto a destination.
|
|
62
|
+
Always rejected: a non-`str`, `""`, `"."`, `".."`, and any name
|
|
63
|
+
containing `/` or NUL. With `windows=True` (see `is_windows_flavoured`)
|
|
64
|
+
also rejected: `\\`, `:` (a drive prefix such as `D:x`, or an NTFS
|
|
65
|
+
alternate data stream), a trailing dot or space (Windows drops it, so
|
|
66
|
+
`"report."` and `"report"` name one file), and the reserved device names
|
|
67
|
+
`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9` and `LPT1`-`LPT9`, in any case
|
|
68
|
+
and with or without an extension (`"nul.txt"`).
|
|
69
|
+
"""
|
|
70
|
+
return _is_safe_name(name, windows, True)
|
pathapi/_permissions.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The permission and ownership arguments of `chmod()` and `chown()`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import operator as _operator
|
|
6
|
+
import typing as _ty
|
|
7
|
+
|
|
8
|
+
__all__ = ["UNCHANGED", "parse_mode", "parse_owner"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
#: A `st_mode` is 16 bits: four of file type, twelve of permissions.
|
|
12
|
+
_MODE_MASK = 0o177777
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_mode(mode: _ty.Union[int, str]) -> int:
|
|
16
|
+
"""Normalize a permission `mode` to an int, parsing `str` as **octal**.
|
|
17
|
+
|
|
18
|
+
`chmod("0755")` is the spelling everyone actually writes a mode in --
|
|
19
|
+
`chmod(1)`, Ansible, Dockerfiles, every shell script -- and stdlib
|
|
20
|
+
refuses it (`TypeError: 'str' object cannot be interpreted as an
|
|
21
|
+
integer`). This library accepts it, which makes the base explicit and
|
|
22
|
+
non-negotiable rather than leaving it to each call site.
|
|
23
|
+
|
|
24
|
+
**Why base 8 is mandatory here, and never a plain `int()`:** `"0755"`
|
|
25
|
+
parsed as decimal is 755, which is `0o1363` -- a different *and valid*
|
|
26
|
+
mode. Nothing would raise; the file would just end up with permissions
|
|
27
|
+
nobody intended. That is exactly why stdlib declines strings, so the
|
|
28
|
+
only safe way to accept them is to parse them one way, in one place.
|
|
29
|
+
|
|
30
|
+
Accepts an optional `0o`/`0O` prefix. Anything outside `[0-7]` raises
|
|
31
|
+
`ValueError` rather than being coerced -- a mode is not a number that
|
|
32
|
+
happens to be written in octal, it is octal.
|
|
33
|
+
|
|
34
|
+
An `int` passes through (including `0o755`, which *is* an int by the time
|
|
35
|
+
it gets here -- the literal is resolved by the parser, so `chmod(0o755)`
|
|
36
|
+
and `chmod("0755")` agree). A `bool` is `TypeError`, and a number that
|
|
37
|
+
cannot be a mode (negative, or wider than the 16 bits of a `st_mode`) is
|
|
38
|
+
`ValueError`, for an `int` and a `str` alike. The file-type bits of a
|
|
39
|
+
`st_mode` are in range, as `os.chmod` accepts them; `stat.S_IMODE()` drops
|
|
40
|
+
them.
|
|
41
|
+
"""
|
|
42
|
+
if isinstance(mode, bool):
|
|
43
|
+
raise TypeError("a mode is an int or an octal str, not bool")
|
|
44
|
+
if isinstance(mode, str):
|
|
45
|
+
text = mode.strip()
|
|
46
|
+
if text[:2].lower() == "0o":
|
|
47
|
+
text = text[2:]
|
|
48
|
+
if not text or any(character not in "01234567" for character in text):
|
|
49
|
+
raise ValueError(f"invalid octal mode: {mode!r}")
|
|
50
|
+
value = int(text, 8)
|
|
51
|
+
else:
|
|
52
|
+
value = _operator.index(mode)
|
|
53
|
+
if not 0 <= value <= _MODE_MASK:
|
|
54
|
+
raise ValueError(f"mode out of range (0 to {_MODE_MASK:#o}): {mode!r}")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
#: Canonical "leave this ownership field unchanged" sentinel for `chown()`.
|
|
59
|
+
#: `None` is the API-level spelling; `-1` is accepted too because that is
|
|
60
|
+
#: `os.chown`'s own sentinel and callers coming from it reach for it.
|
|
61
|
+
UNCHANGED = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
#: `uid_t` and `gid_t` are 32 bits wide.
|
|
65
|
+
_ID_MAX = 0xFFFFFFFF
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_owner(
|
|
69
|
+
uid: _ty.Union[int, str, None], gid: _ty.Union[int, str, None]
|
|
70
|
+
) -> "_ty.Tuple[_ty.Optional[int], _ty.Optional[int]]":
|
|
71
|
+
"""Normalize a `chown()` uid/gid pair to canonical `int | None`.
|
|
72
|
+
|
|
73
|
+
`None` means "leave unchanged". `-1` is accepted as an alias for it,
|
|
74
|
+
since that is how `os.chown` spells the same thing and callers arriving
|
|
75
|
+
from the stdlib reach for it out of habit.
|
|
76
|
+
|
|
77
|
+
The point of centralizing this is that **every backend spells
|
|
78
|
+
"unchanged" differently** -- `os.chown` wants `-1`, SFTP `setstat` wants
|
|
79
|
+
the field omitted from the attrs entirely, and other middlewares want
|
|
80
|
+
`None`. If each scheme translated the caller's input itself, that is
|
|
81
|
+
three chances for the semantics to disagree. Normalizing once on `Path`
|
|
82
|
+
means a backend's `_chown()` receives an already-canonical pair and only
|
|
83
|
+
has to convert to its own wire spelling.
|
|
84
|
+
|
|
85
|
+
A `str` is passed through as a *name* (`shutil.chown` accepts user and
|
|
86
|
+
group names, and it is useful not to force a caller to resolve them) --
|
|
87
|
+
backends that cannot resolve names should say so rather than guess.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def _one(value):
|
|
91
|
+
if value is None or isinstance(value, str):
|
|
92
|
+
return value
|
|
93
|
+
if isinstance(value, bool):
|
|
94
|
+
raise TypeError("an owner is an id, a name or None, not bool")
|
|
95
|
+
value = _operator.index(value)
|
|
96
|
+
if value == -1:
|
|
97
|
+
return None
|
|
98
|
+
if not 0 <= value <= _ID_MAX:
|
|
99
|
+
raise ValueError(f"id out of range (0 to {_ID_MAX}): {value!r}")
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
return _one(uid), _one(gid)
|
pathapi/_stubs.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Placeholders for the methods a subclass has to supply."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools as _functools
|
|
6
|
+
import typing as _ty
|
|
7
|
+
|
|
8
|
+
__all__ = ["derived_from", "notimplemented", "primitive"]
|
|
9
|
+
|
|
10
|
+
#: Set on every function that is only the default of a primitive;
|
|
11
|
+
#: `protocols.supports()` reads it.
|
|
12
|
+
STUB_ATTR = "__pathapi_stub__"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def primitive(method):
|
|
16
|
+
"""Mark `method` as the default of a primitive: it raises
|
|
17
|
+
`NotImplementedError`, and a class that does not replace it does not
|
|
18
|
+
implement the primitive."""
|
|
19
|
+
setattr(method, STUB_ATTR, True)
|
|
20
|
+
return method
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def notimplemented(method):
|
|
24
|
+
"""Mark a method as the stub of a primitive. Used bare (`@notimplemented`)
|
|
25
|
+
or with the reason a backend gives (`@notimplemented("why")`); calling the
|
|
26
|
+
stub raises `NotImplementedError` with the reason, or with the method's
|
|
27
|
+
name."""
|
|
28
|
+
if isinstance(method, str):
|
|
29
|
+
reason = method
|
|
30
|
+
return lambda function: _stub(function, reason)
|
|
31
|
+
return _stub(method, None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _stub(method, reason):
|
|
35
|
+
@_functools.wraps(method)
|
|
36
|
+
def _notimplemented(*args, **kwargs):
|
|
37
|
+
raise NotImplementedError(
|
|
38
|
+
reason or f"Method not implemented: {method.__name__}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
setattr(_notimplemented, STUB_ATTR, True)
|
|
42
|
+
return _notimplemented
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
#: Set by `derived_from` on a default that only wraps other primitives.
|
|
46
|
+
DERIVED_ATTR = "__pathapi_derived_from__"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def derived_from(*primitives: str):
|
|
50
|
+
"""Mark a method as built on other primitives of the class, such as an
|
|
51
|
+
`iterdir()` that lists through `_listdir()`: `protocols.supports()` counts
|
|
52
|
+
it as implemented only when one of those primitives is."""
|
|
53
|
+
|
|
54
|
+
def mark(function):
|
|
55
|
+
setattr(function, DERIVED_ATTR, primitives)
|
|
56
|
+
return function
|
|
57
|
+
|
|
58
|
+
return mark
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def derivation(function: object) -> "tuple[str, ...]":
|
|
62
|
+
"""The primitives `function` is built on (see `derived_from`), or `()`."""
|
|
63
|
+
return getattr(function, DERIVED_ATTR, ())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_stub(function: object) -> bool:
|
|
67
|
+
"""Whether `function` is the default of a primitive (see `primitive`)."""
|
|
68
|
+
return getattr(function, STUB_ATTR, False) is True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _unimplemented(name: str) -> _ty.NoReturn:
|
|
72
|
+
"""Raise the error `notimplemented` raises for `name`. A protocol stub
|
|
73
|
+
returns this call rather than `...` or `raise NotImplementedError`, which
|
|
74
|
+
mypy and pyright read as "abstract": a class that subclasses the protocol
|
|
75
|
+
explicitly then cannot be instantiated."""
|
|
76
|
+
raise NotImplementedError(f"Method not implemented: {name}")
|