1010
1111- [cancel_and_await][frequenz.core.asyncio.cancel_and_await]: A function that cancels a
1212 task and waits for it to finish, handling `CancelledError` exceptions.
13- - [Service][frequenz.core.asyncio.Service]: A base class for
14- implementing services running in the background that can be started and stopped.
13+ - [Service][frequenz.core.asyncio.Service]: An interface for services running in the
14+ background.
15+ - [ServiceBase][frequenz.core.asyncio.ServiceBase]: A base class for implementing
16+ services running in the background.
1517- [TaskCreator][frequenz.core.asyncio.TaskCreator]: A protocol for creating tasks.
1618"""
1719
2426from types import TracebackType
2527from typing import Any , Protocol , Self , TypeVar , runtime_checkable
2628
29+ from typing_extensions import override
30+
2731_logger = logging .getLogger (__name__ )
2832
2933
@@ -90,29 +94,144 @@ class Service(abc.ABC):
9094 [stopped][frequenz.core.asyncio.Service.stop] and can work as an async context
9195 manager to provide deterministic cleanup.
9296
93- To implement a service, subclasses must implement the
94- [`start()`][frequenz.core.asyncio.Service.start] method, which should start the
95- background tasks needed by the service using the
96- [`create_task()`][frequezn.core.asyncio.Service.create_task] method.
97-
98- If you need to collect results or handle exceptions of the tasks when stopping the
99- service, then you need to also override the
100- [`stop()`][frequenz.core.asyncio.Service.stop] method, as the base
101- implementation does not collect any results and re-raises all exceptions.
102-
10397 Warning:
10498 As services manage [`asyncio.Task`][] objects, a reference to a running service
10599 must be held for as long as the service is expected to be running. Otherwise, its
106100 tasks will be cancelled and the service will stop. For more information, please
107101 refer to the [Python `asyncio`
108102 documentation](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task).
109103
104+ Example:
105+ ```python
106+ async def as_context_manager(service: Service) -> None:
107+ async with service:
108+ assert service.is_running
109+ await asyncio.sleep(5)
110+ assert not service.is_running
111+
112+ async def manual_start_stop(service: Service) -> None:
113+ # Use only if necessary, as cleanup is more complicated
114+ service.start()
115+ await asyncio.sleep(5)
116+ await service.stop()
117+ ```
118+ """
119+
120+ @abc .abstractmethod
121+ def start (self ) -> None :
122+ """Start this service."""
123+
124+ @property
125+ @abc .abstractmethod
126+ def unique_id (self ) -> str :
127+ """The unique ID of this service."""
128+
129+ @property
130+ @abc .abstractmethod
131+ def tasks (self ) -> collections .abc .Set [asyncio .Task [Any ]]:
132+ """The set of running tasks spawned by this service.
133+
134+ Users typically should not modify the tasks in the returned set and only use
135+ them for informational purposes.
136+
137+ Danger:
138+ Changing the returned tasks may lead to unexpected behavior, don't do it
139+ unless the class explicitly documents it is safe to do so.
140+ """
141+
142+ @property
143+ @abc .abstractmethod
144+ def is_running (self ) -> bool :
145+ """Whether this service is running.
146+
147+ A service is considered running when at least one task is running.
148+ """
149+
150+ @abc .abstractmethod
151+ def cancel (self , msg : str | None = None ) -> None :
152+ """Cancel all running tasks spawned by this service.
153+
154+ Args:
155+ msg: The message to be passed to the tasks being cancelled.
156+ """
157+
158+ @abc .abstractmethod
159+ async def stop (self , msg : str | None = None ) -> None : # noqa: DOC502
160+ """Stop this service.
161+
162+ This method cancels all running tasks spawned by this service and waits for them
163+ to finish.
164+
165+ Args:
166+ msg: The message to be passed to the tasks being cancelled.
167+
168+ Raises:
169+ BaseExceptionGroup: If any of the tasks spawned by this service raised an
170+ exception.
171+ """
172+
173+ @abc .abstractmethod
174+ async def __aenter__ (self ) -> Self :
175+ """Enter an async context.
176+
177+ Start this service.
178+
179+ Returns:
180+ This service.
181+ """
182+
183+ @abc .abstractmethod
184+ async def __aexit__ (
185+ self ,
186+ exc_type : type [BaseException ] | None ,
187+ exc_val : BaseException | None ,
188+ exc_tb : TracebackType | None ,
189+ ) -> None :
190+ """Exit an async context.
191+
192+ Stop this service.
193+
194+ Args:
195+ exc_type: The type of the exception raised, if any.
196+ exc_val: The exception raised, if any.
197+ exc_tb: The traceback of the exception raised, if any.
198+ """
199+
200+ @abc .abstractmethod
201+ def __await__ (self ) -> collections .abc .Generator [None , None , None ]: # noqa: DOC502
202+ """Wait for this service to finish.
203+
204+ Wait until all the service tasks are finished.
205+
206+ Returns:
207+ An implementation-specific generator for the awaitable.
208+
209+ Raises:
210+ BaseExceptionGroup: If any of the tasks spawned by this service raised an
211+ exception (`CancelError` is not considered an error and not returned in
212+ the exception group).
213+ """
214+
215+
216+ class ServiceBase (Service , abc .ABC ):
217+ """A base class for implementing a service running in the background.
218+
219+ To implement a service, subclasses must implement the
220+ [`start()`][frequenz.core.asyncio.ServiceBase.start] method, which should start the
221+ background tasks needed by the service using the
222+ [`create_task()`][frequenz.core.asyncio.ServiceBase.create_task] method.
223+
224+ If you need to collect results or handle exceptions of the tasks when stopping the
225+ service, then you need to also override the
226+ [`stop()`][frequenz.core.asyncio.ServiceBase.stop] method, as the base
227+ implementation does not collect any results and re-raises all exceptions.
228+
110229 Example:
111230 ```python
112231 import datetime
113232 import asyncio
114233
115- class Clock(Service ):
234+ class Clock(ServiceBase ):
116235 def __init__(self, resolution_s: float, *, unique_id: str | None = None) -> None:
117236 super().__init__(unique_id=unique_id)
118237 self._resolution_s = resolution_s
@@ -162,16 +281,19 @@ def __init__(
162281 self ._tasks : set [asyncio .Task [Any ]] = set ()
163282 self ._task_creator : TaskCreator = task_creator
164283
284+ @override
165285 @abc .abstractmethod
166286 def start (self ) -> None :
167287 """Start this service."""
168288
169289 @property
290+ @override
170291 def unique_id (self ) -> str :
171292 """The unique ID of this service."""
172293 return self ._unique_id
173294
174295 @property
296+ @override
175297 def tasks (self ) -> collections .abc .Set [asyncio .Task [Any ]]:
176298 """The set of running tasks spawned by this service.
177299
@@ -185,6 +307,7 @@ def tasks(self) -> collections.abc.Set[asyncio.Task[Any]]:
185307 return self ._tasks
186308
187309 @property
310+ @override
188311 def is_running (self ) -> bool :
189312 """Whether this service is running.
190313
@@ -205,17 +328,17 @@ def create_task(
205328 A reference to the task will be held by the service, so there is no need to save
206329 the task object.
207330
208- Tasks can be retrieved via the [`tasks`][frequenz.core.asyncio.Service.tasks]
209- property.
331+ Tasks can be retrieved via the
332+ [`tasks`][frequenz.core.asyncio.ServiceBase.tasks] property.
210333
211334 Managed tasks always have a `name` including information about the service
212335 itself. If you need to retrieve the final name of the task you can always do so
213336 by calling [`.get_name()`][asyncio.Task.get_name] on the returned task.
214337
215338 Tasks created this way will also be automatically cancelled when calling
216- [`cancel()`][frequenz.core.asyncio.Service .cancel] or
217- [`stop()`][frequenz.core.asyncio.Service .stop], or when the service is used as
218- a async context manager.
339+ [`cancel()`][frequenz.core.asyncio.ServiceBase .cancel] or
340+ [`stop()`][frequenz.core.asyncio.ServiceBase .stop], or when the service is used
341+ as a async context manager.
219342
220343 Args:
221344 coro: The coroutine to be managed.
@@ -250,6 +373,7 @@ def _log_exception(task: asyncio.Task[TaskReturnT]) -> None:
250373 task .add_done_callback (_log_exception )
251374 return task
252375
376+ @override
253377 def cancel (self , msg : str | None = None ) -> None :
254378 """Cancel all running tasks spawned by this service.
255379
@@ -259,6 +383,7 @@ def cancel(self, msg: str | None = None) -> None:
259383 for task in self ._tasks :
260384 task .cancel (msg )
261385
386+ @override
262387 async def stop (self , msg : str | None = None ) -> None :
263388 """Stop this service.
264389
@@ -286,6 +411,7 @@ async def stop(self, msg: str | None = None) -> None:
286411 # add the exceptions we just filtered by adding a from clause here.
287412 raise rest # pylint: disable=raise-missing-from
288413
414+ @override
289415 async def __aenter__ (self ) -> Self :
290416 """Enter an async context.
291417
@@ -297,6 +423,7 @@ async def __aenter__(self) -> Self:
297423 self .start ()
298424 return self
299425
426+ @override
300427 async def __aexit__ (
301428 self ,
302429 exc_type : type [BaseException ] | None ,
@@ -347,6 +474,7 @@ async def wait(self) -> None:
347474 f"Error while stopping service { self } " , exceptions
348475 )
349476
477+ @override
350478 def __await__ (self ) -> collections .abc .Generator [None , None , None ]:
351479 """Await this service.
352480
0 commit comments