nuspace 0.1.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.
- nuspace/__init__.py +40 -0
- nuspace/_root.py +34 -0
- nuspace/apps/__init__.py +75 -0
- nuspace/apps/ops.py +280 -0
- nuspace/apps/runner.py +250 -0
- nuspace/apps/shapes.py +50 -0
- nuspace/cli/__init__.py +6 -0
- nuspace/cli/__main__.py +7 -0
- nuspace/cli/_meta.py +12 -0
- nuspace/cli/main.py +35 -0
- nuspace/core/__init__.py +18 -0
- nuspace/core/fields.py +184 -0
- nuspace/core/host.py +255 -0
- nuspace/core/ids.py +30 -0
- nuspace/core/session.py +125 -0
- nuspace/core/shapes.py +56 -0
- nuspace/core/tpl.py +243 -0
- nuspace/pages/__init__.py +101 -0
- nuspace/pages/ops.py +510 -0
- nuspace/pages/runner.py +422 -0
- nuspace/pages/shapes.py +111 -0
- nuspace/web/__init__.py +75 -0
- nuspace/web/apps/__init__.py +18 -0
- nuspace/web/apps/driver.py +176 -0
- nuspace/web/apps/ref.py +113 -0
- nuspace/web/arms.py +103 -0
- nuspace/web/lens/__init__.py +22 -0
- nuspace/web/lens/driver.py +98 -0
- nuspace/web/lens/ref.py +73 -0
- nuspace/web/lens/reflect.py +371 -0
- nuspace/web/nav.py +74 -0
- nuspace/web/pages/__init__.py +21 -0
- nuspace/web/pages/driver.py +295 -0
- nuspace/web/pages/ref.py +170 -0
- nuspace/web/pages/session.py +170 -0
- nuspace/web/serve/__init__.py +33 -0
- nuspace/web/serve/app.py +150 -0
- nuspace/web/serve/fabric.py +186 -0
- nuspace/web/serve/session.py +180 -0
- nuspace/web/serve/shell.py +216 -0
- nuspace/web/space.py +199 -0
- nuspace/web/wire.py +47 -0
- nuspace-0.1.0.dist-info/METADATA +38 -0
- nuspace-0.1.0.dist-info/RECORD +47 -0
- nuspace-0.1.0.dist-info/WHEEL +4 -0
- nuspace-0.1.0.dist-info/entry_points.txt +2 -0
- nuspace-0.1.0.dist-info/licenses/LICENSE.md +667 -0
nuspace/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Nuspace: a space that runs applications over one shape tree, built on Nu.
|
|
2
|
+
|
|
3
|
+
Rebuild in progress. The imperative driver layer was deleted wholesale
|
|
4
|
+
because it was python wearing a Nu costume: Control atoms holding opaque
|
|
5
|
+
callables, per-run state on host objects, and a python -> Nu -> arun ->
|
|
6
|
+
python cycle instead of one tree.
|
|
7
|
+
|
|
8
|
+
What nuspace is meant to be is one large Nu program. Storage shapes, the
|
|
9
|
+
tpl registry and the wysiwyg template below survived because they are
|
|
10
|
+
already that. Everything else gets written against the guides in
|
|
11
|
+
``go/progress/tasks/task-144-nuspace-v1/reference/``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from nuspace.apps import App, run_apps
|
|
15
|
+
from nuspace.core import (
|
|
16
|
+
TPL_PROGRAM,
|
|
17
|
+
TPL_TEXT,
|
|
18
|
+
Space,
|
|
19
|
+
Tpl,
|
|
20
|
+
mint_ordered_id,
|
|
21
|
+
resolve,
|
|
22
|
+
)
|
|
23
|
+
from nuspace.pages import Page, Section, page_tree
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__version__ = "0.0.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"TPL_PROGRAM",
|
|
30
|
+
"TPL_TEXT",
|
|
31
|
+
"App",
|
|
32
|
+
"Page",
|
|
33
|
+
"Section",
|
|
34
|
+
"Space",
|
|
35
|
+
"Tpl",
|
|
36
|
+
"mint_ordered_id",
|
|
37
|
+
"page_tree",
|
|
38
|
+
"resolve",
|
|
39
|
+
"run_apps",
|
|
40
|
+
]
|
nuspace/_root.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Which space root class a call addresses, resolved at call time.
|
|
2
|
+
|
|
3
|
+
``core.shapes`` imports the per-submodule shapes to build ``Space``, so a
|
|
4
|
+
submodule that named ``Space`` at import time would close the cycle. This is
|
|
5
|
+
the one place the import is deferred instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from nuspace.core import Space
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["resolve_root"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_root(root: type[Space] | None) -> type[Space]:
|
|
21
|
+
"""``root``, or ``Space`` when it is None.
|
|
22
|
+
|
|
23
|
+
``Space.apps`` and ``DemoSpace.apps`` are different addresses, so an op
|
|
24
|
+
against a subclassed space has to be told which root it is working.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
root: the space's root Shape class, or None for the stock ``Space``.
|
|
28
|
+
"""
|
|
29
|
+
if root is not None:
|
|
30
|
+
return root
|
|
31
|
+
# Deferred on purpose: see the module docstring.
|
|
32
|
+
from nuspace.core.shapes import Space
|
|
33
|
+
|
|
34
|
+
return Space
|
nuspace/apps/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""nuspace.apps -- every app in the space, running, as one Nu tree.
|
|
2
|
+
|
|
3
|
+
Module layout:
|
|
4
|
+
|
|
5
|
+
- :mod:`.shapes` -- store layout (``App``) and the host's own ``Runner``.
|
|
6
|
+
- :mod:`.ops` -- write + read primitives (``add_app`` / ``snippet_of`` / ...).
|
|
7
|
+
What a ui, a cli or an agent calls instead of writing ref chains.
|
|
8
|
+
- :mod:`.runner` -- the driver: seed, reconcile, live loop, ``run_apps``.
|
|
9
|
+
|
|
10
|
+
No ``interactions`` module: every op here is a plain ``-> Nu`` function over
|
|
11
|
+
existing atoms, and nothing in this layer touches the host directly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .ops import (
|
|
17
|
+
add_app,
|
|
18
|
+
app_ids,
|
|
19
|
+
attached,
|
|
20
|
+
error_of,
|
|
21
|
+
exists,
|
|
22
|
+
is_running,
|
|
23
|
+
remove_app,
|
|
24
|
+
rename_app,
|
|
25
|
+
running,
|
|
26
|
+
set_policy,
|
|
27
|
+
set_snippet,
|
|
28
|
+
snippet_of,
|
|
29
|
+
)
|
|
30
|
+
from .runner import (
|
|
31
|
+
CHANGED_APP_INDEX,
|
|
32
|
+
DEFAULT_CHANNEL_PREFIX,
|
|
33
|
+
app_body,
|
|
34
|
+
apps_store,
|
|
35
|
+
apps_tree,
|
|
36
|
+
changed_app,
|
|
37
|
+
driver,
|
|
38
|
+
free_port,
|
|
39
|
+
reconcile,
|
|
40
|
+
run_apps,
|
|
41
|
+
supervisor,
|
|
42
|
+
worker_init,
|
|
43
|
+
)
|
|
44
|
+
from .shapes import DEFAULT_POLICY, App, Runner
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"CHANGED_APP_INDEX",
|
|
49
|
+
"DEFAULT_CHANNEL_PREFIX",
|
|
50
|
+
"DEFAULT_POLICY",
|
|
51
|
+
"App",
|
|
52
|
+
"Runner",
|
|
53
|
+
"add_app",
|
|
54
|
+
"app_body",
|
|
55
|
+
"app_ids",
|
|
56
|
+
"apps_store",
|
|
57
|
+
"apps_tree",
|
|
58
|
+
"attached",
|
|
59
|
+
"changed_app",
|
|
60
|
+
"driver",
|
|
61
|
+
"error_of",
|
|
62
|
+
"exists",
|
|
63
|
+
"free_port",
|
|
64
|
+
"is_running",
|
|
65
|
+
"reconcile",
|
|
66
|
+
"remove_app",
|
|
67
|
+
"rename_app",
|
|
68
|
+
"run_apps",
|
|
69
|
+
"running",
|
|
70
|
+
"set_policy",
|
|
71
|
+
"set_snippet",
|
|
72
|
+
"snippet_of",
|
|
73
|
+
"supervisor",
|
|
74
|
+
"worker_init",
|
|
75
|
+
]
|
nuspace/apps/ops.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Write + read primitives over the apps store.
|
|
2
|
+
|
|
3
|
+
Every one returns a Nu tree and nothing else, so a ui, a cli or an agent
|
|
4
|
+
composes them instead of hand-writing ref chains. ``root`` is the space's own
|
|
5
|
+
root Shape class, resolved at call time by :func:`~nuspace._root.resolve_root`
|
|
6
|
+
so this module never needs ``Space`` while it is being imported.
|
|
7
|
+
|
|
8
|
+
Write:
|
|
9
|
+
- :func:`init_apps` -- the container, once, on a cold store.
|
|
10
|
+
- :func:`add_app` -- every field of a new app, in one tree.
|
|
11
|
+
- :func:`remove_app` -- drop the row. The runner kills the worker.
|
|
12
|
+
- :func:`set_snippet` / :func:`rename_app` / :func:`set_policy` -- one field.
|
|
13
|
+
- :func:`clear_error` -- forget what the runner last recorded.
|
|
14
|
+
|
|
15
|
+
Read:
|
|
16
|
+
- :func:`app_ids` / :func:`exists` / :func:`snippet_of` / :func:`error_of`
|
|
17
|
+
-- the store.
|
|
18
|
+
- :func:`running` / :func:`is_running` / :func:`attached` -- the host's own
|
|
19
|
+
``Runner``, which is mem and therefore only readable from inside the
|
|
20
|
+
process the runner is in.
|
|
21
|
+
- whole rows, one dict per app: :func:`app_rows` / :func:`app_statuses`. What
|
|
22
|
+
a rail or a status bar is filled from.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
import nu
|
|
30
|
+
from nuspace._root import resolve_root
|
|
31
|
+
from nuspace.core.ids import mint_ordered_id
|
|
32
|
+
|
|
33
|
+
from .shapes import DEFAULT_POLICY, Runner
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from nu.domains.shape import Shape
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"add_app",
|
|
42
|
+
"app_ids",
|
|
43
|
+
"app_rows",
|
|
44
|
+
"app_statuses",
|
|
45
|
+
"attached",
|
|
46
|
+
"clear_error",
|
|
47
|
+
"error_of",
|
|
48
|
+
"exists",
|
|
49
|
+
"init_apps",
|
|
50
|
+
"is_running",
|
|
51
|
+
"remove_app",
|
|
52
|
+
"rename_app",
|
|
53
|
+
"running",
|
|
54
|
+
"set_policy",
|
|
55
|
+
"set_snippet",
|
|
56
|
+
"snippet_of",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
#: The name ``Map`` binds the current element under, and the ref that reads it.
|
|
61
|
+
_ITEM = "_na_item"
|
|
62
|
+
_item = nu.AnyAttrRef(_ITEM)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _state_key(app_id: nu.StrArg, suffix: str) -> nu.Nu:
|
|
66
|
+
"""``apps.<app_id><suffix>`` as a term, for either a python str or a term."""
|
|
67
|
+
return nu.Str("apps.") + app_id + nu.Str(suffix)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- write: the space ------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def init_apps(*, root: type[Shape] | None = None) -> nu.Nu:
|
|
74
|
+
"""Create the apps container if the store has none. Idempotent.
|
|
75
|
+
|
|
76
|
+
A subscription over a missing container resolves to INVALID and silently
|
|
77
|
+
never fires, so anything that means to watch ``apps`` boots through here
|
|
78
|
+
first. ``Dict.create()``, not ``{}``: a literal is captured once at Form
|
|
79
|
+
construction and shared across every evaluation of the term.
|
|
80
|
+
"""
|
|
81
|
+
return resolve_root(root).apps.init(nu.Dict.create())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- write -----------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def add_app(
|
|
88
|
+
source: nu.StrArg,
|
|
89
|
+
*,
|
|
90
|
+
app_id: nu.StrArg | None = None,
|
|
91
|
+
name: nu.StrArg | None = None,
|
|
92
|
+
policy: nu.StrArg = DEFAULT_POLICY,
|
|
93
|
+
root: type[Shape] | None = None,
|
|
94
|
+
) -> nu.Nu:
|
|
95
|
+
"""Write a whole app: id, name, policy and source, in one tree.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
source: the snippet, a ``nu.prog`` module with an ``out`` entry point.
|
|
99
|
+
app_id: the app's key. Minted in creation order when absent, in which
|
|
100
|
+
case the caller never learns it -- pass ``mint_ordered_id("a")``
|
|
101
|
+
yourself if you mean to address the app afterwards.
|
|
102
|
+
name: what to call it. Defaults to the id.
|
|
103
|
+
policy: when it runs. Nothing reads it yet.
|
|
104
|
+
root: the space's root Shape class.
|
|
105
|
+
"""
|
|
106
|
+
# Minted while the tree is built, not while it runs, so re-running one tree
|
|
107
|
+
# rewrites one app rather than adding another.
|
|
108
|
+
app_id = mint_ordered_id("a") if app_id is None else app_id
|
|
109
|
+
app = resolve_root(root).apps[app_id]
|
|
110
|
+
# Snippet last: every field write wakes its own reconcile, so this order
|
|
111
|
+
# leaves the pass that actually launches the app holding the final source.
|
|
112
|
+
return (
|
|
113
|
+
app.name.set(app_id if name is None else name)
|
|
114
|
+
>> app.policy.set(policy)
|
|
115
|
+
>> app.snippet.set(source)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def remove_app(app_id: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
|
|
120
|
+
"""Drop an app from the store. A no-op when it is not there."""
|
|
121
|
+
apps = resolve_root(root).apps
|
|
122
|
+
# Guarded rather than bare: del_item on a missing key raises, and removing
|
|
123
|
+
# something already gone is exactly what a retried ui click does.
|
|
124
|
+
return nu.IfDo(apps.contains(app_id), apps.del_item(app_id))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def set_snippet(app_id: nu.StrArg, source: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
|
|
128
|
+
"""Replace an app's source. The runner restarts that app and no other."""
|
|
129
|
+
return resolve_root(root).apps[app_id].snippet.set(source)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def rename_app(app_id: nu.StrArg, name: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
|
|
133
|
+
"""Replace an app's display name. The id does not move."""
|
|
134
|
+
return resolve_root(root).apps[app_id].name.set(name)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def set_policy(app_id: nu.StrArg, policy: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
|
|
138
|
+
"""Replace an app's policy string."""
|
|
139
|
+
return resolve_root(root).apps[app_id].policy.set(policy)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def clear_error(app_id: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
|
|
143
|
+
"""Forget the construction error recorded for this app. A no-op when clean.
|
|
144
|
+
|
|
145
|
+
Guarded rather than bare: ``del_item`` on a missing key raises, and an app
|
|
146
|
+
that never failed has no key.
|
|
147
|
+
"""
|
|
148
|
+
state = resolve_root(root).state
|
|
149
|
+
key = _state_key(app_id, ".error")
|
|
150
|
+
return nu.IfDo(state.contains(key), state.del_item(key))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --- read ------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def app_ids(*, root: type[Shape] | None = None) -> nu.Nu:
|
|
157
|
+
"""Every app id in the space, as a list."""
|
|
158
|
+
# nu.list, not the bare keys view: the view is lazy and dies with its
|
|
159
|
+
# Snapshot, so an undrained one reads as StorageClosedError later.
|
|
160
|
+
return nu.list(resolve_root(root).apps.keys())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def exists(app_id: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
|
|
164
|
+
"""Whether the store has an app under this id."""
|
|
165
|
+
return resolve_root(root).apps.contains(app_id)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def snippet_of(app_id: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
|
|
169
|
+
"""An app's source, verbatim. EMPTY when there is no such app."""
|
|
170
|
+
return resolve_root(root).apps[app_id].snippet
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def error_of(app_id: nu.StrArg, *, root: type[Shape] | None = None) -> nu.Nu:
|
|
174
|
+
"""The construction error the runner recorded for this app, or ``""``.
|
|
175
|
+
|
|
176
|
+
Written by ``app_body`` when a snippet does not construct, since a
|
|
177
|
+
dispatched body has no waiter to raise into.
|
|
178
|
+
"""
|
|
179
|
+
return resolve_root(root).state.get_item(_state_key(app_id, ".error"), nu.Str(""))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def running() -> nu.Nu:
|
|
183
|
+
"""Which apps have a worker, and its pool id, as a dict.
|
|
184
|
+
|
|
185
|
+
``Runner.workers`` is mem in the host process, so this only answers inside
|
|
186
|
+
the runner's own tree.
|
|
187
|
+
"""
|
|
188
|
+
return nu.dict(Runner.workers.items())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def is_running(app_id: nu.StrArg) -> nu.Nu:
|
|
192
|
+
"""Whether this app has a worker on record right now."""
|
|
193
|
+
return Runner.workers.contains(app_id)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def attached() -> nu.Nu:
|
|
197
|
+
"""Whether a runner is supervising apps in this very process. A live read.
|
|
198
|
+
|
|
199
|
+
Total, and true only where the bookkeeping really is: the ``FabricExists``
|
|
200
|
+
half answers False rather than raising where no ``dict`` is bound at all,
|
|
201
|
+
and ``Runner.attached`` is mem, so a runner in another process cannot make
|
|
202
|
+
this say yes.
|
|
203
|
+
"""
|
|
204
|
+
return nu.And(nu.FabricRef(dict).exists(), nu.NotEmpty(Runner.attached))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --- read: whole rows ------------------------------------------------------
|
|
208
|
+
#
|
|
209
|
+
# Both answer with a list of dicts rather than a scalar, so one read fills a
|
|
210
|
+
# rail or a status bar. They exist because a caller that wants every app would
|
|
211
|
+
# otherwise read the ids and then loop in its own language, which puts a python
|
|
212
|
+
# (or a javascript) for-loop back in the middle of what is meant to be one tree.
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def app_rows(*, root: type[Shape] | None = None) -> nu.Nu:
|
|
216
|
+
"""Every app as ``{id, name, source, policy}``, one dict per app.
|
|
217
|
+
|
|
218
|
+
Keys sort by mint time, so this is creation order with no order slot.
|
|
219
|
+
"""
|
|
220
|
+
apps = resolve_root(root).apps
|
|
221
|
+
app = apps[_item]
|
|
222
|
+
return nu.Collect(
|
|
223
|
+
nu.Map(
|
|
224
|
+
# nu.list, not the bare keys view, for the reason app_ids gives.
|
|
225
|
+
nu.list(apps.keys()),
|
|
226
|
+
nu.Dict.of(id=_item, name=app.name, source=app.snippet, policy=app.policy),
|
|
227
|
+
key=_ITEM,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _live(supervised: nu.BoolArg) -> nu.Nu | None:
|
|
233
|
+
"""Whether the app ``Map`` is on has a worker, or None when nobody asked.
|
|
234
|
+
|
|
235
|
+
``supervised`` is ``False`` for a tree that must not touch mem at all, and
|
|
236
|
+
a term for one that wants the answer read rather than assumed.
|
|
237
|
+
"""
|
|
238
|
+
if supervised is False:
|
|
239
|
+
return None
|
|
240
|
+
running_now = is_running(nu.StrAttrRef(_ITEM))
|
|
241
|
+
if supervised is True:
|
|
242
|
+
return running_now
|
|
243
|
+
return nu.And(supervised, running_now)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def app_statuses(*, supervised: nu.BoolArg = False, root: type[Shape] | None = None) -> nu.Nu:
|
|
247
|
+
"""Every app as ``{section_id, state, error, started_at}``, in the same order.
|
|
248
|
+
|
|
249
|
+
``section_id`` rather than ``app_id`` because this is the supervisor's
|
|
250
|
+
contract, not the Apps surface's: an app and a section are the same
|
|
251
|
+
substance and share one status shape. An app whose namespace holds an
|
|
252
|
+
``error`` key reads ``failed``.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
supervised: whether ``Runner.workers`` is readable here, which it only
|
|
256
|
+
is inside the runner's own tree. ``False`` never claims an app is
|
|
257
|
+
running and never touches mem; ``True`` reads it outright; a term
|
|
258
|
+
(see :func:`attached`) reads whether to read it, which is what a
|
|
259
|
+
web driver that may or may not share a process with a runner wants.
|
|
260
|
+
root: the space's root Shape class.
|
|
261
|
+
"""
|
|
262
|
+
root = resolve_root(root)
|
|
263
|
+
# Same binding Map makes, read as a Str so `+` concatenates rather than
|
|
264
|
+
# collapsing to INVALID the way it would on an untyped AnyAttrRef.
|
|
265
|
+
key = _state_key(nu.StrAttrRef(_ITEM), ".error")
|
|
266
|
+
idle = nu.Str("idle")
|
|
267
|
+
live = _live(supervised)
|
|
268
|
+
quiet = idle if live is None else nu.If(live, nu.Str("running"), idle)
|
|
269
|
+
return nu.Collect(
|
|
270
|
+
nu.Map(
|
|
271
|
+
app_ids(root=root),
|
|
272
|
+
nu.Dict.of(
|
|
273
|
+
section_id=_item,
|
|
274
|
+
state=nu.If(root.state.contains(key), nu.Str("failed"), quiet),
|
|
275
|
+
error=nu.ToStr(root.state.get_item(key, nu.Str(""))),
|
|
276
|
+
started_at=nu.Int(0),
|
|
277
|
+
),
|
|
278
|
+
key=_ITEM,
|
|
279
|
+
)
|
|
280
|
+
)
|
nuspace/apps/runner.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""The apps driver: every app in the space, running, as one Nu tree.
|
|
2
|
+
|
|
3
|
+
One ``arun`` owns it: seed from the store, then react to it forever, with a
|
|
4
|
+
single reconcile path covering add, edit and delete. A snippet owns its own
|
|
5
|
+
atomicity, and without ``redis_url`` a worker can read and write the store
|
|
6
|
+
but cannot react to another process's writes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import nu
|
|
14
|
+
import nu.kv
|
|
15
|
+
import nu.mp_pool
|
|
16
|
+
import nu.prog
|
|
17
|
+
from nuspace._root import resolve_root
|
|
18
|
+
from nuspace.core.host import DEFAULT_CHANNEL_PREFIX, free_port, host, worker_init
|
|
19
|
+
|
|
20
|
+
from .shapes import Runner
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from nu.domains.shape import Shape
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"CHANGED_APP_INDEX",
|
|
29
|
+
"DEFAULT_CHANNEL_PREFIX",
|
|
30
|
+
"app_body",
|
|
31
|
+
"apps_store",
|
|
32
|
+
"apps_tree",
|
|
33
|
+
"changed_app",
|
|
34
|
+
"driver",
|
|
35
|
+
"free_port",
|
|
36
|
+
"reconcile",
|
|
37
|
+
"run_apps",
|
|
38
|
+
"supervisor",
|
|
39
|
+
"worker_init",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
#: Position of the app id in a key from ``Space.apps.on_change()``. The
|
|
44
|
+
#: subscription is depth-unbounded, so a key is either ``('/', 'apps', id)`` or
|
|
45
|
+
#: ``('/', 'apps', id, field)``; index 2 reaches the id in both. Measured, and
|
|
46
|
+
#: pinned by ``tests/nuspace/apps/test_changed_key.py``.
|
|
47
|
+
CHANGED_APP_INDEX = 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
#: The app a reconcile pass is about, carried into the worker by ``carry=True``.
|
|
51
|
+
_APP = nu.StrAttrRef("app")
|
|
52
|
+
|
|
53
|
+
#: The pool worker a reconcile pass just launched.
|
|
54
|
+
_WORKER = nu.IntAttrRef("w")
|
|
55
|
+
|
|
56
|
+
#: The key that woke the live loop, and the app id inside it.
|
|
57
|
+
_KEY = nu.TupleAttrRef("k")
|
|
58
|
+
changed_app = _KEY[CHANGED_APP_INDEX]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def app_body(*, root: type[Shape] | None = None) -> nu.Nu:
|
|
62
|
+
"""One app running, as the single term every ``Dispatch`` ships.
|
|
63
|
+
|
|
64
|
+
One per space, not one per app: a ``Dispatch`` body is payload, so it is
|
|
65
|
+
fixed at construction and the app arrives as the carried attr ``app``. The
|
|
66
|
+
``auto_flow_atomic`` covers the runner's own ``LoadNu`` read and stops
|
|
67
|
+
there, since the pass does not descend through ``Eval``.
|
|
68
|
+
"""
|
|
69
|
+
root = resolve_root(root)
|
|
70
|
+
# Construction failures are written to the app's namespace, not raised: a
|
|
71
|
+
# dispatched body has no waiter, so an uncaught error vanishes silently.
|
|
72
|
+
load = root.apps[_APP].snippet.load(scope={"path": nu.Str("apps.") + _APP})
|
|
73
|
+
report = nu.kv.auto_flow_atomic(
|
|
74
|
+
root.state.set_item(
|
|
75
|
+
nu.Str("apps.") + _APP + nu.Str(".error"), nu.ToStr(nu.AttrRef("error"))
|
|
76
|
+
),
|
|
77
|
+
scope=root,
|
|
78
|
+
)
|
|
79
|
+
return nu.TryCatch(
|
|
80
|
+
nu.prog.Eval(nu.kv.auto_flow_atomic(load, scope=root)),
|
|
81
|
+
catch=report,
|
|
82
|
+
errors=nu.prog.ConstructionError,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def reconcile(*, root: type[Shape] | None = None) -> nu.Nu:
|
|
87
|
+
"""Make the world agree with the store, for the one app bound at ``app``.
|
|
88
|
+
|
|
89
|
+
One path for add, edit and delete: whatever is running for this app stops,
|
|
90
|
+
and if the store still has the app it starts again. ``Launch``, ``Dispatch``
|
|
91
|
+
and ``Kill`` all return promptly, so the seed can run this sequentially
|
|
92
|
+
without the first app blocking the rest.
|
|
93
|
+
"""
|
|
94
|
+
root = resolve_root(root)
|
|
95
|
+
pool = nu.mp_pool.PoolRef()
|
|
96
|
+
stop = nu.IfDo(
|
|
97
|
+
Runner.workers.contains(_APP),
|
|
98
|
+
pool.kill(Runner.workers[_APP]) >> Runner.workers.del_item(_APP),
|
|
99
|
+
)
|
|
100
|
+
start = nu.IfDo(
|
|
101
|
+
# An app that has been deleted reaches here too -- that is the delete
|
|
102
|
+
# path, and this is what stops it coming back.
|
|
103
|
+
root.apps.contains(_APP),
|
|
104
|
+
# The worker id is read twice, recorded and then dispatched to, so it
|
|
105
|
+
# is bound once and scoped to the two reads that want it.
|
|
106
|
+
nu.Let(
|
|
107
|
+
"w",
|
|
108
|
+
pool.launch(),
|
|
109
|
+
body=Runner.workers.set_item(_APP, _WORKER)
|
|
110
|
+
>> pool.dispatch(app_body(root=root), _WORKER, carry=True),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
return stop >> start
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def driver(*, root: type[Shape] | None = None) -> tuple[nu.Nu, nu.Nu]:
|
|
117
|
+
"""The seed pass and the live loop, as two terms.
|
|
118
|
+
|
|
119
|
+
Returned separately because they compose differently: the seed must finish
|
|
120
|
+
before anything else starts, and the live loop never finishes at all.
|
|
121
|
+
"""
|
|
122
|
+
root = resolve_root(root)
|
|
123
|
+
body = reconcile(root=root)
|
|
124
|
+
# Both containers must exist before anything reads them: a subscription
|
|
125
|
+
# over a missing container resolves to INVALID and silently never fires.
|
|
126
|
+
# Dict.create(), not {} -- a literal dict is captured once at Form
|
|
127
|
+
# construction and shared across every evaluation of the term.
|
|
128
|
+
#
|
|
129
|
+
# `attached` is the marker a web driver in this same process reads to say
|
|
130
|
+
# whether anything is supervising at all. It is written here rather than
|
|
131
|
+
# assumed by whoever assembled the tree, so the browser is told what is
|
|
132
|
+
# true rather than what the build script believed.
|
|
133
|
+
boot = (
|
|
134
|
+
root.apps.init(nu.Dict.create())
|
|
135
|
+
>> Runner.workers.init(nu.Dict.create())
|
|
136
|
+
>> Runner.attached.set(nu.Bool(True))
|
|
137
|
+
)
|
|
138
|
+
# nu.list is load-bearing: the keys view is lazy and auto_flow_atomic
|
|
139
|
+
# brackets the items slot separately, so an undrained view outlives its
|
|
140
|
+
# Snapshot and dies with StorageClosedError.
|
|
141
|
+
seed = boot >> nu.ForEachDo(nu.list(root.apps.keys()), body, item="app")
|
|
142
|
+
# The bare key ('/', 'apps') also fires, naming no app. It must be skipped
|
|
143
|
+
# explicitly: indexing past the end raises IndexError inside the react
|
|
144
|
+
# loop, killing it and leaving the runner silently deaf.
|
|
145
|
+
live = nu.ReactForever(
|
|
146
|
+
root.apps.on_change(),
|
|
147
|
+
nu.IfDo(
|
|
148
|
+
nu.Len(_KEY) > nu.Int(CHANGED_APP_INDEX),
|
|
149
|
+
# Same binding the seed pass makes with ForEachDo(item="app"), and
|
|
150
|
+
# scoped the same way, so no reaction leaves one behind.
|
|
151
|
+
nu.Let("app", changed_app, body=body),
|
|
152
|
+
),
|
|
153
|
+
changed_key="k",
|
|
154
|
+
)
|
|
155
|
+
return seed, live
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def supervisor(
|
|
159
|
+
*,
|
|
160
|
+
root: type[Shape] | None = None,
|
|
161
|
+
alongside: nu.Nu | None = None,
|
|
162
|
+
duration: float | None = None,
|
|
163
|
+
) -> nu.Nu:
|
|
164
|
+
"""Every app in the space, supervised, as one term for whoever mounts it.
|
|
165
|
+
|
|
166
|
+
No ``With`` head: the store, the pool and the ``dict`` behind
|
|
167
|
+
``Runner.workers`` come from the context this is run in, which is what
|
|
168
|
+
lets a web server and this share one process and one store.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
root: the space's root Shape class.
|
|
172
|
+
alongside: a tree to run beside the live loop, for demos and tests.
|
|
173
|
+
duration: stop after this many seconds. None runs forever.
|
|
174
|
+
"""
|
|
175
|
+
root = resolve_root(root)
|
|
176
|
+
seed, live = driver(root=root)
|
|
177
|
+
flow = live if alongside is None else (live | alongside)
|
|
178
|
+
if duration is not None:
|
|
179
|
+
flow = nu.Race(flow, nu.DelayedDo(nu.Float(duration), nu.Noop()))
|
|
180
|
+
return nu.kv.auto_flow_atomic(seed >> flow, scope=root)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def apps_store(
|
|
184
|
+
path: str,
|
|
185
|
+
*,
|
|
186
|
+
redis_url: str | None = None,
|
|
187
|
+
channel_prefix: str = DEFAULT_CHANNEL_PREFIX,
|
|
188
|
+
) -> nu.Nu:
|
|
189
|
+
"""The navigator bracket for a space on disk, with or without redis."""
|
|
190
|
+
if redis_url is None:
|
|
191
|
+
return nu.kv.rocksdb_navigator(path)
|
|
192
|
+
return nu.kv.rocksdb_navigator_redis(path, redis_url=redis_url, channel_prefix=channel_prefix)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def apps_tree(
|
|
196
|
+
*,
|
|
197
|
+
path: str,
|
|
198
|
+
address: str,
|
|
199
|
+
root: type[Shape] | None = None,
|
|
200
|
+
redis_url: str | None = None,
|
|
201
|
+
channel_prefix: str = DEFAULT_CHANNEL_PREFIX,
|
|
202
|
+
alongside: nu.Nu | None = None,
|
|
203
|
+
duration: float | None = None,
|
|
204
|
+
) -> nu.Nu:
|
|
205
|
+
"""The whole runner: one tree, for one ``arun``.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
path: the store directory. This tree takes its write lock, which is
|
|
209
|
+
why workers reach it through a proxy rather than opening it.
|
|
210
|
+
address: where the Navigator is served, ``host:port``.
|
|
211
|
+
root: the space's root Shape class. Defaults to ``Space``.
|
|
212
|
+
redis_url: Redis carrying change notifications, or None.
|
|
213
|
+
channel_prefix: namespaces the Redis channels.
|
|
214
|
+
alongside: a tree to run beside the live loop, for demos and tests.
|
|
215
|
+
duration: stop after this many seconds. None runs forever.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
The tree. Brackets tear down LIFO when it ends, reaping every worker.
|
|
219
|
+
"""
|
|
220
|
+
return host(
|
|
221
|
+
supervisor(root=root, alongside=alongside, duration=duration),
|
|
222
|
+
store=apps_store(path, redis_url=redis_url, channel_prefix=channel_prefix),
|
|
223
|
+
address=address,
|
|
224
|
+
redis_url=redis_url,
|
|
225
|
+
channel_prefix=channel_prefix,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def run_apps(
|
|
230
|
+
path: str,
|
|
231
|
+
*,
|
|
232
|
+
address: str | None = None,
|
|
233
|
+
root: type[Shape] | None = None,
|
|
234
|
+
redis_url: str | None = None,
|
|
235
|
+
channel_prefix: str = DEFAULT_CHANNEL_PREFIX,
|
|
236
|
+
alongside: nu.Nu | None = None,
|
|
237
|
+
duration: float | None = None,
|
|
238
|
+
max_parallel: int = 64,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Assemble the tree and run it. Deployment glue, nothing else."""
|
|
241
|
+
tree = apps_tree(
|
|
242
|
+
path=path,
|
|
243
|
+
address=address or f"127.0.0.1:{free_port()}",
|
|
244
|
+
root=root,
|
|
245
|
+
redis_url=redis_url,
|
|
246
|
+
channel_prefix=channel_prefix,
|
|
247
|
+
alongside=alongside,
|
|
248
|
+
duration=duration,
|
|
249
|
+
)
|
|
250
|
+
await nu.arun(tree, nu.Context(), max_parallel=max_parallel)
|