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/_venv.py ADDED
@@ -0,0 +1,174 @@
1
+ """Temporary virtual environment management for offwork."""
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+ import venv
7
+ import atexit
8
+ import shutil
9
+ import signal
10
+ import asyncio
11
+ import logging
12
+ import tempfile
13
+ from types import FrameType
14
+ from pathlib import Path
15
+ from contextlib import asynccontextmanager
16
+ from dataclasses import dataclass
17
+ from collections.abc import Sequence, AsyncIterator
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _STALE_THRESHOLD_SECS = 60 * 60 * 24 # 24 hours
22
+ _DEFAULT_PREFIX = "offwork-tmp-"
23
+
24
+
25
+ def _find_project_root() -> Path | None:
26
+ """Walk up from this file to find the directory containing pyproject.toml."""
27
+ current = Path(__file__).resolve().parent
28
+ while current != current.parent:
29
+ if (current / "pyproject.toml").exists():
30
+ return current
31
+ current = current.parent
32
+ return None
33
+
34
+
35
+ def _venv_python(venv_dir: Path) -> Path:
36
+ """Return the path to the Python executable inside a venv."""
37
+ if sys.platform == "win32":
38
+ return venv_dir / "Scripts" / "python.exe"
39
+ return venv_dir / "bin" / "python"
40
+
41
+
42
+ def cleanup_stale_venvs(
43
+ prefix: str = _DEFAULT_PREFIX,
44
+ max_age_secs: float = _STALE_THRESHOLD_SECS,
45
+ ) -> list[str]:
46
+ """Remove stale offwork temp directories left behind by crashed processes.
47
+
48
+ Returns the list of directories that were removed.
49
+ """
50
+ tmproot = Path(tempfile.gettempdir())
51
+ cutoff = time.time() - max_age_secs
52
+ removed: list[str] = []
53
+
54
+ for entry in tmproot.iterdir():
55
+ if not entry.name.startswith(prefix) or not entry.is_dir():
56
+ continue
57
+ try:
58
+ mtime = entry.stat().st_mtime
59
+ except OSError:
60
+ continue
61
+ if mtime < cutoff:
62
+ logger.info("Removing stale temp venv: %s", entry)
63
+ shutil.rmtree(entry, ignore_errors=True)
64
+ removed.append(str(entry))
65
+
66
+ return removed
67
+
68
+
69
+ @dataclass
70
+ class TempVenv:
71
+ """A temporary virtual environment."""
72
+
73
+ venv_dir: Path
74
+ python: Path
75
+
76
+ async def pip_install(
77
+ self, *packages: str, extra_args: Sequence[str] = ()
78
+ ) -> None:
79
+ """Install packages via pip in this venv."""
80
+ if not packages:
81
+ return
82
+ cmd = [str(self.python), "-m", "pip", "install", *packages, *extra_args]
83
+ logger.info("Installing in temp venv: %s", " ".join(packages))
84
+ proc = await asyncio.create_subprocess_exec(
85
+ *cmd,
86
+ stdout=asyncio.subprocess.PIPE,
87
+ stderr=asyncio.subprocess.PIPE,
88
+ )
89
+ _stdout, stderr = await proc.communicate()
90
+ if proc.returncode != 0:
91
+ raise RuntimeError(
92
+ f"pip install failed in temp venv:\n{stderr.decode() if stderr else ''}"
93
+ )
94
+
95
+
96
+ @asynccontextmanager
97
+ async def temp_venv(
98
+ *,
99
+ install_offwork: bool = True,
100
+ extras: Sequence[str] = (),
101
+ prefix: str = _DEFAULT_PREFIX,
102
+ ) -> AsyncIterator[TempVenv]:
103
+ """Create a temporary venv, optionally install offwork, yield, then clean up.
104
+
105
+ Cleanup is guaranteed on normal exit, exceptions, SIGTERM, SIGINT, and
106
+ ``atexit``. Stale directories from previously crashed processes are
107
+ cleaned up on entry.
108
+
109
+ Parameters
110
+ ----------
111
+ install_offwork:
112
+ Install offwork into the venv (editable from source tree, or from PyPI).
113
+ extras:
114
+ Optional extras to install, e.g. ``("redis",)``.
115
+ prefix:
116
+ Prefix for the temporary directory name.
117
+ """
118
+ # Opportunistically clean up leftovers from previous crashes.
119
+ cleanup_stale_venvs(prefix)
120
+
121
+ tmpdir = tempfile.mkdtemp(prefix=prefix)
122
+
123
+ def _cleanup() -> None:
124
+ if os.path.isdir(tmpdir):
125
+ logger.info("Cleaning up temporary venv at %s", tmpdir)
126
+ print(f"Cleaning up temporary venv at {tmpdir}...", file=sys.stderr)
127
+ shutil.rmtree(tmpdir, ignore_errors=True)
128
+
129
+ # Safety net: atexit ensures cleanup even if the context manager is
130
+ # bypassed (e.g. an unhandled exception in calling code outside the
131
+ # ``with`` block, or ``sys.exit()`` called elsewhere).
132
+ atexit.register(_cleanup)
133
+
134
+ # SIGTERM normally kills the process before context-manager __exit__
135
+ # runs. Convert it into a clean SystemExit so the finally block and
136
+ # atexit handlers execute.
137
+ prev_sigterm = signal.getsignal(signal.SIGTERM)
138
+
139
+ def _sigterm_handler(signum: int, frame: FrameType | None) -> None:
140
+ raise SystemExit(128 + signum)
141
+
142
+ signal.signal(signal.SIGTERM, _sigterm_handler)
143
+
144
+ try:
145
+ venv_dir = Path(tmpdir) / "venv"
146
+ logger.info("Creating temporary venv at %s", venv_dir)
147
+ print("Creating temporary virtual environment...", file=sys.stderr)
148
+ loop = asyncio.get_running_loop()
149
+ await loop.run_in_executor(
150
+ None, lambda: venv.create(str(venv_dir), with_pip=True)
151
+ )
152
+
153
+ python = _venv_python(venv_dir)
154
+ if not python.exists():
155
+ raise RuntimeError(f"venv Python not found at {python}")
156
+
157
+ tv = TempVenv(venv_dir=venv_dir, python=python)
158
+
159
+ if install_offwork:
160
+ root = _find_project_root()
161
+ if root is not None:
162
+ spec = str(root)
163
+ else:
164
+ spec = "offwork"
165
+ if extras:
166
+ spec += f"[{','.join(extras)}]"
167
+ print("Installing offwork into temporary venv...", file=sys.stderr)
168
+ await tv.pip_install(spec, extra_args=["--quiet"])
169
+
170
+ yield tv
171
+ finally:
172
+ _cleanup()
173
+ atexit.unregister(_cleanup)
174
+ signal.signal(signal.SIGTERM, prev_sigterm)
@@ -0,0 +1,15 @@
1
+ from offwork.core.task import Task, resolve_args
2
+ from offwork.core.errors import Error, RemoteError, WorkerError, DependencyError
3
+ from offwork.core.models import ImportInfo, FunctionNode
4
+ from offwork.core.version import _VERSION
5
+
6
+ __all__ = [
7
+ "Task",
8
+ "resolve_args",
9
+ "Error",
10
+ "RemoteError",
11
+ "WorkerError",
12
+ "DependencyError",
13
+ "ImportInfo",
14
+ "FunctionNode",
15
+ ]
offwork/core/errors.py ADDED
@@ -0,0 +1,83 @@
1
+ """Exception hierarchy for offwork."""
2
+
3
+ import sys
4
+ import types
5
+
6
+
7
+ class Error(Exception):
8
+ """Raised when offwork cannot trace or analyze a function."""
9
+
10
+
11
+ class WorkerError(Error):
12
+ """Raised when worker execution fails."""
13
+
14
+
15
+ class DependencyError(Error):
16
+ """Raised when dependency installation fails."""
17
+
18
+
19
+ class RemoteError(WorkerError):
20
+ """Raised on the client side when a remote execution fails.
21
+
22
+ Carries the worker-side traceback and formats it cleanly when the
23
+ exception is printed, suppressing the noisy client-side frames.
24
+ """
25
+
26
+ remote_traceback: str | None
27
+
28
+ def __init__(self, message: str, remote_traceback: str | None = None) -> None:
29
+ super().__init__(message)
30
+ self.remote_traceback = remote_traceback
31
+
32
+
33
+ class TaskStalled(Error):
34
+ """Raised when a worker stops sending heartbeats for a task."""
35
+
36
+
37
+ class TaskCancelled(Error):
38
+ """Raised when a task is cancelled before or during execution."""
39
+
40
+
41
+ class SignatureError(Error):
42
+ """Raised when HMAC signature verification of a task fails."""
43
+
44
+
45
+ class PairingError(Error):
46
+ """Raised when the PIN-based pairing protocol fails."""
47
+
48
+
49
+ class ThrottleError(Error):
50
+ """Raised when a task is rejected due to rate limiting."""
51
+
52
+
53
+ class WorkerOnlyError(Error):
54
+ """Raised when a worker-only import stub is used on the client."""
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Custom excepthook: suppress client traceback for RemoteError
59
+ # ---------------------------------------------------------------------------
60
+
61
+ _original_excepthook = sys.excepthook
62
+
63
+
64
+ def _offwork_excepthook(
65
+ exc_type: type[BaseException],
66
+ exc_value: BaseException,
67
+ exc_tb: types.TracebackType | None,
68
+ ) -> None:
69
+ if isinstance(exc_value, RemoteError) and exc_value.remote_traceback:
70
+ tb = exc_value.remote_traceback
71
+ # Replace "Traceback (most recent call last):" with our header
72
+ tb = tb.replace(
73
+ "Traceback (most recent call last):",
74
+ "Worker traceback (most recent call last):",
75
+ 1,
76
+ )
77
+ sys.stderr.write(f"\n{tb}")
78
+ return
79
+ _original_excepthook(exc_type, exc_value, exc_tb)
80
+
81
+
82
+ if sys.excepthook is not _offwork_excepthook:
83
+ sys.excepthook = _offwork_excepthook
offwork/core/models.py ADDED
@@ -0,0 +1,174 @@
1
+ """Data models for function nodes and import bindings."""
2
+
3
+ import json
4
+ import hashlib
5
+ from typing import Any, Self
6
+ from dataclasses import field, dataclass
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ImportInfo:
11
+ """A single import binding brought into scope by an import statement."""
12
+
13
+ statement: str
14
+ bound_name: str
15
+ package: str | None = None
16
+ worker_only: bool = False
17
+
18
+ def to_dict(self) -> dict[str, Any]:
19
+ """Serialize to a plain dict."""
20
+ d: dict[str, Any] = {"statement": self.statement, "bound_name": self.bound_name}
21
+ if self.package is not None:
22
+ d["package"] = self.package
23
+ if self.worker_only:
24
+ d["worker_only"] = True
25
+ return d
26
+
27
+ @classmethod
28
+ def from_dict(cls, data: dict[str, Any]) -> Self:
29
+ """Deserialize from a plain dict.
30
+
31
+ Raises
32
+ ------
33
+ KeyError
34
+ If required fields (``statement``, ``bound_name``) are missing.
35
+ """
36
+ return cls(
37
+ statement=data["statement"],
38
+ bound_name=data["bound_name"],
39
+ package=data.get("package"),
40
+ worker_only=bool(data.get("worker_only", False)),
41
+ )
42
+
43
+
44
+ @dataclass
45
+ class FunctionNode:
46
+ """A node in the dependency graph representing one traced function."""
47
+
48
+ qualified_name: str
49
+ name: str
50
+ module: str
51
+ source: str
52
+ imports: list[ImportInfo] = field(default_factory=list)
53
+ dependencies: list[str] = field(default_factory=list)
54
+ owner_class: str | None = None
55
+ closure_vars: dict[str, str] = field(default_factory=dict)
56
+ closure_func_refs: dict[str, str] = field(default_factory=dict)
57
+ module_vars: dict[str, str] = field(default_factory=dict)
58
+ class_bases: list[str] = field(default_factory=list)
59
+ class_keywords: dict[str, str] = field(default_factory=dict)
60
+ class_attrs: list[str] = field(default_factory=list)
61
+ class_decorators: list[str] = field(default_factory=list)
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ """Serialize to a plain dict including all fields."""
65
+ d: dict[str, Any] = {
66
+ "qualified_name": self.qualified_name,
67
+ "name": self.name,
68
+ "module": self.module,
69
+ "source": self.source,
70
+ "imports": [imp.to_dict() for imp in self.imports],
71
+ "dependencies": list(self.dependencies),
72
+ "owner_class": self.owner_class,
73
+ }
74
+ if self.closure_vars:
75
+ d["closure_vars"] = dict(self.closure_vars)
76
+ if self.closure_func_refs:
77
+ d["closure_func_refs"] = dict(self.closure_func_refs)
78
+ if self.module_vars:
79
+ d["module_vars"] = dict(self.module_vars)
80
+ if self.class_bases:
81
+ d["class_bases"] = list(self.class_bases)
82
+ if self.class_keywords:
83
+ d["class_keywords"] = dict(self.class_keywords)
84
+ if self.class_attrs:
85
+ d["class_attrs"] = list(self.class_attrs)
86
+ if self.class_decorators:
87
+ d["class_decorators"] = list(self.class_decorators)
88
+ return d
89
+
90
+ def to_content_blob(self) -> dict[str, Any]:
91
+ """Return only the fields stored in the content-addressable store.
92
+
93
+ Excludes ``qualified_name`` and ``dependencies`` which are graph
94
+ topology, not intrinsic content.
95
+ """
96
+ blob: dict[str, Any] = {
97
+ "name": self.name,
98
+ "module": self.module,
99
+ "source": self.source,
100
+ "imports": [imp.to_dict() for imp in self.imports],
101
+ "owner_class": self.owner_class,
102
+ }
103
+ if self.closure_vars:
104
+ blob["closure_vars"] = dict(self.closure_vars)
105
+ if self.closure_func_refs:
106
+ blob["closure_func_refs"] = dict(self.closure_func_refs)
107
+ if self.module_vars:
108
+ blob["module_vars"] = dict(self.module_vars)
109
+ if self.class_bases:
110
+ blob["class_bases"] = list(self.class_bases)
111
+ if self.class_keywords:
112
+ blob["class_keywords"] = dict(self.class_keywords)
113
+ if self.class_attrs:
114
+ blob["class_attrs"] = list(self.class_attrs)
115
+ if self.class_decorators:
116
+ blob["class_decorators"] = list(self.class_decorators)
117
+ return blob
118
+
119
+ def content_hash(self) -> str:
120
+ """Compute a deterministic content hash for this node.
121
+
122
+ The hash covers the node's own content but NOT its dependencies,
123
+ so adding/removing edges doesn't invalidate existing hashes.
124
+ """
125
+ canonical = {
126
+ "name": self.name,
127
+ "module": self.module,
128
+ "source": self.source,
129
+ "imports": [
130
+ imp.to_dict()
131
+ for imp in sorted(self.imports, key=lambda i: i.statement)
132
+ ],
133
+ "owner_class": self.owner_class,
134
+ "closure_vars": dict(sorted(self.closure_vars.items())),
135
+ "closure_func_refs": dict(sorted(self.closure_func_refs.items())),
136
+ "module_vars": dict(sorted(self.module_vars.items())),
137
+ "class_bases": list(self.class_bases),
138
+ "class_keywords": dict(sorted(self.class_keywords.items())),
139
+ "class_attrs": list(self.class_attrs),
140
+ "class_decorators": list(self.class_decorators),
141
+ }
142
+ raw = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
143
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
144
+
145
+ @classmethod
146
+ def from_dict(cls, data: dict[str, Any]) -> Self:
147
+ """Deserialize from a plain dict.
148
+
149
+ Raises
150
+ ------
151
+ KeyError
152
+ If required fields (``qualified_name``, ``name``, ``module``,
153
+ ``source``, ``imports``, ``dependencies``) are missing.
154
+ """
155
+ required = ("qualified_name", "name", "module", "source", "imports", "dependencies")
156
+ missing = [k for k in required if k not in data]
157
+ if missing:
158
+ raise KeyError(f"FunctionNode missing required fields: {', '.join(missing)}")
159
+ return cls(
160
+ qualified_name=data["qualified_name"],
161
+ name=data["name"],
162
+ module=data["module"],
163
+ source=data["source"],
164
+ imports=[ImportInfo.from_dict(imp) for imp in data["imports"]],
165
+ dependencies=data["dependencies"],
166
+ owner_class=data.get("owner_class"),
167
+ closure_vars=data.get("closure_vars", {}),
168
+ closure_func_refs=data.get("closure_func_refs", {}),
169
+ module_vars=data.get("module_vars", {}),
170
+ class_bases=data.get("class_bases", []),
171
+ class_keywords=data.get("class_keywords", {}),
172
+ class_attrs=data.get("class_attrs", []),
173
+ class_decorators=data.get("class_decorators", []),
174
+ )