Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(framework) Introduce JSON formatting function in flwr ls #4613

Merged
merged 27 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f9e13fa
feat(framework) Refactor flwr ls table formatter
chongshenng Dec 2, 2024
6a96dfe
Introduce try-except block to print JSON-formatted errors
chongshenng Dec 2, 2024
bc761b5
Introduce JSON formatting function
chongshenng Dec 2, 2024
97b9a70
Format
chongshenng Dec 2, 2024
5de9505
Add missing import
chongshenng Dec 2, 2024
28cf26c
Update docstring
chongshenng Dec 2, 2024
bd1c86a
Add output formats to constants
chongshenng Dec 3, 2024
1c501a1
Merge branch 'main' into introduce-json-format
chongshenng Dec 3, 2024
392b9c3
Revert
chongshenng Dec 3, 2024
605d576
Break arg lines
chongshenng Dec 3, 2024
3d22e45
Address comments
chongshenng Dec 3, 2024
4885ba3
Merge branch 'main' into refactor-ls-table-format
chongshenng Dec 3, 2024
8490c95
Update
chongshenng Dec 3, 2024
066dfcf
Remove SystemExit
chongshenng Dec 3, 2024
ca0dd00
Update
chongshenng Dec 3, 2024
69336ad
Address comment
chongshenng Dec 3, 2024
ab8e4d5
Fix
chongshenng Dec 3, 2024
3c1808c
Move fn to utils
chongshenng Dec 4, 2024
65288bf
Merge main
chongshenng Dec 4, 2024
ba8f8fb
Merge branch 'print-errors-as-json' into introduce-json-format
chongshenng Dec 4, 2024
e6eae95
Merge main
chongshenng Dec 4, 2024
0cc5ed6
Merge branch 'main' into introduce-json-format
danieljanes Dec 4, 2024
51c5c1c
Lint
chongshenng Dec 4, 2024
52838e5
Remove bbcode function, move format
chongshenng Dec 4, 2024
0cbfc46
Merge branch 'main' into introduce-json-format
danieljanes Dec 4, 2024
09d1647
Update types
chongshenng Dec 4, 2024
b5952ca
Refactor
chongshenng Dec 4, 2024
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
219 changes: 163 additions & 56 deletions src/py/flwr/cli/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"""Flower command line interface `ls` command."""


import json
import re
from datetime import datetime, timedelta
from logging import DEBUG
from pathlib import Path
Expand All @@ -32,7 +34,7 @@
validate_federation_in_project_config,
validate_project_config,
)
from flwr.common.constant import FAB_CONFIG_FILE, SubStatus
from flwr.common.constant import FAB_CONFIG_FILE, CliOutputFormat, SubStatus
from flwr.common.date import format_timedelta, isoformat8601_utc
from flwr.common.grpc import GRPC_MAX_MESSAGE_LENGTH, create_channel
from flwr.common.logger import log
Expand Down Expand Up @@ -70,52 +72,56 @@ def ls(
] = None,
) -> None:
"""List runs."""
# Load and validate federation config
typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)

pyproject_path = app / FAB_CONFIG_FILE if app else None
config, errors, warnings = load_and_validate(path=pyproject_path)
config = validate_project_config(config, errors, warnings)
federation, federation_config = validate_federation_in_project_config(
federation, config
)

if "address" not in federation_config:
typer.secho(
"❌ `flwr ls` currently works with Exec API. Ensure that the correct"
"Exec API address is provided in the `pyproject.toml`.",
fg=typer.colors.RED,
bold=True,
try:
# Load and validate federation config
typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)

pyproject_path = app / FAB_CONFIG_FILE if app else None
config, errors, warnings = load_and_validate(path=pyproject_path)
config = validate_project_config(config, errors, warnings)
federation, federation_config = validate_federation_in_project_config(
federation, config
)
raise typer.Exit(code=1)

