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.
Files changed (42) hide show
  1. offwork/__init__.py +167 -0
  2. offwork/__main__.py +770 -0
  3. offwork/_venv.py +174 -0
  4. offwork/core/__init__.py +15 -0
  5. offwork/core/errors.py +83 -0
  6. offwork/core/models.py +174 -0
  7. offwork/core/pairing.py +389 -0
  8. offwork/core/progress.py +91 -0
  9. offwork/core/signing.py +91 -0
  10. offwork/core/task.py +520 -0
  11. offwork/core/token.py +184 -0
  12. offwork/core/version.py +10 -0
  13. offwork/graph/__init__.py +5 -0
  14. offwork/graph/analyzer.py +637 -0
  15. offwork/graph/decorator.py +87 -0
  16. offwork/graph/graph.py +995 -0
  17. offwork/graph/store.py +500 -0
  18. offwork/graph/tracing.py +429 -0
  19. offwork/py.typed +0 -0
  20. offwork/typing.py +48 -0
  21. offwork/worker/__init__.py +18 -0
  22. offwork/worker/backends/__init__.py +3 -0
  23. offwork/worker/backends/base.py +149 -0
  24. offwork/worker/backends/http.py +237 -0
  25. offwork/worker/backends/local.py +452 -0
  26. offwork/worker/backends/rabbitmq.py +410 -0
  27. offwork/worker/backends/redis.py +175 -0
  28. offwork/worker/deps.py +365 -0
  29. offwork/worker/remote.py +793 -0
  30. offwork/worker/result.py +276 -0
  31. offwork/worker/sandbox/Dockerfile +24 -0
  32. offwork/worker/sandbox/__init__.py +18 -0
  33. offwork/worker/sandbox/_protocol.py +50 -0
  34. offwork/worker/sandbox/docker.py +438 -0
  35. offwork/worker/sandbox/guest_agent.py +622 -0
  36. offwork/worker/schedule.py +26 -0
  37. offwork/worker/worker.py +263 -0
  38. offwork-0.4.0.dist-info/METADATA +143 -0
  39. offwork-0.4.0.dist-info/RECORD +42 -0
  40. offwork-0.4.0.dist-info/WHEEL +4 -0
  41. offwork-0.4.0.dist-info/entry_points.txt +3 -0
  42. 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)