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
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)
@@ -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}")