hostctl 0.2.3__tar.gz → 0.2.5__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.3 → hostctl-0.2.5}/.gitignore +3 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/CHANGELOG.md +86 -1
- {hostctl-0.2.3 → hostctl-0.2.5}/PKG-INFO +1 -1
- {hostctl-0.2.3 → hostctl-0.2.5}/pyproject.toml +2 -2
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/__init__.py +6 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/composite_path.py +230 -125
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/serial.py +1 -1
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/providers.py +58 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_sync_contract.py +12 -6
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_composite_path_backend_kwargs.py +7 -8
- hostctl-0.2.5/tests/test_composite_path_inherited_operations.py +124 -0
- hostctl-0.2.5/tests/test_executor_exports.py +85 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_system_hosts.py +2 -2
- {hostctl-0.2.3 → hostctl-0.2.5}/LICENSE +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/README.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/docs/api/reference.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/docs/changelog.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/cli.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/contracts.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/extending.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/path.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/providers.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/run.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/transfer.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/docs/index.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/examples/application_provider.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/examples/local_run.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/examples/remote_run.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/mkdocs.yml +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/AGENTS.md +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/__init__.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/__main__.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/_async.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/_cli.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/_common.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/container.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/local.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/psrp.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/qemu.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/ssh.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/winrm.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/__init__.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/_common.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/_connection.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/_ssh.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/_winrm.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/container.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/container_path.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/qemu.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/system.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/container.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/psrp.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/ssh.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/provider/_common.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/provider/transports.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/py.typed +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/_common.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/cmd.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/fish.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/posix.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/powershell.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/sync.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/__init__.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_live.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_path_contract.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_process_contract.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_run_contract.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_application_provider.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_cli.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_composite_path_propagation.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_connection_string.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_container_path.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_container_process.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_exec_command.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_api.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_container.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_local.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_qemu.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_remote.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_winrm.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_observability.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_process.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_provider_fault_injection.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_psrp.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qemu_path.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qga_transport.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_serial_executor.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_serial_host.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_serial_live.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_shell.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_ssh_process.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_winrm_path.py +0 -0
|
@@ -7,6 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.5] - 2026-08-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `write_output`, `normalize_input`, and `dispatch_output` are exported from
|
|
15
|
+
`hostctl.executor`, joining `capture_streams`. An executor implemented
|
|
16
|
+
outside hostctl previously had to import `hostctl.executor._common` to
|
|
17
|
+
reproduce hostctl's own stdout/stderr and stdin semantics.
|
|
18
|
+
|
|
19
|
+
Sharing these is a correctness requirement rather than a convenience: a
|
|
20
|
+
`SystemHost` can dispatch the same call through different providers on
|
|
21
|
+
different attempts, so providers that disagree about output handling return
|
|
22
|
+
results that differ by which transport won. `normalize_input` is the one
|
|
23
|
+
worth not reimplementing — a mismatch there does not raise, it deadlocks,
|
|
24
|
+
because bytes handed to a text-mode stdin kill `subprocess`'s writer thread
|
|
25
|
+
without closing the pipe, so the child never sees EOF and `timeout=` never
|
|
26
|
+
fires.
|
|
27
|
+
|
|
28
|
+
No behaviour change; these are the same objects `_common` defines.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Four conformance tests covering timestamp handling were skipping for a
|
|
33
|
+
reason that was not true, so the contract they check went unverified. The
|
|
34
|
+
checks called `os.utime()` on paths belonging to fake *remote* providers,
|
|
35
|
+
which map into a private sandbox root and have no local existence; the
|
|
36
|
+
resulting `FileNotFoundError` was reported as "this provider cannot set
|
|
37
|
+
timestamps". Timestamps are now set through the sandbox that actually stores
|
|
38
|
+
the file. Test-only change.
|
|
39
|
+
|
|
40
|
+
## [0.2.4] - 2026-08-05
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- Directory listings no longer discard the backend's own `_scandir()`.
|
|
45
|
+
`walk()` and `glob()` went through hostctl's `_scandir()`, which was a
|
|
46
|
+
verbatim copy of `pathlib_next`'s generic fallback and called `iterdir()` —
|
|
47
|
+
so a scheme whose listing already carries metadata never got to use it.
|
|
48
|
+
`SftpPath._scandir()` reads every child's attributes in a single
|
|
49
|
+
`listdir_attr` round trip; before this fix a remote `walk()` paid a listing
|
|
50
|
+
plus one `stat()` per entry. `_scandir()` is now the routed primitive and
|
|
51
|
+
`iterdir()` derives from it, matching the direction upstream intends.
|
|
52
|
+
|
|
53
|
+
- `copy()` and `move()` on a composite path now use the backend's own
|
|
54
|
+
implementation when the destination resolves to a path that backend
|
|
55
|
+
understands. They previously called `Path.copy(self, ...)` unconditionally,
|
|
56
|
+
which bypassed every backend override — `SftpPath.copy()` fans out over
|
|
57
|
+
asyncssh workers and `SftpPath.rm()`/`checksum()` run server-side, so the
|
|
58
|
+
results stayed correct while the transport-native path was silently
|
|
59
|
+
discarded. A genuine cross-backend transfer (a destination on another
|
|
60
|
+
provider) still uses the generic implementation, which is what it is for.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- Composite paths forward the method that was called to the selected backend
|
|
65
|
+
instead of re-declaring a copy of `pathlib_next.Path`'s surface. Operations
|
|
66
|
+
hostctl never declares — `touch()`, `rm()`, `lstat()`, `is_symlink()`,
|
|
67
|
+
`chown()`, `checksum()` — now work through `host.path()`, and an operation
|
|
68
|
+
added upstream is reachable without a new method here. `chown()`, added in
|
|
69
|
+
`pathlib_next` 0.9.1, was unreachable before this.
|
|
70
|
+
|
|
71
|
+
`_CompositePathMixin` drops from 47 to 36 methods. Operations with real
|
|
72
|
+
composite behavior stay hand-written, each for a reason: `iterdir`/`_scandir`
|
|
73
|
+
(rebuild children as composite paths), `rename` (cross-provider guard),
|
|
74
|
+
`readlink` (rebuilds its result), `copy`/`move` (backend when the destination
|
|
75
|
+
resolves on this provider, generic for a true cross-backend transfer), and
|
|
76
|
+
`open` (the capability gate depends on the mode).
|
|
77
|
+
|
|
78
|
+
The pure-path derivations (`parent`, `parents`, `joinpath`, `/`,
|
|
79
|
+
`with_name`/`with_stem`/`with_suffix`, `relative_to`, `with_segments`) also
|
|
80
|
+
stay: `pathlib.PurePath` builds those through `object.__new__`, bypassing the
|
|
81
|
+
composite constructor, so they re-attach routing state that inheritance drops
|
|
82
|
+
rather than duplicating anything.
|
|
83
|
+
|
|
84
|
+
- Provider fallback now also triggers on `NotImplementedError`, but only for
|
|
85
|
+
operations that cannot mutate before raising (reads, `stat`, `chown`,
|
|
86
|
+
`chmod`). Writes and composed wrappers still propagate it: a wrapper built
|
|
87
|
+
from several primitives may have already changed something when a later
|
|
88
|
+
primitive raises — `symlink_to(force=True)` unlinks before calling
|
|
89
|
+
`_symlink_to()`, so a backend lacking that primitive deletes the entry and
|
|
90
|
+
only then fails. Retrying that against another provider would repeat the
|
|
91
|
+
work with the original already gone.
|
|
92
|
+
|
|
10
93
|
## [0.2.3] - 2026-08-04
|
|
11
94
|
|
|
12
95
|
### Fixed
|
|
@@ -344,7 +427,9 @@ test suite on Python 3.9 through 3.14.
|
|
|
344
427
|
assigned to it; a config-less host now builds its own family configuration
|
|
345
428
|
instead.
|
|
346
429
|
|
|
347
|
-
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.
|
|
430
|
+
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.5...HEAD
|
|
431
|
+
[0.2.5]: https://github.com/jose-pr/hostctl/compare/v0.2.4...v0.2.5
|
|
432
|
+
[0.2.4]: https://github.com/jose-pr/hostctl/compare/v0.2.3...v0.2.4
|
|
348
433
|
[0.2.3]: https://github.com/jose-pr/hostctl/compare/v0.2.2...v0.2.3
|
|
349
434
|
[0.2.2]: https://github.com/jose-pr/hostctl/compare/v0.2.1...v0.2.2
|
|
350
435
|
[0.2.1]: https://github.com/jose-pr/hostctl/compare/v0.2.0...v0.2.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hostctl
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
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.5"
|
|
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"
|
|
@@ -40,7 +40,7 @@ classifiers = [
|
|
|
40
40
|
# because both projects are pre-1.0, where a minor may break. Composite kwargs
|
|
41
41
|
# passthrough does not change this: forwarding is signature-aware, so `force=`
|
|
42
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).
|
|
43
|
+
# boundary when it does not (verified against 0.9.0 and 0.9.1).
|
|
44
44
|
dependencies = ["pathlib_next>=0.8.6,<0.10", "netimps>=0.1,<0.3"]
|
|
45
45
|
|
|
46
46
|
[project.optional-dependencies]
|
|
@@ -13,7 +13,10 @@ from ._common import (
|
|
|
13
13
|
PathLike as PathLike,
|
|
14
14
|
normalize_environment as normalize_environment,
|
|
15
15
|
capture_streams as capture_streams,
|
|
16
|
+
dispatch_output as dispatch_output,
|
|
17
|
+
normalize_input as normalize_input,
|
|
16
18
|
reject_stdin_conflict as reject_stdin_conflict,
|
|
19
|
+
write_output as write_output,
|
|
17
20
|
)
|
|
18
21
|
from .container import (
|
|
19
22
|
ContainerExecutor as ContainerExecutor,
|
|
@@ -65,7 +68,10 @@ __all__ = [
|
|
|
65
68
|
"normalize_container_error",
|
|
66
69
|
"normalize_environment",
|
|
67
70
|
"capture_streams",
|
|
71
|
+
"dispatch_output",
|
|
72
|
+
"normalize_input",
|
|
68
73
|
"reject_stdin_conflict",
|
|
74
|
+
"write_output",
|
|
69
75
|
"normalize_serial_error",
|
|
70
76
|
"SerialExecutor",
|
|
71
77
|
"SerialFactory",
|
|
@@ -73,16 +73,183 @@ def _accepts_kwargs(
|
|
|
73
73
|
return kwargs
|
|
74
74
|
|
|
75
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
|
+
|
|
76
175
|
class _CompositePathMixin:
|
|
77
176
|
"""Provider routing shared by the POSIX and Windows concrete classes."""
|
|
78
177
|
|
|
79
178
|
__slots__ = ()
|
|
80
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
|
+
|
|
81
207
|
def copy(self, target, **kwargs):
|
|
82
|
-
return
|
|
208
|
+
return self._transfer("copy", target, **kwargs)
|
|
83
209
|
|
|
84
210
|
def move(self, target, **kwargs):
|
|
85
|
-
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)
|
|
86
253
|
|
|
87
254
|
def _copy_from(self, source, **kwargs):
|
|
88
255
|
"""Accept Python 3.14 stdlib ``Path.copy()`` destinations."""
|
|
@@ -182,9 +349,11 @@ class _CompositePathMixin:
|
|
|
182
349
|
*,
|
|
183
350
|
pin: bool = False,
|
|
184
351
|
with_provider: bool = False,
|
|
352
|
+
retry_on_not_implemented: bool = False,
|
|
185
353
|
):
|
|
186
354
|
candidates = self._providers_in_order(operation, pin=pin)
|
|
187
355
|
attempted = False
|
|
356
|
+
not_implemented: typing.Optional[NotImplementedError] = None
|
|
188
357
|
for provider in candidates:
|
|
189
358
|
attempted = True
|
|
190
359
|
old = (self._provider, self._backend_path, self._factory, self._pinned)
|
|
@@ -196,6 +365,21 @@ class _CompositePathMixin:
|
|
|
196
365
|
# undo this and try the next provider.
|
|
197
366
|
self._adopt(provider, backend_path, pinned=True)
|
|
198
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
|
|
199
383
|
except OperationNotStarted as exc:
|
|
200
384
|
if pin:
|
|
201
385
|
self._provider, self._backend_path, self._factory, self._pinned = (
|
|
@@ -212,6 +396,14 @@ class _CompositePathMixin:
|
|
|
212
396
|
return (result, provider) if with_provider else result
|
|
213
397
|
if not attempted:
|
|
214
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
|
|
215
407
|
raise OperationNotStarted(f"no path provider completed {operation}")
|
|
216
408
|
|
|
217
409
|
def via(self, name: str):
|
|
@@ -313,49 +505,52 @@ class _CompositePathMixin:
|
|
|
313
505
|
def parent(self):
|
|
314
506
|
return self._child(str(super().parent))
|
|
315
507
|
|
|
316
|
-
def stat(self, *, follow_symlinks: bool = True):
|
|
317
|
-
return self._dispatch(
|
|
318
|
-
"stat", lambda path: path.stat(follow_symlinks=follow_symlinks)
|
|
319
|
-
)
|
|
320
|
-
|
|
321
|
-
def _scan_with_provider(self):
|
|
322
|
-
# The provider's iterator is returned only after the pre-dispatch
|
|
323
|
-
# operation has succeeded; errors after that point are terminal.
|
|
324
|
-
return self._dispatch(
|
|
325
|
-
"scandir", lambda path: iter(path.iterdir()), with_provider=True
|
|
326
|
-
)
|
|
327
|
-
|
|
328
508
|
def _scandir(self):
|
|
329
|
-
"""Yield ``(name, stat)`` pairs
|
|
330
|
-
|
|
331
|
-
``
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
``
|
|
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.
|
|
335
517
|
"""
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
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
|
|
344
525
|
|
|
345
526
|
def iterdir(self):
|
|
346
|
-
|
|
347
|
-
|
|
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:
|
|
348
537
|
yield type(self).from_path(
|
|
349
|
-
|
|
538
|
+
provider.path(str(self), name),
|
|
350
539
|
provider,
|
|
351
540
|
provider.path,
|
|
352
541
|
self._providers,
|
|
353
542
|
self._selector,
|
|
354
543
|
pinned=self._pinned,
|
|
355
|
-
logical_segments=(str(self),
|
|
544
|
+
logical_segments=(str(self), name),
|
|
356
545
|
)
|
|
357
546
|
|
|
358
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
|
+
"""
|
|
359
554
|
raw_mode = mode.replace("b", "")
|
|
360
555
|
write = any(flag in raw_mode for flag in "wax+")
|
|
361
556
|
operation = "open_write" if write else "open_read"
|
|
@@ -369,84 +564,11 @@ class _CompositePathMixin:
|
|
|
369
564
|
newline=newline,
|
|
370
565
|
),
|
|
371
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,
|
|
372
570
|
)
|
|
373
571
|
|
|
374
|
-
def read_bytes(self):
|
|
375
|
-
return self._dispatch("read", lambda path: path.read_bytes())
|
|
376
|
-
|
|
377
|
-
def write_bytes(self, data):
|
|
378
|
-
return self._dispatch("write", lambda path: path.write_bytes(data), pin=True)
|
|
379
|
-
|
|
380
|
-
def read_text(self, *args, **kwargs):
|
|
381
|
-
return self._dispatch("read", lambda path: path.read_text(*args, **kwargs))
|
|
382
|
-
|
|
383
|
-
def write_text(self, data, *args, **kwargs):
|
|
384
|
-
return self._dispatch(
|
|
385
|
-
"write", lambda path: path.write_text(data, *args, **kwargs), pin=True
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
def exists(self, *, follow_symlinks=True):
|
|
389
|
-
return self._dispatch(
|
|
390
|
-
"exists", lambda path: path.exists(follow_symlinks=follow_symlinks)
|
|
391
|
-
)
|
|
392
|
-
|
|
393
|
-
def is_file(self):
|
|
394
|
-
return self._dispatch("is_file", lambda path: path.is_file())
|
|
395
|
-
|
|
396
|
-
def is_dir(self):
|
|
397
|
-
return self._dispatch("is_dir", lambda path: path.is_dir())
|
|
398
|
-
|
|
399
|
-
def mkdir(self, mode=0o777, parents=False, exist_ok=False, **kwargs):
|
|
400
|
-
return self._dispatch(
|
|
401
|
-
"mkdir",
|
|
402
|
-
lambda path: path.mkdir(
|
|
403
|
-
mode=mode,
|
|
404
|
-
parents=parents,
|
|
405
|
-
exist_ok=exist_ok,
|
|
406
|
-
**_accepts_kwargs(path.mkdir, kwargs, "mkdir"),
|
|
407
|
-
),
|
|
408
|
-
pin=True,
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
def chmod(self, mode, *, follow_symlinks=True, **kwargs):
|
|
412
|
-
return self._dispatch(
|
|
413
|
-
"chmod",
|
|
414
|
-
lambda path: path.chmod(
|
|
415
|
-
mode,
|
|
416
|
-
follow_symlinks=follow_symlinks,
|
|
417
|
-
**_accepts_kwargs(path.chmod, kwargs, "chmod"),
|
|
418
|
-
),
|
|
419
|
-
pin=True,
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
def symlink_to(self, target, target_is_directory: bool = False, **kwargs):
|
|
423
|
-
"""Create this path as a symlink, through the selected provider.
|
|
424
|
-
|
|
425
|
-
The backend path class owns the transport's real capability: a
|
|
426
|
-
backend without symlink support raises ``NotImplementedError``
|
|
427
|
-
(never a silent no-op), and that surfaces here unchanged.
|
|
428
|
-
|
|
429
|
-
Extra keyword arguments are forwarded to the backend's
|
|
430
|
-
``symlink_to`` only when its signature actually accepts them --
|
|
431
|
-
see :func:`_accepts_kwargs`. A backend extension (for example
|
|
432
|
-
pytruenas' ``force=``/``onremove=``) is therefore reachable through
|
|
433
|
-
the composite wrapper, while a kwarg no backend understands still
|
|
434
|
-
fails at this boundary with a plain ``TypeError`` rather than an
|
|
435
|
-
obscure one from inside a transport.
|
|
436
|
-
"""
|
|
437
|
-
logical_target = str(target)
|
|
438
|
-
|
|
439
|
-
def symlink_with_selected_provider(path):
|
|
440
|
-
method = getattr(path, "symlink_to", None)
|
|
441
|
-
if method is None:
|
|
442
|
-
raise NotImplementedError(
|
|
443
|
-
f"{type(path).__name__} does not support symlink_to"
|
|
444
|
-
)
|
|
445
|
-
extra = _accepts_kwargs(method, kwargs, "symlink_to")
|
|
446
|
-
return method(logical_target, target_is_directory, **extra)
|
|
447
|
-
|
|
448
|
-
return self._dispatch("symlink_to", symlink_with_selected_provider, pin=True)
|
|
449
|
-
|
|
450
572
|
def readlink(self):
|
|
451
573
|
def readlink_with_selected_provider(path):
|
|
452
574
|
method = getattr(path, "readlink", None)
|
|
@@ -472,23 +594,6 @@ class _CompositePathMixin:
|
|
|
472
594
|
logical_segments=(str(target),),
|
|
473
595
|
)
|
|
474
596
|
|
|
475
|
-
def unlink(self, missing_ok=False, **kwargs):
|
|
476
|
-
return self._dispatch(
|
|
477
|
-
"unlink",
|
|
478
|
-
lambda path: path.unlink(
|
|
479
|
-
missing_ok=missing_ok,
|
|
480
|
-
**_accepts_kwargs(path.unlink, kwargs, "unlink"),
|
|
481
|
-
),
|
|
482
|
-
pin=True,
|
|
483
|
-
)
|
|
484
|
-
|
|
485
|
-
def rmdir(self, **kwargs):
|
|
486
|
-
return self._dispatch(
|
|
487
|
-
"rmdir",
|
|
488
|
-
lambda path: path.rmdir(**_accepts_kwargs(path.rmdir, kwargs, "rmdir")),
|
|
489
|
-
pin=True,
|
|
490
|
-
)
|
|
491
|
-
|
|
492
597
|
def rename(self, target):
|
|
493
598
|
logical_target = str(target)
|
|
494
599
|
|
|
@@ -18,8 +18,8 @@ from ..executor import (
|
|
|
18
18
|
SerialLike,
|
|
19
19
|
SerialSettings,
|
|
20
20
|
capture_streams,
|
|
21
|
+
write_output,
|
|
21
22
|
)
|
|
22
|
-
from ..executor._common import write_output
|
|
23
23
|
from ..process import Process, SerialConsoleProcess, terminal_options
|
|
24
24
|
from ..serial import PromptConsoleProfile, RawConsoleProfile, SerialConsoleProtocol
|
|
25
25
|
from ._common import (
|
|
@@ -762,6 +762,64 @@ def _docker_live() -> tuple[object, Callable[[], None]]:
|
|
|
762
762
|
return host, host.close
|
|
763
763
|
|
|
764
764
|
|
|
765
|
+
def _sandbox_for(host, provider: Provider, path):
|
|
766
|
+
"""Return the `_Sandbox` backing a fake provider's paths, or None.
|
|
767
|
+
|
|
768
|
+
The fakes present target-flavoured absolute paths (`/hostctl-conformance/
|
|
769
|
+
...`, `C:\\hostctl-conformance\\...`) that have no local existence -- each
|
|
770
|
+
maps into a private temporary root. Reaching that root is the only way to
|
|
771
|
+
drive a real filesystem call against a fake remote path.
|
|
772
|
+
|
|
773
|
+
Each fake reaches its sandbox by a different route (`_sftp_backend._client`
|
|
774
|
+
for SFTP, `_path_backend.runner` for WinRM, the transport itself for QGA),
|
|
775
|
+
so this walks object attributes breadth-first rather than encoding one
|
|
776
|
+
chain per transport -- a hardcoded chain silently returns None when a fake
|
|
777
|
+
is restructured, which reintroduces exactly the misreported skip this
|
|
778
|
+
helper exists to prevent.
|
|
779
|
+
"""
|
|
780
|
+
|
|
781
|
+
if provider.live or provider.name == "local":
|
|
782
|
+
return None
|
|
783
|
+
roots = list(getattr(path, "_providers", ()) or ())
|
|
784
|
+
roots += [host]
|
|
785
|
+
seen: set = set()
|
|
786
|
+
queue = list(roots)
|
|
787
|
+
while queue:
|
|
788
|
+
current = queue.pop(0)
|
|
789
|
+
if current is None or id(current) in seen:
|
|
790
|
+
continue
|
|
791
|
+
seen.add(id(current))
|
|
792
|
+
sandbox = getattr(current, "sandbox", None)
|
|
793
|
+
if sandbox is not None and hasattr(sandbox, "local"):
|
|
794
|
+
return sandbox
|
|
795
|
+
for value in list(getattr(current, "__dict__", {}).values()):
|
|
796
|
+
if hasattr(value, "__dict__"):
|
|
797
|
+
queue.append(value)
|
|
798
|
+
return None
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def conformance_utime(host, provider: Provider, path, times) -> bool:
|
|
802
|
+
"""Set `path`'s mtime through whatever really stores it.
|
|
803
|
+
|
|
804
|
+
Returns False only when the provider genuinely cannot set timestamps, so a
|
|
805
|
+
caller can skip for that reason alone. `os.utime(str(path))` is NOT a
|
|
806
|
+
substitute: for every fake remote provider that call targets a local path
|
|
807
|
+
that does not exist, raising `FileNotFoundError` -- an `OSError` that reads
|
|
808
|
+
as "this transport has no timestamp support" while actually meaning the
|
|
809
|
+
test pointed at the wrong filesystem.
|
|
810
|
+
"""
|
|
811
|
+
|
|
812
|
+
target = str(path)
|
|
813
|
+
sandbox = _sandbox_for(host, provider, path)
|
|
814
|
+
if sandbox is not None:
|
|
815
|
+
target = str(sandbox.local(target))
|
|
816
|
+
try:
|
|
817
|
+
os.utime(target, times)
|
|
818
|
+
except (OSError, NotImplementedError):
|
|
819
|
+
return False
|
|
820
|
+
return True
|
|
821
|
+
|
|
822
|
+
|
|
765
823
|
@contextlib.contextmanager
|
|
766
824
|
def provider_context(provider: Provider) -> Iterator[object]:
|
|
767
825
|
try:
|