hostctl 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {hostctl-0.2.2 → hostctl-0.2.4}/.gitignore +3 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/CHANGELOG.md +87 -1
- {hostctl-0.2.2 → hostctl-0.2.4}/PKG-INFO +1 -1
- {hostctl-0.2.2 → hostctl-0.2.4}/pyproject.toml +5 -2
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/composite_path.py +276 -98
- hostctl-0.2.4/tests/test_composite_path_backend_kwargs.py +118 -0
- hostctl-0.2.4/tests/test_composite_path_inherited_operations.py +124 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_system_hosts.py +2 -2
- {hostctl-0.2.2 → hostctl-0.2.4}/LICENSE +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/README.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/docs/api/reference.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/docs/changelog.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/docs/guide/cli.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/docs/guide/contracts.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/docs/guide/extending.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/docs/guide/path.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/docs/guide/providers.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/docs/guide/run.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/docs/guide/transfer.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/docs/index.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/examples/application_provider.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/examples/local_run.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/examples/remote_run.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/mkdocs.yml +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/AGENTS.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/__main__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/_async.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/_cli.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/executor/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/executor/_common.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/executor/container.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/executor/local.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/executor/psrp.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/executor/qemu.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/executor/ssh.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/executor/winrm.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/_common.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/_connection.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/_ssh.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/_winrm.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/container.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/container_path.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/qemu.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/serial.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/host/system.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/process/container.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/process/psrp.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/process/ssh.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/provider/_common.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/provider/transports.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/py.typed +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/shell/_common.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/shell/cmd.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/shell/fish.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/shell/posix.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/shell/powershell.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/src/hostctl/sync.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/conformance/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/conformance/providers.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/conformance/test_live.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/conformance/test_path_contract.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/conformance/test_process_contract.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/conformance/test_run_contract.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/conformance/test_sync_contract.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_application_provider.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_cli.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_composite_path_propagation.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_connection_string.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_container_path.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_container_process.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_exec_command.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_host_api.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_host_container.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_host_local.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_host_qemu.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_host_remote.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_host_winrm.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_observability.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_process.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_provider_fault_injection.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_psrp.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_qemu_path.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_qga_transport.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_serial_executor.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_serial_host.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_serial_live.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_shell.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_ssh_process.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.4}/tests/test_winrm_path.py +0 -0
|
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.4] - 2026-08-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Directory listings no longer discard the backend's own `_scandir()`.
|
|
15
|
+
`walk()` and `glob()` went through hostctl's `_scandir()`, which was a
|
|
16
|
+
verbatim copy of `pathlib_next`'s generic fallback and called `iterdir()` —
|
|
17
|
+
so a scheme whose listing already carries metadata never got to use it.
|
|
18
|
+
`SftpPath._scandir()` reads every child's attributes in a single
|
|
19
|
+
`listdir_attr` round trip; before this fix a remote `walk()` paid a listing
|
|
20
|
+
plus one `stat()` per entry. `_scandir()` is now the routed primitive and
|
|
21
|
+
`iterdir()` derives from it, matching the direction upstream intends.
|
|
22
|
+
|
|
23
|
+
- `copy()` and `move()` on a composite path now use the backend's own
|
|
24
|
+
implementation when the destination resolves to a path that backend
|
|
25
|
+
understands. They previously called `Path.copy(self, ...)` unconditionally,
|
|
26
|
+
which bypassed every backend override — `SftpPath.copy()` fans out over
|
|
27
|
+
asyncssh workers and `SftpPath.rm()`/`checksum()` run server-side, so the
|
|
28
|
+
results stayed correct while the transport-native path was silently
|
|
29
|
+
discarded. A genuine cross-backend transfer (a destination on another
|
|
30
|
+
provider) still uses the generic implementation, which is what it is for.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Composite paths forward the method that was called to the selected backend
|
|
35
|
+
instead of re-declaring a copy of `pathlib_next.Path`'s surface. Operations
|
|
36
|
+
hostctl never declares — `touch()`, `rm()`, `lstat()`, `is_symlink()`,
|
|
37
|
+
`chown()`, `checksum()` — now work through `host.path()`, and an operation
|
|
38
|
+
added upstream is reachable without a new method here. `chown()`, added in
|
|
39
|
+
`pathlib_next` 0.9.1, was unreachable before this.
|
|
40
|
+
|
|
41
|
+
`_CompositePathMixin` drops from 47 to 36 methods. Operations with real
|
|
42
|
+
composite behavior stay hand-written, each for a reason: `iterdir`/`_scandir`
|
|
43
|
+
(rebuild children as composite paths), `rename` (cross-provider guard),
|
|
44
|
+
`readlink` (rebuilds its result), `copy`/`move` (backend when the destination
|
|
45
|
+
resolves on this provider, generic for a true cross-backend transfer), and
|
|
46
|
+
`open` (the capability gate depends on the mode).
|
|
47
|
+
|
|
48
|
+
The pure-path derivations (`parent`, `parents`, `joinpath`, `/`,
|
|
49
|
+
`with_name`/`with_stem`/`with_suffix`, `relative_to`, `with_segments`) also
|
|
50
|
+
stay: `pathlib.PurePath` builds those through `object.__new__`, bypassing the
|
|
51
|
+
composite constructor, so they re-attach routing state that inheritance drops
|
|
52
|
+
rather than duplicating anything.
|
|
53
|
+
|
|
54
|
+
- Provider fallback now also triggers on `NotImplementedError`, but only for
|
|
55
|
+
operations that cannot mutate before raising (reads, `stat`, `chown`,
|
|
56
|
+
`chmod`). Writes and composed wrappers still propagate it: a wrapper built
|
|
57
|
+
from several primitives may have already changed something when a later
|
|
58
|
+
primitive raises — `symlink_to(force=True)` unlinks before calling
|
|
59
|
+
`_symlink_to()`, so a backend lacking that primitive deletes the entry and
|
|
60
|
+
only then fails. Retrying that against another provider would repeat the
|
|
61
|
+
work with the original already gone.
|
|
62
|
+
|
|
63
|
+
## [0.2.3] - 2026-08-04
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- Composite paths no longer drop backend-specific keyword arguments.
|
|
68
|
+
`CompositePosixPath.symlink_to()` accepted only the stdlib signature and
|
|
69
|
+
forwarded nothing else, so a backend's documented extension was unreachable
|
|
70
|
+
through the very abstraction meant to expose it — a path obtained from
|
|
71
|
+
`host.path()` raised `TypeError: unexpected keyword argument 'force'` even
|
|
72
|
+
when the selected backend implemented `force=`.
|
|
73
|
+
|
|
74
|
+
Forwarding is **signature-aware** rather than blind: the selected backend's
|
|
75
|
+
method is inspected, and only keywords it declares are passed through.
|
|
76
|
+
Anything else still raises `TypeError` at the composite boundary, naming
|
|
77
|
+
the backend class and the rejected keyword. Blind passthrough would have
|
|
78
|
+
turned a clear error at the abstraction boundary into a confusing one from
|
|
79
|
+
inside a transport, and the existing contract — a backend lacking a
|
|
80
|
+
capability raises `NotImplementedError`, never a silent no-op — is
|
|
81
|
+
unchanged. A method whose signature cannot be introspected (a C function,
|
|
82
|
+
a `functools.partial`) receives the keywords, since an error from it is no
|
|
83
|
+
worse than calling it directly.
|
|
84
|
+
|
|
85
|
+
Applied to `mkdir()`, `chmod()`, `unlink()`, and `rmdir()` alongside
|
|
86
|
+
`symlink_to()`, since the same normalization affected each of them.
|
|
87
|
+
|
|
88
|
+
This pairs with `pathlib_next`'s `symlink_to(force=)`, which that project
|
|
89
|
+
exposes as a generic `Path` extension over a `_symlink_to()` backend
|
|
90
|
+
primitive. No version floor change: hostctl's `pathlib_next>=0.8.6` floor
|
|
91
|
+
stays where it is, so `force=` is forwarded when the installed version
|
|
92
|
+
provides it and rejected at the boundary when it does not.
|
|
93
|
+
|
|
10
94
|
## [0.2.2] - 2026-07-29
|
|
11
95
|
|
|
12
96
|
### Changed
|
|
@@ -313,7 +397,9 @@ test suite on Python 3.9 through 3.14.
|
|
|
313
397
|
assigned to it; a config-less host now builds its own family configuration
|
|
314
398
|
instead.
|
|
315
399
|
|
|
316
|
-
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.
|
|
400
|
+
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.4...HEAD
|
|
401
|
+
[0.2.4]: https://github.com/jose-pr/hostctl/compare/v0.2.3...v0.2.4
|
|
402
|
+
[0.2.3]: https://github.com/jose-pr/hostctl/compare/v0.2.2...v0.2.3
|
|
317
403
|
[0.2.2]: https://github.com/jose-pr/hostctl/compare/v0.2.1...v0.2.2
|
|
318
404
|
[0.2.1]: https://github.com/jose-pr/hostctl/compare/v0.2.0...v0.2.1
|
|
319
405
|
[0.2.0]: https://github.com/jose-pr/hostctl/compare/v0.1.2...v0.2.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hostctl
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Run commands and access files on a host, local or remote, protocol-agnostic
|
|
5
5
|
Project-URL: Homepage, https://github.com/jose-pr/hostctl/
|
|
6
6
|
Project-URL: Documentation, https://jose-pr.github.io/hostctl/
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hostctl"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
authors = [{ name = "Jose A." }]
|
|
9
9
|
description = "Run commands and access files on a host, local or remote, protocol-agnostic"
|
|
10
10
|
readme = "README.md"
|
|
@@ -37,7 +37,10 @@ classifiers = [
|
|
|
37
37
|
# Both ranges span two minors deliberately: hostctl uses no API added in
|
|
38
38
|
# pathlib_next 0.9 or netimps 0.2, so raising the floor would exclude working
|
|
39
39
|
# installs for nothing. The ceilings stay at the next major-ish boundary
|
|
40
|
-
# because both projects are pre-1.0, where a minor may break.
|
|
40
|
+
# because both projects are pre-1.0, where a minor may break. Composite kwargs
|
|
41
|
+
# passthrough does not change this: forwarding is signature-aware, so `force=`
|
|
42
|
+
# is forwarded when the installed pathlib_next provides it and rejected at the
|
|
43
|
+
# boundary when it does not (verified against 0.9.0 and 0.9.1).
|
|
41
44
|
dependencies = ["pathlib_next>=0.8.6,<0.10", "netimps>=0.1,<0.3"]
|
|
42
45
|
|
|
43
46
|
[project.optional-dependencies]
|
|
@@ -8,6 +8,7 @@ authority for I/O.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import inspect
|
|
11
12
|
import pathlib
|
|
12
13
|
import typing
|
|
13
14
|
|
|
@@ -27,16 +28,228 @@ def _supports(provider: PathProvider, operation: PathOperation) -> bool:
|
|
|
27
28
|
)
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _accepts_kwargs(
|
|
32
|
+
method: typing.Callable[..., object],
|
|
33
|
+
kwargs: dict[str, object],
|
|
34
|
+
operation: PathOperation,
|
|
35
|
+
) -> dict[str, object]:
|
|
36
|
+
"""Return ``kwargs``, first checking the backend method accepts them.
|
|
37
|
+
|
|
38
|
+
Composite dispatch normalises to the stdlib signature, which made a
|
|
39
|
+
backend's documented extension unreachable through the wrapper. Rather
|
|
40
|
+
than forward blindly -- which turns a clear ``TypeError`` here into a
|
|
41
|
+
confusing one from inside a transport -- consult the selected backend's
|
|
42
|
+
signature and reject at this boundary what it cannot take.
|
|
43
|
+
|
|
44
|
+
A method whose signature cannot be introspected (a C function, a
|
|
45
|
+
``functools.partial`` over one) is given the benefit of the doubt and
|
|
46
|
+
the kwargs are forwarded; a backend that then rejects them raises its
|
|
47
|
+
own ``TypeError``, which is no worse than calling it directly.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
if not kwargs:
|
|
51
|
+
return kwargs
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
signature = inspect.signature(method)
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
return kwargs
|
|
57
|
+
|
|
58
|
+
parameters = signature.parameters
|
|
59
|
+
if any(
|
|
60
|
+
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
|
61
|
+
for parameter in parameters.values()
|
|
62
|
+
):
|
|
63
|
+
return kwargs
|
|
64
|
+
|
|
65
|
+
unsupported = sorted(name for name in kwargs if name not in parameters)
|
|
66
|
+
if unsupported:
|
|
67
|
+
owner = getattr(method, "__self__", None)
|
|
68
|
+
backend = type(owner).__name__ if owner is not None else "backend path"
|
|
69
|
+
raise TypeError(
|
|
70
|
+
f"{backend}.{operation}() does not accept "
|
|
71
|
+
+ ", ".join(repr(name) for name in unsupported)
|
|
72
|
+
)
|
|
73
|
+
return kwargs
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_composite_owner(base: type) -> bool:
|
|
77
|
+
"""True for classes belonging to this module's composite hierarchy.
|
|
78
|
+
|
|
79
|
+
Used to tell a deliberate composite override (``iterdir``, ``rename``,
|
|
80
|
+
``readlink``, ``copy``) apart from an inherited ``pathlib_next``
|
|
81
|
+
implementation, which is exactly what forwarding must displace.
|
|
82
|
+
"""
|
|
83
|
+
return getattr(base, "__module__", "") == __name__
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _make_forwarder(
|
|
87
|
+
name: str, capability: PathOperation, pin: bool, retry_safe: bool
|
|
88
|
+
) -> typing.Callable[..., object]:
|
|
89
|
+
"""Build a method forwarding ``name`` to the selected backend path."""
|
|
90
|
+
|
|
91
|
+
def forwarder(self, *args, **kwargs):
|
|
92
|
+
# A composite path used as an argument (a symlink target, a
|
|
93
|
+
# samefile operand) is meaningless to the backend, which would
|
|
94
|
+
# re-parse it through its own constructor. Hand over the logical
|
|
95
|
+
# string and let the backend build its own path type from it.
|
|
96
|
+
args = tuple(
|
|
97
|
+
str(arg) if isinstance(arg, _CompositePathMixin) else arg for arg in args
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def call(path: Path):
|
|
101
|
+
method = getattr(path, name, None)
|
|
102
|
+
if method is None:
|
|
103
|
+
raise NotImplementedError(
|
|
104
|
+
f"{type(path).__name__} does not support {name}"
|
|
105
|
+
)
|
|
106
|
+
return method(*args, **_accepts_kwargs(method, kwargs, name))
|
|
107
|
+
|
|
108
|
+
return self._dispatch(
|
|
109
|
+
capability, call, pin=pin, retry_on_not_implemented=retry_safe
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
forwarder.__name__ = name
|
|
113
|
+
forwarder.__qualname__ = f"_CompositePathMixin.{name}"
|
|
114
|
+
forwarder.__doc__ = (
|
|
115
|
+
f"Route ``{name}`` to the selected path provider's backend path.\n\n"
|
|
116
|
+
f" Forwarded verbatim, so a backend overriding ``{name}`` for a\n"
|
|
117
|
+
f" transport-native implementation is the code that runs.\n"
|
|
118
|
+
f" Generated from ``_FORWARDED``; see that table for the\n"
|
|
119
|
+
f" capability gate and retry contract.\n "
|
|
120
|
+
)
|
|
121
|
+
return forwarder
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Methods forwarded verbatim to the selected backend path. Each entry maps a
|
|
125
|
+
# method name to the capability string gating it, whether the call pins the
|
|
126
|
+
# provider, and whether a `NotImplementedError` from it may fall through to the
|
|
127
|
+
# next provider.
|
|
128
|
+
#
|
|
129
|
+
# The method that was *called* is the method invoked on the backend -- never a
|
|
130
|
+
# decomposition into primitives. Backends override derived operations for real
|
|
131
|
+
# optimization (``SftpPath.copy`` fans out over asyncssh workers,
|
|
132
|
+
# ``SftpPath.rm``/``checksum`` run server-side, ``LocalPath`` reaches ``shutil``
|
|
133
|
+
# and ``os.scandir``), and decomposing would silently discard all of it while
|
|
134
|
+
# still producing correct results. Anything the backend does not override
|
|
135
|
+
# resolves to ``pathlib_next``'s own wrapper, so operations added upstream work
|
|
136
|
+
# by adding a row here rather than writing a body.
|
|
137
|
+
#
|
|
138
|
+
# ``retry_safe`` is deliberately conservative. A wrapper composed of several
|
|
139
|
+
# primitives may already have mutated when a later primitive raises --
|
|
140
|
+
# ``Path.symlink_to(force=True)`` unlinks *before* calling ``_symlink_to``, so a
|
|
141
|
+
# backend lacking that primitive deletes the entry and only then raises.
|
|
142
|
+
# ``NotImplementedError`` cannot distinguish "did nothing" from "did half", so
|
|
143
|
+
# only calls that cannot mutate before raising opt in.
|
|
144
|
+
_FORWARDED: "dict[str, tuple[str, bool, bool]]" = {
|
|
145
|
+
# name: (capability, pin, retry_safe)
|
|
146
|
+
"exists": ("exists", False, True),
|
|
147
|
+
"is_file": ("is_file", False, True),
|
|
148
|
+
"is_dir": ("is_dir", False, True),
|
|
149
|
+
"is_symlink": ("stat", False, True),
|
|
150
|
+
"is_block_device": ("stat", False, True),
|
|
151
|
+
"is_char_device": ("stat", False, True),
|
|
152
|
+
"is_fifo": ("stat", False, True),
|
|
153
|
+
"is_socket": ("stat", False, True),
|
|
154
|
+
"stat": ("stat", False, True),
|
|
155
|
+
"lstat": ("stat", False, True),
|
|
156
|
+
"samefile": ("stat", False, True),
|
|
157
|
+
"read_bytes": ("read", False, True),
|
|
158
|
+
"read_text": ("read", False, True),
|
|
159
|
+
"checksum": ("read", False, True),
|
|
160
|
+
"supported_checksums": ("read", False, True),
|
|
161
|
+
"chown": ("chmod", True, True),
|
|
162
|
+
"chmod": ("chmod", True, True),
|
|
163
|
+
"lchmod": ("chmod", True, True),
|
|
164
|
+
"write_bytes": ("write", True, False),
|
|
165
|
+
"write_text": ("write", True, False),
|
|
166
|
+
"mkdir": ("mkdir", True, False),
|
|
167
|
+
"touch": ("write", True, False),
|
|
168
|
+
"unlink": ("unlink", True, False),
|
|
169
|
+
"rmdir": ("rmdir", True, False),
|
|
170
|
+
"rm": ("unlink", True, False),
|
|
171
|
+
"symlink_to": ("symlink_to", True, False),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
30
175
|
class _CompositePathMixin:
|
|
31
176
|
"""Provider routing shared by the POSIX and Windows concrete classes."""
|
|
32
177
|
|
|
33
178
|
__slots__ = ()
|
|
34
179
|
|
|
180
|
+
def __init_subclass__(cls, **kwargs):
|
|
181
|
+
"""Install the forwarders on every concrete composite class.
|
|
182
|
+
|
|
183
|
+
Generated here rather than written out so that following
|
|
184
|
+
``pathlib_next`` is a row in ``_FORWARDED``, not a new method body.
|
|
185
|
+
A class defining the name in its own body always wins -- that is the
|
|
186
|
+
opt-out for an operation needing real composite logic (``iterdir``,
|
|
187
|
+
``rename``, ``readlink``, ``copy``/``move``).
|
|
188
|
+
"""
|
|
189
|
+
super().__init_subclass__(**kwargs)
|
|
190
|
+
for name, (capability, pin, retry_safe) in _FORWARDED.items():
|
|
191
|
+
# Only a definition inside the composite classes themselves opts
|
|
192
|
+
# out. Testing the whole MRO would match everything inherited
|
|
193
|
+
# from ``pathlib_next.Path`` -- which is the entire surface this
|
|
194
|
+
# table exists to route.
|
|
195
|
+
owner = next(
|
|
196
|
+
(
|
|
197
|
+
base
|
|
198
|
+
for base in cls.__mro__
|
|
199
|
+
if name in vars(base) and _is_composite_owner(base)
|
|
200
|
+
),
|
|
201
|
+
None,
|
|
202
|
+
)
|
|
203
|
+
if owner is not None:
|
|
204
|
+
continue
|
|
205
|
+
setattr(cls, name, _make_forwarder(name, capability, pin, retry_safe))
|
|
206
|
+
|
|
35
207
|
def copy(self, target, **kwargs):
|
|
36
|
-
return
|
|
208
|
+
return self._transfer("copy", target, **kwargs)
|
|
37
209
|
|
|
38
210
|
def move(self, target, **kwargs):
|
|
39
|
-
return
|
|
211
|
+
return self._transfer("move", target, **kwargs)
|
|
212
|
+
|
|
213
|
+
def _transfer(self, name, target, **kwargs):
|
|
214
|
+
"""Route ``copy``/``move`` to the backend when both ends agree.
|
|
215
|
+
|
|
216
|
+
A backend overrides these for transport-native transfer --
|
|
217
|
+
``SftpPath.copy`` fans out over asyncssh workers rather than
|
|
218
|
+
streaming bytes through the client -- so handing the call straight
|
|
219
|
+
to ``Path.copy`` would be correct and much slower.
|
|
220
|
+
|
|
221
|
+
The backend can only be used when the destination resolves to a
|
|
222
|
+
path *it* understands: a plain backend path, or a composite path
|
|
223
|
+
sharing this provider. Anything else (a composite path on another
|
|
224
|
+
provider, a foreign ``Path``) is a genuine cross-backend transfer,
|
|
225
|
+
which is what the generic implementation exists for.
|
|
226
|
+
"""
|
|
227
|
+
generic = getattr(Path, name)
|
|
228
|
+
provider = self._provider
|
|
229
|
+
backend_target = target
|
|
230
|
+
if isinstance(target, _CompositePathMixin):
|
|
231
|
+
if provider is None or not any(
|
|
232
|
+
item is provider for item in target.providers
|
|
233
|
+
):
|
|
234
|
+
return generic(self, target, **kwargs)
|
|
235
|
+
backend_target = target._provider_path(provider)
|
|
236
|
+
elif isinstance(target, str):
|
|
237
|
+
backend_target = target
|
|
238
|
+
elif not isinstance(target, Path):
|
|
239
|
+
return generic(self, target, **kwargs)
|
|
240
|
+
|
|
241
|
+
def call(path: Path):
|
|
242
|
+
method = getattr(type(path), name, None)
|
|
243
|
+
if method is None or method is generic:
|
|
244
|
+
# The backend adds nothing over the generic implementation;
|
|
245
|
+
# use it directly so composite-aware behavior is preserved.
|
|
246
|
+
return generic(self, target, **kwargs)
|
|
247
|
+
return method(path, backend_target, **kwargs)
|
|
248
|
+
|
|
249
|
+
# Gated on "write", not a "copy"/"move" capability: neither is in
|
|
250
|
+
# PathProvider.DEFAULT_CAPABILITIES, so gating on the method name
|
|
251
|
+
# would reject every provider that has not opted in by hand.
|
|
252
|
+
return self._dispatch("write", call, pin=True)
|
|
40
253
|
|
|
41
254
|
def _copy_from(self, source, **kwargs):
|
|
42
255
|
"""Accept Python 3.14 stdlib ``Path.copy()`` destinations."""
|
|
@@ -136,9 +349,11 @@ class _CompositePathMixin:
|
|
|
136
349
|
*,
|
|
137
350
|
pin: bool = False,
|
|
138
351
|
with_provider: bool = False,
|
|
352
|
+
retry_on_not_implemented: bool = False,
|
|
139
353
|
):
|
|
140
354
|
candidates = self._providers_in_order(operation, pin=pin)
|
|
141
355
|
attempted = False
|
|
356
|
+
not_implemented: typing.Optional[NotImplementedError] = None
|
|
142
357
|
for provider in candidates:
|
|
143
358
|
attempted = True
|
|
144
359
|
old = (self._provider, self._backend_path, self._factory, self._pinned)
|
|
@@ -150,6 +365,21 @@ class _CompositePathMixin:
|
|
|
150
365
|
# undo this and try the next provider.
|
|
151
366
|
self._adopt(provider, backend_path, pinned=True)
|
|
152
367
|
result = callback(backend_path)
|
|
368
|
+
except NotImplementedError as exc:
|
|
369
|
+
if not retry_on_not_implemented:
|
|
370
|
+
# The call may have mutated before raising -- a wrapper
|
|
371
|
+
# composed of primitives can fail partway (see _FORWARDED).
|
|
372
|
+
# Report it rather than silently repeating the work
|
|
373
|
+
# against another provider.
|
|
374
|
+
raise
|
|
375
|
+
not_implemented = exc
|
|
376
|
+
if pin:
|
|
377
|
+
self._provider, self._backend_path, self._factory, self._pinned = (
|
|
378
|
+
old
|
|
379
|
+
)
|
|
380
|
+
if self._selector is not None:
|
|
381
|
+
self._selector.decline(provider.name, str(exc))
|
|
382
|
+
continue
|
|
153
383
|
except OperationNotStarted as exc:
|
|
154
384
|
if pin:
|
|
155
385
|
self._provider, self._backend_path, self._factory, self._pinned = (
|
|
@@ -166,6 +396,14 @@ class _CompositePathMixin:
|
|
|
166
396
|
return (result, provider) if with_provider else result
|
|
167
397
|
if not attempted:
|
|
168
398
|
raise NotImplementedError(f"no path provider supports {operation}")
|
|
399
|
+
if not_implemented is not None:
|
|
400
|
+
# Every candidate declined by saying it cannot do this at all.
|
|
401
|
+
# Surfacing OperationNotStarted here would rename a permanent
|
|
402
|
+
# "no backend implements this" into a transient "nothing started",
|
|
403
|
+
# which reads as retryable and hides the real cause -- most
|
|
404
|
+
# visibly with a single provider, where "try the next one" has
|
|
405
|
+
# nothing to try.
|
|
406
|
+
raise not_implemented
|
|
169
407
|
raise OperationNotStarted(f"no path provider completed {operation}")
|
|
170
408
|
|
|
171
409
|
def via(self, name: str):
|
|
@@ -267,49 +505,52 @@ class _CompositePathMixin:
|
|
|
267
505
|
def parent(self):
|
|
268
506
|
return self._child(str(super().parent))
|
|
269
507
|
|
|
270
|
-
def stat(self, *, follow_symlinks: bool = True):
|
|
271
|
-
return self._dispatch(
|
|
272
|
-
"stat", lambda path: path.stat(follow_symlinks=follow_symlinks)
|
|
273
|
-
)
|
|
274
|
-
|
|
275
|
-
def _scan_with_provider(self):
|
|
276
|
-
# The provider's iterator is returned only after the pre-dispatch
|
|
277
|
-
# operation has succeeded; errors after that point are terminal.
|
|
278
|
-
return self._dispatch(
|
|
279
|
-
"scandir", lambda path: iter(path.iterdir()), with_provider=True
|
|
280
|
-
)
|
|
281
|
-
|
|
282
508
|
def _scandir(self):
|
|
283
|
-
"""Yield ``(name, stat)`` pairs
|
|
284
|
-
|
|
285
|
-
``
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
``
|
|
509
|
+
"""Yield ``(name, stat)`` pairs, routed to the backend's listing.
|
|
510
|
+
|
|
511
|
+
``_scandir`` is the primitive and ``iterdir`` derives from it, not
|
|
512
|
+
the other way round: a backend whose listing call already carries
|
|
513
|
+
metadata answers in one round trip (``SftpPath`` uses
|
|
514
|
+
``listdir_attr``; FTP/HTTP/S3 do the equivalent). Listing via
|
|
515
|
+
``iterdir`` instead would rebuild every child as a composite path
|
|
516
|
+
and then stat each one separately, discarding that.
|
|
289
517
|
"""
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
yield entry.name, stat
|
|
518
|
+
entries, _provider = self._dispatch(
|
|
519
|
+
"scandir",
|
|
520
|
+
lambda path: iter(path._scandir()),
|
|
521
|
+
with_provider=True,
|
|
522
|
+
retry_on_not_implemented=True,
|
|
523
|
+
)
|
|
524
|
+
return entries
|
|
298
525
|
|
|
299
526
|
def iterdir(self):
|
|
300
|
-
|
|
301
|
-
|
|
527
|
+
# Dispatches for the provider as well as the entries: children must
|
|
528
|
+
# be built against the provider that actually scanned, and
|
|
529
|
+
# ``__slots__`` leaves nowhere to stash it between calls.
|
|
530
|
+
entries, provider = self._dispatch(
|
|
531
|
+
"scandir",
|
|
532
|
+
lambda path: iter(path._scandir()),
|
|
533
|
+
with_provider=True,
|
|
534
|
+
retry_on_not_implemented=True,
|
|
535
|
+
)
|
|
536
|
+
for name, _stat in entries:
|
|
302
537
|
yield type(self).from_path(
|
|
303
|
-
|
|
538
|
+
provider.path(str(self), name),
|
|
304
539
|
provider,
|
|
305
540
|
provider.path,
|
|
306
541
|
self._providers,
|
|
307
542
|
self._selector,
|
|
308
543
|
pinned=self._pinned,
|
|
309
|
-
logical_segments=(str(self),
|
|
544
|
+
logical_segments=(str(self), name),
|
|
310
545
|
)
|
|
311
546
|
|
|
312
547
|
def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None):
|
|
548
|
+
"""Open through the selected provider.
|
|
549
|
+
|
|
550
|
+
Hand-written rather than generated because the capability gate
|
|
551
|
+
depends on the mode: a read opens under ``open_read``, a write
|
|
552
|
+
under ``open_write``.
|
|
553
|
+
"""
|
|
313
554
|
raw_mode = mode.replace("b", "")
|
|
314
555
|
write = any(flag in raw_mode for flag in "wax+")
|
|
315
556
|
operation = "open_write" if write else "open_read"
|
|
@@ -323,66 +564,11 @@ class _CompositePathMixin:
|
|
|
323
564
|
newline=newline,
|
|
324
565
|
),
|
|
325
566
|
pin=True,
|
|
567
|
+
# A read has not mutated anything when it reports it cannot open;
|
|
568
|
+
# a write may have created or truncated the file first.
|
|
569
|
+
retry_on_not_implemented=not write,
|
|
326
570
|
)
|
|
327
571
|
|
|
328
|
-
def read_bytes(self):
|
|
329
|
-
return self._dispatch("read", lambda path: path.read_bytes())
|
|
330
|
-
|
|
331
|
-
def write_bytes(self, data):
|
|
332
|
-
return self._dispatch("write", lambda path: path.write_bytes(data), pin=True)
|
|
333
|
-
|
|
334
|
-
def read_text(self, *args, **kwargs):
|
|
335
|
-
return self._dispatch("read", lambda path: path.read_text(*args, **kwargs))
|
|
336
|
-
|
|
337
|
-
def write_text(self, data, *args, **kwargs):
|
|
338
|
-
return self._dispatch(
|
|
339
|
-
"write", lambda path: path.write_text(data, *args, **kwargs), pin=True
|
|
340
|
-
)
|
|
341
|
-
|
|
342
|
-
def exists(self, *, follow_symlinks=True):
|
|
343
|
-
return self._dispatch(
|
|
344
|
-
"exists", lambda path: path.exists(follow_symlinks=follow_symlinks)
|
|
345
|
-
)
|
|
346
|
-
|
|
347
|
-
def is_file(self):
|
|
348
|
-
return self._dispatch("is_file", lambda path: path.is_file())
|
|
349
|
-
|
|
350
|
-
def is_dir(self):
|
|
351
|
-
return self._dispatch("is_dir", lambda path: path.is_dir())
|
|
352
|
-
|
|
353
|
-
def mkdir(self, mode=0o777, parents=False, exist_ok=False):
|
|
354
|
-
return self._dispatch(
|
|
355
|
-
"mkdir",
|
|
356
|
-
lambda path: path.mkdir(mode=mode, parents=parents, exist_ok=exist_ok),
|
|
357
|
-
pin=True,
|
|
358
|
-
)
|
|
359
|
-
|
|
360
|
-
def chmod(self, mode, *, follow_symlinks=True):
|
|
361
|
-
return self._dispatch(
|
|
362
|
-
"chmod",
|
|
363
|
-
lambda path: path.chmod(mode, follow_symlinks=follow_symlinks),
|
|
364
|
-
pin=True,
|
|
365
|
-
)
|
|
366
|
-
|
|
367
|
-
def symlink_to(self, target, target_is_directory: bool = False):
|
|
368
|
-
"""Create this path as a symlink, through the selected provider.
|
|
369
|
-
|
|
370
|
-
The backend path class owns the transport's real capability: a
|
|
371
|
-
backend without symlink support raises ``NotImplementedError``
|
|
372
|
-
(never a silent no-op), and that surfaces here unchanged.
|
|
373
|
-
"""
|
|
374
|
-
logical_target = str(target)
|
|
375
|
-
|
|
376
|
-
def symlink_with_selected_provider(path):
|
|
377
|
-
method = getattr(path, "symlink_to", None)
|
|
378
|
-
if method is None:
|
|
379
|
-
raise NotImplementedError(
|
|
380
|
-
f"{type(path).__name__} does not support symlink_to"
|
|
381
|
-
)
|
|
382
|
-
return method(logical_target, target_is_directory)
|
|
383
|
-
|
|
384
|
-
return self._dispatch("symlink_to", symlink_with_selected_provider, pin=True)
|
|
385
|
-
|
|
386
572
|
def readlink(self):
|
|
387
573
|
def readlink_with_selected_provider(path):
|
|
388
574
|
method = getattr(path, "readlink", None)
|
|
@@ -408,14 +594,6 @@ class _CompositePathMixin:
|
|
|
408
594
|
logical_segments=(str(target),),
|
|
409
595
|
)
|
|
410
596
|
|
|
411
|
-
def unlink(self, missing_ok=False):
|
|
412
|
-
return self._dispatch(
|
|
413
|
-
"unlink", lambda path: path.unlink(missing_ok=missing_ok), pin=True
|
|
414
|
-
)
|
|
415
|
-
|
|
416
|
-
def rmdir(self):
|
|
417
|
-
return self._dispatch("rmdir", lambda path: path.rmdir(), pin=True)
|
|
418
|
-
|
|
419
597
|
def rename(self, target):
|
|
420
598
|
logical_target = str(target)
|
|
421
599
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Backend-specific keyword arguments survive composite dispatch.
|
|
2
|
+
|
|
3
|
+
The composite path normalises every operation to the stdlib signature, which
|
|
4
|
+
made a backend's documented extension unreachable through the very wrapper
|
|
5
|
+
meant to expose it. Forwarding is signature-aware on purpose: a kwarg the
|
|
6
|
+
selected backend declares is passed through, and one it does not is rejected
|
|
7
|
+
*here*, so the error names the composite boundary instead of surfacing from
|
|
8
|
+
inside a transport.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from pathlib_next.mempath import MemPath, MemPathBackend
|
|
15
|
+
|
|
16
|
+
from hostctl import PathProvider, PosixHost
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ExtendedMemPath(MemPath):
|
|
20
|
+
"""A backend path with an extension, as real backends do."""
|
|
21
|
+
|
|
22
|
+
calls: list[tuple[str, dict]] = []
|
|
23
|
+
|
|
24
|
+
# MemPath has no symlink_to to delegate to; recording the call is the
|
|
25
|
+
# whole point here -- what is under test is which kwargs arrive, not
|
|
26
|
+
# whether the memory backend can model a symlink.
|
|
27
|
+
def symlink_to(self, target, target_is_directory=False, *, force=False):
|
|
28
|
+
type(self).calls.append(("symlink_to", {"force": force}))
|
|
29
|
+
|
|
30
|
+
def mkdir(self, mode=0o777, parents=False, exist_ok=False, *, owner=None):
|
|
31
|
+
type(self).calls.append(("mkdir", {"owner": owner}))
|
|
32
|
+
return super().mkdir(mode=mode, parents=parents, exist_ok=exist_ok)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def host():
|
|
37
|
+
backend = MemPathBackend()
|
|
38
|
+
ExtendedMemPath("root", backend=backend).mkdir()
|
|
39
|
+
ExtendedMemPath("root/target", backend=backend).write_bytes(b"t")
|
|
40
|
+
# Reset *after* fixture setup: the mkdir above is an ExtendedMemPath call
|
|
41
|
+
# too, and would otherwise read as something the test provoked.
|
|
42
|
+
ExtendedMemPath.calls = []
|
|
43
|
+
# ``symlink_to``/``readlink`` are not in DEFAULT_CAPABILITIES, so a
|
|
44
|
+
# provider that supports them has to say so or dispatch never reaches
|
|
45
|
+
# the backend at all.
|
|
46
|
+
provider = PathProvider(
|
|
47
|
+
"extended",
|
|
48
|
+
lambda *p: ExtendedMemPath(*p, backend=backend),
|
|
49
|
+
capabilities=PathProvider.DEFAULT_CAPABILITIES | {"symlink_to", "readlink"},
|
|
50
|
+
)
|
|
51
|
+
return PosixHost(path_providers=(provider,))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_backend_extension_kwarg_reaches_the_backend(host):
|
|
55
|
+
link = host.path("root", "link")
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
link.symlink_to("root/target", force=True)
|
|
59
|
+
except NotImplementedError:
|
|
60
|
+
pytest.skip("backing pathname does not implement symlink_to here")
|
|
61
|
+
|
|
62
|
+
assert ("symlink_to", {"force": True}) in ExtendedMemPath.calls
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_mkdir_extension_kwarg_reaches_the_backend(host):
|
|
66
|
+
host.path("root", "made").mkdir(owner="operator")
|
|
67
|
+
|
|
68
|
+
assert ("mkdir", {"owner": "operator"}) in ExtendedMemPath.calls
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_unknown_kwarg_fails_at_the_composite_boundary(host):
|
|
72
|
+
"""Not forwarded blindly: the message names the backend, not a transport."""
|
|
73
|
+
link = host.path("root", "link")
|
|
74
|
+
|
|
75
|
+
with pytest.raises(TypeError) as excinfo:
|
|
76
|
+
link.symlink_to("root/target", no_such_option=True)
|
|
77
|
+
|
|
78
|
+
assert "no_such_option" in str(excinfo.value)
|
|
79
|
+
assert "ExtendedMemPath.symlink_to()" in str(excinfo.value)
|
|
80
|
+
assert ExtendedMemPath.calls == []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_no_kwargs_leaves_the_stdlib_call_untouched(host):
|
|
84
|
+
"""The common path must not pay for -- or be changed by -- introspection."""
|
|
85
|
+
host.path("root", "plain").mkdir()
|
|
86
|
+
|
|
87
|
+
assert ("mkdir", {"owner": None}) in ExtendedMemPath.calls
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_force_reaches_a_real_backend_end_to_end(tmp_path):
|
|
91
|
+
"""`symlink_to(force=)` over a genuine backend, no fake in the way.
|
|
92
|
+
|
|
93
|
+
As of pathlib_next 0.9.0+, `force=` is a generic `Path` extension rather
|
|
94
|
+
than a backend-specific one, so this exercises the whole chain --
|
|
95
|
+
composite wrapper, signature check, real filesystem -- which is the call
|
|
96
|
+
that raised `TypeError` before the passthrough existed.
|
|
97
|
+
"""
|
|
98
|
+
from pathlib_next import Path as PnPath
|
|
99
|
+
|
|
100
|
+
if "force" not in inspect.signature(PnPath.symlink_to).parameters:
|
|
101
|
+
pytest.skip("installed pathlib_next predates symlink_to(force=)")
|
|
102
|
+
|
|
103
|
+
provider = PathProvider(
|
|
104
|
+
"local",
|
|
105
|
+
lambda *p: PnPath(*p),
|
|
106
|
+
capabilities=PathProvider.DEFAULT_CAPABILITIES | {"symlink_to", "readlink"},
|
|
107
|
+
)
|
|
108
|
+
host = PosixHost(path_providers=(provider,))
|
|
109
|
+
target = tmp_path / "target"
|
|
110
|
+
target.write_text("t")
|
|
111
|
+
link = tmp_path / "link"
|
|
112
|
+
link.write_text("occupied") # force= has to displace this
|
|
113
|
+
|
|
114
|
+
host.path(str(link).replace("\\", "/")).symlink_to(
|
|
115
|
+
str(target).replace("\\", "/"), force=True
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert link.is_symlink()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Composite paths forward the called method and inherit the rest.
|
|
2
|
+
|
|
3
|
+
Two properties, and they pull against each other:
|
|
4
|
+
|
|
5
|
+
1. A backend that overrides a derived operation for a transport-native
|
|
6
|
+
implementation must be the code that runs -- ``SftpPath.copy`` fans out
|
|
7
|
+
over asyncssh workers, ``SftpPath.rm``/``checksum`` execute server-side,
|
|
8
|
+
``LocalPath`` reaches ``shutil``/``os.scandir``. Decomposing a call into
|
|
9
|
+
backend primitives would still produce correct results while silently
|
|
10
|
+
discarding every one of those, which no output assertion would catch.
|
|
11
|
+
2. An operation hostctl never declares must still work, so that following
|
|
12
|
+
``pathlib_next`` does not mean hand-writing a forwarder per release.
|
|
13
|
+
|
|
14
|
+
The tests below pin both, because satisfying either one alone is easy and
|
|
15
|
+
the failure mode of trading one for the other is invisible.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import inspect
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
from pathlib_next.mempath import MemPath, MemPathBackend
|
|
22
|
+
|
|
23
|
+
from hostctl import PathProvider, PosixHost
|
|
24
|
+
|
|
25
|
+
FULL = PathProvider.DEFAULT_CAPABILITIES | {"symlink_to", "readlink", "scandir"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RecordingMemPath(MemPath):
|
|
29
|
+
"""A backend that overrides derived operations, as real backends do."""
|
|
30
|
+
|
|
31
|
+
calls: list = []
|
|
32
|
+
|
|
33
|
+
def copy(self, target, **kwargs):
|
|
34
|
+
type(self).calls.append("copy")
|
|
35
|
+
return super().copy(target, **kwargs)
|
|
36
|
+
|
|
37
|
+
def rm(self, *args, **kwargs):
|
|
38
|
+
type(self).calls.append("rm")
|
|
39
|
+
return super().rm(*args, **kwargs)
|
|
40
|
+
|
|
41
|
+
def touch(self, mode=0o666, exist_ok=True):
|
|
42
|
+
type(self).calls.append("touch")
|
|
43
|
+
return super().touch(mode=mode, exist_ok=exist_ok)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def host():
|
|
48
|
+
backend = MemPathBackend()
|
|
49
|
+
RecordingMemPath("root", backend=backend).mkdir()
|
|
50
|
+
RecordingMemPath("root/a.txt", backend=backend).write_bytes(b"a")
|
|
51
|
+
RecordingMemPath.calls = []
|
|
52
|
+
provider = PathProvider(
|
|
53
|
+
"recording",
|
|
54
|
+
lambda *p: RecordingMemPath(*p, backend=backend),
|
|
55
|
+
capabilities=FULL,
|
|
56
|
+
)
|
|
57
|
+
return PosixHost(path_providers=(provider,))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_backend_override_of_copy_runs(host):
|
|
61
|
+
"""The backend's own copy() must run, not a primitive decomposition."""
|
|
62
|
+
source = host.path("root", "a.txt")
|
|
63
|
+
|
|
64
|
+
source.copy(host.path("root", "b.txt"))
|
|
65
|
+
|
|
66
|
+
assert "copy" in RecordingMemPath.calls
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_backend_override_of_rm_runs(host):
|
|
70
|
+
"""rm() is a derived operation; SftpPath implements it server-side."""
|
|
71
|
+
target = host.path("root", "a.txt")
|
|
72
|
+
|
|
73
|
+
target.rm()
|
|
74
|
+
|
|
75
|
+
assert "rm" in RecordingMemPath.calls
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_backend_override_of_touch_runs(host):
|
|
79
|
+
"""touch() is derived from chmod()/open() but backends may override it."""
|
|
80
|
+
host.path("root", "t.txt").touch()
|
|
81
|
+
|
|
82
|
+
assert "touch" in RecordingMemPath.calls
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize(
|
|
86
|
+
"operation",
|
|
87
|
+
[
|
|
88
|
+
pytest.param(lambda p: p.lstat(), id="lstat"),
|
|
89
|
+
pytest.param(lambda p: p.is_symlink(), id="is_symlink"),
|
|
90
|
+
pytest.param(lambda p: p.read_text(), id="read_text"),
|
|
91
|
+
pytest.param(lambda p: p.exists(), id="exists"),
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
def test_operations_work_without_a_composite_declaration(host, operation):
|
|
95
|
+
"""None of these need a hand-written method in composite_path.py."""
|
|
96
|
+
operation(host.path("root", "a.txt"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_chown_reaches_the_backend_without_a_composite_method(host):
|
|
100
|
+
"""The regression that motivated this: 0.9.1 added chown(), hostctl had none.
|
|
101
|
+
|
|
102
|
+
The assertion is that the call *reaches the backend* -- MemPath has no
|
|
103
|
+
``_chown`` primitive, so NotImplementedError from it is the proof that
|
|
104
|
+
routing happened rather than a missing attribute at the composite layer.
|
|
105
|
+
"""
|
|
106
|
+
from pathlib_next import Path as PnPath
|
|
107
|
+
|
|
108
|
+
if not hasattr(PnPath, "chown"):
|
|
109
|
+
pytest.skip("installed pathlib_next predates chown()")
|
|
110
|
+
|
|
111
|
+
from hostctl.host import composite_path
|
|
112
|
+
|
|
113
|
+
assert "def chown" not in inspect.getsource(composite_path)
|
|
114
|
+
|
|
115
|
+
with pytest.raises(NotImplementedError) as excinfo:
|
|
116
|
+
host.path("root", "a.txt").chown(uid=0)
|
|
117
|
+
|
|
118
|
+
assert "_chown" in str(excinfo.value)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_walk_yields_composite_paths(host):
|
|
122
|
+
"""A derived traversal must still hand back routed paths, not raw ones."""
|
|
123
|
+
for root, _dirs, _files in host.path("root").walk():
|
|
124
|
+
assert root.provider is not None
|
|
@@ -388,10 +388,10 @@ def test_composite_path_uses_target_flavour_independent_of_client():
|
|
|
388
388
|
)
|
|
389
389
|
windows = WindowsHost(path_providers=(windows_provider,)).path(
|
|
390
390
|
r"C:\\Users",
|
|
391
|
-
"
|
|
391
|
+
"operator",
|
|
392
392
|
)
|
|
393
393
|
assert type(windows).__name__ == "CompositeWindowsPath"
|
|
394
|
-
assert str(windows) == r"C:\Users\
|
|
394
|
+
assert str(windows) == r"C:\Users\operator"
|
|
395
395
|
assert windows.drive == "C:"
|
|
396
396
|
assert windows.root == "\\"
|
|
397
397
|
assert windows.parent.name == "Users"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|