asyncio-for-robotics 1.4.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 (39) hide show
  1. asyncio_for_robotics/__init__.py +15 -0
  2. asyncio_for_robotics/core/__init__.py +15 -0
  3. asyncio_for_robotics/core/_compat.py +13 -0
  4. asyncio_for_robotics/core/_logger.py +125 -0
  5. asyncio_for_robotics/core/scope.py +379 -0
  6. asyncio_for_robotics/core/sub.py +382 -0
  7. asyncio_for_robotics/core/utils.py +161 -0
  8. asyncio_for_robotics/example/__init__.py +0 -0
  9. asyncio_for_robotics/example/custom_stdout.py +44 -0
  10. asyncio_for_robotics/example/delete_me.py +32 -0
  11. asyncio_for_robotics/example/python_discussion.py +116 -0
  12. asyncio_for_robotics/example/ros2_discussion.py +113 -0
  13. asyncio_for_robotics/example/ros2_double_listener.py +78 -0
  14. asyncio_for_robotics/example/ros2_double_talker.py +108 -0
  15. asyncio_for_robotics/example/ros2_event_callback.py +153 -0
  16. asyncio_for_robotics/example/ros2_listener.py +24 -0
  17. asyncio_for_robotics/example/ros2_pubsub.py +58 -0
  18. asyncio_for_robotics/example/ros2_service_client.py +33 -0
  19. asyncio_for_robotics/example/ros2_service_server.py +29 -0
  20. asyncio_for_robotics/example/ros2_talker.py +35 -0
  21. asyncio_for_robotics/example/zenoh_discussion.py +68 -0
  22. asyncio_for_robotics/py.typed +1 -0
  23. asyncio_for_robotics/ros2/__init__.py +46 -0
  24. asyncio_for_robotics/ros2/future.py +47 -0
  25. asyncio_for_robotics/ros2/service.py +317 -0
  26. asyncio_for_robotics/ros2/session.py +106 -0
  27. asyncio_for_robotics/ros2/session_types.py +281 -0
  28. asyncio_for_robotics/ros2/sub.py +107 -0
  29. asyncio_for_robotics/ros2/utils.py +45 -0
  30. asyncio_for_robotics/textio/__init__.py +12 -0
  31. asyncio_for_robotics/textio/sub.py +117 -0
  32. asyncio_for_robotics/zenoh/__init__.py +33 -0
  33. asyncio_for_robotics/zenoh/session.py +113 -0
  34. asyncio_for_robotics/zenoh/sub.py +75 -0
  35. asyncio_for_robotics-1.4.0.dist-info/METADATA +283 -0
  36. asyncio_for_robotics-1.4.0.dist-info/RECORD +39 -0
  37. asyncio_for_robotics-1.4.0.dist-info/WHEEL +5 -0
  38. asyncio_for_robotics-1.4.0.dist-info/licenses/LICENSE +21 -0
  39. asyncio_for_robotics-1.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,15 @@
