Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

## Summary

* The base client dependency was updated to v0.8.0.
* The dispatch-cli application now features fancy colors and formatting!
174 changes: 161 additions & 13 deletions src/frequenz/client/dispatch/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import asyncio
import os
from datetime import datetime, timezone
from pprint import pformat
from typing import Any, List

Expand All @@ -16,13 +17,6 @@
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import CompleteStyle

from frequenz.client.dispatch.recurrence import (
EndCriteria,
Frequency,
RecurrenceRule,
Weekday,
)

from ._cli_types import (
FuzzyDateTime,
FuzzyIntRange,
Expand All @@ -31,10 +25,140 @@
TargetComponentParamType,
)
from ._client import Client
from .recurrence import EndCriteria, Frequency, RecurrenceRule, Weekday
from .types import Dispatch

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


def format_datetime(dt: datetime | None) -> str:
"""Format datetime object to a readable string, or return 'N/A' if None."""
return dt.strftime("%Y-%m-%d %H:%M:%S %Z") if dt else "N/A"


def format_recurrence(recurrence: RecurrenceRule) -> str:
"""Format the recurrence rule, omitting empty or unspecified fields."""
parts: List[str] = []
# Since frequency is not UNSPECIFIED here (we check before calling this function)
parts.append(f"Frequency: {recurrence.frequency.name}")
if recurrence.interval:
parts.append(f"Interval: {recurrence.interval}")
if recurrence.end_criteria:
parts.append(f"End Criteria: {recurrence.end_criteria}")
# Include only non-empty lists
if recurrence.byminutes:
parts.append(f"Minutes: {', '.join(map(str, recurrence.byminutes))}")
if recurrence.byhours:
parts.append(f"Hours: {', '.join(map(str, recurrence.byhours))}")
if recurrence.byweekdays:
weekdays = ", ".join(day.name for day in recurrence.byweekdays)
parts.append(f"Weekdays: {weekdays}")
if recurrence.bymonthdays:
parts.append(f"Month Days: {', '.join(map(str, recurrence.bymonthdays))}")
if recurrence.bymonths:
months = ", ".join(map(month_name, recurrence.bymonths))
parts.append(f"Months: {months}")
return "\n".join(parts)


def month_name(month: int) -> str:
"""Return the name of the month."""
return datetime(2000, month, 1).strftime("%B")


# pylint: disable=too-many-statements, too-many-locals
def print_dispatch(dispatch: Dispatch) -> None:
"""Print the dispatch details in a nicely formatted way with colors."""
# Determine the status and color
status: str = "running" if dispatch.started else "not running"
status_color: str = "green" if dispatch.started else "red"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: What about gray for not started? Red looks like an error to me. Maybe we can sort of mimic systemctl status as many users may be familiar with them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My desire was that it stands out immediately what the status is :)

status_str: str = click.style(status, fg=status_color, bold=True)

# Format the next start time with color
next_start_time_str: str = format_datetime(dispatch.next_run)
next_start_time_colored: str = click.style(next_start_time_str, fg="cyan")

start_in_timedelta = (
dispatch.next_run - datetime.now(timezone.utc) if dispatch.next_run else None
)
start_in_timedelta_str = str(start_in_timedelta) if start_in_timedelta else "N/A"
start_in_timedelta_colored = click.style(start_in_timedelta_str, fg="yellow")

# Format the target
if dispatch.target:
if len(dispatch.target) == 1:
target_str: str = str(dispatch.target[0])
else:
target_str = ", ".join(str(s) for s in dispatch.target)
else:
target_str = "None"

# Prepare the dispatch details
lines: List[str] = []
# Define the keys for alignment
keys = [
"ID",
"Type",
"Start Time",
"Duration",
"Target",
"Active",
"Dry Run",
"Payload",
"Recurrence",
"Create Time",
"Update Time",
]
max_key_length: int = max(len(k) for k in keys)

# Helper function to format each line
def format_line(key: str, value: str, color: str = "cyan") -> str:
key_str = click.style(f"{key}:", fg=color)
val_color = "white"

if value in ("None", "False"):
val_color = "red"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here maybe gray for None?

elif value == "True":
val_color = "green"

val_str: str = click.style(value, fg=val_color)
return f"{key_str:<{max_key_length + 2}} {val_str}"

lines.append(click.style("Dispatch Details:", bold=True, underline=True))
lines.append(format_line("ID", str(dispatch.id)))
lines.append(format_line("Type", str(dispatch.type)))
lines.append(format_line("Start Time", format_datetime(dispatch.start_time)))
if dispatch.duration:
lines.append(format_line("Duration", str(dispatch.duration)))
else:
lines.append(format_line("Duration", "Infinite"))
lines.append(format_line("Target", target_str))
lines.append(format_line("Active", str(dispatch.active)))
lines.append(format_line("Dry Run", str(dispatch.dry_run)))
if dispatch.payload:
lines.append(format_line("Payload", str(dispatch.payload)))
# Only include recurrence if frequency is not UNSPECIFIED
if dispatch.recurrence and dispatch.recurrence.frequency != Frequency.UNSPECIFIED:
recurrence_str = format_recurrence(dispatch.recurrence)
# Indent recurrence details for better readability
indented_recurrence = "\n " + recurrence_str.replace("\n", "\n ")
lines.append(format_line("Recurrence", indented_recurrence, "green"))
else:
lines.append(format_line("Recurrence", "None"))
lines.append(format_line("Create Time", format_datetime(dispatch.create_time)))
lines.append(format_line("Update Time", format_datetime(dispatch.update_time)))

