From 7d351bc65487a6f993675871ad0af1be3f7ae207 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Thu, 31 Oct 2024 16:35:36 +0100 Subject: [PATCH 1/8] Refactor: Move Recurrence types in their own public module Signed-off-by: Mathias L. Baumann --- src/frequenz/client/dispatch/__main__.py | 2 +- src/frequenz/client/dispatch/_client.py | 2 +- .../client/dispatch/_internal_types.py | 2 +- src/frequenz/client/dispatch/recurrence.py | 166 ++++++++++++++++++ .../client/dispatch/test/generator.py | 3 +- src/frequenz/client/dispatch/types.py | 163 +---------------- tests/test_dispatch_cli.py | 6 +- tests/test_dispatch_types.py | 6 +- 8 files changed, 187 insertions(+), 163 deletions(-) create mode 100644 src/frequenz/client/dispatch/recurrence.py diff --git a/src/frequenz/client/dispatch/__main__.py b/src/frequenz/client/dispatch/__main__.py index 8b69526c..58671539 100644 --- a/src/frequenz/client/dispatch/__main__.py +++ b/src/frequenz/client/dispatch/__main__.py @@ -16,7 +16,7 @@ from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.shortcuts import CompleteStyle -from frequenz.client.dispatch.types import ( +from frequenz.client.dispatch.recurrence import ( EndCriteria, Frequency, RecurrenceRule, diff --git a/src/frequenz/client/dispatch/_client.py b/src/frequenz/client/dispatch/_client.py index 7126786c..b37c3c87 100644 --- a/src/frequenz/client/dispatch/_client.py +++ b/src/frequenz/client/dispatch/_client.py @@ -40,11 +40,11 @@ from frequenz.client.base.streaming import GrpcStreamBroadcaster from ._internal_types import DispatchCreateRequest +from .recurrence import RecurrenceRule from .types import ( ComponentSelector, Dispatch, DispatchEvent, - RecurrenceRule, component_selector_to_protobuf, ) diff --git a/src/frequenz/client/dispatch/_internal_types.py b/src/frequenz/client/dispatch/_internal_types.py index c3831a8b..2c3c8a72 100644 --- a/src/frequenz/client/dispatch/_internal_types.py +++ b/src/frequenz/client/dispatch/_internal_types.py @@ -18,9 +18,9 @@ from frequenz.client.base.conversion import to_datetime, to_timestamp +from .recurrence import RecurrenceRule from .types import ( ComponentSelector, - RecurrenceRule, component_selector_from_protobuf, component_selector_to_protobuf, ) diff --git a/src/frequenz/client/dispatch/recurrence.py b/src/frequenz/client/dispatch/recurrence.py new file mode 100644 index 00000000..a9fdf4df --- /dev/null +++ b/src/frequenz/client/dispatch/recurrence.py @@ -0,0 +1,166 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Types for recurrence rules.""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import IntEnum + +from dateutil import rrule + +# pylint: disable=no-name-in-module +from frequenz.api.dispatch.v1.dispatch_pb2 import RecurrenceRule as PBRecurrenceRule + +from frequenz.client.base.conversion import to_datetime, to_timestamp + +# pylint: enable=no-name-in-module + + +class Weekday(IntEnum): + """Enum representing the day of the week.""" + + UNSPECIFIED = PBRecurrenceRule.WEEKDAY_UNSPECIFIED + MONDAY = PBRecurrenceRule.WEEKDAY_MONDAY + TUESDAY = PBRecurrenceRule.WEEKDAY_TUESDAY + WEDNESDAY = PBRecurrenceRule.WEEKDAY_WEDNESDAY + THURSDAY = PBRecurrenceRule.WEEKDAY_THURSDAY + FRIDAY = PBRecurrenceRule.WEEKDAY_FRIDAY + SATURDAY = PBRecurrenceRule.WEEKDAY_SATURDAY + SUNDAY = PBRecurrenceRule.WEEKDAY_SUNDAY + + +class Frequency(IntEnum): + """Enum representing the frequency of the recurrence.""" + + UNSPECIFIED = PBRecurrenceRule.FREQUENCY_UNSPECIFIED + MINUTELY = PBRecurrenceRule.FREQUENCY_MINUTELY + HOURLY = PBRecurrenceRule.FREQUENCY_HOURLY + DAILY = PBRecurrenceRule.FREQUENCY_DAILY + WEEKLY = PBRecurrenceRule.FREQUENCY_WEEKLY + MONTHLY = PBRecurrenceRule.FREQUENCY_MONTHLY + YEARLY = PBRecurrenceRule.FREQUENCY_YEARLY + + +@dataclass(kw_only=True) +class EndCriteria: + """Controls when a recurring dispatch should end.""" + + count: int | None = None + """The number of times this dispatch should recur.""" + until: datetime | None = None + """The end time of this dispatch in UTC.""" + + @classmethod + def from_protobuf(cls, pb_criteria: PBRecurrenceRule.EndCriteria) -> "EndCriteria": + """Convert a protobuf end criteria to an end criteria. + + Args: + pb_criteria: The protobuf end criteria to convert. + + Returns: + The converted end criteria. + """ + instance = cls() + + match pb_criteria.WhichOneof("count_or_until"): + case "count": + instance.count = pb_criteria.count + case "until": + instance.until = to_datetime(pb_criteria.until) + return instance + + def to_protobuf(self) -> PBRecurrenceRule.EndCriteria: + """Convert an end criteria to a protobuf end criteria. + + Returns: + The converted protobuf end criteria. + """ + pb_criteria = PBRecurrenceRule.EndCriteria() + + if self.count is not None: + pb_criteria.count = self.count + elif self.until is not None: + pb_criteria.until.CopyFrom(to_timestamp(self.until)) + + return pb_criteria + + +# pylint: disable=too-many-instance-attributes +@dataclass(kw_only=True) +class RecurrenceRule: + """Ruleset governing when and how a dispatch should re-occur. + + Attributes follow the iCalendar specification (RFC5545) for recurrence rules. + """ + + frequency: Frequency = Frequency.UNSPECIFIED + """The frequency specifier of this recurring dispatch.""" + + interval: int = 0 + """How often this dispatch should recur, based on the frequency.""" + + end_criteria: EndCriteria | None = None + """When this dispatch should end. + + Can recur a fixed number of times or until a given timestamp.""" + + byminutes: list[int] = field(default_factory=list) + """On which minute(s) of the hour the event occurs.""" + + byhours: list[int] = field(default_factory=list) + """On which hour(s) of the day the event occurs.""" + + byweekdays: list[Weekday] = field(default_factory=list) + """On which day(s) of the week the event occurs.""" + + bymonthdays: list[int] = field(default_factory=list) + """On which day(s) of the month the event occurs.""" + + bymonths: list[int] = field(default_factory=list) + """On which month(s) of the year the event occurs.""" + + @classmethod + def from_protobuf(cls, pb_rule: PBRecurrenceRule) -> "RecurrenceRule": + """Convert a protobuf recurrence rule to a recurrence rule. + + Args: + pb_rule: The protobuf recurrence rule to convert. + + Returns: + The converted recurrence rule. + """ + return RecurrenceRule( + frequency=Frequency(pb_rule.freq), + interval=pb_rule.interval, + end_criteria=( + EndCriteria.from_protobuf(pb_rule.end_criteria) + if pb_rule.HasField("end_criteria") + else None + ), + byminutes=list(pb_rule.byminutes), + byhours=list(pb_rule.byhours), + byweekdays=[Weekday(day) for day in pb_rule.byweekdays], + bymonthdays=list(pb_rule.bymonthdays), + bymonths=list(pb_rule.bymonths), + ) + + def to_protobuf(self) -> PBRecurrenceRule: + """Convert a recurrence rule to a protobuf recurrence rule. + + Returns: + The converted protobuf recurrence rule. + """ + pb_rule = PBRecurrenceRule() + + pb_rule.freq = self.frequency.value + pb_rule.interval = self.interval + if self.end_criteria is not None: + pb_rule.end_criteria.CopyFrom(self.end_criteria.to_protobuf()) + pb_rule.byminutes.extend(self.byminutes) + pb_rule.byhours.extend(self.byhours) + pb_rule.byweekdays.extend([day.value for day in self.byweekdays]) + pb_rule.bymonthdays.extend(self.bymonthdays) + pb_rule.bymonths.extend(self.bymonths) + + return pb_rule diff --git a/src/frequenz/client/dispatch/test/generator.py b/src/frequenz/client/dispatch/test/generator.py index 68862bf6..636990a1 100644 --- a/src/frequenz/client/dispatch/test/generator.py +++ b/src/frequenz/client/dispatch/test/generator.py @@ -9,7 +9,8 @@ from frequenz.client.common.microgrid.components import ComponentCategory from .._internal_types import rounded_start_time -from ..types import Dispatch, EndCriteria, Frequency, RecurrenceRule, Weekday +from ..recurrence import EndCriteria, Frequency, RecurrenceRule, Weekday +from ..types import Dispatch class DispatchGenerator: diff --git a/src/frequenz/client/dispatch/types.py b/src/frequenz/client/dispatch/types.py index e19976c2..e5ee5953 100644 --- a/src/frequenz/client/dispatch/types.py +++ b/src/frequenz/client/dispatch/types.py @@ -4,7 +4,7 @@ """Type wrappers for the generated protobuf messages.""" -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime, timedelta from enum import IntEnum from typing import Any, cast @@ -14,9 +14,11 @@ ComponentSelector as PBComponentSelector, ) from frequenz.api.dispatch.v1.dispatch_pb2 import Dispatch as PBDispatch -from frequenz.api.dispatch.v1.dispatch_pb2 import DispatchData, DispatchMetadata -from frequenz.api.dispatch.v1.dispatch_pb2 import RecurrenceRule as PBRecurrenceRule -from frequenz.api.dispatch.v1.dispatch_pb2 import StreamMicrogridDispatchesResponse +from frequenz.api.dispatch.v1.dispatch_pb2 import ( + DispatchData, + DispatchMetadata, + StreamMicrogridDispatchesResponse, +) from google.protobuf.json_format import MessageToDict from google.protobuf.struct_pb2 import Struct @@ -25,6 +27,8 @@ # pylint: enable=no-name-in-module from frequenz.client.common.microgrid.components import ComponentCategory +from .recurrence import RecurrenceRule + ComponentSelector = list[int] | list[ComponentCategory] """A component selector specifying which components a dispatch targets. @@ -94,155 +98,6 @@ def component_selector_to_protobuf( return pb_selector -class Weekday(IntEnum): - """Enum representing the day of the week.""" - - UNSPECIFIED = PBRecurrenceRule.WEEKDAY_UNSPECIFIED - MONDAY = PBRecurrenceRule.WEEKDAY_MONDAY - TUESDAY = PBRecurrenceRule.WEEKDAY_TUESDAY - WEDNESDAY = PBRecurrenceRule.WEEKDAY_WEDNESDAY - THURSDAY = PBRecurrenceRule.WEEKDAY_THURSDAY - FRIDAY = PBRecurrenceRule.WEEKDAY_FRIDAY - SATURDAY = PBRecurrenceRule.WEEKDAY_SATURDAY - SUNDAY = PBRecurrenceRule.WEEKDAY_SUNDAY - - -class Frequency(IntEnum): - """Enum representing the frequency of the recurrence.""" - - UNSPECIFIED = PBRecurrenceRule.FREQUENCY_UNSPECIFIED - MINUTELY = PBRecurrenceRule.FREQUENCY_MINUTELY - HOURLY = PBRecurrenceRule.FREQUENCY_HOURLY - DAILY = PBRecurrenceRule.FREQUENCY_DAILY - WEEKLY = PBRecurrenceRule.FREQUENCY_WEEKLY - MONTHLY = PBRecurrenceRule.FREQUENCY_MONTHLY - YEARLY = PBRecurrenceRule.FREQUENCY_YEARLY - - -@dataclass(kw_only=True) -class EndCriteria: - """Controls when a recurring dispatch should end.""" - - count: int | None = None - """The number of times this dispatch should recur.""" - until: datetime | None = None - """The end time of this dispatch in UTC.""" - - @classmethod - def from_protobuf(cls, pb_criteria: PBRecurrenceRule.EndCriteria) -> "EndCriteria": - """Convert a protobuf end criteria to an end criteria. - - Args: - pb_criteria: The protobuf end criteria to convert. - - Returns: - The converted end criteria. - """ - instance = cls() - - match pb_criteria.WhichOneof("count_or_until"): - case "count": - instance.count = pb_criteria.count - case "until": - instance.until = to_datetime(pb_criteria.until) - return instance - - def to_protobuf(self) -> PBRecurrenceRule.EndCriteria: - """Convert an end criteria to a protobuf end criteria. - - Returns: - The converted protobuf end criteria. - """ - pb_criteria = PBRecurrenceRule.EndCriteria() - - if self.count is not None: - pb_criteria.count = self.count - elif self.until is not None: - pb_criteria.until.CopyFrom(to_timestamp(self.until)) - - return pb_criteria - - -# pylint: disable=too-many-instance-attributes -@dataclass(kw_only=True) -class RecurrenceRule: - """Ruleset governing when and how a dispatch should re-occur. - - Attributes follow the iCalendar specification (RFC5545) for recurrence rules. - """ - - frequency: Frequency = Frequency.UNSPECIFIED - """The frequency specifier of this recurring dispatch.""" - - interval: int = 0 - """How often this dispatch should recur, based on the frequency.""" - - end_criteria: EndCriteria | None = None - """When this dispatch should end. - - Can recur a fixed number of times or until a given timestamp.""" - - byminutes: list[int] = field(default_factory=list) - """On which minute(s) of the hour the event occurs.""" - - byhours: list[int] = field(default_factory=list) - """On which hour(s) of the day the event occurs.""" - - byweekdays: list[Weekday] = field(default_factory=list) - """On which day(s) of the week the event occurs.""" - - bymonthdays: list[int] = field(default_factory=list) - """On which day(s) of the month the event occurs.""" - - bymonths: list[int] = field(default_factory=list) - """On which month(s) of the year the event occurs.""" - - @classmethod - def from_protobuf(cls, pb_rule: PBRecurrenceRule) -> "RecurrenceRule": - """Convert a protobuf recurrence rule to a recurrence rule. - - Args: - pb_rule: The protobuf recurrence rule to convert. - - Returns: - The converted recurrence rule. - """ - return RecurrenceRule( - frequency=Frequency(pb_rule.freq), - interval=pb_rule.interval, - end_criteria=( - EndCriteria.from_protobuf(pb_rule.end_criteria) - if pb_rule.HasField("end_criteria") - else None - ), - byminutes=list(pb_rule.byminutes), - byhours=list(pb_rule.byhours), - byweekdays=[Weekday(day) for day in pb_rule.byweekdays], - bymonthdays=list(pb_rule.bymonthdays), - bymonths=list(pb_rule.bymonths), - ) - - def to_protobuf(self) -> PBRecurrenceRule: - """Convert a recurrence rule to a protobuf recurrence rule. - - Returns: - The converted protobuf recurrence rule. - """ - pb_rule = PBRecurrenceRule() - - pb_rule.freq = self.frequency.value - pb_rule.interval = self.interval - if self.end_criteria is not None: - pb_rule.end_criteria.CopyFrom(self.end_criteria.to_protobuf()) - pb_rule.byminutes.extend(self.byminutes) - pb_rule.byhours.extend(self.byhours) - pb_rule.byweekdays.extend([day.value for day in self.byweekdays]) - pb_rule.bymonthdays.extend(self.bymonthdays) - pb_rule.bymonths.extend(self.bymonths) - - return pb_rule - - @dataclass(frozen=True, kw_only=True) class TimeIntervalFilter: """Filter for a time interval.""" @@ -261,7 +116,7 @@ class TimeIntervalFilter: @dataclass(kw_only=True, frozen=True) -class Dispatch: +class Dispatch: # pylint: disable=too-many-instance-attributes """Represents a dispatch operation within a microgrid system.""" id: int diff --git a/tests/test_dispatch_cli.py b/tests/test_dispatch_cli.py index 92e67b32..3fdbcfc6 100644 --- a/tests/test_dispatch_cli.py +++ b/tests/test_dispatch_cli.py @@ -14,14 +14,14 @@ from frequenz.client.common.microgrid.components import ComponentCategory from frequenz.client.dispatch.__main__ import cli -from frequenz.client.dispatch.test.client import ALL_KEY, FakeClient -from frequenz.client.dispatch.types import ( - Dispatch, +from frequenz.client.dispatch.recurrence import ( EndCriteria, Frequency, RecurrenceRule, Weekday, ) +from frequenz.client.dispatch.test.client import ALL_KEY, FakeClient +from frequenz.client.dispatch.types import Dispatch TEST_NOW = datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc) """Arbitrary time used as NOW for testing.""" diff --git a/tests/test_dispatch_types.py b/tests/test_dispatch_types.py index 1da99f0e..1738f68e 100644 --- a/tests/test_dispatch_types.py +++ b/tests/test_dispatch_types.py @@ -7,12 +7,14 @@ from frequenz.client.common.microgrid.components import ComponentCategory from frequenz.client.dispatch._internal_types import DispatchCreateRequest -from frequenz.client.dispatch.types import ( - Dispatch, +from frequenz.client.dispatch.recurrence import ( EndCriteria, Frequency, RecurrenceRule, Weekday, +) +from frequenz.client.dispatch.types import ( + Dispatch, component_selector_from_protobuf, component_selector_to_protobuf, ) From 456570cd095403e97f5f0ccc025d3e3c2b9ff153 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Thu, 31 Oct 2024 16:46:18 +0100 Subject: [PATCH 2/8] Add rrule prepare() support This moves some functionality from the high-level interface to the client. Signed-off-by: Mathias L. Baumann --- pyproject.toml | 1 + src/frequenz/client/dispatch/recurrence.py | 57 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 82f35d7a..edef83f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ dev-mypy = [ "frequenz-client-dispatch[cli,dev-mkdocs,dev-noxfile,dev-pytest]", "grpc-stubs == 1.53.0.5", "types-protobuf == 5.28.3.20241030", + "types-python-dateutil == 2.9.0.20241003", ] dev-noxfile = ["nox == 2024.10.9", "frequenz-repo-config[lib] == 0.10.0"] dev-pylint = [ diff --git a/src/frequenz/client/dispatch/recurrence.py b/src/frequenz/client/dispatch/recurrence.py index a9fdf4df..88c5f69d 100644 --- a/src/frequenz/client/dispatch/recurrence.py +++ b/src/frequenz/client/dispatch/recurrence.py @@ -42,6 +42,27 @@ class Frequency(IntEnum): YEARLY = PBRecurrenceRule.FREQUENCY_YEARLY +_RRULE_FREQ_MAP = { + Frequency.MINUTELY: rrule.MINUTELY, + Frequency.HOURLY: rrule.HOURLY, + Frequency.DAILY: rrule.DAILY, + Frequency.WEEKLY: rrule.WEEKLY, + Frequency.MONTHLY: rrule.MONTHLY, +} +"""To map from our Frequency enum to the dateutil library enum.""" + +_RRULE_WEEKDAY_MAP = { + Weekday.MONDAY: rrule.MO, + Weekday.TUESDAY: rrule.TU, + Weekday.WEDNESDAY: rrule.WE, + Weekday.THURSDAY: rrule.TH, + Weekday.FRIDAY: rrule.FR, + Weekday.SATURDAY: rrule.SA, + Weekday.SUNDAY: rrule.SU, +} +"""To map from our Weekday enum to the dateutil library enum.""" + + @dataclass(kw_only=True) class EndCriteria: """Controls when a recurring dispatch should end.""" @@ -164,3 +185,39 @@ def to_protobuf(self) -> PBRecurrenceRule: pb_rule.bymonths.extend(self.bymonths) return pb_rule + + def prepare(self, start_time: datetime) -> rrule.rrule: + """Prepare the rrule object. + + Args: + start_time: The start time of the dispatch. + + Returns: + The rrule object. + + Raises: + ValueError: If the interval is 0. + """ + if self.interval == 0: + raise ValueError("Interval must be greater than 0") + + count, until = (None, None) + if end := self.end_criteria: + count = end.count + until = end.until + + rrule_obj = rrule.rrule( + freq=_RRULE_FREQ_MAP[self.frequency], + dtstart=start_time, + count=count, + until=until, + byminute=self.byminutes or None, + byhour=self.byhours or None, + byweekday=[_RRULE_WEEKDAY_MAP[weekday] for weekday in self.byweekdays] + or None, + bymonthday=self.bymonthdays or None, + bymonth=self.bymonths or None, + interval=self.interval, + ) + + return rrule_obj From 4a1e248e890e3006bcdf67a95e9a3daa4d7a9194 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Thu, 31 Oct 2024 16:48:24 +0100 Subject: [PATCH 3/8] Make convert function internal Signed-off-by: Mathias L. Baumann --- src/frequenz/client/dispatch/_client.py | 6 +++--- src/frequenz/client/dispatch/_internal_types.py | 10 ++++++---- src/frequenz/client/dispatch/types.py | 8 ++++---- tests/test_dispatch_types.py | 8 ++++---- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/frequenz/client/dispatch/_client.py b/src/frequenz/client/dispatch/_client.py index b37c3c87..bb86b859 100644 --- a/src/frequenz/client/dispatch/_client.py +++ b/src/frequenz/client/dispatch/_client.py @@ -45,7 +45,7 @@ ComponentSelector, Dispatch, DispatchEvent, - component_selector_to_protobuf, + _component_selector_to_protobuf, ) # pylint: enable=no-name-in-module @@ -166,7 +166,7 @@ def to_interval( # Setup parameters start_time_interval = to_interval(start_from, start_to) end_time_interval = to_interval(end_from, end_to) - selectors = list(map(component_selector_to_protobuf, component_selectors)) + selectors = list(map(_component_selector_to_protobuf, component_selectors)) filters = DispatchFilter( selectors=selectors, start_time_interval=start_time_interval, @@ -354,7 +354,7 @@ async def update( else: msg.update.duration = round(val.total_seconds()) case "selector": - msg.update.selector.CopyFrom(component_selector_to_protobuf(val)) + msg.update.selector.CopyFrom(_component_selector_to_protobuf(val)) case "is_active": msg.update.is_active = val case "payload": diff --git a/src/frequenz/client/dispatch/_internal_types.py b/src/frequenz/client/dispatch/_internal_types.py index 2c3c8a72..4922df06 100644 --- a/src/frequenz/client/dispatch/_internal_types.py +++ b/src/frequenz/client/dispatch/_internal_types.py @@ -21,8 +21,8 @@ from .recurrence import RecurrenceRule from .types import ( ComponentSelector, - component_selector_from_protobuf, - component_selector_to_protobuf, + _component_selector_from_protobuf, + _component_selector_to_protobuf, ) # pylint: enable=no-name-in-module @@ -97,7 +97,9 @@ def from_protobuf( to_datetime(pb_object.dispatch_data.start_time) ), duration=duration, - selector=component_selector_from_protobuf(pb_object.dispatch_data.selector), + selector=_component_selector_from_protobuf( + pb_object.dispatch_data.selector + ), active=pb_object.dispatch_data.is_active, dry_run=pb_object.dispatch_data.is_dry_run, payload=MessageToDict(pb_object.dispatch_data.payload), @@ -121,7 +123,7 @@ def to_protobuf(self) -> PBDispatchCreateRequest: duration=( round(self.duration.total_seconds()) if self.duration else None ), - selector=component_selector_to_protobuf(self.selector), + selector=_component_selector_to_protobuf(self.selector), is_active=self.active, is_dry_run=self.dry_run, payload=payload, diff --git a/src/frequenz/client/dispatch/types.py b/src/frequenz/client/dispatch/types.py index e5ee5953..4ebf9ec7 100644 --- a/src/frequenz/client/dispatch/types.py +++ b/src/frequenz/client/dispatch/types.py @@ -36,7 +36,7 @@ """ -def component_selector_from_protobuf( +def _component_selector_from_protobuf( pb_selector: PBComponentSelector, ) -> ComponentSelector: """Convert a protobuf component selector to a component selector. @@ -66,7 +66,7 @@ def component_selector_from_protobuf( raise ValueError("Invalid component selector") -def component_selector_to_protobuf( +def _component_selector_to_protobuf( selector: ComponentSelector, ) -> PBComponentSelector: """Convert a component selector to a protobuf component selector. @@ -181,7 +181,7 @@ def from_protobuf(cls, pb_object: PBDispatch) -> "Dispatch": if pb_object.data.duration else None ), - selector=component_selector_from_protobuf(pb_object.data.selector), + selector=_component_selector_from_protobuf(pb_object.data.selector), active=pb_object.data.is_active, dry_run=pb_object.data.is_dry_run, payload=MessageToDict(pb_object.data.payload), @@ -209,7 +209,7 @@ def to_protobuf(self) -> PBDispatch: duration=( round(self.duration.total_seconds()) if self.duration else None ), - selector=component_selector_to_protobuf(self.selector), + selector=_component_selector_to_protobuf(self.selector), is_active=self.active, is_dry_run=self.dry_run, payload=payload, diff --git a/tests/test_dispatch_types.py b/tests/test_dispatch_types.py index 1738f68e..d6a33599 100644 --- a/tests/test_dispatch_types.py +++ b/tests/test_dispatch_types.py @@ -15,8 +15,8 @@ ) from frequenz.client.dispatch.types import ( Dispatch, - component_selector_from_protobuf, - component_selector_to_protobuf, + _component_selector_from_protobuf, + _component_selector_to_protobuf, ) @@ -30,8 +30,8 @@ def test_component_selector() -> None: [ComponentCategory.METER], [ComponentCategory.EV_CHARGER, ComponentCategory.BATTERY], ): - protobuf = component_selector_to_protobuf(selector) - assert component_selector_from_protobuf(protobuf) == selector + protobuf = _component_selector_to_protobuf(selector) + assert _component_selector_from_protobuf(protobuf) == selector def test_end_criteria() -> None: From 377a20bfe51d7ca55e4ee0dc2f6305ef8fe83c23 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Mon, 4 Nov 2024 13:00:58 +0100 Subject: [PATCH 4/8] Rename test files "dispatch" is redundant in this context. Signed-off-by: Mathias L. Baumann --- tests/{test_dispatch_cli.py => test_cli.py} | 0 tests/{test_dispatch_client.py => test_client.py} | 0 tests/{test_dispatch_types.py => test_proto.py} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename tests/{test_dispatch_cli.py => test_cli.py} (100%) rename tests/{test_dispatch_client.py => test_client.py} (100%) rename tests/{test_dispatch_types.py => test_proto.py} (100%) diff --git a/tests/test_dispatch_cli.py b/tests/test_cli.py similarity index 100% rename from tests/test_dispatch_cli.py rename to tests/test_cli.py diff --git a/tests/test_dispatch_client.py b/tests/test_client.py similarity index 100% rename from tests/test_dispatch_client.py rename to tests/test_client.py diff --git a/tests/test_dispatch_types.py b/tests/test_proto.py similarity index 100% rename from tests/test_dispatch_types.py rename to tests/test_proto.py From 677fbbc655a50beed8ef135691e93bd3ff8daa83 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Tue, 5 Nov 2024 10:06:09 +0100 Subject: [PATCH 5/8] Dispatch: Add running, until and next_run methods Signed-off-by: Mathias L. Baumann --- RELEASE_NOTES.md | 1 + src/frequenz/client/dispatch/types.py | 135 +++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8c48b285..6997403b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,6 +3,7 @@ ## New Features * Update BaseApiClient to get the http2 keepalive feature. +* Some Methods from the high-level API have been moved to this repo: The dispatch class now offers: `until`, `running`, `next_run` and `next_run_after`. ## Bug Fixes diff --git a/src/frequenz/client/dispatch/types.py b/src/frequenz/client/dispatch/types.py index 4ebf9ec7..b85b6206 100644 --- a/src/frequenz/client/dispatch/types.py +++ b/src/frequenz/client/dispatch/types.py @@ -5,8 +5,8 @@ from dataclasses import dataclass -from datetime import datetime, timedelta -from enum import IntEnum +from datetime import datetime, timedelta, timezone +from enum import Enum, IntEnum from typing import Any, cast # pylint: disable=no-name-in-module @@ -27,7 +27,7 @@ # pylint: enable=no-name-in-module from frequenz.client.common.microgrid.components import ComponentCategory -from .recurrence import RecurrenceRule +from .recurrence import Frequency, RecurrenceRule, Weekday ComponentSelector = list[int] | list[ComponentCategory] """A component selector specifying which components a dispatch targets. @@ -115,6 +115,19 @@ class TimeIntervalFilter: """Filter by end_time < end_to.""" +class RunningState(Enum): + """The running state of a dispatch.""" + + RUNNING = "RUNNING" + """The dispatch is running.""" + + STOPPED = "STOPPED" + """The dispatch is stopped.""" + + DIFFERENT_TYPE = "DIFFERENT_TYPE" + """The dispatch is for a different type.""" + + @dataclass(kw_only=True, frozen=True) class Dispatch: # pylint: disable=too-many-instance-attributes """Represents a dispatch operation within a microgrid system.""" @@ -160,6 +173,122 @@ class Dispatch: # pylint: disable=too-many-instance-attributes update_time: datetime """The last update time of the dispatch in UTC. Set when a dispatch is modified.""" + def running(self, type_: str) -> RunningState: + """Check if the dispatch is currently supposed to be running. + + Args: + type_: The type of the dispatch that should be running. + + Returns: + RUNNING if the dispatch is running, + STOPPED if it is stopped, + DIFFERENT_TYPE if it is for a different type. + """ + if self.type != type_: + return RunningState.DIFFERENT_TYPE + + if not self.active: + return RunningState.STOPPED + + now = datetime.now(tz=timezone.utc) + + if now < self.start_time: + return RunningState.STOPPED + + # A dispatch without duration is always running, once it started + if self.duration is None: + return RunningState.RUNNING + + if until := self._until(now): + return RunningState.RUNNING if now < until else RunningState.STOPPED + + return RunningState.STOPPED + + @property + def until(self) -> datetime | None: + """Time when the dispatch should end. + + Returns the time that a running dispatch should end. + If the dispatch is not running, None is returned. + + Returns: + The time when the dispatch should end or None if the dispatch is not running. + """ + if not self.active: + return None + + now = datetime.now(tz=timezone.utc) + return self._until(now) + + @property + def next_run(self) -> datetime | None: + """Calculate the next run of a dispatch. + + Returns: + The next run of the dispatch or None if the dispatch is finished. + """ + return self.next_run_after(datetime.now(tz=timezone.utc)) + + def next_run_after(self, after: datetime) -> datetime | None: + """Calculate the next run of a dispatch. + + Args: + after: The time to calculate the next run from. + + Returns: + The next run of the dispatch or None if the dispatch is finished. + """ + if ( + not self.recurrence.frequency + or self.recurrence.frequency == Frequency.UNSPECIFIED + or self.duration is None # Infinite duration + ): + if after > self.start_time: + return None + return self.start_time + + # Make sure no weekday is UNSPECIFIED + if Weekday.UNSPECIFIED in self.recurrence.byweekdays: + return None + + # No type information for rrule, so we need to cast + return cast( + datetime | None, + self.recurrence.prepare(self.start_time).after(after, inc=True), + ) + + def _until(self, now: datetime) -> datetime | None: + """Calculate the time when the dispatch should end. + + If no previous run is found, None is returned. + + Args: + now: The current time. + + Returns: + The time when the dispatch should end or None if the dispatch is not running. + + Raises: + ValueError: If the dispatch has no duration. + """ + if self.duration is None: + raise ValueError("_until: Dispatch has no duration") + + if ( + not self.recurrence.frequency + or self.recurrence.frequency == Frequency.UNSPECIFIED + ): + return self.start_time + self.duration + + latest_past_start: datetime | None = self.recurrence.prepare( + self.start_time + ).before(now, inc=True) + + if not latest_past_start: + return None + + return latest_past_start + self.duration + @classmethod def from_protobuf(cls, pb_object: PBDispatch) -> "Dispatch": """Convert a protobuf dispatch to a dispatch. From 4e000784bb720542f2cb5fa93e587e5098aa78de Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Tue, 5 Nov 2024 10:12:42 +0100 Subject: [PATCH 6/8] Add tests for newly added functions Signed-off-by: Mathias L. Baumann --- pyproject.toml | 1 + tests/test_dispatch.py | 277 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 tests/test_dispatch.py diff --git a/pyproject.toml b/pyproject.toml index edef83f5..b15aa488 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ dev-pytest = [ "pytest-mock == 3.14.0", "pytest-asyncio == 0.24.0", "async-solipsism == 0.7", + "time-machine == 2.15.0", "hypothesis == 6.116.0", "frequenz-client-dispatch[cli]", ] diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py new file mode 100644 index 00000000..0fa2e173 --- /dev/null +++ b/tests/test_dispatch.py @@ -0,0 +1,277 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Dispatch class methods using pytest parametrization.""" + +from dataclasses import replace +from datetime import datetime, timedelta, timezone + +import pytest +import time_machine + +from frequenz.client.common.microgrid.components import ComponentCategory +from frequenz.client.dispatch.recurrence import Frequency, RecurrenceRule, Weekday +from frequenz.client.dispatch.types import Dispatch, RunningState + +# Define a fixed current time for testing to avoid issues with datetime.now() +CURRENT_TIME = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + +@pytest.fixture +def dispatch_base() -> Dispatch: + """Fixture to create a base Dispatch instance.""" + return Dispatch( + id=1, + type="TypeA", + start_time=CURRENT_TIME, + duration=timedelta(minutes=20), + selector=[ComponentCategory.BATTERY], + active=True, + dry_run=False, + payload={}, + recurrence=RecurrenceRule(), + create_time=CURRENT_TIME - timedelta(hours=1), + update_time=CURRENT_TIME - timedelta(minutes=30), + ) + + +@time_machine.travel(CURRENT_TIME) +@pytest.mark.parametrize( + "dispatch_type, requested_type, active, start_time_offset, duration, expected_state", + [ + # Dispatch type does not match the requested type + ( + "TypeA", + "TypeB", + True, + timedelta(minutes=-10), + timedelta(minutes=20), + RunningState.DIFFERENT_TYPE, + ), + # Dispatch is inactive + ( + "TypeA1", + "TypeA1", + False, + timedelta(minutes=-10), + timedelta(minutes=20), + RunningState.STOPPED, + ), + # Current time is before the start time + ( + "TypeA2", + "TypeA2", + True, + timedelta(minutes=10), + timedelta(minutes=20), + RunningState.STOPPED, + ), + # Dispatch with infinite duration + ( + "TypeA3", + "TypeA3", + True, + timedelta(minutes=-10), + None, + RunningState.RUNNING, + ), + # Dispatch is currently running + ( + "TypeA4", + "TypeA4", + True, + timedelta(minutes=-10), + timedelta(minutes=20), + RunningState.RUNNING, + ), + # Dispatch duration has passed + ( + "TypeA5", + "TypeA5", + True, + timedelta(minutes=-30), + timedelta(minutes=20), + RunningState.STOPPED, + ), + ], +) +# pylint: disable=too-many-arguments,too-many-positional-arguments +def test_dispatch_running( + dispatch_base: Dispatch, + dispatch_type: str, + requested_type: str, + active: bool, + start_time_offset: timedelta, + duration: timedelta | None, + expected_state: RunningState, +) -> None: + """Test the running method of the Dispatch class.""" + dispatch = replace( + dispatch_base, + type=dispatch_type, + start_time=CURRENT_TIME + start_time_offset, + duration=duration, + active=active, + ) + + assert dispatch.running(requested_type) == expected_state + + +@time_machine.travel(CURRENT_TIME) +@pytest.mark.parametrize( + "active, duration, start_time_offset, expected_until_offset", + [ + # Dispatch is inactive + (False, timedelta(minutes=20), timedelta(minutes=-10), None), + # Dispatch with infinite duration (no duration) + (True, None, timedelta(minutes=-10), None), + # Current time is before the start time + (True, timedelta(minutes=20), timedelta(minutes=10), timedelta(minutes=30)), + # Dispatch is currently running + ( + True, + timedelta(minutes=20), + timedelta(minutes=-10), + timedelta(minutes=10), + ), + ], +) +def test_dispatch_until( + dispatch_base: Dispatch, + active: bool, + duration: timedelta | None, + start_time_offset: timedelta, + expected_until_offset: timedelta | None, +) -> None: + """Test the until property of the Dispatch class.""" + start_time = CURRENT_TIME + start_time_offset + dispatch = replace( + dispatch_base, + active=active, + duration=duration, + start_time=start_time, + ) + + if duration is None: + with pytest.raises(ValueError): + _ = dispatch.until + return + + expected_until = ( + CURRENT_TIME + expected_until_offset + if expected_until_offset is not None + else None + ) + + assert dispatch.until == expected_until + + +@time_machine.travel(CURRENT_TIME) +@pytest.mark.parametrize( + "recurrence, duration, start_time_offset, expected_next_run_offset", + [ + # No recurrence and start time in the past + (RecurrenceRule(), timedelta(minutes=20), timedelta(minutes=-10), None), + # No recurrence and start time in the future + ( + RecurrenceRule(), + timedelta(minutes=20), + timedelta(minutes=10), + timedelta(minutes=10), + ), + # Daily recurrence + ( + RecurrenceRule(frequency=Frequency.DAILY, interval=1), + timedelta(minutes=20), + timedelta(minutes=-10), + timedelta(days=1, minutes=-10), + ), + # Weekly recurrence on Monday + ( + RecurrenceRule( + frequency=Frequency.WEEKLY, byweekdays=[Weekday.MONDAY], interval=1 + ), + timedelta(minutes=20), + timedelta(minutes=-10), + None, # We'll compute expected_next_run inside the test + ), + ], +) +def test_dispatch_next_run( + dispatch_base: Dispatch, + recurrence: RecurrenceRule, + duration: timedelta | None, + start_time_offset: timedelta, + expected_next_run_offset: timedelta | None, +) -> None: + """Test the next_run property of the Dispatch class.""" + start_time = CURRENT_TIME + start_time_offset + dispatch = replace( + dispatch_base, + start_time=start_time, + duration=duration, + recurrence=recurrence, + ) + + if recurrence.frequency == Frequency.WEEKLY: + # Compute the next run based on the recurrence rule + expected_next_run = recurrence.prepare(start_time).after( + CURRENT_TIME, inc=False + ) + elif expected_next_run_offset is not None: + expected_next_run = CURRENT_TIME + expected_next_run_offset + else: + expected_next_run = None + + assert dispatch.next_run == expected_next_run + + +@time_machine.travel(CURRENT_TIME) +@pytest.mark.parametrize( + "after_offset, recurrence, duration, expected_next_run_after_offset", + [ + # No recurrence + (timedelta(minutes=10), RecurrenceRule(), timedelta(minutes=20), None), + # Weekly recurrence, after current time + ( + timedelta(days=2), + RecurrenceRule( + frequency=Frequency.WEEKLY, byweekdays=[Weekday.MONDAY], interval=1 + ), + timedelta(minutes=20), + None, # We'll compute expected_next_run_after inside the test + ), + # Daily recurrence + ( + timedelta(minutes=10), + RecurrenceRule(frequency=Frequency.DAILY, interval=1), + timedelta(minutes=20), + timedelta(days=1), + ), + ], +) +def test_dispatch_next_run_after( + dispatch_base: Dispatch, + after_offset: timedelta, + recurrence: RecurrenceRule, + duration: timedelta | None, + expected_next_run_after_offset: timedelta | None, +) -> None: + """Test the next_run_after method of the Dispatch class.""" + after = CURRENT_TIME + after_offset + dispatch = replace( + dispatch_base, + recurrence=recurrence, + duration=duration, + ) + + if recurrence.frequency == Frequency.WEEKLY: + expected_next_run_after = recurrence.prepare(dispatch.start_time).after( + after, inc=True + ) + elif expected_next_run_after_offset is not None: + expected_next_run_after = CURRENT_TIME + expected_next_run_after_offset + else: + expected_next_run_after = None + + assert dispatch.next_run_after(after) == expected_next_run_after From 7251a66120f8a5b72154290f8645d9f5c8329e3d Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Tue, 5 Nov 2024 18:43:58 +0100 Subject: [PATCH 7/8] Rename RecurrenceRule.prepare to _as_rrule Signed-off-by: Mathias L. Baumann --- src/frequenz/client/dispatch/recurrence.py | 2 +- src/frequenz/client/dispatch/types.py | 12 ++++++++---- tests/test_dispatch.py | 12 +++++++----- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/frequenz/client/dispatch/recurrence.py b/src/frequenz/client/dispatch/recurrence.py index 88c5f69d..8460669a 100644 --- a/src/frequenz/client/dispatch/recurrence.py +++ b/src/frequenz/client/dispatch/recurrence.py @@ -186,7 +186,7 @@ def to_protobuf(self) -> PBRecurrenceRule: return pb_rule - def prepare(self, start_time: datetime) -> rrule.rrule: + def _as_rrule(self, start_time: datetime) -> rrule.rrule: """Prepare the rrule object. Args: diff --git a/src/frequenz/client/dispatch/types.py b/src/frequenz/client/dispatch/types.py index b85b6206..28dfa60e 100644 --- a/src/frequenz/client/dispatch/types.py +++ b/src/frequenz/client/dispatch/types.py @@ -254,7 +254,9 @@ def next_run_after(self, after: datetime) -> datetime | None: # No type information for rrule, so we need to cast return cast( datetime | None, - self.recurrence.prepare(self.start_time).after(after, inc=True), + self.recurrence._as_rrule( # pylint: disable=protected-access + self.start_time + ).after(after, inc=True), ) def _until(self, now: datetime) -> datetime | None: @@ -280,9 +282,11 @@ def _until(self, now: datetime) -> datetime | None: ): return self.start_time + self.duration - latest_past_start: datetime | None = self.recurrence.prepare( - self.start_time - ).before(now, inc=True) + latest_past_start: datetime | None = ( + self.recurrence._as_rrule( # pylint: disable=protected-access + self.start_time + ).before(now, inc=True) + ) if not latest_past_start: return None diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index 0fa2e173..efa145df 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -215,9 +215,9 @@ def test_dispatch_next_run( if recurrence.frequency == Frequency.WEEKLY: # Compute the next run based on the recurrence rule - expected_next_run = recurrence.prepare(start_time).after( - CURRENT_TIME, inc=False - ) + expected_next_run = recurrence._as_rrule( # pylint: disable=protected-access + start_time + ).after(CURRENT_TIME, inc=False) elif expected_next_run_offset is not None: expected_next_run = CURRENT_TIME + expected_next_run_offset else: @@ -266,8 +266,10 @@ def test_dispatch_next_run_after( ) if recurrence.frequency == Frequency.WEEKLY: - expected_next_run_after = recurrence.prepare(dispatch.start_time).after( - after, inc=True + expected_next_run_after = ( + recurrence._as_rrule( # pylint: disable=protected-access + dispatch.start_time + ).after(after, inc=True) ) elif expected_next_run_after_offset is not None: expected_next_run_after = CURRENT_TIME + expected_next_run_after_offset From 467885840b16fdcde8e69d08898139959b984df5 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Tue, 5 Nov 2024 18:52:40 +0100 Subject: [PATCH 8/8] Remove RunningState and change method `running` to `started` property Signed-off-by: Mathias L. Baumann --- RELEASE_NOTES.md | 2 +- src/frequenz/client/dispatch/types.py | 44 +++++++++------------------ tests/test_dispatch.py | 40 ++++++------------------ 3 files changed, 24 insertions(+), 62 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6997403b..b4d54191 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,7 +3,7 @@ ## New Features * Update BaseApiClient to get the http2 keepalive feature. -* Some Methods from the high-level API have been moved to this repo: The dispatch class now offers: `until`, `running`, `next_run` and `next_run_after`. +* Some Methods from the high-level API have been moved to this repo: The dispatch class now offers: `until`, `started`, `next_run` and `next_run_after`. ## Bug Fixes diff --git a/src/frequenz/client/dispatch/types.py b/src/frequenz/client/dispatch/types.py index 28dfa60e..95a084a4 100644 --- a/src/frequenz/client/dispatch/types.py +++ b/src/frequenz/client/dispatch/types.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from enum import Enum, IntEnum +from enum import IntEnum from typing import Any, cast # pylint: disable=no-name-in-module @@ -115,19 +115,6 @@ class TimeIntervalFilter: """Filter by end_time < end_to.""" -class RunningState(Enum): - """The running state of a dispatch.""" - - RUNNING = "RUNNING" - """The dispatch is running.""" - - STOPPED = "STOPPED" - """The dispatch is stopped.""" - - DIFFERENT_TYPE = "DIFFERENT_TYPE" - """The dispatch is for a different type.""" - - @dataclass(kw_only=True, frozen=True) class Dispatch: # pylint: disable=too-many-instance-attributes """Represents a dispatch operation within a microgrid system.""" @@ -173,36 +160,33 @@ class Dispatch: # pylint: disable=too-many-instance-attributes update_time: datetime """The last update time of the dispatch in UTC. Set when a dispatch is modified.""" - def running(self, type_: str) -> RunningState: - """Check if the dispatch is currently supposed to be running. + @property + def started(self) -> bool: + """Check if the dispatch has started. - Args: - type_: The type of the dispatch that should be running. + A dispatch is considered started if the current time is after the start + time but before the end time. - Returns: - RUNNING if the dispatch is running, - STOPPED if it is stopped, - DIFFERENT_TYPE if it is for a different type. + Recurring dispatches are considered started if the current time is after + the start time of the last occurrence but before the end time of the + last occurrence. """ - if self.type != type_: - return RunningState.DIFFERENT_TYPE - if not self.active: - return RunningState.STOPPED + return False now = datetime.now(tz=timezone.utc) if now < self.start_time: - return RunningState.STOPPED + return False # A dispatch without duration is always running, once it started if self.duration is None: - return RunningState.RUNNING + return True if until := self._until(now): - return RunningState.RUNNING if now < until else RunningState.STOPPED + return now < until - return RunningState.STOPPED + return False @property def until(self) -> datetime | None: diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index efa145df..5b3af0b8 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -11,7 +11,7 @@ from frequenz.client.common.microgrid.components import ComponentCategory from frequenz.client.dispatch.recurrence import Frequency, RecurrenceRule, Weekday -from frequenz.client.dispatch.types import Dispatch, RunningState +from frequenz.client.dispatch.types import Dispatch # Define a fixed current time for testing to avoid issues with datetime.now() CURRENT_TIME = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) @@ -37,84 +37,62 @@ def dispatch_base() -> Dispatch: @time_machine.travel(CURRENT_TIME) @pytest.mark.parametrize( - "dispatch_type, requested_type, active, start_time_offset, duration, expected_state", + "active, start_time_offset, duration, expected_state", [ - # Dispatch type does not match the requested type - ( - "TypeA", - "TypeB", - True, - timedelta(minutes=-10), - timedelta(minutes=20), - RunningState.DIFFERENT_TYPE, - ), # Dispatch is inactive ( - "TypeA1", - "TypeA1", False, timedelta(minutes=-10), timedelta(minutes=20), - RunningState.STOPPED, + False, ), # Current time is before the start time ( - "TypeA2", - "TypeA2", True, timedelta(minutes=10), timedelta(minutes=20), - RunningState.STOPPED, + False, ), # Dispatch with infinite duration ( - "TypeA3", - "TypeA3", True, timedelta(minutes=-10), None, - RunningState.RUNNING, + True, ), # Dispatch is currently running ( - "TypeA4", - "TypeA4", True, timedelta(minutes=-10), timedelta(minutes=20), - RunningState.RUNNING, + True, ), # Dispatch duration has passed ( - "TypeA5", - "TypeA5", True, timedelta(minutes=-30), timedelta(minutes=20), - RunningState.STOPPED, + False, ), ], ) # pylint: disable=too-many-arguments,too-many-positional-arguments def test_dispatch_running( dispatch_base: Dispatch, - dispatch_type: str, - requested_type: str, active: bool, start_time_offset: timedelta, duration: timedelta | None, - expected_state: RunningState, + expected_state: bool, ) -> None: """Test the running method of the Dispatch class.""" dispatch = replace( dispatch_base, - type=dispatch_type, start_time=CURRENT_TIME + start_time_offset, duration=duration, active=active, ) - assert dispatch.running(requested_type) == expected_state + assert dispatch.started == expected_state @time_machine.travel(CURRENT_TIME)