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.
Files changed (70) hide show
  1. istos/__init__.py +79 -0
  2. istos/app/__init__.py +44 -0
  3. istos/app/_base.py +278 -0
  4. istos/app/lifecycle.py +146 -0
  5. istos/app/messaging.py +553 -0
  6. istos/app/queues.py +438 -0
  7. istos/app/streaming.py +368 -0
  8. istos/app/web.py +482 -0
  9. istos/cli.py +134 -0
  10. istos/communication/__init__.py +13 -0
  11. istos/communication/config.py +201 -0
  12. istos/communication/durable.py +133 -0
  13. istos/communication/persist.py +348 -0
  14. istos/communication/sessions.py +381 -0
  15. istos/consistency/__init__.py +22 -0
  16. istos/consistency/config.py +94 -0
  17. istos/consistency/databases.py +100 -0
  18. istos/consistency/redis_storage.py +100 -0
  19. istos/consistency/sqlalchemy_storage.py +278 -0
  20. istos/consistency/storage.py +117 -0
  21. istos/context.py +116 -0
  22. istos/di/__init__.py +25 -0
  23. istos/di/context.py +44 -0
  24. istos/di/depends.py +247 -0
  25. istos/discovery/__init__.py +0 -0
  26. istos/discovery/asyncapi.py +295 -0
  27. istos/errors.py +157 -0
  28. istos/fitness.py +398 -0
  29. istos/http/__init__.py +0 -0
  30. istos/http/asgi.py +46 -0
  31. istos/http/gateway.py +162 -0
  32. istos/http/health.py +52 -0
  33. istos/http/mcp.py +99 -0
  34. istos/logging.py +181 -0
  35. istos/messages/__init__.py +21 -0
  36. istos/messages/serialization.py +148 -0
  37. istos/middleware/__init__.py +4 -0
  38. istos/middleware/base.py +133 -0
  39. istos/middleware/ratelimit.py +67 -0
  40. istos/observability/__init__.py +9 -0
  41. istos/observability/metrics.py +72 -0
  42. istos/observability/tracing.py +83 -0
  43. istos/primitives/__init__.py +0 -0
  44. istos/primitives/channel.py +237 -0
  45. istos/primitives/channel_fabric.py +242 -0
  46. istos/primitives/clients.py +99 -0
  47. istos/primitives/handler.py +293 -0
  48. istos/primitives/liveliness.py +63 -0
  49. istos/primitives/publish.py +146 -0
  50. istos/primitives/query.py +154 -0
  51. istos/primitives/session_store.py +29 -0
  52. istos/primitives/stream.py +178 -0
  53. istos/primitives/subscribe.py +289 -0
  54. istos/py.typed +0 -0
  55. istos/queue/__init__.py +11 -0
  56. istos/queue/cron.py +95 -0
  57. istos/queue/role.py +340 -0
  58. istos/queue/store.py +361 -0
  59. istos/queue/worker.py +187 -0
  60. istos/retry.py +62 -0
  61. istos/routing.py +162 -0
  62. istos/security/__init__.py +0 -0
  63. istos/security/authz.py +325 -0
  64. istos/testing/__init__.py +3 -0
  65. istos/testing/testclient.py +233 -0
  66. istos/validation.py +128 -0
  67. istos-0.1.0.dist-info/METADATA +555 -0
  68. istos-0.1.0.dist-info/RECORD +70 -0
  69. istos-0.1.0.dist-info/WHEEL +4 -0
  70. 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())