Skip to content

Commit 108ca25

Browse files
authored
Add colors and formatting to dispatch-cli (frequenz-floss#110)
2 parents 636d4cb + 688ea86 commit 108ca25

File tree

3 files changed

+166
-17
lines changed

3 files changed

+166
-17
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
## Summary
44

5-
* The base client dependency was updated to v0.8.0.
5+
* The dispatch-cli application now features fancy colors and formatting!

src/frequenz/client/dispatch/__main__.py

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

66
import asyncio
77
import os
8+
from datetime import datetime, timezone
89
from pprint import pformat
910
from typing import Any, List
1011

@@ -16,13 +17,6 @@
1617
from prompt_toolkit.patch_stdout import patch_stdout
1718
from prompt_toolkit.shortcuts import CompleteStyle
1819

19-
from frequenz.client.dispatch.recurrence import (
20-
EndCriteria,
21-
Frequency,
22-
RecurrenceRule,
23-
Weekday,
24-
)
25-
2620
from ._cli_types import (
2721
FuzzyDateTime,
2822
FuzzyIntRange,
@@ -31,10 +25,140 @@
3125
TargetComponentParamType,
3226
)
3327
from ._client import Client
28+
from .recurrence import EndCriteria, Frequency, RecurrenceRule, Weekday
29+
from .types import Dispatch
3430

3531
DEFAULT_DISPATCH_API_URL = "grpc://fz-0004.frequenz.io:50051"
3632

3733

34+
def format_datetime(dt: datetime | None) -> str:
35+
"""Format datetime object to a readable string, or return 'N/A' if None."""
36+
return dt.strftime("%Y-%m-%d %H:%M:%S %Z") if dt else "N/A"
37+
38+
39+
def format_recurrence(recurrence: RecurrenceRule) -> str:
40+
"""Format the recurrence rule, omitting empty or unspecified fields."""
41+
parts: List[str] = []
42+
# Since frequency is not UNSPECIFIED here (we check before calling this function)
43+
parts.append(f"Frequency: {recurrence.frequency.name}")
44+
if recurrence.interval:
45+
parts.append(f"Interval: {recurrence.interval}")
46+
if recurrence.end_criteria:
47+
parts.append(f"End Criteria: {recurrence.end_criteria}")
48+
# Include only non-empty lists
49+
if recurrence.byminutes:
50+
parts.append(f"Minutes: {', '.join(map(str, recurrence.byminutes))}")
51+
if recurrence.byhours:
52+
parts.append(f"Hours: {', '.join(map(str, recurrence.byhours))}")
53+
if recurrence.byweekdays:
54+
weekdays = ", ".join(day.name for day in recurrence.byweekdays)
55+
parts.append(f"Weekdays: {weekdays}")
56+
if recurrence.bymonthdays:
57+
parts.append(f"Month Days: {', '.join(map(str, recurrence.bymonthdays))}")
58+
if recurrence.bymonths:
59+
months = ", ".join(map(month_name, recurrence.bymonths))
60+
parts.append(f"Months: {months}")
61+
return "\n".join(parts)
62+
63+
64+
def month_name(month: int) -> str:
65+
"""Return the name of the month."""
66+
return datetime(2000, month, 1).strftime("%B")
67+
68+
69+
# pylint: disable=too-many-statements, too-many-locals
70+
def print_dispatch(dispatch: Dispatch) -> None:
71+
"""Print the dispatch details in a nicely formatted way with colors."""
72+
# Determine the status and color
73+
status: str = "running" if dispatch.started else "not running"
74+
status_color: str = "green" if dispatch.started else "red"
75+
status_str: str = click.style(status, fg=status_color, bold=True)
76+
77+
# Format the next start time with color
78+
next_start_time_str: str = format_datetime(dispatch.next_run)
79+
next_start_time_colored: str = click.style(next_start_time_str, fg="cyan")
80+
81+
start_in_timedelta = (
82+
dispatch.next_run - datetime.now(timezone.utc) if dispatch.next_run else None
83+
)
84+
start_in_timedelta_str = str(start_in_timedelta) if start_in_timedelta else "N/A"
85+
start_in_timedelta_colored = click.style(start_in_timedelta_str, fg="yellow")
86+
87+
# Format the target
88+
if dispatch.target:
89+
if len(dispatch.target) == 1:
90+
target_str: str = str(dispatch.target[0])
91+
else:
92+
target_str = ", ".join(str(s) for s in dispatch.target)
93+
else:
94+
target_str = "None"
95+
96+
# Prepare the dispatch details
97+
lines: List[str] = []
98+
# Define the keys for alignment
99+
keys = [
100+
"ID",
101+
"Type",
102+
"Start Time",
103+
"Duration",
104+
"Target",
105+
"Active",
106+
"Dry Run",
107+
"Payload",
108+
"Recurrence",
109+
"Create Time",
110+
"Update Time",
111+
]
112+
max_key_length: int = max(len(k) for k in keys)
113+
114+
# Helper function to format each line
115+
def format_line(key: str, value: str, color: str = "cyan") -> str:
116+
key_str = click.style(f"{key}:", fg=color)
117+
val_color = "white"
118+
119+
if value in ("None", "False"):
120+
val_color = "red"
121+
elif value == "True":
122+
val_color = "green"
123+
124+
val_str: str = click.style(value, fg=val_color)
125+
return f"{key_str:<{max_key_length + 2}} {val_str}"
126+
127+
lines.append(click.style("Dispatch Details:", bold=True, underline=True))
128+
lines.append(format_line("ID", str(dispatch.id)))
129+
lines.append(format_line("Type", str(dispatch.type)))
130+
lines.append(format_line("Start Time", format_datetime(dispatch.start_time)))
131+
if dispatch.duration:
132+
lines.append(format_line("Duration", str(dispatch.duration)))
133+
else:
134+
lines.append(format_line("Duration", "Infinite"))
135+
lines.append(format_line("Target", target_str))
136+
lines.append(format_line("Active", str(dispatch.active)))
137+
lines.append(format_line("Dry Run", str(dispatch.dry_run)))
138+
if dispatch.payload:
139+
lines.append(format_line("Payload", str(dispatch.payload)))
140+
# Only include recurrence if frequency is not UNSPECIFIED
141+
if dispatch.recurrence and dispatch.recurrence.frequency != Frequency.UNSPECIFIED:
142+
recurrence_str = format_recurrence(dispatch.recurrence)
143+
# Indent recurrence details for better readability
144+
indented_recurrence = "\n " + recurrence_str.replace("\n", "\n ")
145+
lines.append(format_line("Recurrence", indented_recurrence, "green"))
146+
else:
147+
lines.append(format_line("Recurrence", "None"))
148+
lines.append(format_line("Create Time", format_datetime(dispatch.create_time)))
149+
lines.append(format_line("Update Time", format_datetime(dispatch.update_time)))
150+
151+
# Combine all lines
152+
dispatch_info: str = "\n".join(lines)
153+
154+
# Output the formatted dispatch details
155+
click.echo(f"{dispatch_info}\n")
156+
click.echo(f"Dispatch is currently {status_str}")
157+
click.echo(
158+
f"Next start in: {start_in_timedelta_colored} ({next_start_time_colored})\n"
159+
)
160+
161+
38162
# Click command groups
39163
@click.group(invoke_without_command=True)
40164
@click.option(
@@ -52,8 +176,15 @@
52176
show_envvar=True,
53177
required=True,
54178
)
179+
@click.option(
180+
"--raw",
181+
is_flag=True,
182+
help="Print output raw instead of formatted and colored",
183+
required=False,
184+
default=False,
185+
)
55186
@click.pass_context
56-
async def cli(ctx: click.Context, url: str, key: str) -> None:
187+
async def cli(ctx: click.Context, url: str, key: str, raw: bool) -> None:
57188
"""Dispatch Service CLI."""
58189
if ctx.obj is None:
59190
ctx.obj = {}
@@ -71,6 +202,8 @@ async def cli(ctx: click.Context, url: str, key: str) -> None:
71202
"key": key,
72203
}
73204

