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.
@@ -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
@@ -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
@@ -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()