run-yonder 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.
- run_yonder-0.1.0.dist-info/METADATA +271 -0
- run_yonder-0.1.0.dist-info/RECORD +11 -0
- run_yonder-0.1.0.dist-info/WHEEL +4 -0
- yonder/__init__.py +33 -0
- yonder/decorator.py +110 -0
- yonder/paths.py +62 -0
- yonder/runners/__init__.py +5 -0
- yonder/runners/base.py +67 -0
- yonder/runners/docker.py +634 -0
- yonder/runners/k8s.py +266 -0
- yonder/session.py +148 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: run-yonder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run Python functions inside containers via a decorator.
|
|
5
|
+
Project-URL: Source, https://gitlab.com/berge472/yonder
|
|
6
|
+
Author-email: Jason Berger <berge472@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: cloudpickle,container,decorator,docker,remote-execution
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: cloudpickle>=3.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: docker>=7.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: kubernetes>=28.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
22
|
+
Provides-Extra: docker
|
|
23
|
+
Requires-Dist: docker>=7.0; extra == 'docker'
|
|
24
|
+
Provides-Extra: k8s
|
|
25
|
+
Requires-Dist: kubernetes>=28.0; extra == 'k8s'
|
|
26
|
+
Description-Content-Type: text/x-rst
|
|
27
|
+
|
|
28
|
+
.. image:: doc/assets/images/logo.svg
|
|
29
|
+
:alt: yonder
|
|
30
|
+
:align: center
|
|
31
|
+
:width: 380px
|
|
32
|
+
|
|
33
|
+
|
|
|
34
|
+
|
|
35
|
+
yonder
|
|
36
|
+
======
|
|
37
|
+
|
|
38
|
+
Run Python functions on container-backed runners via a decorator.
|
|
39
|
+
Functions and their arguments are serialized with
|
|
40
|
+
`cloudpickle <https://github.com/cloudpipe/cloudpickle>`_, shipped to the
|
|
41
|
+
runner, executed there, and the result is shipped back.
|
|
42
|
+
|
|
43
|
+
Install
|
|
44
|
+
-------
|
|
45
|
+
|
|
46
|
+
.. code:: bash
|
|
47
|
+
|
|
48
|
+
# core package
|
|
49
|
+
pip install -e .
|
|
50
|
+
|
|
51
|
+
# with the bundled Docker runner
|
|
52
|
+
pip install -e ".[docker]"
|
|
53
|
+
|
|
54
|
+
# with the bundled Kubernetes runner
|
|
55
|
+
pip install -e ".[k8s]"
|
|
56
|
+
|
|
57
|
+
Quick start
|
|
58
|
+
-----------
|
|
59
|
+
|
|
60
|
+
.. code:: python
|
|
61
|
+
|
|
62
|
+
import yonder
|
|
63
|
+
from yonder import DockerRunner
|
|
64
|
+
|
|
65
|
+
# Existing image. If the image doesn't already have cloudpickle, set
|
|
66
|
+
# inject_cloudpickle=True to derive an image that does (cached across
|
|
67
|
+
# runs, keyed by the base image). `when=` is an optional zero-arg
|
|
68
|
+
# predicate that makes the runner a no-op (calls run locally) when it
|
|
69
|
+
# returns False — useful for "use the container only if the library
|
|
70
|
+
# isn't already installed on the host".
|
|
71
|
+
runnerA = DockerRunner(
|
|
72
|
+
image="url/to/image",
|
|
73
|
+
env={"ENV_VARA": "value"},
|
|
74
|
+
mounts={"/host/path": "/in/container"},
|
|
75
|
+
inject_cloudpickle=True,
|
|
76
|
+
when=lambda: liba_not_present(),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Build from a Dockerfile (context defaults to the Dockerfile's directory).
|
|
80
|
+
runnerB = DockerRunner(
|
|
81
|
+
dockerfile="./docker/myimg.Dockerfile",
|
|
82
|
+
build_args={"VERSION": "1.2.3"},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# On-demand: fresh container per call instead of one long-lived container.
|
|
86
|
+
ephemeral = DockerRunner(image="url/to/image", on_demand=True)
|
|
87
|
+
|
|
88
|
+
# Attach to a container you manage outside yonder. yonder will start it
|
|
89
|
+
# if it's stopped, but never removes it on close.
|
|
90
|
+
external = DockerRunner(container="my-dev-container")
|
|
91
|
+
|
|
92
|
+
# Register them by name.
|
|
93
|
+
yonder.register({
|
|
94
|
+
"runnerA": runnerA,
|
|
95
|
+
"runnerB": runnerB,
|
|
96
|
+
"ephemeral": ephemeral,
|
|
97
|
+
"external": external,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
# Decorate. Dispatch policy lives on the runner (via the runner's
|
|
101
|
+
# `when=`); the decorator just names the target.
|
|
102
|
+
@yonder.yonder(runner="runnerA")
|
|
103
|
+
def functionA(a, b):
|
|
104
|
+
return a + b
|
|
105
|
+
|
|
106
|
+
@yonder.yonder(runner="runnerB")
|
|
107
|
+
def functionB(a, b):
|
|
108
|
+
return a * b
|
|
109
|
+
|
|
110
|
+
Lazy start by default
|
|
111
|
+
~~~~~~~~~~~~~~~~~~~~~
|
|
112
|
+
|
|
113
|
+
A registered runner is **not** built/started until it's actually needed.
|
|
114
|
+
The first decorated call that targets a runner triggers its ``start()``
|
|
115
|
+
(image build/pull, persistent container creation). Runners that are never
|
|
116
|
+
called incur no cost.
|
|
117
|
+
|
|
118
|
+
You can pre-warm explicitly when you'd rather pay startup cost up front:
|
|
119
|
+
|
|
120
|
+
.. code:: python
|
|
121
|
+
|
|
122
|
+
runnerA.start() # pre-warm a specific runner
|
|
123
|
+
yonder.wait() # pre-warm every registered runner (parallel)
|
|
124
|
+
|
|
125
|
+
Both are opt-in. On-demand runners always behave the same way — they spin
|
|
126
|
+
up a fresh container per call regardless of pre-warming.
|
|
127
|
+
|
|
128
|
+
Tear down
|
|
129
|
+
~~~~~~~~~
|
|
130
|
+
|
|
131
|
+
Tear down when done:
|
|
132
|
+
|
|
133
|
+
.. code:: python
|
|
134
|
+
|
|
135
|
+
yonder.closeAll()
|
|
136
|
+
# or, per-runner:
|
|
137
|
+
runnerA.close()
|
|
138
|
+
|
|
139
|
+
``DockerRunner`` is also a context manager:
|
|
140
|
+
|
|
141
|
+
.. code:: python
|
|
142
|
+
|
|
143
|
+
with DockerRunner(image="python:3.11-slim") as r:
|
|
144
|
+
yonder.register({"tmp": r})
|
|
145
|
+
yonder.wait()
|
|
146
|
+
...
|
|
147
|
+
# container removed on exit
|
|
148
|
+
|
|
149
|
+
DockerRunner options
|
|
150
|
+
--------------------
|
|
151
|
+
|
|
152
|
+
.. code:: python
|
|
153
|
+
|
|
154
|
+
DockerRunner(
|
|
155
|
+
# Exactly one of these three is required:
|
|
156
|
+
image=None, # use/pull an existing image; yonder creates the container
|
|
157
|
+
dockerfile=None, # build an image locally first; yonder creates the container
|
|
158
|
+
container=None, # attach to an existing container (managed outside yonder)
|
|
159
|
+
|
|
160
|
+
build_context=None, # override the build context directory (dockerfile mode)
|
|
161
|
+
build_args=None, # dict of Docker ARGs (dockerfile mode)
|
|
162
|
+
|
|
163
|
+
env=None, # environment variables (image/dockerfile mode only)
|
|
164
|
+
mounts=None, # {host_path: container_path} (image/dockerfile mode only)
|
|
165
|
+
on_demand=False, # one-shot container per call (image/dockerfile mode only)
|
|
166
|
+
name=None, # container/tag name (auto-generated if omitted)
|
|
167
|
+
inject_cloudpickle=False, # derive `FROM image + RUN pip install cloudpickle`
|
|
168
|
+
# (image mode only; tagged by base-image hash so the
|
|
169
|
+
# docker layer cache makes re-runs cheap)
|
|
170
|
+
client=None, # pre-built docker.DockerClient (else docker.from_env())
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
K8sRunner
|
|
174
|
+
---------
|
|
175
|
+
|
|
176
|
+
Attach to an existing Kubernetes pod and exec into one of its containers.
|
|
177
|
+
Pods are treated like externally-managed containers — yonder never creates
|
|
178
|
+
or removes them.
|
|
179
|
+
|
|
180
|
+
.. code:: python
|
|
181
|
+
|
|
182
|
+
from yonder import K8sRunner
|
|
183
|
+
|
|
184
|
+
# By pod name.
|
|
185
|
+
by_name = K8sRunner(
|
|
186
|
+
pod="my-pod-abc123",
|
|
187
|
+
namespace="default",
|
|
188
|
+
container="app", # optional; defaults to the pod's first container
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# By label selector — first Running pod that matches is used.
|
|
192
|
+
by_selector = K8sRunner(
|
|
193
|
+
selector="app=my-app,env=dev",
|
|
194
|
+
namespace="default",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
.. code:: python
|
|
198
|
+
|
|
199
|
+
K8sRunner(
|
|
200
|
+
# Exactly one of these two is required:
|
|
201
|
+
pod=None, # attach by pod name
|
|
202
|
+
selector=None, # attach by label selector
|
|
203
|
+
|
|
204
|
+
namespace="default", # pod namespace
|
|
205
|
+
container=None, # container within the pod (default: first)
|
|
206
|
+
kubeconfig=None, # kubeconfig path (else KUBECONFIG/~/.kube/config,
|
|
207
|
+
# falling back to in-cluster config)
|
|
208
|
+
context=None, # kubeconfig context
|
|
209
|
+
name=None, # runner name (auto-derived if omitted)
|
|
210
|
+
api_client=None, # pre-built kubernetes.client.ApiClient
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
The target container must have ``python`` on ``PATH`` and ``cloudpickle``
|
|
214
|
+
installed (same Python minor version as the host is strongly recommended).
|
|
215
|
+
|
|
216
|
+
Architecture
|
|
217
|
+
------------
|
|
218
|
+
|
|
219
|
+
- **``Runner``** (``yonder.Runner``) — abstract base. Implementations hold
|
|
220
|
+
their own config and expose ``run(payload) -> bytes``, plus optional
|
|
221
|
+
``start()`` / ``close()`` lifecycle hooks.
|
|
222
|
+
- **``DockerRunner``** — default implementation; uses the
|
|
223
|
+
`Docker SDK for Python <https://docker-py.readthedocs.io/>`_. Supports
|
|
224
|
+
existing images, Dockerfile builds, and attaching to externally-managed
|
|
225
|
+
containers; persistent and on-demand modes.
|
|
226
|
+
- **``K8sRunner``** — Kubernetes implementation; uses the
|
|
227
|
+
`Kubernetes Python client <https://github.com/kubernetes-client/python>`_
|
|
228
|
+
to exec into a pod selected by name or label selector.
|
|
229
|
+
- **``YonderSession``** — process-wide name→runner registry. Populate with
|
|
230
|
+
``yonder.register({...})``, bring everything online with
|
|
231
|
+
``yonder.wait()``, tear everything down with ``yonder.closeAll()``.
|
|
232
|
+
- **Decorator** — serializes ``(func, args, kwargs)`` with cloudpickle,
|
|
233
|
+
calls the named runner's ``run(payload)``, deserializes the envelope, and
|
|
234
|
+
either returns the value or re-raises the exception.
|
|
235
|
+
|
|
236
|
+
Writing your own Runner
|
|
237
|
+
-----------------------
|
|
238
|
+
|
|
239
|
+
.. code:: python
|
|
240
|
+
|
|
241
|
+
from yonder import Runner, register
|
|
242
|
+
|
|
243
|
+
class MyRunner(Runner):
|
|
244
|
+
def __init__(self, **config):
|
|
245
|
+
...
|
|
246
|
+
|
|
247
|
+
def start(self):
|
|
248
|
+
# eager init (build/pull image, warm container, etc.)
|
|
249
|
+
...
|
|
250
|
+
|
|
251
|
+
def run(self, payload: bytes) -> bytes:
|
|
252
|
+
# send payload to your execution environment,
|
|
253
|
+
# return the cloudpickled envelope
|
|
254
|
+
...
|
|
255
|
+
|
|
256
|
+
def close(self):
|
|
257
|
+
...
|
|
258
|
+
|
|
259
|
+
register({"mine": MyRunner(...)})
|
|
260
|
+
|
|
261
|
+
Container requirements
|
|
262
|
+
----------------------
|
|
263
|
+
|
|
264
|
+
Images used with ``DockerRunner`` must have:
|
|
265
|
+
|
|
266
|
+
- ``python`` on ``PATH``
|
|
267
|
+
- ``cloudpickle`` installed (``pip install cloudpickle``). If your base
|
|
268
|
+
image doesn't have it, pass ``inject_cloudpickle=True`` and yonder will
|
|
269
|
+
build a derived image that does (one-time, cached).
|
|
270
|
+
- Ideally the same Python minor version as the host (cloudpickle pickles
|
|
271
|
+
function bytecode, which is not portable across CPython minor versions).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
yonder/__init__.py,sha256=inhtrjsHPEg76En7NrFcut_Gs_aZdl5lxMNkmR-GDcg,595
|
|
2
|
+
yonder/decorator.py,sha256=S49_AGY1siJSygZ-s2lp8EnMCm4tUOlnUqF_K9ScG2g,4472
|
|
3
|
+
yonder/paths.py,sha256=446OXCfZEjUIBzjxepFjfvpgD7ezPUZKOJGs7MTItF0,2314
|
|
4
|
+
yonder/session.py,sha256=1KT4kME2cRfqYmJAJgTMO7o3a9IrR_klvPIIobuC4O4,5067
|
|
5
|
+
yonder/runners/__init__.py,sha256=NJj2bCqYMocXnm1UaG2JL9wFx3GUiKHchX8LAYinLX4,136
|
|
6
|
+
yonder/runners/base.py,sha256=fa4F76FsWcYv29vWc05iVNDbrIvN6VGX3JOTOEagwT8,2735
|
|
7
|
+
yonder/runners/docker.py,sha256=MNJGByghoiVltG6IrSFrvd_-0w32o2jr1Hu8EhPUPLc,26265
|
|
8
|
+
yonder/runners/k8s.py,sha256=HI7rOWohV6rfFdZ68cXXPCHSQSfyeHuQh82PpicbxIM,9478
|
|
9
|
+
run_yonder-0.1.0.dist-info/METADATA,sha256=FrMXaQvCpSvcDDase1dk9Dn-sZXCCjRSOrTXf9Wnzd0,9026
|
|
10
|
+
run_yonder-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
run_yonder-0.1.0.dist-info/RECORD,,
|
yonder/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""yonder — run Python functions on registered container runners."""
|
|
2
|
+
|
|
3
|
+
from .decorator import yonder
|
|
4
|
+
from .paths import HostPath
|
|
5
|
+
from .runners.base import Runner
|
|
6
|
+
from .runners.docker import DockerRunner
|
|
7
|
+
from .runners.k8s import K8sRunner
|
|
8
|
+
from .session import (
|
|
9
|
+
YonderSession,
|
|
10
|
+
closeAll,
|
|
11
|
+
get,
|
|
12
|
+
getSession,
|
|
13
|
+
register,
|
|
14
|
+
setLogLevel,
|
|
15
|
+
wait,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"yonder",
|
|
20
|
+
"register",
|
|
21
|
+
"get",
|
|
22
|
+
"wait",
|
|
23
|
+
"closeAll",
|
|
24
|
+
"setLogLevel",
|
|
25
|
+
"getSession",
|
|
26
|
+
"YonderSession",
|
|
27
|
+
"Runner",
|
|
28
|
+
"DockerRunner",
|
|
29
|
+
"K8sRunner",
|
|
30
|
+
"HostPath",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
yonder/decorator.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import pickle
|
|
5
|
+
|
|
6
|
+
import cloudpickle
|
|
7
|
+
|
|
8
|
+
from .paths import translate as _translate_host_paths
|
|
9
|
+
from .session import get as _get_runner
|
|
10
|
+
|
|
11
|
+
_DOCS_BASE = "https://jason-berger.gitlab.io/yonder"
|
|
12
|
+
_REF_MODE_DOCS = f"{_DOCS_BASE}/pages/runners.html#runner-modes"
|
|
13
|
+
_REF_MODE_EXAMPLE = f"{_DOCS_BASE}/pages/examples.html#rdkit-example"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def yonder(runner: str):
|
|
17
|
+
"""Decorator: run the wrapped function on a registered runner.
|
|
18
|
+
|
|
19
|
+
``runner`` is the name previously registered via :func:`yonder.register`.
|
|
20
|
+
|
|
21
|
+
Whether a call dispatches to the runner or runs locally is controlled
|
|
22
|
+
by the runner's own ``when`` predicate (see :class:`~yonder.Runner`).
|
|
23
|
+
If the runner has no ``when``, every call dispatches.
|
|
24
|
+
|
|
25
|
+
Transport is selected by the runner via its ``mode`` attribute. With
|
|
26
|
+
``mode="cloudpickle"`` (the default), the function is shipped as
|
|
27
|
+
bytecode via cloudpickle and host/container Python minor versions must
|
|
28
|
+
match. With ``mode="reference"``, the function is shipped as
|
|
29
|
+
``(module, qualname, args, kwargs)`` and the container imports the
|
|
30
|
+
module under its own Python; the function must be defined at module
|
|
31
|
+
scope in a non-``__main__`` module.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def decorate(func):
|
|
35
|
+
@functools.wraps(func)
|
|
36
|
+
def wrapper(*args, **kwargs):
|
|
37
|
+
runner_obj = _get_runner(runner)
|
|
38
|
+
runner_when = getattr(runner_obj, "when", None)
|
|
39
|
+
if runner_when is not None and not runner_when():
|
|
40
|
+
# Local dispatch: HostPath instances pass through unchanged
|
|
41
|
+
# (they're str subclasses, so open(p)/Path(p)/etc. all work).
|
|
42
|
+
return func(*args, **kwargs)
|
|
43
|
+
|
|
44
|
+
# Remote dispatch: rewrite any HostPath in args/kwargs to its
|
|
45
|
+
# in-runner equivalent before serializing.
|
|
46
|
+
args = tuple(
|
|
47
|
+
_translate_host_paths(a, runner_obj.translate_host_path)
|
|
48
|
+
for a in args
|
|
49
|
+
)
|
|
50
|
+
kwargs = {
|
|
51
|
+
k: _translate_host_paths(v, runner_obj.translate_host_path)
|
|
52
|
+
for k, v in kwargs.items()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
mode = getattr(runner_obj, "mode", "cloudpickle")
|
|
56
|
+
if mode == "reference":
|
|
57
|
+
_validate_reference(func)
|
|
58
|
+
payload = pickle.dumps(
|
|
59
|
+
{
|
|
60
|
+
"module": func.__module__,
|
|
61
|
+
"qualname": func.__qualname__,
|
|
62
|
+
"args": args,
|
|
63
|
+
"kwargs": kwargs,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
result_bytes = runner_obj.run(payload)
|
|
67
|
+
envelope = pickle.loads(result_bytes)
|
|
68
|
+
else:
|
|
69
|
+
payload = cloudpickle.dumps(
|
|
70
|
+
{"func": func, "args": args, "kwargs": kwargs}
|
|
71
|
+
)
|
|
72
|
+
result_bytes = runner_obj.run(payload)
|
|
73
|
+
envelope = cloudpickle.loads(result_bytes)
|
|
74
|
+
|
|
75
|
+
if envelope["status"] == "ok":
|
|
76
|
+
return envelope["value"]
|
|
77
|
+
raise envelope["exception"]
|
|
78
|
+
|
|
79
|
+
wrapper.__yonder__ = True
|
|
80
|
+
wrapper.__yonder_runner__ = runner
|
|
81
|
+
# Reference-mode bootstrap reads this to skip the wrapper and call
|
|
82
|
+
# the original function inside the container.
|
|
83
|
+
wrapper.__yonder_inner__ = func
|
|
84
|
+
return wrapper
|
|
85
|
+
|
|
86
|
+
return decorate
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _validate_reference(func) -> None:
|
|
90
|
+
if func.__qualname__ != func.__name__:
|
|
91
|
+
raise TypeError(
|
|
92
|
+
f"reference-mode runners require module-scope functions, but "
|
|
93
|
+
f"`{func.__qualname__}` is defined inside another scope. Move "
|
|
94
|
+
f"the function to the top of a module file (e.g. tasks.py) and "
|
|
95
|
+
f"import it where you need it.\n"
|
|
96
|
+
f" docs: {_REF_MODE_DOCS}\n"
|
|
97
|
+
f" example: {_REF_MODE_EXAMPLE}"
|
|
98
|
+
)
|
|
99
|
+
if func.__module__ == "__main__":
|
|
100
|
+
raise TypeError(
|
|
101
|
+
f"reference-mode runners cannot ship `{func.__name__}` because "
|
|
102
|
+
f"it is defined in the same script you're running "
|
|
103
|
+
f"(module `__main__`). The container would have to re-execute "
|
|
104
|
+
f"your script to import it, which loops back through this code "
|
|
105
|
+
f"and recurses. Move `{func.__name__}` into a separate "
|
|
106
|
+
f"importable module (e.g. tasks.py) and import it from your "
|
|
107
|
+
f"script — see the rdkit example below.\n"
|
|
108
|
+
f" docs: {_REF_MODE_DOCS}\n"
|
|
109
|
+
f" example: {_REF_MODE_EXAMPLE}"
|
|
110
|
+
)
|
yonder/paths.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Path translation between host and runner filesystems.
|
|
2
|
+
|
|
3
|
+
Users wrap a host-side filesystem path in :class:`HostPath` to tell the
|
|
4
|
+
decorator "translate this against the runner's mount table when this
|
|
5
|
+
call dispatches remotely; pass it through unchanged when it runs
|
|
6
|
+
locally." ``HostPath`` is a ``str`` subclass, so a ``HostPath`` always
|
|
7
|
+
works anywhere a path string works — ``open(p)``, ``pathlib.Path(p)``,
|
|
8
|
+
``os.path.join(p, ...)`` — without unwrapping.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HostPath(str):
|
|
17
|
+
"""A host-filesystem path that yonder should translate to its
|
|
18
|
+
in-runner equivalent when a call dispatches remotely.
|
|
19
|
+
|
|
20
|
+
Inherits from :class:`str`, so it can be used anywhere a path string
|
|
21
|
+
can::
|
|
22
|
+
|
|
23
|
+
runner = DockerRunner(image="...", mounts={"/home/jason/data": "/data"})
|
|
24
|
+
|
|
25
|
+
@yonder(runner="r")
|
|
26
|
+
def process(path: str) -> str:
|
|
27
|
+
return open(path).read()
|
|
28
|
+
|
|
29
|
+
process(HostPath("/home/jason/data/file.txt"))
|
|
30
|
+
# remote dispatch: decorator rewrites to "/data/file.txt"
|
|
31
|
+
# local dispatch (runner.when() returned False): passes through as
|
|
32
|
+
# "/home/jason/data/file.txt"
|
|
33
|
+
|
|
34
|
+
If no mount on the resolved runner covers the path, translation
|
|
35
|
+
raises :class:`ValueError` before any payload is shipped.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __new__(cls, value):
|
|
39
|
+
return super().__new__(cls, os.fspath(value))
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
42
|
+
return f"HostPath({str.__repr__(self)})"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def translate(value: Any, translate_fn: Callable[[str], str]) -> Any:
|
|
46
|
+
"""Walk ``value`` and apply ``translate_fn`` to every :class:`HostPath`
|
|
47
|
+
found inside it.
|
|
48
|
+
|
|
49
|
+
Recurses into ``list``, ``tuple``, and ``dict`` so wrapping a path
|
|
50
|
+
inside an ordinary container still gets translated. Other types pass
|
|
51
|
+
through unchanged — this is opt-in via ``HostPath``, not a blanket
|
|
52
|
+
string-rewriter.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(value, HostPath):
|
|
55
|
+
return translate_fn(value)
|
|
56
|
+
if isinstance(value, list):
|
|
57
|
+
return [translate(v, translate_fn) for v in value]
|
|
58
|
+
if isinstance(value, tuple):
|
|
59
|
+
return tuple(translate(v, translate_fn) for v in value)
|
|
60
|
+
if isinstance(value, dict):
|
|
61
|
+
return {k: translate(v, translate_fn) for k, v in value.items()}
|
|
62
|
+
return value
|
yonder/runners/base.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Callable, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Runner(ABC):
|
|
8
|
+
"""A self-contained execution target for a yonder-decorated function.
|
|
9
|
+
|
|
10
|
+
Concrete runners hold their own configuration (image, env, mounts, etc.)
|
|
11
|
+
and expose a single :meth:`run` method that takes a cloudpickled payload
|
|
12
|
+
of the form ``{"func": ..., "args": (...), "kwargs": {...}}`` and returns
|
|
13
|
+
the cloudpickled envelope produced by the in-container bootstrap.
|
|
14
|
+
|
|
15
|
+
:meth:`start` and :meth:`close` are optional lifecycle hooks; the default
|
|
16
|
+
implementations are no-ops, which is correct for stateless / on-demand
|
|
17
|
+
runners. Long-lived runners override them to manage their container.
|
|
18
|
+
|
|
19
|
+
``when`` is an optional zero-arg predicate stored on the runner itself.
|
|
20
|
+
If set and it returns ``False``, the decorator skips this runner and
|
|
21
|
+
runs the function locally — independent of any ``when=`` passed to the
|
|
22
|
+
``@yonder`` decorator. It composes with the decorator's predicate as
|
|
23
|
+
AND: the runner is used only when both return ``True``.
|
|
24
|
+
|
|
25
|
+
``mode`` selects the transport. ``"cloudpickle"`` (default) ships the
|
|
26
|
+
function as bytecode via cloudpickle; the host and container Pythons
|
|
27
|
+
must match minor versions. ``"reference"`` ships the function as
|
|
28
|
+
``(module, qualname, args, kwargs)`` and lets the container import
|
|
29
|
+
its own copy — bytecode never crosses the wire and host/container
|
|
30
|
+
Python versions can differ. Reference mode requires the function to
|
|
31
|
+
be defined at module scope in a non-``__main__`` module.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
when: Optional[Callable[[], bool]] = None
|
|
35
|
+
mode: str = "cloudpickle"
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def run(self, payload: bytes) -> bytes: ...
|
|
39
|
+
|
|
40
|
+
def translate_host_path(self, path: str) -> str:
|
|
41
|
+
"""Translate a host-side path to its equivalent inside the runner.
|
|
42
|
+
|
|
43
|
+
Called by the decorator on every :class:`yonder.HostPath` it finds
|
|
44
|
+
in a call's args/kwargs before dispatching. Default implementation
|
|
45
|
+
raises — subclasses that mount host paths into the runner (e.g.
|
|
46
|
+
:class:`yonder.DockerRunner`) should override.
|
|
47
|
+
|
|
48
|
+
Should raise :class:`ValueError` if no mount on this runner covers
|
|
49
|
+
``path``.
|
|
50
|
+
"""
|
|
51
|
+
raise NotImplementedError(
|
|
52
|
+
f"{type(self).__name__} does not support HostPath translation. "
|
|
53
|
+
f"Pass the in-runner path directly instead."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def start(self) -> None:
|
|
57
|
+
"""Optional eager initialization. Default no-op."""
|
|
58
|
+
|
|
59
|
+
def close(self) -> None:
|
|
60
|
+
"""Optional cleanup. Default no-op."""
|
|
61
|
+
|
|
62
|
+
def __enter__(self):
|
|
63
|
+
self.start()
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, exc_type, exc, tb):
|
|
67
|
+
self.close()
|