hostctl 0.1.0__py3-none-any.whl
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/AGENTS.md +292 -0
- hostctl/README.md +248 -0
- hostctl/__init__.py +194 -0
- hostctl/__main__.py +6 -0
- hostctl/_async.py +169 -0
- hostctl/_cli.py +232 -0
- hostctl/executor/__init__.py +82 -0
- hostctl/executor/_common.py +234 -0
- hostctl/executor/_qga.py +646 -0
- hostctl/executor/container.py +171 -0
- hostctl/executor/local.py +91 -0
- hostctl/executor/psrp.py +145 -0
- hostctl/executor/qemu.py +305 -0
- hostctl/executor/serial.py +312 -0
- hostctl/executor/ssh.py +226 -0
- hostctl/executor/winrm.py +255 -0
- hostctl/host/__init__.py +93 -0
- hostctl/host/_common.py +804 -0
- hostctl/host/_local.py +258 -0
- hostctl/host/_ssh.py +587 -0
- hostctl/host/_winrm.py +1002 -0
- hostctl/host/composite_path.py +567 -0
- hostctl/host/container.py +606 -0
- hostctl/host/container_path.py +664 -0
- hostctl/host/qemu.py +1078 -0
- hostctl/host/serial.py +401 -0
- hostctl/host/system.py +809 -0
- hostctl/process/__init__.py +49 -0
- hostctl/process/_common.py +113 -0
- hostctl/process/container.py +265 -0
- hostctl/process/psrp.py +208 -0
- hostctl/process/qemu_serial.py +247 -0
- hostctl/process/serial.py +257 -0
- hostctl/process/ssh.py +165 -0
- hostctl/provider/__init__.py +33 -0
- hostctl/provider/_common.py +452 -0
- hostctl/provider/transports.py +303 -0
- hostctl/py.typed +0 -0
- hostctl/serial/__init__.py +285 -0
- hostctl/shell/__init__.py +123 -0
- hostctl/shell/_common.py +616 -0
- hostctl/shell/cmd.py +167 -0
- hostctl/shell/fish.py +63 -0
- hostctl/shell/posix.py +83 -0
- hostctl/shell/powershell.py +120 -0
- hostctl/sync.py +245 -0
- hostctl-0.1.0.dist-info/METADATA +302 -0
- hostctl-0.1.0.dist-info/RECORD +51 -0
- hostctl-0.1.0.dist-info/WHEEL +4 -0
- hostctl-0.1.0.dist-info/entry_points.txt +2 -0
- hostctl-0.1.0.dist-info/licenses/LICENSE +21 -0
hostctl/shell/cmd.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Windows CMD/BAT shell flavour."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import typing
|
|
8
|
+
from pathlib import PureWindowsPath
|
|
9
|
+
|
|
10
|
+
from ..executor import Environment, PathLike
|
|
11
|
+
from ._common import ShellCommand, ShellFlavour, ShellOperator, ShellToken
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _argument(value: object) -> str:
|
|
15
|
+
"""Quote one argv value for cmd.exe and a C-runtime child program."""
|
|
16
|
+
if isinstance(value, os.PathLike):
|
|
17
|
+
value = os.fspath(value)
|
|
18
|
+
elif isinstance(value, bytes):
|
|
19
|
+
value = value.decode("utf-8", "surrogateescape")
|
|
20
|
+
value = str(value)
|
|
21
|
+
if value and not any(char.isspace() or char in '&|<>()@^"%!' for char in value):
|
|
22
|
+
return value
|
|
23
|
+
value = value.replace("^", "^^").replace("%", "^%").replace("!", "^!")
|
|
24
|
+
for character in "&|<>()":
|
|
25
|
+
value = value.replace(character, f"^{character}")
|
|
26
|
+
escaped = []
|
|
27
|
+
backslashes = 0
|
|
28
|
+
for char in value:
|
|
29
|
+
if char == "\\":
|
|
30
|
+
backslashes += 1
|
|
31
|
+
elif char == '"':
|
|
32
|
+
# The caret preserves the quote through cmd.exe; the extra
|
|
33
|
+
# backslash makes the child C argv parser retain it as data.
|
|
34
|
+
escaped.append("\\" * (backslashes * 2 + 1) + '^"')
|
|
35
|
+
backslashes = 0
|
|
36
|
+
else:
|
|
37
|
+
escaped.append("\\" * backslashes + char)
|
|
38
|
+
backslashes = 0
|
|
39
|
+
escaped.append("\\" * (backslashes * 2))
|
|
40
|
+
# The delimiters must survive cmd.exe so the child C runtime, rather
|
|
41
|
+
# than cmd itself, consumes them as argv quoting.
|
|
42
|
+
return '^"' + "".join(escaped) + '^"'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _builtin_argument(value: object) -> str:
|
|
46
|
+
"""Escape data for a cmd.exe builtin, which has no C argv parser."""
|
|
47
|
+
if isinstance(value, os.PathLike):
|
|
48
|
+
value = os.fspath(value)
|
|
49
|
+
elif isinstance(value, bytes):
|
|
50
|
+
value = value.decode("utf-8", "surrogateescape")
|
|
51
|
+
text = str(value).replace("^", "^^")
|
|
52
|
+
for character in '&|<>()@^"%!':
|
|
53
|
+
if character == "^":
|
|
54
|
+
continue
|
|
55
|
+
text = text.replace(character, f"^{character}")
|
|
56
|
+
return text
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CmdShellFlavour(ShellFlavour):
|
|
60
|
+
"""Windows ``cmd.exe`` and BAT-compatible command construction."""
|
|
61
|
+
|
|
62
|
+
name = "cmd"
|
|
63
|
+
default_executable = "cmd.exe"
|
|
64
|
+
command_separator = "&"
|
|
65
|
+
path_flavor = PureWindowsPath
|
|
66
|
+
info_script = (
|
|
67
|
+
"echo hostname=%COMPUTERNAME%&"
|
|
68
|
+
"echo os_family=windows&"
|
|
69
|
+
"echo os_name=%OS%&"
|
|
70
|
+
"echo architecture=%PROCESSOR_ARCHITECTURE%"
|
|
71
|
+
)
|
|
72
|
+
builtins = frozenset(
|
|
73
|
+
(
|
|
74
|
+
"assoc",
|
|
75
|
+
"break",
|
|
76
|
+
"call",
|
|
77
|
+
"cd",
|
|
78
|
+
"chdir",
|
|
79
|
+
"cls",
|
|
80
|
+
"color",
|
|
81
|
+
"copy",
|
|
82
|
+
"date",
|
|
83
|
+
"del",
|
|
84
|
+
"dir",
|
|
85
|
+
"echo",
|
|
86
|
+
"endlocal",
|
|
87
|
+
"erase",
|
|
88
|
+
"exit",
|
|
89
|
+
"md",
|
|
90
|
+
"mkdir",
|
|
91
|
+
"mklink",
|
|
92
|
+
"move",
|
|
93
|
+
"path",
|
|
94
|
+
"pause",
|
|
95
|
+
"popd",
|
|
96
|
+
"prompt",
|
|
97
|
+
"pushd",
|
|
98
|
+
"rd",
|
|
99
|
+
"ren",
|
|
100
|
+
"rename",
|
|
101
|
+
"rmdir",
|
|
102
|
+
"set",
|
|
103
|
+
"setlocal",
|
|
104
|
+
"shift",
|
|
105
|
+
"start",
|
|
106
|
+
"time",
|
|
107
|
+
"title",
|
|
108
|
+
"type",
|
|
109
|
+
"ver",
|
|
110
|
+
"verify",
|
|
111
|
+
"vol",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def quote(self, value: object) -> str:
|
|
116
|
+
return _argument(self._text(value))
|
|
117
|
+
|
|
118
|
+
def structured_command(self, values: typing.Iterable[object]) -> str:
|
|
119
|
+
values = tuple(values)
|
|
120
|
+
if values and self._text(values[0]).casefold() in self.builtins:
|
|
121
|
+
return " ".join(_builtin_argument(self._text(value)) for value in values)
|
|
122
|
+
return super().structured_command(values)
|
|
123
|
+
|
|
124
|
+
def operator(self, value: ShellOperator) -> str:
|
|
125
|
+
return {
|
|
126
|
+
ShellOperator.PIPE: "|",
|
|
127
|
+
ShellOperator.AND: "&&",
|
|
128
|
+
ShellOperator.OR: "||",
|
|
129
|
+
ShellOperator.REDIRECT: ">",
|
|
130
|
+
ShellOperator.APPEND: ">>",
|
|
131
|
+
ShellOperator.SEQUENCE: self.command_separator,
|
|
132
|
+
}[value]
|
|
133
|
+
|
|
134
|
+
def environment_assignment(self, key: str, value: object) -> str:
|
|
135
|
+
value = self._text(value).replace("^", "^^")
|
|
136
|
+
value = value.replace("%", "^%").replace("!", "^!").replace('"', '^"')
|
|
137
|
+
return f'set "{key}={value}"'
|
|
138
|
+
|
|
139
|
+
def change_directory(self, cwd: PathLike) -> str:
|
|
140
|
+
return f"cd /d {_builtin_argument(self._text(cwd))}"
|
|
141
|
+
|
|
142
|
+
def command(
|
|
143
|
+
self,
|
|
144
|
+
cmds: typing.Iterable[ShellToken],
|
|
145
|
+
*,
|
|
146
|
+
executable: typing.Optional[str] = None,
|
|
147
|
+
cwd: typing.Optional[PathLike] = None,
|
|
148
|
+
env: typing.Optional[Environment] = None,
|
|
149
|
+
) -> ShellCommand:
|
|
150
|
+
script = self.script(cmds, cwd=cwd, env=env)
|
|
151
|
+
executable_text = _argument(executable or self.default_executable)
|
|
152
|
+
return ShellCommand(
|
|
153
|
+
f'{executable_text} /d /v:off /s /c "{script}"',
|
|
154
|
+
None,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def invocation(
|
|
158
|
+
self, script: str, *, executable: typing.Optional[str] = None
|
|
159
|
+
) -> typing.Sequence[str]:
|
|
160
|
+
return (
|
|
161
|
+
executable or self.default_executable,
|
|
162
|
+
"/d",
|
|
163
|
+
"/v:off",
|
|
164
|
+
"/s",
|
|
165
|
+
"/c",
|
|
166
|
+
script,
|
|
167
|
+
)
|
hostctl/shell/fish.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Fish shell flavour."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import typing
|
|
8
|
+
from pathlib import Path, PurePath, PurePosixPath
|
|
9
|
+
|
|
10
|
+
from ..executor import Environment, PathLike
|
|
11
|
+
from ._common import ShellCommand, ShellFlavour, ShellOperator, ShellToken
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FishShellFlavour(ShellFlavour):
|
|
15
|
+
name = "fish"
|
|
16
|
+
default_executable = "/usr/bin/fish"
|
|
17
|
+
command_separator = ";"
|
|
18
|
+
info_script = (
|
|
19
|
+
"echo hostname=(hostname);"
|
|
20
|
+
"echo os_family=(uname -s);"
|
|
21
|
+
"echo architecture=(uname -m)"
|
|
22
|
+
)
|
|
23
|
+
path_flavor = PurePosixPath
|
|
24
|
+
|
|
25
|
+
def quote(self, value: object) -> str:
|
|
26
|
+
if isinstance(value, (PurePath, Path)):
|
|
27
|
+
value = value.as_posix()
|
|
28
|
+
return shlex.quote(self._text(value))
|
|
29
|
+
|
|
30
|
+
def operator(self, value: ShellOperator) -> str:
|
|
31
|
+
return {
|
|
32
|
+
ShellOperator.PIPE: "|",
|
|
33
|
+
ShellOperator.AND: "; and ",
|
|
34
|
+
ShellOperator.OR: "; or ",
|
|
35
|
+
ShellOperator.REDIRECT: ">",
|
|
36
|
+
ShellOperator.APPEND: ">>",
|
|
37
|
+
ShellOperator.SEQUENCE: self.command_separator,
|
|
38
|
+
}[value]
|
|
39
|
+
|
|
40
|
+
def environment_assignment(self, key: str, value: object) -> str:
|
|
41
|
+
return f"set -gx {key} {self.quote(value)}"
|
|
42
|
+
|
|
43
|
+
def change_directory(self, cwd: PathLike) -> str:
|
|
44
|
+
return f"cd -- {self.quote(PurePosixPath(cwd).as_posix())}"
|
|
45
|
+
|
|
46
|
+
def command(
|
|
47
|
+
self,
|
|
48
|
+
cmds: typing.Iterable[ShellToken],
|
|
49
|
+
*,
|
|
50
|
+
executable: typing.Optional[str] = None,
|
|
51
|
+
cwd: typing.Optional[PathLike] = None,
|
|
52
|
+
env: typing.Optional[Environment] = None,
|
|
53
|
+
) -> ShellCommand:
|
|
54
|
+
command = self.invocation(
|
|
55
|
+
self.script(cmds, cwd=cwd, env=env),
|
|
56
|
+
executable=executable,
|
|
57
|
+
)
|
|
58
|
+
return ShellCommand(shlex.join(command), None)
|
|
59
|
+
|
|
60
|
+
def invocation(
|
|
61
|
+
self, script: str, *, executable: typing.Optional[str] = None
|
|
62
|
+
) -> typing.Sequence[str]:
|
|
63
|
+
return (executable or self.default_executable, "-c", script)
|
hostctl/shell/posix.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""POSIX shell flavour."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import typing
|
|
8
|
+
from pathlib import Path, PurePath, PurePosixPath
|
|
9
|
+
|
|
10
|
+
from ..executor import Environment, PathLike
|
|
11
|
+
from ._common import ShellCommand, ShellFlavour, ShellOperator, ShellToken
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PosixShellFlavour(ShellFlavour):
|
|
15
|
+
name = "posix"
|
|
16
|
+
default_executable = "/bin/sh"
|
|
17
|
+
command_separator = ";"
|
|
18
|
+
info_script = (
|
|
19
|
+
"printf 'hostname=%s\\n' \"$(hostname 2>/dev/null)\";"
|
|
20
|
+
"printf 'os_family=%s\\n' \"$(uname -s 2>/dev/null)\";"
|
|
21
|
+
"if [ -r /etc/os-release ]; then . /etc/os-release;"
|
|
22
|
+
"printf 'os_name=%s\\n' \"$ID\";"
|
|
23
|
+
"printf 'os_version=%s\\n' \"$VERSION_ID\"; fi;"
|
|
24
|
+
"printf 'architecture=%s\\n' \"$(uname -m 2>/dev/null)\""
|
|
25
|
+
)
|
|
26
|
+
path_flavor = PurePosixPath
|
|
27
|
+
|
|
28
|
+
def quote(self, value: object) -> str:
|
|
29
|
+
if isinstance(value, (PurePath, Path)):
|
|
30
|
+
value = value.as_posix()
|
|
31
|
+
return shlex.quote(self._text(value))
|
|
32
|
+
|
|
33
|
+
def operator(self, value: ShellOperator) -> str:
|
|
34
|
+
return {
|
|
35
|
+
ShellOperator.PIPE: "|",
|
|
36
|
+
ShellOperator.AND: "&&",
|
|
37
|
+
ShellOperator.OR: "||",
|
|
38
|
+
ShellOperator.REDIRECT: ">",
|
|
39
|
+
ShellOperator.APPEND: ">>",
|
|
40
|
+
ShellOperator.SEQUENCE: self.command_separator,
|
|
41
|
+
}[value]
|
|
42
|
+
|
|
43
|
+
def environment_assignment(self, key: str, value: object) -> str:
|
|
44
|
+
return f"export {key}={self.quote(value)}"
|
|
45
|
+
|
|
46
|
+
def change_directory(self, cwd: PathLike) -> str:
|
|
47
|
+
return f"cd -- {self.quote(PurePosixPath(cwd).as_posix())}"
|
|
48
|
+
|
|
49
|
+
def command(
|
|
50
|
+
self,
|
|
51
|
+
cmds: typing.Iterable[ShellToken],
|
|
52
|
+
*,
|
|
53
|
+
executable: typing.Optional[str] = None,
|
|
54
|
+
cwd: typing.Optional[PathLike] = None,
|
|
55
|
+
env: typing.Optional[Environment] = None,
|
|
56
|
+
) -> ShellCommand:
|
|
57
|
+
command = self.invocation(
|
|
58
|
+
self.script(cmds, env=env),
|
|
59
|
+
executable=executable,
|
|
60
|
+
)
|
|
61
|
+
remote_command = shlex.join(command)
|
|
62
|
+
if cwd:
|
|
63
|
+
remote_command = f"{self.change_directory(cwd)}{self.operator(ShellOperator.AND)}{remote_command}"
|
|
64
|
+
return ShellCommand(remote_command, None)
|
|
65
|
+
|
|
66
|
+
def invocation(
|
|
67
|
+
self, script: str, *, executable: typing.Optional[str] = None
|
|
68
|
+
) -> typing.Sequence[str]:
|
|
69
|
+
return (executable or self.default_executable, "-c", script)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BashShellFlavour(PosixShellFlavour):
|
|
73
|
+
"""Bash using the portable POSIX command-construction baseline."""
|
|
74
|
+
|
|
75
|
+
name = "bash"
|
|
76
|
+
default_executable = "/bin/bash"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ZshShellFlavour(PosixShellFlavour):
|
|
80
|
+
"""Zsh using the portable POSIX command-construction baseline."""
|
|
81
|
+
|
|
82
|
+
name = "zsh"
|
|
83
|
+
default_executable = "/bin/zsh"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Windows PowerShell shell flavour."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import typing
|
|
8
|
+
from pathlib import PureWindowsPath
|
|
9
|
+
|
|
10
|
+
from ..executor import Environment, PathLike
|
|
11
|
+
from ._common import ShellCommand, ShellFlavour, ShellOperator, ShellToken
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _literal(value: object) -> str:
|
|
15
|
+
return (
|
|
16
|
+
"'"
|
|
17
|
+
+ str(value)
|
|
18
|
+
.replace("'", "''")
|
|
19
|
+
.replace("‘", "‘‘")
|
|
20
|
+
.replace("’", "’’")
|
|
21
|
+
.replace("‚", "‚‚")
|
|
22
|
+
.replace("‛", "‛‛")
|
|
23
|
+
+ "'"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PowerShellFlavour(ShellFlavour):
|
|
28
|
+
name = "powershell"
|
|
29
|
+
default_executable = "powershell.exe"
|
|
30
|
+
command_separator = ";"
|
|
31
|
+
context_order = ("cwd", "env", "command")
|
|
32
|
+
execution_epilogue = "; exit $LASTEXITCODE"
|
|
33
|
+
structured_command_prefix = "& "
|
|
34
|
+
path_flavor = PureWindowsPath
|
|
35
|
+
info_script = (
|
|
36
|
+
'Write-Output ("hostname=" + [Environment]::MachineName);'
|
|
37
|
+
'Write-Output ("os_family=" + [Environment]::OSVersion.Platform);'
|
|
38
|
+
'Write-Output ("os_name=" + '
|
|
39
|
+
"[System.Runtime.InteropServices.RuntimeInformation]::OSDescription);"
|
|
40
|
+
'Write-Output ("os_version=" + [Environment]::OSVersion.Version);'
|
|
41
|
+
'Write-Output ("architecture=" + '
|
|
42
|
+
"[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
major_version: int = 5,
|
|
48
|
+
executable: typing.Optional[str] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
if major_version == 6 or major_version < 5:
|
|
51
|
+
raise ValueError("supported PowerShell versions are 5 and 7+")
|
|
52
|
+
self.major_version = major_version
|
|
53
|
+
if executable is not None:
|
|
54
|
+
self.default_executable = executable
|
|
55
|
+
elif major_version >= 7:
|
|
56
|
+
self.default_executable = "pwsh"
|
|
57
|
+
self.name = "pwsh"
|
|
58
|
+
|
|
59
|
+
def quote(self, value: object) -> str:
|
|
60
|
+
return _literal(self._text(value))
|
|
61
|
+
|
|
62
|
+
def operator(self, value: ShellOperator) -> str:
|
|
63
|
+
if self.major_version >= 7 and value in (
|
|
64
|
+
ShellOperator.AND,
|
|
65
|
+
ShellOperator.OR,
|
|
66
|
+
):
|
|
67
|
+
return {
|
|
68
|
+
ShellOperator.AND: " && ",
|
|
69
|
+
ShellOperator.OR: " || ",
|
|
70
|
+
}[value]
|
|
71
|
+
try:
|
|
72
|
+
return {
|
|
73
|
+
ShellOperator.PIPE: " | ",
|
|
74
|
+
ShellOperator.REDIRECT: " > ",
|
|
75
|
+
ShellOperator.APPEND: " >> ",
|
|
76
|
+
ShellOperator.SEQUENCE: self.command_separator,
|
|
77
|
+
}[value]
|
|
78
|
+
except KeyError as exc:
|
|
79
|
+
raise NotImplementedError(
|
|
80
|
+
f"{value.name} is not portable to Windows PowerShell"
|
|
81
|
+
) from exc
|
|
82
|
+
|
|
83
|
+
def environment_assignment(self, key: str, value: object) -> str:
|
|
84
|
+
return f"$env:{key}={_literal(self._text(value))}"
|
|
85
|
+
|
|
86
|
+
def change_directory(self, cwd: PathLike) -> str:
|
|
87
|
+
return (
|
|
88
|
+
f"Set-Location -LiteralPath {_literal(self._text(cwd))} -ErrorAction Stop"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def join_cwd(self, changed: str, command: str) -> str:
|
|
92
|
+
# PowerShell 5 has no &&. ErrorAction Stop makes a failed Set-Location
|
|
93
|
+
# terminate the script before the payload is evaluated.
|
|
94
|
+
return f"{changed};{command}"
|
|
95
|
+
|
|
96
|
+
def command(
|
|
97
|
+
self,
|
|
98
|
+
cmds: typing.Iterable[ShellToken],
|
|
99
|
+
*,
|
|
100
|
+
executable: typing.Optional[str] = None,
|
|
101
|
+
cwd: typing.Optional[PathLike] = None,
|
|
102
|
+
env: typing.Optional[Environment] = None,
|
|
103
|
+
) -> ShellCommand:
|
|
104
|
+
script = self.script(cmds, cwd=cwd, env=env)
|
|
105
|
+
command = self.invocation(
|
|
106
|
+
script,
|
|
107
|
+
executable=executable,
|
|
108
|
+
)
|
|
109
|
+
return ShellCommand(subprocess.list2cmdline(command), None)
|
|
110
|
+
|
|
111
|
+
def invocation(
|
|
112
|
+
self, script: str, *, executable: typing.Optional[str] = None
|
|
113
|
+
) -> typing.Sequence[str]:
|
|
114
|
+
return (
|
|
115
|
+
executable or self.default_executable,
|
|
116
|
+
"-NoProfile",
|
|
117
|
+
"-NonInteractive",
|
|
118
|
+
"-Command",
|
|
119
|
+
script,
|
|
120
|
+
)
|
hostctl/sync.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Checksum and progress helpers for :mod:`pathlib_next` copy and sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import typing
|
|
10
|
+
|
|
11
|
+
from pathlib_next.utils.sync import PathAndStat
|
|
12
|
+
|
|
13
|
+
if typing.TYPE_CHECKING:
|
|
14
|
+
from .host import Host
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_REMOTE_ALGORITHMS = frozenset(("md5", "sha1", "sha256", "sha384", "sha512"))
|
|
18
|
+
_HEX_DIGEST = re.compile(r"^[0-9a-fA-F]+$")
|
|
19
|
+
|
|
20
|
+
#: Remote hashing is an optimization, never a requirement. A host which owns
|
|
21
|
+
#: the path but cannot hash it in place -- no such tool, a refused command, an
|
|
22
|
+
#: unparseable answer -- must degrade to reading the content, not fail the
|
|
23
|
+
#: whole sync. Missing files and other path-level errors are deliberately not
|
|
24
|
+
#: absorbed here: those are real answers about the path and must propagate.
|
|
25
|
+
_UNAVAILABLE_REMOTE_TOOL = (
|
|
26
|
+
ValueError,
|
|
27
|
+
OSError,
|
|
28
|
+
NotImplementedError,
|
|
29
|
+
subprocess.SubprocessError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def stat_checksum(entry: PathAndStat) -> tuple[int, float]:
|
|
34
|
+
"""Return the cached size and modification time without reading content.
|
|
35
|
+
|
|
36
|
+
This is rsync's quick check: no content is read and no command is run.
|
|
37
|
+
Two caveats decide whether it suits a given sync.
|
|
38
|
+
|
|
39
|
+
It can *miss* a change which preserves both size and modification time.
|
|
40
|
+
|
|
41
|
+
It can also *report* a change which is not one, on interpreters where
|
|
42
|
+
``pathlib_next.Path.copy()`` preserves ``st_mode`` but not timestamps: a
|
|
43
|
+
file this helper copies lands with a fresh modification time and compares
|
|
44
|
+
unequal on the next run, so the sync never settles. Python 3.14 added a
|
|
45
|
+
stdlib ``Path.copy()`` which does preserve timestamps, and a local path
|
|
46
|
+
resolves to it there, so the same sync converges. Because that difference
|
|
47
|
+
is version- and backend-dependent, use this helper where source
|
|
48
|
+
modification times are meaningful on both sides -- a tree replicated by
|
|
49
|
+
something which preserves them -- and prefer :func:`host_checksum` when
|
|
50
|
+
hostctl's own copies must converge to a no-op on every supported
|
|
51
|
+
interpreter.
|
|
52
|
+
"""
|
|
53
|
+
if entry.stat is None:
|
|
54
|
+
raise FileNotFoundError(entry.path)
|
|
55
|
+
return entry.stat.st_size, entry.stat.st_mtime
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def host_checksum(
|
|
59
|
+
*hosts: Host,
|
|
60
|
+
algorithm: str = "md5",
|
|
61
|
+
chunk_size: int = 1024 * 1024,
|
|
62
|
+
) -> typing.Callable[[PathAndStat], str]:
|
|
63
|
+
"""Build a ``PathSyncer`` checksum using execution beside owned paths.
|
|
64
|
+
|
|
65
|
+
More than one host may be supplied for a cross-host sync. Paths which do
|
|
66
|
+
not belong to any supplied host are hashed through their binary ``open()``
|
|
67
|
+
contract, preserving interoperability with arbitrary ``pathlib_next``
|
|
68
|
+
implementations.
|
|
69
|
+
"""
|
|
70
|
+
normalized = algorithm.casefold().replace("-", "")
|
|
71
|
+
if normalized not in _REMOTE_ALGORITHMS:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"unsupported remote checksum algorithm: {algorithm!r}; "
|
|
74
|
+
f"choose one of {', '.join(sorted(_REMOTE_ALGORITHMS))}"
|
|
75
|
+
)
|
|
76
|
+
if not hosts:
|
|
77
|
+
raise ValueError("host_checksum requires at least one host")
|
|
78
|
+
if chunk_size <= 0:
|
|
79
|
+
raise ValueError("chunk_size must be positive")
|
|
80
|
+
|
|
81
|
+
owners = tuple((host, _host_path_token(host)) for host in hosts)
|
|
82
|
+
|
|
83
|
+
def checksum(entry: PathAndStat) -> str:
|
|
84
|
+
for host, owner_token in owners:
|
|
85
|
+
if _host_owns_path(host, entry.path, owner_token):
|
|
86
|
+
try:
|
|
87
|
+
return _remote_checksum(host, entry.path, normalized)
|
|
88
|
+
except _UNAVAILABLE_REMOTE_TOOL:
|
|
89
|
+
# The host owns the path but cannot hash it in place: the
|
|
90
|
+
# tool is missing, refused, or answered unintelligibly.
|
|
91
|
+
# Reading the content is slower but still correct, so a
|
|
92
|
+
# sync degrades rather than aborting.
|
|
93
|
+
break
|
|
94
|
+
return _stream_checksum(entry.path, normalized, chunk_size)
|
|
95
|
+
|
|
96
|
+
return checksum
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ProgressReader:
|
|
100
|
+
"""Wrap a binary reader and report ``(bytes_read, total_bytes)``."""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
reader: typing.BinaryIO,
|
|
105
|
+
callback: typing.Callable[[int, typing.Optional[int]], None],
|
|
106
|
+
*,
|
|
107
|
+
total: typing.Optional[int] = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
if not callable(callback):
|
|
110
|
+
raise TypeError("callback must be callable")
|
|
111
|
+
self.reader = reader
|
|
112
|
+
self.callback = callback
|
|
113
|
+
self.total = total
|
|
114
|
+
self.bytes_read = 0
|
|
115
|
+
|
|
116
|
+
def read(self, size: int = -1) -> bytes:
|
|
117
|
+
data = self.reader.read(size)
|
|
118
|
+
self.bytes_read += len(data)
|
|
119
|
+
self.callback(self.bytes_read, self.total)
|
|
120
|
+
return data
|
|
121
|
+
|
|
122
|
+
def readable(self) -> bool:
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def closed(self) -> bool:
|
|
127
|
+
return self.reader.closed
|
|
128
|
+
|
|
129
|
+
def close(self) -> None:
|
|
130
|
+
self.reader.close()
|
|
131
|
+
|
|
132
|
+
def __enter__(self) -> ProgressReader:
|
|
133
|
+
self.reader.__enter__()
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
def __exit__(self, exc_type, exc_value, traceback) -> typing.Optional[bool]:
|
|
137
|
+
return self.reader.__exit__(exc_type, exc_value, traceback)
|
|
138
|
+
|
|
139
|
+
def __getattr__(self, name: str) -> object:
|
|
140
|
+
return getattr(self.reader, name)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _remote_checksum(host: Host, path: object, algorithm: str) -> str:
|
|
144
|
+
flavour = host.shell_flavour.name
|
|
145
|
+
if flavour in ("powershell", "pwsh"):
|
|
146
|
+
quote = host.shell_flavour.quote
|
|
147
|
+
script = (
|
|
148
|
+
f"Get-FileHash -LiteralPath {quote(os.fspath(path))} "
|
|
149
|
+
f"-Algorithm {quote(algorithm.upper())} "
|
|
150
|
+
"| Select-Object -ExpandProperty Hash"
|
|
151
|
+
)
|
|
152
|
+
result = host.run(script, encoding="utf-8")
|
|
153
|
+
try:
|
|
154
|
+
return _parse_digest(result.stdout, algorithm)
|
|
155
|
+
except ValueError:
|
|
156
|
+
result = host.run(
|
|
157
|
+
("certutil.exe", "-hashfile", os.fspath(path), algorithm.upper()),
|
|
158
|
+
encoding="utf-8",
|
|
159
|
+
)
|
|
160
|
+
elif flavour == "cmd":
|
|
161
|
+
result = host.run(
|
|
162
|
+
("certutil", "-hashfile", os.fspath(path), algorithm.upper()),
|
|
163
|
+
encoding="utf-8",
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
result = host.run(
|
|
167
|
+
(f"{algorithm}sum", os.fspath(path)),
|
|
168
|
+
encoding="utf-8",
|
|
169
|
+
)
|
|
170
|
+
return _parse_digest(result.stdout, algorithm)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_digest(output: typing.Union[bytes, str, None], algorithm: str) -> str:
|
|
174
|
+
if isinstance(output, bytes):
|
|
175
|
+
output = output.decode("utf-8", "replace")
|
|
176
|
+
expected = hashlib.new(algorithm).digest_size * 2
|
|
177
|
+
for line in (output or "").splitlines():
|
|
178
|
+
fields = line.split()
|
|
179
|
+
if not fields:
|
|
180
|
+
continue
|
|
181
|
+
# Two shapes to accept: certutil spreads the digest across
|
|
182
|
+
# space-separated byte pairs, so the whole line joins into one
|
|
183
|
+
# candidate; md5sum emits "<digest> <path>", so the first field is
|
|
184
|
+
# the candidate. A path may itself look like a digest, hence the
|
|
185
|
+
# first field rather than any field.
|
|
186
|
+
for candidate in ("".join(fields), fields[0]):
|
|
187
|
+
if len(candidate) == expected and _HEX_DIGEST.fullmatch(candidate):
|
|
188
|
+
return candidate.casefold()
|
|
189
|
+
raise ValueError(f"unable to parse {algorithm} checksum from host output")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _stream_checksum(path: object, algorithm: str, chunk_size: int) -> str:
|
|
193
|
+
digest = hashlib.new(algorithm)
|
|
194
|
+
with path.open("rb") as stream:
|
|
195
|
+
while True:
|
|
196
|
+
chunk = stream.read(chunk_size)
|
|
197
|
+
if not chunk:
|
|
198
|
+
break
|
|
199
|
+
digest.update(chunk)
|
|
200
|
+
return digest.hexdigest()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _host_path_token(host: Host) -> typing.Optional[tuple[object, ...]]:
|
|
204
|
+
"""Return the backend identity for a host without using path prefixes."""
|
|
205
|
+
selector = getattr(host, "_path_selector", None)
|
|
206
|
+
if selector is not None:
|
|
207
|
+
return None
|
|
208
|
+
try:
|
|
209
|
+
sample = host.path()
|
|
210
|
+
except (NotImplementedError, RuntimeError, OSError):
|
|
211
|
+
return None
|
|
212
|
+
return _path_token(sample)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _host_owns_path(
|
|
216
|
+
host: Host,
|
|
217
|
+
path: object,
|
|
218
|
+
owner_token: typing.Optional[tuple[object, ...]],
|
|
219
|
+
) -> bool:
|
|
220
|
+
provider = getattr(path, "provider", None)
|
|
221
|
+
selector = getattr(host, "_path_selector", None)
|
|
222
|
+
if provider is not None and selector is not None:
|
|
223
|
+
return any(
|
|
224
|
+
provider is candidate for candidate in getattr(selector, "providers", ())
|
|
225
|
+
)
|
|
226
|
+
return owner_token is not None and _path_token(path) == owner_token
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _path_token(path: object) -> typing.Optional[tuple[object, ...]]:
|
|
230
|
+
provider = getattr(path, "provider", None)
|
|
231
|
+
if provider is not None:
|
|
232
|
+
return ("provider", id(provider))
|
|
233
|
+
|
|
234
|
+
backend_path = getattr(path, "_backend_path", None)
|
|
235
|
+
if backend_path is not None and backend_path is not path:
|
|
236
|
+
return _path_token(backend_path)
|
|
237
|
+
|
|
238
|
+
backend = getattr(path, "backend", None)
|
|
239
|
+
if backend is None:
|
|
240
|
+
return ("local", type(path))
|
|
241
|
+
|
|
242
|
+
container = getattr(backend, "container", None)
|
|
243
|
+
if container is not None:
|
|
244
|
+
return ("container", id(container))
|
|
245
|
+
return ("backend", id(backend))
|