# Combine all lines
dispatch_info: str = "\n".join(lines)

# Output the formatted dispatch details
click.echo(f"{dispatch_info}\n")
click.echo(f"Dispatch is currently {status_str}")
click.echo(
f"Next start in: {start_in_timedelta_colored} ({next_start_time_colored})\n"
)


# Click command groups
@click.group(invoke_without_command=True)
@click.option(
Expand All @@ -52,8 +176,15 @@
show_envvar=True,
required=True,
)
@click.option(
"--raw",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible future improvements:

  • Split --raw from --no-color
  • Set color on/off automatically based on sys.stdin.isatty()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Set color on/off automatically based on sys.stdin.isatty()

that actually seems to happen automatically, at least there is no color if I pipe it through cat or forward it into a file

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah cool. Extra point for click 💯

is_flag=True,
help="Print output raw instead of formatted and colored",
required=False,
default=False,
)
@click.pass_context
async def cli(ctx: click.Context, url: str, key: str) -> None:
async def cli(ctx: click.Context, url: str, key: str, raw: bool) -> None:
"""Dispatch Service CLI."""
if ctx.obj is None:
ctx.obj = {}
Expand All @@ -71,6 +202,8 @@ async def cli(ctx: click.Context, url: str, key: str) -> None:
"key": key,
}

ctx.obj["raw"] = raw

# Check if a subcommand was given
if ctx.invoked_subcommand is None:
await interactive_mode(url, key)
Expand Down Expand Up @@ -102,7 +235,10 @@ async def list_(ctx: click.Context, /, **filters: Any) -> None:
num_dispatches = 0
async for page in ctx.obj["client"].list(**filters):
for dispatch in page:
click.echo(pformat(dispatch, compact=True))
if ctx.obj["raw"]:
click.echo(pformat(dispatch, compact=True))
else:
print_dispatch(dispatch)
num_dispatches += 1

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


def parse_recurrence(kwargs: dict[str, Any]) -> RecurrenceRule | None:
Expand Down Expand Up @@ -275,7 +414,10 @@ async def create(
recurrence=parse_recurrence(kwargs),
**kwargs,
)
click.echo(pformat(dispatch, compact=True))
if ctx.obj["raw"]:
click.echo(pformat(dispatch, compact=True))
else:
print_dispatch(dispatch)
click.echo("Dispatch created.")


Expand Down Expand Up @@ -330,7 +472,10 @@ def skip_field(value: Any) -> bool:
microgrid_id=microgrid_id, dispatch_id=dispatch_id, new_fields=new_fields
)
click.echo("Dispatch updated:")
click.echo(pformat(changed_dispatch, compact=True))
if ctx.obj["raw"]:
click.echo(pformat(changed_dispatch, compact=True))
else:
print_dispatch(changed_dispatch)
except grpc.RpcError as e:
raise click.ClickException(f"Update failed: {e}")

Expand All @@ -348,7 +493,10 @@ async def get(ctx: click.Context, microgrid_id: int, dispatch_ids: List[int]) ->
dispatch = await ctx.obj["client"].get(
microgrid_id=microgrid_id, dispatch_id=dispatch_id
)
click.echo(pformat(dispatch, compact=True))
if ctx.obj["raw"]:
click.echo(pformat(dispatch, compact=True))
else:
print_dispatch(dispatch)
except grpc.RpcError as e:
click.echo(f"Error getting dispatch {dispatch_id}: {e}", err=True)
num_failed += 1
Expand Down
7 changes: 4 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ async def test_list_command(
fake_client.set_dispatches(microgrid_id_, dispatch_list)

result = await runner.invoke(
cli, ["list", str(microgrid_id)], env=ENVIRONMENT_VARIABLES
cli, ["--raw", "list", str(microgrid_id)], env=ENVIRONMENT_VARIABLES
)
assert expected_output in result.output
assert result.exit_code == expected_return_code
Expand Down Expand Up @@ -343,6 +343,7 @@ async def test_create_command(
expected_return_code: int,
) -> None:
"""Test the create command."""
args.insert(0, "--raw")
result = await runner.invoke(cli, args, env=ENVIRONMENT_VARIABLES)
now = datetime.now(get_localzone())

Expand Down Expand Up @@ -572,7 +573,7 @@ async def test_update_command(
"""Test the update command."""
fake_client.set_dispatches(1, dispatches)
result = await runner.invoke(
cli, ["update", "1", "1", *args], env=ENVIRONMENT_VARIABLES
cli, ["--raw", "update", "1", "1", *args], env=ENVIRONMENT_VARIABLES
)
assert expected_output in result.output
assert result.exit_code == expected_return_code
Expand Down Expand Up @@ -623,7 +624,7 @@ async def test_get_command(
"""Test the get command."""
fake_client.set_dispatches(1, dispatches)
result = await runner.invoke(
cli, ["get", "1", str(dispatch_id)], env=ENVIRONMENT_VARIABLES
cli, ["--raw", "get", "1", str(dispatch_id)], env=ENVIRONMENT_VARIABLES
)
assert result.exit_code == 0 if dispatches else 1
assert expected_in_output in result.output
Expand Down
Loading