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.
- seamless_workflow/__init__.py +40 -0
- seamless_workflow/adapters.py +50 -0
- seamless_workflow/assignment.py +8 -0
- seamless_workflow/attachments/__init__.py +3 -0
- seamless_workflow/attachments/api.py +217 -0
- seamless_workflow/attachments/fs/__init__.py +1 -0
- seamless_workflow/attachments/fs/native.py +63 -0
- seamless_workflow/attachments/fs/service.py +601 -0
- seamless_workflow/attachments/manual.py +65 -0
- seamless_workflow/attachments/output_widget.py +62 -0
- seamless_workflow/attachments/policy.py +41 -0
- seamless_workflow/attachments/runtime.py +594 -0
- seamless_workflow/attachments/session.py +93 -0
- seamless_workflow/attachments/share/__init__.py +2 -0
- seamless_workflow/attachments/share/api.py +300 -0
- seamless_workflow/attachments/share/archive.py +60 -0
- seamless_workflow/attachments/share/driver.py +110 -0
- seamless_workflow/attachments/share/mime.py +180 -0
- seamless_workflow/attachments/share/openapi.py +358 -0
- seamless_workflow/attachments/share/server.py +1660 -0
- seamless_workflow/attachments/share/spec.py +139 -0
- seamless_workflow/attachments/share/static/seamless-client.js +538 -0
- seamless_workflow/attachments/spec.py +42 -0
- seamless_workflow/attachments/traitlet.py +344 -0
- seamless_workflow/attachments/widget.py +229 -0
- seamless_workflow/builder_state.py +1107 -0
- seamless_workflow/configuration.py +103 -0
- seamless_workflow/context.py +3287 -0
- seamless_workflow/controller.py +160 -0
- seamless_workflow/diagnostics.py +69 -0
- seamless_workflow/endpoints.py +33 -0
- seamless_workflow/errors.py +69 -0
- seamless_workflow/graph.py +636 -0
- seamless_workflow/ingress.py +411 -0
- seamless_workflow/jupyter.py +49 -0
- seamless_workflow/lifecycle.py +26 -0
- seamless_workflow/reactive.py +386 -0
- seamless_workflow/runtime_api.py +227 -0
- seamless_workflow/scheduler.py +106 -0
- seamless_workflow/serialization.py +317 -0
- seamless_workflow/serve_graph.py +55 -0
- seamless_workflow/shareserver.py +36 -0
- seamless_workflow/sidework.py +102 -0
- seamless_workflow/views.py +62 -0
- seamless_workflow-0.2.0.dist-info/METADATA +157 -0
- seamless_workflow-0.2.0.dist-info/RECORD +49 -0
- seamless_workflow-0.2.0.dist-info/WHEEL +5 -0
- seamless_workflow-0.2.0.dist-info/entry_points.txt +2 -0
- 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,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
|