seamless-workflow 0.2.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 (49) hide show
  1. seamless_workflow/__init__.py +40 -0
  2. seamless_workflow/adapters.py +50 -0
  3. seamless_workflow/assignment.py +8 -0
  4. seamless_workflow/attachments/__init__.py +3 -0
  5. seamless_workflow/attachments/api.py +217 -0
  6. seamless_workflow/attachments/fs/__init__.py +1 -0
  7. seamless_workflow/attachments/fs/native.py +63 -0
  8. seamless_workflow/attachments/fs/service.py +601 -0
  9. seamless_workflow/attachments/manual.py +65 -0
  10. seamless_workflow/attachments/output_widget.py +62 -0
  11. seamless_workflow/attachments/policy.py +41 -0
  12. seamless_workflow/attachments/runtime.py +594 -0
  13. seamless_workflow/attachments/session.py +93 -0
  14. seamless_workflow/attachments/share/__init__.py +2 -0
  15. seamless_workflow/attachments/share/api.py +300 -0
  16. seamless_workflow/attachments/share/archive.py +60 -0
  17. seamless_workflow/attachments/share/driver.py +110 -0
  18. seamless_workflow/attachments/share/mime.py +180 -0
  19. seamless_workflow/attachments/share/openapi.py +358 -0
  20. seamless_workflow/attachments/share/server.py +1660 -0
  21. seamless_workflow/attachments/share/spec.py +139 -0
  22. seamless_workflow/attachments/share/static/seamless-client.js +538 -0
  23. seamless_workflow/attachments/spec.py +42 -0
  24. seamless_workflow/attachments/traitlet.py +344 -0
  25. seamless_workflow/attachments/widget.py +229 -0
  26. seamless_workflow/builder_state.py +1107 -0
  27. seamless_workflow/configuration.py +103 -0
  28. seamless_workflow/context.py +3287 -0
  29. seamless_workflow/controller.py +160 -0
  30. seamless_workflow/diagnostics.py +69 -0
  31. seamless_workflow/endpoints.py +33 -0
  32. seamless_workflow/errors.py +69 -0
  33. seamless_workflow/graph.py +636 -0
  34. seamless_workflow/ingress.py +411 -0
  35. seamless_workflow/jupyter.py +49 -0
  36. seamless_workflow/lifecycle.py +26 -0
  37. seamless_workflow/reactive.py +386 -0
  38. seamless_workflow/runtime_api.py +227 -0
  39. seamless_workflow/scheduler.py +106 -0
  40. seamless_workflow/serialization.py +317 -0
  41. seamless_workflow/serve_graph.py +55 -0
  42. seamless_workflow/shareserver.py +36 -0
  43. seamless_workflow/sidework.py +102 -0
  44. seamless_workflow/views.py +62 -0
  45. seamless_workflow-0.2.0.dist-info/METADATA +157 -0
  46. seamless_workflow-0.2.0.dist-info/RECORD +49 -0
  47. seamless_workflow-0.2.0.dist-info/WHEEL +5 -0
  48. seamless_workflow-0.2.0.dist-info/entry_points.txt +2 -0
  49. seamless_workflow-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,40 @@
