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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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