stateous 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.
- stateous/__init__.py +24 -0
- stateous/__main__.py +6 -0
- stateous/cli/__init__.py +1 -0
- stateous/cli/errors.py +39 -0
- stateous/cli/main.py +219 -0
- stateous/cli/project_file.py +112 -0
- stateous/cli/running.py +276 -0
- stateous/cli/serving.py +400 -0
- stateous/cli/tokens.py +255 -0
- stateous/client/__init__.py +5 -0
- stateous/client/client.py +225 -0
- stateous/compiler/__init__.py +4 -0
- stateous/compiler/codegen.py +426 -0
- stateous/compiler/compile.py +145 -0
- stateous/compiler/exclusive.py +383 -0
- stateous/compiler/load.py +98 -0
- stateous/compiler/plot.py +88 -0
- stateous/compiler/validate.py +326 -0
- stateous/control/__init__.py +1 -0
- stateous/control/api.py +564 -0
- stateous/control/engine.py +1358 -0
- stateous/control/graph.py +149 -0
- stateous/control/store/__init__.py +7 -0
- stateous/control/store/errors.py +10 -0
- stateous/control/store/migrations/__init__.py +160 -0
- stateous/control/store/migrations/env.py +44 -0
- stateous/control/store/migrations/script.py.mako +28 -0
- stateous/control/store/migrations/versions/0001_baseline.py +105 -0
- stateous/control/store/migrations/versions/0002_claims.py +34 -0
- stateous/control/store/migrations/versions/0003_children.py +40 -0
- stateous/control/store/store.py +765 -0
- stateous/control/store/tables.py +149 -0
- stateous/model/__init__.py +3 -0
- stateous/model/context.py +170 -0
- stateous/model/contract.py +163 -0
- stateous/model/durations.py +36 -0
- stateous/model/events.py +44 -0
- stateous/model/graph_file.py +147 -0
- stateous/model/guards.py +299 -0
- stateous/model/host.py +50 -0
- stateous/model/wire.py +209 -0
- stateous/model/workflow.py +417 -0
- stateous/py.typed +0 -0
- stateous/supervisor/__init__.py +1 -0
- stateous/supervisor/folder.py +277 -0
- stateous/supervisor/outbox.py +171 -0
- stateous/supervisor/registry.py +81 -0
- stateous/supervisor/remote.py +238 -0
- stateous/supervisor/supervisor.py +797 -0
- stateous-0.1.0.dist-info/METADATA +87 -0
- stateous-0.1.0.dist-info/RECORD +55 -0
- stateous-0.1.0.dist-info/WHEEL +4 -0
- stateous-0.1.0.dist-info/entry_points.txt +2 -0
- stateous-0.1.0.dist-info/licenses/LICENSE +202 -0
- stateous-0.1.0.dist-info/licenses/NOTICE +5 -0
stateous/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Stateous: a process as a graph of states.
|
|
2
|
+
|
|
3
|
+
A control plane keeps every token of it durable and decides each next state. A
|
|
4
|
+
supervisor, on any computer, does the work and reports what happened. Everything
|
|
5
|
+
a user imports comes from here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from stateous.client import Client
|
|
9
|
+
from stateous.model.context import Context
|
|
10
|
+
from stateous.model.contract import OutcomeError, StateRef, on_state
|
|
11
|
+
from stateous.model.events import Occurrence
|
|
12
|
+
from stateous.model.workflow import END, START, Workflow
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"END",
|
|
16
|
+
"START",
|
|
17
|
+
"Client",
|
|
18
|
+
"Context",
|
|
19
|
+
"Occurrence",
|
|
20
|
+
"OutcomeError",
|
|
21
|
+
"StateRef",
|
|
22
|
+
"Workflow",
|
|
23
|
+
"on_state",
|
|
24
|
+
]
|
stateous/__main__.py
ADDED
stateous/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The `stateous` command."""
|
stateous/cli/errors.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""How a command fails: one plain sentence on stderr, and exit code 1. Never a
|
|
2
|
+
traceback."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import functools
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import NoReturn, ParamSpec, TypeVar
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from stateous.client import ClientError
|
|
13
|
+
|
|
14
|
+
P = ParamSpec("P")
|
|
15
|
+
R = TypeVar("R")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommandError(Exception):
|
|
19
|
+
"""A command cannot go on. The message is one plain sentence."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def exit_with(message: str) -> NoReturn:
|
|
23
|
+
"""One plain sentence to stderr, then exit code 1. Never a traceback."""
|
|
24
|
+
typer.echo(f"error: {message}", err=True)
|
|
25
|
+
raise typer.Exit(1)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def reported(command: Callable[P, R]) -> Callable[P, R]:
|
|
29
|
+
"""Show an expected failure as `error: <sentence>` and exit 1."""
|
|
30
|
+
|
|
31
|
+
@functools.wraps(command)
|
|
32
|
+
def run(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
33
|
+
try:
|
|
34
|
+
return command(*args, **kwargs)
|
|
35
|
+
except (CommandError, ClientError) as error:
|
|
36
|
+
typer.echo(f"error: {error}", err=True)
|
|
37
|
+
raise typer.Exit(1) from None
|
|
38
|
+
|
|
39
|
+
return run
|
stateous/cli/main.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""The `stateous` command: compile, serve, init, supervise and token.
|
|
2
|
+
|
|
3
|
+
The commands import what they need when they run. `serve` and `supervise` import
|
|
4
|
+
the server, the database and the supervisor (`stateous.cli.running`). So `--help`,
|
|
5
|
+
`token ...`, `compile` and `init` start fast."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import errno
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Annotated, Any, TextIO, cast
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from stateous.cli.errors import exit_with
|
|
19
|
+
from stateous.cli.tokens import token_app
|
|
20
|
+
|
|
21
|
+
DEFAULT_DB = "sqlite:///stateous.db"
|
|
22
|
+
DEFAULT_LISTEN = "127.0.0.1:8080"
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
help="A process as a graph of states. A control plane keeps every token durable.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
add_completion=False,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.callback()
|
|
32
|
+
def _root() -> None:
|
|
33
|
+
"""A process as a graph of states. A control plane keeps every token durable."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("compile")
|
|
37
|
+
def compile_command(
|
|
38
|
+
flow: Annotated[Path, typer.Argument(help="The workflow's file, e.g. sdlc/flow.py.")],
|
|
39
|
+
out: Annotated[
|
|
40
|
+
Path | None,
|
|
41
|
+
typer.Option(
|
|
42
|
+
"--out", help="The graph file to write. Default: build/<workflow>.json next to FLOW."
|
|
43
|
+
),
|
|
44
|
+
] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Check a workflow, and write its graph file."""
|
|
47
|
+
from stateous.compiler.codegen import CodegenError, generate_contract
|
|
48
|
+
from stateous.compiler.compile import compile_workflow
|
|
49
|
+
from stateous.compiler.load import LoadError, load_workflow
|
|
50
|
+
from stateous.model.graph_file import dump_graph
|
|
51
|
+
from stateous.model.workflow import DefinitionError
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
workflow = load_workflow(flow)
|
|
55
|
+
document = compile_workflow(workflow)
|
|
56
|
+
generate_contract(document) # a name that cannot be Python is found now, not at `init`
|
|
57
|
+
except (LoadError, DefinitionError, CodegenError) as error:
|
|
58
|
+
typer.echo(f"error: {error}", err=True)
|
|
59
|
+
raise typer.Exit(1) from None
|
|
60
|
+
target = out if out is not None else flow.parent / "build" / f"{workflow.name}.json"
|
|
61
|
+
try:
|
|
62
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
target.write_text(dump_graph(document), encoding="utf-8", newline="\n")
|
|
64
|
+
except OSError as error:
|
|
65
|
+
typer.echo(f"error: cannot write {target}: {error.strerror}", err=True)
|
|
66
|
+
raise typer.Exit(1) from None
|
|
67
|
+
typer.echo(f"wrote {target}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def serve(
|
|
72
|
+
graph: Annotated[
|
|
73
|
+
Path,
|
|
74
|
+
typer.Argument(help="The graph file that `stateous compile` wrote.", show_default=False),
|
|
75
|
+
],
|
|
76
|
+
db: Annotated[str, typer.Option("--db", help="The database URL.")] = DEFAULT_DB,
|
|
77
|
+
listen: Annotated[
|
|
78
|
+
str, typer.Option("--listen", help="HOST:PORT to listen on.")
|
|
79
|
+
] = DEFAULT_LISTEN,
|
|
80
|
+
supervise_folder: Annotated[
|
|
81
|
+
Path | None,
|
|
82
|
+
typer.Option(
|
|
83
|
+
"--supervise",
|
|
84
|
+
help="Also run the supervisor of this folder, in this process (for development).",
|
|
85
|
+
),
|
|
86
|
+
] = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Run the control plane: the HTTP API and the clock, over one database."""
|
|
89
|
+
from stateous.cli import running
|
|
90
|
+
|
|
91
|
+
running.serve(graph, db, listen, supervise_folder)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def init(
|
|
96
|
+
url: Annotated[str, typer.Argument(help="The control plane, e.g. http://127.0.0.1:8080.")],
|
|
97
|
+
folder: Annotated[Path, typer.Argument(help="The supervisor's folder. Made if absent.")],
|
|
98
|
+
stateous_path: Annotated[
|
|
99
|
+
Path | None,
|
|
100
|
+
typer.Option(
|
|
101
|
+
"--stateous-path",
|
|
102
|
+
help="A local Stateous checkout for the folder to depend on, to work on Stateous "
|
|
103
|
+
"itself.",
|
|
104
|
+
),
|
|
105
|
+
] = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Write a supervisor's folder: contract.py, a stub for each state with a routine
|
|
108
|
+
(of each workflow), stateous.yaml, pyproject.toml. Run it again to update
|
|
109
|
+
contract.py."""
|
|
110
|
+
from stateous.cli.errors import CommandError
|
|
111
|
+
from stateous.cli.project_file import checkout, write_pyproject
|
|
112
|
+
from stateous.client.client import Client, ClientError
|
|
113
|
+
from stateous.compiler.codegen import generate_contract, generate_stub, routine_states
|
|
114
|
+
from stateous.supervisor.folder import write_folder
|
|
115
|
+
|
|
116
|
+
source: str | None = None
|
|
117
|
+
if stateous_path is not None:
|
|
118
|
+
try:
|
|
119
|
+
source = checkout(stateous_path)
|
|
120
|
+
except CommandError as error:
|
|
121
|
+
exit_with(str(error))
|
|
122
|
+
try:
|
|
123
|
+
with Client(url) as client:
|
|
124
|
+
document = client.workflow()
|
|
125
|
+
except ClientError as error:
|
|
126
|
+
exit_with(f"cannot fetch the workflow from {url}: {error}")
|
|
127
|
+
|
|
128
|
+
def stub(state: str) -> str:
|
|
129
|
+
return generate_stub(document, state)
|
|
130
|
+
|
|
131
|
+
states = routine_states(document)
|
|
132
|
+
try:
|
|
133
|
+
lines = write_folder(
|
|
134
|
+
folder, url=url, contract=generate_contract(document), states=states, stub=stub
|
|
135
|
+
)
|
|
136
|
+
lines.append(write_pyproject(folder, stateous_path=source))
|
|
137
|
+
except OSError as error:
|
|
138
|
+
exit_with(f"cannot write {folder}: {error.strerror or error}")
|
|
139
|
+
for line in lines:
|
|
140
|
+
typer.echo(line)
|
|
141
|
+
typer.echo(f"\nWrite the routines, then run: stateous supervise {folder}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command()
|
|
145
|
+
def supervise(
|
|
146
|
+
folder: Annotated[Path, typer.Argument(help="The supervisor's folder.")],
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Run the routines of a supervisor's folder. Ctrl-C once: take no new tasks and
|
|
149
|
+
wait for the running routines. Twice: cancel them and exit."""
|
|
150
|
+
from stateous.cli import running
|
|
151
|
+
|
|
152
|
+
running.supervise(folder)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
app.add_typer(token_app, name="token")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main() -> None:
|
|
159
|
+
"""The `stateous` command. Output to a closed pipe (`stateous token show | head -1`)
|
|
160
|
+
ends the command quietly, with exit code 1: no traceback. Any other error shows as
|
|
161
|
+
usual, also an EINVAL that did not come from a write to stdout."""
|
|
162
|
+
stdout = _Stdout(sys.stdout)
|
|
163
|
+
sys.stdout = cast("TextIO", stdout)
|
|
164
|
+
try:
|
|
165
|
+
try:
|
|
166
|
+
app(prog_name="stateous")
|
|
167
|
+
finally:
|
|
168
|
+
sys.stdout.flush()
|
|
169
|
+
except OSError as error:
|
|
170
|
+
if not _closed_pipe(error, stdout.failure):
|
|
171
|
+
raise
|
|
172
|
+
# Python flushes stdout again at exit: send it to nothing, so that flush cannot fail.
|
|
173
|
+
nothing = os.open(os.devnull, os.O_WRONLY)
|
|
174
|
+
os.dup2(nothing, sys.stdout.fileno())
|
|
175
|
+
sys.exit(1)
|
|
176
|
+
finally:
|
|
177
|
+
sys.stdout = stdout.stream
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _Stdout:
|
|
181
|
+
"""Stdout, which keeps the error of a write or a flush that failed. All else goes
|
|
182
|
+
to the stream."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, stream: TextIO) -> None:
|
|
185
|
+
self.stream = stream
|
|
186
|
+
self.failure: OSError | None = None
|
|
187
|
+
|
|
188
|
+
def write(self, text: str) -> int:
|
|
189
|
+
try:
|
|
190
|
+
return self.stream.write(text)
|
|
191
|
+
except OSError as error:
|
|
192
|
+
self.failure = error
|
|
193
|
+
raise
|
|
194
|
+
|
|
195
|
+
def writelines(self, lines: Iterable[str]) -> None:
|
|
196
|
+
try:
|
|
197
|
+
self.stream.writelines(lines)
|
|
198
|
+
except OSError as error:
|
|
199
|
+
self.failure = error
|
|
200
|
+
raise
|
|
201
|
+
|
|
202
|
+
def flush(self) -> None:
|
|
203
|
+
try:
|
|
204
|
+
self.stream.flush()
|
|
205
|
+
except OSError as error:
|
|
206
|
+
self.failure = error
|
|
207
|
+
raise
|
|
208
|
+
|
|
209
|
+
def __getattr__(self, name: str) -> Any:
|
|
210
|
+
return getattr(self.stream, name)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _closed_pipe(error: OSError, stdout_failure: OSError | None) -> bool:
|
|
214
|
+
"""True when `error` is a write to a pipe that the reader closed. On Windows, the
|
|
215
|
+
error is EINVAL, not EPIPE; but EINVAL also comes from other calls (for example
|
|
216
|
+
`open('a?b.py')`), so it counts only when a write or a flush of stdout raised it."""
|
|
217
|
+
if isinstance(error, BrokenPipeError) or error.errno == errno.EPIPE:
|
|
218
|
+
return True
|
|
219
|
+
return sys.platform == "win32" and error.errno == errno.EINVAL and error is stdout_failure
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The pyproject.toml that `stateous init` writes (see the guide, "Set up a supervisor").
|
|
2
|
+
|
|
3
|
+
The supervisor's folder is a Python project of its own. It depends on Stateous and
|
|
4
|
+
on what its routines import, so `uv sync` makes its environment (`.venv`), and
|
|
5
|
+
editors find `stateous` there. To work on Stateous itself, `--stateous-path`
|
|
6
|
+
points the dependency at a local checkout, as an editable `[tool.uv.sources]` entry.
|
|
7
|
+
|
|
8
|
+
The text is built in Python, as `stateous.compiler.codegen` builds `contract.py`."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import tomllib
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, cast
|
|
17
|
+
|
|
18
|
+
from stateous.cli.errors import CommandError
|
|
19
|
+
|
|
20
|
+
PACKAGE = "stateous"
|
|
21
|
+
PYPROJECT = "pyproject.toml"
|
|
22
|
+
DEFAULT_NAME = "supervisor"
|
|
23
|
+
"""The project's name when nothing of the folder's name can be one."""
|
|
24
|
+
|
|
25
|
+
_HEADER = """\
|
|
26
|
+
# Written by `stateous init`. This folder is a Python project of its own:
|
|
27
|
+
# `uv sync` makes its environment (.venv), and `uv run stateous ...` runs in it.
|
|
28
|
+
# Add what your routines import to `dependencies`.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_SOURCE_COMMENT = "# A local checkout of Stateous: delete this table to use Stateous from PyPI."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def checkout(path: Path) -> str:
|
|
35
|
+
"""`path` as a Stateous checkout: absolute, with forward slashes. Raises
|
|
36
|
+
`CommandError` when `path` is not a folder that holds a pyproject.toml with the
|
|
37
|
+
project name `stateous`."""
|
|
38
|
+
resolved = path.expanduser().resolve()
|
|
39
|
+
data: dict[str, Any]
|
|
40
|
+
try:
|
|
41
|
+
data = tomllib.loads((resolved / PYPROJECT).read_text(encoding="utf-8"))
|
|
42
|
+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
|
|
43
|
+
data = {}
|
|
44
|
+
project: Any = data.get("project")
|
|
45
|
+
name: object = None
|
|
46
|
+
if isinstance(project, dict):
|
|
47
|
+
table = cast("dict[str, object]", project)
|
|
48
|
+
name = table.get("name")
|
|
49
|
+
if name != PACKAGE:
|
|
50
|
+
raise CommandError(
|
|
51
|
+
f"--stateous-path {path} is not a Stateous checkout: give the folder that holds "
|
|
52
|
+
f"its {PYPROJECT}"
|
|
53
|
+
)
|
|
54
|
+
return resolved.as_posix()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_pyproject(folder: Path, *, stateous_path: str | None = None) -> str:
|
|
58
|
+
"""Write `folder/pyproject.toml`, only when it is absent. `stateous_path` is a
|
|
59
|
+
checkout, as `checkout` returns it. Returns one line: `wrote <path>` or
|
|
60
|
+
`kept <path>`."""
|
|
61
|
+
target = folder / PYPROJECT
|
|
62
|
+
if target.exists():
|
|
63
|
+
return f"kept {target}"
|
|
64
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
text = pyproject_text(project_name(folder.resolve().name), stateous_path)
|
|
66
|
+
target.write_text(text, encoding="utf-8", newline="\n")
|
|
67
|
+
return f"wrote {target}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def pyproject_text(name: str, stateous_path: str | None) -> str:
|
|
71
|
+
"""The text of the file: a `[project]`, and a `[tool.uv.sources]` table when a
|
|
72
|
+
checkout is given."""
|
|
73
|
+
lines = [
|
|
74
|
+
_HEADER,
|
|
75
|
+
"[project]",
|
|
76
|
+
f"name = {_toml_string(name)}",
|
|
77
|
+
'version = "0.1.0"',
|
|
78
|
+
'requires-python = ">=3.12"',
|
|
79
|
+
f"dependencies = [{_toml_string(requirement())}]",
|
|
80
|
+
]
|
|
81
|
+
if stateous_path is not None:
|
|
82
|
+
lines += [
|
|
83
|
+
"",
|
|
84
|
+
_SOURCE_COMMENT,
|
|
85
|
+
"[tool.uv.sources]",
|
|
86
|
+
f"{PACKAGE} = {{ path = {_toml_string(stateous_path)}, editable = true }}",
|
|
87
|
+
]
|
|
88
|
+
return "\n".join(lines) + "\n"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def requirement() -> str:
|
|
92
|
+
"""`stateous>=<the version that runs>`: `contract.py` is written by this version,
|
|
93
|
+
and an older Stateous may not read it. Only `stateous` when the version is not
|
|
94
|
+
known."""
|
|
95
|
+
try:
|
|
96
|
+
return f"{PACKAGE}>={version(PACKAGE)}"
|
|
97
|
+
except PackageNotFoundError:
|
|
98
|
+
return PACKAGE
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def project_name(folder_name: str) -> str:
|
|
102
|
+
"""`folder_name` as a valid project name: letters, digits, `.`, `_` and `-`, with
|
|
103
|
+
a letter or a digit at the start and at the end."""
|
|
104
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]", "-", folder_name).strip("._-")
|
|
105
|
+
return cleaned or DEFAULT_NAME
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _toml_string(text: str) -> str:
|
|
109
|
+
"""`text` as a TOML basic string."""
|
|
110
|
+
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
|
111
|
+
escaped = re.sub(r"[\x00-\x1f\x7f]", lambda found: f"\\u{ord(found.group()):04x}", escaped)
|
|
112
|
+
return f'"{escaped}"'
|
stateous/cli/running.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""The long commands: `stateous serve` and `stateous supervise`.
|
|
2
|
+
|
|
3
|
+
This module imports the server, the database and the supervisor. `stateous.cli.main`
|
|
4
|
+
imports it only in these two commands, so `--help`, `token ...`, `compile` and
|
|
5
|
+
`init` start fast."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
from collections.abc import Callable, Coroutine
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from types import FrameType
|
|
19
|
+
from typing import Any, NoReturn
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
import uvicorn
|
|
23
|
+
|
|
24
|
+
from stateous.cli.errors import exit_with
|
|
25
|
+
from stateous.cli.serving import (
|
|
26
|
+
GracefulServer,
|
|
27
|
+
ServeRefused,
|
|
28
|
+
Serving,
|
|
29
|
+
configure_logging,
|
|
30
|
+
prepare_serve,
|
|
31
|
+
)
|
|
32
|
+
from stateous.supervisor.folder import Folder, FolderError, load_folder
|
|
33
|
+
from stateous.supervisor.remote import ControlPlaneApi
|
|
34
|
+
from stateous.supervisor.supervisor import (
|
|
35
|
+
Supervisor,
|
|
36
|
+
SupervisorError,
|
|
37
|
+
open_supervisor,
|
|
38
|
+
stop_on_signals,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger("stateous.cli")
|
|
42
|
+
|
|
43
|
+
LEFTOVER_WAIT = 2.0
|
|
44
|
+
"""After the supervisor stopped: seconds to wait for the tasks still on the loop
|
|
45
|
+
(for example a routine that ignores its cancellation), and then for the threads of
|
|
46
|
+
the loop (`asyncio.to_thread`). Then the command exits."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def serve(graph: Path, db: str, listen: str, supervise_folder: Path | None) -> None:
|
|
50
|
+
"""The body of `stateous serve`."""
|
|
51
|
+
configure_logging()
|
|
52
|
+
folder: Folder | None = None
|
|
53
|
+
if supervise_folder is not None:
|
|
54
|
+
try:
|
|
55
|
+
folder = load_folder(supervise_folder)
|
|
56
|
+
except FolderError as error:
|
|
57
|
+
exit_with(str(error))
|
|
58
|
+
try:
|
|
59
|
+
serving = prepare_serve(graph, db, listen)
|
|
60
|
+
except ServeRefused as error:
|
|
61
|
+
exit_with(str(error))
|
|
62
|
+
typer.echo(f"serving on {serving.url} (Ctrl-C stops)", err=True)
|
|
63
|
+
code, clean = 0, True
|
|
64
|
+
try:
|
|
65
|
+
if folder is None:
|
|
66
|
+
server = GracefulServer(serving.config)
|
|
67
|
+
|
|
68
|
+
def stop() -> None:
|
|
69
|
+
server.should_exit = True
|
|
70
|
+
|
|
71
|
+
serving.on_lost(stop)
|
|
72
|
+
server.run(sockets=[serving.listener])
|
|
73
|
+
else:
|
|
74
|
+
log_to_stderr()
|
|
75
|
+
code, clean = serve_with_supervisor(serving, folder)
|
|
76
|
+
finally:
|
|
77
|
+
serving.close()
|
|
78
|
+
if serving.clock.lost:
|
|
79
|
+
typer.echo(
|
|
80
|
+
"error: another control plane took the database; this control plane stopped",
|
|
81
|
+
err=True,
|
|
82
|
+
)
|
|
83
|
+
code = 1
|
|
84
|
+
if code != 0 or not clean:
|
|
85
|
+
if folder is not None:
|
|
86
|
+
exit_now(code)
|
|
87
|
+
raise typer.Exit(code)
|
|
88
|
+
typer.echo("stopped", err=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def supervise(folder: Path) -> None:
|
|
92
|
+
"""The body of `stateous supervise`."""
|
|
93
|
+
log_to_stderr()
|
|
94
|
+
try:
|
|
95
|
+
loaded = load_folder(folder)
|
|
96
|
+
code, clean = run_bounded(supervise_folder(loaded))
|
|
97
|
+
except (FolderError, SupervisorError) as error:
|
|
98
|
+
exit_with(str(error))
|
|
99
|
+
except KeyboardInterrupt: # Ctrl-C before the handlers were in place
|
|
100
|
+
code, clean = 1, True
|
|
101
|
+
if code != 0 or not clean:
|
|
102
|
+
exit_now(code)
|
|
103
|
+
raise typer.Exit(code)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def log_to_stderr() -> None:
|
|
107
|
+
"""The supervisor's log lines on stderr, at INFO. `configure_logging` adds a
|
|
108
|
+
handler only when there is none, so a second call adds no second handler. The
|
|
109
|
+
HTTP libraries log only warnings."""
|
|
110
|
+
configure_logging()
|
|
111
|
+
for name in ("httpx2", "httpcore"):
|
|
112
|
+
logging.getLogger(name).setLevel(logging.WARNING)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_bounded(
|
|
116
|
+
main: Coroutine[Any, Any, int], *, leftover_wait: float = LEFTOVER_WAIT
|
|
117
|
+
) -> tuple[int, bool]:
|
|
118
|
+
"""Run `main` on a new event loop. Returns its exit code, and whether the end is
|
|
119
|
+
clean. As `asyncio.run`, but the end does not wait without limit: the tasks
|
|
120
|
+
left on the loop are cancelled, and the loop waits at most `leftover_wait`
|
|
121
|
+
seconds for them. So a routine that ignores its cancellation cannot stop the
|
|
122
|
+
command from exiting. Then it waits at most `leftover_wait` seconds for the
|
|
123
|
+
threads of the loop (`asyncio.to_thread`). When one still runs, the end is not
|
|
124
|
+
clean: a normal exit would wait for that thread, so only `exit_now` ends the
|
|
125
|
+
process."""
|
|
126
|
+
loop = asyncio.new_event_loop()
|
|
127
|
+
executor = ThreadPoolExecutor(thread_name_prefix="stateous-to-thread")
|
|
128
|
+
loop.set_default_executor(executor)
|
|
129
|
+
clean = False
|
|
130
|
+
try:
|
|
131
|
+
code = loop.run_until_complete(main)
|
|
132
|
+
finally:
|
|
133
|
+
try:
|
|
134
|
+
left = [task for task in asyncio.all_tasks(loop) if not task.done()]
|
|
135
|
+
for task in left:
|
|
136
|
+
task.cancel()
|
|
137
|
+
if left:
|
|
138
|
+
_, pending = loop.run_until_complete(asyncio.wait(left, timeout=leftover_wait))
|
|
139
|
+
if pending:
|
|
140
|
+
log.warning("%d task(s) did not stop; exiting without them", len(pending))
|
|
141
|
+
loop.set_exception_handler(_quiet_about(pending))
|
|
142
|
+
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
143
|
+
clean = _threads_ended(executor, leftover_wait)
|
|
144
|
+
finally:
|
|
145
|
+
loop.close()
|
|
146
|
+
return code, clean
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _threads_ended(executor: ThreadPoolExecutor, wait: float) -> bool:
|
|
150
|
+
"""Shut down `executor`. True when its threads end within `wait` seconds."""
|
|
151
|
+
ended = threading.Event()
|
|
152
|
+
|
|
153
|
+
def shut_down() -> None:
|
|
154
|
+
executor.shutdown(wait=True, cancel_futures=True)
|
|
155
|
+
ended.set()
|
|
156
|
+
|
|
157
|
+
threading.Thread(target=shut_down, name="stateous-shutdown", daemon=True).start()
|
|
158
|
+
if ended.wait(wait):
|
|
159
|
+
return True
|
|
160
|
+
log.warning("a thread of a routine did not stop; exiting without it")
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def exit_now(code: int) -> NoReturn:
|
|
165
|
+
"""End the process with `code` at once, after the log and the output are
|
|
166
|
+
flushed. A routine's thread that still runs (`asyncio.to_thread`) cannot hold
|
|
167
|
+
the exit then. Use it after a forced stop, when the loop is closed."""
|
|
168
|
+
logging.shutdown()
|
|
169
|
+
for stream in (sys.stdout, sys.stderr):
|
|
170
|
+
with contextlib.suppress(Exception):
|
|
171
|
+
stream.flush()
|
|
172
|
+
os._exit(code)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _quiet_about(abandoned: set[asyncio.Task[Any]]) -> Callable[..., None]:
|
|
176
|
+
"""An exception handler for the loop that says nothing more about the tasks that
|
|
177
|
+
were left behind (the line above names them already). Each other report goes to
|
|
178
|
+
the default handler."""
|
|
179
|
+
|
|
180
|
+
def handle(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
|
181
|
+
if context.get("task") in abandoned:
|
|
182
|
+
return
|
|
183
|
+
loop.default_exception_handler(context)
|
|
184
|
+
|
|
185
|
+
return handle
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def supervise_folder(folder: Folder) -> int:
|
|
189
|
+
"""Open and run the supervisor of `folder`. Returns its exit code."""
|
|
190
|
+
api = ControlPlaneApi(folder.config.url)
|
|
191
|
+
try:
|
|
192
|
+
supervisor = await open_supervisor(folder, api)
|
|
193
|
+
try:
|
|
194
|
+
with stop_on_signals(supervisor):
|
|
195
|
+
return await supervisor.run()
|
|
196
|
+
finally:
|
|
197
|
+
supervisor.close()
|
|
198
|
+
finally:
|
|
199
|
+
await api.aclose()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SupervisedServer(GracefulServer):
|
|
203
|
+
"""Uvicorn's server, but a stop signal goes to the supervisor. The server serves
|
|
204
|
+
on while the supervisor waits for its routines, so they can still report. It
|
|
205
|
+
stops when the supervisor's `run` ends. The signal is not recorded, so uvicorn
|
|
206
|
+
does not raise it again at the end."""
|
|
207
|
+
|
|
208
|
+
def __init__(self, config: uvicorn.Config) -> None:
|
|
209
|
+
super().__init__(config)
|
|
210
|
+
self.supervisor: Supervisor | None = None
|
|
211
|
+
|
|
212
|
+
def handle_exit(self, sig: int, frame: FrameType | None) -> None:
|
|
213
|
+
if self.supervisor is None: # not yet running, or `run` has returned
|
|
214
|
+
super().handle_exit(sig, frame) # GracefulServer: graceful first, then at once
|
|
215
|
+
else:
|
|
216
|
+
self.supervisor.on_signal()
|
|
217
|
+
|
|
218
|
+
def stop_all(self) -> None:
|
|
219
|
+
"""Stop the server, and stop the supervisor at once: its control plane does no
|
|
220
|
+
more work. It runs on the server's loop (`Serving.on_lost`): it does not block
|
|
221
|
+
and does not raise."""
|
|
222
|
+
self.should_exit = True
|
|
223
|
+
if self.supervisor is not None:
|
|
224
|
+
self.supervisor.stop_now()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def serve_with_supervisor(serving: Serving, folder: Folder) -> tuple[int, bool]:
|
|
228
|
+
"""Run `serve_and_supervise`. Returns the exit code, and whether the end is
|
|
229
|
+
clean (`run_bounded`)."""
|
|
230
|
+
try:
|
|
231
|
+
return run_bounded(serve_and_supervise(serving, folder))
|
|
232
|
+
except SupervisorError as error:
|
|
233
|
+
exit_with(str(error))
|
|
234
|
+
except KeyboardInterrupt: # a signal before the server took the signals
|
|
235
|
+
return 1, True
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def serve_and_supervise(serving: Serving, folder: Folder) -> int:
|
|
239
|
+
"""The control plane and the supervisor of `folder` in one loop. The supervisor
|
|
240
|
+
speaks HTTP to the server's own address. Returns the supervisor's exit code:
|
|
241
|
+
1 also when the server stopped before the supervisor ran (a lost hold, or a
|
|
242
|
+
signal at the start)."""
|
|
243
|
+
server = SupervisedServer(serving.config)
|
|
244
|
+
serving.on_lost(server.stop_all)
|
|
245
|
+
serving_task = asyncio.create_task(server.serve(sockets=[serving.listener]))
|
|
246
|
+
api = ControlPlaneApi(serving.url)
|
|
247
|
+
supervising = asyncio.create_task(supervise_beside(server, folder, api))
|
|
248
|
+
try:
|
|
249
|
+
await asyncio.wait({serving_task, supervising}, return_when=asyncio.FIRST_COMPLETED)
|
|
250
|
+
if not supervising.done(): # the server stopped first
|
|
251
|
+
if server.supervisor is None:
|
|
252
|
+
supervising.cancel()
|
|
253
|
+
else:
|
|
254
|
+
server.stop_all()
|
|
255
|
+
await asyncio.wait({supervising})
|
|
256
|
+
code = 1 if supervising.cancelled() else supervising.result()
|
|
257
|
+
if code != 0:
|
|
258
|
+
server.force_exit = True
|
|
259
|
+
return code
|
|
260
|
+
finally:
|
|
261
|
+
await api.aclose()
|
|
262
|
+
server.should_exit = True
|
|
263
|
+
await serving_task
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def supervise_beside(server: SupervisedServer, folder: Folder, api: ControlPlaneApi) -> int:
|
|
267
|
+
"""Wait until the server serves, then open and run the supervisor of `folder`."""
|
|
268
|
+
while not server.started:
|
|
269
|
+
await asyncio.sleep(0.05)
|
|
270
|
+
supervisor = await open_supervisor(folder, api)
|
|
271
|
+
server.supervisor = supervisor
|
|
272
|
+
try:
|
|
273
|
+
return await supervisor.run()
|
|
274
|
+
finally:
|
|
275
|
+
server.supervisor = None # later signals stop the server: a hung request can be forced
|
|
276
|
+
supervisor.close()
|