hostctl 0.2.1__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.
Files changed (111) hide show
  1. {hostctl-0.2.1 → hostctl-0.2.3}/CHANGELOG.md +55 -1
  2. {hostctl-0.2.1 → hostctl-0.2.3}/PKG-INFO +4 -4
  3. {hostctl-0.2.1 → hostctl-0.2.3}/pyproject.toml +11 -3
  4. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/composite_path.py +83 -10
  5. hostctl-0.2.3/tests/test_composite_path_backend_kwargs.py +119 -0
  6. {hostctl-0.2.1 → hostctl-0.2.3}/.gitignore +0 -0
  7. {hostctl-0.2.1 → hostctl-0.2.3}/LICENSE +0 -0
  8. {hostctl-0.2.1 → hostctl-0.2.3}/README.md +0 -0
  9. {hostctl-0.2.1 → hostctl-0.2.3}/docs/api/reference.md +0 -0
  10. {hostctl-0.2.1 → hostctl-0.2.3}/docs/changelog.md +0 -0
  11. {hostctl-0.2.1 → hostctl-0.2.3}/docs/guide/cli.md +0 -0
  12. {hostctl-0.2.1 → hostctl-0.2.3}/docs/guide/contracts.md +0 -0
  13. {hostctl-0.2.1 → hostctl-0.2.3}/docs/guide/extending.md +0 -0
  14. {hostctl-0.2.1 → hostctl-0.2.3}/docs/guide/path.md +0 -0
  15. {hostctl-0.2.1 → hostctl-0.2.3}/docs/guide/providers.md +0 -0
  16. {hostctl-0.2.1 → hostctl-0.2.3}/docs/guide/run.md +0 -0
  17. {hostctl-0.2.1 → hostctl-0.2.3}/docs/guide/transfer.md +0 -0
  18. {hostctl-0.2.1 → hostctl-0.2.3}/docs/index.md +0 -0
  19. {hostctl-0.2.1 → hostctl-0.2.3}/examples/application_provider.py +0 -0
  20. {hostctl-0.2.1 → hostctl-0.2.3}/examples/copy_between_hosts.py +0 -0
  21. {hostctl-0.2.1 → hostctl-0.2.3}/examples/local_run.py +0 -0
  22. {hostctl-0.2.1 → hostctl-0.2.3}/examples/remote_run.py +0 -0
  23. {hostctl-0.2.1 → hostctl-0.2.3}/mkdocs.yml +0 -0
  24. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/AGENTS.md +0 -0
  25. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/__init__.py +0 -0
  26. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/__main__.py +0 -0
  27. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/_async.py +0 -0
  28. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/_cli.py +0 -0
  29. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/executor/__init__.py +0 -0
  30. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/executor/_common.py +0 -0
  31. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/executor/_qga.py +0 -0
  32. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/executor/container.py +0 -0
  33. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/executor/local.py +0 -0
  34. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/executor/psrp.py +0 -0
  35. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/executor/qemu.py +0 -0
  36. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/executor/serial.py +0 -0
  37. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/executor/ssh.py +0 -0
  38. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/executor/winrm.py +0 -0
  39. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/__init__.py +0 -0
  40. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/_common.py +0 -0
  41. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/_connection.py +0 -0
  42. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/_local.py +0 -0
  43. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/_ssh.py +0 -0
  44. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/_winrm.py +0 -0
  45. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/container.py +0 -0
  46. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/container_path.py +0 -0
  47. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/qemu.py +0 -0
  48. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/serial.py +0 -0
  49. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/host/system.py +0 -0
  50. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/process/__init__.py +0 -0
  51. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/process/_common.py +0 -0
  52. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/process/container.py +0 -0
  53. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/process/psrp.py +0 -0
  54. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/process/qemu_serial.py +0 -0
  55. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/process/serial.py +0 -0
  56. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/process/ssh.py +0 -0
  57. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/provider/__init__.py +0 -0
  58. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/provider/_common.py +0 -0
  59. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/provider/transports.py +0 -0
  60. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/py.typed +0 -0
  61. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/serial/__init__.py +0 -0
  62. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/shell/__init__.py +0 -0
  63. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/shell/_common.py +0 -0
  64. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/shell/cmd.py +0 -0
  65. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/shell/fish.py +0 -0
  66. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/shell/posix.py +0 -0
  67. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/shell/powershell.py +0 -0
  68. {hostctl-0.2.1 → hostctl-0.2.3}/src/hostctl/sync.py +0 -0
  69. {hostctl-0.2.1 → hostctl-0.2.3}/tests/conformance/__init__.py +0 -0
  70. {hostctl-0.2.1 → hostctl-0.2.3}/tests/conformance/path_fakes.py +0 -0
  71. {hostctl-0.2.1 → hostctl-0.2.3}/tests/conformance/providers.py +0 -0
  72. {hostctl-0.2.1 → hostctl-0.2.3}/tests/conformance/test_composite_path_routing.py +0 -0
  73. {hostctl-0.2.1 → hostctl-0.2.3}/tests/conformance/test_live.py +0 -0
  74. {hostctl-0.2.1 → hostctl-0.2.3}/tests/conformance/test_path_contract.py +0 -0
  75. {hostctl-0.2.1 → hostctl-0.2.3}/tests/conformance/test_process_contract.py +0 -0
  76. {hostctl-0.2.1 → hostctl-0.2.3}/tests/conformance/test_run_contract.py +0 -0
  77. {hostctl-0.2.1 → hostctl-0.2.3}/tests/conformance/test_sync_contract.py +0 -0
  78. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_application_provider.py +0 -0
  79. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_asyncssh_errors.py +0 -0
  80. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_cli.py +0 -0
  81. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_composite_path_propagation.py +0 -0
  82. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_connection_string.py +0 -0
  83. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_container_path.py +0 -0
  84. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_container_process.py +0 -0
  85. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_exec_command.py +0 -0
  86. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_host_api.py +0 -0
  87. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_host_container.py +0 -0
  88. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_host_local.py +0 -0
  89. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_host_module_layout.py +0 -0
  90. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_host_qemu.py +0 -0
  91. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_host_remote.py +0 -0
  92. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_host_winrm.py +0 -0
  93. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_observability.py +0 -0
  94. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_process.py +0 -0
  95. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_provider_fault_injection.py +0 -0
  96. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_psrp.py +0 -0
  97. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_qemu_executor.py +0 -0
  98. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_qemu_path.py +0 -0
  99. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_qemu_serial.py +0 -0
  100. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_qemu_serial_process.py +0 -0
  101. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_qga_ssh_transport.py +0 -0
  102. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_qga_transport.py +0 -0
  103. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_serial_executor.py +0 -0
  104. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_serial_host.py +0 -0
  105. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_serial_live.py +0 -0
  106. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_shell.py +0 -0
  107. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_ssh_process.py +0 -0
  108. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_sync_helpers.py +0 -0
  109. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_system_host_fidelity.py +0 -0
  110. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_system_hosts.py +0 -0
  111. {hostctl-0.2.1 → hostctl-0.2.3}/tests/test_winrm_path.py +0 -0
