hostctl 0.2.4__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.4 → hostctl-0.2.5}/CHANGELOG.md +32 -1
  2. {hostctl-0.2.4 → hostctl-0.2.5}/PKG-INFO +1 -1
  3. {hostctl-0.2.4 → hostctl-0.2.5}/pyproject.toml +1 -1
  4. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/__init__.py +6 -0
  5. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/serial.py +1 -1
  6. {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/providers.py +58 -0
  7. {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_sync_contract.py +12 -6
  8. hostctl-0.2.5/tests/test_executor_exports.py +85 -0
  9. {hostctl-0.2.4 → hostctl-0.2.5}/.gitignore +0 -0
  10. {hostctl-0.2.4 → hostctl-0.2.5}/LICENSE +0 -0
  11. {hostctl-0.2.4 → hostctl-0.2.5}/README.md +0 -0
  12. {hostctl-0.2.4 → hostctl-0.2.5}/docs/api/reference.md +0 -0
  13. {hostctl-0.2.4 → hostctl-0.2.5}/docs/changelog.md +0 -0
  14. {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/cli.md +0 -0
  15. {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/contracts.md +0 -0
  16. {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/extending.md +0 -0
  17. {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/path.md +0 -0
  18. {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/providers.md +0 -0
  19. {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/run.md +0 -0
  20. {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/transfer.md +0 -0
  21. {hostctl-0.2.4 → hostctl-0.2.5}/docs/index.md +0 -0
  22. {hostctl-0.2.4 → hostctl-0.2.5}/examples/application_provider.py +0 -0
  23. {hostctl-0.2.4 → hostctl-0.2.5}/examples/copy_between_hosts.py +0 -0
  24. {hostctl-0.2.4 → hostctl-0.2.5}/examples/local_run.py +0 -0
  25. {hostctl-0.2.4 → hostctl-0.2.5}/examples/remote_run.py +0 -0
  26. {hostctl-0.2.4 → hostctl-0.2.5}/mkdocs.yml +0 -0
  27. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/AGENTS.md +0 -0
  28. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/__init__.py +0 -0
  29. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/__main__.py +0 -0
  30. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/_async.py +0 -0
  31. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/_cli.py +0 -0
  32. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/_common.py +0 -0
  33. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/_qga.py +0 -0
  34. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/container.py +0 -0
  35. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/local.py +0 -0
  36. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/psrp.py +0 -0
  37. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/qemu.py +0 -0
  38. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/serial.py +0 -0
  39. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/ssh.py +0 -0
  40. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/winrm.py +0 -0
  41. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/__init__.py +0 -0
  42. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/_common.py +0 -0
  43. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/_connection.py +0 -0
  44. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/_local.py +0 -0
  45. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/_ssh.py +0 -0
  46. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/_winrm.py +0 -0
  47. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/composite_path.py +0 -0
  48. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/container.py +0 -0
  49. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/container_path.py +0 -0
  50. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/qemu.py +0 -0
  51. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/system.py +0 -0
  52. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/__init__.py +0 -0
  53. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/_common.py +0 -0
  54. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/container.py +0 -0
  55. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/psrp.py +0 -0
  56. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/qemu_serial.py +0 -0
  57. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/serial.py +0 -0
  58. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/ssh.py +0 -0
  59. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/provider/__init__.py +0 -0
  60. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/provider/_common.py +0 -0
  61. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/provider/transports.py +0 -0
  62. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/py.typed +0 -0
  63. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/serial/__init__.py +0 -0
  64. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/__init__.py +0 -0
  65. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/_common.py +0 -0
  66. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/cmd.py +0 -0
  67. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/fish.py +0 -0
  68. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/posix.py +0 -0
  69. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/powershell.py +0 -0
  70. {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/sync.py +0 -0
  71. {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/__init__.py +0 -0
  72. {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/path_fakes.py +0 -0
  73. {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_composite_path_routing.py +0 -0
  74. {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_live.py +0 -0
  75. {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_path_contract.py +0 -0
  76. {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_process_contract.py +0 -0
  77. {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_run_contract.py +0 -0
  78. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_application_provider.py +0 -0
  79. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_asyncssh_errors.py +0 -0
  80. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_cli.py +0 -0
  81. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_composite_path_backend_kwargs.py +0 -0
  82. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_composite_path_inherited_operations.py +0 -0
  83. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_composite_path_propagation.py +0 -0
  84. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_connection_string.py +0 -0
  85. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_container_path.py +0 -0
  86. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_container_process.py +0 -0
  87. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_exec_command.py +0 -0
  88. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_api.py +0 -0
  89. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_container.py +0 -0
  90. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_local.py +0 -0
  91. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_module_layout.py +0 -0
  92. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_qemu.py +0 -0
  93. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_remote.py +0 -0
  94. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_winrm.py +0 -0
  95. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_observability.py +0 -0
  96. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_process.py +0 -0
  97. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_provider_fault_injection.py +0 -0
  98. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_psrp.py +0 -0
  99. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qemu_executor.py +0 -0
  100. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qemu_path.py +0 -0
  101. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qemu_serial.py +0 -0
  102. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qemu_serial_process.py +0 -0
  103. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qga_ssh_transport.py +0 -0
  104. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qga_transport.py +0 -0
  105. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_serial_executor.py +0 -0
  106. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_serial_host.py +0 -0
  107. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_serial_live.py +0 -0
  108. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_shell.py +0 -0
  109. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_ssh_process.py +0 -0
  110. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_sync_helpers.py +0 -0
  111. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_system_host_fidelity.py +0 -0
  112. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_system_hosts.py +0 -0
  113. {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_winrm_path.py +0 -0
@@ -7,6 +7,36 @@ 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
+
10
40
  ## [0.2.4] - 2026-08-05
11
41
 
12
42
  ### Fixed
@@ -397,7 +427,8 @@ test suite on Python 3.9 through 3.14.
397
427
  assigned to it; a config-less host now builds its own family configuration
398
428
  instead.
399
429
 
400
- [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.4...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
401
432
  [0.2.4]: https://github.com/jose-pr/hostctl/compare/v0.2.3...v0.2.4
402
433
  [0.2.3]: https://github.com/jose-pr/hostctl/compare/v0.2.2...v0.2.3
403
434
  [0.2.2]: https://github.com/jose-pr/hostctl/compare/v0.2.1...v0.2.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hostctl
3
- Version: 0.2.4
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.4"
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"
@@ -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",
@@ -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:
@@ -15,7 +15,12 @@ from pathlib_next.utils.sync import PathAndStat, PathSyncer, SyncEvent
15
15
 
16
16
  from hostctl.sync import host_checksum, stat_checksum
17
17
 
18
- from .providers import conformance_path, fake_providers, provider_context
18
+ from .providers import (
19
+ conformance_path,
20
+ conformance_utime,
21
+ fake_providers,
22
+ provider_context,
23
+ )
19
24
 
20
25
  _PATH_PROVIDERS = tuple(
21
26
  provider for provider in fake_providers() if "path" in provider.capabilities
@@ -144,6 +149,11 @@ def test_path_syncer_skips_files_whose_stat_already_matches(provider, tmp_path):
144
149
  # Build a target entry whose stat matches its source exactly.
145
150
  (source / "same.txt").write_bytes(b"identical")
146
151
  (target / "same.txt").write_bytes(b"identical")
152
+ # Two writes land at whatever mtime the clock gave them, so align the
153
+ # pair explicitly rather than skipping when they happen to differ.
154
+ pinned = stat_checksum(PathAndStat(source / "same.txt"))[1]
155
+ for side in (source, target):
156
+ conformance_utime(host, provider, side / "same.txt", (pinned, pinned))
147
157
  matching = stat_checksum(PathAndStat(source / "same.txt")) == stat_checksum(
148
158
  PathAndStat(target / "same.txt")
149
159
  )
@@ -174,8 +184,6 @@ def test_stat_checksum_does_not_converge_after_a_copy(provider, tmp_path):
174
184
  convergence and "a second pass would copy again" are exact opposites.
175
185
  """
176
186
 
177
- import os
178
-
179
187
  with provider_context(provider) as host:
180
188
  source = conformance_path(host, provider, tmp_path, "converge-source.bin")
181
189
  target = conformance_path(host, provider, tmp_path, "converge-target.bin")
@@ -184,9 +192,7 @@ def test_stat_checksum_does_not_converge_after_a_copy(provider, tmp_path):
184
192
  # Age the source well beyond any filesystem timestamp granularity, so
185
193
  # the comparison below cannot pass by coincidence of a coarse clock.
186
194
  aged = stat_checksum(PathAndStat(source))[1] - 3600
187
- try:
188
- os.utime(str(source), (aged, aged))
189
- except (OSError, NotImplementedError):
195
+ if not conformance_utime(host, provider, source, (aged, aged)):
190
196
  pytest.skip(f"{provider.name} cannot set timestamps for this check")
191
197
  if stat_checksum(PathAndStat(source))[1] != aged:
192
198
  pytest.skip(f"{provider.name} did not honor the backdated timestamp")
@@ -0,0 +1,85 @@
1
+ """The stream helpers a third-party executor needs are public.
2
+
3
+ `hostctl/AGENTS.md` states that only names in `__all__` are stable and anything
4
+ else "may change without notice". An executor implemented outside hostctl --
5
+ pytruenas 0.4.0's web shell is the real case -- must reproduce hostctl's own
6
+ stdout/stderr and stdin semantics exactly, because a `SystemHost` can dispatch
7
+ the same call through different providers on different attempts. Providers
8
+ that disagree about output handling produce results that differ by which
9
+ transport happened to win.
10
+
11
+ That argument is already written into `normalize_input`'s docstring as the
12
+ reason every executor must share it. These tests pin the corollary: sharing is
13
+ only possible if the helpers are reachable without importing a private module.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import io
19
+
20
+ import hostctl.executor as executor
21
+
22
+
23
+ def test_stream_helpers_are_exported():
24
+ """All four helpers are public, not just `capture_streams`.
25
+
26
+ Regression test for a real gap: `capture_streams` was exported while
27
+ `write_output`, `normalize_input`, and `dispatch_output` -- its neighbours
28
+ in `_common.py`, used on the same call path -- were not, so consumers
29
+ imported them from `hostctl.executor._common`.
30
+ """
31
+
32
+ for name in (
33
+ "capture_streams",
34
+ "dispatch_output",
35
+ "normalize_input",
36
+ "write_output",
37
+ ):
38
+ assert name in executor.__all__, f"{name} is missing from __all__"
39
+ assert callable(getattr(executor, name)), f"{name} is not importable"
40
+
41
+
42
+ def test_exported_helpers_are_the_implementations_themselves():
43
+ """The public names must not be re-wrapped copies of the private ones.
44
+
45
+ A shim would drift from the original, which is the outcome the shared
46
+ helpers exist to prevent.
47
+ """
48
+
49
+ from hostctl.executor import _common
50
+
51
+ for name in (
52
+ "capture_streams",
53
+ "dispatch_output",
54
+ "normalize_input",
55
+ "write_output",
56
+ ):
57
+ assert getattr(executor, name) is getattr(_common, name)
58
+
59
+
60
+ def test_write_output_survives_a_stream_mode_mismatch():
61
+ """Pin the bytes<->str fallback an external executor would have to clone.
62
+
63
+ This is why reimplementing `write_output` is a trap rather than a chore:
64
+ the fallback is invisible until a transport hands text to a binary sink.
65
+ """
66
+
67
+ binary = io.BytesIO()
68
+ executor.write_output(binary, "text into a binary sink", encoding=None, errors=None)
69
+ assert binary.getvalue() == b"text into a binary sink"
70
+
71
+ text = io.StringIO()
72
+ executor.write_output(text, b"bytes into a text sink", encoding=None, errors=None)
73
+ assert text.getvalue() == "bytes into a text sink"
74
+
75
+
76
+ def test_normalize_input_matches_the_stream_mode():
77
+ """Pin the conversion whose absence deadlocks `subprocess`.
78
+
79
+ Handing `bytes` to a text-mode stdin kills the writer thread without
80
+ closing the pipe, so the child never sees EOF and `timeout=` never fires.
81
+ """
82
+
83
+ assert executor.normalize_input(b"payload", text_mode=True) == "payload"
84
+ assert executor.normalize_input("payload", text_mode=False) == b"payload"
85
+ assert executor.normalize_input(None, text_mode=True) is None
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