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/graph/store.py ADDED
@@ -0,0 +1,500 @@
1
+ """Content-addressable store for serializing and reconstructing functions."""
2
+
3
+ import ast
4
+ import json
5
+ import logging
6
+ from typing import Any, Self
7
+ from graphlib import TopologicalSorter
8
+ from dataclasses import field, dataclass
9
+
10
+ from offwork.core.models import ImportInfo, FunctionNode
11
+ from offwork.core.version import _VERSION
12
+ from offwork.graph.analyzer import hoist_closure_vars, hoist_closure_func_refs
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ # -- Reconstruction helpers (used by Store.reconstruct) ----------------------
18
+
19
+
20
+ def _topological_order(nodes: dict[str, FunctionNode]) -> list[str]:
21
+ """Return qualified names in dependency-first order."""
22
+ sorter: TopologicalSorter[str] = TopologicalSorter()
23
+ for qname, node in nodes.items():
24
+ sorter.add(qname, *[dep for dep in node.dependencies if dep in nodes])
25
+ return list(sorter.static_order())
26
+
27
+
28
+ def _collect_imports(
29
+ nodes: dict[str, FunctionNode], order: list[str]
30
+ ) -> list[str]:
31
+ """Deduplicate and sort import statements across all nodes."""
32
+ seen: dict[str, None] = {}
33
+ for qname in order:
34
+ for imp in nodes[qname].imports:
35
+ seen.setdefault(imp.statement, None)
36
+ return sorted(seen)
37
+
38
+
39
+ def _collect_module_vars(
40
+ nodes: dict[str, FunctionNode], order: list[str]
41
+ ) -> list[str]:
42
+ """Deduplicate module-level variable assignments across all nodes."""
43
+ seen: dict[str, str] = {}
44
+ func_and_class_names = {node.name for node in nodes.values()}
45
+ for qname in order:
46
+ for var_name, var_src in nodes[qname].module_vars.items():
47
+ if var_name not in seen and var_name not in func_and_class_names:
48
+ seen[var_name] = var_src
49
+ return list(seen.values())
50
+
51
+
52
+ def _group_by_class(
53
+ nodes: dict[str, FunctionNode], order: list[str]
54
+ ) -> dict[str, list[str]]:
55
+ """Group qualified names by owner_class, preserving topological order."""
56
+ groups: dict[str, list[str]] = {}
57
+ for qname in order:
58
+ owner = nodes[qname].owner_class
59
+ if owner is not None:
60
+ groups.setdefault(owner, []).append(qname)
61
+ return groups
62
+
63
+
64
+ def _apply_closure_transforms(
65
+ node: FunctionNode, all_nodes: dict[str, FunctionNode]
66
+ ) -> str:
67
+ """Apply closure variable and function reference hoisting to source."""
68
+ source = node.source
69
+ if node.closure_vars:
70
+ source = hoist_closure_vars(source, node.closure_vars)
71
+ if node.closure_func_refs:
72
+ source = hoist_closure_func_refs(source, node.closure_func_refs, all_nodes)
73
+ return source
74
+
75
+
76
+ class _SuperRewriter(ast.NodeTransformer):
77
+ """Replace ``super()`` with ``super(ClassName, self)`` or ``super(ClassName, cls)``."""
78
+
79
+ def __init__(self, class_name: str) -> None:
80
+ self._class_name = class_name
81
+ self._first_param: str | None = None
82
+
83
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef:
84
+ self._first_param = node.args.args[0].arg if node.args.args else "self"
85
+ self.generic_visit(node)
86
+ self._first_param = None
87
+ return node
88
+
89
+ visit_AsyncFunctionDef = visit_FunctionDef # type: ignore[assignment]
90
+
91
+ def visit_Call(self, node: ast.Call) -> ast.Call:
92
+ self.generic_visit(node)
93
+ if (
94
+ isinstance(node.func, ast.Name)
95
+ and node.func.id == "super"
96
+ and not node.args
97
+ and not node.keywords
98
+ ):
99
+ node.args = [
100
+ ast.Name(id=self._class_name, ctx=ast.Load()),
101
+ ast.Name(id=self._first_param or "self", ctx=ast.Load()),
102
+ ]
103
+ return node
104
+
105
+
106
+ def _rewrite_bare_super(source: str, class_name: str) -> str:
107
+ """Replace zero-arg ``super()`` with ``super(ClassName, self/cls)``."""
108
+ if "super()" not in source:
109
+ return source
110
+ tree = ast.parse(source)
111
+ tree = _SuperRewriter(class_name).visit(tree)
112
+ ast.fix_missing_locations(tree)
113
+ return ast.unparse(tree)
114
+
115
+
116
+ def _indent_method(source: str) -> str:
117
+ """Indent a method source for embedding inside a class block."""
118
+ return "\n".join(
119
+ (" " + line if line.strip() else "")
120
+ for line in source.rstrip().splitlines()
121
+ )
122
+
123
+
124
+ def _build_class_block(
125
+ owner_class: str,
126
+ member_qnames: list[str],
127
+ nodes: dict[str, FunctionNode],
128
+ ) -> str:
129
+ """Build a complete ``class ... :`` block from member nodes."""
130
+ class_name = owner_class.rsplit(".", 1)[-1]
131
+
132
+ method_sources = [
133
+ _indent_method(_rewrite_bare_super(
134
+ _apply_closure_transforms(nodes[qname], nodes), class_name,
135
+ ))
136
+ for qname in member_qnames
137
+ ]
138
+
139
+ bases: list[str] = []
140
+ keywords: dict[str, str] = {}
141
+ class_attrs: list[str] = []
142
+ class_decorators: list[str] = []
143
+ for qname in member_qnames:
144
+ n = nodes[qname]
145
+ if n.class_bases and not bases:
146
+ bases = n.class_bases
147
+ if n.class_keywords and not keywords:
148
+ keywords = n.class_keywords
149
+ if n.class_attrs and not class_attrs:
150
+ class_attrs = n.class_attrs
151
+ if n.class_decorators and not class_decorators:
152
+ class_decorators = n.class_decorators
153
+
154
+ header_parts = list(bases)
155
+ for k, v in keywords.items():
156
+ header_parts.append(f"{k}={v}")
157
+
158
+ if header_parts:
159
+ header = f"class {class_name}({', '.join(header_parts)}):\n"
160
+ else:
161
+ header = f"class {class_name}:\n"
162
+
163
+ decorator_lines = "".join(f"@{d}\n" for d in class_decorators)
164
+ attr_block = "".join(
165
+ _indent_method(_rewrite_bare_super(attr, class_name)) + "\n\n"
166
+ for attr in class_attrs
167
+ )
168
+
169
+ return decorator_lines + header + attr_block + "\n\n".join(method_sources)
170
+
171
+
172
+ @dataclass
173
+ class MergeResult:
174
+ """Outcome of merging two stores."""
175
+
176
+ added_objects: int = 0
177
+ added_refs: int = 0
178
+ conflicts: dict[str, tuple[str, str]] = field(default_factory=dict)
179
+
180
+
181
+ class Store:
182
+ """Content-addressable store for function objects with separate topology.
183
+
184
+ Separates three concerns:
185
+ - **objects**: content-addressable blobs keyed by content hash
186
+ - **deps**: dependency adjacency list (hash -> [dep hashes])
187
+ - **refs**: named references (qualified name -> hash)
188
+ """
189
+
190
+ def __init__(self) -> None:
191
+ self._objects: dict[str, dict[str, Any]] = {}
192
+ self._deps: dict[str, list[str]] = {}
193
+ self._refs: dict[str, str] = {}
194
+
195
+ # -- Object operations ---------------------------------------------------
196
+
197
+ def put(self, node: FunctionNode) -> str:
198
+ """Store a node's content blob. Returns the content hash.
199
+
200
+ Does NOT set deps -- call :meth:`set_deps` separately.
201
+ """
202
+ h = node.content_hash()
203
+ if h not in self._objects:
204
+ self._objects[h] = node.to_content_blob()
205
+ return h
206
+
207
+ def put_blob(self, h: str, blob: dict[str, Any]) -> None:
208
+ """Store a pre-hashed content blob (used by deserialization)."""
209
+ self._objects[h] = blob
210
+
211
+ def get(self, h: str) -> dict[str, Any] | None:
212
+ """Retrieve raw content blob by hash."""
213
+ return self._objects.get(h)
214
+
215
+ def has(self, h: str) -> bool:
216
+ """Check whether a content hash exists in the store."""
217
+ return h in self._objects
218
+
219
+ @property
220
+ def object_hashes(self) -> set[str]:
221
+ """Set of all content hashes currently in the store."""
222
+ return set(self._objects)
223
+
224
+ # -- Dep operations ------------------------------------------------------
225
+
226
+ def set_deps(self, h: str, dep_hashes: list[str]) -> None:
227
+ """Set dependency edges for a content hash."""
228
+ if dep_hashes:
229
+ self._deps[h] = list(dep_hashes)
230
+ else:
231
+ self._deps.pop(h, None)
232
+
233
+ def get_deps(self, h: str) -> list[str]:
234
+ """Get dependency hashes for a content hash."""
235
+ return list(self._deps.get(h, []))
236
+
237
+ # -- Ref operations ------------------------------------------------------
238
+
239
+ def set_ref(self, name: str, h: str) -> None:
240
+ """Map a qualified name to a content hash."""
241
+ self._refs[name] = h
242
+
243
+ def get_ref(self, name: str) -> str | None:
244
+ """Look up a content hash by qualified name."""
245
+ return self._refs.get(name)
246
+
247
+ def del_ref(self, name: str) -> None:
248
+ """Remove a named reference."""
249
+ self._refs.pop(name, None)
250
+
251
+ @property
252
+ def refs(self) -> dict[str, str]:
253
+ """Snapshot of all named references (qualified name -> hash)."""
254
+ return dict(self._refs)
255
+
256
+ # -- Graph operations ----------------------------------------------------
257
+
258
+ def walk(self, root_hash: str) -> list[str]:
259
+ """BFS walk returning all reachable hashes from *root_hash*."""
260
+ visited: dict[str, None] = {}
261
+ stack = [root_hash]
262
+ while stack:
263
+ h = stack.pop()
264
+ if h in visited:
265
+ continue
266
+ visited[h] = None
267
+ stack.extend(self._deps.get(h, []))
268
+ return list(visited)
269
+
270
+ def subgraph(self, *root_hashes: str) -> "Store":
271
+ """Extract transitive closure of *root_hashes* into a new store."""
272
+ reachable: set[str] = set()
273
+ for root in root_hashes:
274
+ reachable.update(self.walk(root))
275
+
276
+ store = Store()
277
+ hash_to_qname = {h: qn for qn, h in self._refs.items()}
278
+ for h in reachable:
279
+ blob = self._objects.get(h)
280
+ if blob is not None:
281
+ store.put_blob(h, dict(blob))
282
+ deps = self._deps.get(h)
283
+ if deps:
284
+ store.set_deps(h, [d for d in deps if d in reachable])
285
+ qn = hash_to_qname.get(h)
286
+ if qn is not None:
287
+ store.set_ref(qn, h)
288
+ return store
289
+
290
+ def missing(self, hashes: set[str]) -> set[str]:
291
+ """Return hashes from *hashes* not present in this store."""
292
+ return hashes - self._objects.keys()
293
+
294
+ def merge(self, other: "Store") -> MergeResult:
295
+ """Merge *other* into this store.
296
+
297
+ Objects are unioned by hash (same hash = same content).
298
+ Deps are unioned per hash.
299
+ Refs: existing refs are kept on conflict; conflicts are reported.
300
+ """
301
+ result = MergeResult()
302
+
303
+ for h, blob in other._objects.items():
304
+ if h not in self._objects:
305
+ self._objects[h] = dict(blob)
306
+ result.added_objects += 1
307
+
308
+ for h, deps in other._deps.items():
309
+ existing = self._deps.get(h)
310
+ if existing is None:
311
+ self._deps[h] = list(deps)
312
+ else:
313
+ merged = list(dict.fromkeys(existing + deps))
314
+ self._deps[h] = merged
315
+
316
+ for name, h in other._refs.items():
317
+ existing_ref = self._refs.get(name)
318
+ if existing_ref is None:
319
+ self._refs[name] = h
320
+ result.added_refs += 1
321
+ elif existing_ref != h:
322
+ result.conflicts[name] = (existing_ref, h)
323
+
324
+ return result
325
+
326
+ def gc(self) -> set[str]:
327
+ """Remove objects unreachable from any ref. Returns removed hashes."""
328
+ reachable: set[str] = set()
329
+ for h in self._refs.values():
330
+ reachable.update(self.walk(h))
331
+ garbage = self._objects.keys() - reachable
332
+ for h in garbage:
333
+ del self._objects[h]
334
+ self._deps.pop(h, None)
335
+ return garbage
336
+
337
+ # -- Reconstruction ------------------------------------------------------
338
+
339
+ def collect(self, function_name: str) -> tuple[str, dict[str, FunctionNode]]:
340
+ """Resolve *function_name*, walk deps, return (target_qname, nodes).
341
+
342
+ Raises :class:`KeyError` if *function_name* is not found.
343
+ """
344
+ hash_to_qname = {h: qn for qn, h in self._refs.items()}
345
+ target_hash = self._resolve_function_hash(function_name)
346
+ reachable = self.walk(target_hash)
347
+
348
+ needed: dict[str, FunctionNode] = {}
349
+ for content_hash in reachable:
350
+ blob = self._objects.get(content_hash)
351
+ if blob is None:
352
+ continue
353
+ needed[hash_to_qname.get(content_hash, f"{blob['module']}.{blob['name']}")] = (
354
+ self._blob_to_node(content_hash, blob, hash_to_qname)
355
+ )
356
+
357
+ target_qname = hash_to_qname.get(target_hash, f"unknown.{function_name}")
358
+ return target_qname, needed
359
+
360
+ def _blob_to_node(
361
+ self,
362
+ content_hash: str,
363
+ blob: dict[str, Any],
364
+ hash_to_qname: dict[str, str],
365
+ ) -> FunctionNode:
366
+ """Convert a raw content blob into a FunctionNode."""
367
+ qname = hash_to_qname.get(content_hash, f"{blob['module']}.{blob['name']}")
368
+ closure_func_refs = {
369
+ var: hash_to_qname.get(ref_hash, ref_hash)
370
+ for var, ref_hash in blob.get("closure_func_refs", {}).items()
371
+ }
372
+ dep_qnames = [
373
+ hash_to_qname.get(dep_hash, dep_hash)
374
+ for dep_hash in self._deps.get(content_hash, [])
375
+ ]
376
+ return FunctionNode(
377
+ qualified_name=qname,
378
+ name=blob["name"],
379
+ module=blob["module"],
380
+ source=blob["source"],
381
+ imports=[ImportInfo.from_dict(imp) for imp in blob["imports"]],
382
+ dependencies=dep_qnames,
383
+ owner_class=blob.get("owner_class"),
384
+ closure_vars=blob.get("closure_vars", {}),
385
+ closure_func_refs=closure_func_refs,
386
+ module_vars=blob.get("module_vars", {}),
387
+ class_bases=blob.get("class_bases", []),
388
+ class_keywords=blob.get("class_keywords", {}),
389
+ class_attrs=blob.get("class_attrs", []),
390
+ class_decorators=blob.get("class_decorators", []),
391
+ )
392
+
393
+ def reconstruct(self, function_name: str) -> str:
394
+ """Reconstruct executable Python source for *function_name*."""
395
+ target_qname, needed = self.collect(function_name)
396
+
397
+ logger.debug(
398
+ "Reconstructing %s: %d dependencies",
399
+ target_qname, len(needed) - 1,
400
+ )
401
+
402
+ order = _topological_order(needed)
403
+ logger.debug("Topological order: %s", order)
404
+
405
+ import_lines = _collect_imports(needed, order)
406
+ module_var_lines = _collect_module_vars(needed, order)
407
+ class_groups = _group_by_class(needed, order)
408
+
409
+ parts: list[str] = []
410
+ if import_lines:
411
+ parts.append("\n".join(import_lines))
412
+ if module_var_lines:
413
+ parts.append("\n".join(module_var_lines))
414
+
415
+ emitted_classes: set[str] = set()
416
+ for qname in order:
417
+ node = needed[qname]
418
+ source = _apply_closure_transforms(node, needed)
419
+ if node.owner_class is None:
420
+ parts.append(source.rstrip())
421
+ elif node.owner_class not in emitted_classes:
422
+ emitted_classes.add(node.owner_class)
423
+ parts.append(_build_class_block(
424
+ node.owner_class, class_groups[node.owner_class], needed,
425
+ ))
426
+
427
+ return "\n\n\n".join(parts) + "\n"
428
+
429
+ # -- Serialization -------------------------------------------------------
430
+
431
+ def to_dict(self) -> dict[str, Any]:
432
+ """Export as a dict in v0.4.0 format."""
433
+ qname_to_hash = self._refs
434
+
435
+ # Build objects with closure_func_refs converted to hashes
436
+ objects: dict[str, dict[str, Any]] = {}
437
+ for h, blob in self._objects.items():
438
+ out = dict(blob)
439
+ if "closure_func_refs" in out:
440
+ out["closure_func_refs"] = {
441
+ var: qname_to_hash.get(ref_qn, ref_qn)
442
+ for var, ref_qn in out["closure_func_refs"].items()
443
+ }
444
+ objects[h] = out
445
+
446
+ return {
447
+ "version": _VERSION,
448
+ "objects": objects,
449
+ "deps": {h: list(d) for h, d in self._deps.items()},
450
+ "refs": dict(self._refs),
451
+ }
452
+
453
+ def to_json(self) -> str:
454
+ """Serialize the store to a JSON string."""
455
+ return json.dumps(self.to_dict(), indent=2)
456
+
457
+ @classmethod
458
+ def from_dict(cls, data: dict[str, Any]) -> Self:
459
+ """Deserialize a store from a dict (as produced by :meth:`to_dict`)."""
460
+ store = cls()
461
+ refs = data.get("refs", {})
462
+ hash_to_qname = {h: qn for qn, h in refs.items()}
463
+
464
+ for h, blob in data.get("objects", {}).items():
465
+ out = dict(blob)
466
+ # Convert closure_func_refs hashes back to qnames
467
+ if "closure_func_refs" in out:
468
+ out["closure_func_refs"] = {
469
+ var: hash_to_qname.get(ref_h, ref_h)
470
+ for var, ref_h in out["closure_func_refs"].items()
471
+ }
472
+ store.put_blob(h, out)
473
+
474
+ for h, dep_list in data.get("deps", {}).items():
475
+ store.set_deps(h, dep_list)
476
+
477
+ for name, h in refs.items():
478
+ store.set_ref(name, h)
479
+
480
+ return store
481
+
482
+ @classmethod
483
+ def from_json(cls, json_str: str) -> Self:
484
+ """Deserialize a store from a JSON string."""
485
+ return cls.from_dict(json.loads(json_str))
486
+
487
+ # -- Private helpers -----------------------------------------------------
488
+
489
+ def _resolve_function_hash(self, function_name: str) -> str:
490
+ """Resolve a function name (qualified or simple) to its hash."""
491
+ if function_name in self._refs:
492
+ return self._refs[function_name]
493
+ for qn, h in self._refs.items():
494
+ blob = self._objects.get(h)
495
+ if blob and blob["name"] == function_name:
496
+ return h
497
+ raise KeyError(
498
+ f"Function '{function_name}' not found in store"
499
+ )
500
+