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/_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)
|
offwork/core/__init__.py
ADDED
|
@@ -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
|
+
)
|