try:
if runs and run_id is not None:
raise ValueError(
"The options '--runs' and '--run-id' are mutually exclusive."
if "address" not in federation_config:
typer.secho(
"❌ `flwr ls` currently works with Exec API. Ensure that the correct"
"Exec API address is provided in the `pyproject.toml`.",
fg=typer.colors.RED,
bold=True,
)

channel = _init_channel(app, federation_config)
stub = ExecStub(channel)

# Display information about a specific run ID
if run_id is not None:
typer.echo(f"🔍 Displaying information for run ID {run_id}...")
_display_one_run(stub, run_id)
# By default, list all runs
else:
typer.echo("📄 Listing all runs...")
_list_runs(stub)

except ValueError as err:
typer.secho(
f"❌ {err}",
fg=typer.colors.RED,
bold=True,
)
raise typer.Exit(code=1) from err
finally:
channel.close()
raise typer.Exit(code=1)

try:
if runs and run_id is not None:
raise ValueError(
"The options '--runs' and '--run-id' are mutually exclusive."
)

channel = _init_channel(app, federation_config)
stub = ExecStub(channel)

# Display information about a specific run ID
if run_id is not None:
typer.echo(f"🔍 Displaying information for run ID {run_id}...")
_display_one_run(stub, run_id)
# By default, list all runs
else:
typer.echo("📄 Listing all runs...")
_list_runs(stub)

except ValueError as err:
typer.secho(
f"❌ {err}",
fg=typer.colors.RED,
bold=True,
)
raise typer.Exit(code=1) from err
finally:
channel.close()
# pylint: disable=broad-except, unused-variable
except (typer.Exit, SystemExit, Exception):
_print_json_error()


def on_channel_state_change(channel_connectivity: str) -> None:
Expand All @@ -139,13 +145,15 @@ def _init_channel(app: Path, federation_config: dict[str, Any]) -> grpc.Channel:
return channel


def _format_run_table(run_dict: dict[int, Run], now_isoformat: str) -> Table:
"""Format run status as a rich Table."""
def _format_run(run_dict: dict[int, Run], now_isoformat: str) -> list[tuple[str, ...]]:
"""Format run status to a list."""
table = Table(header_style="bold cyan", show_lines=True)

def _format_datetime(dt: Optional[datetime]) -> str:
return isoformat8601_utc(dt).replace("T", " ") if dt else "N/A"

run_status_list: list[tuple[str, ...]] = []

# Add columns
table.add_column(
Text("Run ID", justify="center"), style="bright_white", overflow="fold"
Expand Down Expand Up @@ -192,31 +200,123 @@ def _format_datetime(dt: Optional[datetime]) -> str:
end_time = datetime.fromisoformat(now_isoformat)
elapsed_time = end_time - running_at

table.add_row(
f"[bold]{run.run_id}[/bold]",
f"{run.fab_id} (v{run.fab_version})",
f"[{status_style}]{status_text}[/{status_style}]",
format_timedelta(elapsed_time),
_format_datetime(pending_at),
_format_datetime(running_at),
_format_datetime(finished_at),
run_status_list.append(
(
f"{run.run_id}",
f"{run.fab_id}",
f"{run.fab_version}",
f"{run.fab_hash}",
f"[{status_style}]{status_text}[/{status_style}]",
format_timedelta(elapsed_time),
_format_datetime(pending_at),
_format_datetime(running_at),
_format_datetime(finished_at),
)
)
return run_status_list


def _to_table(run_list: list[tuple[str, ...]]) -> Table:
"""Format run status list to a rich Table."""
table = Table(header_style="bold cyan", show_lines=True)

# Add columns
table.add_column(
Text("Run ID", justify="center"), style="bright_white", overflow="fold"
)
table.add_column(Text("FAB", justify="center"), style="dim white")
table.add_column(Text("Status", justify="center"))
table.add_column(Text("Elapsed", justify="center"), style="blue")
table.add_column(Text("Created At", justify="center"), style="dim white")
table.add_column(Text("Running At", justify="center"), style="dim white")
table.add_column(Text("Finished At", justify="center"), style="dim white")

for row in run_list:
(
run_id,
fab_id,
fab_version,
_,
status_text,
elapsed,
created_at,
running_at,
finished_at,
) = row
formatted_row = (
f"[bold]{run_id}[/bold]",
f"{fab_id} (v{fab_version})",
status_text,
elapsed,
created_at,
running_at,
finished_at,
)
table.add_row(*formatted_row)

return table


def _to_json(run_list: list[tuple[str, ...]]) -> str:
"""Format run status list to a JSON formatted string."""

def _remove_bbcode_tags(strings: tuple[str, ...]) -> tuple[str, ...]:
chongshenng marked this conversation as resolved.
Show resolved Hide resolved
"""Remove BBCode tags from the provided text."""
# Regular expression pattern to match BBCode tags
bbcode_pattern = re.compile(r"\[/?\w+\]")
# Substitute BBCode tags with an empty string
return tuple(bbcode_pattern.sub("", s) for s in strings)

runs_list = []
for row in run_list:
row = _remove_bbcode_tags(row)
(
run_id,
fab_id,
fab_version,
fab_hash,
status_text,
elapsed,
created_at,
running_at,
finished_at,
) = row
runs_list.append(
{
"run-id": run_id,
"fab-id": fab_id,
"fab-name": fab_id.split("/")[-1],
"fab-version": fab_version,
"fab-hash": fab_hash[:8],
"status": status_text,
"elapsed": elapsed,
"created-at": created_at,
"running-at": running_at,
"finished-at": finished_at,
}
)

return json.dumps({"runs": runs_list})


def _list_runs(
stub: ExecStub,
output_format: str = CliOutputFormat.default,
) -> None:
"""List all runs."""
res: ListRunsResponse = stub.ListRuns(ListRunsRequest())
run_dict = {run_id: run_from_proto(proto) for run_id, proto in res.run_dict.items()}

Console().print(_format_run_table(run_dict, res.now))
if output_format == CliOutputFormat.json:
Console().print_json(_to_json(_format_run(run_dict, res.now)))
else:
Console().print(_to_table(_format_run(run_dict, res.now)))


def _display_one_run(
stub: ExecStub,
run_id: int,
output_format: str = CliOutputFormat.default,
) -> None:
"""Display information about a specific run."""
res: ListRunsResponse = stub.ListRuns(ListRunsRequest(run_id=run_id))
Expand All @@ -225,4 +325,11 @@ def _display_one_run(

run_dict = {run_id: run_from_proto(proto) for run_id, proto in res.run_dict.items()}

Console().print(_format_run_table(run_dict, res.now))
if output_format == CliOutputFormat.json:
Console().print_json(_to_json(_format_run(run_dict, res.now)))
else:
Console().print(_to_table(_format_run(run_dict, res.now)))


def _print_json_error() -> None:
"""Print error message as JSON."""
9 changes: 9 additions & 0 deletions src/py/flwr/common/constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

from __future__ import annotations

from enum import Enum

MISSING_EXTRA_REST = """
Extra dependencies required for using the REST-based Fleet API are missing.

Expand Down Expand Up @@ -181,3 +183,10 @@ class SubStatus:
def __new__(cls) -> SubStatus:
"""Prevent instantiation."""
raise TypeError(f"{cls.__name__} cannot be instantiated.")


class CliOutputFormat(str, Enum):
"""Define output format for `flwr` CLI commands."""

default = "default" # pylint: disable=invalid-name
json = "json" # pylint: disable=invalid-name
chongshenng marked this conversation as resolved.
Show resolved Hide resolved