Skip to content

Commit ec026f9

Browse files
authored
Update API and add support for start_immediately (frequenz-floss#105)
2 parents a6a2b1d + 5db3b6c commit ec026f9

File tree

13 files changed

+187
-130
lines changed

13 files changed

+187
-130
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

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ classifiers = [
3737
requires-python = ">= 3.11, < 4"
3838
dependencies = [
3939
"typing-extensions >= 4.6.1, < 5",
40-
"frequenz-api-dispatch >= 0.15.1, < 0.16",
40+
"frequenz-api-dispatch == 1.0.0-rc1",
4141
"frequenz-client-base >= 0.7.0, < 0.8.0",
4242
"frequenz-client-common >= 0.1.0, < 0.3.0",
4343
"grpcio >= 1.66.1, < 2",
@@ -87,7 +87,7 @@ dev-pylint = [
8787
"pylint == 3.3.1",
8888
# For checking the noxfile, docs/ script, and tests
8989
"frequenz-client-dispatch[cli,dev-mkdocs,dev-noxfile,dev-pytest]",
90-
"frequenz-api-dispatch >= 0.15.1, < 0.16",
90+
"frequenz-api-dispatch == 1.0.0-rc1",
9191
]
9292
dev-pytest = [
9393
"pytest == 8.3.3",

src/frequenz/client/dispatch/__main__.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
FuzzyIntRange,
2929
FuzzyTimeDelta,
3030
JsonDictParamType,
31-
SelectorParamType,
31+
TargetComponentParamType,
3232
)
3333
from ._client import Client
3434

@@ -79,7 +79,7 @@ async def cli(ctx: click.Context, url: str, key: str) -> None:
7979
@cli.command("list")
8080
@click.pass_context
8181
@click.argument("microgrid-id", required=True, type=int)
82-
@click.option("--selector", "-s", type=SelectorParamType(), multiple=True)
82+
@click.option("--target", "-t", type=TargetComponentParamType(), multiple=True)
8383
@click.option("--start-from", type=FuzzyDateTime())
8484
@click.option("--start-to", type=FuzzyDateTime())
8585
@click.option("--end-from", type=FuzzyDateTime())
@@ -92,11 +92,12 @@ async def list_(ctx: click.Context, /, **filters: Any) -> None:
9292
9393
Lists all dispatches for MICROGRID_ID that match the given filters.
9494
95-
The selector option can be given multiple times.
95+
The target option can be given multiple times.
9696
"""
97-
if "selector" in filters:
98-
selector = filters.pop("selector")
99-
filters["component_selectors"] = selector
97+
if "target" in filters:
98+
target = filters.pop("target")
99+
# Name of the parameter in client.list()
100+
filters["target_components"] = target
100101

101102
num_dispatches = 0
102103
async for page in ctx.obj["client"].list(**filters):
@@ -186,7 +187,7 @@ def validate_reccurance(ctx: click.Context, param: click.Parameter, value: Any)
186187
["--interval"],
187188
type=int,
188189
help="Interval of the dispatch, based on frequency",
189-
default=0,
190+
default=1,
190191
),
191192
click.Option(
192193
["--count"],
@@ -241,7 +242,7 @@ def validate_reccurance(ctx: click.Context, param: click.Parameter, value: Any)
241242
required=True,
242243
type=str,
243244
)
244-
@click.argument("selector", required=True, type=SelectorParamType())
245+
@click.argument("target", required=True, type=TargetComponentParamType())
245246
@click.argument("start-time", required=True, type=FuzzyDateTime())
246247
@click.argument("duration", required=False, type=FuzzyTimeDelta())
247248
@click.option("--active", "-a", type=bool, default=True)
@@ -260,7 +261,7 @@ async def create(
260261
Creates a new dispatch for MICROGRID_ID of type TYPE running for DURATION seconds
261262
starting at START_TIME.
262263
263-
SELECTOR is a comma-separated list of either component categories or component IDs.
264+
TARGET is a comma-separated list of either component categories or component IDs.
264265
Possible component categories: "BATTERY, GRID, METER, INVERTER, EV_CHARGER, CHP".
265266
"""
266267
# Remove keys with `None` value
@@ -286,7 +287,7 @@ async def create(
286287
@click.option("--start-time", type=FuzzyDateTime())
287288
@click.option("--duration", type=FuzzyTimeDelta())
288289
@click.option("--no-duration", is_flag=True)
289-
@click.option("--selector", type=SelectorParamType())
290+
@click.option("--target", type=TargetComponentParamType())
290291
@click.option("--active", type=bool)
291292
@click.option(
292293
"--payload", "-p", type=JsonDictParamType(), help="JSON payload for the dispatch"

src/frequenz/client/dispatch/_cli_types.py

Lines changed: 10 additions & 7 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)
@@ -130,10 +133,10 @@ def convert(
130133
self.fail(f"Invalid integer range: {value}", param, ctx)
131134

132135

133-
class SelectorParamType(click.ParamType):
134-
"""Click parameter type for selectors."""
136+
class TargetComponentParamType(click.ParamType):
137+
"""Click parameter type for targets."""
135138

136-
name = "selector"
139+
name = "target"
137140

138141
def convert(
139142
self, value: Any, param: click.Parameter | None, ctx: click.Context | None
@@ -154,7 +157,7 @@ def convert(
154157
values = value.split(",")
155158

156159
if len(values) == 0:
157-
self.fail("Empty selector list", param, ctx)
160+
self.fail("Empty target list", param, ctx)
158161

159162
error: Exception | None = None
160163
# Attempt to parse component ids

src/frequenz/client/dispatch/_client.py

Lines changed: 23 additions & 19 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
@@ -42,10 +42,10 @@
4242
from ._internal_types import DispatchCreateRequest
4343
from .recurrence import RecurrenceRule
4444
from .types import (
45-
ComponentSelector,
4645
Dispatch,
4746
DispatchEvent,
48-
_component_selector_to_protobuf,
47+
TargetComponents,
48+
_target_components_to_protobuf,
4949
)
5050

5151
# pylint: enable=no-name-in-module
@@ -110,7 +110,7 @@ async def list(
110110
self,
111111
microgrid_id: int,
112112
*,
113-
component_selectors: Iterator[ComponentSelector] = iter(()),
113+
target_components: Iterator[TargetComponents] = iter(()),
114114
start_from: datetime | None = None,
115115
start_to: datetime | None = None,
116116
end_from: datetime | None = None,
@@ -136,7 +136,7 @@ async def list(
136136
137137
Args:
138138
microgrid_id: The microgrid_id to list dispatches for.
139-
component_selectors: optional, list of component ids or categories to filter by.
139+
target_components: optional, list of component ids or categories to filter by.
140140
start_from: optional, filter by start_time >= start_from.
141141
start_to: optional, filter by start_time < start_to.
142142
end_from: optional, filter by end_time >= end_from.
@@ -166,9 +166,9 @@ def to_interval(
166166
# Setup parameters
167167
start_time_interval = to_interval(start_from, start_to)
168168
end_time_interval = to_interval(end_from, end_to)
169-
selectors = list(map(_component_selector_to_protobuf, component_selectors))
169+
targets = list(map(_target_components_to_protobuf, target_components))
170170
filters = DispatchFilter(
171-
selectors=selectors,
171+
targets=targets,
172172
start_time_interval=start_time_interval,
173173
end_time_interval=end_time_interval,
174174
is_active=active,
@@ -254,9 +254,9 @@ 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,
259-
selector: ComponentSelector,
259+
target: TargetComponents,
260260
*,
261261
active: bool = True,
262262
dry_run: bool = False,
@@ -268,10 +268,10 @@ 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).
274-
selector: The component selector for the dispatch.
274+
target: The component target for the dispatch.
275275
active: The active status of the dispatch.
276276
dry_run: The dry_run status of the dispatch.
277277
payload: The payload of the dispatch.
@@ -283,19 +283,23 @@ 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")
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")
288289

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")
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,
295299
type=type,
296300
start_time=start_time,
297301
duration=duration,
298-
selector=selector,
302+
target=target,
299303
active=active,
300304
dry_run=dry_run,
301305
payload=payload or {},
@@ -353,8 +357,8 @@ async def update(
353357
msg.update.ClearField("duration")
354358
else:
355359
msg.update.duration = round(val.total_seconds())
356-
case "selector":
357-
msg.update.selector.CopyFrom(_component_selector_to_protobuf(val))
360+
case "target":
361+
msg.update.target.CopyFrom(_target_components_to_protobuf(val))
358362
case "is_active":
359363
msg.update.is_active = val
360364
case "payload":

src/frequenz/client/dispatch/_internal_types.py

Lines changed: 20 additions & 15 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,14 +15,15 @@
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

2122
from .recurrence import RecurrenceRule
2223
from .types import (
23-
ComponentSelector,
24-
_component_selector_from_protobuf,
25-
_component_selector_to_protobuf,
24+
TargetComponents,
25+
_target_components_from_protobuf,
26+
_target_components_to_protobuf,
2627
)
2728

2829
# pylint: enable=no-name-in-module
@@ -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
@@ -51,8 +52,8 @@ class DispatchCreateRequest:
5152
like a command to turn on a component.
5253
"""
5354

54-
selector: ComponentSelector
55-
"""The component selector specifying which components the dispatch targets."""
55+
target: TargetComponents
56+
"""The target components of the dispatch."""
5657

5758
active: bool
5859
"""Indicates whether the dispatch is active and eligible for processing."""
@@ -69,7 +70,6 @@ class DispatchCreateRequest:
6970

7071
recurrence: RecurrenceRule | None
7172
"""The recurrence rule for the dispatch.
72-
7373
Defining any repeating patterns or schedules."""
7474

7575
@classmethod
@@ -93,13 +93,13 @@ def from_protobuf(
9393
return DispatchCreateRequest(
9494
microgrid_id=pb_object.microgrid_id,
9595
type=pb_object.dispatch_data.type,
96-
start_time=rounded_start_time(
97-
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))
98100
),
99101
duration=duration,
100-
selector=_component_selector_from_protobuf(
101-
pb_object.dispatch_data.selector
102-
),
102+
target=_target_components_from_protobuf(pb_object.dispatch_data.target),
103103
active=pb_object.dispatch_data.is_active,
104104
dry_run=pb_object.dispatch_data.is_dry_run,
105105
payload=MessageToDict(pb_object.dispatch_data.payload),
@@ -119,16 +119,21 @@ def to_protobuf(self) -> PBDispatchCreateRequest:
119119
microgrid_id=self.microgrid_id,
120120
dispatch_data=DispatchData(
121121
type=self.type,
122-
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+
),
123127
duration=(
124128
round(self.duration.total_seconds()) if self.duration else None
125129
),
126-
selector=_component_selector_to_protobuf(self.selector),
130+
target=_target_components_to_protobuf(self.target),
127131
is_active=self.active,
128132
is_dry_run=self.dry_run,
129133
payload=payload,
130134
recurrence=self.recurrence.to_protobuf() if self.recurrence else None,
131135
),
136+
start_immediately=self.start_time == "NOW",
132137
)
133138

134139

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,8 @@ def _filter_dispatch(
176176
"""Filter a dispatch based on the request."""
177177
if request.HasField("filter"):
178178
_filter = request.filter
179-
for selector in _filter.selectors:
180-
if selector != dispatch.selector:
179+
for target in _filter.targets:
180+
if target != dispatch.target:
181181
return False
182182
if _filter.HasField("start_time_interval"):
183183
if start_from := _filter.start_time_interval.__dict__["from"]:
@@ -272,7 +272,7 @@ async def UpdateMicrogridDispatch(
272272
getattr(request.update, split_path[0]),
273273
)
274274
# Fields that need to be copied
275-
case "start_time" | "selector" | "payload":
275+
case "start_time" | "target" | "payload":
276276
getattr(pb_dispatch.data, split_path[0]).CopyFrom(
277277
getattr(request.update, split_path[0])
278278
)
@@ -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,

0 commit comments

Comments
 (0)