Skip to content

Commit 0f4ca6b

Browse files
committed
Make Service an ABC and add a ServiceBase
By having both mixed up, it was hard to separate documentation for users of services, and developers wanting to implement services. It also gave access to service users to internals for service implementation, like `create_task`, which is not ideal. Now users can use the `Service` type to refer to services generically and implementors can use `ServiceBase` to have a sane starting point to implement a service. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 47bef4a commit 0f4ca6b

File tree

2 files changed

+148
-20
lines changed

2 files changed

+148
-20
lines changed

src/frequenz/core/asyncio.py

Lines changed: 146 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
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

@@ -24,6 +26,8 @@
2426
from types import TracebackType
2527
from 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

tests/test_asyncio.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import async_solipsism
1010
import pytest
1111

12-
from frequenz.core.asyncio import Service, TaskCreator
12+
from frequenz.core.asyncio import ServiceBase, TaskCreator
1313

1414

1515
# This method replaces the event loop for all tests in the file.
@@ -19,7 +19,7 @@ def event_loop_policy() -> async_solipsism.EventLoopPolicy:
1919
return async_solipsism.EventLoopPolicy()
2020

2121

22-
class FakeService(Service):
22+
class FakeService(ServiceBase):
2323
"""A service that does nothing."""
2424

2525
def __init__(

0 commit comments

Comments
 (0)