Skip to content

Commit ff7e3f1

Browse files
authored
Add support for duration=None (#85)
2 parents 232b3f3 + e528986 commit ff7e3f1

File tree

9 files changed

+186
-63
lines changed

9 files changed

+186
-63
lines changed

RELEASE_NOTES.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,16 @@
22

33
## Summary
44

5-
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.
5+
<!-- Here goes a general summary of what this release is about -->
66

77
## Upgrading
88

9-
- The `Client.list()` function now yields a `list[Dispatch]` representing one page of dispatches
10-
- `Client.__init__` no longer accepts a `grpc_channel` argument, instead a `server_url` argument is required.
11-
- 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`)
9+
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
1210

1311
## New Features
1412

15-
- Pagination support in the dispatch list request.
16-
- `Client.__init__`:
17-
- Has a new parameter `connect` which is a boolean that determines if the client should connect to the server on initialization.
18-
- Automatically sets up the channel for encrypted TLS communication.
19-
- A new method `stream()` to receive dispatch events in real-time.
13+
* Added support for duration=None when creating a dispatch.
14+
15+
## Bug Fixes
16+
17+
<!-- Here goes notable bug fixes that are worth a special mention or explanation -->

src/frequenz/client/dispatch/__main__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,9 @@ def validate_reccurance(ctx: click.Context, param: click.Parameter, value: Any)
239239
required=True,
240240
type=str,
241241
)
242-
@click.argument("start-time", required=True, type=FuzzyDateTime())
243-
@click.argument("duration", required=True, type=FuzzyTimeDelta())
244242
@click.argument("selector", required=True, type=SelectorParamType())
243+
@click.argument("start-time", required=True, type=FuzzyDateTime())
244+
@click.argument("duration", required=False, type=FuzzyTimeDelta())
245245
@click.option("--active", "-a", type=bool, default=True)
246246
@click.option("--dry-run", "-d", type=bool, default=False)
247247
@click.option(
@@ -279,6 +279,7 @@ async def create(
279279
@click.argument("dispatch_id", type=int)
280280
@click.option("--start-time", type=FuzzyDateTime())
281281
@click.option("--duration", type=FuzzyTimeDelta())
282+
@click.option("--no-duration", is_flag=True)
282283
@click.option("--selector", type=SelectorParamType())
283284
@click.option("--active", type=bool)
284285
@click.option(
@@ -310,6 +311,13 @@ def skip_field(value: Any) -> bool:
310311
if len(new_fields) == 0:
311312
raise click.BadArgumentUsage("At least one field must be given to update.")
312313

314+
if new_fields.get("no_duration"):
315+
if new_fields.get("duration"):
316+
raise click.BadArgumentUsage("Cannot set both no_duration and duration.")
317+
new_fields["duration"] = None # type: ignore
318+
319+
new_fields.pop("no_duration")
320+
313321
try:
314322
changed_dispatch = await ctx.obj["client"].update(
315323
microgrid_id=microgrid_id, dispatch_id=dispatch_id, new_fields=new_fields

src/frequenz/client/dispatch/_client.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ async def create(
229229
microgrid_id: int,
230230
type: str, # pylint: disable=redefined-builtin
231231
start_time: datetime,
232-
duration: timedelta,
232+
duration: timedelta | None,
233233
selector: ComponentSelector,
234234
active: bool = True,
235235
dry_run: bool = False,
@@ -245,7 +245,8 @@ async def create(
245245
microgrid_id: The microgrid_id to create the dispatch for.
246246
type: User defined string to identify the dispatch type.
247247
start_time: The start time of the dispatch.
248-
duration: The duration of the dispatch.
248+
duration: The duration of the dispatch. Can be `None` for infinite
249+
or no-duration dispatches (e.g. switching a component on).
249250
selector: The component selector for the dispatch.
250251
active: The active status of the dispatch.
251252
dry_run: The dry_run status of the dispatch.
@@ -325,7 +326,10 @@ async def update(
325326
case "start_time":
326327
msg.update.start_time.CopyFrom(to_timestamp(val))
327328
case "duration":
328-
msg.update.duration = int(val.total_seconds())
329+
if val is None:
330+
msg.update.ClearField("duration")
331+
else:
332+
msg.update.duration = round(val.total_seconds())
329333
case "selector":
330334
msg.update.selector.CopyFrom(component_selector_to_protobuf(val))
331335
case "is_active":

src/frequenz/client/dispatch/_internal_types.py

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
from frequenz.api.dispatch.v1.dispatch_pb2 import (
1313
CreateMicrogridDispatchRequest as PBDispatchCreateRequest,
1414
)
15-
16-
# pylint: enable=no-name-in-module
15+
from frequenz.api.dispatch.v1.dispatch_pb2 import DispatchData
1716
from google.protobuf.json_format import MessageToDict
17+
from google.protobuf.struct_pb2 import Struct
1818

1919
from frequenz.client.base.conversion import to_datetime, to_timestamp
2020

@@ -25,6 +25,8 @@
2525
component_selector_to_protobuf,
2626
)
2727

28+
# pylint: enable=no-name-in-module
29+
2830

2931
# pylint: disable=too-many-instance-attributes
3032
@dataclass(kw_only=True)
@@ -42,8 +44,12 @@ class DispatchCreateRequest:
4244
start_time: datetime
4345
"""The start time of the dispatch in UTC."""
4446

45-
duration: timedelta
46-
"""The duration of the dispatch, represented as a timedelta."""
47+
duration: timedelta | None
48+
"""The duration of the dispatch, represented as a timedelta.
49+
50+
If None, the dispatch is considered to be "infinite" or "instantaneous",
51+
like a command to turn on a component.
52+
"""
4753

4854
selector: ComponentSelector
4955
"""The component selector specifying which components the dispatch targets."""
@@ -78,13 +84,19 @@ def from_protobuf(
7884
Returns:
7985
The converted dispatch.
8086
"""
87+
duration = (
88+
timedelta(seconds=pb_object.dispatch_data.duration)
89+
if pb_object.dispatch_data.HasField("duration")
90+
else None
91+
)
92+
8193
return DispatchCreateRequest(
8294
microgrid_id=pb_object.microgrid_id,
8395
type=pb_object.dispatch_data.type,
8496
start_time=rounded_start_time(
8597
to_datetime(pb_object.dispatch_data.start_time)
8698
),
87-
duration=timedelta(seconds=pb_object.dispatch_data.duration),
99+
duration=duration,
88100
selector=component_selector_from_protobuf(pb_object.dispatch_data.selector),
89101
active=pb_object.dispatch_data.is_active,
90102
dry_run=pb_object.dispatch_data.is_dry_run,
@@ -98,24 +110,24 @@ def to_protobuf(self) -> PBDispatchCreateRequest:
98110
Returns:
99111
The converted protobuf dispatch create request.
100112
"""
101-
pb_request = PBDispatchCreateRequest()
102-
103-
pb_request.microgrid_id = self.microgrid_id
104-
pb_request.dispatch_data.type = self.type
105-
pb_request.dispatch_data.start_time.CopyFrom(to_timestamp(self.start_time))
106-
pb_request.dispatch_data.duration = round(self.duration.total_seconds())
107-
pb_request.dispatch_data.selector.CopyFrom(
108-
component_selector_to_protobuf(self.selector)
113+
payload = Struct()
114+
payload.update(self.payload)
115+
116+
return PBDispatchCreateRequest(
117+
microgrid_id=self.microgrid_id,
118+
dispatch_data=DispatchData(
119+
type=self.type,
120+
start_time=to_timestamp(self.start_time),
121+
duration=(
122+
round(self.duration.total_seconds()) if self.duration else None
123+
),
124+
selector=component_selector_to_protobuf(self.selector),
125+
is_active=self.active,
126+
is_dry_run=self.dry_run,
127+
payload=payload,
128+
recurrence=self.recurrence.to_protobuf() if self.recurrence else None,
129+
),
109130
)
110-
pb_request.dispatch_data.is_active = self.active
111-
pb_request.dispatch_data.is_dry_run = self.dry_run
112-
pb_request.dispatch_data.payload.update(self.payload)
113-
if self.recurrence:
114-
pb_request.dispatch_data.recurrence.CopyFrom(self.recurrence.to_protobuf())
115-
else:
116-
pb_request.dispatch_data.ClearField("recurrence")
117-
118-
return pb_request
119131

120132

121133
def rounded_start_time(start_time: datetime) -> datetime:

src/frequenz/client/dispatch/test/_service.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,16 @@ def _filter_dispatch(dispatch: Dispatch, request: PBDispatchListRequest) -> bool
142142
return False
143143
if _filter.HasField("end_time_interval"):
144144
if end_from := _filter.end_time_interval.__dict__["from"]:
145-
if dispatch.start_time + dispatch.duration < _to_dt(end_from):
145+
if (
146+
dispatch.duration
147+
and dispatch.start_time + dispatch.duration < _to_dt(end_from)
148+
):
146149
return False
147150
if end_to := _filter.end_time_interval.to:
148-
if dispatch.start_time + dispatch.duration >= _to_dt(end_to):
151+
if (
152+
dispatch.duration
153+
and dispatch.start_time + dispatch.duration >= _to_dt(end_to)
154+
):
149155
return False
150156
if _filter.HasField("is_active"):
151157
if dispatch.active != _filter.is_active:

src/frequenz/client/dispatch/test/generator.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,12 @@ def generate_dispatch(self) -> Dispatch:
8585
datetime.now(tz=timezone.utc)
8686
+ timedelta(seconds=self._rng.randint(0, 1000000))
8787
),
88-
duration=timedelta(seconds=self._rng.randint(0, 1000000)),
88+
duration=self._rng.choice(
89+
[
90+
None,
91+
timedelta(seconds=self._rng.randint(0, 1000000)),
92+
]
93+
),
8994
selector=self._rng.choice( # type: ignore
9095
[
9196
[

src/frequenz/client/dispatch/types.py

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
ComponentSelector as PBComponentSelector,
1515
)
1616
from frequenz.api.dispatch.v1.dispatch_pb2 import Dispatch as PBDispatch
17+
from frequenz.api.dispatch.v1.dispatch_pb2 import DispatchData, DispatchMetadata
1718
from frequenz.api.dispatch.v1.dispatch_pb2 import RecurrenceRule as PBRecurrenceRule
1819
from frequenz.api.dispatch.v1.dispatch_pb2 import StreamMicrogridDispatchesResponse
1920
from google.protobuf.json_format import MessageToDict
21+
from google.protobuf.struct_pb2 import Struct
2022

2123
from frequenz.client.base.conversion import to_datetime, to_timestamp
2224

@@ -272,7 +274,7 @@ class Dispatch:
272274
start_time: datetime
273275
"""The start time of the dispatch in UTC."""
274276

275-
duration: timedelta
277+
duration: timedelta | None
276278
"""The duration of the dispatch, represented as a timedelta."""
277279

278280
selector: ComponentSelector
@@ -318,7 +320,11 @@ def from_protobuf(cls, pb_object: PBDispatch) -> "Dispatch":
318320
create_time=to_datetime(pb_object.metadata.create_time),
319321
update_time=to_datetime(pb_object.metadata.modification_time),
320322
start_time=to_datetime(pb_object.data.start_time),
321-
duration=timedelta(seconds=pb_object.data.duration),
323+
duration=(
324+
timedelta(seconds=pb_object.data.duration)
325+
if pb_object.data.duration
326+
else None
327+
),
322328
selector=component_selector_from_protobuf(pb_object.data.selector),
323329
active=pb_object.data.is_active,
324330
dry_run=pb_object.data.is_dry_run,
@@ -332,23 +338,28 @@ def to_protobuf(self) -> PBDispatch:
332338
Returns:
333339
The converted protobuf dispatch.
334340
"""
335-
pb_dispatch = PBDispatch()
336-
337-
pb_dispatch.metadata.dispatch_id = self.id
338-
pb_dispatch.metadata.create_time.CopyFrom(to_timestamp(self.create_time))
339-
pb_dispatch.metadata.modification_time.CopyFrom(to_timestamp(self.update_time))
340-
pb_dispatch.data.type = self.type
341-
pb_dispatch.data.start_time.CopyFrom(to_timestamp(self.start_time))
342-
pb_dispatch.data.duration = int(self.duration.total_seconds())
343-
pb_dispatch.data.selector.CopyFrom(
344-
component_selector_to_protobuf(self.selector)
341+
payload = Struct()
342+
payload.update(self.payload)
343+
344+
return PBDispatch(
345+
metadata=DispatchMetadata(
346+
dispatch_id=self.id,
347+
create_time=to_timestamp(self.create_time),
348+
modification_time=to_timestamp(self.update_time),
349+
),
350+
data=DispatchData(
351+
type=self.type,
352+
start_time=to_timestamp(self.start_time),
353+
duration=(
354+
round(self.duration.total_seconds()) if self.duration else None
355+
),
356+
selector=component_selector_to_protobuf(self.selector),
357+
is_active=self.active,
358+
is_dry_run=self.dry_run,
359+
payload=payload,
360+
recurrence=self.recurrence.to_protobuf(),
361+
),
345362
)
346-
pb_dispatch.data.is_active = self.active
347-
pb_dispatch.data.is_dry_run = self.dry_run
348-
pb_dispatch.data.payload.update(self.payload)
349-
pb_dispatch.data.recurrence.CopyFrom(self.recurrence.to_protobuf())
350-
351-
return pb_dispatch
352363

353364

354365
class Event(IntEnum):

tests/test_dispatch_cli.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,9 @@ async def test_list_command( # pylint: disable=too-many-arguments
172172
"create",
173173
"829",
174174
"test",
175+
"BATTERY",
175176
"in 1 hour",
176177
"1h",
177-
"BATTERY",
178178
"--active",
179179
"False",
180180
],
@@ -192,9 +192,9 @@ async def test_list_command( # pylint: disable=too-many-arguments
192192
"create",
193193
"1",
194194
"test",
195+
"1,2,3",
195196
"in 2 hours",
196197
"1 hour",
197-
"1,2,3",
198198
"--dry-run",
199199
"true",
200200
],
@@ -223,9 +223,9 @@ async def test_list_command( # pylint: disable=too-many-arguments
223223
"create",
224224
"1",
225225
"test",
226+
"CHP",
226227
"in 1 hour",
227228
"1h",
228-
"CHP",
229229
"--frequency",
230230
"hourly",
231231
"--interval",
@@ -274,9 +274,9 @@ async def test_list_command( # pylint: disable=too-many-arguments
274274
"create",
275275
"50",
276276
"test50",
277+
"EV_CHARGER",
277278
"in 5 hours",
278279
"1h",
279-
"EV_CHARGER",
280280
"--frequency",
281281
"daily",
282282
"--until",
@@ -353,8 +353,9 @@ async def test_create_command( # pylint: disable=too-many-arguments,too-many-lo
353353
(now + expected_start_time_delta).astimezone(timezone.utc).timestamp(),
354354
abs=2,
355355
)
356-
assert created_dispatch.duration.total_seconds() == pytest.approx(
357-
expected_duration.total_seconds(), abs=2
356+
assert created_dispatch.duration and (
357+
created_dispatch.duration.total_seconds()
358+
== pytest.approx(expected_duration.total_seconds(), abs=2)
358359
)
359360
assert created_dispatch.selector == expected_selector
360361
assert created_dispatch.recurrence == expected_reccurence

0 commit comments

Comments
 (0)