hostctl 0.2.2__tar.gz → 0.2.3__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.3}/CHANGELOG.md +33 -1
- {hostctl-0.2.2 → hostctl-0.2.3}/PKG-INFO +1 -1
- {hostctl-0.2.2 → hostctl-0.2.3}/pyproject.toml +5 -2
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/composite_path.py +83 -10
- hostctl-0.2.3/tests/test_composite_path_backend_kwargs.py +119 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/.gitignore +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/LICENSE +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/README.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/docs/api/reference.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/docs/changelog.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/docs/guide/cli.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/docs/guide/contracts.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/docs/guide/extending.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/docs/guide/path.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/docs/guide/providers.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/docs/guide/run.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/docs/guide/transfer.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/docs/index.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/examples/application_provider.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/examples/local_run.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/examples/remote_run.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/mkdocs.yml +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/AGENTS.md +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/__main__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/_async.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/_cli.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/executor/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/executor/_common.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/executor/container.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/executor/local.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/executor/psrp.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/executor/qemu.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/executor/ssh.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/executor/winrm.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/_common.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/_connection.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/_ssh.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/_winrm.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/container.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/container_path.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/qemu.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/serial.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/host/system.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/process/container.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/process/psrp.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/process/ssh.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/provider/_common.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/provider/transports.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/py.typed +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/shell/_common.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/shell/cmd.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/shell/fish.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/shell/posix.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/shell/powershell.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/src/hostctl/sync.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/conformance/__init__.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/conformance/providers.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/conformance/test_live.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/conformance/test_path_contract.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/conformance/test_process_contract.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/conformance/test_run_contract.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/conformance/test_sync_contract.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_application_provider.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_cli.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_composite_path_propagation.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_connection_string.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_container_path.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_container_process.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_exec_command.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_host_api.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_host_container.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_host_local.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_host_qemu.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_host_remote.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_host_winrm.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_observability.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_process.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_provider_fault_injection.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_psrp.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_qemu_path.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_qga_transport.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_serial_executor.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_serial_host.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_serial_live.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_shell.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_ssh_process.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_system_hosts.py +0 -0
- {hostctl-0.2.2 → hostctl-0.2.3}/tests/test_winrm_path.py +0 -0
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.3] - 2026-08-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Composite paths no longer drop backend-specific keyword arguments.
|
|
15
|
+
`CompositePosixPath.symlink_to()` accepted only the stdlib signature and
|
|
16
|
+
forwarded nothing else, so a backend's documented extension was unreachable
|
|
17
|
+
through the very abstraction meant to expose it — a path obtained from
|
|
18
|
+
`host.path()` raised `TypeError: unexpected keyword argument 'force'` even
|
|
19
|
+
when the selected backend implemented `force=`.
|
|
20
|
+
|
|
21
|
+
Forwarding is **signature-aware** rather than blind: the selected backend's
|
|
22
|
+
method is inspected, and only keywords it declares are passed through.
|
|
23
|
+
Anything else still raises `TypeError` at the composite boundary, naming
|
|
24
|
+
the backend class and the rejected keyword. Blind passthrough would have
|
|
25
|
+
turned a clear error at the abstraction boundary into a confusing one from
|
|
26
|
+
inside a transport, and the existing contract — a backend lacking a
|
|
27
|
+
capability raises `NotImplementedError`, never a silent no-op — is
|
|
28
|
+
unchanged. A method whose signature cannot be introspected (a C function,
|
|
29
|
+
a `functools.partial`) receives the keywords, since an error from it is no
|
|
30
|
+
worse than calling it directly.
|
|
31
|
+
|
|
32
|
+
Applied to `mkdir()`, `chmod()`, `unlink()`, and `rmdir()` alongside
|
|
33
|
+
`symlink_to()`, since the same normalization affected each of them.
|
|
34
|
+
|
|
35
|
+
This pairs with `pathlib_next`'s `symlink_to(force=)`, which that project
|
|
36
|
+
exposes as a generic `Path` extension over a `_symlink_to()` backend
|
|
37
|
+
primitive. No version floor change: hostctl's `pathlib_next>=0.8.6` floor
|
|
38
|
+
stays where it is, so `force=` is forwarded when the installed version
|
|
39
|
+
provides it and rejected at the boundary when it does not.
|
|
40
|
+
|
|
10
41
|
## [0.2.2] - 2026-07-29
|
|
11
42
|
|
|
12
43
|
### Changed
|
|
@@ -313,7 +344,8 @@ test suite on Python 3.9 through 3.14.
|
|
|
313
344
|
assigned to it; a config-less host now builds its own family configuration
|
|
314
345
|
instead.
|
|
315
346
|
|
|
316
|
-
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.
|
|
347
|
+
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.3...HEAD
|
|
348
|
+
[0.2.3]: https://github.com/jose-pr/hostctl/compare/v0.2.2...v0.2.3
|
|
317
349
|
[0.2.2]: https://github.com/jose-pr/hostctl/compare/v0.2.1...v0.2.2
|
|
318
350
|
[0.2.1]: https://github.com/jose-pr/hostctl/compare/v0.2.0...v0.2.1
|
|
319
351
|
[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.3
|
|
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.3"
|
|
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).
|
|
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,6 +28,51 @@ 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
|
+
|
|
30
76
|
class _CompositePathMixin:
|
|
31
77
|
"""Provider routing shared by the POSIX and Windows concrete classes."""
|
|
32
78
|
|
|
@@ -350,26 +396,43 @@ class _CompositePathMixin:
|
|
|
350
396
|
def is_dir(self):
|
|
351
397
|
return self._dispatch("is_dir", lambda path: path.is_dir())
|
|
352
398
|
|
|
353
|
-
def mkdir(self, mode=0o777, parents=False, exist_ok=False):
|
|
399
|
+
def mkdir(self, mode=0o777, parents=False, exist_ok=False, **kwargs):
|
|
354
400
|
return self._dispatch(
|
|
355
401
|
"mkdir",
|
|
356
|
-
lambda path: path.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
|
+
),
|
|
357
408
|
pin=True,
|
|
358
409
|
)
|
|
359
410
|
|
|
360
|
-
def chmod(self, mode, *, follow_symlinks=True):
|
|
411
|
+
def chmod(self, mode, *, follow_symlinks=True, **kwargs):
|
|
361
412
|
return self._dispatch(
|
|
362
413
|
"chmod",
|
|
363
|
-
lambda path: path.chmod(
|
|
414
|
+
lambda path: path.chmod(
|
|
415
|
+
mode,
|
|
416
|
+
follow_symlinks=follow_symlinks,
|
|
417
|
+
**_accepts_kwargs(path.chmod, kwargs, "chmod"),
|
|
418
|
+
),
|
|
364
419
|
pin=True,
|
|
365
420
|
)
|
|
366
421
|
|
|
367
|
-
def symlink_to(self, target, target_is_directory: bool = False):
|
|
422
|
+
def symlink_to(self, target, target_is_directory: bool = False, **kwargs):
|
|
368
423
|
"""Create this path as a symlink, through the selected provider.
|
|
369
424
|
|
|
370
425
|
The backend path class owns the transport's real capability: a
|
|
371
426
|
backend without symlink support raises ``NotImplementedError``
|
|
372
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.
|
|
373
436
|
"""
|
|
374
437
|
logical_target = str(target)
|
|
375
438
|
|
|
@@ -379,7 +442,8 @@ class _CompositePathMixin:
|
|
|
379
442
|
raise NotImplementedError(
|
|
380
443
|
f"{type(path).__name__} does not support symlink_to"
|
|
381
444
|
)
|
|
382
|
-
|
|
445
|
+
extra = _accepts_kwargs(method, kwargs, "symlink_to")
|
|
446
|
+
return method(logical_target, target_is_directory, **extra)
|
|
383
447
|
|
|
384
448
|
return self._dispatch("symlink_to", symlink_with_selected_provider, pin=True)
|
|
385
449
|
|
|
@@ -408,13 +472,22 @@ class _CompositePathMixin:
|
|
|
408
472
|
logical_segments=(str(target),),
|
|
409
473
|
)
|
|
410
474
|
|
|
411
|
-
def unlink(self, missing_ok=False):
|
|
475
|
+
def unlink(self, missing_ok=False, **kwargs):
|
|
412
476
|
return self._dispatch(
|
|
413
|
-
"unlink",
|
|
477
|
+
"unlink",
|
|
478
|
+
lambda path: path.unlink(
|
|
479
|
+
missing_ok=missing_ok,
|
|
480
|
+
**_accepts_kwargs(path.unlink, kwargs, "unlink"),
|
|
481
|
+
),
|
|
482
|
+
pin=True,
|
|
414
483
|
)
|
|
415
484
|
|
|
416
|
-
def rmdir(self):
|
|
417
|
-
return self._dispatch(
|
|
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
|
+
)
|
|
418
491
|
|
|
419
492
|
def rename(self, target):
|
|
420
493
|
logical_target = str(target)
|
|
@@ -0,0 +1,119 @@
|
|
|
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 (`.agents/findings/processed/
|
|
6
|
+
2026-08-04_composite_symlink_to_drops_backend_kwargs.md`). Forwarding is
|
|
7
|
+
signature-aware on purpose: a kwarg the selected backend declares is passed
|
|
8
|
+
through, and one it does not is rejected *here*, so the error names the
|
|
9
|
+
composite boundary instead of surfacing from inside a transport.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from pathlib_next.mempath import MemPath, MemPathBackend
|
|
16
|
+
|
|
17
|
+
from hostctl import PathProvider, PosixHost
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExtendedMemPath(MemPath):
|
|
21
|
+
"""A backend path with an extension, like pytruenas' ``TruenasPath``."""
|
|
22
|
+
|
|
23
|
+
calls: list[tuple[str, dict]] = []
|
|
24
|
+
|
|
25
|
+
# MemPath has no symlink_to to delegate to; recording the call is the
|
|
26
|
+
# whole point here -- what is under test is which kwargs arrive, not
|
|
27
|
+
# whether the memory backend can model a symlink.
|
|
28
|
+
def symlink_to(self, target, target_is_directory=False, *, force=False):
|
|
29
|
+
type(self).calls.append(("symlink_to", {"force": force}))
|
|
30
|
+
|
|
31
|
+
def mkdir(self, mode=0o777, parents=False, exist_ok=False, *, owner=None):
|
|
32
|
+
type(self).calls.append(("mkdir", {"owner": owner}))
|
|
33
|
+
return super().mkdir(mode=mode, parents=parents, exist_ok=exist_ok)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def host():
|
|
38
|
+
backend = MemPathBackend()
|
|
39
|
+
ExtendedMemPath("root", backend=backend).mkdir()
|
|
40
|
+
ExtendedMemPath("root/target", backend=backend).write_bytes(b"t")
|
|
41
|
+
# Reset *after* fixture setup: the mkdir above is an ExtendedMemPath call
|
|
42
|
+
# too, and would otherwise read as something the test provoked.
|
|
43
|
+
ExtendedMemPath.calls = []
|
|
44
|
+
# ``symlink_to``/``readlink`` are not in DEFAULT_CAPABILITIES, so a
|
|
45
|
+
# provider that supports them has to say so or dispatch never reaches
|
|
46
|
+
# the backend at all.
|
|
47
|
+
provider = PathProvider(
|
|
48
|
+
"extended",
|
|
49
|
+
lambda *p: ExtendedMemPath(*p, backend=backend),
|
|
50
|
+
capabilities=PathProvider.DEFAULT_CAPABILITIES | {"symlink_to", "readlink"},
|
|
51
|
+
)
|
|
52
|
+
return PosixHost(path_providers=(provider,))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_backend_extension_kwarg_reaches_the_backend(host):
|
|
56
|
+
link = host.path("root", "link")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
link.symlink_to("root/target", force=True)
|
|
60
|
+
except NotImplementedError:
|
|
61
|
+
pytest.skip("backing pathname does not implement symlink_to here")
|
|
62
|
+
|
|
63
|
+
assert ("symlink_to", {"force": True}) in ExtendedMemPath.calls
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_mkdir_extension_kwarg_reaches_the_backend(host):
|
|
67
|
+
host.path("root", "made").mkdir(owner="jose")
|
|
68
|
+
|
|
69
|
+
assert ("mkdir", {"owner": "jose"}) in ExtendedMemPath.calls
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_unknown_kwarg_fails_at_the_composite_boundary(host):
|
|
73
|
+
"""Not forwarded blindly: the message names the backend, not a transport."""
|
|
74
|
+
link = host.path("root", "link")
|
|
75
|
+
|
|
76
|
+
with pytest.raises(TypeError) as excinfo:
|
|
77
|
+
link.symlink_to("root/target", no_such_option=True)
|
|
78
|
+
|
|
79
|
+
assert "no_such_option" in str(excinfo.value)
|
|
80
|
+
assert "ExtendedMemPath.symlink_to()" in str(excinfo.value)
|
|
81
|
+
assert ExtendedMemPath.calls == []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_no_kwargs_leaves_the_stdlib_call_untouched(host):
|
|
85
|
+
"""The common path must not pay for -- or be changed by -- introspection."""
|
|
86
|
+
host.path("root", "plain").mkdir()
|
|
87
|
+
|
|
88
|
+
assert ("mkdir", {"owner": None}) in ExtendedMemPath.calls
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_force_reaches_a_real_backend_end_to_end(tmp_path):
|
|
92
|
+
"""`symlink_to(force=)` over a genuine backend, no fake in the way.
|
|
93
|
+
|
|
94
|
+
As of pathlib_next 0.9.0+, `force=` is a generic `Path` extension rather
|
|
95
|
+
than a backend-specific one, so this exercises the whole chain --
|
|
96
|
+
composite wrapper, signature check, real filesystem -- which is the call
|
|
97
|
+
that raised `TypeError` before the passthrough existed.
|
|
98
|
+
"""
|
|
99
|
+
from pathlib_next import Path as PnPath
|
|
100
|
+
|
|
101
|
+
if "force" not in inspect.signature(PnPath.symlink_to).parameters:
|
|
102
|
+
pytest.skip("installed pathlib_next predates symlink_to(force=)")
|
|
103
|
+
|
|
104
|
+
provider = PathProvider(
|
|
105
|
+
"local",
|
|
106
|
+
lambda *p: PnPath(*p),
|
|
107
|
+
capabilities=PathProvider.DEFAULT_CAPABILITIES | {"symlink_to", "readlink"},
|
|
108
|
+
)
|
|
109
|
+
host = PosixHost(path_providers=(provider,))
|
|
110
|
+
target = tmp_path / "target"
|
|
111
|
+
target.write_text("t")
|
|
112
|
+
link = tmp_path / "link"
|
|
113
|
+
link.write_text("occupied") # force= has to displace this
|
|
114
|
+
|
|
115
|
+
host.path(str(link).replace("\\", "/")).symlink_to(
|
|
116
|
+
str(target).replace("\\", "/"), force=True
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert link.is_symlink()
|
|
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
|
|
File without changes
|
|
File without changes
|