Skip to content

Commit 5db3b6c

Browse files
committed
Add support for start_immediately
Signed-off-by: Mathias L. Baumann <[email protected]>
1 parent 8e97d4f commit 5db3b6c

File tree

7 files changed

+84
-23
lines changed

7 files changed

+84
-23
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
* Update BaseApiClient to get the http2 keepalive feature.
66
* 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`.
7+
* 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!
78

89
## Bug Fixes
910

src/frequenz/client/dispatch/_cli_types.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import json
77
from datetime import datetime, timedelta, timezone
8-
from typing import Any, cast
8+
from typing import Any, Literal, cast
99

1010
import asyncclick as click
1111
import parsedatetime # type: ignore
@@ -32,12 +32,15 @@ def __init__(self) -> None:
3232

3333
def convert(
3434
self, value: Any, param: click.Parameter | None, ctx: click.Context | None
35-
) -> datetime:
36-
"""Convert the value to a datetime object."""
35+
) -> datetime | Literal["NOW"] | None:
36+
"""Convert the value to a datetime object or the string "NOW"."""
3737
if isinstance(value, datetime):
3838
return value
3939

4040
try:
41+
if value.upper() == "NOW":
42+
return "NOW"
43+
4144
parsed_dt, parse_status = self.cal.parseDT(value, tzinfo=self.local_tz)
4245
if parse_status == 0:
4346
self.fail(f"Invalid time expression: {value}", param, ctx)

src/frequenz/client/dispatch/_client.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from datetime import datetime, timedelta
88
from importlib.resources import files
99
from pathlib import Path
10-
from typing import Any, AsyncIterator, Awaitable, Iterator, cast
10+
from typing import Any, AsyncIterator, Awaitable, Iterator, Literal, cast
1111

1212
# pylint: disable=no-name-in-module
1313
from frequenz.api.common.v1.pagination.pagination_params_pb2 import PaginationParams
@@ -254,7 +254,7 @@ async def create( # pylint: disable=too-many-positional-arguments
254254
self,
255255
microgrid_id: int,
256256
type: str, # pylint: disable=redefined-builtin
257-
start_time: datetime,
257+
start_time: datetime | Literal["NOW"],
258258
duration: timedelta | None,
259259
target: TargetComponents,
260260
*,
@@ -268,7 +268,7 @@ async def create( # pylint: disable=too-many-positional-arguments
268268
Args:
269269
microgrid_id: The microgrid_id to create the dispatch for.
270270
type: User defined string to identify the dispatch type.
271-
start_time: The start time of the dispatch.
271+
start_time: The start time of the dispatch. Can be "NOW" for immediate start.
272272
duration: The duration of the dispatch. Can be `None` for infinite
273273
or no-duration dispatches (e.g. switching a component on).
274274
target: The component target for the dispatch.
@@ -283,12 +283,16 @@ async def create( # pylint: disable=too-many-positional-arguments
283283
Raises:
284284
ValueError: If start_time is in the past.
285285
"""
286-
if start_time <= datetime.now(tz=start_time.tzinfo):
287-
raise ValueError("start_time must not be in the past")
288-
289-
# Raise if it's not UTC
290-
if start_time.tzinfo is None or start_time.tzinfo.utcoffset(start_time) is None:
291-
raise ValueError("start_time must be timezone aware")
286+
if isinstance(start_time, datetime):
287+
if start_time <= datetime.now(tz=start_time.tzinfo):
288+
raise ValueError("start_time must not be in the past")
289+
290+
# Raise if it's not UTC
291+
if (
292+
start_time.tzinfo is None
293+
or start_time.tzinfo.utcoffset(start_time) is None
294+
):
295+
raise ValueError("start_time must be timezone aware")
292296

293297
request = DispatchCreateRequest(
294298
microgrid_id=microgrid_id,

src/frequenz/client/dispatch/_internal_types.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from dataclasses import dataclass
88
from datetime import datetime, timedelta
9-
from typing import Any
9+
from typing import Any, Literal
1010

1111
# pylint: disable=no-name-in-module
1212
from frequenz.api.dispatch.v1.dispatch_pb2 import (
@@ -15,6 +15,7 @@
1515
from frequenz.api.dispatch.v1.dispatch_pb2 import DispatchData
1616
from google.protobuf.json_format import MessageToDict
1717
from google.protobuf.struct_pb2 import Struct
18+
from google.protobuf.timestamp_pb2 import Timestamp
1819

1920
from frequenz.client.base.conversion import to_datetime, to_timestamp
2021

@@ -41,7 +42,7 @@ class DispatchCreateRequest:
4142
4243
This is understood and processed by downstream applications."""
4344

44-
start_time: datetime
45+
start_time: datetime | Literal["NOW"]
4546
"""The start time of the dispatch in UTC."""
4647

4748
duration: timedelta | None
@@ -92,8 +93,10 @@ def from_protobuf(
9293
return DispatchCreateRequest(
9394
microgrid_id=pb_object.microgrid_id,
9495
type=pb_object.dispatch_data.type,
95-
start_time=rounded_start_time(
96-
to_datetime(pb_object.dispatch_data.start_time)
96+
start_time=(
97+
"NOW"
98+
if pb_object.start_immediately
99+
else rounded_start_time(to_datetime(pb_object.dispatch_data.start_time))
97100
),
98101
duration=duration,
99102
target=_target_components_from_protobuf(pb_object.dispatch_data.target),
@@ -116,7 +119,11 @@ def to_protobuf(self) -> PBDispatchCreateRequest:
116119
microgrid_id=self.microgrid_id,
117120
dispatch_data=DispatchData(
118121
type=self.type,
119-
start_time=to_timestamp(self.start_time),
122+
start_time=(
123+
to_timestamp(self.start_time)
124+
if isinstance(self.start_time, datetime)
125+
else Timestamp()
126+
),
120127
duration=(
121128
round(self.duration.total_seconds()) if self.duration else None
122129
),
@@ -126,6 +133,7 @@ def to_protobuf(self) -> PBDispatchCreateRequest:
126133
payload=payload,
127134
recurrence=self.recurrence.to_protobuf() if self.recurrence else None,
128135
),
136+
start_immediately=self.start_time == "NOW",
129137
)
130138

131139

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,9 @@ def _dispatch_from_request(
397397
params = _request.__dict__
398398
params.pop("microgrid_id")
399399

400+
if _request.start_time == "NOW":
401+
params["start_time"] = datetime.now(tz=timezone.utc)
402+
400403
return Dispatch(
401404
id=_id,
402405
create_time=create_time,

tests/test_cli.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from dataclasses import replace
77
from datetime import datetime, timedelta, timezone
8-
from typing import Any, Generator
8+
from typing import Any, Generator, Literal
99
from unittest.mock import patch
1010

1111
import pytest
@@ -309,6 +309,24 @@ async def test_list_command(
309309
),
310310
0,
311311
),
312+
(
313+
[
314+
"create",
315+
"1",
316+
"test_start_immediately",
317+
"BATTERY",
318+
"now",
319+
"1h",
320+
],
321+
1,
322+
"test_start_immediately",
323+
"NOW",
324+
timedelta(seconds=3600),
325+
[ComponentCategory.BATTERY],
326+
{},
327+
RecurrenceRule(),
328+
0,
329+
),
312330
],
313331
)
314332
async def test_create_command(
@@ -317,7 +335,7 @@ async def test_create_command(
317335
args: list[str],
318336
expected_microgrid_id: int,
319337
expected_type: str,
320-
expected_start_time_delta: timedelta,
338+
expected_start_time_delta: timedelta | Literal["NOW"],
321339
expected_duration: timedelta,
322340
expected_target: list[int] | list[ComponentCategory],
323341
expected_options: dict[str, Any],
@@ -355,10 +373,17 @@ async def test_create_command(
355373
assert len(dispatches) == 1
356374
created_dispatch = dispatches[0]
357375
assert created_dispatch.type == expected_type
358-
assert created_dispatch.start_time.timestamp() == pytest.approx(
359-
(now + expected_start_time_delta).astimezone(timezone.utc).timestamp(),
360-
abs=2,
361-
)
376+
377+
if isinstance(expected_start_time_delta, timedelta):
378+
assert created_dispatch.start_time.timestamp() == pytest.approx(
379+
(now + expected_start_time_delta).astimezone(timezone.utc).timestamp(),
380+
abs=2,
381+
)
382+
else:
383+
assert created_dispatch.start_time.timestamp() == pytest.approx(
384+
now.astimezone(timezone.utc).timestamp(), abs=2
385+
)
386+
362387
assert created_dispatch.duration and (
363388
created_dispatch.duration.total_seconds()
364389
== pytest.approx(expected_duration.total_seconds(), abs=2)

tests/test_proto.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,20 @@ def test_dispatch_create_request_with_no_recurrence() -> None:
149149
)
150150

151151
assert request.to_protobuf().dispatch_data.HasField("recurrence") is False
152+
153+
154+
def test_dispatch_create_start_immediately() -> None:
155+
"""Test the dispatch create request with no start time."""
156+
request = DispatchCreateRequest(
157+
microgrid_id=123,
158+
type="test",
159+
start_time="NOW",
160+
duration=timedelta(days=10),
161+
target=[1, 2, 3],
162+
active=True,
163+
dry_run=False,
164+
payload={"key": "value"},
165+
recurrence=RecurrenceRule(),
166+
)
167+
168+
assert request == DispatchCreateRequest.from_protobuf(request.to_protobuf())

0 commit comments

Comments
 (0)