1
+ from .core.scope import AUTO_SCOPE, Scope, ScopeBreak, scoped
2
+ from .core.utils import soft_timeout, soft_wait_for, Rate
3
+ from .core.sub import BaseSub, ConverterSub
4
+
5
+ __all__ = [
6
+ "AUTO_SCOPE",
7
+ "Scope",
8
+ "ScopeBreak",
9
+ "scoped",
10
+ "soft_wait_for",
11
+ "soft_timeout",
12
+ "Rate",
13
+ "BaseSub",
14
+ "ConverterSub",
15
+ ]
@@ -0,0 +1,15 @@
1
+ from .scope import AUTO_SCOPE, Scope, ScopeBreak, scoped
2
+ from .utils import soft_timeout, soft_wait_for, Rate
3
+ from .sub import BaseSub, ConverterSub
4
+
5
+ __all__ = [
6
+ "AUTO_SCOPE",
7
+ "Scope",
8
+ "ScopeBreak",
9
+ "scoped",
10
+ "BaseSub",
11
+ "ConverterSub",
12
+ "soft_wait_for",
13
+ "soft_timeout",
14
+ "Rate",
15
+ ]
@@ -0,0 +1,13 @@
1
+ try:
2
+ from asyncio import TaskGroup
3
+ except ImportError:
4
+ try:
5
+ from taskgroup import TaskGroup
6
+ except ImportError:
7
+ TaskGroup = NotImplemented
8
+
9
+ try:
10
+ BaseExceptionGroup = BaseExceptionGroup
11
+ except NameError:
12
+ # BaseExceptionGroup = NotImplemented
13
+ from exceptiongroup import BaseExceptionGroup
@@ -0,0 +1,125 @@
1
+ import json
2
+ import copy
3
+ import logging.config
4
+ import os
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
+ import colorama
9
+ from colorama import Fore, Style, init
10
+
11
+
12
+ class JsonLineFormatter(logging.Formatter):
13
+ """Outputs log records as JSON lines."""
14
+
15
+ def format(self, record):
16
+ log_record = {
17
+ "time": datetime.fromtimestamp(record.created).isoformat(),
18
+ "level": record.levelname,
19
+ "logger": record.name,
20
+ "message": record.getMessage(),
21
+ "module": record.module,
22
+ "funcName": record.funcName,
23
+ "line": record.lineno,
24
+ }
25
+ if record.exc_info:
26
+ log_record["exc_info"] = self.formatException(record.exc_info)
27
+ return json.dumps(log_record)
28
+
29
+
30
+ LEVEL_COLORS = {
31
+ "DEBUG": Fore.BLUE,
32
+ "INFO": Fore.CYAN,
33
+ "WARNING": Fore.YELLOW,
34
+ "ERROR": Fore.RED,
35
+ "CRITICAL": Fore.BLACK + Style.BRIGHT + colorama.Back.RED,
36
+ }
37
+
38
+
39
+ class OnlyLevelFilter(logging.Filter):
40
+ """Only allow a single level through this handler."""
41
+ def __init__(self, level):
42
+ self.level = level
43
+ def filter(self, record):
44
+ return record.levelno == self.level
45
+
46
+ class ColoredFormatter(logging.Formatter):
47
+ """Adds colors to levelname in logs."""
48
+
49
+ def format(self, record: logging.LogRecord):
50
+ record = copy.deepcopy(record)
51
+ levelname = record.levelname
52
+ if levelname in LEVEL_COLORS:
53
+ record.levelname = (
54
+ f"{LEVEL_COLORS[levelname]}{record.levelname[0]}{Style.RESET_ALL}"
55
+ )
56
+ record.msg = f"{LEVEL_COLORS[levelname]}{record.msg}{Style.RESET_ALL}"
57
+ return super().format(record)
58
+
59
+
60
+ def setup_logger(debug_path: Optional[str] = None):
61
+ handlers = ["stdout", "stderr"]
62
+ if debug_path:
63
+ handlers.append("json")
64
+ handlers.append("userlog")
65
+ cfg = {
66
+ "version": 1,
67
+ "disable_existing_loggers": False,
68
+ "formatters": {
69
+ "user": {
70
+ "()": ColoredFormatter,
71
+ "format": "%(levelname)s| %(message)s",
72
+ "datefmt": "%Y-%m-%dT%H:%M:%S",
73
+ },
74
+ "json": {
75
+ "()": JsonLineFormatter, # custom formatter
76
+ },
77
+ },
78
+ "filters": {"only_info": {"()": OnlyLevelFilter, "level": logging.INFO}},
79
+ "handlers": {
80
+ "stdout": {
81
+ "class": "logging.StreamHandler",
82
+ "level": "INFO",
83
+ "formatter": "user",
84
+ "filters": ["only_info"],
85
+ "stream": "ext://sys.stdout",
86
+ },
87
+ "stderr": {
88
+ "class": "logging.StreamHandler",
89
+ "level": "WARNING",
90
+ "formatter": "user",
91
+ "stream": "ext://sys.stderr",
92
+ },
93
+ "json": {
94
+ "class": "logging.FileHandler",
95
+ "level": "DEBUG",
96
+ "formatter": "json",
97
+ "filename": (
98
+ os.path.join(os.path.expanduser(debug_path) , "debug.log.jsonl")
99
+ if debug_path is not None
100
+ else "log.jsonl"
101
+ ), # path relative to working dir
102
+ "mode": "w",
103
+ },
104
+ "userlog": {
105
+ "class": "logging.FileHandler",
106
+ "level": "DEBUG",
107
+ "formatter": "user",
108
+ "filename": (
109
+ os.path.join(os.path.expanduser(debug_path) , "debug.log")
110
+ if debug_path is not None
111
+ else "log"
112
+ ), # path relative to working dir
113
+ "mode": "w",
114
+ },
115
+ },
116
+ "loggers": {
117
+ "asyncio_for_robotics": {
118
+ "level": "DEBUG",
119
+ "handlers": handlers,
120
+ "propagate": False,
121
+ },
122
+ },
123
+ }
124
+ logging.config.dictConfig(cfg)
125
+
@@ -0,0 +1,379 @@
1
+ import asyncio
2
+ import contextvars
3
+ import inspect
4
+ import logging
5
+ from collections.abc import Awaitable, Callable
6
+ from contextlib import AsyncExitStack
7
+ from functools import wraps
8
+ from typing import Any, Coroutine, Optional, ParamSpec, TypeVar, cast
9
+
10
+ from ._compat import BaseExceptionGroup, TaskGroup
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _MISSING = object()
15
+ _P = ParamSpec("_P")
16
+ _R = TypeVar("_R")
17
+ _CURRENT_SCOPE: contextvars.ContextVar["Scope | None"] = contextvars.ContextVar(
18
+ "afor_current_scope",
19
+ default=None,
20
+ )
21
+
22
+
23
+ class ScopeBreak(Exception):
24
+ """Exit the current afor.Scope immediately.
25
+
26
+ Raise this inside the body of the current scope when you want to stop that
27
+ scope now and jump to after the ``async with`` block.
28
+ """
29
+
30
+
31
+ class _ScopeCancelled(Exception):
32
+ """Internal exception used to stop a scope early."""
33
+
34
+
35
+ class _AutoScope:
36
+ """Sentinel type for :data:`AUTO_SCOPE`.
37
+
38
+ Instances are never entered, attached to, or otherwise used as a real
39
+ scope: the identity check ``scope is AUTO_SCOPE`` is the only
40
+ interaction. All scope-shaped operations raise ``RuntimeError`` so
41
+ accidental use fails loudly.
42
+ """
43
+
44
+ def __repr__(self) -> str:
45
+ return "AUTO_SCOPE"
46
+
47
+ def _fail(self) -> None:
48
+ raise RuntimeError(
49
+ "AUTO_SCOPE is a sentinel, not a usable scope. "
50
+ "Pass a real afor.Scope, or None to opt out of auto-attach."
51
+ )
52
+
53
+ async def __aenter__(self):
54
+ self._fail()
55
+
56
+ async def __aexit__(self, exc_type, exc, tb):
57
+ self._fail()
58
+
59
+
60
+ #: Sentinel for ``scope=`` parameters.
61
+ #:
62
+ #: When passed as ``scope=AUTO_SCOPE`` (the default on every subscriber),
63
+ #: the resource attaches to :meth:`Scope.current` if one is active, or
64
+ #: runs unattached otherwise. Pass ``None`` to opt out explicitly, or a
65
+ #: concrete :class:`Scope` to attach to a specific one.
66
+ AUTO_SCOPE: "Scope" = cast("Scope", _AutoScope())
67
+
68
+
69
+ class Scope:
70
+ """Lexical owner for async resources and background tasks.
71
+
72
+ A scope binds a ``TaskGroup`` and an ``AsyncExitStack`` together: resources
73
+ created inside the scope auto-attach to it and are cleaned up when the
74
+ scope exits. If any task fails, the scope cancels the others and
75
+ propagates the error.
76
+
77
+ Use as a context manager or as a decorator::
78
+
79
+ async with afor.Scope() as scope:
80
+ sub = Sub(...) # auto-attaches
81
+ scope.task_group.create_task(background_work())
82
+
83
+ @afor.scoped
84
+ async def main():
85
+ sub = Sub(...) # same auto-attach, less indentation
86
+
87
+ ``raise afor.ScopeBreak()`` exits the current scope immediately (like
88
+ ``break`` for an ``async with`` block).
89
+
90
+ Advanced users can access ``scope.task_group`` and ``scope.exit_stack``
91
+ directly for custom structured-concurrency or cleanup needs. The
92
+ ``scope.finished`` future lets external code observe when teardown
93
+ completes.
94
+ """
95
+
96
+ def __init__(self) -> None:
97
+ #: Underlying AsyncExitStack.
98
+ #: It is None before ``__aenter__`` and after ``__aexit__``.
99
+ self._exit_stack: Optional[AsyncExitStack] = None
100
+ #: Underlying TaskGroup.
101
+ #: It is None before ``__aenter__`` and after ``__aexit__``.
102
+ self._task_group: Optional[TaskGroup] = None
103
+ self._finished: Optional[asyncio.Future[None]] = None
104
+ self._updating_finished = False
105
+ self._current_scope_token: Optional[contextvars.Token["Scope | None"]] = None
106
+ self._cancel_called = False
107
+
108
+ @property
109
+ def exit_stack(self) -> AsyncExitStack:
110
+ """Underlying AsyncExitStack.
111
+
112
+ Returns:
113
+ Underlying AsyncExitStack.
114
+
115
+ Raises:
116
+ RuntimeError: if called when the scope is not active.
117
+ """
118
+ if self._exit_stack is None:
119
+ raise RuntimeError(
120
+ "Cannot get the AsyncExitStack of a afor.Scope that is not active:"
121
+ "not entered yet; or was exited."
122
+ )
123
+ return self._exit_stack
124
+
125
+ @property
126
+ def task_group(self) -> TaskGroup:
127
+ """Underlying TaskGroup.
128
+
129
+ Returns:
130
+ Underlying TaskGroup.
131
+
132
+ Raises:
133
+ RuntimeError: if called when the scope is not active.
134
+ """
135
+ if self._task_group is None:
136
+ raise RuntimeError(
137
+ "Cannot get the TaskGroup of a afor.Scope that is not active:"
138
+ "not entered yet; or was exited."
139
+ )
140
+ return self._task_group
141
+
142
+ async def __aenter__(self) -> "Scope":
143
+ """Enter the scope and activate its TaskGroup and AsyncExitStack."""
144
+ logger.debug("Entered scope block")
145
+ self._finished = asyncio.get_running_loop().create_future()
146
+ self._finished.add_done_callback(self._finished_done_callback)
147
+ self._finished.add_done_callback(self._finished_consume_exception_callback)
148
+ self._exit_stack = AsyncExitStack()
149
+ await self._exit_stack.__aenter__()
150
+ self._exit_stack.callback(self._cancel_tg)
151
+ self._task_group = await self._exit_stack.enter_async_context(TaskGroup())
152
+ self._current_scope_token = _CURRENT_SCOPE.set(self)
153
+ return self
154
+
155
+ async def __aexit__(self, exc_type, exc, tb) -> bool:
156
+ """Leave the scope, running cleanup callbacks and exiting the TaskGroup.
157
+
158
+ ``ScopeBreak`` is treated as normal scoped control flow and is
159
+ suppressed after teardown finishes.
160
+ """
161
+ logger.debug("Exiting scope block")
162
+ assert self._exit_stack is not None
163
+ assert self._current_scope_token is not None
164
+ _CURRENT_SCOPE.reset(self._current_scope_token)
165
+ try:
166
+ self.cancel()
167
+ suppressed = await self._exit_stack.__aexit__(exc_type, exc, tb)
168
+ self._set_finished_result()
169
+ return suppressed or exc_type is ScopeBreak
170
+ except BaseException as err:
171
+ if exc_type is ScopeBreak:
172
+ filtered_break = self._strip_scope_break(err)
173
+ if filtered_break is None:
174
+ self._set_finished_cancelled()
175
+ return True
176
+ err = filtered_break
177
+ filtered = self._strip_scope_cancel(err)
178
+ if filtered is None:
179
+ if exc_type is asyncio.CancelledError:
180
+ self._set_finished_cancelled()
181
+ else:
182
+ self._set_finished_result()
183
+ return exc_type in (None, asyncio.CancelledError)
184
+ self._set_finished_exception(filtered)
185
+ raise filtered
186
+ finally:
187
+ self._task_group = None
188
+ self._exit_stack = None
189
+ logger.debug("Finished scope block")
190
+
191
+ @classmethod
192
+ def current(cls, default: Any = _MISSING) -> "Scope":
193
+ """Return the current lexical afor scope.
194
+
195
+ Args:
196
+ default:
197
+ Value returned when no scope is active.
198
+ If omitted, a RuntimeError is raised instead.
199
+
200
+ Returns:
201
+ The currently active scope for this execution context.
202
+ """
203
+ scope = _CURRENT_SCOPE.get()
204
+ if scope is None:
205
+ if default is _MISSING:
206
+ raise RuntimeError("No active afor.Scope")
207
+ return default
208
+ return scope
209
+
210
+ @property
211
+ def finished(self) -> asyncio.Future[None]:
212
+ """Future resolved when scope teardown completes.
213
+
214
+ This future is available after the scope has been entered.
215
+ Typical uses:
216
+ - inspect whether another scope finished cleanly
217
+ - await teardown after requesting ``scope.cancel()`` elsewhere
218
+
219
+ It is usually not useful to await ``finished`` from inside the same
220
+ scope body unless some other task is responsible for ending that scope.
221
+
222
+ States:
223
+ - pending: scope is still running
224
+ - result ``None``: scope completed normally
225
+ - cancelled: scope ended through cancellation / scope-break control flow
226
+ - exception: scope failed
227
+
228
+ Calling ``scope.finished.cancel()`` while the scope is still running
229
+ requests cancellation of that scope.
230
+ """
231
+ if self._finished is None:
232
+ raise RuntimeError(
233
+ "Scope.finished is available only after entering the scope"
234
+ )
235
+ return self._finished
236
+
237
+ def cancel(self) -> None:
238
+ """Request early stop of this scope.
239
+
240
+ This is different from reaching the natural end of the block:
241
+ ``cancel()`` injects an internal cancellation task into the scope's
242
+ TaskGroup so the scope will tear down on exit.
243
+
244
+ This is useful when you need to stop a scope that is not the current
245
+ one, or when teardown request and control-flow exit are separate
246
+ concerns.
247
+
248
+ To exit the current scope immediately and continue after the block, use
249
+ ``raise afor.ScopeBreak()`` instead.
250
+
251
+ Multiple calls are harmless.
252
+ """
253
+ if self._cancel_called:
254
+ return
255
+ logger.debug("Cancelling scope")
256
+ self._cancel_called = True
257
+ assert self._task_group is not None
258
+ self._cancel_tg()
259
+
260
+ def _cancel_tg(self) -> None:
261
+ if self._task_group is None:
262
+ return
263
+ logger.debug("Cancelling task_group")
264
+ for t in self._task_group._tasks:
265
+ t.cancel()
266
+
267
+ @classmethod
268
+ def _strip_scope_cancel(cls, err: BaseException) -> BaseException | None:
269
+ if isinstance(err, _ScopeCancelled):
270
+ return None
271
+ if isinstance(err, BaseExceptionGroup):
272
+ remaining = []
273
+ for inner in err.exceptions:
274
+ stripped = cls._strip_scope_cancel(inner)
275
+ if stripped is not None:
276
+ remaining.append(stripped)
277
+ if not remaining:
278
+ return None
279
+ return err.derive(remaining)
280
+ return err
281
+
282
+ @classmethod
283
+ def _strip_scope_break(cls, err: BaseException) -> BaseException | None:
284
+ if isinstance(err, ScopeBreak):
285
+ return None
286
+ if isinstance(err, BaseExceptionGroup):
287
+ remaining = []
288
+ for inner in err.exceptions:
289
+ stripped = cls._strip_scope_break(inner)
290
+ if stripped is not None:
291
+ remaining.append(stripped)
292
+ if not remaining:
293
+ return None
294
+ return err.derive(remaining)
295
+ return err
296
+
297
+ def _set_finished_result(self) -> None:
298
+ if self._finished is None:
299
+ return
300
+ if not self._finished.done():
301
+ self._updating_finished = True
302
+ try:
303
+ self._finished.set_result(None)
304
+ finally:
305
+ self._updating_finished = False
306
+
307
+ def _set_finished_cancelled(self) -> None:
308
+ if self._finished is None:
309
+ return
310
+ if not self._finished.done():
311
+ self._updating_finished = True
312
+ try:
313
+ self._finished.cancel()
314
+ finally:
315
+ self._updating_finished = False
316
+
317
+ def _set_finished_exception(self, exc: BaseException) -> None:
318
+ if self._finished is None:
319
+ return
320
+ if not self._finished.done():
321
+ self._updating_finished = True
322
+ try:
323
+ self._finished.set_exception(exc)
324
+ finally:
325
+ self._updating_finished = False
326
+
327
+ def _finished_done_callback(self, fut: asyncio.Future[None]) -> None:
328
+ if self._updating_finished:
329
+ return
330
+ if not fut.cancelled():
331
+ return
332
+ if self._task_group is None:
333
+ return
334
+ self.cancel()
335
+
336
+ @staticmethod
337
+ def _finished_consume_exception_callback(fut: asyncio.Future[None]) -> None:
338
+ if fut.cancelled():
339
+ return
340
+ try:
341
+ fut.exception()
342
+ except BaseException:
343
+ return
344
+
345
+
346
+ # func: Callable[_P, Coroutine[_A, _Y, _R]],
347
+ # ) -> Callable[_P, Coroutine[_A, _Y, _R]]:
348
+
349
+
350
+ def scoped(
351
+ func: Callable[_P, Coroutine[Any, Any, _R]],
352
+ ) -> Callable[_P, Coroutine[Any, Any, _R]]:
353
+ """Decorator: wrap an async function in ``async with afor.Scope():``.
354
+
355
+ Resources created inside the function auto-attach to the scope and are
356
+ cleaned up when the function returns (or raises). Equivalent to::
357
+
358
+ async def main():
359
+ async with afor.Scope():
360
+ ... # your code
361
+
362
+ but with one less indentation level::
363
+
364
+ @afor.scoped
365
+ async def main():
366
+ ... # your code
367
+
368
+ Raises:
369
+ TypeError: If *func* is not an async function.
370
+ """
371
+ if not inspect.iscoroutinefunction(func):
372
+ raise TypeError("@afor.scoped requires an async function")
373
+
374
+ @wraps(func)
375
+ async def wrapped(*args: _P.args, **kwargs: _P.kwargs) -> _R:
376
+ async with Scope():
377
+ return await func(*args, **kwargs)
378
+
379
+ return wrapped