Skip to content

Commit fe4551b

Browse files
authored
Initial version of the code (#10)
- **Add more keywords to the project metadata** - **Remove the generated dummy code** - **Add a typing module with a decorator to disable `__init__`** - **Add a new `asyncio` module with general purpose async tools** - **Add a `datetime` module with a `UNIX_EPOCH` constant**
2 parents 4c325a3 + 4d25d17 commit fe4551b

File tree

8 files changed

+872
-42
lines changed

8 files changed

+872
-42
lines changed

pyproject.toml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,19 @@ name = "frequenz-core"
1414
description = "Core utilities to complement Python's standard library"
1515
readme = "README.md"
1616
license = { text = "MIT" }
17-
keywords = ["frequenz", "python", "lib", "library", "core", "stdlib"]
17+
keywords = [
18+
"asyncio",
19+
"collections",
20+
"core",
21+
"datetime",
22+
"frequenz",
23+
"lib",
24+
"library",
25+
"math",
26+
"python",
27+
"stdlib",
28+
"typing",
29+
]
1830
classifiers = [
1931
"Development Status :: 5 - Production/Stable",
2032
"Intended Audience :: Developers",
@@ -157,7 +169,7 @@ packages = ["frequenz.core"]
157169
strict = true
158170

159171
[[tool.mypy.overrides]]
160-
module = ["mkdocs_macros.*", "sybil", "sybil.*"]
172+
module = ["mkdocs_macros.*", "sybil", "sybil.*", "async_solipsism"]
161173
ignore_missing_imports = true
162174

163175
[tool.setuptools_scm]

src/frequenz/core/__init__.py

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,4 @@
11
# License: MIT
22
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
33

4-
"""Core utilities to complement Python's standard library.
5-
6-
TODO(cookiecutter): Add a more descriptive module description.
7-
"""
8-
9-
10-
# TODO(cookiecutter): Remove this function
11-
def delete_me(*, blow_up: bool = False) -> bool:
12-
"""Do stuff for demonstration purposes.
13-
14-
Args:
15-
blow_up: If True, raise an exception.
16-
17-
Returns:
18-
True if no exception was raised.
19-
20-
Raises:
21-
RuntimeError: if blow_up is True.
22-
"""
23-
if blow_up:
24-
raise RuntimeError("This function should be removed!")
25-
return True
4+
"""Core utilities to complement Python's standard library."""

src/frequenz/core/asyncio.py

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""General purpose async tools.
5+
6+
This module provides general purpose async tools that can be used to simplify the
7+
development of asyncio-based applications.
8+
9+
The module provides the following classes and functions:
10+
11+
- [cancel_and_await][frequenz.core.asyncio.cancel_and_await]: A function that cancels a
12+
task and waits for it to finish, handling `CancelledError` exceptions.
13+
- [BackgroundService][frequenz.core.asyncio.BackgroundService]: A base class for
14+
implementing background services that can be started and stopped.
15+
"""
16+
17+
18+
import abc
19+
import asyncio
20+
import collections.abc
21+
from types import TracebackType
22+
from typing import Any, Self
23+
24+
25+
async def cancel_and_await(task: asyncio.Task[Any]) -> None:
26+
"""Cancel a task and wait for it to finish.
27+
28+
Exits immediately if the task is already done.
29+
30+
The `CancelledError` is suppressed, but any other exception will be propagated.
31+
32+
Args:
33+
task: The task to be cancelled and waited for.
34+
"""
35+
if task.done():
36+
return
37+
task.cancel()
38+
try:
39+
await task
40+
except asyncio.CancelledError:
41+
pass
42+
43+
44+
class BackgroundService(abc.ABC):
45+
"""A background service that can be started and stopped.
46+
47+
A background service is a service that runs in the background spawning one or more
48+
tasks. The service can be [started][frequenz.core.asyncio.BackgroundService.start]
49+
and [stopped][frequenz.core.asyncio.BackgroundService.stop] and can work as an
50+
async context manager to provide deterministic cleanup.
51+
52+
To implement a background service, subclasses must implement the
53+
[`start()`][frequenz.core.asyncio.BackgroundService.start] method, which should
54+
start the background tasks needed by the service, and add them to the `_tasks`
55+
protected attribute.
56+
57+
If you need to collect results or handle exceptions of the tasks when stopping the
58+
service, then you need to also override the
59+
[`stop()`][frequenz.core.asyncio.BackgroundService.stop] method, as the base
60+
implementation does not collect any results and re-raises all exceptions.
61+
62+
!!! warning
63+
64+
As background services manage [`asyncio.Task`][] objects, a reference to them
65+
must be held for as long as the background service is expected to be running,
66+
otherwise its tasks will be cancelled and the service will stop. For more
67+
information, please refer to the [Python `asyncio`
68+
documentation](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task).
69+
70+
Example:
71+
```python
72+
import datetime
73+
import asyncio
74+
75+
class Clock(BackgroundService):
76+
def __init__(self, resolution_s: float, *, name: str | None = None) -> None:
77+
super().__init__(name=name)
78+
self._resolution_s = resolution_s
79+
80+
def start(self) -> None:
81+
self._tasks.add(asyncio.create_task(self._tick()))
82+
83+
async def _tick(self) -> None:
84+
while True:
85+
await asyncio.sleep(self._resolution_s)
86+
print(datetime.datetime.now())
87+
88+
async def main() -> None:
89+
# As an async context manager
90+
async with Clock(resolution_s=1):
91+
await asyncio.sleep(5)
92+
93+
# Manual start/stop (only use if necessary, as cleanup is more complicated)
94+
clock = Clock(resolution_s=1)
95+
clock.start()
96+
await asyncio.sleep(5)
97+
await clock.stop()
98+
99+
asyncio.run(main())
100+
```
101+
"""
102+
103+
def __init__(self, *, name: str | None = None) -> None:
104+
"""Initialize this BackgroundService.
105+
106+
Args:
107+
name: The name of this background service. If `None`, `str(id(self))` will
108+
be used. This is used mostly for debugging purposes.
109+
"""
110+
self._name: str = str(id(self)) if name is None else name
111+
self._tasks: set[asyncio.Task[Any]] = set()
112+
113+
@abc.abstractmethod
114+
def start(self) -> None:
115+
"""Start this background service."""
116+
117+
@property
118+
def name(self) -> str:
119+
"""The name of this background service.
120+
121+
Returns:
122+
The name of this background service.
123+
"""
124+
return self._name
125+
126+
@property
127+
def tasks(self) -> collections.abc.Set[asyncio.Task[Any]]:
128+
"""Return the set of running tasks spawned by this background service.
129+
130+
Users typically should not modify the tasks in the returned set and only use
131+
them for informational purposes.
132+
133+
!!! danger
134+
135+
Changing the returned tasks may lead to unexpected behavior, don't do it
136+
unless the class explicitly documents it is safe to do so.
137+
138+
Returns:
139+
The set of running tasks spawned by this background service.
140+
"""
141+
return self._tasks
142+
143+
@property
144+
def is_running(self) -> bool:
145+
"""Return whether this background service is running.
146+
147+
A service is considered running when at least one task is running.
148+
149+
Returns:
150+
Whether this background service is running.
151+
"""
152+
return any(not task.done() for task in self._tasks)
153+
154+
def cancel(self, msg: str | None = None) -> None:
155+
"""Cancel all running tasks spawned by this background service.
156+
157+
Args:
158+
msg: The message to be passed to the tasks being cancelled.
159+
"""
160+
for task in self._tasks:
161+
task.cancel(msg)
162+
163+
async def stop(self, msg: str | None = None) -> None:
164+
"""Stop this background service.
165+
166+
This method cancels all running tasks spawned by this service and waits for them
167+
to finish.
168+
169+
Args:
170+
msg: The message to be passed to the tasks being cancelled.
171+
172+
Raises:
173+
BaseExceptionGroup: If any of the tasks spawned by this service raised an
174+
exception.
175+
"""
176+
if not self._tasks:
177+
return
178+
self.cancel(msg)
179+
try:
180+
await self.wait()
181+
except BaseExceptionGroup as exc_group:
182+
# We want to ignore CancelledError here as we explicitly cancelled all the
183+
# tasks.
184+
_, rest = exc_group.split(asyncio.CancelledError)
185+
if rest is not None:
186+
# We are filtering out from an exception group, we really don't want to
187+
# add the exceptions we just filtered by adding a from clause here.
188+
raise rest # pylint: disable=raise-missing-from
189+
190+
async def __aenter__(self) -> Self:
191+
"""Enter an async context.
192+
193+
Start this background service.
194+
195+
Returns:
196+
This background service.
197+
"""
198+
self.start()
199+
return self
200+
201+
async def __aexit__(
202+
self,
203+
exc_type: type[BaseException] | None,
204+
exc_val: BaseException | None,
205+
exc_tb: TracebackType | None,
206+
) -> None:
207+
"""Exit an async context.
208+
209+
Stop this background service.
210+
211+
Args:
212+
exc_type: The type of the exception raised, if any.
213+
exc_val: The exception raised, if any.
214+
exc_tb: The traceback of the exception raised, if any.
215+
"""
216+
await self.stop()
217+
218+
async def wait(self) -> None:
219+
"""Wait this background service to finish.
220+
221+
Wait until all background service tasks are finished.
222+
223+
Raises:
224+
BaseExceptionGroup: If any of the tasks spawned by this service raised an
225+
exception (`CancelError` is not considered an error and not returned in
226+
the exception group).
227+
"""
228+
# We need to account for tasks that were created between when we started
229+
# awaiting and we finished awaiting.
230+
while self._tasks:
231+
done, pending = await asyncio.wait(self._tasks)
232+
assert not pending
233+
234+
# We remove the done tasks, but there might be new ones created after we
235+
# started waiting.
236+
self._tasks = self._tasks - done
237+
238+
exceptions: list[BaseException] = []
239+
for task in done:
240+
try:
241+
# This will raise a CancelledError if the task was cancelled or any
242+
# other exception if the task raised one.
243+
_ = task.result()
244+
except BaseException as error: # pylint: disable=broad-except
245+
exceptions.append(error)
246+
if exceptions:
247+
raise BaseExceptionGroup(
248+
f"Error while stopping background service {self}", exceptions
249+
)
250+
251+
def __await__(self) -> collections.abc.Generator[None, None, None]:
252+
"""Await this background service.
253+
254+
An awaited background service will wait for all its tasks to finish.
255+
256+
Returns:
257+
An implementation-specific generator for the awaitable.
258+
"""
259+
return self.wait().__await__()
260+
261+
def __del__(self) -> None:
262+
"""Destroy this instance.
263+
264+
Cancel all running tasks spawned by this background service.
265+
"""
266+
self.cancel("{self!r} was deleted")
267+
268+
def __repr__(self) -> str:
269+
"""Return a string representation of this instance.
270+
271+
Returns:
272+
A string representation of this instance.
273+
"""
274+
return f"{type(self).__name__}(name={self._name!r}, tasks={self._tasks!r})"
275+
276+
def __str__(self) -> str:
277+
"""Return a string representation of this instance.
278+
279+
Returns:
280+
A string representation of this instance.
281+
"""
282+
return f"{type(self).__name__}[{self._name}]"

src/frequenz/core/datetime.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Timeseries basic types."""
5+
6+
from datetime import datetime, timezone
7+
8+
UNIX_EPOCH = datetime.fromtimestamp(0.0, tz=timezone.utc)
9+
"""The UNIX epoch (in UTC)."""

0 commit comments

Comments
 (0)