From 91a86f3c2df92f5806c48392e682d864f3d1d5b9 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Thu, 7 Nov 2024 16:44:26 +0100 Subject: [PATCH 1/3] Update API dependency * selector was renamed to target * start_immediately was added in the proto rpc create Signed-off-by: Mathias L. Baumann # Conflicts: # src/frequenz/client/dispatch/_client.py # src/frequenz/client/dispatch/_internal_types.py # src/frequenz/client/dispatch/types.py # tests/test_proto.py --- pyproject.toml | 4 +- src/frequenz/client/dispatch/__main__.py | 19 +++--- src/frequenz/client/dispatch/_cli_types.py | 8 +-- src/frequenz/client/dispatch/_client.py | 22 +++---- .../client/dispatch/_internal_types.py | 17 ++--- src/frequenz/client/dispatch/test/_service.py | 6 +- src/frequenz/client/dispatch/test/client.py | 2 +- .../client/dispatch/test/generator.py | 2 +- src/frequenz/client/dispatch/types.py | 66 +++++++++---------- tests/test_cli.py | 40 +++++------ tests/test_dispatch.py | 2 +- tests/test_proto.py | 20 +++--- 12 files changed, 102 insertions(+), 106 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b15aa488..370e86da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ classifiers = [ requires-python = ">= 3.11, < 4" dependencies = [ "typing-extensions >= 4.6.1, < 5", - "frequenz-api-dispatch >= 0.15.1, < 0.16", + "frequenz-api-dispatch == 1.0.0-rc1", "frequenz-client-base >= 0.7.0, < 0.8.0", "frequenz-client-common >= 0.1.0, < 0.3.0", "grpcio >= 1.66.1, < 2", @@ -87,7 +87,7 @@ dev-pylint = [ "pylint == 3.3.1", # For checking the noxfile, docs/ script, and tests "frequenz-client-dispatch[cli,dev-mkdocs,dev-noxfile,dev-pytest]", - "frequenz-api-dispatch >= 0.15.1, < 0.16", + "frequenz-api-dispatch == 1.0.0-rc1", ] dev-pytest = [ "pytest == 8.3.3", diff --git a/src/frequenz/client/dispatch/__main__.py b/src/frequenz/client/dispatch/__main__.py index 58671539..607b8d4f 100644 --- a/src/frequenz/client/dispatch/__main__.py +++ b/src/frequenz/client/dispatch/__main__.py @@ -28,7 +28,7 @@ FuzzyIntRange, FuzzyTimeDelta, JsonDictParamType, - SelectorParamType, + TargetComponentParamType, ) from ._client import Client @@ -79,7 +79,7 @@ async def cli(ctx: click.Context, url: str, key: str) -> None: @cli.command("list") @click.pass_context @click.argument("microgrid-id", required=True, type=int) -@click.option("--selector", "-s", type=SelectorParamType(), multiple=True) +@click.option("--target", "-t", type=TargetComponentParamType(), multiple=True) @click.option("--start-from", type=FuzzyDateTime()) @click.option("--start-to", type=FuzzyDateTime()) @click.option("--end-from", type=FuzzyDateTime()) @@ -92,11 +92,12 @@ async def list_(ctx: click.Context, /, **filters: Any) -> None: Lists all dispatches for MICROGRID_ID that match the given filters. - The selector option can be given multiple times. + The target option can be given multiple times. """ - if "selector" in filters: - selector = filters.pop("selector") - filters["component_selectors"] = selector + if "target" in filters: + target = filters.pop("target") + # Name of the parameter in client.list() + filters["target_components"] = target num_dispatches = 0 async for page in ctx.obj["client"].list(**filters): @@ -241,7 +242,7 @@ def validate_reccurance(ctx: click.Context, param: click.Parameter, value: Any) required=True, type=str, ) -@click.argument("selector", required=True, type=SelectorParamType()) +@click.argument("target", required=True, type=TargetComponentParamType()) @click.argument("start-time", required=True, type=FuzzyDateTime()) @click.argument("duration", required=False, type=FuzzyTimeDelta()) @click.option("--active", "-a", type=bool, default=True) @@ -260,7 +261,7 @@ async def create( Creates a new dispatch for MICROGRID_ID of type TYPE running for DURATION seconds starting at START_TIME. - SELECTOR is a comma-separated list of either component categories or component IDs. + TARGET is a comma-separated list of either component categories or component IDs. Possible component categories: "BATTERY, GRID, METER, INVERTER, EV_CHARGER, CHP". """ # Remove keys with `None` value @@ -286,7 +287,7 @@ async def create( @click.option("--start-time", type=FuzzyDateTime()) @click.option("--duration", type=FuzzyTimeDelta()) @click.option("--no-duration", is_flag=True) -@click.option("--selector", type=SelectorParamType()) +@click.option("--target", type=TargetComponentParamType()) @click.option("--active", type=bool) @click.option( "--payload", "-p", type=JsonDictParamType(), help="JSON payload for the dispatch" diff --git a/src/frequenz/client/dispatch/_cli_types.py b/src/frequenz/client/dispatch/_cli_types.py index 530fa3ba..b30ef18f 100644 --- a/src/frequenz/client/dispatch/_cli_types.py +++ b/src/frequenz/client/dispatch/_cli_types.py @@ -130,10 +130,10 @@ def convert( self.fail(f"Invalid integer range: {value}", param, ctx) -class SelectorParamType(click.ParamType): - """Click parameter type for selectors.""" +class TargetComponentParamType(click.ParamType): + """Click parameter type for targets.""" - name = "selector" + name = "target" def convert( self, value: Any, param: click.Parameter | None, ctx: click.Context | None @@ -154,7 +154,7 @@ def convert( values = value.split(",") if len(values) == 0: - self.fail("Empty selector list", param, ctx) + self.fail("Empty target list", param, ctx) error: Exception | None = None # Attempt to parse component ids diff --git a/src/frequenz/client/dispatch/_client.py b/src/frequenz/client/dispatch/_client.py index bb86b859..da61b61c 100644 --- a/src/frequenz/client/dispatch/_client.py +++ b/src/frequenz/client/dispatch/_client.py @@ -42,10 +42,10 @@ from ._internal_types import DispatchCreateRequest from .recurrence import RecurrenceRule from .types import ( - ComponentSelector, Dispatch, DispatchEvent, - _component_selector_to_protobuf, + TargetComponents, + _target_components_to_protobuf, ) # pylint: enable=no-name-in-module @@ -110,7 +110,7 @@ async def list( self, microgrid_id: int, *, - component_selectors: Iterator[ComponentSelector] = iter(()), + target_components: Iterator[TargetComponents] = iter(()), start_from: datetime | None = None, start_to: datetime | None = None, end_from: datetime | None = None, @@ -136,7 +136,7 @@ async def list( Args: microgrid_id: The microgrid_id to list dispatches for. - component_selectors: optional, list of component ids or categories to filter by. + target_components: optional, list of component ids or categories to filter by. start_from: optional, filter by start_time >= start_from. start_to: optional, filter by start_time < start_to. end_from: optional, filter by end_time >= end_from. @@ -166,9 +166,9 @@ 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)) + targets = list(map(_target_components_to_protobuf, target_components)) filters = DispatchFilter( - selectors=selectors, + targets=targets, start_time_interval=start_time_interval, end_time_interval=end_time_interval, is_active=active, @@ -256,7 +256,7 @@ async def create( # pylint: disable=too-many-positional-arguments type: str, # pylint: disable=redefined-builtin start_time: datetime, duration: timedelta | None, - selector: ComponentSelector, + target: TargetComponents, *, active: bool = True, dry_run: bool = False, @@ -271,7 +271,7 @@ async def create( # pylint: disable=too-many-positional-arguments start_time: The start time of the dispatch. duration: The duration of the dispatch. Can be `None` for infinite or no-duration dispatches (e.g. switching a component on). - selector: The component selector for the dispatch. + target: The component target for the dispatch. active: The active status of the dispatch. dry_run: The dry_run status of the dispatch. payload: The payload of the dispatch. @@ -295,7 +295,7 @@ async def create( # pylint: disable=too-many-positional-arguments type=type, start_time=start_time, duration=duration, - selector=selector, + target=target, active=active, dry_run=dry_run, payload=payload or {}, @@ -353,8 +353,8 @@ async def update( msg.update.ClearField("duration") else: msg.update.duration = round(val.total_seconds()) - case "selector": - msg.update.selector.CopyFrom(_component_selector_to_protobuf(val)) + case "target": + msg.update.target.CopyFrom(_target_components_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 4922df06..da7e83e3 100644 --- a/src/frequenz/client/dispatch/_internal_types.py +++ b/src/frequenz/client/dispatch/_internal_types.py @@ -20,9 +20,9 @@ from .recurrence import RecurrenceRule from .types import ( - ComponentSelector, - _component_selector_from_protobuf, - _component_selector_to_protobuf, + TargetComponents, + _target_components_from_protobuf, + _target_components_to_protobuf, ) # pylint: enable=no-name-in-module @@ -51,8 +51,8 @@ class DispatchCreateRequest: like a command to turn on a component. """ - selector: ComponentSelector - """The component selector specifying which components the dispatch targets.""" + target: TargetComponents + """The target components of the dispatch.""" active: bool """Indicates whether the dispatch is active and eligible for processing.""" @@ -69,7 +69,6 @@ class DispatchCreateRequest: recurrence: RecurrenceRule | None """The recurrence rule for the dispatch. - Defining any repeating patterns or schedules.""" @classmethod @@ -97,9 +96,7 @@ def from_protobuf( to_datetime(pb_object.dispatch_data.start_time) ), duration=duration, - selector=_component_selector_from_protobuf( - pb_object.dispatch_data.selector - ), + target=_target_components_from_protobuf(pb_object.dispatch_data.target), active=pb_object.dispatch_data.is_active, dry_run=pb_object.dispatch_data.is_dry_run, payload=MessageToDict(pb_object.dispatch_data.payload), @@ -123,7 +120,7 @@ def to_protobuf(self) -> PBDispatchCreateRequest: duration=( round(self.duration.total_seconds()) if self.duration else None ), - selector=_component_selector_to_protobuf(self.selector), + target=_target_components_to_protobuf(self.target), is_active=self.active, is_dry_run=self.dry_run, payload=payload, diff --git a/src/frequenz/client/dispatch/test/_service.py b/src/frequenz/client/dispatch/test/_service.py index 2a129bf5..b0d0dce9 100644 --- a/src/frequenz/client/dispatch/test/_service.py +++ b/src/frequenz/client/dispatch/test/_service.py @@ -176,8 +176,8 @@ def _filter_dispatch( """Filter a dispatch based on the request.""" if request.HasField("filter"): _filter = request.filter - for selector in _filter.selectors: - if selector != dispatch.selector: + for target in _filter.targets: + if target != dispatch.target: return False if _filter.HasField("start_time_interval"): if start_from := _filter.start_time_interval.__dict__["from"]: @@ -272,7 +272,7 @@ async def UpdateMicrogridDispatch( getattr(request.update, split_path[0]), ) # Fields that need to be copied - case "start_time" | "selector" | "payload": + case "start_time" | "target" | "payload": getattr(pb_dispatch.data, split_path[0]).CopyFrom( getattr(request.update, split_path[0]) ) diff --git a/src/frequenz/client/dispatch/test/client.py b/src/frequenz/client/dispatch/test/client.py index ba12a074..629a43bf 100644 --- a/src/frequenz/client/dispatch/test/client.py +++ b/src/frequenz/client/dispatch/test/client.py @@ -82,7 +82,7 @@ def to_create_params(microgrid_id: int, dispatch: Dispatch) -> dict[str, Any]: "type": dispatch.type, "start_time": dispatch.start_time, "duration": dispatch.duration, - "selector": dispatch.selector, + "target": dispatch.target, "active": dispatch.active, "dry_run": dispatch.dry_run, "payload": dispatch.payload, diff --git a/src/frequenz/client/dispatch/test/generator.py b/src/frequenz/client/dispatch/test/generator.py index 636990a1..c00ab843 100644 --- a/src/frequenz/client/dispatch/test/generator.py +++ b/src/frequenz/client/dispatch/test/generator.py @@ -92,7 +92,7 @@ def generate_dispatch(self) -> Dispatch: timedelta(seconds=self._rng.randint(0, 1000000)), ] ), - selector=self._rng.choice( # type: ignore + target=self._rng.choice( # type: ignore [ [ self._rng.choice(list(ComponentCategory)[1:]) diff --git a/src/frequenz/client/dispatch/types.py b/src/frequenz/client/dispatch/types.py index 95a084a4..33d5c736 100644 --- a/src/frequenz/client/dispatch/types.py +++ b/src/frequenz/client/dispatch/types.py @@ -10,15 +10,13 @@ from typing import Any, cast # pylint: disable=no-name-in-module -from frequenz.api.dispatch.v1.dispatch_pb2 import ( - ComponentSelector as PBComponentSelector, -) from frequenz.api.dispatch.v1.dispatch_pb2 import Dispatch as PBDispatch from frequenz.api.dispatch.v1.dispatch_pb2 import ( DispatchData, DispatchMetadata, StreamMicrogridDispatchesResponse, ) +from frequenz.api.dispatch.v1.dispatch_pb2 import TargetComponents as PBTargetComponents from google.protobuf.json_format import MessageToDict from google.protobuf.struct_pb2 import Struct @@ -29,73 +27,73 @@ from .recurrence import Frequency, RecurrenceRule, Weekday -ComponentSelector = list[int] | list[ComponentCategory] -"""A component selector specifying which components a dispatch targets. +TargetComponents = list[int] | list[ComponentCategory] +"""One or more target components specifying which components a dispatch targets. -A component selector can be a list of component IDs or a list of categories. +It can be a list of component IDs or a list of categories. """ -def _component_selector_from_protobuf( - pb_selector: PBComponentSelector, -) -> ComponentSelector: - """Convert a protobuf component selector to a component selector. +def _target_components_from_protobuf( + pb_target: PBTargetComponents, +) -> TargetComponents: + """Convert protobuf target components to a more native type. Args: - pb_selector: The protobuf component selector to convert. + pb_target: The protobuf target components to convert. Raises: - ValueError: If the protobuf component selector is invalid. + ValueError: If the protobuf target components are invalid. Returns: - The converted component selector. + The converted target components. """ - match pb_selector.WhichOneof("selector"): + match pb_target.WhichOneof("components"): case "component_ids": - id_list: list[int] = list(pb_selector.component_ids.ids) + id_list: list[int] = list(pb_target.component_ids.ids) return id_list case "component_categories": category_list: list[ComponentCategory] = list( map( ComponentCategory.from_proto, - pb_selector.component_categories.categories, + pb_target.component_categories.categories, ) ) return category_list case _: - raise ValueError("Invalid component selector") + raise ValueError("Invalid target components") -def _component_selector_to_protobuf( - selector: ComponentSelector, -) -> PBComponentSelector: - """Convert a component selector to a protobuf component selector. +def _target_components_to_protobuf( + target: TargetComponents, +) -> PBTargetComponents: + """Convert target components to protobuf. Args: - selector: The component selector to convert. + target: The target components to convert. Raises: - ValueError: If the component selector is invalid. + ValueError: If the target components are invalid. Returns: - The converted protobuf component selector. + The converted protobuf target components. """ - pb_selector = PBComponentSelector() - match selector: + pb_target = PBTargetComponents() + match target: case list(component_ids) if all(isinstance(id, int) for id in component_ids): - pb_selector.component_ids.ids.extend(cast(list[int], component_ids)) + pb_target.component_ids.ids.extend(cast(list[int], component_ids)) case list(categories) if all( isinstance(cat, ComponentCategory) for cat in categories ): - pb_selector.component_categories.categories.extend( + pb_target.component_categories.categories.extend( map( lambda cat: cat.to_proto(), cast(list[ComponentCategory], categories), ) ) case _: - raise ValueError("Invalid component selector") - return pb_selector + raise ValueError(f"Invalid target components: {target}") + return pb_target @dataclass(frozen=True, kw_only=True) @@ -133,8 +131,8 @@ class Dispatch: # pylint: disable=too-many-instance-attributes duration: timedelta | None """The duration of the dispatch, represented as a timedelta.""" - selector: ComponentSelector - """The component selector specifying which components the dispatch targets.""" + target: TargetComponents + """The target components of the dispatch.""" active: bool """Indicates whether the dispatch is active and eligible for processing.""" @@ -298,7 +296,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), + target=_target_components_from_protobuf(pb_object.data.target), active=pb_object.data.is_active, dry_run=pb_object.data.is_dry_run, payload=MessageToDict(pb_object.data.payload), @@ -326,7 +324,7 @@ def to_protobuf(self) -> PBDispatch: duration=( round(self.duration.total_seconds()) if self.duration else None ), - selector=_component_selector_to_protobuf(self.selector), + target=_target_components_to_protobuf(self.target), is_active=self.active, is_dry_run=self.dry_run, payload=payload, diff --git a/tests/test_cli.py b/tests/test_cli.py index 3fdbcfc6..60f42752 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -66,7 +66,7 @@ def mock_client(fake_client: FakeClient) -> Generator[None, None, None]: type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - selector=[1, 2, 3], + target=[1, 2, 3], active=True, dry_run=False, payload={}, @@ -89,7 +89,7 @@ def mock_client(fake_client: FakeClient) -> Generator[None, None, None]: type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - selector=[1, 2, 3], + target=[1, 2, 3], active=True, dry_run=False, payload={}, @@ -111,7 +111,7 @@ def mock_client(fake_client: FakeClient) -> Generator[None, None, None]: type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - selector=[1, 2, 3], + target=[1, 2, 3], active=True, dry_run=False, payload={}, @@ -126,7 +126,7 @@ def mock_client(fake_client: FakeClient) -> Generator[None, None, None]: type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - selector=[1, 2, 3], + target=[1, 2, 3], active=True, dry_run=False, payload={}, @@ -171,7 +171,7 @@ async def test_list_command( @pytest.mark.parametrize( "args, expected_microgrid_id, expected_type, " "expected_start_time_delta, expected_duration, " - "expected_selector, expected_options, expected_reccurence, expected_return_code", + "expected_target, expected_options, expected_reccurence, expected_return_code", [ ( [ @@ -319,7 +319,7 @@ async def test_create_command( expected_type: str, expected_start_time_delta: timedelta, expected_duration: timedelta, - expected_selector: list[int] | list[ComponentCategory], + expected_target: list[int] | list[ComponentCategory], expected_options: dict[str, Any], expected_reccurence: RecurrenceRule | None, expected_return_code: int, @@ -363,7 +363,7 @@ async def test_create_command( created_dispatch.duration.total_seconds() == pytest.approx(expected_duration.total_seconds(), abs=2) ) - assert created_dispatch.selector == expected_selector + assert created_dispatch.target == expected_target assert created_dispatch.recurrence == expected_reccurence for key, value in expected_options.items(): @@ -381,7 +381,7 @@ async def test_create_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - selector=[ComponentCategory.BATTERY], + target=[ComponentCategory.BATTERY], active=True, dry_run=False, payload={}, @@ -405,7 +405,7 @@ async def test_create_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - selector=[ComponentCategory.BATTERY], + target=[ComponentCategory.BATTERY], active=True, dry_run=False, payload={}, @@ -431,7 +431,7 @@ async def test_create_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - selector=[ComponentCategory.BATTERY, ComponentCategory.EV_CHARGER], + target=[ComponentCategory.BATTERY, ComponentCategory.EV_CHARGER], active=True, dry_run=False, payload={}, @@ -441,19 +441,19 @@ async def test_create_command( ) ], [ - "--selector", + "--target", "BATTERY, EV_CHARGER, CHP", ], { - "selector": [ + "target": [ ComponentCategory.BATTERY, ComponentCategory.EV_CHARGER, ComponentCategory.CHP, ], }, 0, - "selector=[,\n " - + ",\n " + "target=[,\n " + + ",\n " + "]", ), ( @@ -463,7 +463,7 @@ async def test_create_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - selector=[500, 501], + target=[500, 501], active=True, dry_run=False, payload={}, @@ -473,7 +473,7 @@ async def test_create_command( ) ], [ - "--selector", + "--target", "400, 401", "--frequency", "daily", @@ -493,7 +493,7 @@ async def test_create_command( '{"key": "value"}', ], { - "selector": [400, 401], + "target": [400, 401], "recurrence": RecurrenceRule( frequency=Frequency.DAILY, interval=5, @@ -509,7 +509,7 @@ async def test_create_command( "payload": {"key": "value"}, }, 0, - """ selector=[400, 401], + """ target=[400, 401], active=True, dry_run=False, payload={'key': 'value'}, @@ -568,7 +568,7 @@ async def test_update_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - selector=[1, 2, 3], + target=[1, 2, 3], active=True, dry_run=False, payload={}, @@ -615,7 +615,7 @@ async def test_get_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - selector=[1, 2, 3], + target=[1, 2, 3], active=True, dry_run=False, payload={}, diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index 5b3af0b8..e7713357 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -25,7 +25,7 @@ def dispatch_base() -> Dispatch: type="TypeA", start_time=CURRENT_TIME, duration=timedelta(minutes=20), - selector=[ComponentCategory.BATTERY], + target=[ComponentCategory.BATTERY], active=True, dry_run=False, payload={}, diff --git a/tests/test_proto.py b/tests/test_proto.py index d6a33599..e4f8e9cb 100644 --- a/tests/test_proto.py +++ b/tests/test_proto.py @@ -15,14 +15,14 @@ ) from frequenz.client.dispatch.types import ( Dispatch, - _component_selector_from_protobuf, - _component_selector_to_protobuf, + _target_components_from_protobuf, + _target_components_to_protobuf, ) -def test_component_selector() -> None: - """Test the component selector.""" - for selector in ( +def test_target_components() -> None: + """Test the target components.""" + for components in ( [1, 2, 3], [10, 20, 30], [ComponentCategory.BATTERY], @@ -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 = _target_components_to_protobuf(components) + assert _target_components_from_protobuf(protobuf) == components def test_end_criteria() -> None: @@ -98,7 +98,7 @@ def test_dispatch() -> None: update_time=datetime(2023, 1, 1, tzinfo=timezone.utc), start_time=datetime(2024, 10, 10, tzinfo=timezone.utc), duration=timedelta(days=10), - selector=[1, 2, 3], + target=[1, 2, 3], active=True, dry_run=False, payload={"key": "value"}, @@ -118,7 +118,7 @@ def test_dispatch() -> None: update_time=datetime(2024, 3, 11, tzinfo=timezone.utc), start_time=datetime(2024, 11, 10, tzinfo=timezone.utc), duration=timedelta(seconds=20), - selector=[ComponentCategory.BATTERY], + target=[ComponentCategory.BATTERY], active=False, dry_run=True, payload={"key": "value1"}, @@ -141,7 +141,7 @@ def test_dispatch_create_request_with_no_recurrence() -> None: type="test", start_time=datetime(2024, 10, 10, tzinfo=timezone.utc), duration=timedelta(days=10), - selector=[1, 2, 3], + target=[1, 2, 3], active=True, dry_run=False, payload={"key": "value"}, From 8e97d4f695494a45842ba57d4223fd0e1edbb8a8 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Thu, 7 Nov 2024 10:46:57 +0100 Subject: [PATCH 2/3] CLI: Change interval default to 1 We basically never want 0 here. Signed-off-by: Mathias L. Baumann --- src/frequenz/client/dispatch/__main__.py | 2 +- tests/test_cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frequenz/client/dispatch/__main__.py b/src/frequenz/client/dispatch/__main__.py index 607b8d4f..7fee706c 100644 --- a/src/frequenz/client/dispatch/__main__.py +++ b/src/frequenz/client/dispatch/__main__.py @@ -187,7 +187,7 @@ def validate_reccurance(ctx: click.Context, param: click.Parameter, value: Any) ["--interval"], type=int, help="Interval of the dispatch, based on frequency", - default=0, + default=1, ), click.Option( ["--count"], diff --git a/tests/test_cli.py b/tests/test_cli.py index 60f42752..4bc8a2da 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -298,7 +298,7 @@ async def test_list_command( {}, RecurrenceRule( frequency=Frequency.DAILY, - interval=0, + interval=1, end_criteria=EndCriteria( count=None, until=(TEST_NOW + timedelta(days=1)) ), From 5db3b6ca8acdb5350b9dce5d992f7689a57ddc90 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Thu, 7 Nov 2024 11:45:13 +0100 Subject: [PATCH 3/3] Add support for start_immediately Signed-off-by: Mathias L. Baumann --- RELEASE_NOTES.md | 1 + src/frequenz/client/dispatch/_cli_types.py | 9 +++-- src/frequenz/client/dispatch/_client.py | 22 ++++++----- .../client/dispatch/_internal_types.py | 18 ++++++--- src/frequenz/client/dispatch/test/_service.py | 3 ++ tests/test_cli.py | 37 ++++++++++++++++--- tests/test_proto.py | 17 +++++++++ 7 files changed, 84 insertions(+), 23 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b4d54191..3eb93fa7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,6 +4,7 @@ * 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`, `started`, `next_run` and `next_run_after`. +* Add `start_immediately` support to the `create` method. You can now specify "NOW" as the start time to trigger immediate dispatch. Note: While the dispatch CLI previously allowed this by converting "NOW" to a timestamp client-side before sending it to the server, this functionality is now supported directly on the server side! ## Bug Fixes diff --git a/src/frequenz/client/dispatch/_cli_types.py b/src/frequenz/client/dispatch/_cli_types.py index b30ef18f..40a899ee 100644 --- a/src/frequenz/client/dispatch/_cli_types.py +++ b/src/frequenz/client/dispatch/_cli_types.py @@ -5,7 +5,7 @@ import json from datetime import datetime, timedelta, timezone -from typing import Any, cast +from typing import Any, Literal, cast import asyncclick as click import parsedatetime # type: ignore @@ -32,12 +32,15 @@ def __init__(self) -> None: def convert( self, value: Any, param: click.Parameter | None, ctx: click.Context | None - ) -> datetime: - """Convert the value to a datetime object.""" + ) -> datetime | Literal["NOW"] | None: + """Convert the value to a datetime object or the string "NOW".""" if isinstance(value, datetime): return value try: + if value.upper() == "NOW": + return "NOW" + parsed_dt, parse_status = self.cal.parseDT(value, tzinfo=self.local_tz) if parse_status == 0: self.fail(f"Invalid time expression: {value}", param, ctx) diff --git a/src/frequenz/client/dispatch/_client.py b/src/frequenz/client/dispatch/_client.py index da61b61c..a3e2e716 100644 --- a/src/frequenz/client/dispatch/_client.py +++ b/src/frequenz/client/dispatch/_client.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta from importlib.resources import files from pathlib import Path -from typing import Any, AsyncIterator, Awaitable, Iterator, cast +from typing import Any, AsyncIterator, Awaitable, Iterator, Literal, cast # pylint: disable=no-name-in-module from frequenz.api.common.v1.pagination.pagination_params_pb2 import PaginationParams @@ -254,7 +254,7 @@ async def create( # pylint: disable=too-many-positional-arguments self, microgrid_id: int, type: str, # pylint: disable=redefined-builtin - start_time: datetime, + start_time: datetime | Literal["NOW"], duration: timedelta | None, target: TargetComponents, *, @@ -268,7 +268,7 @@ async def create( # pylint: disable=too-many-positional-arguments Args: microgrid_id: The microgrid_id to create the dispatch for. type: User defined string to identify the dispatch type. - start_time: The start time of the dispatch. + start_time: The start time of the dispatch. Can be "NOW" for immediate start. duration: The duration of the dispatch. Can be `None` for infinite or no-duration dispatches (e.g. switching a component on). target: The component target for the dispatch. @@ -283,12 +283,16 @@ async def create( # pylint: disable=too-many-positional-arguments Raises: ValueError: If start_time is in the past. """ - if start_time <= datetime.now(tz=start_time.tzinfo): - raise ValueError("start_time must not be in the past") - - # Raise if it's not UTC - if start_time.tzinfo is None or start_time.tzinfo.utcoffset(start_time) is None: - raise ValueError("start_time must be timezone aware") + if isinstance(start_time, datetime): + if start_time <= datetime.now(tz=start_time.tzinfo): + raise ValueError("start_time must not be in the past") + + # Raise if it's not UTC + if ( + start_time.tzinfo is None + or start_time.tzinfo.utcoffset(start_time) is None + ): + raise ValueError("start_time must be timezone aware") request = DispatchCreateRequest( microgrid_id=microgrid_id, diff --git a/src/frequenz/client/dispatch/_internal_types.py b/src/frequenz/client/dispatch/_internal_types.py index da7e83e3..b17d0fbb 100644 --- a/src/frequenz/client/dispatch/_internal_types.py +++ b/src/frequenz/client/dispatch/_internal_types.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any +from typing import Any, Literal # pylint: disable=no-name-in-module from frequenz.api.dispatch.v1.dispatch_pb2 import ( @@ -15,6 +15,7 @@ from frequenz.api.dispatch.v1.dispatch_pb2 import DispatchData from google.protobuf.json_format import MessageToDict from google.protobuf.struct_pb2 import Struct +from google.protobuf.timestamp_pb2 import Timestamp from frequenz.client.base.conversion import to_datetime, to_timestamp @@ -41,7 +42,7 @@ class DispatchCreateRequest: This is understood and processed by downstream applications.""" - start_time: datetime + start_time: datetime | Literal["NOW"] """The start time of the dispatch in UTC.""" duration: timedelta | None @@ -92,8 +93,10 @@ def from_protobuf( return DispatchCreateRequest( microgrid_id=pb_object.microgrid_id, type=pb_object.dispatch_data.type, - start_time=rounded_start_time( - to_datetime(pb_object.dispatch_data.start_time) + start_time=( + "NOW" + if pb_object.start_immediately + else rounded_start_time(to_datetime(pb_object.dispatch_data.start_time)) ), duration=duration, target=_target_components_from_protobuf(pb_object.dispatch_data.target), @@ -116,7 +119,11 @@ def to_protobuf(self) -> PBDispatchCreateRequest: microgrid_id=self.microgrid_id, dispatch_data=DispatchData( type=self.type, - start_time=to_timestamp(self.start_time), + start_time=( + to_timestamp(self.start_time) + if isinstance(self.start_time, datetime) + else Timestamp() + ), duration=( round(self.duration.total_seconds()) if self.duration else None ), @@ -126,6 +133,7 @@ def to_protobuf(self) -> PBDispatchCreateRequest: payload=payload, recurrence=self.recurrence.to_protobuf() if self.recurrence else None, ), + start_immediately=self.start_time == "NOW", ) diff --git a/src/frequenz/client/dispatch/test/_service.py b/src/frequenz/client/dispatch/test/_service.py index b0d0dce9..2a9bc8b9 100644 --- a/src/frequenz/client/dispatch/test/_service.py +++ b/src/frequenz/client/dispatch/test/_service.py @@ -397,6 +397,9 @@ def _dispatch_from_request( params = _request.__dict__ params.pop("microgrid_id") + if _request.start_time == "NOW": + params["start_time"] = datetime.now(tz=timezone.utc) + return Dispatch( id=_id, create_time=create_time, diff --git a/tests/test_cli.py b/tests/test_cli.py index 4bc8a2da..2fe6bb1c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,7 +5,7 @@ from dataclasses import replace from datetime import datetime, timedelta, timezone -from typing import Any, Generator +from typing import Any, Generator, Literal from unittest.mock import patch import pytest @@ -309,6 +309,24 @@ async def test_list_command( ), 0, ), + ( + [ + "create", + "1", + "test_start_immediately", + "BATTERY", + "now", + "1h", + ], + 1, + "test_start_immediately", + "NOW", + timedelta(seconds=3600), + [ComponentCategory.BATTERY], + {}, + RecurrenceRule(), + 0, + ), ], ) async def test_create_command( @@ -317,7 +335,7 @@ async def test_create_command( args: list[str], expected_microgrid_id: int, expected_type: str, - expected_start_time_delta: timedelta, + expected_start_time_delta: timedelta | Literal["NOW"], expected_duration: timedelta, expected_target: list[int] | list[ComponentCategory], expected_options: dict[str, Any], @@ -355,10 +373,17 @@ async def test_create_command( assert len(dispatches) == 1 created_dispatch = dispatches[0] assert created_dispatch.type == expected_type - assert created_dispatch.start_time.timestamp() == pytest.approx( - (now + expected_start_time_delta).astimezone(timezone.utc).timestamp(), - abs=2, - ) + + if isinstance(expected_start_time_delta, timedelta): + assert created_dispatch.start_time.timestamp() == pytest.approx( + (now + expected_start_time_delta).astimezone(timezone.utc).timestamp(), + abs=2, + ) + else: + assert created_dispatch.start_time.timestamp() == pytest.approx( + now.astimezone(timezone.utc).timestamp(), abs=2 + ) + assert created_dispatch.duration and ( created_dispatch.duration.total_seconds() == pytest.approx(expected_duration.total_seconds(), abs=2) diff --git a/tests/test_proto.py b/tests/test_proto.py index e4f8e9cb..144ab46b 100644 --- a/tests/test_proto.py +++ b/tests/test_proto.py @@ -149,3 +149,20 @@ def test_dispatch_create_request_with_no_recurrence() -> None: ) assert request.to_protobuf().dispatch_data.HasField("recurrence") is False + + +def test_dispatch_create_start_immediately() -> None: + """Test the dispatch create request with no start time.""" + request = DispatchCreateRequest( + microgrid_id=123, + type="test", + start_time="NOW", + duration=timedelta(days=10), + target=[1, 2, 3], + active=True, + dry_run=False, + payload={"key": "value"}, + recurrence=RecurrenceRule(), + ) + + assert request == DispatchCreateRequest.from_protobuf(request.to_protobuf())