From eba6ca50805035dafe447a45f7e1a77bb815cbac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Zar=C4=99bski?= Date: Tue, 24 Mar 2026 16:43:47 +0000 Subject: [PATCH 1/8] Divide CLI commands across files --- src/simvue_cli/actions.py | 5 +- src/simvue_cli/cli/__init__.py | 2394 +------------------------- src/simvue_cli/cli/admin/__init__.py | 17 + src/simvue_cli/cli/admin/tenant.py | 209 +++ src/simvue_cli/cli/admin/user.py | 221 +++ src/simvue_cli/cli/alert.py | 246 +++ src/simvue_cli/cli/artifact.py | 138 ++ src/simvue_cli/cli/config.py | 111 ++ src/simvue_cli/cli/folder.py | 266 +++ src/simvue_cli/cli/monitor.py | 85 + src/simvue_cli/cli/push.py | 124 ++ src/simvue_cli/cli/run.py | 506 ++++++ src/simvue_cli/cli/storage.py | 225 +++ src/simvue_cli/cli/tag.py | 188 ++ src/simvue_cli/cli/utilities.py | 135 ++ src/simvue_cli/cli/venv.py | 42 + 16 files changed, 2541 insertions(+), 2371 deletions(-) create mode 100644 src/simvue_cli/cli/admin/__init__.py create mode 100644 src/simvue_cli/cli/admin/tenant.py create mode 100644 src/simvue_cli/cli/admin/user.py create mode 100644 src/simvue_cli/cli/alert.py create mode 100644 src/simvue_cli/cli/artifact.py create mode 100644 src/simvue_cli/cli/config.py create mode 100644 src/simvue_cli/cli/folder.py create mode 100644 src/simvue_cli/cli/monitor.py create mode 100644 src/simvue_cli/cli/push.py create mode 100644 src/simvue_cli/cli/run.py create mode 100644 src/simvue_cli/cli/storage.py create mode 100644 src/simvue_cli/cli/tag.py create mode 100644 src/simvue_cli/cli/utilities.py create mode 100644 src/simvue_cli/cli/venv.py diff --git a/src/simvue_cli/actions.py b/src/simvue_cli/actions.py index f05f342..e6300d9 100644 --- a/src/simvue_cli/actions.py +++ b/src/simvue_cli/actions.py @@ -107,10 +107,9 @@ def create_simvue_run( description: str | None, name: str | None, folder: str, - timeout: int | None, retention: int | None, environment: bool, -) -> Run | None: +) -> Run: """Create and initialise a new Simvue run Parameters @@ -126,8 +125,6 @@ def create_simvue_run( a name to assign to this run folder : str folder path for this run - timeout : int | None - timout of run retention : int | None retention period in seconds environment : bool diff --git a/src/simvue_cli/cli/__init__.py b/src/simvue_cli/cli/__init__.py index 15c2116..5c5976a 100644 --- a/src/simvue_cli/cli/__init__.py +++ b/src/simvue_cli/cli/__init__.py @@ -10,47 +10,26 @@ __date__ = "2024-09-09" import os -import pathlib -import re import sys -import shutil import click -import json -import time -import click_option_group -import datetime import logging -import contextlib -import importlib.metadata -from simvue.run import FOLDER_REGEX -import tabulate -import requests -import simvue as simvue_client -from simvue.api.objects import Alert, Run, Folder, S3Storage, Tag, Storage, Artifact -from simvue.api.objects.administrator import User, Tenant -from simvue.exception import ObjectNotFoundError -import toml import simvue_cli.config -import simvue_cli.actions -import simvue_cli.server -from simvue_cli.cli.display import ( - create_objects_display, - SIMVUE_LOGO, - format_folder_tree, -) -from simvue_cli.validation import ( - SimvueName, - SimvueFolder, - JSONType, - Email, - FullName, - UserName, -) +from .config import config as config_cli +from .run import simvue_run as run_cli +from .alert import simvue_alert as alert_cli +from .folder import simvue_folder as folder_cli +from .utilities import ping_server, about_simvue, purge_simvue, whoami +from .admin import admin as admin_cli +from .tag import simvue_tag as tag_cli +from .storage import simvue_storage as storage_cli +from .artifact import simvue_artifact as artifact_cli +from .push import push as push_cli +from .monitor import monitor as monitor_cli +from .venv import venv_setup as venv_cli -from click_params import PUBLIC_URL logging.basicConfig() logging.getLogger("simvue").setLevel(logging.ERROR) @@ -116,2340 +95,21 @@ def simvue(ctx, plain: bool, profile: str | None, verbose: bool) -> None: os.environ["SIMVUE_TOKEN"] = _profile.token.get_secret_value() -@simvue.command("ping") -@click.option( - "-t", - "--timeout", - help="Timeout the command after n seconds", - default=None, - type=int, -) -def ping_server(timeout: int | None) -> None: - """Ping the Simvue server""" - successful_pings: int = 0 - with contextlib.suppress(KeyboardInterrupt): - url = simvue_client.Client()._user_config.server.url - ip_address = simvue_cli.server.get_ip_of_url(url) - counter: int = 0 - while True: - if timeout and counter > timeout: - return - start_time = time.time() - try: - server_version: int | str = simvue_cli.actions.get_server_version() - if ( - status_code := 200 - if isinstance(server_version, str) - else server_version - ) != 200: - raise RuntimeError - successful_pings += 1 - end_time = time.time() # Record the end time - elapsed_time = (end_time - start_time) * 1000 # Convert to milliseconds - click.secho( - f"Reply from {url} ({ip_address}): status_code={status_code}, time={elapsed_time:.2f}ms" - ) - except (requests.ConnectionError, requests.Timeout, RuntimeError): - click.secho( - f"Reply from {url} ({ip_address}): status_code={status_code}, error" - ) - - time.sleep(1) - counter += 1 - - -@simvue.command("whoami") -@click.option("-u", "--user", help="click.echo only the user name", default=False) -@click.option("-t", "--tenant", help="click.echo only the tenant", default=False) -def whoami(user: bool, tenant: bool) -> None: - """Retrieve current user information""" - if user and tenant: - click.secho("cannot click.echo 'only' with more than one choice") - raise click.Abort - user_info = simvue_cli.actions.user_info() - user_name = user_info.get("user") - tenant_info = user_info.get("tenant") - if user: - click.secho(user_name) - elif tenant: - click.secho(tenant_info) - else: - click.secho(f"{user_name}({tenant_info})") - - -@simvue.command("about") -@click.pass_context -def about_simvue(ctx) -> None: - """Display full information on Simvue instance""" - width = shutil.get_terminal_size().columns - if not ctx.obj.get("plain"): - click.echo( - "\n".join( - "\t" * int(0.015 * width) + f"{r}" for r in SIMVUE_LOGO.split("\n") - ) - ) - click.echo(f"\n{width * '='}\n") - click.echo( - "\n" + "\t" * int(0.04 * width) + "Provided under the Apache-2.0 License" - ) - click.echo( - "\t" * int(0.04 * width) - + f"© Copyright {datetime.datetime.now().strftime('%Y')} Simvue Development Team\n" - ) - out_table: list[list[str]] = [] - with contextlib.suppress(importlib.metadata.PackageNotFoundError): - out_table.append( - ["CLI Version: ", importlib.metadata.version(simvue_cli.__name__)] - ) - with contextlib.suppress(importlib.metadata.PackageNotFoundError): - out_table.append( - ["Python API Version: ", importlib.metadata.version(simvue_client.__name__)] - ) - # with contextlib.suppress(Exception): - server_version: int | str = simvue_cli.actions.get_server_version() - if isinstance(server_version, int): - raise RuntimeError - out_table.append(["Server Version: ", server_version]) - if not ctx.obj.get("plain"): - click.echo( - "\n".join( - "\t" * int(0.045 * width) + f"{r}" - for r in tabulate.tabulate(out_table, tablefmt="plain") - .__str__() - .split("\n") - ) - ) - click.echo(f"\n{width * '='}\n") - else: - click.echo(tabulate.tabulate(out_table, tablefmt="plain").__str__()) - - -@simvue.group("config") -@click.option( - "_global", - "--global/--all", - default=None, - help="Update global or all configurations. Default of None will update local configuration only.", - show_default=True, -) -@click.pass_context -def config(ctx, _global: bool | None) -> None: - """Configure Simvue""" - if _global is not None: - ctx.obj["config_locations"] = "global" if _global else "all" - else: - ctx.obj["config_locations"] = "project" - - -@config.command("server.url") -@click.argument("url", type=PUBLIC_URL) -@click.pass_context -def config_set_url(ctx, url: str) -> None: - """Update Simvue configuration URL""" - _profile_name, _ = ctx.obj["profile"] - _target_locations = ctx.obj["config_locations"] - _out_files: list[pathlib.Path] = simvue_cli.config.set_profile_option( - profile_name=_profile_name, key="url", value=url, targets=_target_locations - ) - for out_file in _out_files: - click.secho(f"Wrote URL value to '{out_file}'") - if not _out_files: - sys.exit(1) - - -@config.command("server.token") -@click.argument("token", type=str) -@click.pass_context -def config_set_token(ctx, token: str) -> None: - """Update Simvue configuration Token""" - _profile_name, _ = ctx.obj["profile"] - _target_locations = ctx.obj["config_locations"] - _out_files: list[pathlib.Path] = simvue_cli.config.set_profile_option( - profile_name=_profile_name, key="token", value=token, targets=_target_locations - ) - for out_file in _out_files: - click.secho(f"Wrote token value to '{out_file}'") - - -@config.command("show") -@click.pass_context -def config_show(ctx) -> None: - """Show the current Simvue configuration.""" - - # Remove environment override to show full listing - # instead highlight current server - _env_url = os.environ.get("SIMVUE_URL") - _env_token = os.environ.get("SIMVUE_TOKEN") - - _config_file, _config = simvue_cli.config.get_current_configuration() - _current_url: str | None = None - _current_token: str | None = None - - logger.info(f"Using configuration from '{_config_file}'.\n") - - _name, _profile = ctx.obj["profile"] - - if _profile: - _current_url = _profile.url - _current_token = _profile.token - _config_str = toml.dumps(_config) - - if ctx.obj["plain"] and _name: - _config_str = _config_str.replace( - f"[profiles.{_name}]", f"[profiles.{_name}] <<< ACTIVE PROFILE" - ) - elif _name: - _config_str = _config_str.replace( - f"[profiles.{_name}]", - click.style(f"[profiles.{_name}]", bold=True, fg="cyan"), - ) - - click.secho(_config_str) - return - - if _config_file: - click.secho(f"Using configuration from '{_config_file}'.\n") - if _env_url and _env_token: - click.secho("Using environment variables:") - click.secho(f" SIMVUE_URL={_env_url}") - click.secho(" SIMVUE_TOKEN=****\n") - _current_url = _env_url - _current_token = _env_token - elif not _config_file: - click.secho("No config file found.\n", fg="red", bold=True) - click.secho(toml.dumps(_config)) - - if not _config_file and (not _current_url or not _current_token): - raise sys.exit(1) - - -@simvue.group("run") -@click.pass_context -def simvue_run(_) -> None: - """Create or retrieve Simvue runs""" - pass - - -@simvue_run.command("create") -@click.pass_context -@click.option( - "--create-only", help="Create run but do not start it", is_flag=True, default=False -) -@click.option( - "--timeout", - help="Set a timeout in seconds after which this run will register as 'lost'", - default=None, -) -@click_option_group.optgroup.group( - "Run attributes", - help="Assign properties such as metadata and labelling to this run", -) -@click_option_group.optgroup.option( - "--name", type=SimvueName, help="Name to assign to this run", default=None -) -@click_option_group.optgroup.option( - "--description", type=str, help="Short run description", default=None -) -@click_option_group.optgroup.option( - "--tag", type=str, help="Tag this run with a label", default=None, multiple=True -) -@click_option_group.optgroup.option( - "--folder", - type=SimvueFolder, - help="Specify folder path for this run", - default="/", - show_default=True, -) -@click_option_group.optgroup.option( - "--retention", - type=int, - help="Specify retention period", - default=None, -) -@click_option_group.optgroup.option( - "--environment", is_flag=True, default=False, help="Include environment metadata" -) -def create_run( - ctx, create_only: bool, tag: tuple[str, ...] | None, **run_params -) -> None: - """Initialise a new Simvue run""" - run_params |= {"running": not create_only, "tags": list(tag) if tag else None} - run: Run = simvue_cli.actions.create_simvue_run(**run_params) - - click.echo(run.id if ctx.obj["plain"] else click.style(run.id)) - - -@simvue_run.command("remove") -@click.pass_context -@click.argument("run_ids", type=str, nargs=-1, required=False) -@click.option( - "-i", - "--interactive", - help="Prompt for confirmation on removal", - type=bool, - default=False, - is_flag=True, -) -def delete_run(ctx, run_ids: list[str] | None, interactive: bool) -> None: - """Remove runs from the Simvue server""" - if not run_ids: - run_ids = [] - for line in sys.stdin: - if not line.strip(): - continue - run_ids += [k.strip() for k in line.split(" ")] - - for run_id in run_ids: - try: - simvue_cli.actions.get_run(run_id) - except (ObjectNotFoundError, RuntimeError): - error_msg = f"Run '{run_id}' not found" - if ctx.obj["plain"]: - click.echo(error_msg) - else: - click.secho(error_msg, fg="red", bold=True) - sys.exit(1) - - if interactive: - remove = click.confirm(f"Remove run '{run_id}'?") - if not remove: - continue - - try: - simvue_cli.actions.delete_run(run_id) - except ValueError as e: - click.echo( - e.args[0] - if ctx.obj["plain"] - else click.style(e.args[0], fg="red", bold=True) - ) - sys.exit(1) - - response_message = f"Run '{run_id}' removed successfully." - - if ctx.obj["plain"]: - click.echo(response_message) - else: - click.secho(response_message, bold=True, fg="green") - - -@simvue_run.command("close") -@click.pass_context -@click.argument("run_id", type=str) -def close_run(ctx, run_id: str) -> None: - """Mark an active run as completed""" - if not (simvue_cli.actions.get_run(run_id)): - error_msg = f"Run '{run_id}' not found" - if ctx.obj["plain"]: - click.echo(error_msg) - else: - click.secho(error_msg, fg="red", bold=True) - sys.exit(1) - try: - simvue_cli.actions.set_run_status(run_id, "completed") - except ValueError as e: - click.echo( - e.args[0] - if ctx.obj["plain"] - else click.style(e.args[0], fg="red", bold=True) - ) - sys.exit(1) - - -@simvue_run.command("abort") -@click.pass_context -@click.argument("run_id", type=str) -@click.option( - "--reason", - type=str, - help="Reason for abort", - default="Manual termination via CLI", - show_default=True, -) -def abort_run(ctx, run_id: str, reason: str) -> None: - """Abort an active run""" - if not (simvue_cli.actions.get_run(run_id)): - error_msg = f"Run '{run_id}' not found" - if ctx.obj["plain"]: - click.echo(error_msg) - else: - click.secho(error_msg, fg="red", bold=True) - sys.exit(1) - simvue_cli.actions.set_run_status(run_id, "terminated", reason=reason) - - -@simvue_run.command("log.metrics") -@click.argument("run_id", type=str) -@click.argument("metrics", type=JSONType) -def log_metrics(run_id: str, metrics: dict) -> None: - """Log metrics to Simvue server""" - simvue_cli.actions.log_metrics(run_id, metrics) - - -@simvue_run.command("log.event") -@click.argument("run_id", type=str) -@click.argument("event_message", type=str) -def log_event(run_id: str, event_message: str) -> None: - """Log event to Simvue server""" - simvue_cli.actions.log_event(run_id, event_message) - - -@simvue_run.command("metadata") -@click.argument("run_id", type=str) -@click.argument("metadata", type=JSONType) -def update_metadata(run_id: str, metadata: dict) -> None: - """Update metadata for a run on the Simvue server""" - simvue_cli.actions.update_metadata(run_id, metadata) - - -@simvue_run.command("list", context_settings={"ignore_unknown_options": True}) -@click.pass_context -@click.option( - "--format", - "table_format", - type=click.Choice(list(tabulate._table_formats.keys())), - help="Display as table with output format", - default=None, -) -@click.option( - "--enumerate", - "enumerate_", - is_flag=True, - help="Show counter next to runs", - default=False, - show_default=True, -) -@click.option( - "--count", - type=int, - help="Maximum number of runs to retrieve", - default=20, - show_default=True, -) -@click.option("-T", "--tags", is_flag=True, help="Show tags") -@click.option("-n", "--name", is_flag=True, help="Show names") -@click.option("-u", "--user", is_flag=True, help="Show users") -@click.option("-t", "--created", is_flag=True, help="Show created timestamp") -@click.option("-d", "--description", is_flag=True, help="Show description") -@click.option("-s", "--status", is_flag=True, help="Show status") -@click.option("-m", "--metadata", multiple=True, help="Show metadata value") -@click.option("-f", "--folder", is_flag=True, help="Show folder") -@click.option( - "-F", - "--filter", - "filters", - multiple=True, - help=""" -Apply filters when searching runs. - -Accepts filters in the form of , with multiple instances -of this option being allowed. The comparators allowed vary depending on the column being -filtered by: - -> Greater than - -< Less than - ->= Greater than or equal to - -<= Less than or equal to - -= or == Equal to (no value implies general 'exists') - -!= Not equal to (no value implies general 'does not exist') - -~ Contains - -!~ Does not contain - -Examples - - --filter folder=/unit_tests - - --filter 'metadata.custom_meta>10' - - --filter starred - - --filter name~test -""", -) -@click.option( - "--sort-by", - help="Specify columns to sort by", - multiple=True, - default=["created"], - type=click.Choice(["created", "started", "endtime", "modified", "name"]), - show_default=True, -) -@click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) -@click.option("--shared", help="Include shared runs", default=False, is_flag=True) -@click.option("--starred", help="Filter to favorited runs", default=False, is_flag=True) -@click.argument("args", nargs=-1, type=click.UNPROCESSED) -def list_runs( - ctx, - table_format: str, - tags: bool, - description: bool, - user: bool, - created: bool, - enumerate_: bool, - name: bool, - folder: bool, - status: bool, - args: str, - shared: bool, - starred: bool, - **kwargs, -) -> None: - """Retrieve runs list from Simvue server""" - _metadata = [ - arg.replace("--", "") for arg in args if re.findall("^--metadata", arg) - ] - - # To avoid ambiguity only allow shared to activated by command line argument - kwargs["filters"] = [ - filter for filter in kwargs["filters"] if not filter.startswith("user") - ] - - if not shared: - kwargs["filters"].append("user == self") - - if starred: - kwargs["filters"].append("starred") - - if _metadata: - kwargs["metadata"] = True - runs = simvue_cli.actions.get_runs_list(**kwargs) - columns = ["id"] + _metadata - - if created: - columns.append("created") - if name: - columns.append("name") - if folder: - columns.append("folder") - if tags: - columns.append("tags") - if user: - columns.append("user") - if description: - columns.append("description") - if status: - columns.append("status") - - table = create_objects_display( - columns, - runs, - plain_text=ctx.obj["plain"], - enumerate_=enumerate_, - format=table_format, - ) - click.echo(table) - - -@simvue_run.command("json") -@click.pass_context -@click.argument("run_id", required=False) -def get_run_json(ctx, run_id: str) -> None: - """Retrieve Run information from Simvue server - - If no RUN_ID is provided the input is read from stdin - """ - if not run_id: - run_id = input() - - try: - run: Run = simvue_cli.actions.get_run(run_id) - run_info = run.to_dict() - click.echo(json.dumps(dict(run_info.items()), indent=2)) - except ObjectNotFoundError as e: - error_msg = f"Failed to retrieve run '{run_id}': {e.args[0]}" - if not ctx.obj["plain"]: - error_msg = click.style(error_msg, fg="red", bold=True) - click.echo(error_msg) - sys.exit(1) - - -@simvue_run.command("artifacts") -@click.pass_context -@click.option( - "--format", - "table_format", - type=click.Choice(list(tabulate._table_formats.keys())), - help="Display as table with output format", - default=None, -) -@click.option( - "--enumerate", - "enumerate_", - is_flag=True, - help="Show counter next to runs", - default=False, - show_default=True, -) -@click.option( - "--count", - type=int, - help="Maximum number of runs to retrieve", - default=20, - show_default=True, -) -@click.option( - "--original-path", - is_flag=True, - help="Show original path of artifact", - default=False, -) -@click.option( - "--storage", is_flag=True, help="Show storage ID of artifact", default=False -) -@click.option( - "--mime-type", is_flag=True, help="Show MIME type of artifact", default=False -) -@click.option("--created", is_flag=True, help="Show created timestamp") -@click.option("--user", is_flag=True, help="Show artifact user UUID") -@click.option("--download-url", is_flag=True, help="Show artifact download URL") -@click.option("--uploaded", is_flag=True, help="Show artifact upload status") -@click.option("--checksum", is_flag=True, help="Show artifact checksum") -@click.option("--name", is_flag=True, help="Show artifact name") -@click.option("--size", is_flag=True, help="Show artifact size") -@click.argument("run_id", required=False) -def get_run_artifacts( - ctx, - run_id: str, - table_format: str, - enumerate_: bool, - original_path: bool, - storage: bool, - mime_type: bool, - created: bool, - user: bool, - download_url: bool, - uploaded: bool, - name: bool, - size: bool, - **_, -) -> None: - """Retrieve the artifacts for a given Run from the Simvue server - - If no RUN_ID is provided the input is read from stdin - """ - if not run_id: - run_id = input() - - try: - if not (artifacts := list(simvue_cli.actions.get_run_artifacts(run_id))): - raise SystemExit - except SystemExit: - sys.exit(1) - except (ObjectNotFoundError, RuntimeError) as e: - _error_msg = f"Failed to retrieve run '{run_id}': {e.args[0]}" - if not ctx.obj["plain"]: - _error_msg = click.style(_error_msg, fg="red", bold=True) - click.echo(_error_msg) - sys.exit(1) - - columns = ["id"] - - if created: - columns.append("created") - if name: - columns.append("name") - if size: - columns.append("size") - if original_path: - columns.append("original_path") - if storage: - columns.append("storage") - if uploaded: - columns.append("uploaded") - if mime_type: - columns.append("mime_type") - if user: - columns.append("user") - if download_url: - columns.append("download_url") - - table = create_objects_display( - columns, - artifacts, - plain_text=ctx.obj["plain"], - enumerate_=enumerate_, - format=table_format, - ) - click.echo(table) - - -@simvue_run.command("pull") -@click.pass_context -@click.option( - "-o", - "--output-dir", - help="Output directory.", - default=f"{pathlib.Path.cwd().joinpath('{run_id}')}", - show_default=True, -) -@click.argument("run_id", required=False) -def pull_simvue_run(ctx, output_dir: str, run_id: str) -> None: - """Retrieve artifacts for the given Simvue run. - - Downloads the artifacts to the specified directory.""" - if not run_id: - run_id = input() - - try: - _downloaded_files: list[pathlib.Path] = simvue_cli.actions.pull_run( - run_id=run_id, - output_dir=pathlib.Path(output_dir.format(run_id=run_id)), - plain=ctx.obj["plain"], - ) - if not _downloaded_files: - click.echo("No artifacts found.") - return - _disp_str = "\n".join(f"{file}" for file in _downloaded_files) - click.echo(_disp_str if ctx.obj["plain"] else click.style(_disp_str, bold=True)) - except RuntimeError as e: - _disp_str = f"Failed to download run '{run_id}': {e.args[0]}" - click.echo( - _disp_str - if ctx.obj["plain"] - else click.style(_disp_str, fg="red", bold=True) - ) - sys.exit(1) - - -@simvue.command("purge") -@click.pass_context -def purge_simvue(_) -> None: - """Remove all local Simvue files in user home area.""" - - click.echo( - "Simvue user files deleted successfully." - if simvue_cli.actions.purge_local_simvue_files() - else "Nothing to do." - ) - - -@simvue.group("alert") -@click.pass_context -def simvue_alert(_) -> None: - """Create and list Simvue alerts""" - pass - - -@simvue_alert.command("trigger") -@click.pass_context -@click.argument("run_id") -@click.argument("alert_id") -@click.option( - "--ok", - "is_ok", - is_flag=True, - help="Set alert to status 'ok' as opposed to critical.", - show_default=True, -) -def trigger_alert(ctx, is_ok: bool, **kwargs) -> None: - """Trigger a user alert""" - try: - simvue_cli.actions.trigger_user_alert( - status="ok" if is_ok else "critical", **kwargs - ) - except ValueError as e: - if ctx.obj["plain"]: - click.echo(e.args[0]) - else: - click.secho(e.args[0], fg="red", bold=True) - sys.exit(1) - - -@simvue_alert.command("list") -@click.pass_context -@click.option( - "--format", - "table_format", - type=click.Choice(list(tabulate._table_formats.keys())), - help="Display as table with output format", - default=None, -) -@click.option( - "--enumerate", - "enumerate_", - is_flag=True, - help="Show counter next to alerts", - default=False, - show_default=True, -) -@click.option( - "--offset", - type=int, - help="Start index for results", - default=None, - show_default=None, -) -@click.option( - "--count", - type=int, - help="Maximum number of alerts to retrieve", - default=20, - show_default=True, -) -@click.option("--run-tags", is_flag=True, help="Show tags") -@click.option("--auto", is_flag=True, help="Show if run tag auto-assign is enabled") -@click.option("--notification", is_flag=True, help="Show notification setting") -@click.option("--created", is_flag=True, help="Show created timestamp") -@click.option("--source", is_flag=True, help="Show alert source") -@click.option("--enabled", is_flag=True, help="Show if alert enabled") -@click.option("--abort", is_flag=True, help="Show alert if alert can abort runs") -@click.option("--name", is_flag=True, help="Show names") -@click.option("--description", is_flag=True, help="Show description") -@click.option( - "--sort-by", - help="Specify columns to sort by", - multiple=True, - default=["created"], - type=click.Choice(["created", "name"]), - show_default=True, -) -@click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) -def alert_list( - ctx, - table_format: str, - enumerate_: bool, - run_tags: bool, - name: bool, - auto: bool, - notification: bool, - source: bool, - enabled: bool, - description: bool, - created: bool, - **kwargs, -) -> None: - """Retrieve alerts list from Simvue server""" - kwargs |= {"filters": kwargs.get("filters" or [])} - alerts = simvue_cli.actions.get_alerts_list(**kwargs) - if not alerts: - return - columns = ["id"] - - if name: - columns.append("name") - if created: - columns.append("created") - if run_tags: - columns.append("run_tags") - if description: - columns.append("description") - if notification: - columns.append("notification") - if enabled: - columns.append("enabled") - if auto: - columns.append("auto") - if source: - columns.append("source") - - table = create_objects_display( - columns, - alerts, - plain_text=ctx.obj["plain"], - enumerate_=enumerate_, - format=table_format, - ) - click.echo(table) - - -@simvue_alert.command("create") -@click.pass_context -@click.argument("name", type=SimvueName) -@click.option( - "--abort", - is_flag=True, - help="Abort run if this alert is triggered", - show_default=True, -) -@click.option("--description", default=None, help="Description for this alert.") -@click.option( - "--email", is_flag=True, help="Notify by email if triggered", show_default=True -) -def create_alert( - ctx, - name: str, - abort: bool = False, - email: bool = False, - description: str | None = None, -) -> None: - """Create a User alert""" - result = simvue_cli.actions.create_user_alert( - name=name, trigger_abort=abort, email_notify=email, description=description - ) - alert_id = result.id - click.echo(alert_id if ctx.obj["plain"] else click.style(alert_id)) - - -@simvue_alert.command("remove") -@click.pass_context -@click.argument("alert_ids", type=str, nargs=-1, required=False) -@click.option( - "-i", - "--interactive", - help="Prompt for confirmation on removal", - type=bool, - default=False, - is_flag=True, -) -def delete_alert(ctx, alert_ids: list[str] | None, interactive: bool) -> None: - """Remove a alert from the Simvue server""" - if not alert_ids: - alert_ids = [] - for line in sys.stdin: - if not line.strip(): - continue - alert_ids += [k.strip() for k in line.split(" ")] - - for alert_id in alert_ids: - try: - simvue_cli.actions.get_alert(alert_id) - except (ObjectNotFoundError, RuntimeError): - error_msg = f"alert '{alert_id}' not found" - if ctx.obj["plain"]: - click.echo(error_msg) - else: - click.secho(error_msg, fg="red", bold=True) - sys.exit(1) - - if interactive: - remove = click.confirm(f"Remove alert '{alert_id}'?") - if not remove: - continue - - try: - simvue_cli.actions.delete_alert(alert_id) - except ValueError as e: - click.echo( - e.args[0] - if ctx.obj["plain"] - else click.style(e.args[0], fg="red", bold=True) - ) - sys.exit(1) - - response_message = f"alert '{alert_id}' removed successfully." - - if ctx.obj["plain"]: - click.echo(response_message) - else: - click.secho(response_message, bold=True, fg="green") - - -@simvue_alert.command("json") -@click.argument("alert_id", required=False) -def get_alert_json(alert_id: str) -> None: - """Retrieve alert information from Simvue server - - If no alert ID is provided the input is read from stdin - """ - if not alert_id: - alert_id = input() - - try: - alert: Alert = simvue_cli.actions.get_alert(alert_id) - alert_info = alert.to_dict() - click.echo(json.dumps(dict(alert_info.items()), indent=2)) - except ObjectNotFoundError as e: - error_msg = f"Failed to retrieve alert '{alert_id}': {e.args[0]}" - click.echo(error_msg, fg="red", bold=True) - - -@simvue.command("monitor") -@click_option_group.optgroup.group( - "Run attributes", - help="Assign properties such as metadata and labelling to this run", -) -@click_option_group.optgroup.option( - "--name", type=SimvueName, help="Name to assign to this run", default=None -) -@click_option_group.optgroup.option( - "--description", type=str, help="Short run description", default=None -) -@click_option_group.optgroup.option( - "--tag", type=str, help="Tag this run with a label", default=None, multiple=True -) -@click_option_group.optgroup.option( - "--folder", - type=SimvueFolder, - help="Specify folder path for this run", - default="/", - show_default=True, -) -@click_option_group.optgroup.option( - "--retention", - type=int, - help="Specify retention period", - default=None, -) -@click.pass_context -@click.option( - "--delimiter", - "-d", - help="File row delimiter", - default=None, - show_default=True, - type=str, -) -@click.option( - "--environment", help="Include environment in metadata", is_flag=True, default=False -) -def monitor(ctx, tag: tuple[str, ...] | None, delimiter: str, **run_params) -> None: - """Monitor stdin for delimited lines sending as metrics""" - metric_labels: list[str] = [] - run_params |= {"tags": list(tag) if tag else None} - - run: Run | None = simvue_cli.actions.create_simvue_run( - timeout=None, running=True, **run_params - ) - - if not run: - raise click.Abort("Failed to create run") - - try: - for i, line in enumerate(sys.stdin): - line = [el for element in line.split(delimiter) if (el := element.strip())] - if i == 0: - metric_labels = line - continue - try: - simvue_cli.actions.log_metrics( - run.id, dict(zip(metric_labels, [float(i) for i in line])) - ) - except (RuntimeError, ValueError) as e: - if ctx.obj["plain"]: - click.echo(e) - else: - click.secho(e, fg="red", bold=True) - sys.exit(1) - click.echo(run.id) - except KeyboardInterrupt as e: - simvue_cli.actions.set_run_status(run.id, "terminated") - raise click.Abort from e - simvue_cli.actions.set_run_status(run.id, "completed") - - -@simvue.group("folder") -@click.pass_context -def simvue_folder(ctx) -> None: - """Create or retrieve Simvue folders""" - pass - - -@simvue_folder.command("list") -@click.pass_context -@click.option( - "--format", - "table_format", - type=click.Choice(list(tabulate._table_formats.keys())), - help="Display as table with output format", - default=None, -) -@click.option( - "--enumerate", - "enumerate_", - is_flag=True, - help="Show counter next to folders", - default=False, - show_default=True, -) -@click.option( - "--count", - type=int, - help="Maximum number of folders to retrieve", - default=20, - show_default=True, -) -@click.option("--path", is_flag=True, help="Show path") -@click.option("--tags", is_flag=True, help="Show tags") -@click.option("--created", is_flag=True, help="Show created timestamp") -@click.option("--name", is_flag=True, help="Show names") -@click.option("--description", is_flag=True, help="Show description") -@click.option( - "--sort-by", - help="Specify columns to sort by", - multiple=True, - default=["created"], - type=click.Choice(["created", "modified", "path"]), - show_default=True, -) -@click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) -def folder_list( - ctx, - table_format: str, - enumerate_: bool, - path: bool, - tags: bool, - name: bool, - created: bool, - description: bool, - **kwargs, -) -> None: - """Retrieve folders list from Simvue server""" - folders = simvue_cli.actions.get_folders_list(**kwargs) - if not folders: - return - columns = ["id"] - - if created: - columns.append("created") - if path: - columns.append("path") - if name: - columns.append("name") - if tags: - columns.append("tags") - if description: - columns.append("description") - - table = create_objects_display( - columns, - folders, - plain_text=ctx.obj["plain"], - enumerate_=enumerate_, - format=table_format, - ) - click.echo(table) - - -@simvue_folder.command("json") -@click.argument("folder_id", required=False) -def get_folder_json(folder_id: str | None) -> None: - """Retrieve folder information from Simvue server - - If no folder_ID is provided the input is read from stdin. - Input can be folder unique identifier or name. - """ - if not folder_id: - folder_id = input() - - if re.match(FOLDER_REGEX, folder_id): - try: - folder: Folder = simvue_cli.actions.get_folder_by_path(folder_id) - except StopIteration: - error_msg: str = f"Failed to retrieve folder '{folder_id}': No such folder." - click.secho(error_msg, fg="red", bold=True) - return - else: - try: - folder = simvue_cli.actions.get_folder(folder_id) - except ObjectNotFoundError as e: - error_msg = f"Failed to retrieve folder '{folder_id}': {e.args[0]}" - click.secho(error_msg, fg="red", bold=True) - return - click.echo(folder.path) - folder_info = folder.to_dict() - click.echo(json.dumps(dict(folder_info.items()), indent=2)) - - -@simvue_folder.command("remove") -@click.pass_context -@click.argument("folder_ids", type=str, nargs=-1, required=False) -@click.option( - "-i", - "--interactive", - help="Prompt for confirmation on removal", - type=bool, - default=False, - is_flag=True, -) -@click.option( - "-r", "--recurse", help="Recursively remove folders.", default=False, is_flag=True -) -@click.option( - "-f", - "--force", - help="Forcefully delete folder even if it contains runs.", - is_flag=True, - default=False, -) -@click.option( - "-c", - "--content", - help="Delete only folder content not folder itself.", - is_flag=True, - default=False, -) -def delete_folder( - ctx, - folder_ids: list[str] | None, - interactive: bool, - force: bool, - recurse: bool, - content: bool, -) -> None: - """Remove a Folder from the Simvue server""" - if not folder_ids: - folder_ids = [] - for line in sys.stdin: - if not line.strip(): - continue - folder_ids += [k.strip() for k in line.split(" ")] - - force = force if not content else False - - for folder_id in folder_ids: - try: - _folder = simvue_cli.actions.get_folder(folder_id) - except (ObjectNotFoundError, RuntimeError): - error_msg = f"Folder '{folder_id}' not found" - if ctx.obj["plain"]: - print(error_msg) - else: - click.secho(error_msg, fg="red", bold=True) - sys.exit(1) - - if _folder.path == "/": - _warn_message: str = "Root directory cannot be deleted." - if ctx.obj["plain"]: - print(_warn_message) - else: - click.secho(_warn_message, fg="red", bold=True) - sys.exit(1) - - if interactive: - remove = click.confirm( - f"Remove folder '{folder_id}'" + " and contained runs" - if force - else "" + "?" - ) - if not remove: - continue - - try: - simvue_cli.actions.delete_folder( - folder_id, force=force, recurse=recurse, contents_only=content - ) - except ValueError as e: - click.echo( - e.args[0] - if ctx.obj["plain"] - else click.style(e.args[0], fg="red", bold=True) - ) - sys.exit(1) - except RuntimeError as e: - if "Folder is in use" in e.args[0]: - _out_msg = f"Failed to delete folder '{folder_id}', folder in use." - else: - _out_msg = e.args[0] - click.echo( - _out_msg - if ctx.obj["plain"] - else click.style(_out_msg, fg="red", bold=True) - ) - sys.exit(1) - - response_message = f"Folder '{folder_id}' removed successfully." - - if ctx.obj["plain"]: - print(response_message) - else: - click.secho(response_message, bold=True, fg="green") - - -@simvue_folder.command("tree") -@click.argument("folder_id", required=False) -@click.option( - "-l", "--detail", help="Include folder details", default=False, is_flag=True -) -def display_folder_tree(folder_id: str | None, detail: bool) -> None: - """Display tree graph of folder structure. - - if no folder_ID is provided the input is read from stdin - """ - if not folder_id: - folder_id = input() - - if re.match(FOLDER_REGEX, folder_id): - try: - folder: Folder = simvue_cli.actions.get_folder_by_path(folder_id) - except StopIteration: - error_msg: str = f"Failed to retrieve folder '{folder_id}': No such folder." - click.secho(error_msg, fg="red", bold=True) - return - else: - try: - folder = simvue_cli.actions.get_folder(folder_id) - except ObjectNotFoundError as e: - error_msg = f"Failed to retrieve folder '{folder_id}': {e.args[0]}" - click.secho(error_msg, fg="red", bold=True) - return - if detail: - _details: dict[str, dict] = simvue_cli.actions.get_folder_details(folder) - print(_details) - click.echo(format_folder_tree(folder.tree)) - - -@simvue.group("tag") -@click.pass_context -def simvue_tag(ctx) -> None: - """Create or retrieve Simvue tags""" - pass - - -@simvue_tag.command("create") -@click.pass_context -@click.argument("name", type=SimvueName) -@click.option( - "--color", - type=str, - default=None, - help="Color for this tag, e.g. '#fffff', 'blue', 'rgb(23, 54, 34)'", -) -@click.option("--description", type=str, default=None, help="Description for this tag.") -def create_tag(ctx, **kwargs) -> None: - """Create a tag""" - result = simvue_cli.actions.create_simvue_tag(**kwargs) - alert_id = result.id - click.echo(alert_id if ctx.obj["plain"] else click.style(alert_id)) - - -@simvue_tag.command("list") -@click.pass_context -@click.option( - "--format", - "table_format", - type=click.Choice(list(tabulate._table_formats.keys())), - help="Display as table with output format", - default=None, -) -@click.option( - "--enumerate", - "enumerate_", - is_flag=True, - help="Show counter next to runs", - default=False, - show_default=True, -) -@click.option("--created", is_flag=True, help="Show created timestamp") -@click.option( - "--count", - type=int, - help="Maximum number of runs to retrieve", - default=20, - show_default=True, -) -@click.option("--name", is_flag=True, help="Show names") -@click.option("--description", is_flag=True, help="Show descriptions") -@click.option("--color", is_flag=True, help="Show hex colors") -@click.option( - "--sort-by", - help="Specify columns to sort by", - multiple=True, - default=["created"], - type=click.Choice(["created", "name"]), - show_default=True, -) -@click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) -def tag_list( - ctx, - enumerate_: bool, - created: bool, - table_format: str | None, - name: bool, - description: bool, - color: bool, - **kwargs, -) -> None: - """Retrieve tags list from Simvue server.""" - tags = simvue_cli.actions.get_tag_list(**kwargs) - if not tags: - return - columns = ["id"] - - if created: - columns.append("created") - - if name: - columns.append("name") - - if color: - columns.append("colour") - - if description: - columns.append("description") - - table = create_objects_display( - columns, - tags, - plain_text=ctx.obj["plain"], - enumerate_=enumerate_, - format=table_format, - ) - click.echo(table) - - -@simvue_tag.command("json") -@click.argument("tag_id", required=False) -def get_tag_json(tag_id: str) -> None: - """Retrieve tag information from Simvue server - - If no tag_ID is provided the input is read from stdin - """ - if not tag_id: - tag_id = input() - - try: - tag: Tag = simvue_cli.actions.get_tag(tag_id) - tag_info = tag.to_dict() - click.echo(json.dumps(dict(tag_info.items()), indent=2)) - except ObjectNotFoundError as e: - error_msg = f"Failed to retrieve tag '{tag_id}': {e.args[0]}" - click.echo(error_msg, fg="red", bold=True) - - -@simvue_tag.command("remove") -@click.pass_context -@click.argument("tag_ids", type=str, nargs=-1, required=False) -@click.option( - "-i", - "--interactive", - help="Prompt for confirmation on removal", - type=bool, - default=False, - is_flag=True, -) -def delete_tag(ctx, tag_ids: list[str] | None, interactive: bool) -> None: - """Remove a tag from the Simvue server""" - if not tag_ids: - tag_ids = [] - for line in sys.stdin: - if not line.strip(): - continue - tag_ids += [k.strip() for k in line.split(" ")] - - for tag_id in tag_ids: - try: - simvue_cli.actions.get_tag(tag_id) - except (ObjectNotFoundError, RuntimeError): - error_msg = f"Tag '{tag_id}' not found" - if ctx.obj["plain"]: - click.echo(error_msg) - else: - click.secho(error_msg, fg="red", bold=True) - sys.exit(1) - - if interactive: - remove = click.confirm(f"Remove tag '{tag_id}'?") - if not remove: - continue - - try: - simvue_cli.actions.delete_tag(tag_id) - except ValueError as e: - click.echo( - e.args[0] - if ctx.obj["plain"] - else click.style(e.args[0], fg="red", bold=True) - ) - sys.exit(1) - - response_message = f"Tag '{tag_id}' removed successfully." - - if ctx.obj["plain"]: - click.echo(response_message) - else: - click.secho(response_message, bold=True, fg="green") - - -@simvue.group("admin") -@click.pass_context -def admin(ctx) -> None: - """Administrator commands, requires admin access""" - pass - - -@admin.group("tenant") -@click.pass_context -def simvue_tenant(ctx) -> None: - """Manager server tenants""" - - -@simvue_tenant.command("json") -@click.argument("tenant_id", required=False) -def get_tenant_json(tenant_id: str) -> None: - """Retrieve tenant information from Simvue server - - If no tenant ID is provided the input is read from stdin - """ - if not tenant_id: - tenant_id = input() - - try: - tenant: Tenant = simvue_cli.actions.get_tenant(tenant_id) - tenant_info = tenant.to_dict() - click.echo(json.dumps(dict(tenant_info.items()), indent=2)) - except ObjectNotFoundError as e: - error_msg = f"Failed to retrieve tenant '{tenant_id}': {e.args[0]}" - click.echo(error_msg, fg="red", bold=True) - - -@simvue_tenant.command("add") -@click.pass_context -@click.argument("name", type=SimvueName) -@click.option( - "--disabled", is_flag=True, default=False, help="disable this tenant on creation" -) -@click.option( - "--max-runs", - "-m", - default=None, - type=click.IntRange(min=1, max_open=True), - help="run quota for this tenant", -) -@click.option( - "--max-request-rate", - "-r", - default=None, - type=click.IntRange(min=1, max_open=True), - help="request rate limit for this tenant", -) -@click.option( - "--max-data-volume", - "-V", - default=None, - type=click.IntRange(min=1, max_open=True), - help="data storage limit for this tenant", -) -def add_tenant(ctx, **kwargs) -> None: - """Add a tenant to the Simvue server""" - tenant: Tenant = simvue_cli.actions.create_simvue_tenant(**kwargs) - click.echo(tenant.id if ctx.obj["plain"] else click.style(tenant.id)) - - -@simvue_tenant.command("remove") -@click.pass_context -@click.argument("tenant_ids", type=str, nargs=-1, required=False) -@click.option( - "-i", - "--interactive", - help="Prompt for confirmation on removal", - type=bool, - default=False, - is_flag=True, -) -def delete_tenant(ctx, tenant_ids: list[str] | None, interactive: bool) -> None: - """Remove a tenant from the Simvue server""" - if not tenant_ids: - tenant_ids = [] - for line in sys.stdin: - if not line.strip(): - continue - tenant_ids += [k.strip() for k in line.split(" ")] - - _total_tenants = simvue_cli.actions.count_tenants() - - if _total_tenants < 2: - error_msg = "Attempting to delete single remaining tenant on server." - if ctx.obj["plain"]: - click.echo(error_msg) - else: - click.secho(error_msg, fg="red", bold=True) - sys.exit(1) - - for tenant_id in tenant_ids: - try: - simvue_cli.actions.get_tenant(tenant_id) - except (ObjectNotFoundError, RuntimeError): - error_msg = f"tenant '{tenant_id}' not found" - if ctx.obj["plain"]: - click.echo(error_msg) - else: - click.secho(error_msg, fg="red", bold=True) - sys.exit(1) - - if interactive: - remove = click.confirm(f"Remove tenant '{tenant_id}'?") - if not remove: - continue - - try: - simvue_cli.actions.delete_tenant(tenant_id) - except ValueError as e: - click.echo( - e.args[0] - if ctx.obj["plain"] - else click.style(e.args[0], fg="red", bold=True) - ) - sys.exit(1) - - response_message = f"tenant '{tenant_id}' removed successfully." - - if ctx.obj["plain"]: - click.echo(response_message) - else: - click.secho(response_message, bold=True, fg="green") - - -@simvue_tenant.command("list") -@click.pass_context -@click.option( - "--format", - "table_format", - type=click.Choice(list(tabulate._table_formats.keys())), - help="Display as table with output format", - default=None, -) -@click.option( - "--enumerate", - "enumerate_", - is_flag=True, - help="Show counter next to tenants", - default=False, - show_default=True, -) -@click.option( - "--count", - type=int, - help="Maximum number of tenants to retrieve", - default=20, - show_default=True, -) -@click.option("--max-runs", is_flag=True, help="Show max runs") -@click.option("--max-data-volume", is_flag=True, help="Show maximum data volume") -@click.option("--max-request-rate", is_flag=True, help="Show maximum request rate") -@click.option("--created", is_flag=True, help="Show created timestamp") -@click.option("--name", is_flag=True, help="Show names") -@click.option("--enabled", is_flag=True, help="Show if enabled") -def tenant_list( - ctx, - table_format: str, - enumerate_: bool, - max_runs: bool, - max_data_volume: bool, - max_request_rate: bool, - created: bool, - name: bool, - enabled: bool, - **kwargs, -) -> None: - """Retrieve tenants list from Simvue server""" - runs = simvue_cli.actions.get_tenants_list(**kwargs) - if not runs: - return - columns = ["id"] - - if created: - columns.append("created") - if name: - columns.append("name") - if enabled: - columns.append("is_enabled") - if max_runs: - columns.append("max_runs") - if max_data_volume: - columns.append("max_data_volume") - if max_request_rate: - columns.append("max_request_rate") - - table = create_objects_display( - columns, - runs, - plain_text=ctx.obj["plain"], - enumerate_=enumerate_, - format=table_format, - ) - click.echo(table) - - -@admin.group("user") -@click.pass_context -def user(ctx) -> None: - """Manage server users""" - pass - - -@user.command("list") -@click.option( - "--format", - "table_format", - type=click.Choice(list(tabulate._table_formats.keys())), - help="Display as table with output format", - default=None, -) -@click.option( - "--enumerate", - "enumerate_", - is_flag=True, - help="Show counter next to tenants", - default=False, - show_default=True, -) -@click.option( - "--count", - type=int, - help="Maximum number of tenants to retrieve", - default=20, - show_default=True, -) -@click.option("--username", is_flag=True, default=False, help="display username") -@click.option("--email", is_flag=True, default=False, help="display user email") -@click.option("--full-name", is_flag=True, default=False, help="display user full name") -@click.option("--admin", is_flag=True, default=False, help="show admin status") -@click.option("--manager", is_flag=True, default=False, help="show manager status") -@click.option("--enabled", is_flag=True, default=False, help="show enabled status") -@click.option( - "--read-only", is_flag=True, default=False, help="show user read only status" -) -@click.option( - "--deleted", is_flag=True, default=False, help="show user deletion status" -) -@click.pass_context -def list_user( - ctx, - enumerate_: bool, - table_format: str | None, - username: bool, - email: bool, - full_name: bool, - admin: bool, - manager: bool, - enabled: bool, - read_only: bool, - deleted: bool, - **kwargs, -) -> None: - """Retrieve user list from Simvue server""" - users = simvue_cli.actions.get_users_list(**kwargs) - if not users: - return - - columns = ["id"] - - if username: - columns.append("username") - if email: - columns.append("email") - if full_name: - columns.append("fullname") - if admin: - columns.append("is_admin") - if manager: - columns.append("is_manager") - if enabled: - columns.append("is_enabled") - if read_only: - columns.append("is_readonly") - if deleted: - columns.append("is_deleted") - - table = create_objects_display( - columns, - users, - plain_text=ctx.obj["plain"], - enumerate_=enumerate_, - format=table_format, - ) - click.echo(table) - - -@user.command("json") -@click.argument("user_id", required=False) -def get_user_json(user_id: str) -> None: - """Retrieve user information from Simvue server - - If no user ID is provided the input is read from stdin - """ - if not user_id: - user_id = input() - - try: - user: User = simvue_cli.actions.get_user(user_id) - user_info = user.to_dict() - click.echo(json.dumps(dict(user_info.items()), indent=2)) - except ObjectNotFoundError as e: - error_msg = f"Failed to retrieve user '{user_id}': {e.args[0]}" - click.echo(error_msg, fg="red", bold=True) - - -@user.command("add") -@click.pass_context -@click.argument("username", type=UserName) -@click.option( - "--email", - "-e", - required=True, - help="registration email for user", - type=Email, -) -@click.option( - "--full-name", - "-n", - required=True, - help="full name of this user", - type=FullName, -) -@click.option( - "--tenant", "-t", required=True, help="tenant group to assign this user to" -) -@click.option( - "--manager", is_flag=True, default=False, help="assign manager role to this user" -) -@click.option( - "--admin", - is_flag=True, - default=False, - help="assign administrator role to this user", -) -@click.option( - "--disabled", is_flag=True, default=False, help="disable this user on creation" -) -@click.option( - "--read-only", is_flag=True, default=False, help="give this user only read access" -) -@click.option("--welcome", is_flag=True, default=False, help="display welcome message") -def add_user(ctx, **kwargs) -> None: - """Create a new Simvue user under the given tenant.""" - user: User = simvue_cli.actions.create_simvue_user(**kwargs) - click.echo(user.id if ctx.obj["plain"] else click.style(user.id)) - - -@user.command("remove") -@click.pass_context -@click.argument("user_ids", type=str, nargs=-1, required=False) -@click.option( - "-i", - "--interactive", - help="Prompt for confirmation on removal", - type=bool, - default=False, - is_flag=True, -) -def delete_user(ctx, user_ids: list[str] | None, interactive: bool) -> None: - """Remove a user from the Simvue server""" - if not user_ids: - user_ids = [] - for line in sys.stdin: - if not line.strip(): - continue - user_ids += [k.strip() for k in line.split(" ")] - - for user_id in user_ids: - try: - simvue_cli.actions.get_user(user_id) - except (ObjectNotFoundError, RuntimeError): - error_msg = f"user '{user_id}' not found" - if ctx.obj["plain"]: - click.echo(error_msg) - else: - click.secho(error_msg, fg="red", bold=True) - sys.exit(1) - - if interactive: - remove = click.confirm(f"Remove user '{user_id}'?") - if not remove: - continue - - try: - simvue_cli.actions.delete_user(user_id) - except ValueError as e: - click.echo( - e.args[0] - if ctx.obj["plain"] - else click.style(e.args[0], fg="red", bold=True) - ) - sys.exit(1) - - response_message = f"user '{user_id}' removed successfully." - - if ctx.obj["plain"]: - click.echo(response_message) - else: - click.secho(response_message, bold=True, fg="green") - - -@simvue.group("storage") -@click.pass_context -def simvue_storage(ctx): - """View and manage Simvue storages""" - pass - - -@simvue_storage.group("add") -@click.pass_context -def simvue_storage_add(ctx) -> None: - """Add a new Simvue storage instance to the server.""" - pass - - -@simvue_storage_add.command("s3") -@click.argument("name") -@click.option( - "--disable-check", - is_flag=True, - default=False, - help="Disable checking of storage system.", - show_default=True, -) -@click.option( - "--region-name", - help="Name of the region associated with this storage.", - required=True, -) -@click.option( - "--endpoint-url", help="Endpoint defining the S3 upload URL", required=True -) -@click.option("--access-key-id", help="Access key identifier.", required=True) -@click.option( - "--access-key-file", - help="File containing secret access key", - required=True, - type=click.File(), -) -@click.option( - "--bucket", help="The bucket associated with this storage.", required=True -) -@click.option( - "--block-tenant", - is_flag=True, - default=False, - help="Disable access by current Tenant.", - show_default=True, -) -@click.option( - "--default", - is_flag=True, - default=False, - help="Set this storage to be the default.", - show_default=True, -) -@click.option( - "--disable", - is_flag=True, - default=False, - help="Disable this storage on creation.", - show_default=True, -) -@click.pass_context -def add_s3_storage(ctx, **kwargs) -> None: - storage: S3Storage = simvue_cli.actions.create_simvue_s3_storage(**kwargs) - click.echo(storage.id if ctx.obj["plain"] else click.style(storage.id)) - - -@simvue_storage.command("json") -@click.pass_context -@click.argument("storage_id", required=False) -def get_storage_json(ctx, storage_id: str) -> None: - """Retrieve storage information from Simvue server - - If no storage_ID is provided the input is read from stdin - """ - if not storage_id: - storage_id = input() - - try: - storage: Storage = simvue_cli.actions.get_storage(storage_id) - storage_info = storage.to_dict() - click.echo(json.dumps(dict(storage_info.items()), indent=2)) - except ObjectNotFoundError as e: - error_msg = f"Failed to retrieve storage '{storage_id}': {e.args[0]}" - click.echo(error_msg, fg="red", bold=True) - - -@simvue_storage.command("remove") -@click.pass_context -@click.argument("storage_ids", type=str, nargs=-1, required=False) -@click.option( - "-i", - "--interactive", - help="Prompt for confirmation on removal", - type=bool, - default=False, - is_flag=True, -) -def delete_storage(ctx, storage_ids: list[str] | None, interactive: bool) -> None: - """Remove a storage from the Simvue server""" - if not storage_ids: - storage_ids = [] - for line in sys.stdin: - if not line.strip(): - continue - storage_ids += [k.strip() for k in line.split(" ")] - - for storage_id in storage_ids: - try: - simvue_cli.actions.get_storage(storage_id) - except (ObjectNotFoundError, RuntimeError): - error_msg = f"storage '{storage_id}' not found" - if ctx.obj["plain"]: - click.echo(error_msg) - else: - click.secho(error_msg, fg="red", bold=True) - sys.exit(1) - - if interactive: - remove = click.confirm(f"Remove storage '{storage_id}'?") - if not remove: - continue - - try: - simvue_cli.actions.delete_storage(storage_id) - except ValueError as e: - click.echo( - e.args[0] - if ctx.obj["plain"] - else click.style(e.args[0], fg="red", bold=True) - ) - sys.exit(1) - - response_message = f"storage '{storage_id}' removed successfully." - - if ctx.obj["plain"]: - click.echo(response_message) - else: - click.secho(response_message, bold=True, fg="green") - - -@simvue_storage.command("list") -@click.pass_context -@click.option( - "--format", - "table_format", - type=click.Choice(list(tabulate._table_formats.keys())), - help="Display as table with output format", - default=None, -) -@click.option( - "--enumerate", - "enumerate_", - is_flag=True, - help="Show counter next to storages", - default=False, - show_default=True, -) -@click.option( - "--count", - type=int, - help="Maximum number of storages to retrieve", - default=20, - show_default=True, -) -@click.option("--name", is_flag=True, help="Show names") -@click.option("--backend", is_flag=True, help="Show backend") -@click.option("--created", is_flag=True, help="Show created timestamp") -@click.option("--default", is_flag=True, help="Show if default storage") -@click.option("--tenant-usable", is_flag=True, help="Show if usable by current tenant") -@click.option("--enabled", is_flag=True, help="Show if storage is enabled") -def list_storages( - ctx, - table_format: str, - backend: bool, - tenant_usable: bool, - default: bool, - enabled: bool, - created: bool, - enumerate_: bool, - name: bool, - **kwargs, -) -> None: - """Retrieve storages list from Simvue server""" - storages = simvue_cli.actions.get_storages_list(**kwargs) - columns = ["id"] - - if created: - columns.append("created") - if name: - columns.append("name") - if backend: - columns.append("backend") - if tenant_usable: - columns.append("is_tenant_useable") - if default: - columns.append("is_default") - if enabled: - columns.append("is_enabled") - - table = create_objects_display( - columns, - storages, - plain_text=ctx.obj["plain"], - enumerate_=enumerate_, - format=table_format, - ) - click.echo(table) - - -@simvue.command("venv") -@click.pass_context -@click.option( - "--language", - required=True, - help="Specify target language", - type=click.Choice(["python", "rust", "julia", "nodejs"]), -) -@click.option( - "--run", required=False, help="ID of run to clone environment from", default="" -) -@click.option( - "--allow-existing", - is_flag=True, - help="Install dependencies in an existing environment", -) -@click.argument("venv_directory", type=click.Path(exists=False)) -def venv_setup(ctx, **kwargs) -> None: - """Initialise virtual environments from run metadata. - - If a run ID is not provided via --run it is read from stdin. - """ - if not kwargs.get("run"): - kwargs["run"] = input() - - try: - simvue_cli.actions.create_environment(**kwargs) - except (FileExistsError, RuntimeError) as e: - error_msg = e.args[0] - if ctx.obj["plain"]: - click.echo(error_msg) - else: - click.secho(error_msg, fg="red", bold=True) - sys.exit(1) - - -@simvue.group("artifact") -@click.pass_context -def simvue_artifact(ctx): - """View and manage Simvue artifacts""" - pass - - -@simvue_artifact.command("json") -@click.argument("artifact_id", required=False) -def get_artifact_json(artifact_id: str) -> None: - """Retrieve artifact information from Simvue server - - If no ARTIFACT_ID is provided the input is read from stdin - """ - if not artifact_id: - artifact_id = input() - - try: - artifact: Artifact = simvue_cli.actions.get_artifact(artifact_id) - artifact_info = artifact.to_dict() - click.echo(json.dumps(dict(artifact_info.items()), indent=2)) - except ObjectNotFoundError as e: - error_msg = f"Failed to retrieve artifact '{artifact_id}': {e.args[0]}" - click.echo(error_msg, fg="red", bold=True) - - -@simvue_artifact.command("list") -@click.pass_context -@click.option( - "--format", - "table_format", - type=click.Choice(list(tabulate._table_formats.keys())), - help="Display as table with output format", - default=None, -) -@click.option( - "--enumerate", - "enumerate_", - is_flag=True, - help="Show counter next to runs", - default=False, - show_default=True, -) -@click.option( - "--count", - type=int, - help="Maximum number of runs to retrieve", - default=20, - show_default=True, -) -@click.option( - "--original-path", - is_flag=True, - help="Show original path of artifact", - default=False, -) -@click.option( - "--storage", is_flag=True, help="Show storage ID of artifact", default=False -) -@click.option( - "--mime-type", is_flag=True, help="Show MIME type of artifact", default=False -) -@click.option("--created", is_flag=True, help="Show created timestamp") -@click.option("--user", is_flag=True, help="Show artifact user UUID") -@click.option("--download-url", is_flag=True, help="Show artifact download URL") -@click.option("--uploaded", is_flag=True, help="Show artifact upload status") -@click.option("--checksum", is_flag=True, help="Show artifact checksum") -@click.option("--name", is_flag=True, help="Show artifact name") -@click.option("--size", is_flag=True, help="Show artifact size") -@click.option( - "--sort-by", - help="Specify columns to sort by", - multiple=True, - default=["created"], - type=click.Choice(["created", "name"]), - show_default=True, -) -@click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) -def artifact_list( - ctx, - table_format: str | None, - enumerate_: bool, - original_path: bool, - storage: bool, - mime_type: bool, - created: bool, - user: bool, - download_url: bool, - uploaded: bool, - name: bool, - size: bool, - **kwargs, -) -> None: - """Retrieve artifact list from Simvue server""" - storages = simvue_cli.actions.get_artifacts_list(**kwargs) - columns = ["id"] - - if created: - columns.append("created") - if name: - columns.append("name") - if size: - columns.append("size") - if original_path: - columns.append("original_path") - if storage: - columns.append("storage_id") - if uploaded: - columns.append("uploaded") - if mime_type: - columns.append("mime_type") - if user: - columns.append("user") - if download_url: - columns.append("download_url") - - table = create_objects_display( - columns, - storages, - plain_text=ctx.obj["plain"], - enumerate_=enumerate_, - format=table_format, - ) - click.echo(table) - - -@simvue.group("push") -@click.pass_context -def push(ctx) -> None: - """Push local data to the Simvue server.""" - - -@push.command("runs") -@click.pass_context -@click.argument( - "input_file", - type=click.Path( - exists=True, - file_okay=True, - dir_okay=False, - readable=True, - allow_dash=False, - resolve_path=True, - path_type=pathlib.Path, - ), -) -@click.option("--name", default=None, help="Name to set to all runs.") -@click.option("--folder", default=None, help="Simvue folder to add runs to.") -@click.option( - "--tenant", - "tenant_visible", - is_flag=True, - default=False, - help="Share with tenant.", -) -@click.option( - "--public", - "public_visible", - is_flag=True, - default=False, - help="Share with public.", -) -@click.option( - "--user", "user_list", multiple=True, help="Share with user.", default=None -) -@click.option( - "--metadata", - "global_metadata", - type=JSONType, - help="Metadata to append to all runs in the form of a JSON string.", -) -@click.option( - "--from-metadata", - is_flag=True, - help="Create runs from a list of metadata only.", -) -def push_runs( - ctx, - input_file: pathlib.Path, - from_metadata: bool, - tenant_visible: bool, - public_visible: bool, - user_list: list[str], - **kwargs, -) -> None: - """Push sets of runs to the Simvue server. - - The default is to create runs from a JSON definition containing a list of run specifications. - - If the option `--from-metadata` runs are created from metadata only having no metrics information. - These runs are taken either from JSON or CSV as sets of metadata. - - Only one visibility option from `--tenant`, `--public` or `--user`, may be specified. - """ - _plain_text = ctx.obj["plain"] - - if sum([int(i or 0) for i in (user_list, public_visible, tenant_visible)]) > 1: - raise click.UsageError("Cannot specify above one visibility option.") - - if from_metadata: - if input_file.suffix == ".csv": - _folder_id = simvue_cli.actions.push_delim_metadata( - input_file, - delimiter=",", - **kwargs, - public_visible=public_visible, - tenant_visible=tenant_visible, - user_list=user_list, - ) - elif input_file.suffix == ".json": - _folder_id = simvue_cli.actions.push_json_metadata( - input_file, - public_visible=public_visible, - tenant_visible=tenant_visible, - user_list=user_list, - **kwargs, - ) - else: - _out_msg: str = f"Unsupported file type '{input_file.suffix}'" - if not _plain_text: - _out_msg = click.style(_out_msg, fg="red", bold=True) - click.echo(_out_msg) - raise click.Abort - click.echo(_folder_id) - return - if input_file.suffix == ".json": - _folder_ids = simvue_cli.actions.push_json_runs( - input_file, - public_visible=public_visible, - tenant_visible=tenant_visible, - user_list=user_list, - **kwargs, - ) - else: - _out_msg: str = f"Unsupported file type '{input_file.suffix}'" - if not _plain_text: - _out_msg = click.style(_out_msg, fg="red", bold=True) - click.echo(_out_msg) - raise click.Abort - click.echo("\n".join(_folder_ids)) +simvue.add_command(config_cli) +simvue.add_command(run_cli) +simvue.add_command(alert_cli) +simvue.add_command(folder_cli) +simvue.add_command(ping_server) +simvue.add_command(whoami) +simvue.add_command(purge_simvue) +simvue.add_command(about_simvue) +simvue.add_command(admin_cli) +simvue.add_command(tag_cli) +simvue.add_command(storage_cli) +simvue.add_command(artifact_cli) +simvue.add_command(push_cli) +simvue.add_command(monitor_cli) +simvue.add_command(venv_cli) if __name__ in "__main__": diff --git a/src/simvue_cli/cli/admin/__init__.py b/src/simvue_cli/cli/admin/__init__.py new file mode 100644 index 0000000..78ddd7e --- /dev/null +++ b/src/simvue_cli/cli/admin/__init__.py @@ -0,0 +1,17 @@ +"""Simvue Server Admin Commands.""" + +import click + +from .user import simvue_user as user_cli +from .tenant import simvue_tenant as tenant_cli + + +@click.group("admin") +@click.pass_context +def admin(_) -> None: + """Administrator commands, require admin access""" + pass + + +admin.add_command(user_cli) +admin.add_command(tenant_cli) diff --git a/src/simvue_cli/cli/admin/tenant.py b/src/simvue_cli/cli/admin/tenant.py new file mode 100644 index 0000000..efc3750 --- /dev/null +++ b/src/simvue_cli/cli/admin/tenant.py @@ -0,0 +1,209 @@ +"""Simvue Tenant Commands.""" + +import click +import sys +import tabulate +import json + +import simvue_cli.actions + +from simvue_cli.cli.display import create_objects_display +from simvue_cli.validation import SimvueName +from simvue.api.objects import Tenant +from simvue.exception import ObjectNotFoundError + + +@click.group("tenant") +@click.pass_context +def simvue_tenant(_) -> None: + """Manager server tenants""" + + +@simvue_tenant.command("json") +@click.argument("tenant_id", required=False) +@click.pass_context +def get_tenant_json(ctx, tenant_id: str) -> None: + """Retrieve tenant information from Simvue server + + If no tenant ID is provided the input is read from stdin + """ + if not tenant_id: + tenant_id = input() + + try: + tenant: Tenant = simvue_cli.actions.get_tenant(tenant_id) + tenant_info = tenant.to_dict() + click.echo(json.dumps(dict(tenant_info.items()), indent=2)) + except ObjectNotFoundError as e: + error_msg = f"Failed to retrieve tenant '{tenant_id}': {e.args[0]}" + if not ctx.obj["plain"]: + error_msg = click.style(error_msg, fg="red", bold=True) + click.echo(error_msg) + + +@simvue_tenant.command("add") +@click.pass_context +@click.argument("name", type=SimvueName) +@click.option( + "--disabled", is_flag=True, default=False, help="disable this tenant on creation" +) +@click.option( + "--max-runs", + "-m", + default=None, + type=click.IntRange(min=1, max_open=True), + help="run quota for this tenant", +) +@click.option( + "--max-request-rate", + "-r", + default=None, + type=click.IntRange(min=1, max_open=True), + help="request rate limit for this tenant", +) +@click.option( + "--max-data-volume", + "-V", + default=None, + type=click.IntRange(min=1, max_open=True), + help="data storage limit for this tenant", +) +def add_tenant(ctx, **kwargs) -> None: + """Add a tenant to the Simvue server""" + tenant: Tenant = simvue_cli.actions.create_simvue_tenant(**kwargs) + click.echo(tenant.id if ctx.obj["plain"] else click.style(tenant.id)) + + +@simvue_tenant.command("remove") +@click.pass_context +@click.argument("tenant_ids", type=str, nargs=-1, required=False) +@click.option( + "-i", + "--interactive", + help="Prompt for confirmation on removal", + type=bool, + default=False, + is_flag=True, +) +def delete_tenant(ctx, tenant_ids: list[str] | None, interactive: bool) -> None: + """Remove a tenant from the Simvue server""" + if not tenant_ids: + tenant_ids = [] + for line in sys.stdin: + if not line.strip(): + continue + tenant_ids += [k.strip() for k in line.split(" ")] + + _total_tenants = simvue_cli.actions.count_tenants() + + if _total_tenants < 2: + error_msg = "Attempting to delete single remaining tenant on server." + if ctx.obj["plain"]: + click.echo(error_msg) + else: + click.secho(error_msg, fg="red", bold=True) + sys.exit(1) + + for tenant_id in tenant_ids: + try: + simvue_cli.actions.get_tenant(tenant_id) + except (ObjectNotFoundError, RuntimeError): + error_msg = f"tenant '{tenant_id}' not found" + if ctx.obj["plain"]: + click.echo(error_msg) + else: + click.secho(error_msg, fg="red", bold=True) + sys.exit(1) + + if interactive: + remove = click.confirm(f"Remove tenant '{tenant_id}'?") + if not remove: + continue + + try: + simvue_cli.actions.delete_tenant(tenant_id) + except ValueError as e: + click.echo( + e.args[0] + if ctx.obj["plain"] + else click.style(e.args[0], fg="red", bold=True) + ) + sys.exit(1) + + response_message = f"tenant '{tenant_id}' removed successfully." + + if ctx.obj["plain"]: + click.echo(response_message) + else: + click.secho(response_message, bold=True, fg="green") + + +@simvue_tenant.command("list") +@click.pass_context +@click.option( + "--format", + "table_format", + type=click.Choice(list(tabulate._table_formats.keys())), + help="Display as table with output format", + default=None, +) +@click.option( + "--enumerate", + "enumerate_", + is_flag=True, + help="Show counter next to tenants", + default=False, + show_default=True, +) +@click.option( + "--count", + type=int, + help="Maximum number of tenants to retrieve", + default=20, + show_default=True, +) +@click.option("--max-runs", is_flag=True, help="Show max runs") +@click.option("--max-data-volume", is_flag=True, help="Show maximum data volume") +@click.option("--max-request-rate", is_flag=True, help="Show maximum request rate") +@click.option("--created", is_flag=True, help="Show created timestamp") +@click.option("--name", is_flag=True, help="Show names") +@click.option("--enabled", is_flag=True, help="Show if enabled") +def tenant_list( + ctx, + table_format: str, + enumerate_: bool, + max_runs: bool, + max_data_volume: bool, + max_request_rate: bool, + created: bool, + name: bool, + enabled: bool, + **kwargs, +) -> None: + """Retrieve tenants list from Simvue server""" + runs = simvue_cli.actions.get_tenants_list(**kwargs) + if not runs: + return + columns = ["id"] + + if created: + columns.append("created") + if name: + columns.append("name") + if enabled: + columns.append("is_enabled") + if max_runs: + columns.append("max_runs") + if max_data_volume: + columns.append("max_data_volume") + if max_request_rate: + columns.append("max_request_rate") + + table = create_objects_display( + columns, + runs, + plain_text=ctx.obj["plain"], + enumerate_=enumerate_, + format=table_format, + ) + click.echo(table) diff --git a/src/simvue_cli/cli/admin/user.py b/src/simvue_cli/cli/admin/user.py new file mode 100644 index 0000000..7d8eb91 --- /dev/null +++ b/src/simvue_cli/cli/admin/user.py @@ -0,0 +1,221 @@ +"""Simvue User Commands.""" + +import click +import tabulate +import json +import sys + +import simvue_cli.actions +from simvue_cli.cli.display import create_objects_display +from simvue_cli.validation import Email, FullName, UserName +from simvue.api.objects import User +from simvue.exception import ObjectNotFoundError + + +@click.group("user") +@click.pass_context +def simvue_user(_) -> None: + """Manage server users""" + pass + + +@simvue_user.command("list") +@click.option( + "--format", + "table_format", + type=click.Choice(list(tabulate._table_formats.keys())), + help="Display as table with output format", + default=None, +) +@click.option( + "--enumerate", + "enumerate_", + is_flag=True, + help="Show counter next to tenants", + default=False, + show_default=True, +) +@click.option( + "--count", + type=int, + help="Maximum number of tenants to retrieve", + default=20, + show_default=True, +) +@click.option("--username", is_flag=True, default=False, help="display username") +@click.option("--email", is_flag=True, default=False, help="display user email") +@click.option("--full-name", is_flag=True, default=False, help="display user full name") +@click.option("--admin", is_flag=True, default=False, help="show admin status") +@click.option("--manager", is_flag=True, default=False, help="show manager status") +@click.option("--enabled", is_flag=True, default=False, help="show enabled status") +@click.option( + "--read-only", is_flag=True, default=False, help="show user read only status" +) +@click.option( + "--deleted", is_flag=True, default=False, help="show user deletion status" +) +@click.pass_context +def list_user( + ctx, + enumerate_: bool, + table_format: str | None, + username: bool, + email: bool, + full_name: bool, + admin: bool, + manager: bool, + enabled: bool, + read_only: bool, + deleted: bool, + **kwargs, +) -> None: + """Retrieve user list from Simvue server""" + users = simvue_cli.actions.get_users_list(**kwargs) + if not users: + return + + columns = ["id"] + + if username: + columns.append("username") + if email: + columns.append("email") + if full_name: + columns.append("fullname") + if admin: + columns.append("is_admin") + if manager: + columns.append("is_manager") + if enabled: + columns.append("is_enabled") + if read_only: + columns.append("is_readonly") + if deleted: + columns.append("is_deleted") + + table = create_objects_display( + columns, + users, + plain_text=ctx.obj["plain"], + enumerate_=enumerate_, + format=table_format, + ) + click.echo(table) + + +@simvue_user.command("json") +@click.argument("user_id", required=False) +@click.pass_context +def get_user_json(ctx, user_id: str) -> None: + """Retrieve user information from Simvue server + + If no user ID is provided the input is read from stdin + """ + if not user_id: + user_id = input() + + try: + user: User = simvue_cli.actions.get_user(user_id) + user_info = user.to_dict() + click.echo(json.dumps(dict(user_info.items()), indent=2)) + except ObjectNotFoundError as e: + error_msg = f"Failed to retrieve user '{user_id}': {e.args[0]}" + if not ctx.obj["plain"]: + error_msg = click.style(error_msg, fg="red", bold=True) + click.echo(error_msg) + + +@simvue_user.command("add") +@click.pass_context +@click.argument("username", type=UserName) +@click.option( + "--email", + "-e", + required=True, + help="registration email for user", + type=Email, +) +@click.option( + "--full-name", + "-n", + required=True, + help="full name of this user", + type=FullName, +) +@click.option( + "--tenant", "-t", required=True, help="tenant group to assign this user to" +) +@click.option( + "--manager", is_flag=True, default=False, help="assign manager role to this user" +) +@click.option( + "--admin", + is_flag=True, + default=False, + help="assign administrator role to this user", +) +@click.option( + "--disabled", is_flag=True, default=False, help="disable this user on creation" +) +@click.option( + "--read-only", is_flag=True, default=False, help="give this user only read access" +) +@click.option("--welcome", is_flag=True, default=False, help="display welcome message") +def add_user(ctx, **kwargs) -> None: + """Create a new Simvue user under the given tenant.""" + user: User = simvue_cli.actions.create_simvue_user(**kwargs) + click.echo(user.id if ctx.obj["plain"] else click.style(user.id)) + + +@simvue_user.command("remove") +@click.pass_context +@click.argument("user_ids", type=str, nargs=-1, required=False) +@click.option( + "-i", + "--interactive", + help="Prompt for confirmation on removal", + type=bool, + default=False, + is_flag=True, +) +def delete_user(ctx, user_ids: list[str] | None, interactive: bool) -> None: + """Remove a user from the Simvue server""" + if not user_ids: + user_ids = [] + for line in sys.stdin: + if not line.strip(): + continue + user_ids += [k.strip() for k in line.split(" ")] + + for user_id in user_ids: + try: + simvue_cli.actions.get_user(user_id) + except (ObjectNotFoundError, RuntimeError): + error_msg = f"user '{user_id}' not found" + if ctx.obj["plain"]: + click.echo(error_msg) + else: + click.secho(error_msg, fg="red", bold=True) + sys.exit(1) + + if interactive: + remove = click.confirm(f"Remove user '{user_id}'?") + if not remove: + continue + + try: + simvue_cli.actions.delete_user(user_id) + except ValueError as e: + click.echo( + e.args[0] + if ctx.obj["plain"] + else click.style(e.args[0], fg="red", bold=True) + ) + sys.exit(1) + + response_message = f"user '{user_id}' removed successfully." + + if ctx.obj["plain"]: + click.echo(response_message) + else: + click.secho(response_message, bold=True, fg="green") diff --git a/src/simvue_cli/cli/alert.py b/src/simvue_cli/cli/alert.py new file mode 100644 index 0000000..ffd2638 --- /dev/null +++ b/src/simvue_cli/cli/alert.py @@ -0,0 +1,246 @@ +"""Simvue Alert Commands.""" + +import click +import json +import tabulate +import sys +import simvue_cli.actions + +from simvue.api.objects.alert.base import AlertBase +from simvue.exception import ObjectNotFoundError + +from simvue_cli.validation import SimvueName +from .display import create_objects_display + + +@click.group("alert") +@click.pass_context +def simvue_alert(_) -> None: + """Create and list Simvue alerts""" + pass + + +@simvue_alert.command("trigger") +@click.pass_context +@click.argument("run_id") +@click.argument("alert_id") +@click.option( + "--ok", + "is_ok", + is_flag=True, + help="Set alert to status 'ok' as opposed to critical.", + show_default=True, +) +def trigger_alert(ctx, is_ok: bool, **kwargs) -> None: + """Trigger a user alert""" + try: + simvue_cli.actions.trigger_user_alert( + status="ok" if is_ok else "critical", **kwargs + ) + except ValueError as e: + if ctx.obj["plain"]: + click.echo(e.args[0]) + else: + click.secho(e.args[0], fg="red", bold=True) + sys.exit(1) + + +@simvue_alert.command("list") +@click.pass_context +@click.option( + "--format", + "table_format", + type=click.Choice(list(tabulate._table_formats.keys())), + help="Display as table with output format", + default=None, +) +@click.option( + "--enumerate", + "enumerate_", + is_flag=True, + help="Show counter next to alerts", + default=False, + show_default=True, +) +@click.option( + "--offset", + type=int, + help="Start index for results", + default=None, + show_default=None, +) +@click.option( + "--count", + type=int, + help="Maximum number of alerts to retrieve", + default=20, + show_default=True, +) +@click.option("--run-tags", is_flag=True, help="Show tags") +@click.option("--auto", is_flag=True, help="Show if run tag auto-assign is enabled") +@click.option("--notification", is_flag=True, help="Show notification setting") +@click.option("--created", is_flag=True, help="Show created timestamp") +@click.option("--source", is_flag=True, help="Show alert source") +@click.option("--enabled", is_flag=True, help="Show if alert enabled") +@click.option("--abort", is_flag=True, help="Show alert if alert can abort runs") +@click.option("--name", is_flag=True, help="Show names") +@click.option("--description", is_flag=True, help="Show description") +@click.option( + "--sort-by", + help="Specify columns to sort by", + multiple=True, + default=["created"], + type=click.Choice(["created", "name"]), + show_default=True, +) +@click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) +def alert_list( + ctx, + table_format: str, + enumerate_: bool, + run_tags: bool, + name: bool, + auto: bool, + notification: bool, + source: bool, + enabled: bool, + description: bool, + created: bool, + **kwargs, +) -> None: + """Retrieve alerts list from Simvue server""" + kwargs |= {"filters": kwargs.get("filters" or [])} + alerts = simvue_cli.actions.get_alerts_list(**kwargs) + if not alerts: + return + columns = ["id"] + + if name: + columns.append("name") + if created: + columns.append("created") + if run_tags: + columns.append("run_tags") + if description: + columns.append("description") + if notification: + columns.append("notification") + if enabled: + columns.append("enabled") + if auto: + columns.append("auto") + if source: + columns.append("source") + + table = create_objects_display( + columns, + alerts, + plain_text=ctx.obj["plain"], + enumerate_=enumerate_, + format=table_format, + ) + click.echo(table) + + +@simvue_alert.command("create") +@click.pass_context +@click.argument("name", type=SimvueName) +@click.option( + "--abort", + is_flag=True, + help="Abort run if this alert is triggered", + show_default=True, +) +@click.option("--description", default=None, help="Description for this alert.") +@click.option( + "--email", is_flag=True, help="Notify by email if triggered", show_default=True +) +def create_alert( + ctx, + name: str, + abort: bool = False, + email: bool = False, + description: str | None = None, +) -> None: + """Create a User alert""" + result = simvue_cli.actions.create_user_alert( + name=name, trigger_abort=abort, email_notify=email, description=description + ) + alert_id = result.id + click.echo(alert_id if ctx.obj["plain"] else click.style(alert_id)) + + +@simvue_alert.command("remove") +@click.pass_context +@click.argument("alert_ids", type=str, nargs=-1, required=False) +@click.option( + "-i", + "--interactive", + help="Prompt for confirmation on removal", + type=bool, + default=False, + is_flag=True, +) +def delete_alert(ctx, alert_ids: list[str] | None, interactive: bool) -> None: + """Remove a alert from the Simvue server""" + if not alert_ids: + alert_ids = [] + for line in sys.stdin: + if not line.strip(): + continue + alert_ids += [k.strip() for k in line.split(" ")] + + for alert_id in alert_ids: + try: + simvue_cli.actions.get_alert(alert_id) + except (ObjectNotFoundError, RuntimeError): + error_msg = f"alert '{alert_id}' not found" + if ctx.obj["plain"]: + click.echo(error_msg) + else: + click.secho(error_msg, fg="red", bold=True) + sys.exit(1) + + if interactive: + remove = click.confirm(f"Remove alert '{alert_id}'?") + if not remove: + continue + + try: + simvue_cli.actions.delete_alert(alert_id) + except ValueError as e: + click.echo( + e.args[0] + if ctx.obj["plain"] + else click.style(e.args[0], fg="red", bold=True) + ) + sys.exit(1) + + response_message = f"alert '{alert_id}' removed successfully." + + if ctx.obj["plain"]: + click.echo(response_message) + else: + click.secho(response_message, bold=True, fg="green") + + +@simvue_alert.command("json") +@click.argument("alert_id", required=False) +@click.pass_context +def get_alert_json(ctx, alert_id: str) -> None: + """Retrieve alert information from Simvue server + + If no alert ID is provided the input is read from stdin + """ + if not alert_id: + alert_id = input() + + try: + alert: AlertBase = simvue_cli.actions.get_alert(alert_id) + alert_info = alert.to_dict() + click.echo(json.dumps(dict(alert_info.items()), indent=2)) + except ObjectNotFoundError as e: + error_msg = f"Failed to retrieve alert '{alert_id}': {e.args[0]}" + if not ctx.obj["plain"]: + error_msg = click.style(error_msg, fg="red", bold=True) + click.echo(error_msg) diff --git a/src/simvue_cli/cli/artifact.py b/src/simvue_cli/cli/artifact.py new file mode 100644 index 0000000..1498d52 --- /dev/null +++ b/src/simvue_cli/cli/artifact.py @@ -0,0 +1,138 @@ +"""Simvue Artifact Commands.""" + +import click +import json +from simvue.api.objects import Artifact +from simvue.exception import ObjectNotFoundError +import tabulate +import simvue_cli.actions +from simvue_cli.cli.display import create_objects_display + + +@click.group("artifact") +@click.pass_context +def simvue_artifact(_): + """View and manage Simvue artifacts""" + pass + + +@simvue_artifact.command("json") +@click.argument("artifact_id", required=False) +@click.pass_context +def get_artifact_json(ctx, artifact_id: str) -> None: + """Retrieve artifact information from Simvue server + + If no ARTIFACT_ID is provided the input is read from stdin + """ + if not artifact_id: + artifact_id = input() + + try: + artifact: Artifact = simvue_cli.actions.get_artifact(artifact_id) + artifact_info = artifact.to_dict() + click.echo(json.dumps(dict(artifact_info.items()), indent=2)) + except ObjectNotFoundError as e: + error_msg = f"Failed to retrieve artifact '{artifact_id}': {e.args[0]}" + if not ctx.obj["plain"]: + error_msg = click.style(error_msg, fg="red", bold=True) + click.echo(error_msg) + + +@simvue_artifact.command("list") +@click.pass_context +@click.option( + "--format", + "table_format", + type=click.Choice(list(tabulate._table_formats.keys())), + help="Display as table with output format", + default=None, +) +@click.option( + "--enumerate", + "enumerate_", + is_flag=True, + help="Show counter next to runs", + default=False, + show_default=True, +) +@click.option( + "--count", + type=int, + help="Maximum number of runs to retrieve", + default=20, + show_default=True, +) +@click.option( + "--original-path", + is_flag=True, + help="Show original path of artifact", + default=False, +) +@click.option( + "--storage", is_flag=True, help="Show storage ID of artifact", default=False +) +@click.option( + "--mime-type", is_flag=True, help="Show MIME type of artifact", default=False +) +@click.option("--created", is_flag=True, help="Show created timestamp") +@click.option("--user", is_flag=True, help="Show artifact user UUID") +@click.option("--download-url", is_flag=True, help="Show artifact download URL") +@click.option("--uploaded", is_flag=True, help="Show artifact upload status") +@click.option("--checksum", is_flag=True, help="Show artifact checksum") +@click.option("--name", is_flag=True, help="Show artifact name") +@click.option("--size", is_flag=True, help="Show artifact size") +@click.option( + "--sort-by", + help="Specify columns to sort by", + multiple=True, + default=["created"], + type=click.Choice(["created", "name"]), + show_default=True, +) +@click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) +def artifact_list( + ctx, + table_format: str | None, + enumerate_: bool, + original_path: bool, + storage: bool, + mime_type: bool, + created: bool, + user: bool, + download_url: bool, + uploaded: bool, + name: bool, + size: bool, + **kwargs, +) -> None: + """Retrieve artifact list from Simvue server""" + artifacts = simvue_cli.actions.get_artifacts_list(**kwargs) + columns = ["id"] + + if created: + columns.append("created") + if name: + columns.append("name") + if size: + columns.append("size") + if original_path: + columns.append("original_path") + if storage: + columns.append("storage_id") + if uploaded: + columns.append("uploaded") + if mime_type: + columns.append("mime_type") + if user: + columns.append("user") + if download_url: + columns.append("download_url") + + table = create_objects_display( + columns, + artifacts, + plain_text=ctx.obj["plain"], + enumerate_=enumerate_, + format=table_format, + ) + click.echo(table) diff --git a/src/simvue_cli/cli/config.py b/src/simvue_cli/cli/config.py new file mode 100644 index 0000000..21aab38 --- /dev/null +++ b/src/simvue_cli/cli/config.py @@ -0,0 +1,111 @@ +"""Simvue CLI Configuration commands.""" + +import click +import pathlib +import sys +import toml +import os + + +import simvue_cli.config + +from click_params import PUBLIC_URL + + +@click.group("config") +@click.option( + "_global", + "--global/--all", + default=None, + help="Update global or all configurations. Default of None will update local configuration only.", + show_default=True, +) +@click.pass_context +def config(ctx, _global: bool | None) -> None: + """Configure Simvue""" + if _global is not None: + ctx.obj["config_locations"] = "global" if _global else "all" + else: + ctx.obj["config_locations"] = "project" + + +@config.command("server.url") +@click.argument("url", type=PUBLIC_URL) +@click.pass_context +def config_set_url(ctx, url: str) -> None: + """Update Simvue configuration URL""" + _profile_name, _ = ctx.obj["profile"] + _target_locations = ctx.obj["config_locations"] + _out_files: list[pathlib.Path] = simvue_cli.config.set_profile_option( + profile_name=_profile_name, key="url", value=url, targets=_target_locations + ) + for out_file in _out_files: + click.secho(f"Wrote URL value to '{out_file}'") + if not _out_files: + sys.exit(1) + + +@config.command("server.token") +@click.argument("token", type=str) +@click.pass_context +def config_set_token(ctx, token: str) -> None: + """Update Simvue configuration Token""" + _profile_name, _ = ctx.obj["profile"] + _target_locations = ctx.obj["config_locations"] + _out_files: list[pathlib.Path] = simvue_cli.config.set_profile_option( + profile_name=_profile_name, key="token", value=token, targets=_target_locations + ) + for out_file in _out_files: + click.secho(f"Wrote token value to '{out_file}'") + + +@config.command("show") +@click.pass_context +def config_show(ctx) -> None: + """Show the current Simvue configuration.""" + + # Remove environment override to show full listing + # instead highlight current server + _env_url = os.environ.get("SIMVUE_URL") + _env_token = os.environ.get("SIMVUE_TOKEN") + + _config_file, _config = simvue_cli.config.get_current_configuration() + _current_url: str | None = None + _current_token: str | None = None + + click.echo(f"Using configuration from '{_config_file}'.\n") + + _name, _profile = ctx.obj["profile"] + + if _profile: + _current_url = _profile.url + _current_token = _profile.token + _config_str = toml.dumps(_config) + + if ctx.obj["plain"] and _name: + _config_str = _config_str.replace( + f"[profiles.{_name}]", f"[profiles.{_name}] <<< ACTIVE PROFILE" + ) + elif _name: + _config_str = _config_str.replace( + f"[profiles.{_name}]", + click.style(f"[profiles.{_name}]", bold=True, fg="cyan"), + ) + + click.secho(_config_str) + return + + if _config_file: + click.secho(f"Using configuration from '{_config_file}'.\n") + if _env_url and _env_token: + click.secho("Using environment variables:") + click.secho(f" SIMVUE_URL={_env_url}") + click.secho(" SIMVUE_TOKEN=****\n") + _current_url = _env_url + _current_token = _env_token + elif not _config_file: + click.secho("No config file found.\n", fg="red", bold=True) + click.secho(toml.dumps(_config)) + + if not _config_file and (not _current_url or not _current_token): + raise sys.exit(1) diff --git a/src/simvue_cli/cli/folder.py b/src/simvue_cli/cli/folder.py new file mode 100644 index 0000000..91d15ee --- /dev/null +++ b/src/simvue_cli/cli/folder.py @@ -0,0 +1,266 @@ +"""Simvue Folder Commands.""" + +import click +import re +import json +import tabulate +import sys + +from .display import create_objects_display, format_folder_tree + +import simvue_cli.actions + +from simvue.api.objects import Folder +from simvue.exception import ObjectNotFoundError +from simvue.models import FOLDER_REGEX + + +@click.group("folder") +@click.pass_context +def simvue_folder(_) -> None: + """Create or retrieve Simvue folders""" + pass + + +@simvue_folder.command("list") +@click.pass_context +@click.option( + "--format", + "table_format", + type=click.Choice(list(tabulate._table_formats.keys())), + help="Display as table with output format", + default=None, +) +@click.option( + "--enumerate", + "enumerate_", + is_flag=True, + help="Show counter next to folders", + default=False, + show_default=True, +) +@click.option( + "--count", + type=int, + help="Maximum number of folders to retrieve", + default=20, + show_default=True, +) +@click.option("--path", is_flag=True, help="Show path") +@click.option("--tags", is_flag=True, help="Show tags") +@click.option("--created", is_flag=True, help="Show created timestamp") +@click.option("--name", is_flag=True, help="Show names") +@click.option("--description", is_flag=True, help="Show description") +@click.option( + "--sort-by", + help="Specify columns to sort by", + multiple=True, + default=["created"], + type=click.Choice(["created", "modified", "path"]), + show_default=True, +) +@click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) +def folder_list( + ctx, + table_format: str, + enumerate_: bool, + path: bool, + tags: bool, + name: bool, + created: bool, + description: bool, + **kwargs, +) -> None: + """Retrieve folders list from Simvue server""" + folders = simvue_cli.actions.get_folders_list(**kwargs) + if not folders: + return + columns = ["id"] + + if created: + columns.append("created") + if path: + columns.append("path") + if name: + columns.append("name") + if tags: + columns.append("tags") + if description: + columns.append("description") + + table = create_objects_display( + columns, + folders, + plain_text=ctx.obj["plain"], + enumerate_=enumerate_, + format=table_format, + ) + click.echo(table) + + +@simvue_folder.command("json") +@click.argument("folder_id", required=False) +def get_folder_json(folder_id: str | None) -> None: + """Retrieve folder information from Simvue server + + If no folder_ID is provided the input is read from stdin. + Input can be folder unique identifier or name. + """ + if not folder_id: + folder_id = input() + + if re.match(FOLDER_REGEX, folder_id): + try: + folder: Folder = simvue_cli.actions.get_folder_by_path(folder_id) + except StopIteration: + error_msg: str = f"Failed to retrieve folder '{folder_id}': No such folder." + click.secho(error_msg, fg="red", bold=True) + return + else: + try: + folder = simvue_cli.actions.get_folder(folder_id) + except ObjectNotFoundError as e: + error_msg = f"Failed to retrieve folder '{folder_id}': {e.args[0]}" + click.secho(error_msg, fg="red", bold=True) + return + click.echo(folder.path) + folder_info = folder.to_dict() + click.echo(json.dumps(dict(folder_info.items()), indent=2)) + + +@simvue_folder.command("remove") +@click.pass_context +@click.argument("folder_ids", type=str, nargs=-1, required=False) +@click.option( + "-i", + "--interactive", + help="Prompt for confirmation on removal", + type=bool, + default=False, + is_flag=True, +) +@click.option( + "-r", "--recurse", help="Recursively remove folders.", default=False, is_flag=True +) +@click.option( + "-f", + "--force", + help="Forcefully delete folder even if it contains runs.", + is_flag=True, + default=False, +) +@click.option( + "-c", + "--content", + help="Delete only folder content not folder itself.", + is_flag=True, + default=False, +) +def delete_folder( + ctx, + folder_ids: list[str] | None, + interactive: bool, + force: bool, + recurse: bool, + content: bool, +) -> None: + """Remove a Folder from the Simvue server""" + if not folder_ids: + folder_ids = [] + for line in sys.stdin: + if not line.strip(): + continue + folder_ids += [k.strip() for k in line.split(" ")] + + force = force if not content else False + + for folder_id in folder_ids: + try: + _folder = simvue_cli.actions.get_folder(folder_id) + except (ObjectNotFoundError, RuntimeError): + error_msg = f"Folder '{folder_id}' not found" + if ctx.obj["plain"]: + print(error_msg) + else: + click.secho(error_msg, fg="red", bold=True) + sys.exit(1) + + if _folder.path == "/": + _warn_message: str = "Root directory cannot be deleted." + if ctx.obj["plain"]: + print(_warn_message) + else: + click.secho(_warn_message, fg="red", bold=True) + sys.exit(1) + + if interactive: + remove = click.confirm( + f"Remove folder '{folder_id}'" + " and contained runs" + if force + else "" + "?" + ) + if not remove: + continue + + try: + simvue_cli.actions.delete_folder( + folder_id, force=force, recurse=recurse, contents_only=content + ) + except ValueError as e: + click.echo( + e.args[0] + if ctx.obj["plain"] + else click.style(e.args[0], fg="red", bold=True) + ) + sys.exit(1) + except RuntimeError as e: + if "Folder is in use" in e.args[0]: + _out_msg = f"Failed to delete folder '{folder_id}', folder in use." + else: + _out_msg = e.args[0] + click.echo( + _out_msg + if ctx.obj["plain"] + else click.style(_out_msg, fg="red", bold=True) + ) + sys.exit(1) + + response_message = f"Folder '{folder_id}' removed successfully." + + if ctx.obj["plain"]: + print(response_message) + else: + click.secho(response_message, bold=True, fg="green") + + +@simvue_folder.command("tree") +@click.argument("folder_id", required=False) +@click.option( + "-l", "--detail", help="Include folder details", default=False, is_flag=True +) +def display_folder_tree(folder_id: str | None, detail: bool) -> None: + """Display tree graph of folder structure. + + if no folder_ID is provided the input is read from stdin + """ + if not folder_id: + folder_id = input() + + if re.match(FOLDER_REGEX, folder_id): + try: + folder: Folder = simvue_cli.actions.get_folder_by_path(folder_id) + except StopIteration: + error_msg: str = f"Failed to retrieve folder '{folder_id}': No such folder." + click.secho(error_msg, fg="red", bold=True) + return + else: + try: + folder = simvue_cli.actions.get_folder(folder_id) + except ObjectNotFoundError as e: + error_msg = f"Failed to retrieve folder '{folder_id}': {e.args[0]}" + click.secho(error_msg, fg="red", bold=True) + return + if detail: + _details: dict[str, dict] = simvue_cli.actions.get_folder_details(folder) + print(_details) + click.echo(format_folder_tree(folder.tree)) diff --git a/src/simvue_cli/cli/monitor.py b/src/simvue_cli/cli/monitor.py new file mode 100644 index 0000000..b372f4b --- /dev/null +++ b/src/simvue_cli/cli/monitor.py @@ -0,0 +1,85 @@ +"""Simvue process monitor Commands.""" + +import click +import sys +import click_option_group + +import simvue_cli.actions + +from simvue_cli.validation import SimvueFolder, SimvueName + +from simvue.api.objects import Run + + +@click.command("monitor") +@click_option_group.optgroup.group( + "Run attributes", + help="Assign properties such as metadata and labelling to this run", +) +@click_option_group.optgroup.option( + "--name", type=SimvueName, help="Name to assign to this run", default=None +) +@click_option_group.optgroup.option( + "--description", type=str, help="Short run description", default=None +) +@click_option_group.optgroup.option( + "--tag", type=str, help="Tag this run with a label", default=None, multiple=True +) +@click_option_group.optgroup.option( + "--folder", + type=SimvueFolder, + help="Specify folder path for this run", + default="/", + show_default=True, +) +@click_option_group.optgroup.option( + "--retention", + type=int, + help="Specify retention period", + default=None, +) +@click.pass_context +@click.option( + "--delimiter", + "-d", + help="File row delimiter", + default=None, + show_default=True, + type=str, +) +@click.option( + "--environment", help="Include environment in metadata", is_flag=True, default=False +) +def monitor(ctx, tag: tuple[str, ...] | None, delimiter: str, **run_params) -> None: + """Monitor stdin for delimited lines sending as metrics""" + metric_labels: list[str] = [] + run_params |= {"tags": list(tag) if tag else None} + + run: Run | None = simvue_cli.actions.create_simvue_run( + timeout=None, running=True, **run_params + ) + + if not run: + raise click.Abort("Failed to create run") + + try: + for i, line in enumerate(sys.stdin): + line = [el for element in line.split(delimiter) if (el := element.strip())] + if i == 0: + metric_labels = line + continue + try: + simvue_cli.actions.log_metrics( + run.id, dict(zip(metric_labels, [float(i) for i in line])) + ) + except (RuntimeError, ValueError) as e: + if ctx.obj["plain"]: + click.echo(e) + else: + click.secho(e, fg="red", bold=True) + sys.exit(1) + click.echo(run.id) + except KeyboardInterrupt as e: + simvue_cli.actions.set_run_status(run.id, "terminated") + raise click.Abort from e + simvue_cli.actions.set_run_status(run.id, "completed") diff --git a/src/simvue_cli/cli/push.py b/src/simvue_cli/cli/push.py new file mode 100644 index 0000000..e529d92 --- /dev/null +++ b/src/simvue_cli/cli/push.py @@ -0,0 +1,124 @@ +"""Commands for Pushing Data to a Server.""" + +import click +import pathlib + +import simvue_cli.actions + +from simvue_cli.validation import JSONType + + +@click.group("push") +@click.pass_context +def push(_) -> None: + """Push local data to the Simvue server.""" + + +@push.command("runs") +@click.pass_context +@click.argument( + "input_file", + type=click.Path( + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + allow_dash=False, + resolve_path=True, + path_type=pathlib.Path, + ), +) +@click.option("--name", default=None, help="Name to set to all runs.") +@click.option("--folder", default=None, help="Simvue folder to add runs to.") +@click.option( + "--tenant", + "tenant_visible", + is_flag=True, + default=False, + help="Share with tenant.", +) +@click.option( + "--public", + "public_visible", + is_flag=True, + default=False, + help="Share with public.", +) +@click.option( + "--user", "user_list", multiple=True, help="Share with user.", default=None +) +@click.option( + "--metadata", + "global_metadata", + type=JSONType, + help="Metadata to append to all runs in the form of a JSON string.", +) +@click.option( + "--from-metadata", + is_flag=True, + help="Create runs from a list of metadata only.", +) +def push_runs( + ctx, + input_file: pathlib.Path, + from_metadata: bool, + tenant_visible: bool, + public_visible: bool, + user_list: list[str], + **kwargs, +) -> None: + """Push sets of runs to the Simvue server. + + The default is to create runs from a JSON definition containing a list of run specifications. + + If the option `--from-metadata` runs are created from metadata only having no metrics information. + These runs are taken either from JSON or CSV as sets of metadata. + + Only one visibility option from `--tenant`, `--public` or `--user`, may be specified. + """ + _plain_text = ctx.obj["plain"] + + if sum([int(i or 0) for i in (user_list, public_visible, tenant_visible)]) > 1: + raise click.UsageError("Cannot specify above one visibility option.") + + if from_metadata: + if input_file.suffix == ".csv": + _folder_id = simvue_cli.actions.push_delim_metadata( + input_file, + delimiter=",", + **kwargs, + public_visible=public_visible, + tenant_visible=tenant_visible, + user_list=user_list, + ) + elif input_file.suffix == ".json": + _folder_id = simvue_cli.actions.push_json_metadata( + input_file, + public_visible=public_visible, + tenant_visible=tenant_visible, + user_list=user_list, + **kwargs, + ) + else: + _out_msg: str = f"Unsupported file type '{input_file.suffix}'" + if not _plain_text: + _out_msg = click.style(_out_msg, fg="red", bold=True) + click.echo(_out_msg) + raise click.Abort + click.echo(_folder_id) + return + if input_file.suffix == ".json": + _folder_ids = simvue_cli.actions.push_json_runs( + input_file, + public_visible=public_visible, + tenant_visible=tenant_visible, + user_list=user_list, + **kwargs, + ) + else: + _out_msg: str = f"Unsupported file type '{input_file.suffix}'" + if not _plain_text: + _out_msg = click.style(_out_msg, fg="red", bold=True) + click.echo(_out_msg) + raise click.Abort + click.echo("\n".join(_folder_ids)) diff --git a/src/simvue_cli/cli/run.py b/src/simvue_cli/cli/run.py new file mode 100644 index 0000000..7e45ae8 --- /dev/null +++ b/src/simvue_cli/cli/run.py @@ -0,0 +1,506 @@ +"""Simvue Run Commands.""" + +import click +import click_option_group +import tabulate +import pathlib +import sys +import json +import re + +from simvue_cli.cli.display import create_objects_display +from simvue_cli.validation import SimvueName, SimvueFolder, JSONType +from simvue.exception import ObjectNotFoundError +import simvue_cli.actions + +from simvue.api.objects import Run + + +@click.group("run") +@click.pass_context +def simvue_run(_) -> None: + """Create or retrieve Simvue runs""" + pass + + +@simvue_run.command("create") +@click.pass_context +@click.option( + "--create-only", help="Create run but do not start it", is_flag=True, default=False +) +@click_option_group.optgroup.group( + "Run attributes", + help="Assign properties such as metadata and labelling to this run", +) +@click_option_group.optgroup.option( + "--name", type=SimvueName, help="Name to assign to this run", default=None +) +@click_option_group.optgroup.option( + "--description", type=str, help="Short run description", default=None +) +@click_option_group.optgroup.option( + "--tag", type=str, help="Tag this run with a label", default=None, multiple=True +) +@click_option_group.optgroup.option( + "--folder", + type=SimvueFolder, + help="Specify folder path for this run", + default="/", + show_default=True, +) +@click_option_group.optgroup.option( + "--retention", + type=int, + help="Specify retention period", + default=None, +) +@click_option_group.optgroup.option( + "--environment", is_flag=True, default=False, help="Include environment metadata" +) +def create_run( + ctx, create_only: bool, tag: tuple[str, ...] | None, **run_params +) -> None: + """Initialise a new Simvue run""" + run_params |= {"running": not create_only, "tags": list(tag) if tag else None} + run: Run = simvue_cli.actions.create_simvue_run(**run_params) + + click.echo(run.id if ctx.obj["plain"] else click.style(run.id)) + + +@simvue_run.command("remove") +@click.pass_context +@click.argument("run_ids", type=str, nargs=-1, required=False) +@click.option( + "-i", + "--interactive", + help="Prompt for confirmation on removal", + type=bool, + default=False, + is_flag=True, +) +def delete_run(ctx, run_ids: list[str] | None, interactive: bool) -> None: + """Remove runs from the Simvue server""" + if not run_ids: + run_ids = [] + for line in sys.stdin: + if not line.strip(): + continue + run_ids += [k.strip() for k in line.split(" ")] + + for run_id in run_ids: + try: + simvue_cli.actions.get_run(run_id) + except (ObjectNotFoundError, RuntimeError): + error_msg = f"Run '{run_id}' not found" + if ctx.obj["plain"]: + click.echo(error_msg) + else: + click.secho(error_msg, fg="red", bold=True) + sys.exit(1) + + if interactive: + remove = click.confirm(f"Remove run '{run_id}'?") + if not remove: + continue + + try: + simvue_cli.actions.delete_run(run_id) + except ValueError as e: + click.echo( + e.args[0] + if ctx.obj["plain"] + else click.style(e.args[0], fg="red", bold=True) + ) + sys.exit(1) + + response_message = f"Run '{run_id}' removed successfully." + + if ctx.obj["plain"]: + click.echo(response_message) + else: + click.secho(response_message, bold=True, fg="green") + + +@simvue_run.command("close") +@click.pass_context +@click.argument("run_id", type=str) +def close_run(ctx, run_id: str) -> None: + """Mark an active run as completed""" + if not (simvue_cli.actions.get_run(run_id)): + error_msg = f"Run '{run_id}' not found" + if ctx.obj["plain"]: + click.echo(error_msg) + else: + click.secho(error_msg, fg="red", bold=True) + sys.exit(1) + try: + simvue_cli.actions.set_run_status(run_id, "completed") + except ValueError as e: + click.echo( + e.args[0] + if ctx.obj["plain"] + else click.style(e.args[0], fg="red", bold=True) + ) + sys.exit(1) + + +@simvue_run.command("abort") +@click.pass_context +@click.argument("run_id", type=str) +@click.option( + "--reason", + type=str, + help="Reason for abort", + default="Manual termination via CLI", + show_default=True, +) +def abort_run(ctx, run_id: str, reason: str) -> None: + """Abort an active run""" + if not (simvue_cli.actions.get_run(run_id)): + error_msg = f"Run '{run_id}' not found" + if ctx.obj["plain"]: + click.echo(error_msg) + else: + click.secho(error_msg, fg="red", bold=True) + sys.exit(1) + simvue_cli.actions.set_run_status(run_id, "terminated", reason=reason) + + +@simvue_run.command("log.metrics") +@click.argument("run_id", type=str) +@click.argument("metrics", type=JSONType) +def log_metrics(run_id: str, metrics: dict) -> None: + """Log metrics to Simvue server""" + simvue_cli.actions.log_metrics(run_id, metrics) + + +@simvue_run.command("log.event") +@click.argument("run_id", type=str) +@click.argument("event_message", type=str) +def log_event(run_id: str, event_message: str) -> None: + """Log event to Simvue server""" + simvue_cli.actions.log_event(run_id, event_message) + + +@simvue_run.command("metadata") +@click.argument("run_id", type=str) +@click.argument("metadata", type=JSONType) +def update_metadata(run_id: str, metadata: dict) -> None: + """Update metadata for a run on the Simvue server""" + simvue_cli.actions.update_metadata(run_id, metadata) + + +@simvue_run.command("list", context_settings={"ignore_unknown_options": True}) +@click.pass_context +@click.option( + "--format", + "table_format", + type=click.Choice(list(tabulate._table_formats.keys())), + help="Display as table with output format", + default=None, +) +@click.option( + "--enumerate", + "enumerate_", + is_flag=True, + help="Show counter next to runs", + default=False, + show_default=True, +) +@click.option( + "--count", + type=int, + help="Maximum number of runs to retrieve", + default=20, + show_default=True, +) +@click.option("-T", "--tags", is_flag=True, help="Show tags") +@click.option("-n", "--name", is_flag=True, help="Show names") +@click.option("-u", "--user", is_flag=True, help="Show users") +@click.option("-t", "--created", is_flag=True, help="Show created timestamp") +@click.option("-d", "--description", is_flag=True, help="Show description") +@click.option("-s", "--status", is_flag=True, help="Show status") +@click.option("-m", "--metadata", multiple=True, help="Show metadata value") +@click.option("-f", "--folder", is_flag=True, help="Show folder") +@click.option( + "-F", + "--filter", + "filters", + multiple=True, + help=""" +Apply filters when searching runs. + +Accepts filters in the form of , with multiple instances +of this option being allowed. The comparators allowed vary depending on the column being +filtered by: + +> Greater than + +< Less than + +>= Greater than or equal to + +<= Less than or equal to + += or == Equal to (no value implies general 'exists') + +!= Not equal to (no value implies general 'does not exist') + +~ Contains + +!~ Does not contain + +Examples + + --filter folder=/unit_tests + + --filter 'metadata.custom_meta>10' + + --filter starred + + --filter name~test +""", +) +@click.option( + "--sort-by", + help="Specify columns to sort by", + multiple=True, + default=["created"], + type=click.Choice(["created", "started", "endtime", "modified", "name"]), + show_default=True, +) +@click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) +@click.option("--shared", help="Include shared runs", default=False, is_flag=True) +@click.option("--starred", help="Filter to favorited runs", default=False, is_flag=True) +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +def list_runs( + ctx, + table_format: str, + tags: bool, + description: bool, + user: bool, + created: bool, + enumerate_: bool, + name: bool, + folder: bool, + status: bool, + args: str, + shared: bool, + starred: bool, + **kwargs, +) -> None: + """Retrieve runs list from Simvue server""" + _metadata = [ + arg.replace("--", "") for arg in args if re.findall("^--metadata", arg) + ] + + # To avoid ambiguity only allow shared to activated by command line argument + kwargs["filters"] = [ + filter for filter in kwargs["filters"] if not filter.startswith("user") + ] + + if not shared: + kwargs["filters"].append("user == self") + + if starred: + kwargs["filters"].append("starred") + + if _metadata: + kwargs["metadata"] = True + runs = simvue_cli.actions.get_runs_list(**kwargs) + columns = ["id"] + _metadata + + if created: + columns.append("created") + if name: + columns.append("name") + if folder: + columns.append("folder") + if tags: + columns.append("tags") + if user: + columns.append("user") + if description: + columns.append("description") + if status: + columns.append("status") + + table = create_objects_display( + columns, + runs, + plain_text=ctx.obj["plain"], + enumerate_=enumerate_, + format=table_format, + ) + click.echo(table) + + +@simvue_run.command("json") +@click.pass_context +@click.argument("run_id", required=False) +def get_run_json(ctx, run_id: str) -> None: + """Retrieve Run information from Simvue server + + If no RUN_ID is provided the input is read from stdin + """ + if not run_id: + run_id = input() + + try: + run: Run = simvue_cli.actions.get_run(run_id) + run_info = run.to_dict() + click.echo(json.dumps(dict(run_info.items()), indent=2)) + except ObjectNotFoundError as e: + error_msg = f"Failed to retrieve run '{run_id}': {e.args[0]}" + if not ctx.obj["plain"]: + error_msg = click.style(error_msg, fg="red", bold=True) + click.echo(error_msg) + sys.exit(1) + + +@simvue_run.command("artifacts") +@click.pass_context +@click.option( + "--format", + "table_format", + type=click.Choice(list(tabulate._table_formats.keys())), + help="Display as table with output format", + default=None, +) +@click.option( + "--enumerate", + "enumerate_", + is_flag=True, + help="Show counter next to runs", + default=False, + show_default=True, +) +@click.option( + "--count", + type=int, + help="Maximum number of runs to retrieve", + default=20, + show_default=True, +) +@click.option( + "--original-path", + is_flag=True, + help="Show original path of artifact", + default=False, +) +@click.option( + "--storage", is_flag=True, help="Show storage ID of artifact", default=False +) +@click.option( + "--mime-type", is_flag=True, help="Show MIME type of artifact", default=False +) +@click.option("--created", is_flag=True, help="Show created timestamp") +@click.option("--user", is_flag=True, help="Show artifact user UUID") +@click.option("--download-url", is_flag=True, help="Show artifact download URL") +@click.option("--uploaded", is_flag=True, help="Show artifact upload status") +@click.option("--checksum", is_flag=True, help="Show artifact checksum") +@click.option("--name", is_flag=True, help="Show artifact name") +@click.option("--size", is_flag=True, help="Show artifact size") +@click.argument("run_id", required=False) +def get_run_artifacts( + ctx, + run_id: str, + table_format: str, + enumerate_: bool, + original_path: bool, + storage: bool, + mime_type: bool, + created: bool, + user: bool, + download_url: bool, + uploaded: bool, + name: bool, + size: bool, + **_, +) -> None: + """Retrieve the artifacts for a given Run from the Simvue server + + If no RUN_ID is provided the input is read from stdin + """ + if not run_id: + run_id = input() + + try: + if not (artifacts := list(simvue_cli.actions.get_run_artifacts(run_id))): + raise SystemExit + except SystemExit: + sys.exit(1) + except (ObjectNotFoundError, RuntimeError) as e: + _error_msg = f"Failed to retrieve run '{run_id}': {e.args[0]}" + if not ctx.obj["plain"]: + _error_msg = click.style(_error_msg, fg="red", bold=True) + click.echo(_error_msg) + sys.exit(1) + + columns = ["id"] + + if created: + columns.append("created") + if name: + columns.append("name") + if size: + columns.append("size") + if original_path: + columns.append("original_path") + if storage: + columns.append("storage") + if uploaded: + columns.append("uploaded") + if mime_type: + columns.append("mime_type") + if user: + columns.append("user") + if download_url: + columns.append("download_url") + + table = create_objects_display( + columns, + artifacts, + plain_text=ctx.obj["plain"], + enumerate_=enumerate_, + format=table_format, + ) + click.echo(table) + + +@simvue_run.command("pull") +@click.pass_context +@click.option( + "-o", + "--output-dir", + help="Output directory.", + default=f"{pathlib.Path.cwd().joinpath('{run_id}')}", + show_default=True, +) +@click.argument("run_id", required=False) +def pull_simvue_run(ctx, output_dir: str, run_id: str) -> None: + """Retrieve artifacts for the given Simvue run. + + Downloads the artifacts to the specified directory.""" + if not run_id: + run_id = input() + + try: + _downloaded_files: list[pathlib.Path] = simvue_cli.actions.pull_run( + run_id=run_id, + output_dir=pathlib.Path(output_dir.format(run_id=run_id)), + plain=ctx.obj["plain"], + ) + if not _downloaded_files: + click.echo("No artifacts found.") + return + _disp_str = "\n".join(f"{file}" for file in _downloaded_files) + click.echo(_disp_str if ctx.obj["plain"] else click.style(_disp_str, bold=True)) + except RuntimeError as e: + _disp_str = f"Failed to download run '{run_id}': {e.args[0]}" + click.echo( + _disp_str + if ctx.obj["plain"] + else click.style(_disp_str, fg="red", bold=True) + ) + sys.exit(1) diff --git a/src/simvue_cli/cli/storage.py b/src/simvue_cli/cli/storage.py new file mode 100644 index 0000000..7fc3b83 --- /dev/null +++ b/src/simvue_cli/cli/storage.py @@ -0,0 +1,225 @@ +"""Simvue Storage Commands.""" + +import click +import json +import sys +from simvue.api.objects import S3Storage +import tabulate +import simvue_cli.actions + +from simvue_cli.cli.display import create_objects_display +from simvue.api.objects.storage.base import StorageBase +from simvue.exception import ObjectNotFoundError + + +@click.group("storage") +@click.pass_context +def simvue_storage(_): + """View and manage Simvue storages""" + pass + + +@simvue_storage.group("add") +@click.pass_context +def simvue_storage_add(_) -> None: + """Add a new Simvue storage instance to the server.""" + pass + + +@simvue_storage_add.command("s3") +@click.argument("name") +@click.option( + "--disable-check", + is_flag=True, + default=False, + help="Disable checking of storage system.", + show_default=True, +) +@click.option( + "--region-name", + help="Name of the region associated with this storage.", + required=True, +) +@click.option( + "--endpoint-url", help="Endpoint defining the S3 upload URL", required=True +) +@click.option("--access-key-id", help="Access key identifier.", required=True) +@click.option( + "--access-key-file", + help="File containing secret access key", + required=True, + type=click.File(), +) +@click.option( + "--bucket", help="The bucket associated with this storage.", required=True +) +@click.option( + "--block-tenant", + is_flag=True, + default=False, + help="Disable access by current Tenant.", + show_default=True, +) +@click.option( + "--default", + is_flag=True, + default=False, + help="Set this storage to be the default.", + show_default=True, +) +@click.option( + "--disable", + is_flag=True, + default=False, + help="Disable this storage on creation.", + show_default=True, +) +@click.pass_context +def add_s3_storage(ctx, **kwargs) -> None: + storage: S3Storage = simvue_cli.actions.create_simvue_s3_storage(**kwargs) + click.echo(storage.id if ctx.obj["plain"] else click.style(storage.id)) + + +@simvue_storage.command("json") +@click.pass_context +@click.argument("storage_id", required=False) +def get_storage_json(ctx, storage_id: str) -> None: + """Retrieve storage information from Simvue server + + If no storage_ID is provided the input is read from stdin + """ + if not storage_id: + storage_id = input() + + try: + storage: StorageBase = simvue_cli.actions.get_storage(storage_id) + storage_info = storage.to_dict() + click.echo(json.dumps(dict(storage_info.items()), indent=2)) + except ObjectNotFoundError as e: + error_msg = f"Failed to retrieve storage '{storage_id}': {e.args[0]}" + if not ctx.obj["plain"]: + error_msg = click.style(error_msg, fg="red", bold=True) + click.echo(error_msg) + + +@simvue_storage.command("remove") +@click.pass_context +@click.argument("storage_ids", type=str, nargs=-1, required=False) +@click.option( + "-i", + "--interactive", + help="Prompt for confirmation on removal", + type=bool, + default=False, + is_flag=True, +) +def delete_storage(ctx, storage_ids: list[str] | None, interactive: bool) -> None: + """Remove a storage from the Simvue server""" + if not storage_ids: + storage_ids = [] + for line in sys.stdin: + if not line.strip(): + continue + storage_ids += [k.strip() for k in line.split(" ")] + + for storage_id in storage_ids: + try: + _ = simvue_cli.actions.get_storage(storage_id) + except (ObjectNotFoundError, RuntimeError): + error_msg = f"storage '{storage_id}' not found" + if ctx.obj["plain"]: + click.echo(error_msg) + else: + click.secho(error_msg, fg="red", bold=True) + sys.exit(1) + + if interactive: + remove = click.confirm(f"Remove storage '{storage_id}'?") + if not remove: + continue + + try: + simvue_cli.actions.delete_storage(storage_id) + except ValueError as e: + click.echo( + e.args[0] + if ctx.obj["plain"] + else click.style(e.args[0], fg="red", bold=True) + ) + sys.exit(1) + + response_message = f"storage '{storage_id}' removed successfully." + + if ctx.obj["plain"]: + click.echo(response_message) + else: + click.secho(response_message, bold=True, fg="green") + + +@simvue_storage.command("list") +@click.pass_context +@click.option( + "--format", + "table_format", + type=click.Choice(list(tabulate._table_formats.keys())), + help="Display as table with output format", + default=None, +) +@click.option( + "--enumerate", + "enumerate_", + is_flag=True, + help="Show counter next to storages", + default=False, + show_default=True, +) +@click.option( + "--count", + type=int, + help="Maximum number of storages to retrieve", + default=20, + show_default=True, +) +@click.option("--name", is_flag=True, help="Show names") +@click.option("--backend", is_flag=True, help="Show backend") +@click.option("--created", is_flag=True, help="Show created timestamp") +@click.option("--default", is_flag=True, help="Show if default storage") +@click.option("--tenant-usable", is_flag=True, help="Show if usable by current tenant") +@click.option("--enabled", is_flag=True, help="Show if storage is enabled") +def list_storages( + ctx, + table_format: str, + backend: bool, + tenant_usable: bool, + default: bool, + enabled: bool, + created: bool, + enumerate_: bool, + name: bool, + **kwargs, +) -> None: + """Retrieve storages list from Simvue server""" + storages = simvue_cli.actions.get_storages_list(**kwargs) + columns = ["id"] + + if created: + columns.append("created") + if name: + columns.append("name") + if backend: + columns.append("backend") + if tenant_usable: + columns.append("is_tenant_useable") + if default: + columns.append("is_default") + if enabled: + columns.append("is_enabled") + + table = create_objects_display( + columns, + storages, + plain_text=ctx.obj["plain"], + enumerate_=enumerate_, + format=table_format, + ) + click.echo(table) diff --git a/src/simvue_cli/cli/tag.py b/src/simvue_cli/cli/tag.py new file mode 100644 index 0000000..2031ce6 --- /dev/null +++ b/src/simvue_cli/cli/tag.py @@ -0,0 +1,188 @@ +"""Simvue Tag Commands.""" + +import click +import tabulate +import json +import sys + +import simvue_cli.actions + +from simvue_cli.cli.display import create_objects_display +from simvue_cli.validation import SimvueName +from simvue.api.objects import Tag +from simvue.exception import ObjectNotFoundError + + +@click.group("tag") +@click.pass_context +def simvue_tag(_) -> None: + """Create or retrieve Simvue tags""" + pass + + +@simvue_tag.command("create") +@click.pass_context +@click.argument("name", type=SimvueName) +@click.option( + "--color", + type=str, + default=None, + help="Color for this tag, e.g. '#fffff', 'blue', 'rgb(23, 54, 34)'", +) +@click.option("--description", type=str, default=None, help="Description for this tag.") +def create_tag(ctx, **kwargs) -> None: + """Create a tag""" + result = simvue_cli.actions.create_simvue_tag(**kwargs) + alert_id = result.id + click.echo(alert_id if ctx.obj["plain"] else click.style(alert_id)) + + +@simvue_tag.command("list") +@click.pass_context +@click.option( + "--format", + "table_format", + type=click.Choice(list(tabulate._table_formats.keys())), + help="Display as table with output format", + default=None, +) +@click.option( + "--enumerate", + "enumerate_", + is_flag=True, + help="Show counter next to runs", + default=False, + show_default=True, +) +@click.option("--created", is_flag=True, help="Show created timestamp") +@click.option( + "--count", + type=int, + help="Maximum number of runs to retrieve", + default=20, + show_default=True, +) +@click.option("--name", is_flag=True, help="Show names") +@click.option("--description", is_flag=True, help="Show descriptions") +@click.option("--color", is_flag=True, help="Show hex colors") +@click.option( + "--sort-by", + help="Specify columns to sort by", + multiple=True, + default=["created"], + type=click.Choice(["created", "name"]), + show_default=True, +) +@click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) +def tag_list( + ctx, + enumerate_: bool, + created: bool, + table_format: str | None, + name: bool, + description: bool, + color: bool, + **kwargs, +) -> None: + """Retrieve tags list from Simvue server.""" + tags = simvue_cli.actions.get_tag_list(**kwargs) + if not tags: + return + columns = ["id"] + + if created: + columns.append("created") + + if name: + columns.append("name") + + if color: + columns.append("colour") + + if description: + columns.append("description") + + table = create_objects_display( + columns, + tags, + plain_text=ctx.obj["plain"], + enumerate_=enumerate_, + format=table_format, + ) + click.echo(table) + + +@simvue_tag.command("json") +@click.argument("tag_id", required=False) +@click.pass_context +def get_tag_json(ctx, tag_id: str) -> None: + """Retrieve tag information from Simvue server + + If no tag_ID is provided the input is read from stdin + """ + if not tag_id: + tag_id = input() + + try: + tag: Tag = simvue_cli.actions.get_tag(tag_id) + tag_info = tag.to_dict() + click.echo(json.dumps(dict(tag_info.items()), indent=2)) + except ObjectNotFoundError as e: + error_msg = f"Failed to retrieve tag '{tag_id}': {e.args[0]}" + if not ctx.obj["plain"]: + error_msg = click.style(error_msg, fg="red", bold=True) + click.echo(error_msg) + + +@simvue_tag.command("remove") +@click.pass_context +@click.argument("tag_ids", type=str, nargs=-1, required=False) +@click.option( + "-i", + "--interactive", + help="Prompt for confirmation on removal", + type=bool, + default=False, + is_flag=True, +) +def delete_tag(ctx, tag_ids: list[str] | None, interactive: bool) -> None: + """Remove a tag from the Simvue server""" + if not tag_ids: + tag_ids = [] + for line in sys.stdin: + if not line.strip(): + continue + tag_ids += [k.strip() for k in line.split(" ")] + + for tag_id in tag_ids: + try: + _ = simvue_cli.actions.get_tag(tag_id) + except (ObjectNotFoundError, RuntimeError): + error_msg = f"Tag '{tag_id}' not found" + if ctx.obj["plain"]: + click.echo(error_msg) + else: + click.secho(error_msg, fg="red", bold=True) + sys.exit(1) + + if interactive: + remove = click.confirm(f"Remove tag '{tag_id}'?") + if not remove: + continue + + try: + simvue_cli.actions.delete_tag(tag_id) + except ValueError as e: + click.echo( + e.args[0] + if ctx.obj["plain"] + else click.style(e.args[0], fg="red", bold=True) + ) + sys.exit(1) + + response_message = f"Tag '{tag_id}' removed successfully." + + if ctx.obj["plain"]: + click.echo(response_message) + else: + click.secho(response_message, bold=True, fg="green") diff --git a/src/simvue_cli/cli/utilities.py b/src/simvue_cli/cli/utilities.py new file mode 100644 index 0000000..dc0ef71 --- /dev/null +++ b/src/simvue_cli/cli/utilities.py @@ -0,0 +1,135 @@ +"""Miscellaneous click.commands.""" + +import click +import contextlib +import simvue.client as simvue_client +import simvue_cli.server +import requests +import time +import datetime +import simvue_cli.actions +import shutil +import importlib +import tabulate + +from simvue_cli.cli.display import SIMVUE_LOGO + + +@click.command("ping") +@click.option( + "-t", + "--timeout", + help="Timeout the command after n seconds", + default=None, + type=int, +) +def ping_server(timeout: int | None) -> None: + """Ping the Simvue server""" + successful_pings: int = 0 + with contextlib.suppress(KeyboardInterrupt): + url = simvue_client.Client()._user_config.server.url + ip_address = simvue_cli.server.get_ip_of_url(url) + counter: int = 0 + while True: + if timeout and counter > timeout: + return + start_time = time.time() + try: + server_version: int | str = simvue_cli.actions.get_server_version() + if ( + status_code := 200 + if isinstance(server_version, str) + else server_version + ) != 200: + raise RuntimeError + successful_pings += 1 + end_time = time.time() # Record the end time + elapsed_time = (end_time - start_time) * 1000 # Convert to milliseconds + click.secho( + f"Reply from {url} ({ip_address}): status_code={status_code}, time={elapsed_time:.2f}ms" + ) + except (requests.ConnectionError, requests.Timeout, RuntimeError): + click.secho( + f"Reply from {url} ({ip_address}): status_code={status_code}, error" + ) + + time.sleep(1) + counter += 1 + + +@click.command("whoami") +@click.option("-u", "--user", help="click.echo only the user name", default=False) +@click.option("-t", "--tenant", help="click.echo only the tenant", default=False) +def whoami(user: bool, tenant: bool) -> None: + """Retrieve current user information""" + if user and tenant: + click.secho("cannot click.echo 'only' with more than one choice") + raise click.Abort + user_info = simvue_cli.actions.user_info() + user_name = user_info.get("user") + tenant_info = user_info.get("tenant") + if user: + click.secho(user_name) + elif tenant: + click.secho(tenant_info) + else: + click.secho(f"{user_name}({tenant_info})") + + +@click.command("about") +@click.pass_context +def about_simvue(ctx) -> None: + """Display full information on Simvue instance""" + width = shutil.get_terminal_size().columns + if not ctx.obj.get("plain"): + click.echo( + "\n".join( + "\t" * int(0.015 * width) + f"{r}" for r in SIMVUE_LOGO.split("\n") + ) + ) + click.echo(f"\n{width * '='}\n") + click.echo( + "\n" + "\t" * int(0.04 * width) + "Provided under the Apache-2.0 License" + ) + click.echo( + "\t" * int(0.04 * width) + + f"© Copyright {datetime.datetime.now().strftime('%Y')} Simvue Development Team\n" + ) + out_table: list[list[str]] = [] + with contextlib.suppress(importlib.metadata.PackageNotFoundError): + out_table.append( + ["CLI Version: ", importlib.metadata.version(simvue_cli.__name__)] + ) + with contextlib.suppress(importlib.metadata.PackageNotFoundError): + out_table.append( + ["Python API Version: ", importlib.metadata.version(simvue_client.__name__)] + ) + # with contextlib.suppress(Exception): + server_version: int | str = simvue_cli.actions.get_server_version() + if isinstance(server_version, int): + raise RuntimeError + out_table.append(["Server Version: ", server_version]) + if not ctx.obj.get("plain"): + click.echo( + "\n".join( + "\t" * int(0.045 * width) + f"{r}" + for r in tabulate.tabulate(out_table, tablefmt="plain") + .__str__() + .split("\n") + ) + ) + click.echo(f"\n{width * '='}\n") + else: + click.echo(tabulate.tabulate(out_table, tablefmt="plain").__str__()) + + +@click.command("purge") +@click.pass_context +def purge_simvue(_) -> None: + """Remove all local Simvue files in user home area.""" + + click.echo( + "Simvue user files deleted successfully." + if simvue_cli.actions.purge_local_simvue_files() + else "Nothing to do." + ) diff --git a/src/simvue_cli/cli/venv.py b/src/simvue_cli/cli/venv.py new file mode 100644 index 0000000..ec39943 --- /dev/null +++ b/src/simvue_cli/cli/venv.py @@ -0,0 +1,42 @@ +"""Simvue Virtual Environment Commands.""" + +import click +import sys + +import simvue_cli.actions + + +@click.command("venv") +@click.pass_context +@click.option( + "--language", + required=True, + help="Specify target language", + type=click.Choice(["python", "rust", "julia", "nodejs"]), +) +@click.option( + "--run", required=False, help="ID of run to clone environment from", default="" +) +@click.option( + "--allow-existing", + is_flag=True, + help="Install dependencies in an existing environment", +) +@click.argument("venv_directory", type=click.Path(exists=False)) +def venv_setup(ctx, **kwargs) -> None: + """Initialise virtual environments from run metadata. + + If a run ID is not provided via --run it is read from stdin. + """ + if not kwargs.get("run"): + kwargs["run"] = input() + + try: + simvue_cli.actions.create_environment(**kwargs) + except (FileExistsError, RuntimeError) as e: + error_msg = e.args[0] + if ctx.obj["plain"]: + click.echo(error_msg) + else: + click.secho(error_msg, fg="red", bold=True) + sys.exit(1) From 3ee064f0113b3f9385bc84a7ee841f23d8430588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Zar=C4=99bski?= Date: Thu, 26 Mar 2026 13:13:40 +0000 Subject: [PATCH 2/8] Fix show path on folder list --- pyproject.toml | 4 + src/simvue_cli/actions.py | 35 ++-- src/simvue_cli/cli/display.py | 12 +- src/simvue_cli/cli/folder.py | 76 ++++++-- src/simvue_cli/cli/run.py | 276 ++++++++++++++++++++------- src/simvue_cli/validation.py | 37 +++- tests/test_command_line_interface.py | 3 +- uv.lock | 39 +++- 8 files changed, 361 insertions(+), 121 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 434e86e..fad2608 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ dev = [ "pytest-cov>=6.0.0", "pytest-xdist>=3.8.0", "pytest>=8.3.5", + "simvue", "ty>=0.0.24", ] docs = ["renku-sphinx-theme>=0.5.0", "sphinx>=8.1.3"] @@ -81,3 +82,6 @@ lint = ["ruff>=0.11.2"] [tool.mypy] ignore_missing_imports = true + +[tool.uv.sources] +simvue = { path = "../python-api" } diff --git a/src/simvue_cli/actions.py b/src/simvue_cli/actions.py index e6300d9..33a6425 100644 --- a/src/simvue_cli/actions.py +++ b/src/simvue_cli/actions.py @@ -1,6 +1,4 @@ -""" -Simvue CLI Actions -================== +"""Simvue CLI Actions Contains callbacks for CLI commands """ @@ -13,24 +11,27 @@ import json import re import sys -from simvue.api.objects.alert.fetch import AlertType -from simvue.api.objects.storage.file import FileStorage import tqdm import typing import time -from simvue.exception import ObjectNotFoundError import toml import venv import shutil import subprocess import click + import simvue.api.request as sv_api import simvue.metadata as sv_meta from datetime import datetime, timezone from collections.abc import Generator + +from simvue.api.objects.alert.fetch import AlertType +from simvue.api.objects.storage.file import FileStorage +from simvue.exception import ObjectNotFoundError + from simvue.run import get_system from simvue.client import Client from simvue.models import DATETIME_FORMAT @@ -88,9 +89,11 @@ def _check_run_exists(run_id: str) -> tuple[pathlib.Path, Run]: if not run_shelf_file.exists(): out_data = {"step": 0, "start_time": time.time()} _metric_steps: list[int] = [ - metric.get("step", 0) for _, metric in run.metrics or [] + typing.cast("int", metric.get("step", 0)) for _, metric in run.metrics or [] + ] + _times: list[int] = [ + typing.cast("int", metric.get("time", 0)) for _, metric in run.metrics or [] ] - _times: list[int] = [metric.get("time", 0) for _, metric in run.metrics or []] if _metric_steps: out_data["step"] = max(_metric_steps) if _times: @@ -138,12 +141,12 @@ def create_simvue_run( """ if folder != "/": try: - _folder = Folder.new(path=folder) - _folder.commit() + _folder: Folder = Folder.new(path=folder) + _ = _folder.commit() except RuntimeError as e: if "status 409" not in e.args[0]: raise e - _run = Run.new(folder=folder) + _run: Run = Run.new(folder=folder) _run.tags = tags or [] _run.status = "running" if running else "created" @@ -357,15 +360,13 @@ def parse_filters(filters: list[str]) -> list[str]: def get_runs_list( - sort_by: list[str], reverse: bool, filters: list[str] | None = None, **kwargs + sort_by: list[str], reverse: bool, **kwargs ) -> Generator[tuple[str, Run]]: """Retrieve list of Simvue runs""" _sorting: list[dict[str, str]] = [ {"column": c, "descending": not reverse} for c in sort_by ] - - if filters: - kwargs["filters"] = json.dumps(parse_filters(filters)) + kwargs["filters"] = json.dumps(kwargs["filters"]) return Run.get(sorting=_sorting, **kwargs) @@ -402,12 +403,14 @@ def get_storages_list(**kwargs) -> typing.Generator[tuple[str, Storage], None, N def get_folders_list( - sort_by: list[str], reverse: bool, **kwargs + sort_by: list[str], reverse: bool, filters: list[str] | None = None, **kwargs ) -> typing.Generator[tuple[str, Run], None, None]: """Retrieve list of Simvue folders""" _sorting: list[dict[str, str]] = [ {"column": c, "descending": not reverse} for c in sort_by ] + if filters: + kwargs["filters"] = json.dumps(parse_filters(filters)) return Folder.get(sorting=_sorting, **kwargs) diff --git a/src/simvue_cli/cli/display.py b/src/simvue_cli/cli/display.py index ae2fae2..10f8bbc 100644 --- a/src/simvue_cli/cli/display.py +++ b/src/simvue_cli/cli/display.py @@ -178,9 +178,9 @@ def create_objects_display( # Remove 'is_' prefix from relevant columns and format table_headers = [ - c.replace("is_", "") + c.replace("is_", "").replace(".", " ").title() if plain_text - else click.style(c.replace("is_", ""), bold=True) + else click.style(c.replace("is_", "").replace(".", " ").title(), bold=True) for c in (("#", *columns) if enumerate_ else columns) ] @@ -202,14 +202,14 @@ def create_objects_display( getattr(obj, _keys[0]), delimiter=_flat_dict_delim ) try: - _metadata_key = column.replace(f"{_keys[0]}.", "").strip() - value = _elements[_metadata_key.replace(".", _flat_dict_delim)] + _subgroup_key = column.replace(f"{_keys[0]}.", "").strip() + value = _elements[_subgroup_key.replace(".", _flat_dict_delim)] if isinstance(value, flatdict.FlatDict): value = ", ".join(f"{k}=..." for k in value.keys()) + if not value: + value = "None" except KeyError: value = "N/A" - - # FIXME: Hack for if a property has not been added to the API yet elif not (value := getattr(obj, column, None)): try: value = obj._get_attribute(column) diff --git a/src/simvue_cli/cli/folder.py b/src/simvue_cli/cli/folder.py index 91d15ee..7563c10 100644 --- a/src/simvue_cli/cli/folder.py +++ b/src/simvue_cli/cli/folder.py @@ -1,11 +1,16 @@ """Simvue Folder Commands.""" +from typing import Literal import click import re import json import tabulate import sys +from click_option_group import optgroup + +from simvue_cli.validation import SimvueFolder, TimeInterval + from .display import create_objects_display, format_folder_tree import simvue_cli.actions @@ -46,11 +51,6 @@ def simvue_folder(_) -> None: default=20, show_default=True, ) -@click.option("--path", is_flag=True, help="Show path") -@click.option("--tags", is_flag=True, help="Show tags") -@click.option("--created", is_flag=True, help="Show created timestamp") -@click.option("--name", is_flag=True, help="Show names") -@click.option("--description", is_flag=True, help="Show description") @click.option( "--sort-by", help="Specify columns to sort by", @@ -60,33 +60,75 @@ def simvue_folder(_) -> None: show_default=True, ) @click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) +@click.option("-t", "--created", is_flag=True, help="Show created timestamp") +@optgroup.group( + name="Folder attribute filters", help="Specify columns to display and/or filter on." +) +@optgroup.option( + "-p", + "--path", + is_flag=False, + flag_value="show-only", + help="Show / Filter path. Passing of TEXT is optional.", + type=SimvueFolder, +) +@optgroup.option( + "-T", + "--tags", + is_flag=False, + flag_value="show-only", + help="Show / Filter tags. Passing of TEXT is optional. Tags in filter must be comma separated.", +) +@optgroup.option( + "--created-within", help='Filter by creation time, e.g. "10h".', type=TimeInterval +) +@optgroup.option( + "-d", + "--description", + is_flag=False, + flag_value="show-only", + help="Show / Filter description. Passing of TEXT is optional.", +) def folder_list( ctx, table_format: str, enumerate_: bool, - path: bool, - tags: bool, - name: bool, + path: Literal["show-only"] | str | None, + tags: Literal["show-only"] | str | None, created: bool, - description: bool, + created_within: int | None, + description: Literal["show-only"] | str | None, **kwargs, ) -> None: """Retrieve folders list from Simvue server""" - folders = simvue_cli.actions.get_folders_list(**kwargs) - if not folders: - return columns = ["id"] + _filter = Folder.filter() + if created: columns.append("created") - if path: + + if created_within: + _filter = _filter.created_within(hours=created_within) + + if path == "show-only": columns.append("path") - if name: - columns.append("name") + elif path: + _filter = _filter.has_path(path) + if tags: columns.append("tags") - if description: + + if description == "show-only": columns.append("description") + elif description: + _filter = _filter.has_description_containing(description) + + kwargs["filters"] = _filter.as_list() + + folders = simvue_cli.actions.get_folders_list(**kwargs) + if not folders: + return table = create_objects_display( columns, @@ -255,7 +297,7 @@ def display_folder_tree(folder_id: str | None, detail: bool) -> None: return else: try: - folder = simvue_cli.actions.get_folder(folder_id) + folder: Folder = simvue_cli.actions.get_folder(folder_id) except ObjectNotFoundError as e: error_msg = f"Failed to retrieve folder '{folder_id}': {e.args[0]}" click.secho(error_msg, fg="red", bold=True) diff --git a/src/simvue_cli/cli/run.py b/src/simvue_cli/cli/run.py index 7e45ae8..a630b52 100644 --- a/src/simvue_cli/cli/run.py +++ b/src/simvue_cli/cli/run.py @@ -8,13 +8,25 @@ import json import re +from click_option_group import optgroup +from typing import Literal, TYPE_CHECKING + from simvue_cli.cli.display import create_objects_display -from simvue_cli.validation import SimvueName, SimvueFolder, JSONType +from simvue_cli.validation import ( + SimvueName, + SimvueFolder, + JSONType, + TimeInterval, +) from simvue.exception import ObjectNotFoundError +from simvue.api.objects.filter import Status import simvue_cli.actions from simvue.api.objects import Run +if TYPE_CHECKING: + from simvue.api.objects.filter import RunsFilter + @click.group("run") @click.pass_context @@ -190,7 +202,7 @@ def update_metadata(run_id: str, metadata: dict) -> None: simvue_cli.actions.update_metadata(run_id, metadata) -@simvue_run.command("list", context_settings={"ignore_unknown_options": True}) +@simvue_run.command("list") @click.pass_context @click.option( "--format", @@ -209,58 +221,11 @@ def update_metadata(run_id: str, metadata: dict) -> None: ) @click.option( "--count", - type=int, + type=click.IntRange(min=1), help="Maximum number of runs to retrieve", default=20, show_default=True, ) -@click.option("-T", "--tags", is_flag=True, help="Show tags") -@click.option("-n", "--name", is_flag=True, help="Show names") -@click.option("-u", "--user", is_flag=True, help="Show users") -@click.option("-t", "--created", is_flag=True, help="Show created timestamp") -@click.option("-d", "--description", is_flag=True, help="Show description") -@click.option("-s", "--status", is_flag=True, help="Show status") -@click.option("-m", "--metadata", multiple=True, help="Show metadata value") -@click.option("-f", "--folder", is_flag=True, help="Show folder") -@click.option( - "-F", - "--filter", - "filters", - multiple=True, - help=""" -Apply filters when searching runs. - -Accepts filters in the form of , with multiple instances -of this option being allowed. The comparators allowed vary depending on the column being -filtered by: - -> Greater than - -< Less than - ->= Greater than or equal to - -<= Less than or equal to - -= or == Equal to (no value implies general 'exists') - -!= Not equal to (no value implies general 'does not exist') - -~ Contains - -!~ Does not contain - -Examples - - --filter folder=/unit_tests - - --filter 'metadata.custom_meta>10' - - --filter starred - - --filter name~test -""", -) @click.option( "--sort-by", help="Specify columns to sort by", @@ -272,18 +237,116 @@ def update_metadata(run_id: str, metadata: dict) -> None: @click.option("--reverse", help="Reverse ordering", default=False, is_flag=True) @click.option("--shared", help="Include shared runs", default=False, is_flag=True) @click.option("--starred", help="Filter to favorited runs", default=False, is_flag=True) +@click.option("-t", "--created", is_flag=True, help="Show the created time.") +@optgroup.group( + name="Run attribute filters", help="Specify columns to display and/or filter on." +) +@optgroup.option( + "-T", + "--tags", + is_flag=False, + flag_value="show-only", + help="Show / filter tags, passing of TEXT is optional. Tags in filter must be comma separated.", +) +@optgroup.option( + "-n", + "--name", + is_flag=False, + flag_value="show-only", + type=SimvueName, + help="Show / filter names", +) +@optgroup.option( + "-u", "--user", is_flag=True, flag_value="show-only", help="Show / filter users" +) +@optgroup.option( + "--created-within", help='Filter by creation time, e.g. "10h".', type=TimeInterval +) +@optgroup.option( + "--started-within", help='Filter by start time, e.g. "10h"', type=TimeInterval +) +@optgroup.option( + "--ended-within", help='Filter by end time, e.g. "10h"', type=TimeInterval +) +@optgroup.option( + "--modified-within", help="Filter by last modified time.", type=TimeInterval +) +@optgroup.option( + "-d", + "--description", + is_flag=False, + flag_value="show-only", + help="Show / filter description", +) +@optgroup.option( + "-s", + "--status", + is_flag=False, + flag_value="show-only", + help="Show / filter status, passing of TEXT is optional.", + type=Status, +) +@optgroup.option( + "-f", + "--folder", + is_flag=False, + flag_value="show-only", + help="Show / filter folder, passing of TEXT is optional.", + type=SimvueFolder, + show_default=True, +) +@optgroup.option("-m", "--metadata", multiple=True, help="Show metadata value") +@optgroup.option( + "--working-dir", + is_flag=False, + flag_value="show-only", + help="Show / filter working directory, passing of TEXT is optional.", +) +@optgroup.option( + "--gpu-name", + is_flag=False, + flag_value="show-only", + help="Show / filter GPU name, passing of TEXT is optional.", +) +@optgroup.option( + "--gpu-driver", + is_flag=False, + flag_value="show-only", + help="Show / filter GPU driver, passing of TEXT is optional.", +) +@optgroup.option( + "--cpu-arch", + is_flag=False, + flag_value="show-only", + help="Show / filter CPU architecture, passing of TEXT is optional.", +) +@optgroup.option( + "--cpu-processor", + is_flag=False, + flag_value="show-only", + help="Show / filter CPU processoritecture, passing of TEXT is optional.", +) @click.argument("args", nargs=-1, type=click.UNPROCESSED) def list_runs( ctx, table_format: str, - tags: bool, - description: bool, - user: bool, + tags: Literal["show-only"] | str | None, + description: Literal["show-only"] | str | None, + user: Literal["show-only"] | str | None, created: bool, enumerate_: bool, - name: bool, - folder: bool, - status: bool, + name: Literal["show-only"] | str | None, + folder: Literal["show-only"] | str | None, + status: Literal["show-only"] | Status | None, + gpu_name: Literal["show-only"] | str | None, + gpu_driver: Literal["show-only"] | str | None, + cpu_arch: Literal["show-only"] | str | None, + cpu_processor: Literal["show-only"] | str | None, + created_within: int | None, + started_within: int | None, + ended_within: int | None, + modified_within: int | None, + working_dir: Literal["show-only"] | str | None, args: str, shared: bool, starred: bool, @@ -295,35 +358,106 @@ def list_runs( ] # To avoid ambiguity only allow shared to activated by command line argument - kwargs["filters"] = [ - filter for filter in kwargs["filters"] if not filter.startswith("user") - ] - if not shared: - kwargs["filters"].append("user == self") + _filter: "RunsFilter" = Run.filter().owner() + else: + _filter = Run.filter().exclude_owner() if starred: - kwargs["filters"].append("starred") + _filter = _filter.starred() if _metadata: kwargs["metadata"] = True - runs = simvue_cli.actions.get_runs_list(**kwargs) + columns = ["id"] + _metadata if created: columns.append("created") - if name: + + if started_within: + _filter = _filter.started_within(hours=started_within) + + if modified_within: + _filter = _filter.modified_within(hours=modified_within) + + if created_within: + _filter = _filter.created_within(hours=created_within) + + if ended_within: + _filter = _filter.ended_within(hours=ended_within) + + if working_dir == "show-only": + kwargs["system_info"] = True + columns.append("system.cwd") + elif working_dir: + kwargs["system_info"] = True + _filter = _filter.has_working_directory(working_dir) + + if gpu_name == "show-only": + kwargs["system_info"] = True + columns.append("system.gpu.name") + elif gpu_name: + kwargs["system_info"] = True + _filter = _filter.has_gpu(name=gpu_name) + + if gpu_driver == "show-only": + kwargs["system_info"] = True + columns.append("system.gpu.driver") + elif gpu_driver: + kwargs["system_info"] = True + _filter = _filter.has_gpu(driver=gpu_driver) + + if cpu_arch == "show-only": + kwargs["system_info"] = True + columns.append("system.cpu.arch") + elif cpu_arch: + kwargs["system_info"] = True + _filter = _filter.has_cpu(architecture=cpu_arch) + + if cpu_processor == "show-only": + kwargs["system_info"] = True + columns.append("system.cpu.processor") + elif cpu_processor: + kwargs["system_info"] = True + _filter = _filter.has_cpu(processor=cpu_processor) + + if name == "show-only": columns.append("name") - if folder: + elif name: + _filter = _filter.has_name(name) + + if folder == "show-only": columns.append("folder") - if tags: + elif folder: + _filter = _filter.in_folder(folder) + + if tags == "show-only": columns.append("tags") - if user: + elif tags: + _tags: list[str] = tags.split(",") + for tag in _tags: + if not (_tag := tag.strip().rstrip()): + continue + _filter = _filter.has_tag(_tag) + + if user == "show-only": columns.append("user") - if description: + elif user: + _filter = _filter.owner(user) + + if description == "show-only": columns.append("description") - if status: + elif description: + _filter = _filter.has_description_containing(description) + + if status == "show-only": columns.append("status") + elif status: + _filter = _filter.has_status(status) + + kwargs["filters"] = _filter.as_list() + + runs = simvue_cli.actions.get_runs_list(**kwargs) table = create_objects_display( columns, @@ -399,7 +533,7 @@ def get_run_json(ctx, run_id: str) -> None: @click.option("--download-url", is_flag=True, help="Show artifact download URL") @click.option("--uploaded", is_flag=True, help="Show artifact upload status") @click.option("--checksum", is_flag=True, help="Show artifact checksum") -@click.option("--name", is_flag=True, help="Show artifact name") +@click.option("--name", is_flag=True, flag_value=None, help="Show artifact name") @click.option("--size", is_flag=True, help="Show artifact size") @click.argument("run_id", required=False) def get_run_artifacts( @@ -414,7 +548,7 @@ def get_run_artifacts( user: bool, download_url: bool, uploaded: bool, - name: bool, + name: str | None, size: bool, **_, ) -> None: diff --git a/src/simvue_cli/validation.py b/src/simvue_cli/validation.py index a7f4a14..a70b774 100644 --- a/src/simvue_cli/validation.py +++ b/src/simvue_cli/validation.py @@ -12,6 +12,7 @@ import json import re import regex +import humanfriendly import click @@ -19,13 +20,35 @@ from simvue.models import FOLDER_REGEX, NAME_REGEX +class TimeIntervalType(click.ParamType): + name: str = "time_interval" + + @typing.override + def convert( + self, value: typing.Any, param: Parameter | None, ctx: Context | None + ) -> typing.Any: + try: + return int(humanfriendly.parse_timespan(value) / 60 / 60) + except humanfriendly.InvalidTimespan: + self.fail(f"Failed to parse time interval '{value}'.") + except Exception as e: + self.fail(f"{e}") + + class PatternMatch(click.ParamType): name: str = "text" - def __init__(self, regex: typing.Pattern[str]) -> None: + def __init__(self, regex: re.Pattern[str]) -> None: self._pattern = re.compile(regex) - def convert(self, value: str, param: Parameter | None, ctx: Context | None) -> str: + def convert( + self, + value: typing.Literal["show-only"] | str, + param: Parameter | None, + ctx: Context | None, + ) -> str: + if value == "show-only": + return value if not self._pattern.match(value): self.fail( f"'{value}' did not match regular expression '{self._pattern.pattern}'" @@ -49,7 +72,14 @@ def convert(self, value: str, param: Parameter | None, ctx: Context | None) -> d class FullNameType(click.ParamType): name: str = "text" - def convert(self, value: str, param: str, ctx) -> str: + def convert( + self, + value: typing.Literal["show-only"] | str, + param: Parameter | str, + ctx: Context | None, + ) -> str: + if value == "show-only": + return value _name_regex = regex.compile(r"^\p{L}[\p{L}\p{M}'-]+(?: \p{L}[\p{L}\p{M}'-]+)*$") if not _name_regex.match(value): self.fail(f"'{value}' is not a valid full name") @@ -62,3 +92,4 @@ def convert(self, value: str, param: str, ctx) -> str: JSONType = JSONParamType() Email = PatternMatch(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$") UserName = PatternMatch(r"^[a-zA-Z0-9\-\_\.]+$") +TimeInterval = TimeIntervalType() diff --git a/tests/test_command_line_interface.py b/tests/test_command_line_interface.py index cddf467..8b84203 100644 --- a/tests/test_command_line_interface.py +++ b/tests/test_command_line_interface.py @@ -95,8 +95,7 @@ def test_runs_list(create_test_run: tuple[simvue.Run, dict]) -> None: "--format=simple", "--folder", "--metadata.test_engine", - "--filter", - "folder~/simvue_cli_testing", + "--folder=/simvue_cli_testing", "--filter", "tag!=fds", "--filter", diff --git a/uv.lock b/uv.lock index e8f3200..9e0263c 100644 --- a/uv.lock +++ b/uv.lock @@ -1463,7 +1463,7 @@ wheels = [ [[package]] name = "simvue" version = "2.4.0" -source = { registry = "https://pypi.org/simple" } +source = { directory = "../python-api" } dependencies = [ { name = "click" }, { name = "deepmerge" }, @@ -1489,10 +1489,35 @@ dependencies = [ { name = "toml" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/d5/3119c5a8fa74a88e1e4469870a39575aeaa172534aa53bbcaf0edf7911b6/simvue-2.4.0.tar.gz", hash = "sha256:7816dbd46e54cc5edd071bf5eb4951919b50859787174797e872f0aeb62f6231", size = 130968, upload-time = "2026-03-23T12:51:26.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/00/699e532defd358c4cc709aae9aea97ba2060359a3e21266a39a44a7cc01d/simvue-2.4.0-py3-none-any.whl", hash = "sha256:4b54e51c39e9afd51cf8824df0fdea1ccf3acf67e387187a2c218a8701d06328", size = 166538, upload-time = "2026-03-23T12:51:24.818Z" }, -] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.8,<9.0.0" }, + { name = "deepmerge", specifier = ">=2.0,<3.0" }, + { name = "email-validator", specifier = ">=2.2.0,<3.0.0" }, + { name = "flatdict", specifier = "==4.0.0" }, + { name = "geocoder", specifier = ">=1.38.1,<2.0.0" }, + { name = "gitpython", specifier = ">=3.1.44,<4.0.0" }, + { name = "humanfriendly", specifier = ">=10.0,<11.0" }, + { name = "matplotlib", marker = "extra == 'plot'", specifier = ">=3.10.0,<4.0.0" }, + { name = "msgpack", specifier = ">=1.1.0,<2.0.0" }, + { name = "numpy", specifier = ">=2.0.0,<3.0.0" }, + { name = "pandas", specifier = ">=2.2.3,<3.0.0" }, + { name = "plotly", marker = "extra == 'plot'", specifier = ">=6.0.0,<7.0.0" }, + { name = "psutil", specifier = ">=6.1.1,<7.0.0" }, + { name = "pydantic", specifier = ">=2.11,<3.0.0" }, + { name = "pydantic-extra-types", specifier = ">=2.10.5,<3.0.0" }, + { name = "pyjwt", specifier = ">=2.10.1,<3.0.0" }, + { name = "pyyaml", specifier = ">=6.0.2,<7.0.0" }, + { name = "randomname", specifier = ">=0.2.1,<0.3.0" }, + { name = "requests", specifier = ">=2.32.3,<3.0.0" }, + { name = "semver", specifier = ">=3.0.4,<4.0.0" }, + { name = "tabulate", specifier = ">=0.9.0,<0.10.0" }, + { name = "tenacity", specifier = ">=9.0.0,<10.0.0" }, + { name = "toml", specifier = ">=0.10.2,<0.11.0" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.12.2,<5.0.0" }, +] +provides-extras = ["plot"] [[package]] name = "simvue-cli" @@ -1518,6 +1543,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, + { name = "simvue" }, { name = "ty" }, ] docs = [ @@ -1539,7 +1565,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11.9" }, { name = "regex", specifier = ">=2024.11.6" }, { name = "requests", specifier = ">=2.32.3" }, - { name = "simvue", specifier = ">=2.4.0" }, + { name = "simvue", directory = "../python-api" }, { name = "tabulate", specifier = ">=0.9.0" }, { name = "toml", specifier = ">=0.10.2" }, { name = "tqdm", specifier = ">=4.67.1" }, @@ -1551,6 +1577,7 @@ dev = [ { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "simvue", directory = "../python-api" }, { name = "ty", specifier = ">=0.0.24" }, ] docs = [ From 849b0d525673890705e9eb7ad153b6d8f8612d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Zar=C4=99bski?= Date: Fri, 27 Mar 2026 14:30:43 +0000 Subject: [PATCH 3/8] Added metric plotting feature --- pyproject.toml | 4 +++ src/simvue_cli/actions.py | 22 +++++++++++++ src/simvue_cli/cli/run.py | 64 ++++++++++++++++++++++++++++++++++++ src/simvue_cli/plot.py | 40 ++++++++++++++++++++++ src/simvue_cli/validation.py | 5 ++- uv.lock | 12 +++++++ 6 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/simvue_cli/plot.py diff --git a/pyproject.toml b/pyproject.toml index fad2608..5362333 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "more-itertools>=10.8.0", "pydantic>=2.11.9", "simvue>=2.4.0", + "plotext>=5.3.2", ] [project.urls] @@ -53,6 +54,9 @@ documentation = "https://docs.simvue.io" [project.scripts] simvue = "simvue_cli.cli:simvue" +[project.optional-dependencies] +plot = [] + [tool.ruff] lint.extend-select = ["C901"] lint.mccabe.max-complexity = 11 diff --git a/src/simvue_cli/actions.py b/src/simvue_cli/actions.py index 33a6425..3f6e7a3 100644 --- a/src/simvue_cli/actions.py +++ b/src/simvue_cli/actions.py @@ -960,3 +960,25 @@ def purge_local_simvue_files() -> list[pathlib.Path]: _remove_files.append(global_simvue_file) return _remove_files + + +def get_metrics( + run_ids: list[str], + metric_names: list[str], + *, + x_axis: typing.Literal["step", "timestamp", "time"] = "step", + n_data_points: int | None = None, +) -> Generator[tuple[str, str, list[float], list[float]]]: + """Retrieve the values for a metric.""" + _x_values: list[float] = [] + _y_values: list[float] = [] + + for entry in Metrics.get( + metrics=metric_names, xaxis=x_axis, runs=run_ids, count=n_data_points + ): + for metric_name in metric_names: + for run_id in run_ids: + _values = entry[run_id][metric_name] + _y_values += [d["value"] for d in _values] + _x_values += [d[x_axis] for d in _values] + yield metric_name, run_id, _x_values, _y_values diff --git a/src/simvue_cli/cli/run.py b/src/simvue_cli/cli/run.py index a630b52..7b22631 100644 --- a/src/simvue_cli/cli/run.py +++ b/src/simvue_cli/cli/run.py @@ -1,5 +1,6 @@ """Simvue Run Commands.""" +import time import click import click_option_group import tabulate @@ -7,20 +8,25 @@ import sys import json import re +import plotext as plt from click_option_group import optgroup from typing import Literal, TYPE_CHECKING from simvue_cli.cli.display import create_objects_display from simvue_cli.validation import ( + MetricName, + ObjectID, SimvueName, SimvueFolder, JSONType, + TimeFormat, TimeInterval, ) from simvue.exception import ObjectNotFoundError from simvue.api.objects.filter import Status import simvue_cli.actions +import simvue_cli.plot from simvue.api.objects import Run @@ -638,3 +644,61 @@ def pull_simvue_run(ctx, output_dir: str, run_id: str) -> None: else click.style(_disp_str, fg="red", bold=True) ) sys.exit(1) + + +@simvue_run.command("plot") +@click.pass_context +@click.argument("run_id", type=ObjectID, nargs=-1) +@click.option("--metric", type=MetricName, multiple=True, required=True) +@click.option( + "--time-format", type=TimeFormat, required=False, default="step", show_default=True +) +@optgroup.group(name="Plotting Options", help="Customise the plotting output") +@optgroup.option( + "-Y", + "--threshold", + type=float, + help="Superimpose threshold line at point on metric axis.", + default=None, +) +@optgroup.option( + "-X", + "--cutoff", + type=float, + help="Superimpose threshold line at point on time axis.", + default=None, +) +@optgroup.option( + "--watch", is_flag=True, default=False, help="Monitor the metric data live." +) +def plot_run_metric( + _, + run_id: list[str], + metric: list[str], + watch: bool, + threshold: float | None, + time_format: Literal["step", "time", "timestamp"], + cutoff: float | None, +) -> None: + """Plot a metric from a given run.""" + + def _get_plot() -> str: + _plot_iter = simvue_cli.actions.get_metrics( + run_ids=run_id, metric_names=metric, x_axis=time_format + ) + return simvue_cli.plot.plot_simvue_metrics( + plot_iterator=_plot_iter, + time_label=time_format, + marker_y_coord=threshold, + marker_x_coord=cutoff, + ) + + try: + while True: + if not watch: + sys.exit(0) + _ = _get_plot() + time.sleep(2) + plt.cld() + except KeyboardInterrupt: + sys.exit(0) diff --git a/src/simvue_cli/plot.py b/src/simvue_cli/plot.py new file mode 100644 index 0000000..f14d2d8 --- /dev/null +++ b/src/simvue_cli/plot.py @@ -0,0 +1,40 @@ +"""Plot Simvue Metrics Locally.""" + +import plotext as plt + +from collections.abc import Generator + + +def plot_simvue_metrics( + *, + plot_iterator: Generator[tuple[str, str, list[float], list[float]]], + time_label: str, + marker_x_coord: float | None = None, + marker_y_coord: float | None = None, + single_metric: bool = False, + single_run: bool = False, + show_plot: bool = True, +) -> str: + _metric_label: str | None = None + for metric_name, run_id, x_values, y_values in plot_iterator: + _legend_label: list[str] = [] + if not single_metric: + _legend_label.append(metric_name) + _metric_label = metric_name + if not single_run: + _legend_label.append(run_id) + _legend_label_str: str | None = ( + "-".join(_legend_label) if _legend_label else None + ) + plt.plot(x_values, y_values, label=_legend_label_str) + plt.plotsize(500, 500) + plt.xlabel(time_label) + if _metric_label: + plt.ylabel(_metric_label) + if marker_x_coord: + plt.vertical_line(marker_x_coord) + if marker_y_coord: + plt.horizontal_line(marker_y_coord) + if show_plot: + plt.show() + return plt.build() diff --git a/src/simvue_cli/validation.py b/src/simvue_cli/validation.py index a70b774..6da7523 100644 --- a/src/simvue_cli/validation.py +++ b/src/simvue_cli/validation.py @@ -17,7 +17,7 @@ import click from click.core import Context, Parameter -from simvue.models import FOLDER_REGEX, NAME_REGEX +from simvue.models import FOLDER_REGEX, METRIC_KEY_REGEX, NAME_REGEX, OBJECT_ID class TimeIntervalType(click.ParamType): @@ -93,3 +93,6 @@ def convert( Email = PatternMatch(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$") UserName = PatternMatch(r"^[a-zA-Z0-9\-\_\.]+$") TimeInterval = TimeIntervalType() +ObjectID = PatternMatch(OBJECT_ID) +MetricName = PatternMatch(METRIC_KEY_REGEX) +TimeFormat = PatternMatch(r"^(step|time|timestamp)$") diff --git a/uv.lock b/uv.lock index 9e0263c..01973ed 100644 --- a/uv.lock +++ b/uv.lock @@ -915,6 +915,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] +[[package]] +name = "plotext" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload-time = "2024-09-24T15:13:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload-time = "2024-09-24T15:13:36.296Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1528,6 +1537,7 @@ dependencies = [ { name = "click-option-group" }, { name = "click-params" }, { name = "more-itertools" }, + { name = "plotext" }, { name = "pydantic" }, { name = "regex" }, { name = "requests" }, @@ -1562,6 +1572,7 @@ requires-dist = [ { name = "click-option-group", specifier = ">=0.5.7" }, { name = "click-params", specifier = ">=0.5.0" }, { name = "more-itertools", specifier = ">=10.8.0" }, + { name = "plotext", specifier = ">=5.3.2" }, { name = "pydantic", specifier = ">=2.11.9" }, { name = "regex", specifier = ">=2024.11.6" }, { name = "requests", specifier = ">=2.32.3" }, @@ -1570,6 +1581,7 @@ requires-dist = [ { name = "toml", specifier = ">=0.10.2" }, { name = "tqdm", specifier = ">=4.67.1" }, ] +provides-extras = ["plot"] [package.metadata.requires-dev] dev = [ From b74331f581419798cd877e0bb965b23354ee7e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Zar=C4=99bski?= Date: Mon, 30 Mar 2026 09:07:32 +0100 Subject: [PATCH 4/8] Fix datetime plot format --- src/simvue_cli/cli/run.py | 8 +++++--- src/simvue_cli/plot.py | 37 ++++++++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/simvue_cli/cli/run.py b/src/simvue_cli/cli/run.py index 7b22631..511cb4d 100644 --- a/src/simvue_cli/cli/run.py +++ b/src/simvue_cli/cli/run.py @@ -682,22 +682,24 @@ def plot_run_metric( ) -> None: """Plot a metric from a given run.""" - def _get_plot() -> str: + def _get_plot() -> None: _plot_iter = simvue_cli.actions.get_metrics( run_ids=run_id, metric_names=metric, x_axis=time_format ) - return simvue_cli.plot.plot_simvue_metrics( + simvue_cli.plot.plot_simvue_metrics( plot_iterator=_plot_iter, time_label=time_format, + single_metric=len(metric) < 2, + single_run=len(run_id) < 2, marker_y_coord=threshold, marker_x_coord=cutoff, ) try: while True: + _get_plot() if not watch: sys.exit(0) - _ = _get_plot() time.sleep(2) plt.cld() except KeyboardInterrupt: diff --git a/src/simvue_cli/plot.py b/src/simvue_cli/plot.py index f14d2d8..fa027bd 100644 --- a/src/simvue_cli/plot.py +++ b/src/simvue_cli/plot.py @@ -1,40 +1,55 @@ """Plot Simvue Metrics Locally.""" +import datetime import plotext as plt from collections.abc import Generator +from simvue.models import DATETIME_FORMAT, typing def plot_simvue_metrics( *, - plot_iterator: Generator[tuple[str, str, list[float], list[float]]], + plot_iterator: Generator[tuple[str, str, list[float | str], list[float]]], time_label: str, - marker_x_coord: float | None = None, + marker_x_coord: float | str | None = None, marker_y_coord: float | None = None, single_metric: bool = False, single_run: bool = False, - show_plot: bool = True, -) -> str: +) -> None: _metric_label: str | None = None + _run_label: str | None = None + plt.clear_figure() + plt.xlabel(time_label) + plt.date_form("H:M:S") for metric_name, run_id, x_values, y_values in plot_iterator: _legend_label: list[str] = [] + _metric_label = metric_name + _run_label = run_id + if time_label == "timestamp": + x_values = [ + datetime.datetime.strptime( + typing.cast("str", val), f"{DATETIME_FORMAT}Z" + ).strftime("%H:%M:%S") + for val in x_values + ] if not single_metric: _legend_label.append(metric_name) - _metric_label = metric_name if not single_run: _legend_label.append(run_id) _legend_label_str: str | None = ( "-".join(_legend_label) if _legend_label else None ) plt.plot(x_values, y_values, label=_legend_label_str) - plt.plotsize(500, 500) - plt.xlabel(time_label) - if _metric_label: + if single_metric and single_run and _run_label and _metric_label: + plt.title(f"{_metric_label} for Run {_run_label}") + elif single_run and _run_label: + plt.title(_run_label) + elif single_metric and _metric_label: + plt.title(_metric_label) + if single_metric: plt.ylabel(_metric_label) if marker_x_coord: plt.vertical_line(marker_x_coord) if marker_y_coord: plt.horizontal_line(marker_y_coord) - if show_plot: - plt.show() - return plt.build() + plt.show() From 9c7c9bbd361b3e801dfd8518bc63490b2f949473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Zar=C4=99bski?= Date: Mon, 30 Mar 2026 09:30:23 +0100 Subject: [PATCH 5/8] Fix multi-plot data generator --- src/simvue_cli/actions.py | 8 +++----- src/simvue_cli/plot.py | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/simvue_cli/actions.py b/src/simvue_cli/actions.py index 3f6e7a3..b214908 100644 --- a/src/simvue_cli/actions.py +++ b/src/simvue_cli/actions.py @@ -968,10 +968,8 @@ def get_metrics( *, x_axis: typing.Literal["step", "timestamp", "time"] = "step", n_data_points: int | None = None, -) -> Generator[tuple[str, str, list[float], list[float]]]: +) -> Generator[tuple[str, str, list[float | str], list[float]]]: """Retrieve the values for a metric.""" - _x_values: list[float] = [] - _y_values: list[float] = [] for entry in Metrics.get( metrics=metric_names, xaxis=x_axis, runs=run_ids, count=n_data_points @@ -979,6 +977,6 @@ def get_metrics( for metric_name in metric_names: for run_id in run_ids: _values = entry[run_id][metric_name] - _y_values += [d["value"] for d in _values] - _x_values += [d[x_axis] for d in _values] + _y_values: list[float] = [d["value"] for d in _values] + _x_values: list[float | str] = [d[x_axis] for d in _values] yield metric_name, run_id, _x_values, _y_values diff --git a/src/simvue_cli/plot.py b/src/simvue_cli/plot.py index fa027bd..d60a98a 100644 --- a/src/simvue_cli/plot.py +++ b/src/simvue_cli/plot.py @@ -16,6 +16,7 @@ def plot_simvue_metrics( single_metric: bool = False, single_run: bool = False, ) -> None: + """Plot a set of metrics in the terminal.""" _metric_label: str | None = None _run_label: str | None = None plt.clear_figure() From ee0366d09064dfd6ebc1ea79d583a4572e26fc06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Zar=C4=99bski?= Date: Mon, 30 Mar 2026 09:50:43 +0100 Subject: [PATCH 6/8] Bump version --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- tests/unit/test_cli_actions.py | 4 ++++ uv.lock | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a47c7..6d03613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# Unreleased + +- Changed filtering to be explicit using same arguments as column visibility, e.g. `--name` vs `--name=my_run` for runs. +- Added folder filtering. +- **NEW** added ability to plot metrics from the terminal, including multiplots and watching metrics live. + # [v1.4.0](https://github.com/simvue-io/simvue-cli/releases/tag/v1.4.0) - 2026-03-24 - Handle download of runs with duplicates of files in artifacts. diff --git a/pyproject.toml b/pyproject.toml index 5362333..c2e48e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "simvue-cli" -version = "1.4.0" +version = "1.5.0" description = "Command Line Interface for interaction with a Simvue v3 server" authors = [{ name = "Simvue Development Team", email = "info@simvue.io" }] license-files = ["LICENSE"] diff --git a/tests/unit/test_cli_actions.py b/tests/unit/test_cli_actions.py index 1dabacf..09a2329 100644 --- a/tests/unit/test_cli_actions.py +++ b/tests/unit/test_cli_actions.py @@ -19,6 +19,7 @@ from simvue.run import SimvueConfiguration, UserAlert import simvue_cli.actions import simvue_cli.config +from tests.conftest import create_test_run @@ -371,3 +372,6 @@ def test_purge_local_files(monkeypatch) -> None: assert _offline_cache in _deleted_files assert _global_config in _deleted_files + +def test_retrieve_metrics(create_test_run: tuple[Run, dict]) -> None: + _run, _data = create_test_run diff --git a/uv.lock b/uv.lock index 01973ed..16680ed 100644 --- a/uv.lock +++ b/uv.lock @@ -1530,7 +1530,7 @@ provides-extras = ["plot"] [[package]] name = "simvue-cli" -version = "1.4.0" +version = "1.5.0" source = { editable = "." } dependencies = [ { name = "click" }, From e0a0dd71db7a51eb3baacb26b1937f58622a2eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Zar=C4=99bski?= Date: Fri, 29 May 2026 08:38:21 +0100 Subject: [PATCH 7/8] Removed dev simvue reference --- pyproject.toml | 6 +----- uv.lock | 54 ++++++-------------------------------------------- 2 files changed, 7 insertions(+), 53 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c2e48e3..0f32bec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "tqdm>=4.67.1", "more-itertools>=10.8.0", "pydantic>=2.11.9", - "simvue>=2.4.0", + "simvue>=2.5.4", "plotext>=5.3.2", ] @@ -78,7 +78,6 @@ dev = [ "pytest-cov>=6.0.0", "pytest-xdist>=3.8.0", "pytest>=8.3.5", - "simvue", "ty>=0.0.24", ] docs = ["renku-sphinx-theme>=0.5.0", "sphinx>=8.1.3"] @@ -86,6 +85,3 @@ lint = ["ruff>=0.11.2"] [tool.mypy] ignore_missing_imports = true - -[tool.uv.sources] -simvue = { path = "../python-api" } diff --git a/uv.lock b/uv.lock index b0dc65b..5f2c268 100644 --- a/uv.lock +++ b/uv.lock @@ -395,7 +395,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1495,7 +1495,7 @@ wheels = [ [[package]] name = "simvue" version = "2.5.4" -source = { directory = "../python-api" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "deepmerge" }, @@ -1523,49 +1523,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "unyt" }, ] - -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.4.1,<9.0.0" }, - { name = "deepmerge", specifier = ">=2.0,<3.0" }, - { name = "email-validator", specifier = ">=2.2.0,<3.0.0" }, - { name = "flatdict", specifier = "==4.1.0" }, - { name = "geocoder", specifier = ">=1.38.1,<2.0.0" }, - { name = "gitpython", specifier = ">=3.1.44,<4.0.0" }, - { name = "humanfriendly", specifier = ">=10.0,<11.0" }, - { name = "matplotlib", marker = "extra == 'plot'", specifier = ">=3.10.0,<4.0.0" }, - { name = "msgpack", specifier = ">=1.1.0,<2.0.0" }, - { name = "numpy", specifier = ">=2.0.0,<3.0.0" }, - { name = "pandas", specifier = ">=2.2.3,<3.0.0" }, - { name = "plotly", marker = "extra == 'plot'", specifier = ">=6.0.0,<7.0.0" }, - { name = "psutil", specifier = ">=6.1.1,<8.0.0" }, - { name = "pydantic", specifier = ">=2.11,<3.0.0" }, - { name = "pydantic-extra-types", specifier = ">=2.10.5,<3.0.0" }, - { name = "pyjwt", specifier = ">=2.13.0,<3.0.0" }, - { name = "pytest", specifier = ">=9.0.3,<10.0.0" }, - { name = "pyyaml", specifier = ">=6.0.2,<7.0.0" }, - { name = "randomname", specifier = ">=0.2.1,<0.3.0" }, - { name = "requests", specifier = ">=2.32.3,<3.0.0" }, - { name = "semver", specifier = ">=3.0.4,<4.0.0" }, - { name = "tabulate", specifier = ">=0.9.0,<0.11.0" }, - { name = "tenacity", specifier = ">=9.0.0,<10.0.0" }, - { name = "toml", specifier = ">=0.10.2,<0.11.0" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.12.2,<5.0.0" }, - { name = "unyt", specifier = ">=3.1.0,<4.0.0" }, -] -provides-extras = ["plot"] - -[package.metadata.requires-dev] -dev = [ - { name = "interrogate", specifier = ">=1.7.0" }, - { name = "jinja2", specifier = ">=3.1.6" }, - { name = "pytest-cov", specifier = ">=7.1.0" }, - { name = "pytest-mock", specifier = ">=3.15.1" }, - { name = "pytest-sugar", specifier = ">=1.1.1" }, - { name = "pytest-timeout", specifier = ">=2.4.0" }, - { name = "pytest-xdist", specifier = ">=3.8.0" }, - { name = "ruff", specifier = ">=0.15.14" }, - { name = "types-requests", specifier = ">=2.33.0.20260518" }, +sdist = { url = "https://files.pythonhosted.org/packages/88/8f/109f0f01d82abbbd1c54708f20e4278d33d3f6f8b94931bb83aaad43fe44/simvue-2.5.4.tar.gz", hash = "sha256:e1215d341bc5030159a5d06eba1b6873c20ca397a6fef0923e4cf4e3db238004", size = 483128, upload-time = "2026-05-28T07:29:55.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8f/c85ae6a856475a042ba8aecf9e38910910a2944d628b74c70d94b93fd13e/simvue-2.5.4-py3-none-any.whl", hash = "sha256:ab6f6fd6656a844e880c63a97efb7218c7ddea1f6f374ed07aaf2bef6ef0cbd9", size = 172400, upload-time = "2026-05-28T07:29:54.627Z" }, ] [[package]] @@ -1593,7 +1553,6 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, - { name = "simvue" }, { name = "ty" }, ] docs = [ @@ -1616,7 +1575,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11.9" }, { name = "regex", specifier = ">=2024.11.6" }, { name = "requests", specifier = ">=2.32.3" }, - { name = "simvue", directory = "../python-api" }, + { name = "simvue", specifier = ">=2.5.4" }, { name = "tabulate", specifier = ">=0.9.0" }, { name = "toml", specifier = ">=0.10.2" }, { name = "tqdm", specifier = ">=4.67.1" }, @@ -1629,7 +1588,6 @@ dev = [ { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, - { name = "simvue", directory = "../python-api" }, { name = "ty", specifier = ">=0.0.24" }, ] docs = [ From 93d182abd2e392c5ffd8ca467ccaeb75e7bac78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Zar=C4=99bski?= Date: Wed, 26 Aug 2026 08:09:22 +0100 Subject: [PATCH 8/8] Update required Simvue version --- pyproject.toml | 2 +- uv.lock | 254 +++++++++++++++++++++++++++---------------------- 2 files changed, 139 insertions(+), 117 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0f32bec..786b6d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "tqdm>=4.67.1", "more-itertools>=10.8.0", "pydantic>=2.11.9", - "simvue>=2.5.4", + "simvue>=2.5.10", "plotext>=5.3.2", ] diff --git a/uv.lock b/uv.lock index 5f2c268..9e832f0 100644 --- a/uv.lock +++ b/uv.lock @@ -395,7 +395,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -483,14 +483,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.50" +version = "3.1.60" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/14/e6b1a48d831755a53c2029351fcef82e70db4a08f338daefe29d8d0cf31c/gitpython-3.1.60.tar.gz", hash = "sha256:e936431879fa85581b4311fa63492ea52251909e2d655b6529c704c904ddcc24", size = 230793, upload-time = "2026-08-25T18:33:46.102Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, + { url = "https://files.pythonhosted.org/packages/71/63/ba28697918b7c190af9f3f21940d03e8814e25dd4ddd39d6929f3a553995/gitpython-3.1.60-py3-none-any.whl", hash = "sha256:39548bffb8fa0f3a548133348868bb4838e79d73283052207dc97781a569b6b4", size = 221893, upload-time = "2026-08-25T18:33:44.75Z" }, ] [[package]] @@ -649,63 +649,75 @@ wheels = [ [[package]] name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" }, - { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" }, - { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" }, - { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" }, - { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, - { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, - { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" }, + { url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" }, + { url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] [[package]] @@ -927,6 +939,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] +[[package]] +name = "pip" +version = "26.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" }, +] + [[package]] name = "plotext" version = "5.3.2" @@ -1494,7 +1515,7 @@ wheels = [ [[package]] name = "simvue" -version = "2.5.4" +version = "2.5.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1508,6 +1529,7 @@ dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pandas" }, + { name = "pip" }, { name = "psutil" }, { name = "pydantic" }, { name = "pydantic-extra-types" }, @@ -1523,9 +1545,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "unyt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/8f/109f0f01d82abbbd1c54708f20e4278d33d3f6f8b94931bb83aaad43fe44/simvue-2.5.4.tar.gz", hash = "sha256:e1215d341bc5030159a5d06eba1b6873c20ca397a6fef0923e4cf4e3db238004", size = 483128, upload-time = "2026-05-28T07:29:55.964Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/dd/44c3e96237363db0b0117d3353bf80e7d464ee368ae2b4cc9649c1fb06ff/simvue-2.5.10.tar.gz", hash = "sha256:8f7c4951f46d7811e3a087059133a87b4771d5ab0a4af5e32bb64e0a68b7da32", size = 507337, upload-time = "2026-08-11T07:11:23.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8f/c85ae6a856475a042ba8aecf9e38910910a2944d628b74c70d94b93fd13e/simvue-2.5.4-py3-none-any.whl", hash = "sha256:ab6f6fd6656a844e880c63a97efb7218c7ddea1f6f374ed07aaf2bef6ef0cbd9", size = 172400, upload-time = "2026-05-28T07:29:54.627Z" }, + { url = "https://files.pythonhosted.org/packages/db/28/d2c0236d397fe7b7d9fed1353002ddef1d687e2d6fe9253682b556fb5c7f/simvue-2.5.10-py3-none-any.whl", hash = "sha256:1ca3d8cb85a7052b1767159458effdeb5ef20a848633860a396346a4d7fb298c", size = 179149, upload-time = "2026-08-11T07:11:21.588Z" }, ] [[package]] @@ -1575,7 +1597,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11.9" }, { name = "regex", specifier = ">=2024.11.6" }, { name = "requests", specifier = ">=2.32.3" }, - { name = "simvue", specifier = ">=2.5.4" }, + { name = "simvue", specifier = ">=2.5.10" }, { name = "tabulate", specifier = ">=0.9.0" }, { name = "toml", specifier = ">=0.10.2" }, { name = "tqdm", specifier = ">=4.67.1" }, @@ -1631,23 +1653,23 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version < '3.11'" }, - { name = "babel", marker = "python_full_version < '3.11'" }, - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "imagesize", marker = "python_full_version < '3.11'" }, - { name = "jinja2", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "requests", marker = "python_full_version < '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -1662,23 +1684,23 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version == '3.11.*'" }, - { name = "babel", marker = "python_full_version == '3.11.*'" }, - { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "imagesize", marker = "python_full_version == '3.11.*'" }, - { name = "jinja2", marker = "python_full_version == '3.11.*'" }, - { name = "packaging", marker = "python_full_version == '3.11.*'" }, - { name = "pygments", marker = "python_full_version == '3.11.*'" }, - { name = "requests", marker = "python_full_version == '3.11.*'" }, - { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, - { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ @@ -1693,23 +1715,23 @@ resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.12'" }, - { name = "babel", marker = "python_full_version >= '3.12'" }, - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "imagesize", marker = "python_full_version >= '3.12'" }, - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, - { name = "requests", marker = "python_full_version >= '3.12'" }, - { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [