Skip to content

Commit 05e2d2b

Browse files
authored
Support payload and recurrence flags in CLI/REPL (#48)
- **Remove commented dead code** - **Add short-notation for some options** - **Actually implement the CLI "payload" option** - **Implement recurrence in the CLI/REPL**
2 parents 8cdfdaf + 4c44f51 commit 05e2d2b

File tree

3 files changed

+322
-19
lines changed

3 files changed

+322
-19
lines changed

src/frequenz/client/dispatch/__main__.py

Lines changed: 139 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,20 @@
1717
from prompt_toolkit.patch_stdout import patch_stdout
1818
from prompt_toolkit.shortcuts import CompleteStyle
1919

20-
from ._cli_types import FuzzyDateTime, FuzzyIntRange, FuzzyTimeDelta, SelectorParamType
20+
from frequenz.client.dispatch.types import (
21+
EndCriteria,
22+
Frequency,
23+
RecurrenceRule,
24+
Weekday,
25+
)
26+
27+
from ._cli_types import (
28+
FuzzyDateTime,
29+
FuzzyIntRange,
30+
FuzzyTimeDelta,
31+
JsonDictParamType,
32+
SelectorParamType,
33+
)
2134
from ._client import Client
2235

2336
DEFAULT_DISPATCH_API_HOST = "88.99.25.81"
@@ -92,6 +105,57 @@ async def list_(ctx: click.Context, /, **filters: Any) -> None:
92105
click.echo(f"{num_dispatches} dispatches total.")
93106

94107

108+
def parse_recurrence(kwargs: dict[str, Any]) -> RecurrenceRule | None:
109+
"""Parse recurrence rule from kwargs."""
110+
interval = kwargs.pop("interval", 0)
111+
by_minute = list(kwargs.pop("by_minute", []))
112+
by_hour = list(kwargs.pop("by_hour", []))
113+
by_weekday = [Weekday[weekday.upper()] for weekday in kwargs.pop("by_weekday", [])]
114+
by_monthday = list(kwargs.pop("by_monthday", []))
115+
116+
if not kwargs.get("frequency"):
117+
return None
118+
119+
return RecurrenceRule(
120+
frequency=Frequency[kwargs.pop("frequency")],
121+
interval=interval,
122+
end_criteria=(
123+
EndCriteria(
124+
count=kwargs.pop("count", None),
125+
until=kwargs.pop("until", None),
126+
)
127+
if kwargs.get("count") or kwargs.get("until")
128+
else None
129+
),
130+
byminutes=by_minute,
131+
byhours=by_hour,
132+
byweekdays=by_weekday,
133+
bymonthdays=by_monthday,
134+
)
135+
136+
137+
def validate_reccurance(ctx: click.Context, param: click.Parameter, value: Any) -> Any:
138+
"""Validate recurrence rule."""
139+
if param.name == "frequency":
140+
return value
141+
142+
count_param = param.name == "count" and value
143+
until_param = param.name == "until" and value
144+
145+
if (
146+
count_param
147+
and ctx.params.get("until") is not None
148+
or until_param
149+
and ctx.params.get("count") is not None
150+
):
151+
raise click.BadArgumentUsage("Only count or until can be set, not both.")
152+
153+
if value and ctx.params.get("frequency") is None:
154+
raise click.BadArgumentUsage(f"Frequency must be set to use {param.name}.")
155+
156+
return value
157+
158+
95159
@cli.command()
96160
@click.argument("microgrid-id", required=True, type=int)
97161
@click.argument(
@@ -102,11 +166,76 @@ async def list_(ctx: click.Context, /, **filters: Any) -> None:
102166
@click.argument("start-time", required=True, type=FuzzyDateTime())
103167
@click.argument("duration", required=True, type=FuzzyTimeDelta())
104168
@click.argument("selector", required=True, type=SelectorParamType())
105-
@click.option("--active", type=bool, default=True)
106-
@click.option("--dry-run", type=bool, default=False)
107-
@click.option("--payload", type=str, help="JSON payload for the dispatch")
108-
@click.option("--recurrence", type=str, help="Recurrence rule (see documentation)")
169+
@click.option("--active", "-a", type=bool, default=True)
170+
@click.option("--dry-run", "-d", type=bool, default=False)
171+
@click.option(
172+
"--payload", "-p", type=JsonDictParamType(), help="JSON payload for the dispatch"
173+
)
109174
@click.pass_context
175+
@click.option(
176+
"--frequency",
177+
"-f",
178+
type=click.Choice(
179+
[
180+
frequency.name
181+
for frequency in Frequency
182+
if frequency != Frequency.UNSPECIFIED
183+
],
184+
case_sensitive=False,
185+
),
186+
help="Frequency of the dispatch",
187+
callback=validate_reccurance,
188+
is_eager=True,
189+
)
190+
@click.option(
191+
"--interval",
192+
type=int,
193+
help="Interval of the dispatch, based on frequency",
194+
default=0,
195+
)
196+
@click.option(
197+
"--count",
198+
type=int,
199+
help="Number of occurrences of the dispatch",
200+
callback=validate_reccurance,
201+
)
202+
@click.option(
203+
"--until",
204+
type=FuzzyDateTime(),
205+
help="End time of the dispatch",
206+
callback=validate_reccurance,
207+
)
208+
@click.option(
209+
"--by-minute",
210+
type=int,
211+
help="Minute of the hour for the dispatch",
212+
multiple=True,
213+
callback=validate_reccurance,
214+
)
215+
@click.option(
216+
"--by-hour",
217+
type=int,
218+
help="Hour of the day for the dispatch",
219+
multiple=True,
220+
callback=validate_reccurance,
221+
)
222+
@click.option(
223+
"--by-weekday",
224+
type=click.Choice(
225+
[weekday.name for weekday in Weekday if weekday != Weekday.UNSPECIFIED],
226+
case_sensitive=False,
227+
),
228+
help="Day of the week for the dispatch",
229+
multiple=True,
230+
callback=validate_reccurance,
231+
)
232+
@click.option(
233+
"--by-monthday",
234+
type=int,
235+
help="Day of the month for the dispatch",
236+
multiple=True,
237+
callback=validate_reccurance,
238+
)
110239
async def create(
111240
ctx: click.Context,
112241
/,
@@ -120,8 +249,12 @@ async def create(
120249
SELECTOR is either one of the following: BATTERY, GRID, METER, INVERTER,
121250
EV_CHARGER, CHP or a list of component IDs separated by commas, e.g. "1,2,3".
122251
"""
252+
# Remove keys with `None` value
253+
kwargs = {k: v for k, v in kwargs.items() if v is not None}
254+
123255
dispatch = await ctx.obj["client"].create(
124256
_type=kwargs.pop("type"),
257+
recurrence=parse_recurrence(kwargs),
125258
**kwargs,
126259
)
127260
click.echo(pformat(dispatch, compact=True))
@@ -148,9 +281,6 @@ async def update(
148281
raise click.BadArgumentUsage("At least one field must be given to update.")
149282

150283
try:
151-
# if duration := new_fields.get("duration"):
152-
# new_fields.pop("duration")
153-
# new_fields["duration"] = timedelta(seconds=int(duration))
154284
await ctx.obj["client"].update(dispatch_id=dispatch_id, new_fields=new_fields)
155285
click.echo("Dispatch updated.")
156286
except grpc.RpcError as e:
@@ -161,7 +291,7 @@ async def update(
161291
@click.argument("dispatch_ids", type=int, nargs=-1) # Allow multiple IDs
162292
@click.pass_context
163293
async def get(ctx: click.Context, dispatch_ids: List[int]) -> None:
164-
"""Get multiple dispatches."""
294+
"""Get one or multiple dispatches."""
165295
num_failed = 0
166296

167297
for dispatch_id in dispatch_ids:

src/frequenz/client/dispatch/_cli_types.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
"""Types for the CLI client."""
55

6+
import json
67
from datetime import datetime, timedelta, timezone
78
from typing import Any, cast
89

@@ -167,3 +168,36 @@ def convert(
167168
param,
168169
ctx,
169170
)
171+
172+
173+
class JsonDictParamType(click.ParamType):
174+
"""Click parameter type for JSON strings."""
175+
176+
name = "json"
177+
178+
def convert(
179+
self, value: Any, param: click.Parameter | None, ctx: click.Context | None
180+
) -> dict[str, Any]:
181+
"""Convert the input value into a dictionary.
182+
183+
Args:
184+
value: The input value (string).
185+
param: The Click parameter object.
186+
ctx: The Click context object.
187+
188+
Returns:
189+
A dictionary parsed from the input JSON string.
190+
"""
191+
if isinstance(value, dict): # Already a dictionary
192+
return value
193+
194+
try:
195+
if not value.startswith("{"):
196+
value = "{" + value
197+
if not value.endswith("}"):
198+
value = value + "}"
199+
200+
return cast(dict[str, Any], json.loads(value))
201+
202+
except ValueError as e:
203+
self.fail(f"Invalid JSON string: {value}. Error: {e}", param, ctx)

0 commit comments

Comments
 (0)