durable-actors 0.5.5__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.
- durable_actors/__init__.py +25 -0
- durable_actors/actor.py +145 -0
- durable_actors/build.py +237 -0
- durable_actors/client.py +512 -0
- durable_actors/codegen.py +781 -0
- durable_actors/connection.py +158 -0
- durable_actors/contract.py +390 -0
- durable_actors/executor_wire.py +41 -0
- durable_actors/fields.py +61 -0
- durable_actors/generated.py +44 -0
- durable_actors/guards.py +20 -0
- durable_actors/host.py +202 -0
- durable_actors/json.py +14 -0
- durable_actors/proxy.py +45 -0
- durable_actors/py.typed +0 -0
- durable_actors/reference.py +89 -0
- durable_actors/runtime.py +282 -0
- durable_actors/sandbox.py +87 -0
- durable_actors/session.py +203 -0
- durable_actors/socket.py +287 -0
- durable_actors/subscription.py +142 -0
- durable_actors/supervisor.py +233 -0
- durable_actors-0.5.5.dist-info/METADATA +111 -0
- durable_actors-0.5.5.dist-info/RECORD +26 -0
- durable_actors-0.5.5.dist-info/WHEEL +4 -0
- durable_actors-0.5.5.dist-info/licenses/LICENSE.md +9 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Define durable Python actors and use synchronous typed clients and subscriptions."""
|
|
2
|
+
|
|
3
|
+
from .actor import Actor as Actor
|
|
4
|
+
from .actor import reentrant as reentrant
|
|
5
|
+
from .client import ActorInvocationError as ActorInvocationError
|
|
6
|
+
from .client import ActorProtocolError as ActorProtocolError
|
|
7
|
+
from .client import ActorTransport as ActorTransport
|
|
8
|
+
from .client import Client as Client
|
|
9
|
+
from .client import SocketGrant as SocketGrant
|
|
10
|
+
from .connection import StateSnapshot as StateSnapshot
|
|
11
|
+
from .connection import StateUpdate as StateUpdate
|
|
12
|
+
from .fields import emitted as emitted
|
|
13
|
+
from .fields import ephemeral as ephemeral
|
|
14
|
+
from .generated import UNSET as UNSET
|
|
15
|
+
from .generated import Unset as Unset
|
|
16
|
+
from .json import JsonValue as JsonValue
|
|
17
|
+
from .proxy import SocketAuthorization as SocketAuthorization
|
|
18
|
+
from .sandbox import SandboxOptions as SandboxOptions
|
|
19
|
+
from .sandbox import SandboxRegion as SandboxRegion
|
|
20
|
+
from .sandbox import sandbox as sandbox
|
|
21
|
+
from .session import ActorSession as ActorSession
|
|
22
|
+
from .session import ActorSessionRejectedError as ActorSessionRejectedError
|
|
23
|
+
from .session import ActorSessionTransport as ActorSessionTransport
|
|
24
|
+
from .socket import ActorSocket as ActorSocket
|
|
25
|
+
from .subscription import Subscription as Subscription
|
durable_actors/actor.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Define durable actors and control invocation concurrency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Generic, Literal, Self, TypeVar
|
|
8
|
+
|
|
9
|
+
from typing_extensions import TypeVar as DefaultTypeVar
|
|
10
|
+
|
|
11
|
+
from .json import JsonValue
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .client import ActorTransport
|
|
15
|
+
from .connection import Connection
|
|
16
|
+
from .socket import ActorSocket
|
|
17
|
+
|
|
18
|
+
Metadata = DefaultTypeVar("Metadata", default=JsonValue)
|
|
19
|
+
Incoming = DefaultTypeVar("Incoming", default=JsonValue)
|
|
20
|
+
Outgoing = DefaultTypeVar("Outgoing", default=Incoming)
|
|
21
|
+
Tag = DefaultTypeVar("Tag", bound=str, default=str)
|
|
22
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Actor(Generic[Metadata, Incoming, Outgoing, Tag]):
|
|
26
|
+
"""Base class for durable actors with typed RPCs and WebSocket hooks.
|
|
27
|
+
|
|
28
|
+
Public def methods become synchronous RPCs. Annotated fields persist by
|
|
29
|
+
default; emitted() also broadcasts their saved changes, and ephemeral() excludes
|
|
30
|
+
temporary values. Use field defaults or factories instead of a constructor.
|
|
31
|
+
|
|
32
|
+
Generic parameters describe connection metadata, incoming application
|
|
33
|
+
messages, outgoing application messages, and allowed connection tags. Outgoing
|
|
34
|
+
defaults to Incoming, tags to str, and other parameters to JsonValue.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
"""Initialize independent field defaults; the runtime restores persisted values afterward."""
|
|
39
|
+
from .contract import describe_actor
|
|
40
|
+
|
|
41
|
+
for field in describe_actor(type(self)).fields.values():
|
|
42
|
+
value = (
|
|
43
|
+
field.default_factory()
|
|
44
|
+
if field.default_factory is not None
|
|
45
|
+
else deepcopy(field.default)
|
|
46
|
+
)
|
|
47
|
+
setattr(self, field.name, value)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def get(cls, actor_id: str, transport: ActorTransport | None = None) -> Self:
|
|
51
|
+
"""Return a typed synchronous reference without activating the actor locally.
|
|
52
|
+
|
|
53
|
+
RPC signatures match the source class. Fields and lifecycle hooks belong
|
|
54
|
+
to the running actor; read state through RPCs or generated subscriptions.
|
|
55
|
+
"""
|
|
56
|
+
from .reference import actor_reference
|
|
57
|
+
|
|
58
|
+
return actor_reference(cls, actor_id, transport)
|
|
59
|
+
|
|
60
|
+
def connect(self, metadata: Metadata) -> Connection[Incoming, Outgoing, JsonValue, JsonValue]:
|
|
61
|
+
"""Open a typed WebSocket through a source-class reference from get()."""
|
|
62
|
+
raise RuntimeError("connect() requires an actor reference from get()")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def id(self) -> str:
|
|
66
|
+
"""Identity of this actor, available during an active invocation or socket hook."""
|
|
67
|
+
from .socket import current_scope
|
|
68
|
+
|
|
69
|
+
return current_scope(self).actor_id
|
|
70
|
+
|
|
71
|
+
def on_connect(self, socket: ActorSocket[Metadata, Outgoing, Tag]) -> None:
|
|
72
|
+
"""Handle a new WebSocket connection before it is accepted.
|
|
73
|
+
|
|
74
|
+
Override with def to inspect metadata, set tags, send a welcome
|
|
75
|
+
message, or reject with socket.reject(). The default accepts the connection.
|
|
76
|
+
"""
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
def on_message(self, socket: ActorSocket[Metadata, Outgoing, Tag], message: Incoming) -> None:
|
|
80
|
+
"""Handle a validated incoming application message from a connected client.
|
|
81
|
+
|
|
82
|
+
Override with def to update actor state or send typed replies
|
|
83
|
+
through socket. The default ignores application messages.
|
|
84
|
+
"""
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def on_disconnect(
|
|
88
|
+
self, socket: ActorSocket[Metadata, Outgoing, Tag], code: int, reason: str, was_clean: bool
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Handle a closed connection; override with def for cleanup.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
socket: Connection with its last known metadata and tags.
|
|
94
|
+
code: WebSocket close status code.
|
|
95
|
+
reason: WebSocket close reason.
|
|
96
|
+
was_clean: Whether the connection completed a clean closing handshake.
|
|
97
|
+
"""
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def get_connections(self) -> list[ActorSocket[Metadata, Outgoing, Tag]]:
|
|
101
|
+
"""Return connected sockets from a synchronous actor method or hook.
|
|
102
|
+
|
|
103
|
+
Use the returned handles only during the current invocation.
|
|
104
|
+
"""
|
|
105
|
+
from .socket import current_scope
|
|
106
|
+
|
|
107
|
+
scope = current_scope(self)
|
|
108
|
+
return scope.blocking(scope.get_connections)
|
|
109
|
+
|
|
110
|
+
def broadcast(
|
|
111
|
+
self,
|
|
112
|
+
message: Outgoing,
|
|
113
|
+
*,
|
|
114
|
+
except_ids: tuple[str, ...] = (),
|
|
115
|
+
tags: tuple[Tag, ...] = (),
|
|
116
|
+
tag_match: Literal["all", "any"] = "all",
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Queue a typed application message for selected connections.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
message: Value matching the actor's outgoing message type.
|
|
122
|
+
except_ids: Connection IDs to exclude.
|
|
123
|
+
tags: Restrict delivery to matching tags; empty selects all connections.
|
|
124
|
+
tag_match: Use "all" to require every tag or "any" to require one.
|
|
125
|
+
|
|
126
|
+
Must be called during an active actor invocation or socket hook.
|
|
127
|
+
"""
|
|
128
|
+
from .socket import current_scope
|
|
129
|
+
|
|
130
|
+
current_scope(self).broadcast(message, except_ids, tags, tag_match)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def reentrant(method: F) -> F:
|
|
134
|
+
"""Allow other invocations to enter before this method finishes.
|
|
135
|
+
|
|
136
|
+
Works with def RPCs and socket hooks. Handlers overlap on worker threads.
|
|
137
|
+
Ordinary invocations still serialize with each other and block new entries.
|
|
138
|
+
Nested method calls inherit the outer invocation's admission policy.
|
|
139
|
+
|
|
140
|
+
Reentrant actors share a live instance. Coordinate shared mutable state
|
|
141
|
+
across overlapping handlers. Failed invocations do not roll back state
|
|
142
|
+
anywhere in the actor class, because that could erase another call's work.
|
|
143
|
+
"""
|
|
144
|
+
setattr(method, "__actor_reentrant__", True)
|
|
145
|
+
return method
|
durable_actors/build.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tomllib
|
|
9
|
+
import zipfile
|
|
10
|
+
from contextlib import redirect_stdout
|
|
11
|
+
from importlib.metadata import distribution, distributions, version
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from tempfile import TemporaryDirectory
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from packaging.requirements import Requirement
|
|
17
|
+
from packaging.utils import canonicalize_name
|
|
18
|
+
|
|
19
|
+
from .actor import Actor
|
|
20
|
+
from .contract import Document, public_contract
|
|
21
|
+
from .guards import is_actor
|
|
22
|
+
|
|
23
|
+
EXCLUDED_DIRECTORIES = {"node_modules", "venv", "__pycache__", "dist", "target", "generated"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_actor(project: Path, entrypoint: str, output: Path, *, local: bool = True) -> Document:
|
|
27
|
+
project, output = project.resolve(), output.resolve()
|
|
28
|
+
source = (project / entrypoint).resolve()
|
|
29
|
+
if not source.is_relative_to(project) or source.suffix != ".py" or not source.is_file():
|
|
30
|
+
raise ValueError("entrypoint must be a Python file inside the project")
|
|
31
|
+
if source.is_relative_to(output):
|
|
32
|
+
raise ValueError("output must not contain the actor source")
|
|
33
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
settings = project_settings(project)
|
|
35
|
+
if not local:
|
|
36
|
+
install_dependencies(project, output, settings)
|
|
37
|
+
module = ".".join(source.relative_to(project).with_suffix("").parts)
|
|
38
|
+
write_artifact(project, output, module, settings)
|
|
39
|
+
with redirect_stdout(sys.stderr):
|
|
40
|
+
contract = public_contract(load_artifact(output / "actors.pyz"))
|
|
41
|
+
if len(json.dumps(contract).encode()) > 4 * 1024 * 1024:
|
|
42
|
+
raise ValueError("public actor contract exceeds 4 MiB")
|
|
43
|
+
return contract
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def project_settings(project: Path) -> Document:
|
|
47
|
+
manifest = project / "pyproject.toml"
|
|
48
|
+
return tomllib.loads(manifest.read_text()) if manifest.is_file() else {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def install_dependencies(project: Path, output: Path, settings: Document) -> None:
|
|
52
|
+
dependencies = settings.get("project", {}).get("dependencies", [])
|
|
53
|
+
requirements = project / "requirements.txt"
|
|
54
|
+
if (
|
|
55
|
+
not requirements.is_file()
|
|
56
|
+
and settings.get("project", {}).get("dynamic")
|
|
57
|
+
and "dependencies" in settings["project"]["dynamic"]
|
|
58
|
+
):
|
|
59
|
+
raise ValueError("declare deployment dependencies explicitly or provide requirements.txt")
|
|
60
|
+
arguments = (
|
|
61
|
+
["-r", str(requirements)] if requirements.is_file() else runtime_requirements(dependencies)
|
|
62
|
+
)
|
|
63
|
+
versions = runtime_versions()
|
|
64
|
+
if arguments:
|
|
65
|
+
install_requirements(project, output / "python", arguments, versions)
|
|
66
|
+
remove_bundled_runtime(output / "python", versions)
|
|
67
|
+
sys.path.insert(0, str(output / "python"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def runtime_versions() -> dict[str, str]:
|
|
71
|
+
versions: dict[str, str] = {}
|
|
72
|
+
visited: set[tuple[str, tuple[str, ...]]] = set()
|
|
73
|
+
pending = [Requirement("durable-actors")]
|
|
74
|
+
while pending:
|
|
75
|
+
requirement = pending.pop()
|
|
76
|
+
name = canonicalize_name(requirement.name)
|
|
77
|
+
extras = tuple(sorted(requirement.extras))
|
|
78
|
+
if (name, extras) in visited:
|
|
79
|
+
continue
|
|
80
|
+
visited.add((name, extras))
|
|
81
|
+
installed = distribution(name)
|
|
82
|
+
versions[name] = installed.version
|
|
83
|
+
for text in installed.requires or []:
|
|
84
|
+
dependency = Requirement(text)
|
|
85
|
+
if dependency.marker is None or any(
|
|
86
|
+
dependency.marker.evaluate({"extra": extra}) for extra in ("", *extras)
|
|
87
|
+
):
|
|
88
|
+
pending.append(dependency)
|
|
89
|
+
return versions
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def install_requirements(
|
|
93
|
+
project: Path, directory: Path, arguments: list[str], versions: dict[str, str]
|
|
94
|
+
) -> None:
|
|
95
|
+
with TemporaryDirectory(prefix="durable-actors-constraints-") as temporary:
|
|
96
|
+
constraints = Path(temporary) / "runtime.txt"
|
|
97
|
+
constraints.write_text("".join(f"{name}=={value}\n" for name, value in versions.items()))
|
|
98
|
+
subprocess.run(
|
|
99
|
+
[
|
|
100
|
+
sys.executable,
|
|
101
|
+
"-m",
|
|
102
|
+
"pip",
|
|
103
|
+
"install",
|
|
104
|
+
"--disable-pip-version-check",
|
|
105
|
+
"--no-compile",
|
|
106
|
+
"--target",
|
|
107
|
+
str(directory),
|
|
108
|
+
"--constraint",
|
|
109
|
+
str(constraints),
|
|
110
|
+
*arguments,
|
|
111
|
+
],
|
|
112
|
+
cwd=project,
|
|
113
|
+
check=True,
|
|
114
|
+
stdout=sys.stderr,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def remove_bundled_runtime(directory: Path, versions: dict[str, str]) -> None:
|
|
119
|
+
for installed in distributions(path=[str(directory)]):
|
|
120
|
+
name = canonicalize_name(installed.metadata["Name"])
|
|
121
|
+
if name not in versions:
|
|
122
|
+
continue
|
|
123
|
+
if installed.version != versions[name]:
|
|
124
|
+
raise ValueError(f"{name} version must match the build runtime")
|
|
125
|
+
if installed.files is None:
|
|
126
|
+
raise ValueError(f"installed {name} has no file manifest")
|
|
127
|
+
for file in installed.files:
|
|
128
|
+
remove_bundled_file(directory, directory / file)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def remove_bundled_file(directory: Path, file: Path) -> None:
|
|
132
|
+
file = file.resolve()
|
|
133
|
+
if not file.is_relative_to(directory) or not file.is_file():
|
|
134
|
+
return
|
|
135
|
+
file.unlink()
|
|
136
|
+
for parent in file.parents:
|
|
137
|
+
if parent == directory:
|
|
138
|
+
break
|
|
139
|
+
try:
|
|
140
|
+
parent.rmdir()
|
|
141
|
+
except OSError:
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def runtime_requirements(dependencies: list[str]) -> list[str]:
|
|
146
|
+
result: list[str] = []
|
|
147
|
+
for dependency in dependencies:
|
|
148
|
+
requirement = Requirement(dependency)
|
|
149
|
+
if canonicalize_name(requirement.name) == "durable-actors":
|
|
150
|
+
if version("durable-actors") not in requirement.specifier:
|
|
151
|
+
raise ValueError("actor SDK version must match the build runtime")
|
|
152
|
+
else:
|
|
153
|
+
result.append(dependency)
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def write_artifact(project: Path, output: Path, module: str, settings: Document) -> None:
|
|
158
|
+
paths = source_paths(project, output)
|
|
159
|
+
for pattern in settings.get("tool", {}).get("durable-actors", {}).get("include", []):
|
|
160
|
+
for path in project.glob(pattern):
|
|
161
|
+
if path.is_file():
|
|
162
|
+
if (
|
|
163
|
+
path.is_symlink()
|
|
164
|
+
or not path.resolve().is_relative_to(project)
|
|
165
|
+
or path.is_relative_to(output)
|
|
166
|
+
):
|
|
167
|
+
raise ValueError("included resources must stay inside the project")
|
|
168
|
+
paths.add(path)
|
|
169
|
+
if sum(path.stat().st_size for path in paths) > 32 * 1024 * 1024:
|
|
170
|
+
raise ValueError("actor source artifact exceeds 32 MiB")
|
|
171
|
+
artifact = output / "actors.pyz"
|
|
172
|
+
with zipfile.ZipFile(artifact, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
173
|
+
archive.writestr("durable-actors.json", json.dumps({"version": 1, "module": module}))
|
|
174
|
+
directories: set[str] = set()
|
|
175
|
+
for path in sorted(paths):
|
|
176
|
+
relative = path.relative_to(project)
|
|
177
|
+
if str(relative) == "durable-actors.json":
|
|
178
|
+
raise ValueError("durable-actors.json is reserved for the artifact manifest")
|
|
179
|
+
archive.write(path, relative.as_posix())
|
|
180
|
+
directories.update(
|
|
181
|
+
parent.as_posix() + "/" for parent in relative.parents if parent != Path(".")
|
|
182
|
+
)
|
|
183
|
+
for directory in sorted(directories):
|
|
184
|
+
archive.writestr(directory, "")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def source_paths(project: Path, output: Path) -> set[Path]:
|
|
188
|
+
paths: set[Path] = set()
|
|
189
|
+
for directory, directories, files in os.walk(project):
|
|
190
|
+
if "pyvenv.cfg" in files:
|
|
191
|
+
directories.clear()
|
|
192
|
+
continue
|
|
193
|
+
directories[:] = sorted(
|
|
194
|
+
name
|
|
195
|
+
for name in directories
|
|
196
|
+
if not name.startswith(".")
|
|
197
|
+
and name not in EXCLUDED_DIRECTORIES
|
|
198
|
+
and not (Path(directory) / name).is_symlink()
|
|
199
|
+
and (Path(directory) / name).resolve() != output
|
|
200
|
+
)
|
|
201
|
+
paths.update(
|
|
202
|
+
Path(directory) / name
|
|
203
|
+
for name in files
|
|
204
|
+
if name.endswith(".py") and not (Path(directory) / name).is_symlink()
|
|
205
|
+
)
|
|
206
|
+
return paths
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def load_artifact(path: Path) -> list[type[Actor[Any, Any, Any, Any]]]:
|
|
210
|
+
with zipfile.ZipFile(path) as archive:
|
|
211
|
+
manifest = json.loads(archive.read("durable-actors.json"))
|
|
212
|
+
if manifest.get("version") != 1:
|
|
213
|
+
raise ValueError("unsupported Python actor artifact")
|
|
214
|
+
sys.path.insert(0, str(path.parent / "python"))
|
|
215
|
+
return load_module(path, manifest["module"])
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def load_module(root: Path, module: str) -> list[type[Actor[Any, Any, Any, Any]]]:
|
|
219
|
+
sys.path[:0] = [str(root), str(root / "src")]
|
|
220
|
+
importlib.invalidate_caches()
|
|
221
|
+
loaded = importlib.import_module(module)
|
|
222
|
+
exported = getattr(loaded, "__all__", vars(loaded))
|
|
223
|
+
return [
|
|
224
|
+
value
|
|
225
|
+
for name, value in vars(loaded).items()
|
|
226
|
+
if name in exported and not name.startswith("_") and is_actor(value)
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main() -> None:
|
|
231
|
+
project, entrypoint, output, *mode = sys.argv[1:]
|
|
232
|
+
contract = build_actor(Path(project), entrypoint, Path(output), local=mode == ["local"])
|
|
233
|
+
print(json.dumps(contract, separators=(",", ":")))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
main()
|