offwork 0.4.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.
- offwork/__init__.py +167 -0
- offwork/__main__.py +770 -0
- offwork/_venv.py +174 -0
- offwork/core/__init__.py +15 -0
- offwork/core/errors.py +83 -0
- offwork/core/models.py +174 -0
- offwork/core/pairing.py +389 -0
- offwork/core/progress.py +91 -0
- offwork/core/signing.py +91 -0
- offwork/core/task.py +520 -0
- offwork/core/token.py +184 -0
- offwork/core/version.py +10 -0
- offwork/graph/__init__.py +5 -0
- offwork/graph/analyzer.py +637 -0
- offwork/graph/decorator.py +87 -0
- offwork/graph/graph.py +995 -0
- offwork/graph/store.py +500 -0
- offwork/graph/tracing.py +429 -0
- offwork/py.typed +0 -0
- offwork/typing.py +48 -0
- offwork/worker/__init__.py +18 -0
- offwork/worker/backends/__init__.py +3 -0
- offwork/worker/backends/base.py +149 -0
- offwork/worker/backends/http.py +237 -0
- offwork/worker/backends/local.py +452 -0
- offwork/worker/backends/rabbitmq.py +410 -0
- offwork/worker/backends/redis.py +175 -0
- offwork/worker/deps.py +365 -0
- offwork/worker/remote.py +793 -0
- offwork/worker/result.py +276 -0
- offwork/worker/sandbox/Dockerfile +24 -0
- offwork/worker/sandbox/__init__.py +18 -0
- offwork/worker/sandbox/_protocol.py +50 -0
- offwork/worker/sandbox/docker.py +438 -0
- offwork/worker/sandbox/guest_agent.py +622 -0
- offwork/worker/schedule.py +26 -0
- offwork/worker/worker.py +263 -0
- offwork-0.4.0.dist-info/METADATA +143 -0
- offwork-0.4.0.dist-info/RECORD +42 -0
- offwork-0.4.0.dist-info/WHEEL +4 -0
- offwork-0.4.0.dist-info/entry_points.txt +3 -0
- offwork-0.4.0.dist-info/licenses/LICENSE +661 -0
offwork/worker/deps.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Third-party dependency extraction and pip installation."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import sys
|
|
5
|
+
import types
|
|
6
|
+
import inspect
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import contextlib
|
|
10
|
+
import importlib.abc
|
|
11
|
+
import importlib.util
|
|
12
|
+
import importlib.machinery
|
|
13
|
+
from typing import Any
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from dataclasses import field, dataclass
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
|
|
18
|
+
from offwork.core.errors import DependencyError, WorkerOnlyError
|
|
19
|
+
from offwork.graph.store import Store
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
DEFAULT_IMPORT_TO_PACKAGE: dict[str, str] = {
|
|
24
|
+
"cv2": "opencv-python",
|
|
25
|
+
"PIL": "Pillow",
|
|
26
|
+
"sklearn": "scikit-learn",
|
|
27
|
+
"bs4": "beautifulsoup4",
|
|
28
|
+
"yaml": "PyYAML",
|
|
29
|
+
"attr": "attrs",
|
|
30
|
+
"dateutil": "python-dateutil",
|
|
31
|
+
"gi": "PyGObject",
|
|
32
|
+
"Crypto": "pycryptodome",
|
|
33
|
+
"serial": "pyserial",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextlib.contextmanager
|
|
38
|
+
def install_package_as(package: str) -> Iterator[None]:
|
|
39
|
+
"""Declare the pip package name for imports inside this block.
|
|
40
|
+
|
|
41
|
+
At runtime this is a no-op -- the import executes normally. The
|
|
42
|
+
``@trace`` analyzer detects the ``with`` block in the AST and records
|
|
43
|
+
the *package* name on every :class:`ImportInfo` inside it, so the
|
|
44
|
+
worker knows which pip package to install.
|
|
45
|
+
|
|
46
|
+
Example::
|
|
47
|
+
|
|
48
|
+
with install_package_as('opencv-python'):
|
|
49
|
+
import cv2
|
|
50
|
+
"""
|
|
51
|
+
yield
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Worker-only imports: stub the package on the client, install on the worker
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _WorkerOnlyStub(types.ModuleType):
|
|
60
|
+
"""Module subclass that raises ``WorkerOnlyError`` on any real use."""
|
|
61
|
+
|
|
62
|
+
__offwork_stub__ = True
|
|
63
|
+
|
|
64
|
+
def _err(self) -> WorkerOnlyError:
|
|
65
|
+
return WorkerOnlyError(
|
|
66
|
+
f"'{self.__name__}' was imported with worker_only_import; "
|
|
67
|
+
"it must only be used inside a @trace function executed on a worker"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def __getattr__(self, name: str) -> Any:
|
|
71
|
+
if name.startswith("__") and name.endswith("__"):
|
|
72
|
+
raise AttributeError(name)
|
|
73
|
+
attr = _StubAttr(f"{self.__name__}.{name}")
|
|
74
|
+
setattr(self, name, attr)
|
|
75
|
+
return attr
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _StubAttr:
|
|
79
|
+
"""Placeholder returned for any attribute access on a stub module."""
|
|
80
|
+
|
|
81
|
+
__offwork_stub__ = True
|
|
82
|
+
|
|
83
|
+
def __init__(self, qualname: str) -> None:
|
|
84
|
+
self._qualname = qualname
|
|
85
|
+
|
|
86
|
+
def _err(self) -> WorkerOnlyError:
|
|
87
|
+
return WorkerOnlyError(
|
|
88
|
+
f"'{self._qualname}' was imported with worker_only_import; "
|
|
89
|
+
"it must only be used inside a @trace function executed on a worker"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
93
|
+
raise self._err()
|
|
94
|
+
|
|
95
|
+
def __getattr__(self, name: str) -> Any:
|
|
96
|
+
if name.startswith("__") and name.endswith("__"):
|
|
97
|
+
raise AttributeError(name)
|
|
98
|
+
return _StubAttr(f"{self._qualname}.{name}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _WorkerOnlyFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
|
102
|
+
"""Meta-path finder that fabricates stubs for an explicit module whitelist.
|
|
103
|
+
|
|
104
|
+
Inserted at the *end* of ``sys.meta_path`` so real installed packages
|
|
105
|
+
still win. Only stubs modules whose top-level name appears in the
|
|
106
|
+
whitelist (built from the caller's ``with`` block source), so missing
|
|
107
|
+
transitive imports from real installed packages still raise the normal
|
|
108
|
+
``ModuleNotFoundError`` instead of being silently stubbed.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self) -> None:
|
|
112
|
+
self.allowed: set[str] = set()
|
|
113
|
+
|
|
114
|
+
def find_spec(
|
|
115
|
+
self,
|
|
116
|
+
fullname: str,
|
|
117
|
+
path: Any = None,
|
|
118
|
+
target: types.ModuleType | None = None,
|
|
119
|
+
) -> importlib.machinery.ModuleSpec | None:
|
|
120
|
+
top = fullname.split(".", 1)[0]
|
|
121
|
+
if top not in self.allowed:
|
|
122
|
+
return None
|
|
123
|
+
return importlib.machinery.ModuleSpec(fullname, self, is_package=True)
|
|
124
|
+
|
|
125
|
+
def create_module(
|
|
126
|
+
self, spec: importlib.machinery.ModuleSpec
|
|
127
|
+
) -> types.ModuleType | None:
|
|
128
|
+
module = _WorkerOnlyStub(spec.name)
|
|
129
|
+
module.__path__ = [] # mark as package so submodule imports work
|
|
130
|
+
return module
|
|
131
|
+
|
|
132
|
+
def exec_module(self, module: types.ModuleType) -> None:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_worker_only_finder: _WorkerOnlyFinder | None = None
|
|
137
|
+
_worker_only_depth: int = 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _get_worker_only_finder() -> _WorkerOnlyFinder:
|
|
141
|
+
global _worker_only_finder
|
|
142
|
+
if _worker_only_finder is None:
|
|
143
|
+
_worker_only_finder = _WorkerOnlyFinder()
|
|
144
|
+
return _worker_only_finder
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _module_names_in_with_block(filename: str, lineno: int) -> set[str]:
|
|
148
|
+
"""Return top-level module names imported inside the ``with`` block at *lineno*.
|
|
149
|
+
|
|
150
|
+
Parses *filename* and finds the ``With`` AST node starting at *lineno*,
|
|
151
|
+
then collects the top-level module name of each ``Import`` /
|
|
152
|
+
``ImportFrom`` statement in its body.
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
source = Path(filename).read_text()
|
|
156
|
+
except OSError:
|
|
157
|
+
return set()
|
|
158
|
+
try:
|
|
159
|
+
tree = ast.parse(source)
|
|
160
|
+
except SyntaxError:
|
|
161
|
+
return set()
|
|
162
|
+
|
|
163
|
+
target: ast.With | None = None
|
|
164
|
+
for node in ast.walk(tree):
|
|
165
|
+
if isinstance(node, ast.With) and node.lineno == lineno:
|
|
166
|
+
target = node
|
|
167
|
+
break
|
|
168
|
+
if target is None:
|
|
169
|
+
return set()
|
|
170
|
+
|
|
171
|
+
names: set[str] = set()
|
|
172
|
+
for child in ast.walk(target):
|
|
173
|
+
if isinstance(child, ast.Import):
|
|
174
|
+
for alias in child.names:
|
|
175
|
+
names.add(alias.name.split(".")[0])
|
|
176
|
+
elif isinstance(child, ast.ImportFrom) and child.module and child.level == 0:
|
|
177
|
+
names.add(child.module.split(".")[0])
|
|
178
|
+
return names
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@contextlib.contextmanager
|
|
182
|
+
def worker_only_import(package: str | None = None) -> Iterator[None]:
|
|
183
|
+
"""Skip installing packages locally; the worker installs them on demand.
|
|
184
|
+
|
|
185
|
+
Imports inside this block resolve to lightweight stubs on the client.
|
|
186
|
+
The ``@trace`` analyzer records the imports as worker-only, and the
|
|
187
|
+
worker installs them via pip before reconstructing the function.
|
|
188
|
+
|
|
189
|
+
The optional *package* argument overrides the pip package name (same
|
|
190
|
+
semantics as :func:`install_package_as`).
|
|
191
|
+
|
|
192
|
+
Example::
|
|
193
|
+
|
|
194
|
+
with offwork.worker_only_import():
|
|
195
|
+
import requests
|
|
196
|
+
|
|
197
|
+
with offwork.worker_only_import("opencv-python-headless"):
|
|
198
|
+
import cv2
|
|
199
|
+
|
|
200
|
+
Stubs raise :class:`WorkerOnlyError` if used outside a worker context.
|
|
201
|
+
"""
|
|
202
|
+
del package # consumed by the AST analyzer, not at runtime
|
|
203
|
+
global _worker_only_depth
|
|
204
|
+
|
|
205
|
+
# Determine the whitelist by parsing the caller's with-block source.
|
|
206
|
+
frame = inspect.currentframe()
|
|
207
|
+
caller = frame.f_back.f_back if frame and frame.f_back else None
|
|
208
|
+
new_allowed: set[str] = set()
|
|
209
|
+
if caller is not None:
|
|
210
|
+
new_allowed = _module_names_in_with_block(
|
|
211
|
+
caller.f_code.co_filename, caller.f_lineno,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
finder = _get_worker_only_finder()
|
|
215
|
+
previous_allowed = set(finder.allowed)
|
|
216
|
+
finder.allowed |= new_allowed
|
|
217
|
+
if finder not in sys.meta_path:
|
|
218
|
+
sys.meta_path.append(finder)
|
|
219
|
+
_worker_only_depth += 1
|
|
220
|
+
try:
|
|
221
|
+
yield
|
|
222
|
+
finally:
|
|
223
|
+
_worker_only_depth -= 1
|
|
224
|
+
# Restore the allowed set so nested blocks behave like a stack.
|
|
225
|
+
finder.allowed = previous_allowed
|
|
226
|
+
if _worker_only_depth == 0 and finder in sys.meta_path:
|
|
227
|
+
sys.meta_path.remove(finder)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class InstallResult:
|
|
232
|
+
"""Outcome of dependency installation."""
|
|
233
|
+
|
|
234
|
+
installed: list[str] = field(default_factory=list)
|
|
235
|
+
already_present: list[str] = field(default_factory=list)
|
|
236
|
+
failed: dict[str, str] = field(default_factory=dict)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _extract_top_module(statement: str) -> str:
|
|
240
|
+
"""Parse an import statement and return the top-level module name."""
|
|
241
|
+
tree = ast.parse(statement)
|
|
242
|
+
node = tree.body[0]
|
|
243
|
+
if isinstance(node, ast.Import):
|
|
244
|
+
return node.names[0].name.split(".")[0]
|
|
245
|
+
if isinstance(node, ast.ImportFrom) and node.module:
|
|
246
|
+
return node.module.split(".")[0]
|
|
247
|
+
raise ValueError(f"Cannot extract module from: {statement}")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def extract_third_party_modules(
|
|
251
|
+
store: Store, function_name: str
|
|
252
|
+
) -> set[str]:
|
|
253
|
+
"""Return third-party module names needed by *function_name* and its deps."""
|
|
254
|
+
_target_qname, nodes = store.collect(function_name)
|
|
255
|
+
modules: set[str] = set()
|
|
256
|
+
for node in nodes.values():
|
|
257
|
+
for imp in node.imports:
|
|
258
|
+
try:
|
|
259
|
+
top = _extract_top_module(imp.statement)
|
|
260
|
+
except (ValueError, IndexError):
|
|
261
|
+
continue
|
|
262
|
+
if top and top not in sys.stdlib_module_names:
|
|
263
|
+
modules.add(top)
|
|
264
|
+
return modules
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def is_installed(module: str) -> bool:
|
|
268
|
+
"""Check whether *module* is importable."""
|
|
269
|
+
return importlib.util.find_spec(module) is not None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def _pip_install(package: str, extra_args: list[str]) -> tuple[int, str, str]:
|
|
273
|
+
"""Run ``pip install <package>`` and return (returncode, stdout, stderr)."""
|
|
274
|
+
proc = await asyncio.create_subprocess_exec(
|
|
275
|
+
sys.executable, "-m", "pip", "install", package, *extra_args,
|
|
276
|
+
stdout=asyncio.subprocess.PIPE,
|
|
277
|
+
stderr=asyncio.subprocess.PIPE,
|
|
278
|
+
)
|
|
279
|
+
stdout_bytes, stderr_bytes = await proc.communicate()
|
|
280
|
+
# returncode is None if the process hasn't terminated; treat as failure
|
|
281
|
+
returncode = proc.returncode if proc.returncode is not None else 1
|
|
282
|
+
return (
|
|
283
|
+
returncode,
|
|
284
|
+
stdout_bytes.decode() if stdout_bytes else "",
|
|
285
|
+
stderr_bytes.decode() if stderr_bytes else "",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _raise_on_failures(failed: dict[str, str]) -> None:
|
|
290
|
+
"""Raise :class:`DependencyError` if any installations failed."""
|
|
291
|
+
if not failed:
|
|
292
|
+
return
|
|
293
|
+
parts = [
|
|
294
|
+
f" {module}: {stderr[:200]}" if stderr else f" {module}"
|
|
295
|
+
for module, stderr in failed.items()
|
|
296
|
+
]
|
|
297
|
+
raise DependencyError(f"Failed to install:\n" + "\n".join(parts))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def install_packages(
|
|
301
|
+
modules: set[str],
|
|
302
|
+
import_to_package: dict[str, str] | None = None,
|
|
303
|
+
pip_args: list[str] | None = None,
|
|
304
|
+
) -> InstallResult:
|
|
305
|
+
"""Install missing packages via pip. Returns an :class:`InstallResult`.
|
|
306
|
+
|
|
307
|
+
Raises :class:`DependencyError` if any installation fails.
|
|
308
|
+
"""
|
|
309
|
+
mapping = {**DEFAULT_IMPORT_TO_PACKAGE, **(import_to_package or {})}
|
|
310
|
+
extra_args = pip_args or []
|
|
311
|
+
result = InstallResult()
|
|
312
|
+
|
|
313
|
+
for module in sorted(modules):
|
|
314
|
+
if is_installed(module):
|
|
315
|
+
result.already_present.append(module)
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
package = mapping.get(module, module)
|
|
319
|
+
logger.debug("pip install %s ...", package)
|
|
320
|
+
returncode, _stdout, stderr = await _pip_install(package, extra_args)
|
|
321
|
+
if returncode == 0:
|
|
322
|
+
result.installed.append(package)
|
|
323
|
+
logger.debug("Installed %s", package)
|
|
324
|
+
else:
|
|
325
|
+
result.failed[module] = stderr.strip()
|
|
326
|
+
logger.error("Failed to install %s: %s", package, stderr.strip()[:200])
|
|
327
|
+
|
|
328
|
+
_raise_on_failures(result.failed)
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _collect_package_hints(
|
|
333
|
+
store: Store, function_name: str
|
|
334
|
+
) -> dict[str, str]:
|
|
335
|
+
"""Extract import->package mappings from ``ImportInfo.package`` fields."""
|
|
336
|
+
_target_qname, nodes = store.collect(function_name)
|
|
337
|
+
hints: dict[str, str] = {}
|
|
338
|
+
for node in nodes.values():
|
|
339
|
+
for imp in node.imports:
|
|
340
|
+
if imp.package:
|
|
341
|
+
try:
|
|
342
|
+
top = _extract_top_module(imp.statement)
|
|
343
|
+
except (ValueError, IndexError):
|
|
344
|
+
continue
|
|
345
|
+
hints[top] = imp.package
|
|
346
|
+
return hints
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
async def ensure_dependencies(
|
|
350
|
+
store: Store,
|
|
351
|
+
function_name: str,
|
|
352
|
+
import_to_package: dict[str, str] | None = None,
|
|
353
|
+
) -> InstallResult:
|
|
354
|
+
"""Extract third-party modules and install any that are missing.
|
|
355
|
+
|
|
356
|
+
Package hints from ``install_package_as`` blocks in the serialized graph
|
|
357
|
+
are automatically used. Explicit *import_to_package* entries take
|
|
358
|
+
priority over hints, which take priority over ``DEFAULT_IMPORT_TO_PACKAGE``.
|
|
359
|
+
"""
|
|
360
|
+
modules = extract_third_party_modules(store, function_name)
|
|
361
|
+
if not modules:
|
|
362
|
+
return InstallResult()
|
|
363
|
+
hints = _collect_package_hints(store, function_name)
|
|
364
|
+
merged = {**hints, **(import_to_package or {})}
|
|
365
|
+
return await install_packages(modules, merged)
|