205+
ctx.obj["raw"] = raw
206+
74207
# Check if a subcommand was given
75208
if ctx.invoked_subcommand is None:
76209
await interactive_mode(url, key)
@@ -102,7 +235,10 @@ async def list_(ctx: click.Context, /, **filters: Any) -> None:
102235
num_dispatches = 0
103236
async for page in ctx.obj["client"].list(**filters):
104237
for dispatch in page:
105-
click.echo(pformat(dispatch, compact=True))
238+
if ctx.obj["raw"]:
239+
click.echo(pformat(dispatch, compact=True))
240+
else:
241+
print_dispatch(dispatch)
106242
num_dispatches += 1
107243

108244
click.echo(f"{num_dispatches} dispatches total.")
@@ -114,7 +250,10 @@ async def list_(ctx: click.Context, /, **filters: Any) -> None:
114250
async def stream(ctx: click.Context, microgrid_id: int) -> None:
115251
"""Stream dispatches."""
116252
async for message in ctx.obj["client"].stream(microgrid_id=microgrid_id):
117-
click.echo(pformat(message, compact=True))
253+
if ctx.obj["raw"]:
254+
click.echo(pformat(message, compact=True))
255+
else:
256+
print_dispatch(message)
118257

119258

120259
def parse_recurrence(kwargs: dict[str, Any]) -> RecurrenceRule | None:
@@ -275,7 +414,10 @@ async def create(
275414
recurrence=parse_recurrence(kwargs),
276415
**kwargs,
277416
)
278-
click.echo(pformat(dispatch, compact=True))
417+
if ctx.obj["raw"]:
418+
click.echo(pformat(dispatch, compact=True))
419+
else:
420+
print_dispatch(dispatch)
279421
click.echo("Dispatch created.")
280422

