istos 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.
- istos/__init__.py +79 -0
- istos/app/__init__.py +44 -0
- istos/app/_base.py +278 -0
- istos/app/lifecycle.py +146 -0
- istos/app/messaging.py +553 -0
- istos/app/queues.py +438 -0
- istos/app/streaming.py +368 -0
- istos/app/web.py +482 -0
- istos/cli.py +134 -0
- istos/communication/__init__.py +13 -0
- istos/communication/config.py +201 -0
- istos/communication/durable.py +133 -0
- istos/communication/persist.py +348 -0
- istos/communication/sessions.py +381 -0
- istos/consistency/__init__.py +22 -0
- istos/consistency/config.py +94 -0
- istos/consistency/databases.py +100 -0
- istos/consistency/redis_storage.py +100 -0
- istos/consistency/sqlalchemy_storage.py +278 -0
- istos/consistency/storage.py +117 -0
- istos/context.py +116 -0
- istos/di/__init__.py +25 -0
- istos/di/context.py +44 -0
- istos/di/depends.py +247 -0
- istos/discovery/__init__.py +0 -0
- istos/discovery/asyncapi.py +295 -0
- istos/errors.py +157 -0
- istos/fitness.py +398 -0
- istos/http/__init__.py +0 -0
- istos/http/asgi.py +46 -0
- istos/http/gateway.py +162 -0
- istos/http/health.py +52 -0
- istos/http/mcp.py +99 -0
- istos/logging.py +181 -0
- istos/messages/__init__.py +21 -0
- istos/messages/serialization.py +148 -0
- istos/middleware/__init__.py +4 -0
- istos/middleware/base.py +133 -0
- istos/middleware/ratelimit.py +67 -0
- istos/observability/__init__.py +9 -0
- istos/observability/metrics.py +72 -0
- istos/observability/tracing.py +83 -0
- istos/primitives/__init__.py +0 -0
- istos/primitives/channel.py +237 -0
- istos/primitives/channel_fabric.py +242 -0
- istos/primitives/clients.py +99 -0
- istos/primitives/handler.py +293 -0
- istos/primitives/liveliness.py +63 -0
- istos/primitives/publish.py +146 -0
- istos/primitives/query.py +154 -0
- istos/primitives/session_store.py +29 -0
- istos/primitives/stream.py +178 -0
- istos/primitives/subscribe.py +289 -0
- istos/py.typed +0 -0
- istos/queue/__init__.py +11 -0
- istos/queue/cron.py +95 -0
- istos/queue/role.py +340 -0
- istos/queue/store.py +361 -0
- istos/queue/worker.py +187 -0
- istos/retry.py +62 -0
- istos/routing.py +162 -0
- istos/security/__init__.py +0 -0
- istos/security/authz.py +325 -0
- istos/testing/__init__.py +3 -0
- istos/testing/testclient.py +233 -0
- istos/validation.py +128 -0
- istos-0.1.0.dist-info/METADATA +555 -0
- istos-0.1.0.dist-info/RECORD +70 -0
- istos-0.1.0.dist-info/WHEEL +4 -0
- istos-0.1.0.dist-info/entry_points.txt +3 -0
istos/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from .app import Istos
|
|
2
|
+
from .routing import IstosRouter
|
|
3
|
+
from .errors import (
|
|
4
|
+
IstosError,
|
|
5
|
+
IstosSecurityWarning,
|
|
6
|
+
IstosSecurityError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
UnauthorizedError,
|
|
9
|
+
ForbiddenError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ErrorResponse,
|
|
12
|
+
exception_handler,
|
|
13
|
+
)
|
|
14
|
+
from .security.authz import (
|
|
15
|
+
Authorizer,
|
|
16
|
+
AuthContext,
|
|
17
|
+
Principal,
|
|
18
|
+
TokenAuthorizer,
|
|
19
|
+
JWTAuthorizer,
|
|
20
|
+
require_roles,
|
|
21
|
+
Public,
|
|
22
|
+
)
|
|
23
|
+
from .communication.persist import ObjectStore, InMemoryObjectStore, S3ObjectStore, PersistRole, ReplayEvent
|
|
24
|
+
from .primitives.channel import ChannelSession, ChannelClosed
|
|
25
|
+
from .primitives.channel_fabric import ChannelClient
|
|
26
|
+
from .primitives.session_store import SessionStore
|
|
27
|
+
from .queue import QueueStore, QueueRole, JobState, JobRecord
|
|
28
|
+
from .http.mcp import MCPServer
|
|
29
|
+
from .di import Depends, DependencyCycleError, current_principal, current_request, current_token
|
|
30
|
+
from .logging import configure_logging, get_logger
|
|
31
|
+
from .testing import IstosTestClient
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Istos",
|
|
35
|
+
"IstosRouter",
|
|
36
|
+
"IstosTestClient",
|
|
37
|
+
"IstosError",
|
|
38
|
+
"IstosSecurityWarning",
|
|
39
|
+
"IstosSecurityError",
|
|
40
|
+
"NotFoundError",
|
|
41
|
+
"UnauthorizedError",
|
|
42
|
+
"ForbiddenError",
|
|
43
|
+
"RateLimitError",
|
|
44
|
+
"ErrorResponse",
|
|
45
|
+
"exception_handler",
|
|
46
|
+
"Authorizer",
|
|
47
|
+
"AuthContext",
|
|
48
|
+
"Principal",
|
|
49
|
+
"TokenAuthorizer",
|
|
50
|
+
"JWTAuthorizer",
|
|
51
|
+
"require_roles",
|
|
52
|
+
"Public",
|
|
53
|
+
"ObjectStore",
|
|
54
|
+
"InMemoryObjectStore",
|
|
55
|
+
"S3ObjectStore",
|
|
56
|
+
"PersistRole",
|
|
57
|
+
"ReplayEvent",
|
|
58
|
+
"ChannelSession",
|
|
59
|
+
"ChannelClosed",
|
|
60
|
+
"ChannelClient",
|
|
61
|
+
"SessionStore",
|
|
62
|
+
"QueueStore",
|
|
63
|
+
"QueueRole",
|
|
64
|
+
"JobState",
|
|
65
|
+
"JobRecord",
|
|
66
|
+
"MCPServer",
|
|
67
|
+
"Depends",
|
|
68
|
+
"DependencyCycleError",
|
|
69
|
+
"current_principal",
|
|
70
|
+
"current_request",
|
|
71
|
+
"current_token",
|
|
72
|
+
"configure_logging",
|
|
73
|
+
"get_logger",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main() -> None:
|
|
78
|
+
from istos.cli import main as cli_main
|
|
79
|
+
cli_main()
|
istos/app/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The Istos application object, composed from domain mixins.
|
|
2
|
+
|
|
3
|
+
The class is assembled here; its behaviour lives in the mixins (messaging,
|
|
4
|
+
streaming, queues, web, lifecycle) over the shared IstosBase state."""
|
|
5
|
+
|
|
6
|
+
from istos.app._base import IstosBase
|
|
7
|
+
from istos.app.messaging import _MessagingMixin
|
|
8
|
+
from istos.app.streaming import _StreamingMixin
|
|
9
|
+
from istos.app.queues import _QueueMixin
|
|
10
|
+
from istos.app.web import _WebMixin
|
|
11
|
+
from istos.app.lifecycle import _LifecycleMixin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Istos(
|
|
15
|
+
_MessagingMixin,
|
|
16
|
+
_StreamingMixin,
|
|
17
|
+
_QueueMixin,
|
|
18
|
+
_WebMixin,
|
|
19
|
+
_LifecycleMixin,
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Unified entry-point for the Istos framework.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
istos = Istos()
|
|
26
|
+
|
|
27
|
+
# Or wire the network from a config; the session is built for you:
|
|
28
|
+
istos = Istos(config=IstosZenohConfig(mode="client"))
|
|
29
|
+
|
|
30
|
+
@istos.handle(prefix="robot/move")
|
|
31
|
+
async def move(distance: int):
|
|
32
|
+
return f"moved {distance}m"
|
|
33
|
+
|
|
34
|
+
class Drone:
|
|
35
|
+
@istos.handle(prefix="drone/fly")
|
|
36
|
+
def fly(self, altitude: int):
|
|
37
|
+
return f"flying at {altitude}m"
|
|
38
|
+
|
|
39
|
+
istos.run() # sync entry
|
|
40
|
+
await istos.run_async() # async entry
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ = ["Istos", "IstosBase"]
|
istos/app/_base.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Shared state for the Istos mixins: the constructor and small accessors.
|
|
2
|
+
|
|
3
|
+
Every mixin inherits this, so ``__init__`` here is the single source of truth
|
|
4
|
+
for instance state and mypy resolves ``self._x`` for the mixin methods."""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import zenoh
|
|
8
|
+
from contextlib import AsyncExitStack
|
|
9
|
+
from typing import Any, AsyncIterator, Callable, List, Mapping, Optional, Type, Union, AsyncContextManager, cast
|
|
10
|
+
|
|
11
|
+
from istos.communication.sessions import SessionManager, AsyncZenohSession, ZenohSession
|
|
12
|
+
from istos.communication.config import IstosZenohConfig
|
|
13
|
+
from istos.communication.persist import PersistRole
|
|
14
|
+
from istos.consistency.storage import StoragePlugin, InMemoryStoragePlugin
|
|
15
|
+
from istos.consistency.sqlalchemy_storage import SqlAlchemyStoragePlugin
|
|
16
|
+
from istos.consistency.config import DatabaseConfig
|
|
17
|
+
from istos.consistency.databases import DatabaseRegistry
|
|
18
|
+
from istos.messages.serialization import JsonSerializer
|
|
19
|
+
from istos.primitives.handler import handler_wrapper
|
|
20
|
+
from istos.primitives.query import query_wrapper
|
|
21
|
+
from istos.primitives.subscribe import subscribe_wrapper
|
|
22
|
+
from istos.primitives.publish import publish_wrapper
|
|
23
|
+
from istos.primitives.stream import stream_wrapper
|
|
24
|
+
from istos.primitives.channel import channel_wrapper
|
|
25
|
+
from istos.primitives.channel_fabric import FabricChannelServer
|
|
26
|
+
from istos.queue import QueueRole, worker_wrapper
|
|
27
|
+
from istos.primitives.liveliness import liveliness_wrapper
|
|
28
|
+
from istos.errors import (
|
|
29
|
+
ExceptionHandler,
|
|
30
|
+
ExceptionHandlerRegistry,
|
|
31
|
+
IstosSecurityError,
|
|
32
|
+
get_default_registry,
|
|
33
|
+
)
|
|
34
|
+
from istos.security.authz import Authorizer
|
|
35
|
+
from istos.http.gateway import HttpRoute
|
|
36
|
+
from istos.routing import IstosRouter
|
|
37
|
+
from istos.logging import configure_logging as _configure_logging, get_logger
|
|
38
|
+
from istos.middleware.base import (
|
|
39
|
+
CorrelationIdMiddleware,
|
|
40
|
+
LoggingMiddleware,
|
|
41
|
+
Middleware,
|
|
42
|
+
MiddlewareStack,
|
|
43
|
+
)
|
|
44
|
+
from istos.http.health import HealthState
|
|
45
|
+
from istos.observability.metrics import MetricsCollector, PrometheusMiddleware
|
|
46
|
+
from istos.observability.tracing import TracingMiddleware, configure_tracing
|
|
47
|
+
from typing import TYPE_CHECKING
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from istos.app import Istos
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class IstosBase:
|
|
54
|
+
"""State and construction shared by every Istos mixin."""
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
# Implemented by sibling mixins on the composed Istos class; declared
|
|
58
|
+
# here so mypy resolves the cross-mixin calls from lifecycle and web.
|
|
59
|
+
handle: Callable[..., Any]
|
|
60
|
+
query_once: Callable[..., Any]
|
|
61
|
+
stream_query: Callable[..., Any]
|
|
62
|
+
export_capabilities: Callable[..., Any]
|
|
63
|
+
_register_builtin_handlers: Callable[..., Any]
|
|
64
|
+
_http_server_port: Callable[..., Any]
|
|
65
|
+
_start_http_server: Callable[..., Any]
|
|
66
|
+
_bind_handlers: Callable[..., Any]
|
|
67
|
+
_unbind_handlers: Callable[..., Any]
|
|
68
|
+
_bind_streams: Callable[..., Any]
|
|
69
|
+
_bind_channels: Callable[..., Any]
|
|
70
|
+
_unbind_channels: Callable[..., Any]
|
|
71
|
+
_bind_subscribers: Callable[..., Any]
|
|
72
|
+
_unbind_subscribers: Callable[..., Any]
|
|
73
|
+
_bind_publishers: Callable[..., Any]
|
|
74
|
+
_unbind_publishers: Callable[..., Any]
|
|
75
|
+
_bind_persist: Callable[..., Any]
|
|
76
|
+
_unbind_persist: Callable[..., Any]
|
|
77
|
+
_bind_queues: Callable[..., Any]
|
|
78
|
+
_unbind_queues: Callable[..., Any]
|
|
79
|
+
_bind_liveliness: Callable[..., Any]
|
|
80
|
+
_unbind_liveliness: Callable[..., Any]
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
session_manager: Optional[SessionManager] = None,
|
|
85
|
+
storage: Optional[StoragePlugin] = None,
|
|
86
|
+
lifespan: Optional[Callable[["Istos"], AsyncContextManager[None]]] = None,
|
|
87
|
+
*,
|
|
88
|
+
storage_config: Optional[DatabaseConfig] = None,
|
|
89
|
+
databases: Optional[Mapping[str, DatabaseConfig]] = None,
|
|
90
|
+
storage_database: Optional[str] = None,
|
|
91
|
+
config: Optional[Union[IstosZenohConfig, zenoh.Config]] = None,
|
|
92
|
+
log_level: str = "INFO",
|
|
93
|
+
json_logs: bool = False,
|
|
94
|
+
enable_health: bool = True,
|
|
95
|
+
enable_metrics: bool = True,
|
|
96
|
+
enable_discovery: bool = True,
|
|
97
|
+
enable_tracing: bool = False,
|
|
98
|
+
tracing_endpoint: Optional[str] = None,
|
|
99
|
+
service_name: str = "istos",
|
|
100
|
+
exception_registry: Optional[ExceptionHandlerRegistry] = None,
|
|
101
|
+
authorizer: Optional[Authorizer] = None,
|
|
102
|
+
require_auth: bool = False,
|
|
103
|
+
configure_logging: Optional[bool] = None,
|
|
104
|
+
http_port: Optional[int] = None,
|
|
105
|
+
enable_mcp: bool = False,
|
|
106
|
+
mcp_path: str = "/mcp",
|
|
107
|
+
):
|
|
108
|
+
# configure_logging=True installs now; None defers to run(); False = never.
|
|
109
|
+
self._log_level = log_level
|
|
110
|
+
self._json_logs = json_logs
|
|
111
|
+
self._configure_logging = configure_logging
|
|
112
|
+
if configure_logging:
|
|
113
|
+
_configure_logging(level=log_level, json_format=json_logs)
|
|
114
|
+
self._logger = get_logger("app")
|
|
115
|
+
|
|
116
|
+
# Build session manager from config= when callers don't pass one.
|
|
117
|
+
self._config: Optional[IstosZenohConfig] = None
|
|
118
|
+
if config is not None:
|
|
119
|
+
if session_manager is not None:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"Pass either 'config' or 'session_manager', not both."
|
|
122
|
+
)
|
|
123
|
+
if isinstance(config, IstosZenohConfig):
|
|
124
|
+
self._config = config
|
|
125
|
+
zenoh_conf = config.build()
|
|
126
|
+
session_cls = AsyncZenohSession if config.session == "async" else ZenohSession
|
|
127
|
+
else:
|
|
128
|
+
# Raw zenoh.Config has no session= hint → async.
|
|
129
|
+
zenoh_conf = config
|
|
130
|
+
session_cls = AsyncZenohSession
|
|
131
|
+
session_manager = session_cls(zenoh_conf)
|
|
132
|
+
|
|
133
|
+
self._session_manager = session_manager or AsyncZenohSession()
|
|
134
|
+
self._databases = DatabaseRegistry(databases or {})
|
|
135
|
+
|
|
136
|
+
# Exactly one of storage / storage_config / storage_database.
|
|
137
|
+
ledger_sources = [
|
|
138
|
+
s for s in (storage, storage_config, storage_database) if s is not None
|
|
139
|
+
]
|
|
140
|
+
if len(ledger_sources) > 1:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
"Specify at most one of `storage` (a ready plugin), `storage_config` "
|
|
143
|
+
"(a single DatabaseConfig), or `storage_database` (a name from "
|
|
144
|
+
"`databases`) — they all define the durability ledger."
|
|
145
|
+
)
|
|
146
|
+
if storage_database is not None:
|
|
147
|
+
if storage_database not in self._databases:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"storage_database={storage_database!r} is not in `databases` "
|
|
150
|
+
f"({self._databases.names()})."
|
|
151
|
+
)
|
|
152
|
+
# Registry owns disposal of the named engine.
|
|
153
|
+
self._storage: StoragePlugin = SqlAlchemyStoragePlugin(
|
|
154
|
+
self._databases.engine(storage_database)
|
|
155
|
+
)
|
|
156
|
+
elif storage_config is not None:
|
|
157
|
+
self._storage = SqlAlchemyStoragePlugin.from_config(storage_config)
|
|
158
|
+
else:
|
|
159
|
+
self._storage = storage or InMemoryStoragePlugin()
|
|
160
|
+
self._serializer = JsonSerializer()
|
|
161
|
+
self.lifespan = lifespan
|
|
162
|
+
self._service_name = service_name
|
|
163
|
+
self._authorizer = authorizer
|
|
164
|
+
# require_auth without an authorizer is a hard error, not a warning.
|
|
165
|
+
if require_auth and authorizer is None:
|
|
166
|
+
raise IstosSecurityError(
|
|
167
|
+
"Istos(require_auth=True) requires an app-wide authorizer, but none "
|
|
168
|
+
"was set. Pass Istos(authorizer=...) (e.g. JWTAuthorizer/TokenAuthorizer), "
|
|
169
|
+
"or use Public per-handler to opt specific endpoints out."
|
|
170
|
+
)
|
|
171
|
+
self.dependency_overrides: dict = {}
|
|
172
|
+
self._exception_registry = exception_registry or get_default_registry()
|
|
173
|
+
self._middleware_stack = MiddlewareStack([
|
|
174
|
+
CorrelationIdMiddleware(),
|
|
175
|
+
LoggingMiddleware(self._logger),
|
|
176
|
+
])
|
|
177
|
+
self._metrics = MetricsCollector()
|
|
178
|
+
if enable_metrics:
|
|
179
|
+
self._middleware_stack.add(PrometheusMiddleware(self._metrics))
|
|
180
|
+
if enable_tracing:
|
|
181
|
+
if configure_tracing(service_name=service_name, endpoint=tracing_endpoint):
|
|
182
|
+
self._middleware_stack.add(TracingMiddleware())
|
|
183
|
+
self._health = HealthState()
|
|
184
|
+
self._enable_health = enable_health
|
|
185
|
+
self._enable_metrics = enable_metrics
|
|
186
|
+
self._enable_discovery = enable_discovery
|
|
187
|
+
self._handlers: List[handler_wrapper] = []
|
|
188
|
+
self._queries: List[query_wrapper] = []
|
|
189
|
+
self._subscribers: List[subscribe_wrapper] = []
|
|
190
|
+
self._publishers: List[publish_wrapper] = []
|
|
191
|
+
self._streams: List[stream_wrapper] = []
|
|
192
|
+
self._liveliness_subs: List[liveliness_wrapper] = []
|
|
193
|
+
self._liveliness_declares: List[str] = []
|
|
194
|
+
self._persist_roles: List["PersistRole"] = []
|
|
195
|
+
self._http_routes: List[HttpRoute] = []
|
|
196
|
+
self._zenoh_subscribers: List[zenoh.Subscriber] = []
|
|
197
|
+
self._zenoh_queryables: List[zenoh.Queryable] = []
|
|
198
|
+
self._zenoh_liveliness_subs: List[Any] = []
|
|
199
|
+
self._zenoh_liveliness_tokens: List[Any] = []
|
|
200
|
+
self._shm_provider: Optional[Any] = None
|
|
201
|
+
self._docs_web_port: Optional[int] = None
|
|
202
|
+
self._http_port: Optional[int] = http_port
|
|
203
|
+
self._docs_prefix: Optional[str] = None
|
|
204
|
+
self._shutdown_event: Optional[asyncio.Event] = None
|
|
205
|
+
self._builtin_handlers_registered = False
|
|
206
|
+
self._lifecycle_stack: Optional[AsyncExitStack] = None
|
|
207
|
+
self._web_runner: Any = None
|
|
208
|
+
self._channels: List[channel_wrapper] = []
|
|
209
|
+
self._ws_channel_routes: List[tuple] = []
|
|
210
|
+
self._channel_servers: List[FabricChannelServer] = []
|
|
211
|
+
self._queue_roles: List[QueueRole] = []
|
|
212
|
+
self._workers: List[worker_wrapper] = []
|
|
213
|
+
self._schedules: List[dict] = []
|
|
214
|
+
self._schedule_tasks: List[asyncio.Task] = []
|
|
215
|
+
self._enable_mcp = enable_mcp
|
|
216
|
+
self._mcp_path = mcp_path
|
|
217
|
+
|
|
218
|
+
def _get_or_init_shm(self) -> Any:
|
|
219
|
+
if self._shm_provider is None:
|
|
220
|
+
self._shm_provider = zenoh.shm.ShmProvider.default_backend(10 * 1024 * 1024)
|
|
221
|
+
return self._shm_provider
|
|
222
|
+
|
|
223
|
+
def add_middleware(self, middleware: Middleware) -> None:
|
|
224
|
+
"""Add middleware to the request pipeline."""
|
|
225
|
+
self._middleware_stack.add(middleware)
|
|
226
|
+
|
|
227
|
+
def exception_handler(self, exc_type: Type[Exception]) -> Callable:
|
|
228
|
+
"""Register a custom exception handler for a given exception type."""
|
|
229
|
+
|
|
230
|
+
def decorator(handler: ExceptionHandler) -> ExceptionHandler:
|
|
231
|
+
self._exception_registry.register(exc_type, handler)
|
|
232
|
+
return handler
|
|
233
|
+
|
|
234
|
+
return decorator
|
|
235
|
+
|
|
236
|
+
def add_health_check(self, name: str, check: Callable[[], Any]) -> None:
|
|
237
|
+
"""Register a custom readiness check."""
|
|
238
|
+
self._health.checks[name] = check
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def metrics(self) -> MetricsCollector:
|
|
242
|
+
"""Access the in-process metrics collector."""
|
|
243
|
+
return self._metrics
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def config(self) -> Optional[IstosZenohConfig]:
|
|
247
|
+
"""The IstosZenohConfig this app was built from, if any."""
|
|
248
|
+
return self._config
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def databases(self) -> DatabaseRegistry:
|
|
252
|
+
"""The registry of named application databases (from `databases=`)."""
|
|
253
|
+
return self._databases
|
|
254
|
+
|
|
255
|
+
def db_session(self, name: str) -> Callable[[], AsyncIterator[Any]]:
|
|
256
|
+
"""
|
|
257
|
+
A ``Depends`` provider that yields one ``AsyncSession`` per request from the
|
|
258
|
+
named database configured in `databases=`. The engine (pool) is shared and
|
|
259
|
+
app-lifetime; the session is per request:
|
|
260
|
+
|
|
261
|
+
@app.handle("orders/create")
|
|
262
|
+
async def create(item: str, db = Depends(app.db_session("app"))):
|
|
263
|
+
db.add(Order(item=item))
|
|
264
|
+
|
|
265
|
+
Overridable in tests: `app.dependency_overrides[app.db_session("app")] = ...`
|
|
266
|
+
(the provider is cached per name, so the key is stable).
|
|
267
|
+
"""
|
|
268
|
+
return self._databases.session_dependency(name)
|
|
269
|
+
|
|
270
|
+
def include_router(self, router: IstosRouter) -> None:
|
|
271
|
+
"""
|
|
272
|
+
Includes a router's routes into the main application.
|
|
273
|
+
"""
|
|
274
|
+
app = cast("Istos", self)
|
|
275
|
+
for action in router._actions:
|
|
276
|
+
action(app)
|
|
277
|
+
|
|
278
|
+
|
istos/app/lifecycle.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Startup, shutdown and process entry points (serving/run/run_async) that orchestrate the domain binds."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import signal
|
|
6
|
+
from contextlib import AsyncExitStack
|
|
7
|
+
from typing import Any, AsyncIterator, cast
|
|
8
|
+
|
|
9
|
+
from istos.logging import ensure_configured
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from istos.app import Istos
|
|
15
|
+
|
|
16
|
+
from istos.app._base import IstosBase
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _LifecycleMixin(IstosBase):
|
|
20
|
+
"""Startup, shutdown and process entry points (serving/run/run_async) that orchestrate the domain binds."""
|
|
21
|
+
|
|
22
|
+
def _install_signal_handlers(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
23
|
+
if self._shutdown_event is None:
|
|
24
|
+
self._shutdown_event = asyncio.Event()
|
|
25
|
+
|
|
26
|
+
def _request_shutdown() -> None:
|
|
27
|
+
self._logger.info("Shutdown signal received")
|
|
28
|
+
if self._shutdown_event is not None:
|
|
29
|
+
self._shutdown_event.set()
|
|
30
|
+
|
|
31
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
32
|
+
try:
|
|
33
|
+
loop.add_signal_handler(sig, _request_shutdown)
|
|
34
|
+
except (NotImplementedError, RuntimeError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
async def _startup(self, *, serve_http: bool) -> Any:
|
|
38
|
+
"""Open the session and bind every registry. Shared by run_async and the
|
|
39
|
+
serving() context manager. No signal handlers, no blocking."""
|
|
40
|
+
if self._configure_logging is not False:
|
|
41
|
+
ensure_configured(self._log_level, self._json_logs)
|
|
42
|
+
self._register_builtin_handlers()
|
|
43
|
+
|
|
44
|
+
stack = AsyncExitStack()
|
|
45
|
+
self._lifecycle_stack = stack
|
|
46
|
+
if self.lifespan:
|
|
47
|
+
await stack.enter_async_context(self.lifespan(cast("Istos", self)))
|
|
48
|
+
|
|
49
|
+
# close() is optional (Redis/SQLAlchemy); InMemory has none.
|
|
50
|
+
storage_close = getattr(self._storage, "close", None)
|
|
51
|
+
if callable(storage_close):
|
|
52
|
+
stack.push_async_callback(storage_close)
|
|
53
|
+
stack.push_async_callback(self._databases.dispose_all)
|
|
54
|
+
|
|
55
|
+
# session="sync" managers use __enter__, not __aenter__.
|
|
56
|
+
if hasattr(self._session_manager, "__aenter__"):
|
|
57
|
+
session = await stack.enter_async_context(self._session_manager) # type: ignore
|
|
58
|
+
else:
|
|
59
|
+
session = stack.enter_context(self._session_manager) # type: ignore
|
|
60
|
+
|
|
61
|
+
await self._bind_handlers(session)
|
|
62
|
+
await self._bind_streams(session)
|
|
63
|
+
await self._bind_channels(session)
|
|
64
|
+
await self._bind_persist(session)
|
|
65
|
+
await self._bind_queues(session)
|
|
66
|
+
await self._bind_publishers(session)
|
|
67
|
+
await self._bind_subscribers(session)
|
|
68
|
+
await self._bind_liveliness(session)
|
|
69
|
+
|
|
70
|
+
self._health.ready = True
|
|
71
|
+
prefixes = [a.prefix for a in self._handlers]
|
|
72
|
+
subs = [s.prefix for s in self._subscribers]
|
|
73
|
+
self._logger.info(
|
|
74
|
+
"Service started with %d handler(s) and %d subscriber(s)",
|
|
75
|
+
len(prefixes), len(subs),
|
|
76
|
+
extra={"handlers": prefixes, "subscribers": subs},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
self._web_runner = None
|
|
80
|
+
if serve_http and self._http_server_port() is not None:
|
|
81
|
+
self._web_runner = await self._start_http_server()
|
|
82
|
+
return session
|
|
83
|
+
|
|
84
|
+
async def _shutdown(self) -> None:
|
|
85
|
+
"""Reverse of _startup: unbind, then close session/storage/databases."""
|
|
86
|
+
self._health.ready = False
|
|
87
|
+
self._logger.info("Service stopping")
|
|
88
|
+
if self._web_runner is not None:
|
|
89
|
+
await self._web_runner.cleanup()
|
|
90
|
+
self._web_runner = None
|
|
91
|
+
await self._unbind_liveliness()
|
|
92
|
+
await self._unbind_subscribers()
|
|
93
|
+
await self._unbind_publishers()
|
|
94
|
+
await self._unbind_queues()
|
|
95
|
+
await self._unbind_persist()
|
|
96
|
+
await self._unbind_channels()
|
|
97
|
+
await self._unbind_handlers()
|
|
98
|
+
if self._lifecycle_stack is not None:
|
|
99
|
+
await self._lifecycle_stack.aclose()
|
|
100
|
+
self._lifecycle_stack = None
|
|
101
|
+
self._logger.info("Service stopped")
|
|
102
|
+
|
|
103
|
+
@contextlib.asynccontextmanager
|
|
104
|
+
async def serving(self, *, serve_http: bool = False) -> "AsyncIterator[Istos]":
|
|
105
|
+
"""Run the mesh for as long as the block is entered, without owning the
|
|
106
|
+
process (no signal handlers, no blocking loop). This is the co-host hook:
|
|
107
|
+
an ASGI host or a test drives the lifecycle.
|
|
108
|
+
|
|
109
|
+
async with app.serving():
|
|
110
|
+
reply = await app.query_once("robot/move", distance=5)
|
|
111
|
+
|
|
112
|
+
Pass ``serve_http=True`` to also start the embedded aiohttp surface;
|
|
113
|
+
under FastAPI/Starlette leave it off and let the ASGI host serve HTTP."""
|
|
114
|
+
await self._startup(serve_http=serve_http)
|
|
115
|
+
try:
|
|
116
|
+
yield cast("Istos", self)
|
|
117
|
+
finally:
|
|
118
|
+
await self._shutdown()
|
|
119
|
+
|
|
120
|
+
async def run_async(self) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Async entry-point.
|
|
123
|
+
Opens a Zenoh session, binds registries, and keeps the loop alive.
|
|
124
|
+
"""
|
|
125
|
+
loop = asyncio.get_running_loop()
|
|
126
|
+
self._install_signal_handlers(loop)
|
|
127
|
+
async with self.serving(serve_http=True):
|
|
128
|
+
try:
|
|
129
|
+
if self._shutdown_event is not None:
|
|
130
|
+
await self._shutdown_event.wait()
|
|
131
|
+
else:
|
|
132
|
+
while True:
|
|
133
|
+
await asyncio.sleep(1)
|
|
134
|
+
except asyncio.CancelledError:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
def run(self) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Sync entry-point.
|
|
140
|
+
Detects whether an event loop is already running and adapts.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
loop = asyncio.get_running_loop()
|
|
144
|
+
loop.create_task(self.run_async())
|
|
145
|
+
except RuntimeError:
|
|
146
|
+
asyncio.run(self.run_async())
|