Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<!-- Here goes a general summary of what this release is about -->

## 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`)
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->

## 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

<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
12 changes: 10 additions & 2 deletions src/frequenz/client/dispatch/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/frequenz/client/dispatch/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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":
Expand Down
56 changes: 34 additions & 22 deletions src/frequenz/client/dispatch/_internal_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -25,6 +25,8 @@
component_selector_to_protobuf,
)

# pylint: enable=no-name-in-module

Comment on lines +28 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't look right here, but you could also just disable it globally in the whole project as mypy will check for this anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is mostly at this place due to black

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it is weird, it has to be just before the next line.


# pylint: disable=too-many-instance-attributes
@dataclass(kw_only=True)
Expand All @@ -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."""
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions src/frequenz/client/dispatch/test/_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion src/frequenz/client/dispatch/test/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
[
[
Expand Down
47 changes: 29 additions & 18 deletions src/frequenz/client/dispatch/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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):
Expand Down
13 changes: 7 additions & 6 deletions tests/test_dispatch_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand All @@ -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",
],
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading