From a669e3c136de1773de13cb628f4624c41e2bfc98 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 14 Jun 2024 17:55:29 +0200 Subject: [PATCH 1/5] Add more keywords to the project metadata Signed-off-by: Leandro Lucarella --- pyproject.toml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7778c7c..5ac2e03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,19 @@ name = "frequenz-core" description = "Core utilities to complement Python's standard library" readme = "README.md" license = { text = "MIT" } -keywords = ["frequenz", "python", "lib", "library", "core", "stdlib"] +keywords = [ + "asyncio", + "collections", + "core", + "datetime", + "frequenz", + "lib", + "library", + "math", + "python", + "stdlib", + "typing", +] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", From 7afcf3a66282d58c28fad2d23013d2005ba78dbd Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 14 Jun 2024 17:56:02 +0200 Subject: [PATCH 2/5] Remove the generated dummy code Signed-off-by: Leandro Lucarella --- src/frequenz/core/__init__.py | 23 +---------------------- tests/test_core.py | 18 ------------------ 2 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 tests/test_core.py diff --git a/src/frequenz/core/__init__.py b/src/frequenz/core/__init__.py index 39e655e..9e229f6 100644 --- a/src/frequenz/core/__init__.py +++ b/src/frequenz/core/__init__.py @@ -1,25 +1,4 @@ # License: MIT # Copyright © 2024 Frequenz Energy-as-a-Service GmbH -"""Core utilities to complement Python's standard library. - -TODO(cookiecutter): Add a more descriptive module description. -""" - - -# TODO(cookiecutter): Remove this function -def delete_me(*, blow_up: bool = False) -> bool: - """Do stuff for demonstration purposes. - - Args: - blow_up: If True, raise an exception. - - Returns: - True if no exception was raised. - - Raises: - RuntimeError: if blow_up is True. - """ - if blow_up: - raise RuntimeError("This function should be removed!") - return True +"""Core utilities to complement Python's standard library.""" diff --git a/tests/test_core.py b/tests/test_core.py deleted file mode 100644 index 7e32233..0000000 --- a/tests/test_core.py +++ /dev/null @@ -1,18 +0,0 @@ -# License: MIT -# Copyright © 2024 Frequenz Energy-as-a-Service GmbH - -"""Tests for the frequenz.core package.""" -import pytest - -from frequenz.core import delete_me - - -def test_core_succeeds() -> None: # TODO(cookiecutter): Remove - """Test that the delete_me function succeeds.""" - assert delete_me() is True - - -def test_core_fails() -> None: # TODO(cookiecutter): Remove - """Test that the delete_me function fails.""" - with pytest.raises(RuntimeError, match="This function should be removed!"): - delete_me(blow_up=True) From 46525f79527edd2165192dd9539ca8ab4cd125a4 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 14 Jun 2024 17:57:30 +0200 Subject: [PATCH 3/5] Add a typing module with a decorator to disable `__init__` This commit adds a new module `frequenz.core.typing` with a decorator `disable_init` that can be used to disable the `__init__` constructor of a class. This is useful when a class doesn't provide a default constructor and requires the use of a factory method to create instances. The code is loosely based on the `_NoDefaultConstructible` class in the SDK [`frequenz.sdk.timeseries._quantities`][1], but it was completely rewritten to be more generic and to use a decorator instead of inheritance, which makes also much easier to use. ```python @disable_init class MyClass(Super): pass ``` Versus: ```python class MyClass( Super, metaclass=_NoDefaultConstructible, ): pass ``` Also it is safer to use, as it will raise an error as soon as a class is declared with an `__init__` method. [1]: https://github.com/frequenz-floss/frequenz-sdk-python/blob/88bd28e2f21b930279d66c1daf3deeaf6f9bb07a/src/frequenz/sdk/timeseries/_quantities.py#L505-L522 Signed-off-by: Leandro Lucarella --- src/frequenz/core/typing.py | 208 ++++++++++++++++++++++++++++++++++++ tests/test_typing.py | 205 +++++++++++++++++++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 src/frequenz/core/typing.py create mode 100644 tests/test_typing.py diff --git a/src/frequenz/core/typing.py b/src/frequenz/core/typing.py new file mode 100644 index 0000000..201b057 --- /dev/null +++ b/src/frequenz/core/typing.py @@ -0,0 +1,208 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Type hints and utility functions for type checking and types. + +For now this module only provides a decorator to disable the `__init__` constructor of +a class, to force the use of a factory method to create instances. See +[disable_init][frequenz.core.typing.disable_init] for more information. +""" + +from collections.abc import Callable +from typing import Any, NoReturn, TypeVar, cast, overload + +TypeT = TypeVar("TypeT", bound=type) +"""A type variable that is bound to a type.""" + + +@overload +def disable_init( + cls: None = None, + *, + error: Exception | None = None, +) -> Callable[[TypeT], TypeT]: ... + + +@overload +def disable_init(cls: TypeT) -> TypeT: ... + + +def disable_init( + cls: TypeT | None = None, + *, + error: Exception | None = None, +) -> TypeT | Callable[[TypeT], TypeT]: + """Disable the `__init__` constructor of a class. + + This decorator can be used to disable the `__init__` constructor of a class. It is + intended to be used with classes that don't provide a default constructor and + require the use of a factory method to create instances. + + When marking a class with this decorator, the class cannot be even declared with a + `__init__` method, as it will raise a `TypeError` when the class is created, as soon + as the class is parsed by the Python interpreter. It will also raise a `TypeError` + when the `__init__` method is called. + + To create an instance you must provide a factory method, using `__new__`. + + Warning: + This decorator will use a custom metaclass to disable the `__init__` constructor + of the class, so if your class already uses a custom metaclass, you should be + aware of potential conflicts. + + Example: Basic example defining a class with a factory method + To be able to type hint the class correctly, you can declare the instance + attributes in the class body, and then use a factory method to create instances. + + ```python + from typing import Self + + @disable_init + class MyClass: + value: int + + @classmethod + def new(cls, value: int = 1) -> Self: + self = cls.__new__(cls) + self.value = value + return self + + instance = MyClass.new() + + # Calling the default constructor (__init__) will raise a TypeError + try: + instance = MyClass() + except TypeError as e: + print(e) + ``` + + Example: Class wrongly providing an `__init__` constructor + ```python + try: + @disable_init + class MyClass: + def __init__(self) -> None: + pass + except TypeError as e: + assert isinstance(e, TypeError) + print(e) + ``` + + Example: Using a custom error message when the default constructor is called + ```python + from typing import Self + + class NoInitError(TypeError): + def __init__(self) -> None: + super().__init__("Please create instances of MyClass using MyClass.new()") + + @disable_init(error=NoInitError()) + class MyClass: + @classmethod + def new(cls) -> Self: + return cls.__new__(cls) + + try: + instance = MyClass() + except NoInitError as e: + assert str(e) == "Please create instances of MyClass using MyClass.new()" + print(e) + ``` + + Args: + cls: The class to be decorated. + error: The error to raise if __init__ is called, if `None` a default + [TypeError][] will be raised. + + Returns: + A decorator that disables the `__init__` constructor of `cls`. + """ + + def decorator(inner_cls: TypeT) -> TypeT: + return cast( + TypeT, + _NoInitConstructibleMeta( + inner_cls.__name__, + inner_cls.__bases__, + dict(inner_cls.__dict__), + no_init_constructible_error=error, + ), + ) + + if cls is None: + return decorator + return decorator(cls) + + +class _NoInitConstructibleMeta(type): + """A metaclass that disables the __init__ constructor.""" + + def __new__( + mcs, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + **kwargs: Any, + ) -> type: + """Create a new class with a disabled __init__ constructor. + + Args: + name: The name of the new class. + bases: The base classes of the new class. + namespace: The namespace of the new class. + **kwargs: Additional keyword arguments. + + Returns: + The new class with a disabled __init__ constructor. + + Raises: + TypeError: If the class provides a default constructor. + """ + if "__init__" in namespace: + raise _get_no_init_constructible_error(name, bases, kwargs) + return super().__new__(mcs, name, bases, namespace) + + def __init__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + **kwargs: Any, + ) -> None: + """Initialize the new class.""" + super().__init__(name, bases, namespace) + cls._no_init_constructible_error = kwargs.get("no_init_constructible_error") + + def __call__(cls, *args: Any, **kwargs: Any) -> NoReturn: + """Raise an error when the __init__ constructor is called. + + Args: + *args: ignored positional arguments. + **kwargs: ignored keyword arguments. + + Raises: + TypeError: Always. + """ + raise _get_no_init_constructible_error( + cls.__name__, + cls.__bases__, + {"no_init_constructible_error": cls._no_init_constructible_error}, + ) + + +def _get_no_init_constructible_error( + name: str, bases: tuple[type, ...], kwargs: Any +) -> Exception: + error = kwargs.get("no_init_constructible_error") + if error is None: + for base in bases: + if attr := getattr(base, "_no_init_constructible_error", None): + error = attr + break + else: + error = TypeError( + f"{name} doesn't provide a default constructor, you must use a " + "factory method to create instances." + ) + assert isinstance(error, Exception) + return error diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 0000000..1a2a89a --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,205 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Test cases for the typing module.""" + +from typing import Self + +import pytest + +from frequenz.core.typing import disable_init + + +def test_disable_init_declaration_with_custom_error() -> None: + """Test that the custom error is raised when trying to declare the class.""" + + class CustomError(TypeError): + """Custom error to raise when trying to declare the class.""" + + def __init__(self) -> None: + """Initialize the custom error.""" + super().__init__("Please use the factory method to create instances.") + + with pytest.raises( + CustomError, match="Please use the factory method to create instances." + ): + + @disable_init(error=CustomError()) + class MyClass: + """Base class that doesn't provide a default constructor.""" + + def __init__(self) -> None: + """Raise an exception when parsed.""" + + +def test_disable_init_instantiation_with_custom_error() -> None: + """Test that the custom error is raised when trying to instantiate the class.""" + + class CustomError(TypeError): + """Custom error to raise when trying to instantiate the class.""" + + def __init__(self) -> None: + """Initialize the custom error.""" + super().__init__("Please use the factory method to create instances.") + + @disable_init(error=CustomError()) + class MyClass: + """Base class that doesn't provide a default constructor.""" + + with pytest.raises( + CustomError, match="Please use the factory method to create instances." + ): + _ = MyClass() + + +def test_disable_init_declaration_subclass_with_custom_error() -> None: + """Test that the custom error is raised when trying to declare a subclass.""" + + class CustomError(TypeError): + """Custom error to raise when trying to declare the class.""" + + def __init__(self) -> None: + """Initialize the custom error.""" + super().__init__("Please use the factory method to create instances.") + + @disable_init(error=CustomError()) + class MyClass: + """Base class that doesn't provide a default constructor.""" + + with pytest.raises( + CustomError, match="Please use the factory method to create instances." + ): + + class Sub(MyClass): + """Base class that doesn't provide a default constructor.""" + + def __init__(self) -> None: + """Raise an exception when parsed.""" + + +def test_disable_init_instantiation_subclass_with_custom_error() -> None: + """Test that the custom error is raised when trying to instantiate a subclass.""" + + class CustomError(TypeError): + """Custom error to raise when trying to instantiate the class.""" + + def __init__(self) -> None: + """Initialize the custom error.""" + super().__init__("Please use the factory method to create instances.") + + @disable_init(error=CustomError()) + class MyClass: + """Base class that doesn't provide a default constructor.""" + + class Sub(MyClass): + """Subclass that doesn't provide a default constructor.""" + + with pytest.raises( + CustomError, match="Please use the factory method to create instances." + ): + _ = Sub() + + +def test_disable_init_default_error() -> None: + """Test that the default error is raised when trying to instantiate the class.""" + + @disable_init + class MyClass: + """Base class that doesn't provide a default constructor.""" + + @classmethod + def new(cls) -> Self: + """Create a new instance of the class.""" + return cls.__new__(cls) + + with pytest.raises( + TypeError, + match="MyClass doesn't provide a default constructor, you must use a " + "factory method to create instances.", + ): + MyClass() + + instance = MyClass.new() + assert isinstance(instance, MyClass) + + +def test_disable_init_subclass() -> None: + """Test that the default error is raised when trying to instantiate a subclass.""" + + @disable_init + class MyClass: + """Base class that doesn't provide a default constructor.""" + + @classmethod + def new(cls) -> Self: + return cls.__new__(cls) + + class Sub(MyClass): + """Subclass that doesn't provide a default constructor.""" + + with pytest.raises( + TypeError, + match=r"Sub doesn't provide a default constructor, you must use a factory " + "method to create instances.", + ): + Sub() + + instance = Sub.new() + assert isinstance(instance, Sub) + + +def test_disable_init_subclass_with_init() -> None: + """Test that the default error is raised when trying to instantiate a subclass.""" + + @disable_init + class MyClass: + """Base class that doesn't provide a default constructor.""" + + with pytest.raises( + TypeError, + match=r"Sub doesn't provide a default constructor, you must use a factory " + "method to create instances.", + ): + + class Sub(MyClass): + """Subclass that doesn't provide a default constructor.""" + + def __init__(self) -> None: + """Raise an exception when parsed.""" + + +def test_disable_init_no_init_allowed() -> None: + """Test that the default error is raised when trying to define an __init__ method.""" + with pytest.raises( + TypeError, + match="MyClass doesn't provide a default constructor, you must use a " + "factory method to create instances.", + ): + + @disable_init + class MyClass: + """Base class that doesn't provide a default constructor.""" + + def __init__(self) -> None: + """Raise an exception when parsed.""" + + +def test_disable_init_with_factory_method() -> None: + """Test that the factory method works when used to create instances.""" + + @disable_init + class MyClass: + """Base class that doesn't provide a default constructor.""" + + value: int + + @classmethod + def new(cls, value: int = 1) -> Self: + """Create a new instance of the class.""" + self = cls.__new__(cls) + self.value = value + return self + + instance = MyClass.new(42) + assert isinstance(instance, MyClass) + assert instance.value == 42 From 389ecbc96526d8b778c88e37caeb075261ceea83 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 14 Jun 2024 18:11:12 +0200 Subject: [PATCH 4/5] Add a new `asyncio` module with general purpose async tools This module provides general purpose async tools that can be used to simplify the development of asyncio-based applications. The code and tests were imported from the SDK: * https://github.com/frequenz-floss/frequenz-sdk-python/blob/88bd28e2f21b930279d66c1daf3deeaf6f9bb07a/src/frequenz/sdk/actor/_background_service.py * https://github.com/frequenz-floss/frequenz-sdk-python/blob/88bd28e2f21b930279d66c1daf3deeaf6f9bb07a/src/frequenz/sdk/_internal/_asyncio.py We need to ignore type hints for async-solipsism because we start using it now and it doesn't provide type hints. Signed-off-by: Leandro Lucarella --- pyproject.toml | 2 +- src/frequenz/core/asyncio.py | 282 +++++++++++++++++++++++++++++++++++ tests/test_asyncio.py | 153 +++++++++++++++++++ 3 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 src/frequenz/core/asyncio.py create mode 100644 tests/test_asyncio.py diff --git a/pyproject.toml b/pyproject.toml index 5ac2e03..913555c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -169,7 +169,7 @@ packages = ["frequenz.core"] strict = true [[tool.mypy.overrides]] -module = ["mkdocs_macros.*", "sybil", "sybil.*"] +module = ["mkdocs_macros.*", "sybil", "sybil.*", "async_solipsism"] ignore_missing_imports = true [tool.setuptools_scm] diff --git a/src/frequenz/core/asyncio.py b/src/frequenz/core/asyncio.py new file mode 100644 index 0000000..289eeda --- /dev/null +++ b/src/frequenz/core/asyncio.py @@ -0,0 +1,282 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""General purpose async tools. + +This module provides general purpose async tools that can be used to simplify the +development of asyncio-based applications. + +The module provides the following classes and functions: + +- [cancel_and_await][frequenz.core.asyncio.cancel_and_await]: A function that cancels a + task and waits for it to finish, handling `CancelledError` exceptions. +- [BackgroundService][frequenz.core.asyncio.BackgroundService]: A base class for + implementing background services that can be started and stopped. +""" + + +import abc +import asyncio +import collections.abc +from types import TracebackType +from typing import Any, Self + + +async def cancel_and_await(task: asyncio.Task[Any]) -> None: + """Cancel a task and wait for it to finish. + + Exits immediately if the task is already done. + + The `CancelledError` is suppressed, but any other exception will be propagated. + + Args: + task: The task to be cancelled and waited for. + """ + if task.done(): + return + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +class BackgroundService(abc.ABC): + """A background service that can be started and stopped. + + A background service is a service that runs in the background spawning one or more + tasks. The service can be [started][frequenz.core.asyncio.BackgroundService.start] + and [stopped][frequenz.core.asyncio.BackgroundService.stop] and can work as an + async context manager to provide deterministic cleanup. + + To implement a background service, subclasses must implement the + [`start()`][frequenz.core.asyncio.BackgroundService.start] method, which should + start the background tasks needed by the service, and add them to the `_tasks` + protected attribute. + + If you need to collect results or handle exceptions of the tasks when stopping the + service, then you need to also override the + [`stop()`][frequenz.core.asyncio.BackgroundService.stop] method, as the base + implementation does not collect any results and re-raises all exceptions. + + !!! warning + + As background services manage [`asyncio.Task`][] objects, a reference to them + must be held for as long as the background service is expected to be running, + otherwise its tasks will be cancelled and the service will stop. For more + information, please refer to the [Python `asyncio` + documentation](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task). + + Example: + ```python + import datetime + import asyncio + + class Clock(BackgroundService): + def __init__(self, resolution_s: float, *, name: str | None = None) -> None: + super().__init__(name=name) + self._resolution_s = resolution_s + + def start(self) -> None: + self._tasks.add(asyncio.create_task(self._tick())) + + async def _tick(self) -> None: + while True: + await asyncio.sleep(self._resolution_s) + print(datetime.datetime.now()) + + async def main() -> None: + # As an async context manager + async with Clock(resolution_s=1): + await asyncio.sleep(5) + + # Manual start/stop (only use if necessary, as cleanup is more complicated) + clock = Clock(resolution_s=1) + clock.start() + await asyncio.sleep(5) + await clock.stop() + + asyncio.run(main()) + ``` + """ + + def __init__(self, *, name: str | None = None) -> None: + """Initialize this BackgroundService. + + Args: + name: The name of this background service. If `None`, `str(id(self))` will + be used. This is used mostly for debugging purposes. + """ + self._name: str = str(id(self)) if name is None else name + self._tasks: set[asyncio.Task[Any]] = set() + + @abc.abstractmethod + def start(self) -> None: + """Start this background service.""" + + @property + def name(self) -> str: + """The name of this background service. + + Returns: + The name of this background service. + """ + return self._name + + @property + def tasks(self) -> collections.abc.Set[asyncio.Task[Any]]: + """Return the set of running tasks spawned by this background service. + + Users typically should not modify the tasks in the returned set and only use + them for informational purposes. + + !!! danger + + Changing the returned tasks may lead to unexpected behavior, don't do it + unless the class explicitly documents it is safe to do so. + + Returns: + The set of running tasks spawned by this background service. + """ + return self._tasks + + @property + def is_running(self) -> bool: + """Return whether this background service is running. + + A service is considered running when at least one task is running. + + Returns: + Whether this background service is running. + """ + return any(not task.done() for task in self._tasks) + + def cancel(self, msg: str | None = None) -> None: + """Cancel all running tasks spawned by this background service. + + Args: + msg: The message to be passed to the tasks being cancelled. + """ + for task in self._tasks: + task.cancel(msg) + + async def stop(self, msg: str | None = None) -> None: + """Stop this background service. + + This method cancels all running tasks spawned by this service and waits for them + to finish. + + Args: + msg: The message to be passed to the tasks being cancelled. + + Raises: + BaseExceptionGroup: If any of the tasks spawned by this service raised an + exception. + """ + if not self._tasks: + return + self.cancel(msg) + try: + await self.wait() + except BaseExceptionGroup as exc_group: + # We want to ignore CancelledError here as we explicitly cancelled all the + # tasks. + _, rest = exc_group.split(asyncio.CancelledError) + if rest is not None: + # We are filtering out from an exception group, we really don't want to + # add the exceptions we just filtered by adding a from clause here. + raise rest # pylint: disable=raise-missing-from + + async def __aenter__(self) -> Self: + """Enter an async context. + + Start this background service. + + Returns: + This background service. + """ + self.start() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit an async context. + + Stop this background service. + + Args: + exc_type: The type of the exception raised, if any. + exc_val: The exception raised, if any. + exc_tb: The traceback of the exception raised, if any. + """ + await self.stop() + + async def wait(self) -> None: + """Wait this background service to finish. + + Wait until all background service tasks are finished. + + Raises: + BaseExceptionGroup: If any of the tasks spawned by this service raised an + exception (`CancelError` is not considered an error and not returned in + the exception group). + """ + # We need to account for tasks that were created between when we started + # awaiting and we finished awaiting. + while self._tasks: + done, pending = await asyncio.wait(self._tasks) + assert not pending + + # We remove the done tasks, but there might be new ones created after we + # started waiting. + self._tasks = self._tasks - done + + exceptions: list[BaseException] = [] + for task in done: + try: + # This will raise a CancelledError if the task was cancelled or any + # other exception if the task raised one. + _ = task.result() + except BaseException as error: # pylint: disable=broad-except + exceptions.append(error) + if exceptions: + raise BaseExceptionGroup( + f"Error while stopping background service {self}", exceptions + ) + + def __await__(self) -> collections.abc.Generator[None, None, None]: + """Await this background service. + + An awaited background service will wait for all its tasks to finish. + + Returns: + An implementation-specific generator for the awaitable. + """ + return self.wait().__await__() + + def __del__(self) -> None: + """Destroy this instance. + + Cancel all running tasks spawned by this background service. + """ + self.cancel("{self!r} was deleted") + + def __repr__(self) -> str: + """Return a string representation of this instance. + + Returns: + A string representation of this instance. + """ + return f"{type(self).__name__}(name={self._name!r}, tasks={self._tasks!r})" + + def __str__(self) -> str: + """Return a string representation of this instance. + + Returns: + A string representation of this instance. + """ + return f"{type(self).__name__}[{self._name}]" diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py new file mode 100644 index 0000000..f187980 --- /dev/null +++ b/tests/test_asyncio.py @@ -0,0 +1,153 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Simple test for the BaseActor.""" +import asyncio +from collections.abc import Iterator +from typing import Literal, assert_never + +import async_solipsism +import pytest + +from frequenz.core.asyncio import BackgroundService + + +# Setting 'autouse' has no effect as this method replaces the event loop for all tests in the file. +@pytest.fixture() +def event_loop() -> Iterator[async_solipsism.EventLoop]: + """Replace the loop with one that doesn't interact with the outside world.""" + loop = async_solipsism.EventLoop() + yield loop + loop.close() + + +class FakeService(BackgroundService): + """A background service that does nothing.""" + + def __init__( + self, + *, + name: str | None = None, + sleep: float | None = None, + exc: BaseException | None = None, + ) -> None: + """Initialize a new FakeService.""" + super().__init__(name=name) + self._sleep = sleep + self._exc = exc + + def start(self) -> None: + """Start this service.""" + + async def nop() -> None: + if self._sleep is not None: + await asyncio.sleep(self._sleep) + if self._exc is not None: + raise self._exc + + self._tasks.add(asyncio.create_task(nop(), name="nop")) + + +async def test_construction_defaults() -> None: + """Test the construction of a background service with default arguments.""" + fake_service = FakeService() + assert fake_service.name == str(id(fake_service)) + assert fake_service.tasks == set() + assert fake_service.is_running is False + assert str(fake_service) == f"FakeService[{fake_service.name}]" + assert repr(fake_service) == f"FakeService(name={fake_service.name!r}, tasks=set())" + + +async def test_construction_custom() -> None: + """Test the construction of a background service with a custom name.""" + fake_service = FakeService(name="test") + assert fake_service.name == "test" + assert fake_service.tasks == set() + assert fake_service.is_running is False + + +async def test_start_await() -> None: + """Test a background service starts and can be awaited.""" + fake_service = FakeService(name="test") + assert fake_service.name == "test" + assert fake_service.is_running is False + + # Is a no-op if the service is not running + await fake_service.stop() + assert fake_service.is_running is False + + fake_service.start() + assert fake_service.is_running is True + + # Should stop immediately + async with asyncio.timeout(1.0): + await fake_service + + assert fake_service.is_running is False + + +async def test_start_stop() -> None: + """Test a background service starts and stops correctly.""" + fake_service = FakeService(name="test", sleep=2.0) + assert fake_service.name == "test" + assert fake_service.is_running is False + + # Is a no-op if the service is not running + await fake_service.stop() + assert fake_service.is_running is False + + fake_service.start() + assert fake_service.is_running is True + + await asyncio.sleep(1.0) + assert fake_service.is_running is True + + await fake_service.stop() + assert fake_service.is_running is False + + await fake_service.stop() + assert fake_service.is_running is False + + +@pytest.mark.parametrize("method", ["await", "wait", "stop"]) +async def test_start_and_crash( + method: Literal["await"] | Literal["wait"] | Literal["stop"], +) -> None: + """Test a background service reports when crashing.""" + exc = RuntimeError("error") + fake_service = FakeService(name="test", exc=exc) + assert fake_service.name == "test" + assert fake_service.is_running is False + + fake_service.start() + with pytest.raises(BaseExceptionGroup) as exc_info: + match method: + case "await": + await fake_service + case "wait": + await fake_service.wait() + case "stop": + # Give the service some time to run and crash, otherwise stop() will + # cancel it before it has a chance to crash + await asyncio.sleep(1.0) + await fake_service.stop() + case _: + assert_never(method) + + rt_errors, rest_errors = exc_info.value.split(RuntimeError) + assert rt_errors is not None + assert rest_errors is None + assert len(rt_errors.exceptions) == 1 + assert rt_errors.exceptions[0] is exc + + +async def test_async_context_manager() -> None: + """Test a background service works as an async context manager.""" + async with FakeService(name="test", sleep=1.0) as fake_service: + assert fake_service.is_running is True + # Is a no-op if the service is running + fake_service.start() + await asyncio.sleep(0) + assert fake_service.is_running is True + + assert fake_service.is_running is False From 4d25d175933e6efca2f8c3dd2962edf0ea43e59a Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 14 Jun 2024 18:18:25 +0200 Subject: [PATCH 5/5] Add a `datetime` module with a `UNIX_EPOCH` constant Signed-off-by: Leandro Lucarella --- src/frequenz/core/datetime.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/frequenz/core/datetime.py diff --git a/src/frequenz/core/datetime.py b/src/frequenz/core/datetime.py new file mode 100644 index 0000000..39cbd2a --- /dev/null +++ b/src/frequenz/core/datetime.py @@ -0,0 +1,9 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Timeseries basic types.""" + +from datetime import datetime, timezone + +UNIX_EPOCH = datetime.fromtimestamp(0.0, tz=timezone.utc) +"""The UNIX epoch (in UTC)."""