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.
@@ -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,4 @@
1
+ Yonder
2
+ ======
3
+
4
+ This is the documentation package for Yonder.
@@ -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"
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ .env
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/