1
+ """Reactive workflow layer for Seamless."""
2
+
3
+ __version__ = "0.1.0"
4
+ from .context import Context
5
+ from seamless import Cell
6
+ from seamless_transformer import Transformer
7
+ from .endpoints import BoundEndpoint
8
+ from . import shareserver
9
+ from .errors import (
10
+ AuthorityError,
11
+ DependencyError,
12
+ PathError,
13
+ ReadOnlyEndpointError,
14
+ StaleWorkflowHandleError,
15
+ ValueUnavailableError,
16
+ ClosedContextError,
17
+ ConcurrentUpdateError,
18
+ ControllerFailedError,
19
+ ReentrantContextError,
20
+ WorkflowExecutionError,
21
+ )
22
+
23
+ __all__ = [
24
+ "AuthorityError",
25
+ "BoundEndpoint",
26
+ "Context",
27
+ "Cell",
28
+ "Transformer",
29
+ "DependencyError",
30
+ "PathError",
31
+ "ReadOnlyEndpointError",
32
+ "StaleWorkflowHandleError",
33
+ "ValueUnavailableError",
34
+ "ClosedContextError",
35
+ "ConcurrentUpdateError",
36
+ "ControllerFailedError",
37
+ "ReentrantContextError",
38
+ "WorkflowExecutionError",
39
+ "shareserver",
40
+ ]
@@ -0,0 +1,50 @@
1
+ """Narrow substrate adapters used by the workflow layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from seamless import Buffer, Checksum
8
+
9
+
10
+ def checksum_for_value(
11
+ value: Any, celltype: str = "mixed", *, checksum_is_value: bool = False
12
+ ) -> Checksum:
13
+ """Serialize a Python value and return its checksum.
14
+
15
+ An explicit Checksum is already serialized state, and is returned as it is.
16
+ The exception is a written value (``checksum_is_value``) of celltype
17
+ ``checksum``: there a Checksum is the value itself.
18
+ """
19
+
20
+ # Ordinary 64-character strings are never reinterpreted as checksums.
21
+ if isinstance(value, Checksum) and not (checksum_is_value and celltype == "checksum"):
22
+ return value
23
+ buffer = Buffer(value, celltype)
24
+ checksum = buffer.get_checksum()
25
+ buffer.tempref()
26
+ return checksum
27
+
28
+
29
+ def buffer_for_checksum(checksum: Checksum | str | bytes | None):
30
+ if checksum is None:
31
+ return None
32
+ return Checksum(checksum).resolve()
33
+
34
+
35
+ def value_for_checksum(checksum: Checksum | str | bytes | None, celltype: str = "mixed"):
36
+ if checksum is None:
37
+ return None
38
+ return Checksum(checksum).resolve(celltype)
39
+
40
+
41
+ def normalize_checksum(checksum: Checksum | str | bytes) -> Checksum:
42
+ return Checksum(checksum)
43
+
44
+
45
+ __all__ = [
46
+ "checksum_for_value",
47
+ "buffer_for_checksum",
48
+ "value_for_checksum",
49
+ "normalize_checksum",
50
+ ]
@@ -0,0 +1,8 @@
1
+ """Assignment helper namespace.
2
+
3
+ The implementation currently keeps assignment methods on :mod:`context` because they
4
+ need direct access to graph mutation internals. This module is intentionally present
5
+ as the public scaffold extension point named by the implementation handoff.
6
+ """
7
+
8
+ __all__: list[str] = []
@@ -0,0 +1,3 @@
1
+ """Context attachments and file mounts."""
2
+ from .spec import AttachmentSpec
3
+ from .session import MountError, ConflictError, SyncReport
@@ -0,0 +1,217 @@
1
+ """Caller-side mount preparation and public handles."""
2
+ import asyncio
3
+ import weakref
4
+ from concurrent.futures import Future
5
+ from uuid import uuid4
6
+ from .spec import AttachmentSpec
7
+ from .fs.service import get_service
8
+ from ..errors import ClosedContextError, ControllerFailedError
9
+
10
+
11
+ def make_sink(controller):
12
+ reference = weakref.ref(controller)
13
+ def sink(operation, payload):
14
+ def fail(error):
15
+ if hasattr(payload, 'release'):
16
+ payload.release()
17
+ reply = Future()
18
+ reply.set_exception(error)
19
+ return reply
20
+ owner = reference()
21
+ if owner is None:
22
+ return fail(ClosedContextError("Context is closed"))
23
+ try:
24
+ reply = owner.enqueue(operation, (payload,), klass=3 if operation == '_mount_observed' else 5,
25
+ internal=operation != '_mount_observed')
26
+ except (ClosedContextError, ControllerFailedError) as exc:
27
+ return fail(exc)
28
+ except BaseException as exc:
29
+ return fail(exc)
30
+ else:
31
+ if hasattr(payload, 'release'):
32
+ # Also covers accepted messages discarded by stop or poison.
33
+ reply.add_done_callback(lambda future: payload.release())
34
+ return reply
35
+ return sink
36
+
37
+
38
+ class MountHandle:
39
+ def __init__(self, backend):
40
+ self.backend = backend
41
+
42
+ def _context(self):
43
+ b = self.backend
44
+ b.context._check_public_caller()
45
+ if getattr(b, "local_path", ()) or getattr(b, "readonly", False):
46
+ raise AttributeError('Only whole Context cell nodes can be mounted')
47
+ from ..errors import NodeError, StaleWorkflowHandleError
48
+
49
+ try:
50
+ b._node()
51
+ except StaleWorkflowHandleError as exc:
52
+ raise NodeError('Mounts require an existing whole cell node') from exc
53
+ return b.context, b.node_path
54
+
55
+ def __call__(self, path, mode='rw', authority='file', *, persistent=True):
56
+ ctx, node_path = self._context()
57
+ spec = AttachmentSpec(path, mode, authority, persistent)
58
+ celltype = ctx._controller.call('_mount_validate', node_path, spec, klass=4)
59
+ service = get_service()
60
+ registration = service.reserve(spec, celltype, uuid4().hex, make_sink(ctx._controller))
61
+ try:
62
+ observation = service.initial_read(registration).result()
63
+ try:
64
+ initial = ctx._controller.call('_mount_attach', node_path, spec, registration, observation, klass=2)
65
+ finally: observation.release()
66
+ initial.result()
67
+ except Exception:
68
+ service.unregister(registration).result()
69
+ raise
70
+
71
+ def unmount(self):
72
+ ctx, path = self._context()
73
+ future = ctx._controller.call('_mount_detach', path, klass=2)
74
+ if future is not None: future.result(get_service().delivery_timeout)
75
+
76
+ @property
77
+ def spec(self):
78
+ ctx, path = self._context()
79
+ return ctx._node_snapshot(path).mount
80
+
81
+ @property
82
+ def status(self):
83
+ ctx, path = self._context()
84
+ return ctx._controller.call('_mount_status', path, klass=4)
85
+
86
+ @property
87
+ def error(self):
88
+ status = self.status
89
+ return None if status is None else status['error']
90
+
91
+ def clear_error(self):
92
+ ctx, path = self._context()
93
+ ctx._controller.call('_mount_clear_error', path, klass=2)
94
+
95
+
96
+ class ContextMounts:
97
+ def __init__(self, context): self.context = context
98
+
99
+ def sync(self, timeout=None):
100
+ ctx = self.context
101
+ ctx._check_public_caller()
102
+ future = ctx._controller.call('_mount_sync', klass=1)
103
+ try: return future.result(timeout)
104
+ finally:
105
+ if ctx._controller.accepting: ctx._controller.call('_withdraw_wait', future, klass=1)
106
+
107
+ async def synchronization(self, timeout=None):
108
+ ctx = self.context
109
+ ctx._check_public_caller()
110
+ installed = ctx._controller.submit('_mount_sync', klass=1)
111
+ try: future = await asyncio.shield(asyncio.wrap_future(installed))
112
+ except asyncio.CancelledError:
113
+ def withdraw(reply):
114
+ if not reply.cancelled() and reply.exception() is None:
115
+ ctx._controller.notify('_withdraw_wait', (reply.result(),), klass=1)
116
+ installed.add_done_callback(withdraw)
117
+ raise
118
+ try:
119
+ return await asyncio.wait_for(asyncio.shield(asyncio.wrap_future(future)), timeout)
120
+ finally:
121
+ if ctx._controller.accepting:
122
+ await asyncio.wrap_future(ctx._controller.submit('_withdraw_wait', (future,), klass=1))
123
+
124
+ @property
125
+ def errors(self):
126
+ report = self.context._controller.call('_mount_report', klass=4)
127
+ return {p: s['error'] or s['sense_error'] for p, s in report.items() if s['error'] or s['sense_error']}
128
+
129
+
130
+ def load_graph(ctx, data, *, mounts=True, shares=True):
131
+ context_shares = ctx.shares
132
+ with context_shares._lock:
133
+ return _load_graph(
134
+ ctx, data, mounts=mounts, shares=shares,
135
+ context_shares=context_shares,
136
+ )
137
+
138
+
139
+ def _load_graph(ctx, data, *, mounts=True, shares=True, context_shares):
140
+ """Load a graph, optionally restoring its file mounts, HTTP shares and downloads."""
141
+ import copy
142
+ from .spec import validate_celltype
143
+ from ..serialization import prepare_graph
144
+ graph = prepare_graph(copy.deepcopy(data))
145
+ old = ctx._controller.call('_mount_registrations', klass=4)
146
+ old_shares = tuple(
147
+ registration for registration in old
148
+ if getattr(getattr(registration, 'spec', None), 'driver', None) == 'share'
149
+ )
150
+ old_downloads = tuple(context_shares._downloads.values())
151
+ download_specs = graph.downloads if shares else {}
152
+ graph.downloads = dict(download_specs)
153
+ prepared, reservations = [], []
154
+ service = get_service() if mounts and any(n.mount for n in graph.nodes.values()) else None
155
+ share_prepared = []
156
+ download_registrations = []
157
+ share_driver = (
158
+ context_shares._driver
159
+ if shares and (download_specs or any('share' in n.attachments for n in graph.nodes.values()))
160
+ else None
161
+ )
162
+ try:
163
+ reads = []
164
+ for path, node in graph.nodes.items():
165
+ spec, node.mount = node.mount, None
166
+ share_spec = node.attachments.pop('share', None)
167
+ if spec is not None and mounts:
168
+ celltype = node.cell_config.celltype
169
+ validate_celltype(celltype, spec.mode)
170
+ reg = service.reserve(spec, celltype, uuid4().hex, make_sink(ctx._controller), replaces=old)
171
+ reservations.append(reg)
172
+ reads.append(('mount', path, spec, reg, service.initial_read(reg)))
173
+ if share_spec is not None and shares:
174
+ celltype = node.cell_config.celltype
175
+ reg = share_driver.reserve(
176
+ share_spec, celltype, uuid4().hex, make_sink(ctx._controller),
177
+ replaces=old_shares + old_downloads, staged=True, node_path=path,
178
+ )
179
+ reservations.append(reg)
180
+ reads.append(('share', path, share_spec, reg, share_driver.initial_read(reg)))
181
+ for download_spec in download_specs.values():
182
+ reg = share_driver.reserve_download(
183
+ download_spec, context_shares._download_provider(download_spec),
184
+ replaces=old_shares + old_downloads, staged=True,
185
+ )
186
+ reservations.append(reg)
187
+ download_registrations.append(reg)
188
+ if share_driver is not None:
189
+ share_driver.start()
190
+ for kind, path, spec, reg, future in reads:
191
+ prepared_item = (path, spec, reg, future.result())
192
+ (prepared if kind == 'mount' else share_prepared).append(prepared_item)
193
+ initial = ctx._controller.call(
194
+ 'set_graph', graph, mount_prepared=tuple(prepared),
195
+ share_prepared=tuple(share_prepared),
196
+ download_registrations=tuple(download_registrations), klass=2,
197
+ )
198
+ for future in initial: future.result()
199
+ for reg in download_registrations:
200
+ share_driver.activate(reg).result(share_driver.delivery_timeout)
201
+ except BaseException:
202
+ for reg in reversed(reservations):
203
+ try:
204
+ future = reg.service.unregister(reg)
205
+ if future is not None:
206
+ future.result(getattr(reg.service, 'delivery_timeout', 60))
207
+ except BaseException:
208
+ pass
209
+ raise
210
+ finally:
211
+ for _, _, _, observation in (*prepared, *share_prepared): observation.release()
212
+ # The old graph's downloads go with it, except those whose key a new
213
+ # share or download took over (already removed by the commit).
214
+ for reg in old_downloads:
215
+ if not reg.closed:
216
+ context_shares._driver.unregister(reg).result(context_shares._driver.delivery_timeout)
217
+ context_shares._downloads = {reg.spec.path: reg for reg in download_registrations}
@@ -0,0 +1 @@
1
+ """Process-global filesystem transport."""
@@ -0,0 +1,63 @@
1
+ """Optional Linux notifications. Polling remains the correctness fallback.
2
+
3
+ Only directory watches are installed, so editor-style target replacement does
4
+ not invalidate a watch. This source only marks registrations dirty; the normal
5
+ transport still performs stable reads and owns watch sequences.
6
+ """
7
+ import os
8
+ import sys
9
+ import struct
10
+
11
+
12
+ class NativeNotifications:
13
+ def __init__(self):
14
+ self.fd = -1
15
+ self.watches = {}
16
+ if not sys.platform.startswith('linux'): return
17
+ try:
18
+ import ctypes
19
+ self.libc = ctypes.CDLL(None, use_errno=True)
20
+ self.libc.inotify_init1.argtypes = [ctypes.c_int]
21
+ self.libc.inotify_init1.restype = ctypes.c_int
22
+ self.libc.inotify_add_watch.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32]
23
+ self.libc.inotify_add_watch.restype = ctypes.c_int
24
+ self.libc.inotify_rm_watch.argtypes = [ctypes.c_int, ctypes.c_int]
25
+ self.libc.inotify_rm_watch.restype = ctypes.c_int
26
+ self.fd = self.libc.inotify_init1(os.O_NONBLOCK | os.O_CLOEXEC)
27
+ except (AttributeError, OSError): pass
28
+
29
+ def poll(self, registrations):
30
+ if self.fd < 0: return set()
31
+ parents = {os.path.dirname(r.path) for r in registrations if r.active and not r.closed}
32
+ for path in set(self.watches) - parents:
33
+ self.libc.inotify_rm_watch(self.fd, self.watches.pop(path))
34
+ for path in parents - set(self.watches):
35
+ # MODIFY, ATTRIB, CLOSE_WRITE, MOVED_FROM/TO, CREATE, DELETE,
36
+ # DELETE_SELF and MOVE_SELF. Failed watches fall back to polling.
37
+ wd = self.libc.inotify_add_watch(self.fd, os.fsencode(path), 0x00000FCE)
38
+ if wd >= 0: self.watches[path] = wd
39
+ reverse = {wd: path for path, wd in self.watches.items()}
40
+ changed = set()
41
+ while True:
42
+ try: data = os.read(self.fd, 65536)
43
+ except BlockingIOError: break
44
+ except OSError: break
45
+ if not data: break
46
+ offset = 0
47
+ while offset + 16 <= len(data):
48
+ wd, mask, cookie, length = struct.unpack_from('iIII', data, offset)
49
+ name = os.fsdecode(data[offset+16:offset+16+length].split(b'\0', 1)[0])
50
+ offset += 16 + length
51
+ if mask & 0x00004000: # queue overflow: request every path
52
+ return {r.path for r in registrations}
53
+ parent = reverse.get(wd)
54
+ if parent is None: continue
55
+ if mask & 0x00008000:
56
+ self.watches.pop(parent, None)
57
+ changed.add(os.path.join(parent, name) if name else parent)
58
+ return changed
59
+
60
+ def close(self):
61
+ if self.fd >= 0:
62
+ os.close(self.fd)
63
+ self.fd = -1