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/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# pathapi
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pathapi/)
|
|
4
|
+
[](https://pypi.org/project/pathapi/)
|
|
5
|
+
[](https://github.com/jose-pr/pathapi/blob/main/LICENSE)
|
|
6
|
+
[](https://jose-pr.github.io/pathapi/)
|
|
7
|
+
[](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()
|
pathapi/_errorpolicy.py
ADDED
|
@@ -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)
|