optio-host 0.1.0__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.
- optio_host-0.1.0/PKG-INFO +79 -0
- optio_host-0.1.0/README.md +51 -0
- optio_host-0.1.0/pyproject.toml +49 -0
- optio_host-0.1.0/setup.cfg +4 -0
- optio_host-0.1.0/src/optio_host/__init__.py +37 -0
- optio_host-0.1.0/src/optio_host/archive.py +114 -0
- optio_host-0.1.0/src/optio_host/context.py +271 -0
- optio_host-0.1.0/src/optio_host/download.py +372 -0
- optio_host-0.1.0/src/optio_host/host.py +928 -0
- optio_host-0.1.0/src/optio_host/paths.py +81 -0
- optio_host-0.1.0/src/optio_host/protocol/__init__.py +43 -0
- optio_host-0.1.0/src/optio_host/protocol/parser.py +127 -0
- optio_host-0.1.0/src/optio_host/protocol/session.py +321 -0
- optio_host-0.1.0/src/optio_host/types.py +16 -0
- optio_host-0.1.0/src/optio_host.egg-info/PKG-INFO +79 -0
- optio_host-0.1.0/src/optio_host.egg-info/SOURCES.txt +24 -0
- optio_host-0.1.0/src/optio_host.egg-info/dependency_links.txt +1 -0
- optio_host-0.1.0/src/optio_host.egg-info/requires.txt +6 -0
- optio_host-0.1.0/src/optio_host.egg-info/top_level.txt +1 -0
- optio_host-0.1.0/tests/test_archive.py +109 -0
- optio_host-0.1.0/tests/test_context.py +361 -0
- optio_host-0.1.0/tests/test_download.py +546 -0
- optio_host-0.1.0/tests/test_local_host.py +52 -0
- optio_host-0.1.0/tests/test_local_host_stdin.py +76 -0
- optio_host-0.1.0/tests/test_paths.py +74 -0
- optio_host-0.1.0/tests/test_protocol_parser.py +177 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: optio-host
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local-or-remote host abstraction + log/deliverables coordination protocol for optio task types.
|
|
5
|
+
Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/deai-network/optio
|
|
8
|
+
Project-URL: Repository, https://github.com/deai-network/optio
|
|
9
|
+
Project-URL: Issues, https://github.com/deai-network/optio/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
19
|
+
Classifier: Topic :: System :: Systems Administration
|
|
20
|
+
Classifier: Framework :: AsyncIO
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: optio-core<0.2,>=0.1
|
|
24
|
+
Requires-Dist: asyncssh>=2.14
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# optio-host
|
|
30
|
+
|
|
31
|
+
Local-or-remote host abstraction plus the log/deliverables coordination protocol used by optio task types.
|
|
32
|
+
|
|
33
|
+
`optio-host` lets a task author run shell commands, manage workdirs, and stream files **without caring whether the work happens locally or on a remote host over SSH**. It also provides a small line-based protocol that long-running worker processes can use to report progress and produce file deliverables.
|
|
34
|
+
|
|
35
|
+
## What's in the box
|
|
36
|
+
|
|
37
|
+
- **`Host` Protocol + `LocalHost` / `RemoteHost` / `make_host()`** — uniform interface for running commands, opening port forwards, transferring files, and tearing down workdirs. SSH details (auth, multiplexing, channel cleanup) are hidden behind `asyncssh`.
|
|
38
|
+
- **`HookContext`** — small carrier passed into task hooks so they can run additional host commands, request file fetches, and report progress without touching `optio-core` internals.
|
|
39
|
+
- **`optio_host.protocol`** — a line-oriented session driver. A long-running process on the host writes lines prefixed `STATUS:`, `DELIVERABLE:`, `DONE`, or `ERROR`. The driver tails the log, dispatches progress events, fetches deliverable files, and resolves the session on `DONE` / `ERROR`.
|
|
40
|
+
- **`create_download_task(...)`** — a ready-made optio task that downloads a file from a remote host with progress reporting and integrity checks.
|
|
41
|
+
|
|
42
|
+
## When to use it
|
|
43
|
+
|
|
44
|
+
You're building an [optio](https://github.com/deai-network/optio) task type that needs to run work on a host — local or remote — and you want:
|
|
45
|
+
|
|
46
|
+
- one abstraction that works in both modes,
|
|
47
|
+
- a structured way for the running process to talk back to optio (progress + deliverables),
|
|
48
|
+
- SSH transport handled for you.
|
|
49
|
+
|
|
50
|
+
If you're writing the end-user task type directly (not consuming this library from another optio task package), you probably want `optio-core` instead.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install optio-host
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`optio-host` depends on `optio-core` and `asyncssh`. Python 3.11+.
|
|
59
|
+
|
|
60
|
+
## Minimal example
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from optio_host import make_host, SSHConfig
|
|
64
|
+
|
|
65
|
+
# Local
|
|
66
|
+
async with make_host(ssh=None) as host:
|
|
67
|
+
result = await host.run(["uname", "-a"])
|
|
68
|
+
print(result.stdout)
|
|
69
|
+
|
|
70
|
+
# Remote
|
|
71
|
+
ssh = SSHConfig(host="worker-1", user="optio", key_path="~/.ssh/id_optio")
|
|
72
|
+
async with make_host(ssh=ssh) as host:
|
|
73
|
+
result = await host.run(["uname", "-a"])
|
|
74
|
+
print(result.stdout)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
Apache-2.0.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# optio-host
|
|
2
|
+
|
|
3
|
+
Local-or-remote host abstraction plus the log/deliverables coordination protocol used by optio task types.
|
|
4
|
+
|
|
5
|
+
`optio-host` lets a task author run shell commands, manage workdirs, and stream files **without caring whether the work happens locally or on a remote host over SSH**. It also provides a small line-based protocol that long-running worker processes can use to report progress and produce file deliverables.
|
|
6
|
+
|
|
7
|
+
## What's in the box
|
|
8
|
+
|
|
9
|
+
- **`Host` Protocol + `LocalHost` / `RemoteHost` / `make_host()`** — uniform interface for running commands, opening port forwards, transferring files, and tearing down workdirs. SSH details (auth, multiplexing, channel cleanup) are hidden behind `asyncssh`.
|
|
10
|
+
- **`HookContext`** — small carrier passed into task hooks so they can run additional host commands, request file fetches, and report progress without touching `optio-core` internals.
|
|
11
|
+
- **`optio_host.protocol`** — a line-oriented session driver. A long-running process on the host writes lines prefixed `STATUS:`, `DELIVERABLE:`, `DONE`, or `ERROR`. The driver tails the log, dispatches progress events, fetches deliverable files, and resolves the session on `DONE` / `ERROR`.
|
|
12
|
+
- **`create_download_task(...)`** — a ready-made optio task that downloads a file from a remote host with progress reporting and integrity checks.
|
|
13
|
+
|
|
14
|
+
## When to use it
|
|
15
|
+
|
|
16
|
+
You're building an [optio](https://github.com/deai-network/optio) task type that needs to run work on a host — local or remote — and you want:
|
|
17
|
+
|
|
18
|
+
- one abstraction that works in both modes,
|
|
19
|
+
- a structured way for the running process to talk back to optio (progress + deliverables),
|
|
20
|
+
- SSH transport handled for you.
|
|
21
|
+
|
|
22
|
+
If you're writing the end-user task type directly (not consuming this library from another optio task package), you probably want `optio-core` instead.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install optio-host
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`optio-host` depends on `optio-core` and `asyncssh`. Python 3.11+.
|
|
31
|
+
|
|
32
|
+
## Minimal example
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from optio_host import make_host, SSHConfig
|
|
36
|
+
|
|
37
|
+
# Local
|
|
38
|
+
async with make_host(ssh=None) as host:
|
|
39
|
+
result = await host.run(["uname", "-a"])
|
|
40
|
+
print(result.stdout)
|
|
41
|
+
|
|
42
|
+
# Remote
|
|
43
|
+
ssh = SSHConfig(host="worker-1", user="optio", key_path="~/.ssh/id_optio")
|
|
44
|
+
async with make_host(ssh=ssh) as host:
|
|
45
|
+
result = await host.run(["uname", "-a"])
|
|
46
|
+
print(result.stdout)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
Apache-2.0.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "optio-host"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local-or-remote host abstraction + log/deliverables coordination protocol for optio task types."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Kristof Csillag", email = "kristof.csillag@deai-labs.com" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
22
|
+
"Operating System :: MacOS",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Topic :: System :: Distributed Computing",
|
|
25
|
+
"Topic :: System :: Systems Administration",
|
|
26
|
+
"Framework :: AsyncIO",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"optio-core>=0.1,<0.2",
|
|
30
|
+
"asyncssh>=2.14",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-asyncio>=0.23",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/deai-network/optio"
|
|
41
|
+
Repository = "https://github.com/deai-network/optio"
|
|
42
|
+
Issues = "https://github.com/deai-network/optio/issues"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
asyncio_mode = "auto"
|
|
49
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""optio-host — local-or-remote host abstraction + log/deliverables protocol.
|
|
2
|
+
|
|
3
|
+
Top-level public API. See ``optio_host.host`` for primitives,
|
|
4
|
+
``optio_host.context`` for HookContext, and ``optio_host.protocol``
|
|
5
|
+
for the log/deliverables coordination protocol.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from optio_host.context import (
|
|
9
|
+
HookContext,
|
|
10
|
+
HookContextProtocol,
|
|
11
|
+
HostCommandError,
|
|
12
|
+
RunResult,
|
|
13
|
+
)
|
|
14
|
+
from optio_host.download import DownloadFailed, create_download_task
|
|
15
|
+
from optio_host.host import (
|
|
16
|
+
Host,
|
|
17
|
+
LocalHost,
|
|
18
|
+
ProcessHandle,
|
|
19
|
+
RemoteHost,
|
|
20
|
+
make_host,
|
|
21
|
+
)
|
|
22
|
+
from optio_host.types import SSHConfig
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Host",
|
|
26
|
+
"LocalHost",
|
|
27
|
+
"RemoteHost",
|
|
28
|
+
"ProcessHandle",
|
|
29
|
+
"make_host",
|
|
30
|
+
"HookContext",
|
|
31
|
+
"HookContextProtocol",
|
|
32
|
+
"HostCommandError",
|
|
33
|
+
"RunResult",
|
|
34
|
+
"SSHConfig",
|
|
35
|
+
"DownloadFailed",
|
|
36
|
+
"create_download_task",
|
|
37
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Tar+gzip helpers for persisting and restoring a workdir.
|
|
2
|
+
|
|
3
|
+
`yield_workdir_archive` is an async generator that yields gzipped tar
|
|
4
|
+
chunks of a directory's contents. `consume_workdir_archive` consumes such
|
|
5
|
+
a stream, wiping the destination first and extracting in. Both run their
|
|
6
|
+
synchronous tarfile work in a thread executor so the event loop stays
|
|
7
|
+
responsive.
|
|
8
|
+
|
|
9
|
+
These helpers back `LocalHost.archive_workdir` and `LocalHost.restore_workdir`.
|
|
10
|
+
`RemoteHost` does not use them — it shells out to `tar` over SSH instead.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import fnmatch
|
|
17
|
+
import io
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import tarfile
|
|
21
|
+
from typing import AsyncIterator, Iterable
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
DEFAULT_WORKDIR_EXCLUDES: list[str] = [
|
|
25
|
+
".git",
|
|
26
|
+
"node_modules",
|
|
27
|
+
"__pycache__",
|
|
28
|
+
".venv",
|
|
29
|
+
"*.pyc",
|
|
30
|
+
".DS_Store",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
_CHUNK_SIZE = 1 << 20 # 1 MiB
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _excluded(relpath: str, patterns: list[str]) -> bool:
|
|
37
|
+
"""Return True if `relpath` matches any pattern in `patterns`.
|
|
38
|
+
|
|
39
|
+
Matching is applied at each path segment (so `.git` in `patterns`
|
|
40
|
+
excludes `./a/b/.git/…`) as well as against the full relative path
|
|
41
|
+
(so `*.pyc` matches `mod.pyc` but also `a/b/mod.pyc`).
|
|
42
|
+
"""
|
|
43
|
+
parts = relpath.split(os.sep)
|
|
44
|
+
for pat in patterns:
|
|
45
|
+
if fnmatch.fnmatch(relpath, pat):
|
|
46
|
+
return True
|
|
47
|
+
for part in parts:
|
|
48
|
+
if fnmatch.fnmatch(part, pat):
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_archive_bytes(root: str, patterns: list[str]) -> bytes:
|
|
54
|
+
"""Build the entire tar.gz in memory and return it as bytes."""
|
|
55
|
+
buf = io.BytesIO()
|
|
56
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
57
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
58
|
+
rel_dir = os.path.relpath(dirpath, root)
|
|
59
|
+
dirnames[:] = [
|
|
60
|
+
d for d in dirnames
|
|
61
|
+
if not _excluded(os.path.join(rel_dir, d) if rel_dir != "." else d, patterns)
|
|
62
|
+
]
|
|
63
|
+
for name in filenames:
|
|
64
|
+
rel = os.path.join(rel_dir, name) if rel_dir != "." else name
|
|
65
|
+
if _excluded(rel, patterns):
|
|
66
|
+
continue
|
|
67
|
+
tar.add(os.path.join(dirpath, name), arcname=rel, recursive=False)
|
|
68
|
+
return buf.getvalue()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def yield_workdir_archive(
|
|
72
|
+
root: str,
|
|
73
|
+
exclude: Iterable[str] | None = None,
|
|
74
|
+
) -> AsyncIterator[bytes]:
|
|
75
|
+
"""Yield 1 MiB chunks of the gzipped tar of `root`."""
|
|
76
|
+
patterns = list(DEFAULT_WORKDIR_EXCLUDES) if exclude is None else list(exclude)
|
|
77
|
+
loop = asyncio.get_event_loop()
|
|
78
|
+
blob = await loop.run_in_executor(None, _build_archive_bytes, root, patterns)
|
|
79
|
+
for offset in range(0, len(blob), _CHUNK_SIZE):
|
|
80
|
+
yield blob[offset : offset + _CHUNK_SIZE]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _empty_dir(path: str) -> None:
|
|
84
|
+
if not os.path.isdir(path):
|
|
85
|
+
os.makedirs(path, exist_ok=True)
|
|
86
|
+
return
|
|
87
|
+
for entry in os.listdir(path):
|
|
88
|
+
full = os.path.join(path, entry)
|
|
89
|
+
if os.path.isdir(full) and not os.path.islink(full):
|
|
90
|
+
shutil.rmtree(full, ignore_errors=True)
|
|
91
|
+
else:
|
|
92
|
+
try:
|
|
93
|
+
os.unlink(full)
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _extract_sync(blob: bytes, dest: str) -> None:
|
|
99
|
+
_empty_dir(dest)
|
|
100
|
+
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
|
|
101
|
+
tar.extractall(dest)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def consume_workdir_archive(
|
|
105
|
+
stream: AsyncIterator[bytes],
|
|
106
|
+
dest: str,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Empty `dest`, then untar the chunked gzipped stream into it."""
|
|
109
|
+
chunks = []
|
|
110
|
+
async for chunk in stream:
|
|
111
|
+
chunks.append(chunk)
|
|
112
|
+
blob = b"".join(chunks)
|
|
113
|
+
loop = asyncio.get_event_loop()
|
|
114
|
+
await loop.run_in_executor(None, _extract_sync, blob, dest)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""HookContext: ProcessContext + host primitives for task-body hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class RunResult:
|
|
11
|
+
stdout: str
|
|
12
|
+
stderr: str
|
|
13
|
+
exit_code: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HostCommandError(Exception):
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
command: str,
|
|
20
|
+
exit_code: int,
|
|
21
|
+
stdout: str,
|
|
22
|
+
stderr: str,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.command = command
|
|
25
|
+
self.exit_code = exit_code
|
|
26
|
+
self.stdout = stdout
|
|
27
|
+
self.stderr = stderr
|
|
28
|
+
super().__init__(
|
|
29
|
+
f"command failed (exit {exit_code}): {command!r}\n"
|
|
30
|
+
f"stderr: {stderr[:200]}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Protocol
|
|
35
|
+
|
|
36
|
+
# Imported at module top so tests can monkeypatch this name on the module.
|
|
37
|
+
from optio_host.download import create_download_task
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HookContext:
|
|
41
|
+
"""ProcessContext + host primitives, passed to before/after_execute hooks
|
|
42
|
+
and to on_deliverable callbacks.
|
|
43
|
+
|
|
44
|
+
Attributes not defined on this class fall through to the wrapped
|
|
45
|
+
ProcessContext via __getattr__, so consumers can call e.g.
|
|
46
|
+
``hook_ctx.report_progress(...)`` directly.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, ctx, host) -> None:
|
|
50
|
+
# Use object.__setattr__ to avoid __getattr__ recursion in __init__.
|
|
51
|
+
object.__setattr__(self, "_ctx", ctx)
|
|
52
|
+
object.__setattr__(self, "_host", host)
|
|
53
|
+
|
|
54
|
+
def __getattr__(self, name: str) -> Any:
|
|
55
|
+
# Only called if the attribute isn't found on the instance / class.
|
|
56
|
+
return getattr(self._ctx, name)
|
|
57
|
+
|
|
58
|
+
async def run_on_host(
|
|
59
|
+
self,
|
|
60
|
+
command: str,
|
|
61
|
+
*,
|
|
62
|
+
check: bool = True,
|
|
63
|
+
capture_stderr: bool = False,
|
|
64
|
+
cwd: str | None = None,
|
|
65
|
+
):
|
|
66
|
+
result = await self._host.run_command(command, cwd=cwd)
|
|
67
|
+
if check:
|
|
68
|
+
if result.exit_code != 0:
|
|
69
|
+
raise HostCommandError(
|
|
70
|
+
command=command,
|
|
71
|
+
exit_code=result.exit_code,
|
|
72
|
+
stdout=result.stdout,
|
|
73
|
+
stderr=result.stderr,
|
|
74
|
+
)
|
|
75
|
+
return result.stdout + (result.stderr if capture_stderr else "")
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
async def copy_file(
|
|
79
|
+
self,
|
|
80
|
+
source,
|
|
81
|
+
target: str,
|
|
82
|
+
*,
|
|
83
|
+
skip_if_unchanged: bool = False,
|
|
84
|
+
) -> None:
|
|
85
|
+
from bson import ObjectId # local import to avoid a hard top-level dep
|
|
86
|
+
|
|
87
|
+
host_home = await self._host.resolve_host_home()
|
|
88
|
+
abs_target = _resolve_target_path(target, self._host.workdir, host_home)
|
|
89
|
+
basename = os.path.basename(abs_target) or abs_target
|
|
90
|
+
ctx = self._ctx
|
|
91
|
+
skipped = False
|
|
92
|
+
|
|
93
|
+
def _progress_cb(percent, message):
|
|
94
|
+
nonlocal skipped
|
|
95
|
+
if message == "already up to date":
|
|
96
|
+
skipped = True
|
|
97
|
+
ctx.report_progress(None, f"Already up to date: {basename}")
|
|
98
|
+
elif percent is not None:
|
|
99
|
+
ctx.report_progress(percent, None)
|
|
100
|
+
|
|
101
|
+
# Resolve blob sources to (iterator, expected_sha) pair.
|
|
102
|
+
expected_sha: str | None = None
|
|
103
|
+
if isinstance(source, ObjectId):
|
|
104
|
+
payload = await self._read_blob_bytes(source)
|
|
105
|
+
if skip_if_unchanged:
|
|
106
|
+
import hashlib
|
|
107
|
+
expected_sha = hashlib.sha256(payload).hexdigest()
|
|
108
|
+
|
|
109
|
+
async def _gen():
|
|
110
|
+
# Yield the payload as one (or a few) chunks. For very large
|
|
111
|
+
# blobs we could stream via load_blob, but reading-then-iterating
|
|
112
|
+
# is simpler and correct.
|
|
113
|
+
yield payload
|
|
114
|
+
|
|
115
|
+
source = _gen()
|
|
116
|
+
|
|
117
|
+
if skip_if_unchanged:
|
|
118
|
+
ctx.report_progress(None, f"Verifying {basename}...")
|
|
119
|
+
else:
|
|
120
|
+
ctx.report_progress(None, f"Copying {basename}...")
|
|
121
|
+
|
|
122
|
+
await self._host.put_file_to_host(
|
|
123
|
+
source,
|
|
124
|
+
abs_target,
|
|
125
|
+
expected_sha256=expected_sha,
|
|
126
|
+
skip_if_unchanged=skip_if_unchanged,
|
|
127
|
+
progress_cb=_progress_cb,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if skip_if_unchanged and not skipped:
|
|
131
|
+
ctx.report_progress(None, f"Copying {basename}...")
|
|
132
|
+
|
|
133
|
+
async def _read_blob_bytes(self, file_id) -> bytes:
|
|
134
|
+
async with self._ctx.load_blob(file_id) as reader:
|
|
135
|
+
return await reader.read()
|
|
136
|
+
|
|
137
|
+
async def read_from_host(self, path: str, *, silent: bool = False) -> bytes:
|
|
138
|
+
host_home = await self._host.resolve_host_home()
|
|
139
|
+
abs_path = _resolve_target_path(path, self._host.workdir, host_home)
|
|
140
|
+
if not silent:
|
|
141
|
+
basename = os.path.basename(abs_path) or abs_path
|
|
142
|
+
self._ctx.report_progress(None, f"Reading {basename}...")
|
|
143
|
+
|
|
144
|
+
def _progress_cb(percent, message):
|
|
145
|
+
if percent is not None:
|
|
146
|
+
self._ctx.report_progress(percent, None)
|
|
147
|
+
|
|
148
|
+
return await self._host.fetch_bytes_from_host(
|
|
149
|
+
abs_path, progress_cb=_progress_cb,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def read_text_from_host(self, path: str, *, silent: bool = False) -> str:
|
|
153
|
+
data = await self.read_from_host(path, silent=silent)
|
|
154
|
+
return data.decode("utf-8")
|
|
155
|
+
|
|
156
|
+
async def download_file(
|
|
157
|
+
self,
|
|
158
|
+
url: str,
|
|
159
|
+
target: str,
|
|
160
|
+
*,
|
|
161
|
+
description: str | None = None,
|
|
162
|
+
cleanup_on_fail: bool = True,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Download ``url`` to ``target`` on the host as a child task.
|
|
165
|
+
|
|
166
|
+
Spawns a child process under the current task that runs curl on the
|
|
167
|
+
same host the parent runs on. Reports a single "Downloading
|
|
168
|
+
<basename>" message followed by numeric progress percent updates on
|
|
169
|
+
the child's ProcessContext.
|
|
170
|
+
|
|
171
|
+
``target`` is resolved by the same rules as ``copy_file``: absolute
|
|
172
|
+
path | ``~`` / ``~/...`` home-relative | workdir-relative. On a
|
|
173
|
+
workdir-escape attempt this raises ``ValueError`` without spawning
|
|
174
|
+
a child.
|
|
175
|
+
|
|
176
|
+
Returns None on success. Raises
|
|
177
|
+
``optio_core.exceptions.ChildProcessFailed`` if the child fails;
|
|
178
|
+
the original ``DownloadFailed`` is preserved via ``__cause__``
|
|
179
|
+
(and on the child's ``ChildResult.original_exception`` when caller
|
|
180
|
+
uses ``parallel_group``). Parent-task cancellation propagates to
|
|
181
|
+
the child automatically.
|
|
182
|
+
"""
|
|
183
|
+
host_home = await self._host.resolve_host_home()
|
|
184
|
+
abs_target = _resolve_target_path(target, self._host.workdir, host_home)
|
|
185
|
+
basename = os.path.basename(abs_target) or abs_target
|
|
186
|
+
|
|
187
|
+
n = self._ctx._child_counter.get("next", 0)
|
|
188
|
+
child_process_id = f"{self._ctx.process_id}.download-{n}"
|
|
189
|
+
child_name = f"download {basename}"
|
|
190
|
+
|
|
191
|
+
task = create_download_task(
|
|
192
|
+
process_id=child_process_id,
|
|
193
|
+
name=child_name,
|
|
194
|
+
url=url,
|
|
195
|
+
target=abs_target,
|
|
196
|
+
host=self._host,
|
|
197
|
+
description=description,
|
|
198
|
+
cleanup_on_fail=cleanup_on_fail,
|
|
199
|
+
)
|
|
200
|
+
await self._ctx.run_child_task(task)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class HookContextProtocol(Protocol):
|
|
204
|
+
"""Type-hint surface for hook authors who want IDE discoverability.
|
|
205
|
+
|
|
206
|
+
Subset of ProcessContext + the four new methods.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
process_id: str
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def params(self) -> dict: ...
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def metadata(self) -> dict: ...
|
|
216
|
+
|
|
217
|
+
def report_progress(self, percent: float | None, message: str | None = None) -> None: ...
|
|
218
|
+
def should_continue(self) -> bool: ...
|
|
219
|
+
async def copy_file(
|
|
220
|
+
self,
|
|
221
|
+
source,
|
|
222
|
+
target: str,
|
|
223
|
+
*,
|
|
224
|
+
skip_if_unchanged: bool = False,
|
|
225
|
+
) -> None: ...
|
|
226
|
+
async def run_on_host(
|
|
227
|
+
self,
|
|
228
|
+
command: str,
|
|
229
|
+
*,
|
|
230
|
+
check: bool = True,
|
|
231
|
+
capture_stderr: bool = False,
|
|
232
|
+
cwd: str | None = None,
|
|
233
|
+
) -> "str | RunResult": ...
|
|
234
|
+
async def read_from_host(self, path: str, *, silent: bool = False) -> bytes: ...
|
|
235
|
+
async def read_text_from_host(self, path: str, *, silent: bool = False) -> str: ...
|
|
236
|
+
async def download_file(
|
|
237
|
+
self,
|
|
238
|
+
url: str,
|
|
239
|
+
target: str,
|
|
240
|
+
*,
|
|
241
|
+
description: str | None = None,
|
|
242
|
+
cleanup_on_fail: bool = True,
|
|
243
|
+
) -> None: ...
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _resolve_target_path(path: str, workdir: str, host_home: str) -> str:
|
|
247
|
+
"""Resolve a user-supplied path to an absolute host path.
|
|
248
|
+
|
|
249
|
+
Three forms:
|
|
250
|
+
- starts with `/` → absolute, used as-is
|
|
251
|
+
- `~` or starts with `~/` → home-relative, expand once
|
|
252
|
+
- otherwise → workdir-relative; reject `..` and any escape
|
|
253
|
+
|
|
254
|
+
workdir and host_home must both be absolute paths.
|
|
255
|
+
"""
|
|
256
|
+
if not path:
|
|
257
|
+
raise ValueError("path must not be empty")
|
|
258
|
+
if path.startswith("/"):
|
|
259
|
+
return path
|
|
260
|
+
if path == "~":
|
|
261
|
+
return host_home
|
|
262
|
+
if path.startswith("~/"):
|
|
263
|
+
return host_home + "/" + path[2:]
|
|
264
|
+
# workdir-relative
|
|
265
|
+
if ".." in path.split("/"):
|
|
266
|
+
raise ValueError(f"workdir-relative path may not contain '..': {path!r}")
|
|
267
|
+
resolved = os.path.normpath(os.path.join(workdir, path))
|
|
268
|
+
workdir_norm = os.path.normpath(workdir).rstrip("/")
|
|
269
|
+
if resolved != workdir_norm and not resolved.startswith(workdir_norm + "/"):
|
|
270
|
+
raise ValueError(f"workdir-relative path escapes workdir: {path!r}")
|
|
271
|
+
return resolved
|