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.
Files changed (55) hide show
  1. stateous/__init__.py +24 -0
  2. stateous/__main__.py +6 -0
  3. stateous/cli/__init__.py +1 -0
  4. stateous/cli/errors.py +39 -0
  5. stateous/cli/main.py +219 -0
  6. stateous/cli/project_file.py +112 -0
  7. stateous/cli/running.py +276 -0
  8. stateous/cli/serving.py +400 -0
  9. stateous/cli/tokens.py +255 -0
  10. stateous/client/__init__.py +5 -0
  11. stateous/client/client.py +225 -0
  12. stateous/compiler/__init__.py +4 -0
  13. stateous/compiler/codegen.py +426 -0
  14. stateous/compiler/compile.py +145 -0
  15. stateous/compiler/exclusive.py +383 -0
  16. stateous/compiler/load.py +98 -0
  17. stateous/compiler/plot.py +88 -0
  18. stateous/compiler/validate.py +326 -0
  19. stateous/control/__init__.py +1 -0
  20. stateous/control/api.py +564 -0
  21. stateous/control/engine.py +1358 -0
  22. stateous/control/graph.py +149 -0
  23. stateous/control/store/__init__.py +7 -0
  24. stateous/control/store/errors.py +10 -0
  25. stateous/control/store/migrations/__init__.py +160 -0
  26. stateous/control/store/migrations/env.py +44 -0
  27. stateous/control/store/migrations/script.py.mako +28 -0
  28. stateous/control/store/migrations/versions/0001_baseline.py +105 -0
  29. stateous/control/store/migrations/versions/0002_claims.py +34 -0
  30. stateous/control/store/migrations/versions/0003_children.py +40 -0
  31. stateous/control/store/store.py +765 -0
  32. stateous/control/store/tables.py +149 -0
  33. stateous/model/__init__.py +3 -0
  34. stateous/model/context.py +170 -0
  35. stateous/model/contract.py +163 -0
  36. stateous/model/durations.py +36 -0
  37. stateous/model/events.py +44 -0
  38. stateous/model/graph_file.py +147 -0
  39. stateous/model/guards.py +299 -0
  40. stateous/model/host.py +50 -0
  41. stateous/model/wire.py +209 -0
  42. stateous/model/workflow.py +417 -0
  43. stateous/py.typed +0 -0
  44. stateous/supervisor/__init__.py +1 -0
  45. stateous/supervisor/folder.py +277 -0
  46. stateous/supervisor/outbox.py +171 -0
  47. stateous/supervisor/registry.py +81 -0
  48. stateous/supervisor/remote.py +238 -0
  49. stateous/supervisor/supervisor.py +797 -0
  50. stateous-0.1.0.dist-info/METADATA +87 -0
  51. stateous-0.1.0.dist-info/RECORD +55 -0
  52. stateous-0.1.0.dist-info/WHEEL +4 -0
  53. stateous-0.1.0.dist-info/entry_points.txt +2 -0
  54. stateous-0.1.0.dist-info/licenses/LICENSE +202 -0
  55. 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
@@ -0,0 +1,6 @@
1
+ """`python -m stateous`: the same as the `stateous` command."""
2
+
3
+ from stateous.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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}"'
@@ -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()