diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e1deef01..d80eb2ff 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,18 +2,16 @@ ## Summary -This release includes a new feature for pagination support in the dispatch list request as well as usage of the base-client for setting up the channel and client configuration. + ## Upgrading -- The `Client.list()` function now yields a `list[Dispatch]` representing one page of dispatches -- `Client.__init__` no longer accepts a `grpc_channel` argument, instead a `server_url` argument is required. -- For the dispatch-cli client, `DISPATCH_API_PORT` and `DISPATCH_API_HOST` environment variables have been replaced with `DISPATCH_API_URL` which should be a full URL including the protocol (e.g. `grpc://fz-0004.frequenz.io:50051`) + ## New Features -- Pagination support in the dispatch list request. -- `Client.__init__`: - - Has a new parameter `connect` which is a boolean that determines if the client should connect to the server on initialization. - - Automatically sets up the channel for encrypted TLS communication. -- A new method `stream()` to receive dispatch events in real-time. +* Added support for duration=None when creating a dispatch. + +## Bug Fixes + + diff --git a/src/frequenz/client/dispatch/__main__.py b/src/frequenz/client/dispatch/__main__.py index 659dcaa6..afcec1b4 100644 --- a/src/frequenz/client/dispatch/__main__.py +++ b/src/frequenz/client/dispatch/__main__.py @@ -239,9 +239,9 @@ def validate_reccurance(ctx: click.Context, param: click.Parameter, value: Any) required=True, type=str, ) -@click.argument("start-time", required=True, type=FuzzyDateTime()) -@click.argument("duration", required=True, type=FuzzyTimeDelta()) @click.argument("selector", required=True, type=SelectorParamType()) +@click.argument("start-time", required=True, type=FuzzyDateTime()) +@click.argument("duration", required=False, type=FuzzyTimeDelta()) @click.option("--active", "-a", type=bool, default=True) @click.option("--dry-run", "-d", type=bool, default=False) @click.option( @@ -279,6 +279,7 @@ async def create( @click.argument("dispatch_id", type=int) @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("--active", type=bool) @click.option( @@ -310,6 +311,13 @@ def skip_field(value: Any) -> bool: if len(new_fields) == 0: raise click.BadArgumentUsage("At least one field must be given to update.") + if new_fields.get("no_duration"): + if new_fields.get("duration"): + raise click.BadArgumentUsage("Cannot set both no_duration and duration.") + new_fields["duration"] = None # type: ignore + + new_fields.pop("no_duration") + try: changed_dispatch = await ctx.obj["client"].update( microgrid_id=microgrid_id, dispatch_id=dispatch_id, new_fields=new_fields diff --git a/src/frequenz/client/dispatch/_client.py b/src/frequenz/client/dispatch/_client.py index bdf7621e..5bac9cc4 100644 --- a/src/frequenz/client/dispatch/_client.py +++ b/src/frequenz/client/dispatch/_client.py @@ -229,7 +229,7 @@ async def create( microgrid_id: int, type: str, # pylint: disable=redefined-builtin start_time: datetime, - duration: timedelta, + duration: timedelta | None, selector: ComponentSelector, active: bool = True, dry_run: bool = False, @@ -245,7 +245,8 @@ async def create( 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. - duration: The duration 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. active: The active status of the dispatch. dry_run: The dry_run status of the dispatch. @@ -325,7 +326,10 @@ async def update( case "start_time": msg.update.start_time.CopyFrom(to_timestamp(val)) case "duration": - msg.update.duration = int(val.total_seconds()) + if val is None: + msg.update.ClearField("duration") + else: + msg.update.duration = round(val.total_seconds()) case "selector": msg.update.selector.CopyFrom(component_selector_to_protobuf(val)) case "is_active": diff --git a/src/frequenz/client/dispatch/_internal_types.py b/src/frequenz/client/dispatch/_internal_types.py index 566d2cda..c3831a8b 100644 --- a/src/frequenz/client/dispatch/_internal_types.py +++ b/src/frequenz/client/dispatch/_internal_types.py @@ -12,9 +12,9 @@ from frequenz.api.dispatch.v1.dispatch_pb2 import ( CreateMicrogridDispatchRequest as PBDispatchCreateRequest, ) - -# pylint: enable=no-name-in-module +from frequenz.api.dispatch.v1.dispatch_pb2 import DispatchData from google.protobuf.json_format import MessageToDict +from google.protobuf.struct_pb2 import Struct from frequenz.client.base.conversion import to_datetime, to_timestamp @@ -25,6 +25,8 @@ component_selector_to_protobuf, ) +# pylint: enable=no-name-in-module + # pylint: disable=too-many-instance-attributes @dataclass(kw_only=True) @@ -42,8 +44,12 @@ class DispatchCreateRequest: start_time: datetime """The start time of the dispatch in UTC.""" - duration: timedelta - """The duration of the dispatch, represented as a timedelta.""" + duration: timedelta | None + """The duration of the dispatch, represented as a timedelta. + + If None, the dispatch is considered to be "infinite" or "instantaneous", + like a command to turn on a component. + """ selector: ComponentSelector """The component selector specifying which components the dispatch targets.""" @@ -78,13 +84,19 @@ def from_protobuf( Returns: The converted dispatch. """ + duration = ( + timedelta(seconds=pb_object.dispatch_data.duration) + if pb_object.dispatch_data.HasField("duration") + else None + ) + 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) ), - duration=timedelta(seconds=pb_object.dispatch_data.duration), + duration=duration, 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, @@ -98,24 +110,24 @@ def to_protobuf(self) -> PBDispatchCreateRequest: Returns: The converted protobuf dispatch create request. """ - pb_request = PBDispatchCreateRequest() - - pb_request.microgrid_id = self.microgrid_id - pb_request.dispatch_data.type = self.type - pb_request.dispatch_data.start_time.CopyFrom(to_timestamp(self.start_time)) - pb_request.dispatch_data.duration = round(self.duration.total_seconds()) - pb_request.dispatch_data.selector.CopyFrom( - component_selector_to_protobuf(self.selector) + payload = Struct() + payload.update(self.payload) + + return PBDispatchCreateRequest( + microgrid_id=self.microgrid_id, + dispatch_data=DispatchData( + type=self.type, + start_time=to_timestamp(self.start_time), + duration=( + round(self.duration.total_seconds()) if self.duration else None + ), + selector=component_selector_to_protobuf(self.selector), + is_active=self.active, + is_dry_run=self.dry_run, + payload=payload, + recurrence=self.recurrence.to_protobuf() if self.recurrence else None, + ), ) - pb_request.dispatch_data.is_active = self.active - pb_request.dispatch_data.is_dry_run = self.dry_run - pb_request.dispatch_data.payload.update(self.payload) - if self.recurrence: - pb_request.dispatch_data.recurrence.CopyFrom(self.recurrence.to_protobuf()) - else: - pb_request.dispatch_data.ClearField("recurrence") - - return pb_request def rounded_start_time(start_time: datetime) -> datetime: diff --git a/src/frequenz/client/dispatch/test/_service.py b/src/frequenz/client/dispatch/test/_service.py index 2b3354b9..e7af1c4b 100644 --- a/src/frequenz/client/dispatch/test/_service.py +++ b/src/frequenz/client/dispatch/test/_service.py @@ -142,10 +142,16 @@ def _filter_dispatch(dispatch: Dispatch, request: PBDispatchListRequest) -> bool return False if _filter.HasField("end_time_interval"): if end_from := _filter.end_time_interval.__dict__["from"]: - if dispatch.start_time + dispatch.duration < _to_dt(end_from): + if ( + dispatch.duration + and dispatch.start_time + dispatch.duration < _to_dt(end_from) + ): return False if end_to := _filter.end_time_interval.to: - if dispatch.start_time + dispatch.duration >= _to_dt(end_to): + if ( + dispatch.duration + and dispatch.start_time + dispatch.duration >= _to_dt(end_to) + ): return False if _filter.HasField("is_active"): if dispatch.active != _filter.is_active: diff --git a/src/frequenz/client/dispatch/test/generator.py b/src/frequenz/client/dispatch/test/generator.py index 4efc42cd..68862bf6 100644 --- a/src/frequenz/client/dispatch/test/generator.py +++ b/src/frequenz/client/dispatch/test/generator.py @@ -85,7 +85,12 @@ def generate_dispatch(self) -> Dispatch: datetime.now(tz=timezone.utc) + timedelta(seconds=self._rng.randint(0, 1000000)) ), - duration=timedelta(seconds=self._rng.randint(0, 1000000)), + duration=self._rng.choice( + [ + None, + timedelta(seconds=self._rng.randint(0, 1000000)), + ] + ), selector=self._rng.choice( # type: ignore [ [ diff --git a/src/frequenz/client/dispatch/types.py b/src/frequenz/client/dispatch/types.py index 9bea6142..031a27d4 100644 --- a/src/frequenz/client/dispatch/types.py +++ b/src/frequenz/client/dispatch/types.py @@ -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 google.protobuf.json_format import MessageToDict +from google.protobuf.struct_pb2 import Struct from frequenz.client.base.conversion import to_datetime, to_timestamp @@ -272,7 +274,7 @@ class Dispatch: start_time: datetime """The start time of the dispatch in UTC.""" - duration: timedelta + duration: timedelta | None """The duration of the dispatch, represented as a timedelta.""" selector: ComponentSelector @@ -318,7 +320,11 @@ def from_protobuf(cls, pb_object: PBDispatch) -> "Dispatch": create_time=to_datetime(pb_object.metadata.create_time), update_time=to_datetime(pb_object.metadata.modification_time), start_time=to_datetime(pb_object.data.start_time), - duration=timedelta(seconds=pb_object.data.duration), + duration=( + timedelta(seconds=pb_object.data.duration) + if pb_object.data.duration + else None + ), selector=component_selector_from_protobuf(pb_object.data.selector), active=pb_object.data.is_active, dry_run=pb_object.data.is_dry_run, @@ -332,23 +338,28 @@ def to_protobuf(self) -> PBDispatch: Returns: The converted protobuf dispatch. """ - pb_dispatch = PBDispatch() - - pb_dispatch.metadata.dispatch_id = self.id - pb_dispatch.metadata.create_time.CopyFrom(to_timestamp(self.create_time)) - pb_dispatch.metadata.modification_time.CopyFrom(to_timestamp(self.update_time)) - pb_dispatch.data.type = self.type - pb_dispatch.data.start_time.CopyFrom(to_timestamp(self.start_time)) - pb_dispatch.data.duration = int(self.duration.total_seconds()) - pb_dispatch.data.selector.CopyFrom( - component_selector_to_protobuf(self.selector) + payload = Struct() + payload.update(self.payload) + + return PBDispatch( + metadata=DispatchMetadata( + dispatch_id=self.id, + create_time=to_timestamp(self.create_time), + modification_time=to_timestamp(self.update_time), + ), + data=DispatchData( + type=self.type, + start_time=to_timestamp(self.start_time), + duration=( + round(self.duration.total_seconds()) if self.duration else None + ), + selector=component_selector_to_protobuf(self.selector), + is_active=self.active, + is_dry_run=self.dry_run, + payload=payload, + recurrence=self.recurrence.to_protobuf(), + ), ) - pb_dispatch.data.is_active = self.active - pb_dispatch.data.is_dry_run = self.dry_run - pb_dispatch.data.payload.update(self.payload) - pb_dispatch.data.recurrence.CopyFrom(self.recurrence.to_protobuf()) - - return pb_dispatch class Event(IntEnum): diff --git a/tests/test_dispatch_cli.py b/tests/test_dispatch_cli.py index 37eb1790..25f2c93b 100644 --- a/tests/test_dispatch_cli.py +++ b/tests/test_dispatch_cli.py @@ -172,9 +172,9 @@ async def test_list_command( # pylint: disable=too-many-arguments "create", "829", "test", + "BATTERY", "in 1 hour", "1h", - "BATTERY", "--active", "False", ], @@ -192,9 +192,9 @@ async def test_list_command( # pylint: disable=too-many-arguments "create", "1", "test", + "1,2,3", "in 2 hours", "1 hour", - "1,2,3", "--dry-run", "true", ], @@ -223,9 +223,9 @@ async def test_list_command( # pylint: disable=too-many-arguments "create", "1", "test", + "CHP", "in 1 hour", "1h", - "CHP", "--frequency", "hourly", "--interval", @@ -274,9 +274,9 @@ async def test_list_command( # pylint: disable=too-many-arguments "create", "50", "test50", + "EV_CHARGER", "in 5 hours", "1h", - "EV_CHARGER", "--frequency", "daily", "--until", @@ -353,8 +353,9 @@ async def test_create_command( # pylint: disable=too-many-arguments,too-many-lo (now + expected_start_time_delta).astimezone(timezone.utc).timestamp(), abs=2, ) - assert created_dispatch.duration.total_seconds() == pytest.approx( - expected_duration.total_seconds(), abs=2 + assert created_dispatch.duration and ( + created_dispatch.duration.total_seconds() + == pytest.approx(expected_duration.total_seconds(), abs=2) ) assert created_dispatch.selector == expected_selector assert created_dispatch.recurrence == expected_reccurence diff --git a/tests/test_dispatch_client.py b/tests/test_dispatch_client.py index e64c373a..13370b21 100644 --- a/tests/test_dispatch_client.py +++ b/tests/test_dispatch_client.py @@ -5,6 +5,7 @@ import random from dataclasses import replace +from datetime import timedelta import grpc from pytest import raises @@ -63,6 +64,15 @@ async def test_create_return_dispatch( assert dispatch == sample +async def test_create_duration_none(client: FakeClient, sample: Dispatch) -> None: + """Test creating a dispatch with a None duration.""" + microgrid_id = random.randint(1, 100) + sample = replace(sample, duration=None) + dispatch = await client.create(**to_create_params(microgrid_id, sample)) + sample = _update_metadata(sample, dispatch) + assert dispatch == sample + + async def test_list_dispatches( client: FakeClient, generator: DispatchGenerator ) -> None: @@ -80,6 +90,25 @@ async def test_list_dispatches( assert dispatch in client.dispatches(microgrid_id=1) +async def test_list_dispatches_no_duration( + client: FakeClient, generator: DispatchGenerator +) -> None: + """Test listing dispatches with a None duration.""" + microgrid_id = random.randint(1, 100) + + client.set_dispatches( + microgrid_id=microgrid_id, + value=[ + replace(generator.generate_dispatch(), duration=None) for _ in range(100) + ], + ) + + dispatches = client.list(microgrid_id=1) + async for page in dispatches: + for dispatch in page: + assert dispatch in client.dispatches(microgrid_id=1) + + async def test_list_create_dispatches( client: FakeClient, generator: DispatchGenerator ) -> None: @@ -124,6 +153,41 @@ async def test_update_dispatch(client: FakeClient, sample: Dispatch) -> None: assert client.dispatches(microgrid_id)[0].recurrence.interval == 4 +async def test_update_dispatch_to_no_duration( + client: FakeClient, sample: Dispatch +) -> None: + """Test updating the duration field of a dispatch to None.""" + microgrid_id = random.randint(1, 100) + client.set_dispatches( + microgrid_id=microgrid_id, + value=[replace(sample, duration=timedelta(minutes=10))], + ) + + await client.update( + microgrid_id=microgrid_id, + dispatch_id=sample.id, + new_fields={"duration": None}, + ) + assert client.dispatches(microgrid_id)[0].duration is None + + +async def test_update_dispatch_from_no_duration( + client: FakeClient, sample: Dispatch +) -> None: + """Test updating the duration field of a dispatch from None.""" + microgrid_id = random.randint(1, 100) + client.set_dispatches( + microgrid_id=microgrid_id, value=[replace(sample, duration=None)] + ) + + await client.update( + microgrid_id=microgrid_id, + dispatch_id=sample.id, + new_fields={"duration": timedelta(minutes=10)}, + ) + assert client.dispatches(microgrid_id)[0].duration == timedelta(minutes=10) + + async def test_update_dispatch_fail(client: FakeClient, sample: Dispatch) -> None: """Test updating the type and dry_run fields of a dispatch.""" microgrid_id = random.randint(1, 100) @@ -160,6 +224,20 @@ async def test_get_dispatch(client: FakeClient, sample: Dispatch) -> None: ) +async def test_get_dispatch_no_duration(client: FakeClient, sample: Dispatch) -> None: + """Test getting a dispatch with a None duration.""" + microgrid_id = random.randint(1, 100) + sample = replace(sample, duration=None) + dispatch = await client.create(**to_create_params(microgrid_id, sample)) + + sample = _update_metadata(sample, dispatch) + assert dispatch == sample + + assert ( + await client.get(microgrid_id=microgrid_id, dispatch_id=dispatch.id) == dispatch + ) + + async def test_get_dispatch_fail(client: FakeClient) -> None: """Test getting a non-existent dispatch.""" with raises(grpc.RpcError):