Skip to content

Commit 3763b42

Browse files
authored
Implement a CLI and REPL dispatch client (#46)
Implement a CLI and REPL dispatch client
2 parents 3ea4919 + 271e8a2 commit 3763b42

File tree

6 files changed

+850
-4
lines changed

6 files changed

+850
-4
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
## New Features
1212

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
13+
* A CLI and REPL client was added. It can be used using `python -m frequenz.client.dispatch`. Use the `--help` parameter to get an overview of the available commands.
1414

1515
## Bug Fixes
1616

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ name = "Frequenz Energy-as-a-Service GmbH"
4949
5050

5151
[project.optional-dependencies]
52+
cli = ["asyncclick == 8.1.7.2", "prompt-toolkit == 3.0.43", "parsedatetime==2.6", "tzlocal==5.2"]
53+
5254
dev-flake8 = [
5355
"flake8 == 7.0.0",
5456
"flake8-docstrings == 1.7.0",
@@ -72,15 +74,15 @@ dev-mypy = [
7274
"mypy == 1.10.0",
7375
"types-Markdown == 3.6.0.20240316",
7476
# For checking the noxfile, docs/ script, and tests
75-
"frequenz-client-dispatch[dev-mkdocs,dev-noxfile,dev-pytest]",
77+
"frequenz-client-dispatch[cli,dev-mkdocs,dev-noxfile,dev-pytest]",
7678
"grpc-stubs == 1.53.0.5",
7779
"types-protobuf == 5.26.0.20240422",
7880
]
7981
dev-noxfile = ["nox == 2024.4.15", "frequenz-repo-config[lib] == 0.9.2"]
8082
dev-pylint = [
8183
"pylint == 3.1.0",
8284
# For checking the noxfile, docs/ script, and tests
83-
"frequenz-client-dispatch[dev-mkdocs,dev-noxfile,dev-pytest]",
85+
"frequenz-client-dispatch[cli,dev-mkdocs,dev-noxfile,dev-pytest]",
8486
"frequenz-api-dispatch >= 0.13.0, < 0.14",
8587
]
8688
dev-pytest = [
@@ -90,6 +92,7 @@ dev-pytest = [
9092
"pytest-asyncio == 0.23.6",
9193
"async-solipsism == 0.6",
9294
"hypothesis == 6.100.2",
95+
"frequenz-client-dispatch[cli]",
9396
]
9497
dev = [
9598
"frequenz-client-dispatch[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]",
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# License: All rights reserved
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""CLI and interactive client for the dispatch service."""
5+
6+
import asyncio
7+
import os
8+
import sys
9+
from pprint import pformat
10+
from typing import Any, List
11+
12+
import asyncclick as click
13+
import grpc
14+
from prompt_toolkit import PromptSession
15+
from prompt_toolkit.completion import NestedCompleter
16+
from prompt_toolkit.history import FileHistory
17+
from prompt_toolkit.patch_stdout import patch_stdout
18+
from prompt_toolkit.shortcuts import CompleteStyle
19+
20+
from ._cli_types import FuzzyDateTime, FuzzyIntRange, FuzzyTimeDelta, SelectorParamType
21+
from ._client import Client
22+
23+
DEFAULT_DISPATCH_API_HOST = "88.99.25.81"
24+
DEFAULT_DISPATCH_API_PORT = 50051
25+
26+
27+
def get_client(host: str, port: int) -> Client:
28+
"""Get a new client instance.
29+
30+
Args:
31+
host: The host of the dispatch service.
32+
port: The port of the dispatch service.
33+
34+
Returns:
35+
Client: A new client instance.
36+
"""
37+
channel = grpc.aio.insecure_channel(f"{host}:{port}")
38+
return Client(channel, f"{host}:{port}")
39+
40+
41+
# Click command groups
42+
@click.group()
43+
@click.option(
44+
"--host",
45+
default=DEFAULT_DISPATCH_API_HOST,
46+
help="Dispatch API host",
47+
envvar="DISPATCH_API_HOST",
48+
show_envvar=True,
49+
show_default=True,
50+
)
51+
@click.option(
52+
"--port",
53+
default=DEFAULT_DISPATCH_API_PORT,
54+
help="Dispatch API port",
55+
envvar="DISPATCH_API_PORT",
56+
show_envvar=True,
57+
show_default=True,
58+
)
59+
@click.pass_context
60+
async def cli(ctx: click.Context, host: str, port: int) -> None:
61+
"""Dispatch Service CLI."""
62+
ctx.ensure_object(dict)
63+
ctx.obj["client"] = get_client(host, port)
64+
65+
66+
@cli.command("list")
67+
@click.pass_context
68+
@click.argument("microgrid-id", required=True, type=int)
69+
@click.option("--selector", "-s", type=SelectorParamType(), multiple=True)
70+
@click.option("--start-from", type=FuzzyDateTime())
71+
@click.option("--start-to", type=FuzzyDateTime())
72+
@click.option("--end-from", type=FuzzyDateTime())
73+
@click.option("--end-to", type=FuzzyDateTime())
74+
@click.option("--active", type=bool)
75+
@click.option("--dry-run", type=bool)
76+
async def list_(ctx: click.Context, /, **filters: Any) -> None:
77+
"""List dispatches.
78+
79+
Lists all dispatches for MICROGRID_ID that match the given filters.
80+
81+
The selector option can be given multiple times.
82+
"""
83+
if "selector" in filters:
84+
selector = filters.pop("selector")
85+
filters["component_selectors"] = selector
86+
87+
num_dispatches = 0
88+
async for dispatch in ctx.obj["client"].list(**filters):
89+
click.echo(pformat(dispatch, compact=True))
90+
num_dispatches += 1
91+
92+
click.echo(f"{num_dispatches} dispatches total.")
93+
94+
95+
@cli.command()
96+
@click.argument("microgrid-id", required=True, type=int)
97+
@click.argument(
98+
"type",
99+
required=True,
100+
type=str,
101+
)
102+
@click.argument("start-time", required=True, type=FuzzyDateTime())
103+
@click.argument("duration", required=True, type=FuzzyTimeDelta())
104+
@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)")
109+
@click.pass_context
110+
async def create(
111+
ctx: click.Context,
112+
/,
113+
**kwargs: Any,
114+
) -> None:
115+
"""Create a dispatch.
116+
117+
Creates a new dispatch for MICROGRID_ID of type TYPE running for DURATION seconds
118+
starting at START_TIME.
119+
120+
SELECTOR is either one of the following: BATTERY, GRID, METER, INVERTER,
121+
EV_CHARGER, CHP or a list of component IDs separated by commas, e.g. "1,2,3".
122+
"""
123+
dispatch = await ctx.obj["client"].create(
124+
_type=kwargs.pop("type"),
125+
**kwargs,
126+
)
127+
click.echo(pformat(dispatch, compact=True))
128+
click.echo("Dispatch created.")
129+
130+
131+
# We could fix the mypy error by using ", /", but this causes issues with
132+
# the click decorators. We can ignore the error here.
133+
@cli.command() # type: ignore[arg-type]
134+
@click.argument("dispatch_id", type=int)
135+
@click.option("--start-time", type=FuzzyDateTime())
136+
@click.option("--duration", type=FuzzyTimeDelta())
137+
@click.option("--selector", type=SelectorParamType())
138+
@click.option("--active", type=bool)
139+
@click.pass_context
140+
async def update(
141+
ctx: click.Context, dispatch_id: int, **new_fields: dict[str, Any]
142+
) -> None:
143+
"""Update a dispatch."""
144+
# Remove keys with `None` value from new_fields
145+
new_fields = {k: v for k, v in new_fields.items() if v is not None}
146+
147+
if len(new_fields) == 0:
148+
raise click.BadArgumentUsage("At least one field must be given to update.")
149+
150+
try:
151+
# if duration := new_fields.get("duration"):
152+
# new_fields.pop("duration")
153+
# new_fields["duration"] = timedelta(seconds=int(duration))
154+
await ctx.obj["client"].update(dispatch_id=dispatch_id, new_fields=new_fields)
155+
click.echo("Dispatch updated.")
156+
except grpc.RpcError as e:
157+
raise click.ClickException(f"Update failed: {e}")
158+
159+
160+
@cli.command()
161+
@click.argument("dispatch_ids", type=int, nargs=-1) # Allow multiple IDs
162+
@click.pass_context
163+
async def get(ctx: click.Context, dispatch_ids: List[int]) -> None:
164+
"""Get multiple dispatches."""
165+
num_failed = 0
166+
167+
for dispatch_id in dispatch_ids:
168+
try:
169+
dispatch = await ctx.obj["client"].get(dispatch_id)
170+
click.echo(pformat(dispatch, compact=True))
171+
except grpc.RpcError as e:
172+
click.echo(f"Error getting dispatch {dispatch_id}: {e}", err=True)
173+
num_failed += 1
174+
175+
if num_failed == len(dispatch_ids):
176+
raise click.ClickException("All gets failed.")
177+
if num_failed > 0:
178+
raise click.ClickException("Some gets failed.")
179+
180+
181+
@cli.command()
182+
@click.argument("dispatch_ids", type=FuzzyIntRange(), nargs=-1) # Allow multiple IDs
183+
@click.pass_context
184+
async def delete(ctx: click.Context, dispatch_ids: list[list[int]]) -> None:
185+
"""Delete multiple dispatches.
186+
187+
Possible formats: "1", "1,2,3", "1-3", "1..3"
188+
"""
189+
# Flatten the list of lists
190+
flat_ids = [dispatch_id for sublist in dispatch_ids for dispatch_id in sublist]
191+
failed_ids = []
192+
success_ids = []
193+
194+
for dispatch_id in flat_ids:
195+
try:
196+
await ctx.obj["client"].delete(dispatch_id)
197+
success_ids.append(dispatch_id)
198+
except grpc.RpcError as e:
199+
click.echo(f"Error deleting dispatch {dispatch_id}: {e}", err=True)
200+
failed_ids.append(dispatch_id)
201+
202+
if success_ids:
203+
click.echo(f"Dispatches deleted: {success_ids}") # Feedback on deleted IDs
204+
if failed_ids:
205+
click.echo(f"Failed to delete: {failed_ids}", err=True)
206+
207+
if failed_ids:
208+
if not success_ids:
209+
raise click.ClickException("All deletions failed.")
210+
raise click.ClickException("Some deletions failed.")
211+
212+
213+
async def interactive_mode() -> None:
214+
"""Interactive mode for the CLI."""
215+
hist_file = os.path.expanduser("~/.dispatch_cli_history.txt")
216+
session: PromptSession[str] = PromptSession(history=FileHistory(filename=hist_file))
217+
218+
user_commands = ["list", "create", "update", "get", "delete", "exit", "help"]
219+
220+
async def display_help() -> None:
221+
await cli.main(args=["--help"], standalone_mode=False)
222+
223+
completer = NestedCompleter.from_nested_dict(
224+
{command: None for command in user_commands}
225+
)
226+
227+
while True:
228+
with patch_stdout():
229+
try:
230+
user_input = await session.prompt_async(
231+
"> ",
232+
completer=completer,
233+
complete_style=CompleteStyle.READLINE_LIKE,
234+
)
235+
except EOFError:
236+
break
237+
238+
if user_input == "help" or not user_input:
239+
await display_help()
240+
elif user_input == "exit":
241+
break
242+
else:
243+
# Split, but keep quoted strings together
244+
params = click.parser.split_arg_string(user_input)
245+
try:
246+
await cli.main(args=params, standalone_mode=False)
247+
except click.ClickException as e:
248+
click.echo(e)
249+
250+
251+
if __name__ == "__main__":
252+
if len(sys.argv) > 1:
253+
asyncio.run(cli.main())
254+
else:
255+
asyncio.run(interactive_mode())

0 commit comments

Comments
 (0)