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/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # pathapi
2
+
3
+ [![Version](https://img.shields.io/pypi/v/pathapi.svg)](https://pypi.org/project/pathapi/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/pathapi.svg)](https://pypi.org/project/pathapi/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/pathapi/blob/main/LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathapi/)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathapi/test.yml)](https://github.com/jose-pr/pathapi/actions/workflows/test.yml)
8
+
9
+ A **robust, extensible pathlib-like base** for any resource addressable as a
10
+ path. Same method names, signatures, semantics and exception types as
11
+ `pathlib.Path` wherever a `pathlib.Path` equivalent exists: write code once
12
+ against `Path` and it runs against your local disk, an in-memory tree, an FTP
13
+ server or the members of a zip or tar file, and against any path class built on
14
+ it. It has no dependencies. Deliberate differences from `pathlib` are listed in
15
+ [Divergences from pathlib](https://jose-pr.github.io/pathapi/divergences/).
16
+
17
+ `pathapi` was published as `pathlib-next` up to 0.10.0; `pip install pathlib-next`
18
+ now installs a shim that depends on this package. See the changelog for the
19
+ migration from `pathlib_next`.
20
+
21
+ ## Features
22
+
23
+ | Class | Read | Write | List | Stat | mkdir | Delete | rename |
24
+ | --- | --- | --- | --- | --- | --- | --- | --- |
25
+ | local path (`LocalPath`) | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
26
+ | in-memory (`MemPath`) | Yes | Yes | Yes | Yes | Yes | Yes | No (`move()` copies) |
27
+ | FTP and FTPS (`FTPPath`) | Yes | Yes | Yes (MLSD, NLST fallback) | Yes | Yes | Yes | Yes |
28
+ | zip members (`ZipPath`) | Yes | Local archive only | Yes | Yes | Local archive only | Local archive only | Local archive only |
29
+ | tar members (`TarPath`, incl. gz/bz2/xz) | Yes | No | Yes | Yes | No | No | No |
30
+ | `ArchivePath` | As `ZipPath`/`TarPath` for the format it detects | | | | | | |
31
+
32
+ Every class gets `glob()`, `walk()`, `copy()`/`move()`, `rm()` and `PathSyncer`
33
+ from the base classes; `LocalPath` and the other classes replace some of them
34
+ where the backend has a faster way.
35
+
36
+ - **One path interface** over local files, in-memory trees, FTP servers and
37
+ archive members, and over any class built on `Path`.
38
+ - **`MemPath`**: a virtual filesystem for tests, mocks and transient data.
39
+ - **`FTPPath`, `ZipPath`, `TarPath`, `ArchivePath`**: an FTP server and the
40
+ members of a zip or tar file as paths, on the standard library alone.
41
+ - **`pathapi.http`**: `HTTPClient` and a transport contract for HTTP
42
+ requests on the standard library (redirects that never carry
43
+ `Authorization` to another origin, TLS verified by default, a `proxies=`
44
+ provider); the `requests` library plugs in as a transport through the
45
+ `pathapi-requests` distribution.
46
+ - **`PathSyncer`**: one-way, content-compared tree sync between any two
47
+ implementations, with dry runs and event hooks.
48
+ - **Extensible**: subclass `Path` for any resource, and verify it with the
49
+ bundled pytest contracts.
50
+
51
+ Paths for other places are separate distributions built on this one:
52
+
53
+ | Distribution | Provides |
54
+ | --- | --- |
55
+ | [`uripath`](https://github.com/jose-pr/uripath) | `URIPath`: one class for any URI, with the schemes `file:`, `data:`, `ftp:`, `zip:`, `tar:`, `http:` and `dav:`, and the `uripath` command |
56
+ | [`sftppath`](https://github.com/jose-pr/sftppath) | `SFTPPath` (paramiko or asyncssh) |
57
+ | [`objpath`](https://github.com/jose-pr/objpath) | `S3Path`, `GSPath` and `AZPath` (Amazon S3, Google Cloud Storage, Azure Blob Storage) |
58
+ | [`repopath`](https://github.com/jose-pr/repopath) | `RepoPath`, `GitHubPath` and `GitLabPath` (the REST APIs of the hosting services) |
59
+ | [`pathapi-requests`](https://github.com/jose-pr/pathapi-requests) | `RequestsTransport`, the HTTP transport that sends through a `requests.Session` |
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install pathapi
65
+ ```
66
+
67
+ There are no extras and no dependencies: everything above runs on the standard
68
+ library. `pytest` is needed only to import `pathapi.testing` and
69
+ `pathapi.http.testing`.
70
+
71
+ ## Quick start
72
+
73
+ **Local filesystem**, as `pathlib.Path`:
74
+
75
+ ```python
76
+ from pathapi import Path
77
+
78
+ p = Path("data") / "report.txt"
79
+ p.parent.mkdir(parents=True, exist_ok=True)
80
+ p.write_text("hello")
81
+ print(p.read_text())
82
+ ```
83
+
84
+ **In memory**, no disk I/O:
85
+
86
+ ```python
87
+ from pathapi.mempath import MemPath
88
+
89
+ p = MemPath("/config/settings.json")
90
+ p.parent.mkdir(parents=True, exist_ok=True)
91
+ p.write_text('{"debug": true}')
92
+ print([child.name for child in p.parent.iterdir()])
93
+ ```
94
+
95
+ **Archive members**: the segments are the member path and the archive is any
96
+ path:
97
+
98
+ ```python
99
+ import zipfile
100
+ from pathapi import ZipPath
101
+
102
+ with zipfile.ZipFile("backup.zip", "w") as archive:
103
+ archive.writestr("etc/config.ini", "[main]\n")
104
+
105
+ member = ZipPath("etc/config.ini", archive="backup.zip")
106
+ print(member.read_text())
107
+ print([child.name for child in member.parent.iterdir()])
108
+ ```
109
+
110
+ **FTP**: nothing connects until the first operation:
111
+
112
+ <!-- example: skip: reads from a remote host -->
113
+ ```python
114
+ from pathapi import FTPPath
115
+
116
+ for child in FTPPath("/pub", host="ftp.example.com", user="anonymous").iterdir():
117
+ print(child.name, child.stat().st_size)
118
+ ```
119
+
120
+ **Copy and sync across classes**:
121
+
122
+ ```python
123
+ from pathapi import Path
124
+ from pathapi.mempath import MemPath
125
+ from pathapi.sync import PathSyncer
126
+
127
+ source = MemPath("/site")
128
+ (source / "css").mkdir(parents=True)
129
+ (source / "index.html").write_text("<h1>hi</h1>")
130
+
131
+ PathSyncer(remove_missing=True).sync(source, Path("site-copy"))
132
+ print(sorted(p.name for p in Path("site-copy").iterdir()))
133
+ ```
134
+
135
+ ## API overview
136
+
137
+ | Module | Purpose |
138
+ | --- | --- |
139
+ | `pathapi.path` | `Pathname`/`Path` base classes and the derived operations |
140
+ | `pathapi.fspath` | `LocalPath`, `PosixPathname`, `WindowsPathname` |
141
+ | `pathapi.mempath` | `MemPath` in-memory filesystem |
142
+ | `pathapi.ftp` | `FTPPath`, `FTPBackend` (FTP and FTPS on `ftplib`) |
143
+ | `pathapi.archive` | `ZipPath`, `TarPath`, `ArchivePath` (members of a zip or tar file), `make_archive`, `unpack_archive` |
144
+ | `pathapi.http` | `HTTPClient`, `HTTPTransport`, `HTTPResponse` (HTTP requests on `http.client`) |
145
+ | `pathapi.protocols` | `Stat`, `Chmod`, `BinaryOpen`, `NativeChecksum` |
146
+ | `pathapi.sync` | `PathSyncer`, `SyncEvent`, `PathAndStat` |
147
+ | `pathapi.glob` | the glob engine behind `Path.glob()` |
148
+ | `pathapi.checksum` | `md5`, `sha256`, `stream`, `native` |
149
+ | `pathapi.providers` | the helpers a class built on `Path` calls |
150
+ | `pathapi.exceptions` | `PathError`, `NonRelativePatternError` |
151
+ | `pathapi.testing` | pytest contracts for custom implementations |
152
+ | `pathapi.http.testing` | testing support (not stable API): the pytest suite that holds an HTTP transport to the promises of the built-in one |
153
+
154
+ The full reference is on the [documentation site](https://jose-pr.github.io/pathapi/);
155
+ an agent-oriented API summary ships inside the package as
156
+ `pathapi/AGENTS.md`.
157
+
158
+ ## Extending
159
+
160
+ Subclass `Path` for a resource of your own (`MemPath` is the reference), as
161
+ worked through in
162
+ [Extending](https://jose-pr.github.io/pathapi/guides/extending/).
163
+ `pathapi.testing` provides the pytest contracts (`PurePathContract`,
164
+ `ReadPathContract`, `PathContract`) and `populate_fixture_tree()` that every
165
+ class of the package and of the distributions above is verified with.
166
+
167
+ ## Supported Python versions
168
+
169
+ Python 3.9 through 3.14. CI runs the full suite on Ubuntu for every version
170
+ from 3.9 to 3.14, on Windows and macOS for 3.9 and 3.14, and with nothing but
171
+ `pytest` and `pyftpdlib` installed on 3.9 and 3.14.
172
+
173
+ ## Development
174
+
175
+ ```bash
176
+ pip install -e ".[dev]"
177
+ python -m pytest -q -rs
178
+ ```
179
+
180
+ Tests that need an optional package are skipped when it is missing, so check
181
+ the skip list (`-rs`). Contributor notes (virtual environments, formatting, CI,
182
+ releases) are in the repository's
183
+ [`AGENTS.md`](https://github.com/jose-pr/pathapi/blob/main/AGENTS.md);
184
+ benchmarks are described in
185
+ [Benchmarks](https://jose-pr.github.io/pathapi/benchmarks/). To preview
186
+ the documentation: `pip install -e ".[docs]"` then `mkdocs serve`.
187
+
188
+ Changes are recorded in the
189
+ [changelog](https://github.com/jose-pr/pathapi/blob/main/CHANGELOG.md).
190
+
191
+ ### Releasing
192
+
193
+ 1. Move the `[Unreleased]` entries of `CHANGELOG.md` under a new
194
+ `## [x.y.z] - <date>` heading and add its link definition at the bottom.
195
+ 2. Bump `version` in `pyproject.toml` in the same commit.
196
+ 3. Before 1.0 the minor version changes only when the documented API breaks;
197
+ new methods, new optional arguments and fixes are patch releases.
198
+ 4. Run the full suite on the oldest and the newest supported Python,
199
+ `mkdocs build --strict` and `python -m build`.
200
+ 5. Push `main`, then the `v*` tag: the release workflow tests, builds and
201
+ publishes to PyPI and creates the GitHub release.
202
+
203
+ ## License
204
+
205
+ MIT; see [LICENSE](https://github.com/jose-pr/pathapi/blob/main/LICENSE).
pathapi/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib as _importlib
4
+ from importlib import metadata as _metadata
5
+
6
+ __all__ = [
7
+ "ArchivePath",
8
+ "BinaryOpen",
9
+ "Chmod",
10
+ "FTPPath",
11
+ "FileStat",
12
+ "FsPathLike",
13
+ "LocalPath",
14
+ "MemPath",
15
+ "NonRelativePatternError",
16
+ "Path",
17
+ "PathError",
18
+ "PathLike",
19
+ "Pathname",
20
+ "PosixPathname",
21
+ "PurePathLike",
22
+ "Stat",
23
+ "TarPath",
24
+ "WindowsPathname",
25
+ "ZipPath",
26
+ "__version__",
27
+ "glob",
28
+ "sync",
29
+ ]
30
+
31
+ try:
32
+ __version__ = _metadata.version("pathapi")
33
+ except _metadata.PackageNotFoundError: # a source tree that is not installed
34
+ __version__ = "0+unknown"
35
+
36
+ from .fspath import LocalPath, PosixPathname, WindowsPathname
37
+ from .mempath import MemPath
38
+ from .path import FsPathLike, Path, PathLike, Pathname, PurePathLike
39
+ from .protocols import BinaryOpen, Chmod, Stat
40
+ from . import glob as glob
41
+ from . import sync as sync
42
+ from ._filestat import FileStat
43
+ from .exceptions import NonRelativePatternError, PathError
44
+
45
+ #: Path classes of the standard-library providers, imported on first use so
46
+ #: that `import pathapi` loads neither `ftplib` nor `ssl`, and no archive
47
+ #: code.
48
+ _LAZY = {
49
+ "ArchivePath": ".archive",
50
+ "FTPPath": ".ftp",
51
+ "TarPath": ".archive",
52
+ "ZipPath": ".archive",
53
+ }
54
+
55
+
56
+ def __getattr__(name: str):
57
+ module = _LAZY.get(name)
58
+ if module is not None:
59
+ value = getattr(_importlib.import_module(module, __name__), name)
60
+ globals()[name] = value
61
+ return value
62
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
pathapi/_commit.py ADDED
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import io as _io
4
+ import typing as _ty
5
+ import warnings as _warnings
6
+
7
+ __all__ = ["CommitOnClose", "DiscardOnError"]
8
+
9
+
10
+ class DiscardOnError:
11
+ """Mixin for a write stream that commits when it is closed. Leaving a
12
+ `with` block through an exception (any `BaseException`) calls `discard()`
13
+ and then closes; leaving it normally just closes. A stream that is
14
+ garbage collected unclosed while `_holds_uncommitted()` is true is
15
+ discarded too, with a `ResourceWarning`: what was never finished is not
16
+ committed. Put it first in the bases, so that its `__exit__` and
17
+ `__del__` win over `io.IOBase`'s."""
18
+
19
+ __slots__ = ()
20
+
21
+ def discard(self) -> None:
22
+ """Drop what was written so that `close()` commits nothing. It does
23
+ nothing more when called twice or after `close()`."""
24
+ raise NotImplementedError
25
+
26
+ def _holds_uncommitted(self) -> bool:
27
+ """Whether closing now would commit something. A subclass returns
28
+ false for a stream that has nothing to commit (never modified, or
29
+ already discarded) so that collecting it is silent."""
30
+ return True
31
+
32
+ def __del__(self):
33
+ try:
34
+ if not self.closed and self._holds_uncommitted():
35
+ self.discard()
36
+ _warnings.warn(
37
+ f"unclosed {type(self).__name__} discarded without "
38
+ "committing; close the file",
39
+ ResourceWarning,
40
+ source=self,
41
+ )
42
+ finally:
43
+ super().__del__()
44
+
45
+ def __exit__(self, exc_type, exc_value, traceback):
46
+ try:
47
+ if exc_type is not None:
48
+ self.discard()
49
+ except BaseException:
50
+ super().__exit__(exc_type, exc_value, traceback)
51
+ raise
52
+ return super().__exit__(exc_type, exc_value, traceback)
53
+
54
+
55
+ class CommitOnClose(DiscardOnError, _io.BytesIO):
56
+ """A write buffer held in memory whose content is handed to `commit` when
57
+ it is closed, once, if it was written to. For a backend with no way to
58
+ write a file piecewise (an FTP `STOR`, a rewrite of an archive): the cost
59
+ is the whole file in memory for the duration of the write.
60
+
61
+ `initial` (an `open("r+")`) is the file's content, with the position at 0,
62
+ and `commit` runs only if the buffer was modified; without it, `commit`
63
+ runs at the first close even if nothing was written, which creates an
64
+ empty file. `commit(buffer)` runs before the buffer is closed, so it can
65
+ read from it.
66
+
67
+ `discard()` drops the buffer so that a later `close()` commits nothing;
68
+ leaving a `with` block through an exception calls it before closing, and
69
+ so does collecting the stream unclosed, which also warns."""
70
+
71
+ _discarded = False
72
+ _dirty = False
73
+
74
+ def __init__(
75
+ self,
76
+ commit: "_ty.Callable[[_io.BytesIO], object]",
77
+ initial: "bytes | None" = None,
78
+ ):
79
+ super().__init__(b"" if initial is None else initial)
80
+ self._commit = commit
81
+ self._dirty = initial is None
82
+
83
+ def write(self, data):
84
+ self._dirty = True
85
+ return super().write(data)
86
+
87
+ def writelines(self, lines):
88
+ self._dirty = True
89
+ return super().writelines(lines)
90
+
91
+ def truncate(self, size=None):
92
+ self._dirty = True
93
+ return super().truncate(size)
94
+
95
+ def _holds_uncommitted(self):
96
+ return self._dirty and not self._discarded
97
+
98
+ def discard(self):
99
+ """Drop what was written: `close()` commits nothing, whatever is
100
+ written after this. Calling it twice, or after `close()`, does
101
+ nothing more."""
102
+ self._discarded = True
103
+ if not self.closed:
104
+ super().truncate(0)
105
+ super().seek(0)
106
+
107
+ def close(self):
108
+ if self.closed:
109
+ return
110
+ try:
111
+ if self._dirty and not self._discarded:
112
+ self._commit(self)
113
+ finally:
114
+ # Closed even when the commit fails, so `IOBase.__del__` does not
115
+ # retry it (over newer content) at garbage collection.
116
+ super().close()
pathapi/_discard.py ADDED
@@ -0,0 +1,92 @@
1
+ """What `BinaryOpen.open()` does in text mode when the `with` block of a write
2
+ stream raised.
3
+
4
+ A stream that commits on close (an upload, an archive rewrite) has a
5
+ `discard()` method. When the block ended in an exception, `discard()` is called
6
+ before the stream is closed, so the close commits nothing; when it ended
7
+ normally, or the file is closed with `close()`, nothing changes; a wrapper
8
+ collected unclosed discards as well, with a `ResourceWarning`. In binary mode
9
+ the stream's own `__exit__` does this (`DiscardOnError`).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io as _io
15
+ import typing as _ty
16
+ import warnings as _warnings
17
+
18
+
19
+ class DiscardingTextIO(_io.TextIOWrapper):
20
+ """A `TextIOWrapper` over a stream with `discard()`. Leaving a `with` block
21
+ through an exception drops the text the wrapper still holds, calls
22
+ `discard`, then closes: a stock wrapper would write that text to the stream
23
+ and let the close commit it."""
24
+
25
+ _dropping = False
26
+ _discard: "_ty.Callable[[], object] | None" = None
27
+
28
+ def __init__(self, stream, discard, *args, **kwargs):
29
+ super().__init__(stream, *args, **kwargs)
30
+ self._discard = discard
31
+
32
+ def flush(self):
33
+ if not self._dropping:
34
+ super().flush()
35
+
36
+ def close(self):
37
+ if self.closed:
38
+ return
39
+ try:
40
+ self.flush()
41
+ except BaseException:
42
+ # The stream has not been given all the text: it must not commit
43
+ # what it has.
44
+ self._dropping = True
45
+ try:
46
+ self._discard()
47
+ finally:
48
+ super().close()
49
+ raise
50
+ super().close()
51
+
52
+ def _unclosed(self):
53
+ try:
54
+ return not self.closed
55
+ except ValueError: # the constructor raised before initialising it
56
+ return False
57
+
58
+ def __del__(self):
59
+ # Collected unclosed: the text still held and the stream's content were
60
+ # never finished, so neither is committed.
61
+ try:
62
+ if self._unclosed() and self._discard is not None:
63
+ self._dropping = True
64
+ self._discard()
65
+ _warnings.warn(
66
+ f"unclosed {type(self).__name__} discarded without "
67
+ "committing; close the file",
68
+ ResourceWarning,
69
+ source=self,
70
+ )
71
+ finally:
72
+ super().__del__()
73
+
74
+ def __exit__(self, exc_type, exc_value, traceback):
75
+ if exc_type is None:
76
+ return super().__exit__(exc_type, exc_value, traceback)
77
+ self._dropping = True
78
+ try:
79
+ self._discard()
80
+ finally:
81
+ super().__exit__(exc_type, exc_value, traceback)
82
+
83
+
84
+ def abandon(stream: _ty.Any) -> None:
85
+ """Close a stream whose `open()` failed after the backend gave it out,
86
+ having it discard what it holds first so the failed call commits nothing."""
87
+ try:
88
+ discard = getattr(stream, "discard", None)
89
+ if callable(discard):
90
+ discard()
91
+ finally:
92
+ stream.close()
@@ -0,0 +1,106 @@
1
+ """The error and follow policies an operation takes as arguments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing as _ty
6
+
7
+ __all__ = [
8
+ "OfferedOnce",
9
+ "as_error_handler",
10
+ "as_error_predicate",
11
+ "check_follow",
12
+ "copy_error_predicate",
13
+ ]
14
+
15
+
16
+ def as_error_handler(
17
+ ignore_error: _ty.Union[bool, _ty.Callable[..., bool], None],
18
+ *,
19
+ default: bool = False,
20
+ ) -> _ty.Callable[..., bool]:
21
+ """Normalize an `ignore_error` argument into a *callable* error policy.
22
+
23
+ Every `ignore_error` (and `copy()`'s `on_error`) parameter in this library
24
+ accepts either a bool or a callable, but the callables have **different
25
+ arities** per call site (`Path.rm()` and `Path.copy(on_error=)` ->
26
+ `(error, path)`, `Path.copy(ignore_error=)` -> `(error)`,
27
+ `PathSyncer.sync()` -> `(error, source, target, event)`). Changing the
28
+ arity of an existing keyword would break its callers, so this helper only
29
+ normalizes the *bool* case and passes a supplied callable through
30
+ untouched -- it is invoked with whatever arguments its own call site
31
+ already uses.
32
+
33
+ `None` means "no policy supplied": it resolves to `default` (False for
34
+ every current caller, i.e. raise on the first error), which preserves
35
+ `Path.copy(ignore_error=None)`'s documented meaning.
36
+
37
+ Centralizing this keeps a fourth call site from drifting back into
38
+ calling a bool (see `PathSyncer.sync()`'s symlink branch, which did
39
+ exactly that and raised `TypeError: 'bool' object is not callable`).
40
+ """
41
+ if callable(ignore_error):
42
+ return ignore_error
43
+ if ignore_error is None:
44
+ ignore_error = default
45
+ result = bool(ignore_error)
46
+ return lambda *args, **kwargs: result
47
+
48
+
49
+ class OfferedOnce:
50
+ """An `(error, path) -> bool` error predicate that sees each error once.
51
+
52
+ `True` swallows the error. An error it answered `False` for, and an
53
+ exception it raised itself, are not offered again when they travel up
54
+ through the enclosing directories of a recursive operation: they are
55
+ raised as they are. Build one with `as_error_predicate()`.
56
+ """
57
+
58
+ __slots__ = ("_predicate", "_seen")
59
+
60
+ def __init__(self, predicate: _ty.Callable[[Exception, _ty.Any], bool]) -> None:
61
+ self._predicate = predicate
62
+ self._seen: "list[BaseException]" = []
63
+
64
+ def __call__(self, error: Exception, path: _ty.Any) -> bool:
65
+ if any(error is seen for seen in self._seen):
66
+ return False
67
+ try:
68
+ swallow = self._predicate(error, path)
69
+ except BaseException as raised:
70
+ self._seen.append(raised)
71
+ raise
72
+ if swallow:
73
+ return True
74
+ self._seen.append(error)
75
+ return False
76
+
77
+
78
+ def as_error_predicate(
79
+ on_error: _ty.Union[bool, _ty.Callable[[Exception, _ty.Any], bool], None],
80
+ ) -> OfferedOnce:
81
+ """`on_error` as an `OfferedOnce` predicate: a bool becomes a constant
82
+ answer (`None` is `False`), a callable is called as `(error, path)`, and
83
+ an `OfferedOnce` is returned as it is, so a recursive call that is handed
84
+ the predicate of its caller shares what that predicate has seen."""
85
+ if isinstance(on_error, OfferedOnce):
86
+ return on_error
87
+ return OfferedOnce(as_error_handler(on_error))
88
+
89
+
90
+ def check_follow(name: str, policy) -> None:
91
+ """Raise `ValueError` naming `name` unless `policy` is `True`, `False`,
92
+ `None` or a callable: what `rm()` accepts for `follow_symlinks=` and
93
+ `follow_binds=`. An `rm()` override calls it before removing anything."""
94
+ if not (policy is None or callable(policy) or isinstance(policy, bool)):
95
+ raise ValueError(f"{name} must be True, False, None or a callable")
96
+
97
+
98
+ def copy_error_predicate(ignore_error, on_error) -> "OfferedOnce | None":
99
+ """The `(error, path) -> bool` predicate for `copy()`'s `on_error=`, or
100
+ None when it was not given. Raises `TypeError` when `ignore_error=` was
101
+ given too: a `copy()` override calls this before it copies anything."""
102
+ if on_error is None:
103
+ return None
104
+ if ignore_error is not None:
105
+ raise TypeError("copy() takes ignore_error or on_error, not both")
106
+ return as_error_predicate(on_error)