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.
- {hostctl-0.2.4 → hostctl-0.2.5}/CHANGELOG.md +32 -1
- {hostctl-0.2.4 → hostctl-0.2.5}/PKG-INFO +1 -1
- {hostctl-0.2.4 → hostctl-0.2.5}/pyproject.toml +1 -1
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/__init__.py +6 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/serial.py +1 -1
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/providers.py +58 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_sync_contract.py +12 -6
- hostctl-0.2.5/tests/test_executor_exports.py +85 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/.gitignore +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/LICENSE +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/README.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/docs/api/reference.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/docs/changelog.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/cli.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/contracts.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/extending.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/path.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/providers.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/run.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/docs/guide/transfer.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/docs/index.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/examples/application_provider.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/examples/local_run.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/examples/remote_run.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/mkdocs.yml +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/AGENTS.md +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/__main__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/_async.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/_cli.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/_common.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/container.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/local.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/psrp.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/qemu.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/ssh.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/executor/winrm.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/_common.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/_connection.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/_ssh.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/_winrm.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/composite_path.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/container.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/container_path.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/qemu.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/host/system.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/container.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/psrp.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/process/ssh.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/provider/_common.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/provider/transports.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/py.typed +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/_common.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/cmd.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/fish.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/posix.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/shell/powershell.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/src/hostctl/sync.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/__init__.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_live.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_path_contract.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_process_contract.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/conformance/test_run_contract.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_application_provider.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_cli.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_composite_path_backend_kwargs.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_composite_path_inherited_operations.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_composite_path_propagation.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_connection_string.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_container_path.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_container_process.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_exec_command.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_api.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_container.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_local.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_qemu.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_remote.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_host_winrm.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_observability.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_process.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_provider_fault_injection.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_psrp.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qemu_path.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_qga_transport.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_serial_executor.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_serial_host.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_serial_live.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_shell.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_ssh_process.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.2.4 → hostctl-0.2.5}/tests/test_system_hosts.py +0 -0
- {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.
|
|
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.
|
|
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/
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|