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.
Files changed (51) hide show
  1. hostctl/AGENTS.md +292 -0
  2. hostctl/README.md +248 -0
  3. hostctl/__init__.py +194 -0
  4. hostctl/__main__.py +6 -0
  5. hostctl/_async.py +169 -0
  6. hostctl/_cli.py +232 -0
  7. hostctl/executor/__init__.py +82 -0
  8. hostctl/executor/_common.py +234 -0
  9. hostctl/executor/_qga.py +646 -0
  10. hostctl/executor/container.py +171 -0
  11. hostctl/executor/local.py +91 -0
  12. hostctl/executor/psrp.py +145 -0
  13. hostctl/executor/qemu.py +305 -0
  14. hostctl/executor/serial.py +312 -0
  15. hostctl/executor/ssh.py +226 -0
  16. hostctl/executor/winrm.py +255 -0
  17. hostctl/host/__init__.py +93 -0
  18. hostctl/host/_common.py +804 -0
  19. hostctl/host/_local.py +258 -0
  20. hostctl/host/_ssh.py +587 -0
  21. hostctl/host/_winrm.py +1002 -0
  22. hostctl/host/composite_path.py +567 -0
  23. hostctl/host/container.py +606 -0
  24. hostctl/host/container_path.py +664 -0
  25. hostctl/host/qemu.py +1078 -0
  26. hostctl/host/serial.py +401 -0
  27. hostctl/host/system.py +809 -0
  28. hostctl/process/__init__.py +49 -0
  29. hostctl/process/_common.py +113 -0
  30. hostctl/process/container.py +265 -0
  31. hostctl/process/psrp.py +208 -0
  32. hostctl/process/qemu_serial.py +247 -0
  33. hostctl/process/serial.py +257 -0
  34. hostctl/process/ssh.py +165 -0
  35. hostctl/provider/__init__.py +33 -0
  36. hostctl/provider/_common.py +452 -0
  37. hostctl/provider/transports.py +303 -0
  38. hostctl/py.typed +0 -0
  39. hostctl/serial/__init__.py +285 -0
  40. hostctl/shell/__init__.py +123 -0
  41. hostctl/shell/_common.py +616 -0
  42. hostctl/shell/cmd.py +167 -0
  43. hostctl/shell/fish.py +63 -0
  44. hostctl/shell/posix.py +83 -0
  45. hostctl/shell/powershell.py +120 -0
  46. hostctl/sync.py +245 -0
  47. hostctl-0.1.0.dist-info/METADATA +302 -0
  48. hostctl-0.1.0.dist-info/RECORD +51 -0
  49. hostctl-0.1.0.dist-info/WHEEL +4 -0
  50. hostctl-0.1.0.dist-info/entry_points.txt +2 -0
  51. 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))