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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
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
@@ -0,0 +1,5 @@
1
+ from .base import Runner
2
+ from .docker import DockerRunner
3
+ from .k8s import K8sRunner
4
+
5
+ __all__ = ["Runner", "DockerRunner", "K8sRunner"]
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()