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.
Files changed (113) hide show
  1. {hostctl-0.2.3 → hostctl-0.2.5}/.gitignore +3 -0
  2. {hostctl-0.2.3 → hostctl-0.2.5}/CHANGELOG.md +86 -1
  3. {hostctl-0.2.3 → hostctl-0.2.5}/PKG-INFO +1 -1
  4. {hostctl-0.2.3 → hostctl-0.2.5}/pyproject.toml +2 -2
  5. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/__init__.py +6 -0
  6. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/composite_path.py +230 -125
  7. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/serial.py +1 -1
  8. {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/providers.py +58 -0
  9. {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_sync_contract.py +12 -6
  10. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_composite_path_backend_kwargs.py +7 -8
  11. hostctl-0.2.5/tests/test_composite_path_inherited_operations.py +124 -0
  12. hostctl-0.2.5/tests/test_executor_exports.py +85 -0
  13. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_system_hosts.py +2 -2
  14. {hostctl-0.2.3 → hostctl-0.2.5}/LICENSE +0 -0
  15. {hostctl-0.2.3 → hostctl-0.2.5}/README.md +0 -0
  16. {hostctl-0.2.3 → hostctl-0.2.5}/docs/api/reference.md +0 -0
  17. {hostctl-0.2.3 → hostctl-0.2.5}/docs/changelog.md +0 -0
  18. {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/cli.md +0 -0
  19. {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/contracts.md +0 -0
  20. {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/extending.md +0 -0
  21. {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/path.md +0 -0
  22. {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/providers.md +0 -0
  23. {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/run.md +0 -0
  24. {hostctl-0.2.3 → hostctl-0.2.5}/docs/guide/transfer.md +0 -0
  25. {hostctl-0.2.3 → hostctl-0.2.5}/docs/index.md +0 -0
  26. {hostctl-0.2.3 → hostctl-0.2.5}/examples/application_provider.py +0 -0
  27. {hostctl-0.2.3 → hostctl-0.2.5}/examples/copy_between_hosts.py +0 -0
  28. {hostctl-0.2.3 → hostctl-0.2.5}/examples/local_run.py +0 -0
  29. {hostctl-0.2.3 → hostctl-0.2.5}/examples/remote_run.py +0 -0
  30. {hostctl-0.2.3 → hostctl-0.2.5}/mkdocs.yml +0 -0
  31. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/AGENTS.md +0 -0
  32. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/__init__.py +0 -0
  33. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/__main__.py +0 -0
  34. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/_async.py +0 -0
  35. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/_cli.py +0 -0
  36. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/_common.py +0 -0
  37. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/_qga.py +0 -0
  38. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/container.py +0 -0
  39. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/local.py +0 -0
  40. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/psrp.py +0 -0
  41. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/qemu.py +0 -0
  42. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/serial.py +0 -0
  43. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/ssh.py +0 -0
  44. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/executor/winrm.py +0 -0
  45. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/__init__.py +0 -0
  46. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/_common.py +0 -0
  47. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/_connection.py +0 -0
  48. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/_local.py +0 -0
  49. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/_ssh.py +0 -0
  50. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/_winrm.py +0 -0
  51. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/container.py +0 -0
  52. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/container_path.py +0 -0
  53. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/qemu.py +0 -0
  54. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/host/system.py +0 -0
  55. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/__init__.py +0 -0
  56. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/_common.py +0 -0
  57. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/container.py +0 -0
  58. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/psrp.py +0 -0
  59. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/qemu_serial.py +0 -0
  60. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/serial.py +0 -0
  61. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/process/ssh.py +0 -0
  62. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/provider/__init__.py +0 -0
  63. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/provider/_common.py +0 -0
  64. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/provider/transports.py +0 -0
  65. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/py.typed +0 -0
  66. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/serial/__init__.py +0 -0
  67. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/__init__.py +0 -0
  68. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/_common.py +0 -0
  69. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/cmd.py +0 -0
  70. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/fish.py +0 -0
  71. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/posix.py +0 -0
  72. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/shell/powershell.py +0 -0
  73. {hostctl-0.2.3 → hostctl-0.2.5}/src/hostctl/sync.py +0 -0
  74. {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/__init__.py +0 -0
  75. {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/path_fakes.py +0 -0
  76. {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_composite_path_routing.py +0 -0
  77. {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_live.py +0 -0
  78. {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_path_contract.py +0 -0
  79. {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_process_contract.py +0 -0
  80. {hostctl-0.2.3 → hostctl-0.2.5}/tests/conformance/test_run_contract.py +0 -0
  81. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_application_provider.py +0 -0
  82. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_asyncssh_errors.py +0 -0
  83. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_cli.py +0 -0
  84. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_composite_path_propagation.py +0 -0
  85. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_connection_string.py +0 -0
  86. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_container_path.py +0 -0
  87. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_container_process.py +0 -0
  88. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_exec_command.py +0 -0
  89. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_api.py +0 -0
  90. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_container.py +0 -0
  91. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_local.py +0 -0
  92. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_module_layout.py +0 -0
  93. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_qemu.py +0 -0
  94. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_remote.py +0 -0
  95. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_host_winrm.py +0 -0
  96. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_observability.py +0 -0
  97. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_process.py +0 -0
  98. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_provider_fault_injection.py +0 -0
  99. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_psrp.py +0 -0
  100. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qemu_executor.py +0 -0
  101. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qemu_path.py +0 -0
  102. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qemu_serial.py +0 -0
  103. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qemu_serial_process.py +0 -0
  104. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qga_ssh_transport.py +0 -0
  105. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_qga_transport.py +0 -0
  106. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_serial_executor.py +0 -0
  107. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_serial_host.py +0 -0
  108. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_serial_live.py +0 -0
  109. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_shell.py +0 -0
  110. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_ssh_process.py +0 -0
  111. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_sync_helpers.py +0 -0
  112. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_system_host_fidelity.py +0 -0
  113. {hostctl-0.2.3 → hostctl-0.2.5}/tests/test_winrm_path.py +0 -0
@@ -17,3 +17,6 @@ site/
17
17
  .agents
18
18
  CLAUDE*
19
19
  .claude
20
+ .*
21
+ !.gitignore
22
+
@@ -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.3...HEAD
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
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.3"
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 Path.copy(self, target, **kwargs)
208
+ return self._transfer("copy", target, **kwargs)
83
209
 
84
210
  def move(self, target, **kwargs):
85
- return Path.move(self, target, **kwargs)
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 for ``walk()``/``glob()``.
330
-
331
- ``pathlib_next.Path._scandir`` is a listing hook with a fixed shape;
332
- overriding it with a different return type silently breaks every
333
- caller. Provider selection still happens once, in
334
- ``_scan_with_provider``.
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
- from pathlib_next.path import FileStat
337
-
338
- for entry in self.iterdir():
339
- try:
340
- stat = FileStat.from_path(entry, follow_symlink=False)
341
- except OSError:
342
- stat = None
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
- children, provider = self._scan_with_provider()
347
- for child in children:
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
- child,
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), getattr(child, "name", str(child))),
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: