run-yonder 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.
- run_yonder-0.1.0/.claude/worktrees/logo-icon-variants/README.rst +244 -0
- run_yonder-0.1.0/.claude/worktrees/logo-icon-variants/doc/README.rst +4 -0
- run_yonder-0.1.0/.claude/worktrees/logo-icon-variants/examples/basic/README.rst +26 -0
- run_yonder-0.1.0/.claude/worktrees/logo-icon-variants/examples/rdkit/README.rst +104 -0
- run_yonder-0.1.0/.claude/worktrees/logo-icon-variants/pyproject.toml +58 -0
- run_yonder-0.1.0/.gitignore +11 -0
- run_yonder-0.1.0/PKG-INFO +271 -0
- run_yonder-0.1.0/README.rst +244 -0
- run_yonder-0.1.0/doc/README.rst +4 -0
- run_yonder-0.1.0/examples/basic/README.rst +26 -0
- run_yonder-0.1.0/examples/rdkit/README.rst +104 -0
- run_yonder-0.1.0/pyproject.toml +58 -0
- run_yonder-0.1.0/src/yonder/__init__.py +33 -0
- run_yonder-0.1.0/src/yonder/decorator.py +110 -0
- run_yonder-0.1.0/src/yonder/paths.py +62 -0
- run_yonder-0.1.0/src/yonder/runners/__init__.py +5 -0
- run_yonder-0.1.0/src/yonder/runners/base.py +67 -0
- run_yonder-0.1.0/src/yonder/runners/docker.py +634 -0
- run_yonder-0.1.0/src/yonder/runners/k8s.py +266 -0
- run_yonder-0.1.0/src/yonder/session.py +148 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
.. image:: doc/assets/images/logo.svg
|
|
2
|
+
:alt: yonder
|
|
3
|
+
:align: center
|
|
4
|
+
:width: 380px
|
|
5
|
+
|
|
6
|
+
|
|
|
7
|
+
|
|
8
|
+
yonder
|
|
9
|
+
======
|
|
10
|
+
|
|
11
|
+
Run Python functions on container-backed runners via a decorator.
|
|
12
|
+
Functions and their arguments are serialized with
|
|
13
|
+
`cloudpickle <https://github.com/cloudpipe/cloudpickle>`_, shipped to the
|
|
14
|
+
runner, executed there, and the result is shipped back.
|
|
15
|
+
|
|
16
|
+
Install
|
|
17
|
+
-------
|
|
18
|
+
|
|
19
|
+
.. code:: bash
|
|
20
|
+
|
|
21
|
+
# core package
|
|
22
|
+
pip install -e .
|
|
23
|
+
|
|
24
|
+
# with the bundled Docker runner
|
|
25
|
+
pip install -e ".[docker]"
|
|
26
|
+
|
|
27
|
+
# with the bundled Kubernetes runner
|
|
28
|
+
pip install -e ".[k8s]"
|
|
29
|
+
|
|
30
|
+
Quick start
|
|
31
|
+
-----------
|
|
32
|
+
|
|
33
|
+
.. code:: python
|
|
34
|
+
|
|
35
|
+
import yonder
|
|
36
|
+
from yonder import DockerRunner
|
|
37
|
+
|
|
38
|
+
# Existing image. If the image doesn't already have cloudpickle, set
|
|
39
|
+
# inject_cloudpickle=True to derive an image that does (cached across
|
|
40
|
+
# runs, keyed by the base image). `when=` is an optional zero-arg
|
|
41
|
+
# predicate that makes the runner a no-op (calls run locally) when it
|
|
42
|
+
# returns False — useful for "use the container only if the library
|
|
43
|
+
# isn't already installed on the host".
|
|
44
|
+
runnerA = DockerRunner(
|
|
45
|
+
image="url/to/image",
|
|
46
|
+
env={"ENV_VARA": "value"},
|
|
47
|
+
mounts={"/host/path": "/in/container"},
|
|
48
|
+
inject_cloudpickle=True,
|
|
49
|
+
when=lambda: liba_not_present(),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Build from a Dockerfile (context defaults to the Dockerfile's directory).
|
|
53
|
+
runnerB = DockerRunner(
|
|
54
|
+
dockerfile="./docker/myimg.Dockerfile",
|
|
55
|
+
build_args={"VERSION": "1.2.3"},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# On-demand: fresh container per call instead of one long-lived container.
|
|
59
|
+
ephemeral = DockerRunner(image="url/to/image", on_demand=True)
|
|
60
|
+
|
|
61
|
+
# Attach to a container you manage outside yonder. yonder will start it
|
|
62
|
+
# if it's stopped, but never removes it on close.
|
|
63
|
+
external = DockerRunner(container="my-dev-container")
|
|
64
|
+
|
|
65
|
+
# Register them by name.
|
|
66
|
+
yonder.register({
|
|
67
|
+
"runnerA": runnerA,
|
|
68
|
+
"runnerB": runnerB,
|
|
69
|
+
"ephemeral": ephemeral,
|
|
70
|
+
"external": external,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
# Decorate. Dispatch policy lives on the runner (via the runner's
|
|
74
|
+
# `when=`); the decorator just names the target.
|
|
75
|
+
@yonder.yonder(runner="runnerA")
|
|
76
|
+
def functionA(a, b):
|
|
77
|
+
return a + b
|
|
78
|
+
|
|
79
|
+
@yonder.yonder(runner="runnerB")
|
|
80
|
+
def functionB(a, b):
|
|
81
|
+
return a * b
|
|
82
|
+
|
|
83
|
+
Lazy start by default
|
|
84
|
+
~~~~~~~~~~~~~~~~~~~~~
|
|
85
|
+
|
|
86
|
+
A registered runner is **not** built/started until it's actually needed.
|
|
87
|
+
The first decorated call that targets a runner triggers its ``start()``
|
|
88
|
+
(image build/pull, persistent container creation). Runners that are never
|
|
89
|
+
called incur no cost.
|
|
90
|
+
|
|
91
|
+
You can pre-warm explicitly when you'd rather pay startup cost up front:
|
|
92
|
+
|
|
93
|
+
.. code:: python
|
|
94
|
+
|
|
95
|
+
runnerA.start() # pre-warm a specific runner
|
|
96
|
+
yonder.wait() # pre-warm every registered runner (parallel)
|
|
97
|
+
|
|
98
|
+
Both are opt-in. On-demand runners always behave the same way — they spin
|
|
99
|
+
up a fresh container per call regardless of pre-warming.
|
|
100
|
+
|
|
101
|
+
Tear down
|
|
102
|
+
~~~~~~~~~
|
|
103
|
+
|
|
104
|
+
Tear down when done:
|
|
105
|
+
|
|
106
|
+
.. code:: python
|
|
107
|
+
|
|
108
|
+
yonder.closeAll()
|
|
109
|
+
# or, per-runner:
|
|
110
|
+
runnerA.close()
|
|
111
|
+
|
|
112
|
+
``DockerRunner`` is also a context manager:
|
|
113
|
+
|
|
114
|
+
.. code:: python
|
|
115
|
+
|
|
116
|
+
with DockerRunner(image="python:3.11-slim") as r:
|
|
117
|
+
yonder.register({"tmp": r})
|
|
118
|
+
yonder.wait()
|
|
119
|
+
...
|
|
120
|
+
# container removed on exit
|
|
121
|
+
|
|
122
|
+
DockerRunner options
|
|
123
|
+
--------------------
|
|
124
|
+
|
|
125
|
+
.. code:: python
|
|
126
|
+
|
|
127
|
+
DockerRunner(
|
|
128
|
+
# Exactly one of these three is required:
|
|
129
|
+
image=None, # use/pull an existing image; yonder creates the container
|
|
130
|
+
dockerfile=None, # build an image locally first; yonder creates the container
|
|
131
|
+
container=None, # attach to an existing container (managed outside yonder)
|
|
132
|
+
|
|
133
|
+
build_context=None, # override the build context directory (dockerfile mode)
|
|
134
|
+
build_args=None, # dict of Docker ARGs (dockerfile mode)
|
|
135
|
+
|
|
136
|
+
env=None, # environment variables (image/dockerfile mode only)
|
|
137
|
+
mounts=None, # {host_path: container_path} (image/dockerfile mode only)
|
|
138
|
+
on_demand=False, # one-shot container per call (image/dockerfile mode only)
|
|
139
|
+
name=None, # container/tag name (auto-generated if omitted)
|
|
140
|
+
inject_cloudpickle=False, # derive `FROM image + RUN pip install cloudpickle`
|
|
141
|
+
# (image mode only; tagged by base-image hash so the
|
|
142
|
+
# docker layer cache makes re-runs cheap)
|
|
143
|
+
client=None, # pre-built docker.DockerClient (else docker.from_env())
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
K8sRunner
|
|
147
|
+
---------
|
|
148
|
+
|
|
149
|
+
Attach to an existing Kubernetes pod and exec into one of its containers.
|
|
150
|
+
Pods are treated like externally-managed containers — yonder never creates
|
|
151
|
+
or removes them.
|
|
152
|
+
|
|
153
|
+
.. code:: python
|
|
154
|
+
|
|
155
|
+
from yonder import K8sRunner
|
|
156
|
+
|
|
157
|
+
# By pod name.
|
|
158
|
+
by_name = K8sRunner(
|
|
159
|
+
pod="my-pod-abc123",
|
|
160
|
+
namespace="default",
|
|
161
|
+
container="app", # optional; defaults to the pod's first container
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# By label selector — first Running pod that matches is used.
|
|
165
|
+
by_selector = K8sRunner(
|
|
166
|
+
selector="app=my-app,env=dev",
|
|
167
|
+
namespace="default",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
.. code:: python
|
|
171
|
+
|
|
172
|
+
K8sRunner(
|
|
173
|
+
# Exactly one of these two is required:
|
|
174
|
+
pod=None, # attach by pod name
|
|
175
|
+
selector=None, # attach by label selector
|
|
176
|
+
|
|
177
|
+
namespace="default", # pod namespace
|
|
178
|
+
container=None, # container within the pod (default: first)
|
|
179
|
+
kubeconfig=None, # kubeconfig path (else KUBECONFIG/~/.kube/config,
|
|
180
|
+
# falling back to in-cluster config)
|
|
181
|
+
context=None, # kubeconfig context
|
|
182
|
+
name=None, # runner name (auto-derived if omitted)
|
|
183
|
+
api_client=None, # pre-built kubernetes.client.ApiClient
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
The target container must have ``python`` on ``PATH`` and ``cloudpickle``
|
|
187
|
+
installed (same Python minor version as the host is strongly recommended).
|
|
188
|
+
|
|
189
|
+
Architecture
|
|
190
|
+
------------
|
|
191
|
+
|
|
192
|
+
- **``Runner``** (``yonder.Runner``) — abstract base. Implementations hold
|
|
193
|
+
their own config and expose ``run(payload) -> bytes``, plus optional
|
|
194
|
+
``start()`` / ``close()`` lifecycle hooks.
|
|
195
|
+
- **``DockerRunner``** — default implementation; uses the
|
|
196
|
+
`Docker SDK for Python <https://docker-py.readthedocs.io/>`_. Supports
|
|
197
|
+
existing images, Dockerfile builds, and attaching to externally-managed
|
|
198
|
+
containers; persistent and on-demand modes.
|
|
199
|
+
- **``K8sRunner``** — Kubernetes implementation; uses the
|
|
200
|
+
`Kubernetes Python client <https://github.com/kubernetes-client/python>`_
|
|
201
|
+
to exec into a pod selected by name or label selector.
|
|
202
|
+
- **``YonderSession``** — process-wide name→runner registry. Populate with
|
|
203
|
+
``yonder.register({...})``, bring everything online with
|
|
204
|
+
``yonder.wait()``, tear everything down with ``yonder.closeAll()``.
|
|
205
|
+
- **Decorator** — serializes ``(func, args, kwargs)`` with cloudpickle,
|
|
206
|
+
calls the named runner's ``run(payload)``, deserializes the envelope, and
|
|
207
|
+
either returns the value or re-raises the exception.
|
|
208
|
+
|
|
209
|
+
Writing your own Runner
|
|
210
|
+
-----------------------
|
|
211
|
+
|
|
212
|
+
.. code:: python
|
|
213
|
+
|
|
214
|
+
from yonder import Runner, register
|
|
215
|
+
|
|
216
|
+
class MyRunner(Runner):
|
|
217
|
+
def __init__(self, **config):
|
|
218
|
+
...
|
|
219
|
+
|
|
220
|
+
def start(self):
|
|
221
|
+
# eager init (build/pull image, warm container, etc.)
|
|
222
|
+
...
|
|
223
|
+
|
|
224
|
+
def run(self, payload: bytes) -> bytes:
|
|
225
|
+
# send payload to your execution environment,
|
|
226
|
+
# return the cloudpickled envelope
|
|
227
|
+
...
|
|
228
|
+
|
|
229
|
+
def close(self):
|
|
230
|
+
...
|
|
231
|
+
|
|
232
|
+
register({"mine": MyRunner(...)})
|
|
233
|
+
|
|
234
|
+
Container requirements
|
|
235
|
+
----------------------
|
|
236
|
+
|
|
237
|
+
Images used with ``DockerRunner`` must have:
|
|
238
|
+
|
|
239
|
+
- ``python`` on ``PATH``
|
|
240
|
+
- ``cloudpickle`` installed (``pip install cloudpickle``). If your base
|
|
241
|
+
image doesn't have it, pass ``inject_cloudpickle=True`` and yonder will
|
|
242
|
+
build a derived image that does (one-time, cached).
|
|
243
|
+
- Ideally the same Python minor version as the host (cloudpickle pickles
|
|
244
|
+
function bytecode, which is not portable across CPython minor versions).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
basic yonder example
|
|
2
|
+
====================
|
|
3
|
+
|
|
4
|
+
Builds a minimal image (``python:3.x-slim`` + ``cloudpickle``) and runs a
|
|
5
|
+
single function either locally or inside the container.
|
|
6
|
+
|
|
7
|
+
Run
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
From the repo root:
|
|
11
|
+
|
|
12
|
+
.. code:: bash
|
|
13
|
+
|
|
14
|
+
pip install -e ".[docker]"
|
|
15
|
+
|
|
16
|
+
# local execution (runner.when() returns False, function runs in this process)
|
|
17
|
+
python examples/basic/main.py
|
|
18
|
+
|
|
19
|
+
# remote execution (function runs inside the freshly built container)
|
|
20
|
+
python examples/basic/main.py --remote
|
|
21
|
+
|
|
22
|
+
# crank up logging to see the build + start + exec steps
|
|
23
|
+
python examples/basic/main.py --remote --log-level DEBUG
|
|
24
|
+
|
|
25
|
+
Compare the ``hostname`` / ``python`` lines between local and remote
|
|
26
|
+
output to confirm the function actually ran in the container.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
rdkit yonder example
|
|
2
|
+
====================
|
|
3
|
+
|
|
4
|
+
Demonstrates yonder's **reference mode**: a transport that ships the
|
|
5
|
+
function as ``(module, qualname, args, kwargs)`` instead of cloudpickled
|
|
6
|
+
bytecode, so host and container Python minor versions can differ.
|
|
7
|
+
|
|
8
|
+
rdkit is the perfect case study. Its pip wheels stop at CPython 3.13, so
|
|
9
|
+
a 3.14 host can't ship cloudpickled functions to a 3.13 rdkit container
|
|
10
|
+
— the in-container unpickler errors with::
|
|
11
|
+
|
|
12
|
+
TypeError: code() takes at most 16 arguments (18 given)
|
|
13
|
+
|
|
14
|
+
Reference mode just imports the module under the container's own Python;
|
|
15
|
+
no bytecode crosses the wire.
|
|
16
|
+
|
|
17
|
+
Layout
|
|
18
|
+
------
|
|
19
|
+
|
|
20
|
+
::
|
|
21
|
+
|
|
22
|
+
examples/rdkit/
|
|
23
|
+
├── Dockerfile python:3.13-slim + rdkit (+ cloudpickle, for the --try-cloudpickle path)
|
|
24
|
+
├── main.py script: configures the runner, calls the function
|
|
25
|
+
├── tasks.py module: defines molecule_properties at module scope
|
|
26
|
+
└── README.rst
|
|
27
|
+
|
|
28
|
+
The function lives in `tasks.py <tasks.py>`_ (not `main.py <main.py>`_)
|
|
29
|
+
because reference mode requires the function to be importable — it can't
|
|
30
|
+
live inside ``main()`` and it can't live in ``__main__``.
|
|
31
|
+
|
|
32
|
+
Run
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
From the repo root:
|
|
36
|
+
|
|
37
|
+
.. code:: bash
|
|
38
|
+
|
|
39
|
+
pip install -e ".[docker]"
|
|
40
|
+
|
|
41
|
+
# reference mode (default): works regardless of host Python version
|
|
42
|
+
python examples/rdkit/main.py
|
|
43
|
+
python examples/rdkit/main.py --smiles "CCO" # ethanol
|
|
44
|
+
python examples/rdkit/main.py --smiles "c1ccccc1" # benzene
|
|
45
|
+
|
|
46
|
+
# cloudpickle mode: demonstrates the version-mismatch failure
|
|
47
|
+
python examples/rdkit/main.py --try-cloudpickle
|
|
48
|
+
|
|
49
|
+
# crank up logging to watch the build + start + exec steps
|
|
50
|
+
python examples/rdkit/main.py --log-level DEBUG
|
|
51
|
+
|
|
52
|
+
``--try-cloudpickle`` builds the same image and runs the same function —
|
|
53
|
+
the only difference is the runner's ``mode=``. On a host whose Python
|
|
54
|
+
minor version doesn't match the container's (3.13), the cloudpickle path
|
|
55
|
+
fails with a clear ``RuntimeError`` from yonder's pre-flight version
|
|
56
|
+
check::
|
|
57
|
+
|
|
58
|
+
RuntimeError: Python minor version mismatch: container ... runs Python
|
|
59
|
+
3.13, host runs Python 3.14. cloudpickle pickles function bytecode,
|
|
60
|
+
which is not portable across CPython minor versions. Fix by running
|
|
61
|
+
yonder from a Python 3.13 venv on the host, or use/build an image whose
|
|
62
|
+
Python is 3.14.
|
|
63
|
+
|
|
64
|
+
The reference-mode run succeeds on the same host with the same image.
|
|
65
|
+
|
|
66
|
+
How reference mode works here
|
|
67
|
+
-----------------------------
|
|
68
|
+
|
|
69
|
+
1. `main.py <main.py>`_ configures the runner with ``mode="reference"``
|
|
70
|
+
and ``source_path="auto"``. The ``"auto"`` sentinel resolves to the
|
|
71
|
+
directory of the file that constructed the runner (here, the example
|
|
72
|
+
directory). That directory is mounted into the container at
|
|
73
|
+
``/workspace/rdkit`` and prepended to ``PYTHONPATH``, so the
|
|
74
|
+
container can ``import tasks``.
|
|
75
|
+
2. The host calls ``molecule_properties("CCO")``. The decorator sees the
|
|
76
|
+
runner is in reference mode, validates the function is at module
|
|
77
|
+
scope (it is — ``tasks.molecule_properties``), and sends a
|
|
78
|
+
stdlib-pickle payload of ``{"module": "tasks", "qualname":
|
|
79
|
+
"molecule_properties", "args": ..., "kwargs": ...}``.
|
|
80
|
+
3. The container's bootstrap (still Python 3.13) installs a no-op
|
|
81
|
+
``yonder`` shim (since yonder isn't pip-installed inside the rdkit
|
|
82
|
+
image), imports ``tasks``, resolves ``molecule_properties``, and
|
|
83
|
+
calls it. rdkit runs against the container's own Python 3.13 — no
|
|
84
|
+
cross-version bytecode anywhere.
|
|
85
|
+
4. The result is ``pickle.dumps``-ed back over stdout and unpacked on
|
|
86
|
+
the host.
|
|
87
|
+
|
|
88
|
+
When to use which mode
|
|
89
|
+
----------------------
|
|
90
|
+
|
|
91
|
+
.. list-table::
|
|
92
|
+
:header-rows: 1
|
|
93
|
+
:widths: 50 50
|
|
94
|
+
|
|
95
|
+
* - Use cloudpickle (default)
|
|
96
|
+
- Use reference
|
|
97
|
+
* - Host and container Python versions match
|
|
98
|
+
- Versions can or must differ
|
|
99
|
+
* - You want to decorate inline functions inside ``main()``
|
|
100
|
+
- You can put the function at module scope
|
|
101
|
+
* - The function is a lambda, closure, or anything not importable
|
|
102
|
+
- The function is in an importable module
|
|
103
|
+
* - You want unsaved editor-buffer changes to ship
|
|
104
|
+
- OK with the container reading from disk
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "yonder"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Run Python functions inside containers via a decorator."
|
|
9
|
+
readme = "README.rst"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Jason Berger", email = "berge472@gmail.com" }]
|
|
13
|
+
keywords = ["container", "docker", "cloudpickle", "decorator", "remote-execution"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"cloudpickle>=3.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
docker = [
|
|
28
|
+
"docker>=7.0",
|
|
29
|
+
]
|
|
30
|
+
k8s = [
|
|
31
|
+
"kubernetes>=28.0",
|
|
32
|
+
]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7",
|
|
35
|
+
"ruff>=0.5",
|
|
36
|
+
"docker>=7.0",
|
|
37
|
+
"kubernetes>=28.0",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Source = "https://gitlab.com/berge472/yonder"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/yonder"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = ["src/yonder", "README.rst", "pyproject.toml"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
addopts = "-ra"
|
|
52
|
+
markers = [
|
|
53
|
+
"docker: integration test that requires a running Docker daemon",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 100
|
|
58
|
+
target-version = "py39"
|