281423

@@ -330,7 +472,10 @@ def skip_field(value: Any) -> bool:
330472
microgrid_id=microgrid_id, dispatch_id=dispatch_id, new_fields=new_fields
331473
)
332474
click.echo("Dispatch updated:")
333-
click.echo(pformat(changed_dispatch, compact=True))
475+
if ctx.obj["raw"]:
476+
click.echo(pformat(changed_dispatch, compact=True))
477+
else:
478+
print_dispatch(changed_dispatch)
334479
except grpc.RpcError as e:
335480
raise click.ClickException(f"Update failed: {e}")
336481

@@ -348,7 +493,10 @@ async def get(ctx: click.Context, microgrid_id: int, dispatch_ids: List[int]) ->
348493
dispatch = await ctx.obj["client"].get(
349494
microgrid_id=microgrid_id, dispatch_id=dispatch_id
350495
)
351-
click.echo(pformat(dispatch, compact=True))
496+
if ctx.obj["raw"]:
497+
click.echo(pformat(dispatch, compact=True))
498+
else:
499+
print_dispatch(dispatch)
352500
except grpc.RpcError as e:
353501
click.echo(f"Error getting dispatch {dispatch_id}: {e}", err=True)
354502
num_failed += 1

tests/test_cli.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ async def test_list_command(
161161
fake_client.set_dispatches(microgrid_id_, dispatch_list)
162162

163163
result = await runner.invoke(
164-
cli, ["list", str(microgrid_id)], env=ENVIRONMENT_VARIABLES
164+
cli, ["--raw", "list", str(microgrid_id)], env=ENVIRONMENT_VARIABLES
165165
)
166166
assert expected_output in result.output
167167
assert result.exit_code == expected_return_code
@@ -343,6 +343,7 @@ async def test_create_command(
343343
expected_return_code: int,
344344
) -> None:
345345
"""Test the create command."""
346+
args.insert(0, "--raw")
346347
result = await runner.invoke(cli, args, env=ENVIRONMENT_VARIABLES)
347348
now = datetime.now(get_localzone())
348349

@@ -572,7 +573,7 @@ async def test_update_command(
572573
"""Test the update command."""
573574
fake_client.set_dispatches(1, dispatches)
574575
result = await runner.invoke(
575-
cli, ["update", "1", "1", *args], env=ENVIRONMENT_VARIABLES
576+
cli, ["--raw", "update", "1", "1", *args], env=ENVIRONMENT_VARIABLES
576577
)
577578
assert expected_output in result.output
578579
assert result.exit_code == expected_return_code
@@ -623,7 +624,7 @@ async def test_get_command(
623624
"""Test the get command."""
624625
fake_client.set_dispatches(1, dispatches)
625626
result = await runner.invoke(
626-
cli, ["get", "1", str(dispatch_id)], env=ENVIRONMENT_VARIABLES
627+
cli, ["--raw", "get", "1", str(dispatch_id)], env=ENVIRONMENT_VARIABLES
627628
)
628629
assert result.exit_code == 0 if dispatches else 1
629630
assert expected_in_output in result.output

0 commit comments

Comments
 (0)