@@ -7,6 +7,58 @@ 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
+
41
+ ## [0.2.2] - 2026-07-29
42
+
43
+ ### Changed
44
+
45
+ - Dependency ranges widened to admit `pathlib_next` 0.9 and `netimps` 0.2:
46
+ `pathlib_next>=0.8.6,<0.10` and `netimps>=0.1,<0.3`, with the `ssh` extra's
47
+ `pathlib_next[sftp-async]` bound moved to `<0.10` alongside it. The
48
+ previous `netimps<0.2` also conflicted with `pathlib_next` 0.9's own `uri`
49
+ extra, which requires `netimps>=0.2.0`.
50
+
51
+ The floors stay where they were rather than rising to the new minors:
52
+ hostctl uses no API added in either release — its three netimps functions
53
+ (`get_default_port`, `is_local_address`, `try_parse`) all exist in 0.1, and
54
+ `ProgressReader` is hostctl's own wrapper, unrelated to pathlib_next 0.9's
55
+ native `copy(progress=)`. A floor demanding versions the code does not need
56
+ would exclude working installs for nothing. The ceilings span two minors
57
+ because both projects are pre-1.0, where a minor may break.
58
+
59
+ Verified against both ends of the range: the suite passes with
60
+ `pathlib_next` 0.8.6 and 0.9.0, each alongside `netimps` 0.2.0.
61
+
10
62
  ## [0.2.1] - 2026-07-29
