hostctl 0.1.2__tar.gz → 0.2.1__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.1.2 → hostctl-0.2.1}/CHANGELOG.md +109 -1
- {hostctl-0.1.2 → hostctl-0.2.1}/PKG-INFO +18 -1
- {hostctl-0.1.2 → hostctl-0.2.1}/README.md +16 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/docs/api/reference.md +2 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/run.md +105 -2
- {hostctl-0.1.2 → hostctl-0.2.1}/pyproject.toml +7 -4
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/AGENTS.md +14 -3
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/__init__.py +4 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/_cli.py +5 -8
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/_common.py +33 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/container.py +3 -4
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/local.py +2 -15
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/psrp.py +5 -2
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/qemu.py +6 -8
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/ssh.py +2 -1
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/winrm.py +2 -1
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/__init__.py +2 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/_common.py +68 -17
- hostctl-0.2.1/src/hostctl/host/_connection.py +384 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/container.py +1 -7
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/system.py +1 -1
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/_common.py +49 -3
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_live.py +3 -1
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_run_contract.py +15 -17
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_cli.py +6 -4
- hostctl-0.2.1/tests/test_connection_string.py +267 -0
- hostctl-0.2.1/tests/test_exec_command.py +169 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_container.py +2 -2
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_system_hosts.py +3 -2
- {hostctl-0.1.2 → hostctl-0.2.1}/.gitignore +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/LICENSE +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/docs/changelog.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/cli.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/contracts.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/extending.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/path.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/providers.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/transfer.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/docs/index.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/examples/application_provider.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/examples/local_run.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/examples/remote_run.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/mkdocs.yml +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/__main__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/_async.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/_ssh.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/_winrm.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/composite_path.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/container_path.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/qemu.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/serial.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/container.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/psrp.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/ssh.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/provider/_common.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/provider/transports.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/py.typed +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/cmd.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/fish.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/posix.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/powershell.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/sync.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/providers.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_path_contract.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_process_contract.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_sync_contract.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_application_provider.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_composite_path_propagation.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_container_path.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_container_process.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_api.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_local.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_qemu.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_remote.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_winrm.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_observability.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_process.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_provider_fault_injection.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_psrp.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qemu_path.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qga_transport.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_serial_executor.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_serial_host.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_serial_live.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_shell.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_ssh_process.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_winrm_path.py +0 -0
|
@@ -7,6 +7,112 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-07-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `ConnectionString` parses a connection target from whatever a user typed.
|
|
15
|
+
`ConnectionString("nas", scheme="wss")` and `ConnectionString("nas:8443",
|
|
16
|
+
...)` parse, because a bare host is not an invalid URI — it is a URI with
|
|
17
|
+
the scheme left off, which is what people type on a command line.
|
|
18
|
+
|
|
19
|
+
Every field can be supplied directly, and three layers decide each one: an
|
|
20
|
+
explicit argument wins, then whatever the string carried, then `defaults`.
|
|
21
|
+
`scheme=`/`port=`/`host=`/… are therefore *overrides* — `scheme="ssh"`
|
|
22
|
+
beats a `wss://` in the string — while `defaults` (a mapping, or another
|
|
23
|
+
`ConnectionString` used as a profile) fills only what nothing else
|
|
24
|
+
supplied.
|
|
25
|
+
|
|
26
|
+
A port carries its own resolution strategy wherever one is accepted: an
|
|
27
|
+
`int`, a callable `scheme -> port | None`, or anything indexable by scheme
|
|
28
|
+
(a plain mapping), so a caller passes its whole table without pre-selecting
|
|
29
|
+
an entry. When no layer supplies one, `netimps.get_default_port` resolves
|
|
30
|
+
the scheme — it knows schemes no system services database does (`ws`/`wss`,
|
|
31
|
+
`socks5`), and an application can register more with
|
|
32
|
+
`netimps.register_port`.
|
|
33
|
+
|
|
34
|
+
`False` stops the search outright: no port, and no lookup.
|
|
35
|
+
|
|
36
|
+
Credentials are parsed but never rendered. `str()` and `repr()` both emit
|
|
37
|
+
the redacted form, with the password **removed rather than masked**, so the
|
|
38
|
+
output stays a valid, reusable connection string that cannot round-trip a
|
|
39
|
+
wrong credential — and a value reaching a log line or a traceback frame
|
|
40
|
+
cannot leak one. A password may carry `key:value` extras after a newline,
|
|
41
|
+
as `parse_credentials` describes, written raw.
|
|
42
|
+
|
|
43
|
+
The host keeps the spelling it was given. `is_local` resolves nothing — it
|
|
44
|
+
is called while a configuration is built, which is network-free, and
|
|
45
|
+
resolving would both block and raise on a name that does not resolve. An
|
|
46
|
+
address literal is answered by `netimps.is_local_address`, so an address
|
|
47
|
+
actually assigned to this machine counts as local and not just loopback,
|
|
48
|
+
while a name is compared against the loopback spellings. `qsl` and
|
|
49
|
+
`query_val()` read the query; `replace()` returns a changed copy.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- **`netimps` is now a required dependency**, supplying scheme/port and
|
|
54
|
+
address semantics rather than hostctl reimplementing them. Note this
|
|
55
|
+
arrives in a patch release: an existing install pinned within `0.2.x` gains
|
|
56
|
+
a new requirement, so a locked or offline environment needs `netimps`
|
|
57
|
+
available before upgrading.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- A path in a command is rendered through `__fspath__` rather than `str()`.
|
|
62
|
+
Every transport now shares one `command_text` helper — the SSH, WinRM,
|
|
63
|
+
container, QGA, and PSRP executors each stringified with `str()`, so only
|
|
64
|
+
the local executor asked for the filesystem representation. `__fspath__` is
|
|
65
|
+
tried by attribute rather than by `isinstance(value, os.PathLike)`, so a
|
|
66
|
+
duck-typed path that never registered with the ABC is honoured too, and an
|
|
67
|
+
object whose `__fspath__` raises or returns a non-string falls back to
|
|
68
|
+
`str()` rather than failing the command. The shell layer follows the same
|
|
69
|
+
rule. No shipped path type changes behaviour — `str()` and `__fspath__`
|
|
70
|
+
agree for all of them — but the contract now matches what a path promises.
|
|
71
|
+
|
|
72
|
+
## [0.2.0] - 2026-07-28
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- `Exec(program, *args)` marks one command for direct execution: the program
|
|
77
|
+
runs with that argv and no shell layer renders. The program and every
|
|
78
|
+
argument may be a `str`, `bytes`, or a path object — all reach the transport
|
|
79
|
+
as text, so the spelling records how the caller held the value rather than
|
|
80
|
+
changing what runs. A **bare program name is now possible**
|
|
81
|
+
(`Exec("ls", "-l")`), resolved through the target's `PATH`; it previously had
|
|
82
|
+
no spelling at all, because a plain string command is always shell text.
|
|
83
|
+
- The full `run()` command syntax is documented in the "Running commands"
|
|
84
|
+
guide: the varargs rule, the three command forms, operators, validation, and
|
|
85
|
+
the PowerShell rendering.
|
|
86
|
+
|
|
87
|
+
### Changed
|
|
88
|
+
|
|
89
|
+
- **Breaking.** Direct execution is marked by `Exec`, not by position. A path
|
|
90
|
+
in the first argument used to mean "this is the executable, the rest is
|
|
91
|
+
argv"; a path is now an ordinary value that stringifies wherever it appears.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
host.run(Path("/bin/ls"), "-l") # before: direct execution
|
|
95
|
+
host.run(Exec("/bin/ls", "-l")) # now
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This is a **silent** change for the old spelling: `run(Path(...), *args)`
|
|
99
|
+
still succeeds, but the values become several `;`-joined shell commands
|
|
100
|
+
instead of one program and its argv, so arguments are subject to shell
|
|
101
|
+
quoting and word-splitting. Anything passing a leading path must be updated;
|
|
102
|
+
there is no deprecation shim.
|
|
103
|
+
|
|
104
|
+
What it buys, both unspellable before: several path commands in one call
|
|
105
|
+
(`run(p1, p2)` is two commands), and direct execution of a `PATH`-resolved
|
|
106
|
+
name.
|
|
107
|
+
|
|
108
|
+
An `Exec` cannot be combined with other commands in one call — there is no
|
|
109
|
+
shell to join them with, and running only one would silently drop the rest.
|
|
110
|
+
It raises `TypeError` rather than choosing.
|
|
111
|
+
- `hostctl run` passes its operand as a single `Exec`, so the program is used
|
|
112
|
+
verbatim. It no longer converts the operand through the target shell's path
|
|
113
|
+
flavour, which means a bare name now resolves through the target's `PATH`
|
|
114
|
+
instead of being treated as a relative path.
|
|
115
|
+
|
|
10
116
|
## [0.1.2] - 2026-07-28
|
|
11
117
|
|
|
12
118
|
Supersedes 0.1.1, which was tagged but never published: a release-gate test
|
|
@@ -186,6 +292,8 @@ test suite on Python 3.9 through 3.14.
|
|
|
186
292
|
assigned to it; a config-less host now builds its own family configuration
|
|
187
293
|
instead.
|
|
188
294
|
|
|
189
|
-
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.1
|
|
295
|
+
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.1...HEAD
|
|
296
|
+
[0.2.1]: https://github.com/jose-pr/hostctl/compare/v0.2.0...v0.2.1
|
|
297
|
+
[0.2.0]: https://github.com/jose-pr/hostctl/compare/v0.1.2...v0.2.0
|
|
190
298
|
[0.1.2]: https://github.com/jose-pr/hostctl/compare/v0.1.0...v0.1.2
|
|
191
299
|
[0.1.0]: https://github.com/jose-pr/hostctl/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hostctl
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
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,6 +24,7 @@ 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
|
|
27
28
|
Requires-Dist: pathlib-next<0.9,>=0.8.6
|
|
28
29
|
Provides-Extra: container
|
|
29
30
|
Requires-Dist: docker<8,>=7.1; extra == 'container'
|
|
@@ -172,6 +173,22 @@ with Host("ssh://admin:secret@nas.example.com") as same_host:
|
|
|
172
173
|
same_host.run("uptime")
|
|
173
174
|
```
|
|
174
175
|
|
|
176
|
+
`run()` takes one positional argument per command, so a `list` is a single
|
|
177
|
+
command whose elements are each quoted, not a list of commands:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
host.run(["chmod", "755", target]) # one command
|
|
181
|
+
host.run(["chmod", "755", target], ["chown", "u", target]) # two commands
|
|
182
|
+
host.run("chmod", "755", target) # WRONG: three commands
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
A raw string is verbatim shell text, and `Exec(program, *args)` is direct
|
|
186
|
+
execution — one program plus argv, with no shell layer, where the program may
|
|
187
|
+
be an absolute path or a bare name resolved through the target's `PATH`. A
|
|
188
|
+
`ShellOperator` between two commands joins them conditionally. See the
|
|
189
|
+
[running commands guide](https://jose-pr.github.io/hostctl/guide/run/) for the
|
|
190
|
+
full rules.
|
|
191
|
+
|
|
175
192
|
A URI may carry `user:password@host` on the way *in* — it is a valid URI, so
|
|
176
193
|
hostctl accepts it, extracts the password into the credentials, and keeps it
|
|
177
194
|
out of every rendered form. Passing the same password both in the URI and as an
|
|
@@ -118,6 +118,22 @@ with Host("ssh://admin:secret@nas.example.com") as same_host:
|
|
|
118
118
|
same_host.run("uptime")
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
`run()` takes one positional argument per command, so a `list` is a single
|
|
122
|
+
command whose elements are each quoted, not a list of commands:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
host.run(["chmod", "755", target]) # one command
|
|
126
|
+
host.run(["chmod", "755", target], ["chown", "u", target]) # two commands
|
|
127
|
+
host.run("chmod", "755", target) # WRONG: three commands
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A raw string is verbatim shell text, and `Exec(program, *args)` is direct
|
|
131
|
+
execution — one program plus argv, with no shell layer, where the program may
|
|
132
|
+
be an absolute path or a bare name resolved through the target's `PATH`. A
|
|
133
|
+
`ShellOperator` between two commands joins them conditionally. See the
|
|
134
|
+
[running commands guide](https://jose-pr.github.io/hostctl/guide/run/) for the
|
|
135
|
+
full rules.
|
|
136
|
+
|
|
121
137
|
A URI may carry `user:password@host` on the way *in* — it is a valid URI, so
|
|
122
138
|
hostctl accepts it, extracts the password into the credentials, and keeps it
|
|
123
139
|
out of every rendered form. Passing the same password both in the URI and as an
|
|
@@ -39,6 +39,7 @@ without a deprecation cycle.
|
|
|
39
39
|
|
|
40
40
|
## Executors and processes
|
|
41
41
|
|
|
42
|
+
::: hostctl.Exec
|
|
42
43
|
::: hostctl.Executor
|
|
43
44
|
::: hostctl.ExecutionOptions
|
|
44
45
|
::: hostctl.LocalExecutor
|
|
@@ -79,6 +80,7 @@ without a deprecation cycle.
|
|
|
79
80
|
|
|
80
81
|
## Connection strings
|
|
81
82
|
|
|
83
|
+
::: hostctl.ConnectionString
|
|
82
84
|
::: hostctl.redact_uri
|
|
83
85
|
::: hostctl.parse_credentials
|
|
84
86
|
::: hostctl.strict_uri_credentials
|
|
@@ -12,10 +12,113 @@ result = host.run("echo hello")
|
|
|
12
12
|
print(result.stdout)
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
## Command syntax
|
|
16
|
+
|
|
17
|
+
`run(*cmds, **options)` takes **one positional argument per command** — it is
|
|
18
|
+
varargs, not a list of commands. This is the distinction to get right:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
host.run(["chmod", "755", target]) # ONE command
|
|
22
|
+
host.run(["chmod", "755", target], ["chown", "u", target]) # TWO commands
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A list is *one* command whose elements are quoted individually. Wrapping
|
|
26
|
+
commands in an outer list does not produce several commands — the inner lists
|
|
27
|
+
are stringified into a single nonsensical argument:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
host.run([["chmod", "755", target]]) # WRONG: one command, mangled
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The mirror mistake is passing an argv as separate positionals, which makes each
|
|
34
|
+
element its own command:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
host.run("chmod", "755", target) # WRONG: runs chmod;755;/path
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### The three forms a command may take
|
|
41
|
+
|
|
42
|
+
| Form | Example | Result |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| Structured `list`/`tuple` | `["chmod", "755", p]` | `chmod 755 '/tmp/a b'` — every element quoted as data |
|
|
45
|
+
| Raw `str` | `"echo $HOME"` | verbatim shell text; globs, pipes, and `$VAR` are interpreted |
|
|
46
|
+
| `Exec(...)` | `Exec("/bin/ls", "-l", p)` | direct execution: one program plus argv, no shell |
|
|
47
|
+
|
|
48
|
+
Prefer the structured form. It quotes each element, so a value containing a
|
|
49
|
+
space, quote, `$`, or `;` is passed as data instead of changing the command.
|
|
50
|
+
Use a raw string only when you want the shell to interpret the text.
|
|
51
|
+
|
|
52
|
+
Elements may be `str`, `bytes`, `int`, or path objects; all are normalized and
|
|
53
|
+
quoted. `["echo", 42, b"by", PurePosixPath("/p q")]` renders as
|
|
54
|
+
`echo 42 by '/p q'`.
|
|
55
|
+
|
|
56
|
+
### Joining and operators
|
|
57
|
+
|
|
58
|
+
Commands are joined with the flavour's separator (`;` on POSIX). Pass a
|
|
59
|
+
`ShellOperator` *between* two commands to join them conditionally instead:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from hostctl import ShellOperator
|
|
63
|
+
|
|
64
|
+
host.run(["test", "-f", target], ShellOperator.AND, ["rm", target])
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`ShellOperator` provides `AND`, `OR`, `PIPE`, `REDIRECT`, `APPEND`, and
|
|
68
|
+
`SEQUENCE`. An operator that is leading, trailing, or adjacent to another
|
|
69
|
+
raises `ValueError`, and a flavour may reject one it cannot represent
|
|
70
|
+
portably — PowerShell 5 rejects `AND`/`OR`, while PowerShell 7 accepts them.
|
|
71
|
+
|
|
72
|
+
### `Exec` is direct execution
|
|
73
|
+
|
|
74
|
+
`Exec(program, *args)` runs one program with an argv, with no shell layer
|
|
75
|
+
rendered — nothing quotes, splits, or interprets the values:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from hostctl import Exec
|
|
79
|
+
|
|
80
|
+
host.run(Exec("/bin/ls", "-l", target)) # absolute path
|
|
81
|
+
host.run(Exec("ls", "-l")) # resolved through the target's PATH
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The program and each argument may be a `str`, `bytes`, or a path object. All
|
|
85
|
+
of them reach the transport as text, so the spelling records how you happened
|
|
86
|
+
to hold the value rather than changing what runs. A bare name is only possible
|
|
87
|
+
here: a plain string command is always shell text.
|
|
88
|
+
|
|
89
|
+
Arguments are argv values, never nested commands — a list or tuple raises
|
|
90
|
+
`TypeError` rather than blurring the two.
|
|
91
|
+
|
|
92
|
+
Direct execution replaces the whole call, so an `Exec` cannot be combined with
|
|
93
|
+
other commands: there is no shell to join them with, and running just one
|
|
94
|
+
would silently drop the rest. Give each its own call, or use structured
|
|
95
|
+
commands if you want them joined.
|
|
96
|
+
|
|
97
|
+
Everything else is an ordinary value. A path is just a value that
|
|
98
|
+
stringifies, wherever it appears:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
host.run(["chmod", "755", Path("/srv/app")]) # a quoted argument
|
|
102
|
+
host.run(Path("/bin/a"), Path("/bin/b")) # two shell commands
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Validation
|
|
106
|
+
|
|
107
|
+
An empty structured command and any control character (including a newline
|
|
108
|
+
inside a value, which could otherwise smuggle in a second command) raise
|
|
109
|
+
`ValueError`. An empty raw string is skipped when joining.
|
|
110
|
+
|
|
111
|
+
### PowerShell targets
|
|
112
|
+
|
|
113
|
+
PowerShell flavours render a structured command through the `&` call operator
|
|
114
|
+
and append `; exit $LASTEXITCODE` so the remote status propagates:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
& 'Get-Item' 'C:/a b'; exit $LASTEXITCODE
|
|
118
|
+
```
|
|
119
|
+
|
|
15
120
|
`run()` returns a `subprocess.CompletedProcess`. Highlights:
|
|
16
121
|
|
|
17
|
-
- Multiple positional commands are joined with `;`. A command given as a
|
|
18
|
-
`list`/`tuple` is shell-quoted piece by piece.
|
|
19
122
|
- `capture_output` may be `True` (both streams), `"stdout"`, `"stderr"`, or
|
|
20
123
|
`False`.
|
|
21
124
|
- SSH `input=`, `cwd=`, `env=`, `check=`, `timeout=`, and
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hostctl"
|
|
7
|
-
version = "0.1
|
|
7
|
+
version = "0.2.1"
|
|
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"
|
|
@@ -28,9 +28,12 @@ classifiers = [
|
|
|
28
28
|
"Topic :: System :: Filesystems",
|
|
29
29
|
"Typing :: Typed",
|
|
30
30
|
]
|
|
31
|
-
# pathlib_next supplies the common local/remote path return type.
|
|
32
|
-
#
|
|
33
|
-
|
|
31
|
+
# pathlib_next supplies the common local/remote path return type. netimps
|
|
32
|
+
# supplies scheme/port and address semantics -- including schemes no system
|
|
33
|
+
# services database knows (ws/wss, socks5) -- so hostctl does not reimplement
|
|
34
|
+
# them. Transport implementations remain extras, so importing hostctl never
|
|
35
|
+
# requires them.
|
|
36
|
+
dependencies = ["pathlib_next>=0.8.6,<0.9", "netimps>=0.1,<0.2"]
|
|
34
37
|
|
|
35
38
|
[project.optional-dependencies]
|
|
36
39
|
# SSH-backed run()/path(): asyncssh for shell exec, pathlib_next's SftpPath
|
|
@@ -57,9 +57,20 @@ shared protocol implementations can be added once; `Shell.__call__` delegates
|
|
|
57
57
|
to `Shell.execute()`.
|
|
58
58
|
`Host` itself supplies the multi-command `run()` provider contract; there is no
|
|
59
59
|
duplicate host-executor protocol or unused host-options wrapper.
|
|
60
|
-
`
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
`Exec(program, *args)` is the direct-execution marker for `Host.run()`: one
|
|
61
|
+
program plus argv, no shell layer. The program may be a `str`, `bytes`, or a
|
|
62
|
+
path — all reach the transport as text, so a bare name resolves through the
|
|
63
|
+
target's `PATH`. Direct execution replaces the whole call, so an `Exec`
|
|
64
|
+
combined with any other command raises. Everything else is an ordinary value:
|
|
65
|
+
a path is a value that stringifies wherever it appears, so `run(p1, p2)` is two
|
|
66
|
+
shell commands. `Exec` is a deliberately non-iterable frozen dataclass --
|
|
67
|
+
`ShellFlavour.command_text` dispatches structured commands on `Iterable`, and
|
|
68
|
+
an iterable marker would be quoted into an argv instead of taking the direct
|
|
69
|
+
branch; `command_text` raises if an `Exec` reaches it.
|
|
70
|
+
`Shell.execute(command, *args)` keeps its own program-plus-argv signature and
|
|
71
|
+
wraps into an `Exec` only when dispatching to a host `run(*cmds)` and only when
|
|
72
|
+
argv values are present -- `Shell.run` renders every command into one script
|
|
73
|
+
and dispatches it with no args, and that script is shell text, not a program.
|
|
63
74
|
|
|
64
75
|
## Configuration and lifecycle
|
|
65
76
|
|
|
@@ -33,7 +33,9 @@ from .executor import (
|
|
|
33
33
|
)
|
|
34
34
|
from .host import (
|
|
35
35
|
ContainerConfig as ContainerConfig,
|
|
36
|
+
ConnectionString as ConnectionString,
|
|
36
37
|
ContainerHost as ContainerHost,
|
|
38
|
+
Exec as Exec,
|
|
37
39
|
Host as Host,
|
|
38
40
|
HostConfig as HostConfig,
|
|
39
41
|
HostInfo as HostInfo,
|
|
@@ -125,6 +127,7 @@ __all__ = [
|
|
|
125
127
|
"ssh_providers",
|
|
126
128
|
"strict_uri_credentials",
|
|
127
129
|
"strict_uri_query",
|
|
130
|
+
"ConnectionString",
|
|
128
131
|
"uri_host",
|
|
129
132
|
"uri_hostname",
|
|
130
133
|
"winrm_providers",
|
|
@@ -146,6 +149,7 @@ __all__ = [
|
|
|
146
149
|
"IosConfig",
|
|
147
150
|
"IosHost",
|
|
148
151
|
# Executors and processes
|
|
152
|
+
"Exec",
|
|
149
153
|
"Executor",
|
|
150
154
|
"ExecutionOptions",
|
|
151
155
|
"ExecutorCapability",
|
|
@@ -8,7 +8,6 @@ import dataclasses
|
|
|
8
8
|
import getpass
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
|
-
from pathlib import PurePath
|
|
12
11
|
import re
|
|
13
12
|
import subprocess
|
|
14
13
|
import sys
|
|
@@ -17,7 +16,7 @@ import typing
|
|
|
17
16
|
|
|
18
17
|
from pathlib_next import Path as NextPath
|
|
19
18
|
|
|
20
|
-
from .host import Host, HostPath
|
|
19
|
+
from .host import Exec, Host, HostPath
|
|
21
20
|
|
|
22
21
|
_URI_OPERAND = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
23
22
|
|
|
@@ -81,13 +80,11 @@ def _command_run(args: argparse.Namespace, stdout, stderr) -> int:
|
|
|
81
80
|
if not command:
|
|
82
81
|
raise ValueError("run requires a command after --")
|
|
83
82
|
with Host(args.uri, **_credentials(args)) as host:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
command_path = PurePath(command[0])
|
|
83
|
+
# `Exec` marks direct execution and takes the program verbatim, so the
|
|
84
|
+
# operand needs no path flavour: a bare name resolves through the
|
|
85
|
+
# target's PATH and an absolute path is used as given.
|
|
88
86
|
result = host.run(
|
|
89
|
-
|
|
90
|
-
*command[1:],
|
|
87
|
+
Exec(command[0], *command[1:]),
|
|
91
88
|
check=False,
|
|
92
89
|
capture_output=True,
|
|
93
90
|
)
|
|
@@ -94,6 +94,39 @@ def normalize_input(
|
|
|
94
94
|
return input
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
def command_text(
|
|
98
|
+
value: object, *, encoding: str = "utf-8", errors: str = "strict"
|
|
99
|
+
) -> str:
|
|
100
|
+
"""Render one command or argv value as the text a transport sends.
|
|
101
|
+
|
|
102
|
+
Anything offering `__fspath__` is asked for it first: that is the
|
|
103
|
+
filesystem representation a path promises, and it is what a transport
|
|
104
|
+
needs. `str()` happens to match for the path types shipped here, but that
|
|
105
|
+
is a coincidence of their `__str__` rather than a contract -- a path type
|
|
106
|
+
whose `repr`-ish `__str__` differs would otherwise send the wrong text.
|
|
107
|
+
|
|
108
|
+
`__fspath__` is tried directly rather than through
|
|
109
|
+
`isinstance(value, os.PathLike)`, so a duck-typed path that never
|
|
110
|
+
registered with the ABC is still honoured. A object whose `__fspath__`
|
|
111
|
+
raises or returns a non-string falls back to `str()` rather than failing
|
|
112
|
+
the command.
|
|
113
|
+
|
|
114
|
+
Bytes -- returned by `__fspath__` or passed directly -- are decoded;
|
|
115
|
+
everything else is stringified.
|
|
116
|
+
"""
|
|
117
|
+
fspath = getattr(value, "__fspath__", None)
|
|
118
|
+
if callable(fspath):
|
|
119
|
+
try:
|
|
120
|
+
resolved = fspath()
|
|
121
|
+
except Exception:
|
|
122
|
+
resolved = None
|
|
123
|
+
if isinstance(resolved, (str, bytes)):
|
|
124
|
+
value = resolved
|
|
125
|
+
if isinstance(value, bytes):
|
|
126
|
+
return value.decode(encoding, errors)
|
|
127
|
+
return str(value)
|
|
128
|
+
|
|
129
|
+
|
|
97
130
|
def normalize_environment(
|
|
98
131
|
env: typing.Optional[Environment],
|
|
99
132
|
) -> typing.Optional[typing.Dict[str, str]]:
|
|
@@ -7,6 +7,7 @@ import typing
|
|
|
7
7
|
|
|
8
8
|
from ._common import (
|
|
9
9
|
CaptureOutput,
|
|
10
|
+
command_text,
|
|
10
11
|
CommandArgument,
|
|
11
12
|
Environment,
|
|
12
13
|
Executor,
|
|
@@ -79,10 +80,8 @@ class ContainerExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
79
80
|
)
|
|
80
81
|
|
|
81
82
|
stdout, stderr = capture_streams(capture_output, stdout, stderr)
|
|
82
|
-
argv = [
|
|
83
|
-
argv.extend(
|
|
84
|
-
value.decode() if isinstance(value, bytes) else str(value) for value in args
|
|
85
|
-
)
|
|
83
|
+
argv = [command_text(command)]
|
|
84
|
+
argv.extend(command_text(value) for value in args)
|
|
86
85
|
merge_stderr = stderr is subprocess.STDOUT
|
|
87
86
|
exec_options: typing.Dict[str, object] = {
|
|
88
87
|
"stdout": True,
|
|
@@ -8,6 +8,7 @@ import typing
|
|
|
8
8
|
|
|
9
9
|
from ._common import (
|
|
10
10
|
CaptureOutput,
|
|
11
|
+
command_text,
|
|
11
12
|
CommandArgument,
|
|
12
13
|
Environment,
|
|
13
14
|
Executor,
|
|
@@ -52,21 +53,7 @@ class LocalExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
52
53
|
raise TypeError(f"unsupported local executor option: {sorted(options)[0]}")
|
|
53
54
|
reject_stdin_conflict(input, stdin)
|
|
54
55
|
stdout, stderr = capture_streams(capture_output, stdout, stderr)
|
|
55
|
-
argv = [
|
|
56
|
-
os.fspath(command),
|
|
57
|
-
*[
|
|
58
|
-
(
|
|
59
|
-
value.decode()
|
|
60
|
-
if isinstance(value, bytes)
|
|
61
|
-
else (
|
|
62
|
-
os.fspath(value)
|
|
63
|
-
if isinstance(value, os.PathLike)
|
|
64
|
-
else str(value)
|
|
65
|
-
)
|
|
66
|
-
)
|
|
67
|
-
for value in args
|
|
68
|
-
],
|
|
69
|
-
]
|
|
56
|
+
argv = [command_text(command), *(command_text(value) for value in args)]
|
|
70
57
|
return subprocess.run(
|
|
71
58
|
argv,
|
|
72
59
|
bufsize=bufsize,
|
|
@@ -9,6 +9,7 @@ import types
|
|
|
9
9
|
|
|
10
10
|
from ._common import (
|
|
11
11
|
CaptureOutput,
|
|
12
|
+
command_text,
|
|
12
13
|
CommandArgument,
|
|
13
14
|
Executor,
|
|
14
15
|
ExecutorCapability,
|
|
@@ -92,7 +93,9 @@ class PsrpExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
92
93
|
"PSRP pipeline timeout is unsupported; configure WinRM timeouts"
|
|
93
94
|
)
|
|
94
95
|
stdout, stderr = capture_streams(capture_output, stdout, stderr)
|
|
95
|
-
result = self._session().invoke(
|
|
96
|
+
result = self._session().invoke(
|
|
97
|
+
command_text(command), raw=False, capture_exit=True
|
|
98
|
+
)
|
|
96
99
|
codec = encoding or "utf-8"
|
|
97
100
|
# Preserve PowerShell's object-pipeline line orientation when
|
|
98
101
|
# projecting objects to subprocess-compatible text.
|
|
@@ -135,7 +138,7 @@ class PsrpExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
135
138
|
errors=errors,
|
|
136
139
|
)
|
|
137
140
|
completed = subprocess.CompletedProcess(
|
|
138
|
-
args=
|
|
141
|
+
args=command_text(command),
|
|
139
142
|
returncode=returncode,
|
|
140
143
|
stdout=out,
|
|
141
144
|
stderr=err,
|
|
@@ -11,6 +11,7 @@ import typing
|
|
|
11
11
|
from ._qga import GuestAgentTransport, QgaProtocolError
|
|
12
12
|
from ._common import (
|
|
13
13
|
CaptureOutput,
|
|
14
|
+
command_text,
|
|
14
15
|
CommandArgument,
|
|
15
16
|
Environment,
|
|
16
17
|
Executor,
|
|
@@ -113,7 +114,8 @@ class QemuExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
113
114
|
"QGA guest-exec has no native working-directory support"
|
|
114
115
|
)
|
|
115
116
|
reject_stdin_conflict(input, stdin)
|
|
116
|
-
|
|
117
|
+
program = command_text(command)
|
|
118
|
+
if not program:
|
|
117
119
|
raise ValueError("QGA guest-exec path must not be empty")
|
|
118
120
|
if timeout is not None and timeout < 0:
|
|
119
121
|
raise ValueError("timeout must not be negative")
|
|
@@ -122,15 +124,11 @@ class QemuExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
122
124
|
|
|
123
125
|
stdout, stderr = capture_streams(capture_output, stdout, stderr)
|
|
124
126
|
argv = [
|
|
125
|
-
(
|
|
126
|
-
value.decode(encoding or "utf-8", errors or "strict")
|
|
127
|
-
if isinstance(value, bytes)
|
|
128
|
-
else str(value)
|
|
129
|
-
)
|
|
127
|
+
command_text(value, encoding=encoding or "utf-8", errors=errors or "strict")
|
|
130
128
|
for value in args
|
|
131
129
|
]
|
|
132
130
|
arguments: typing.Dict[str, object] = {
|
|
133
|
-
"path":
|
|
131
|
+
"path": program,
|
|
134
132
|
"arg": argv,
|
|
135
133
|
"capture-output": True,
|
|
136
134
|
}
|
|
@@ -156,7 +154,7 @@ class QemuExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
156
154
|
arguments["input-data"] = base64.b64encode(payload).decode("ascii")
|
|
157
155
|
|
|
158
156
|
deadline = None if timeout is None else self._clock() + timeout
|
|
159
|
-
command_display = [
|
|
157
|
+
command_display = [program, *argv]
|
|
160
158
|
started = self._execute(
|
|
161
159
|
{"execute": "guest-exec", "arguments": arguments},
|
|
162
160
|
deadline,
|
|
@@ -10,6 +10,7 @@ import typing
|
|
|
10
10
|
|
|
11
11
|
from ._common import (
|
|
12
12
|
CaptureOutput,
|
|
13
|
+
command_text,
|
|
13
14
|
CommandArgument,
|
|
14
15
|
Environment,
|
|
15
16
|
Executor,
|
|
@@ -135,7 +136,7 @@ class SshExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
135
136
|
raise NotImplementedError("SshExecutor does not support native arguments")
|
|
136
137
|
if bufsize == 0:
|
|
137
138
|
raise ValueError("bufsize=0 is unsupported by the buffered SSH executor")
|
|
138
|
-
command =
|
|
139
|
+
command = command_text(command)
|
|
139
140
|
reject_stdin_conflict(input, stdin)
|
|
140
141
|
if text and encoding is None:
|
|
141
142
|
encoding = "utf-8"
|
|
@@ -9,6 +9,7 @@ import typing
|
|
|
9
9
|
|
|
10
10
|
from ._common import (
|
|
11
11
|
CaptureOutput,
|
|
12
|
+
command_text,
|
|
12
13
|
CommandArgument,
|
|
13
14
|
Executor,
|
|
14
15
|
ExecutorCommand,
|
|
@@ -165,7 +166,7 @@ class WinRMExecutor(Executor[subprocess.CompletedProcess]):
|
|
|
165
166
|
raise TypeError(f"unsupported WinRM executor option: {sorted(options)[0]}")
|
|
166
167
|
if args:
|
|
167
168
|
raise NotImplementedError("WinRMExecutor does not support native arguments")
|
|
168
|
-
command =
|
|
169
|
+
command = command_text(command)
|
|
169
170
|
if timeout is not None:
|
|
170
171
|
raise NotImplementedError(
|
|
171
172
|
"WinRMExecutor timeout is unsupported; configure transport timeouts"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Host contracts and built-in host implementations."""
|
|
2
2
|
|
|
3
3
|
from ._common import (
|
|
4
|
+
Exec as Exec,
|
|
4
5
|
Host as Host,
|
|
5
6
|
HostConfig as HostConfig,
|
|
6
7
|
HostInfo as HostInfo,
|
|
@@ -12,6 +13,7 @@ from ._common import (
|
|
|
12
13
|
uri_host as uri_host,
|
|
13
14
|
uri_hostname as uri_hostname,
|
|
14
15
|
)
|
|
16
|
+
from ._connection import ConnectionString as ConnectionString
|
|
15
17
|
from ._ssh import ssh_providers as ssh_providers
|
|
16
18
|
from ._winrm import winrm_providers as winrm_providers
|
|
17
19
|
from ._local import LocalConfig as LocalConfig, LocalHost as LocalHost
|