11
63
 
12
64
  ### Added
@@ -292,7 +344,9 @@ test suite on Python 3.9 through 3.14.
292
344
  assigned to it; a config-less host now builds its own family configuration
293
345
  instead.
294
346
 
295
- [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.1...HEAD
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
349
+ [0.2.2]: https://github.com/jose-pr/hostctl/compare/v0.2.1...v0.2.2
296
350
  [0.2.1]: https://github.com/jose-pr/hostctl/compare/v0.2.0...v0.2.1
297
351
  [0.2.0]: https://github.com/jose-pr/hostctl/compare/v0.1.2...v0.2.0
298
352
  [0.1.2]: https://github.com/jose-pr/hostctl/compare/v0.1.0...v0.1.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hostctl
3
- Version: 0.2.1
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/
@@ -24,8 +24,8 @@ Classifier: Topic :: System :: Filesystems
24
24
  Classifier: Topic :: System :: Systems Administration
25
25
  Classifier: Typing :: Typed
26
26
  Requires-Python: >=3.9
27
- Requires-Dist: netimps<0.2,>=0.1
28
- Requires-Dist: pathlib-next<0.9,>=0.8.6
27
+ Requires-Dist: netimps<0.3,>=0.1
28
+ Requires-Dist: pathlib-next<0.10,>=0.8.6
29
29
  Provides-Extra: container
30
30
  Requires-Dist: docker<8,>=7.1; extra == 'container'
31
31
  Provides-Extra: dev
@@ -44,7 +44,7 @@ Provides-Extra: serial
44
44
  Requires-Dist: pyserial<4,>=3.5; extra == 'serial'
45
45
  Provides-Extra: ssh
46
46
  Requires-Dist: asyncssh; extra == 'ssh'
47
- Requires-Dist: pathlib-next[sftp-async]<0.9,>=0.8.4; extra == 'ssh'
47
+ Requires-Dist: pathlib-next[sftp-async]<0.10,>=0.8.4; extra == 'ssh'
48
48
  Provides-Extra: winrm
49
49
  Requires-Dist: pywinrm<0.6,>=0.5.0; extra == 'winrm'
50
50
  Provides-Extra: winrm-credssp
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hostctl"
7
- version = "0.2.1"
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"
@@ -33,12 +33,20 @@ classifiers = [
33
33
  # services database knows (ws/wss, socks5) -- so hostctl does not reimplement
34
34
  # them. Transport implementations remain extras, so importing hostctl never
35
35
  # requires them.
36
- dependencies = ["pathlib_next>=0.8.6,<0.9", "netimps>=0.1,<0.2"]
36
+ #
37
+ # Both ranges span two minors deliberately: hostctl uses no API added in
38
+ # pathlib_next 0.9 or netimps 0.2, so raising the floor would exclude working
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. 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).
44
+ dependencies = ["pathlib_next>=0.8.6,<0.10", "netimps>=0.1,<0.3"]
37
45
 
38
46
  [project.optional-dependencies]
39
47
  # SSH-backed run()/path(): asyncssh for shell exec, pathlib_next's SftpPath
40
48
  # (asyncssh-only since 0.8.2) for the remote path backend.
41
- ssh = ["asyncssh", "pathlib_next[sftp-async]>=0.8.4,<0.9"]
49
+ ssh = ["asyncssh", "pathlib_next[sftp-async]>=0.8.4,<0.10"]
42
50
  # Windows Remote Management command execution.
43
51
  winrm = ["pywinrm>=0.5.0,<0.6"]
44
52
  # PowerShell Remoting Protocol runspaces (Python 3.10+ only). The marker
@@ -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(mode=mode, parents=parents, exist_ok=exist_ok),
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(mode, follow_symlinks=follow_symlinks),
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
- return method(logical_target, target_is_directory)
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", lambda path: path.unlink(missing_ok=missing_ok), pin=True
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("rmdir", lambda path: path.rmdir(), pin=True)
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