diff --git a/pyproject.toml b/pyproject.toml index e151b01..8bc18bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "httpx>=0.24", "pathspec>=0.11", "platformdirs>=4.0", + "rich>=13.0", + "pyyaml>=6.0", ] [project.optional-dependencies] diff --git a/scripts/generate_cli.py b/scripts/generate_cli.py index db02657..1bd37c8 100644 --- a/scripts/generate_cli.py +++ b/scripts/generate_cli.py @@ -321,6 +321,8 @@ def generate_command_code( query_params = extract_query_params(operation) body_props = extract_json_body_properties(operation) + is_table = cmd_name in ("list", "search") + lines: list[str] = [f'@{group_name}_group.command("{cmd_name}")'] for pp in path_params: @@ -382,9 +384,15 @@ def generate_command_code( f'@click.option("--{opt}", "{var}"{required_arg}, help={_quote(desc)})' ) + if is_table: + lines.append('@click.option("-o", "--output", "output_format", type=click.Choice(["table", "yaml", "json"]), default="table", help="Output format.")') + else: + lines.append('@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.")') + lines.append("@click.pass_context") sig_parts = ["ctx"] + path_params + [py_var_name(q["name"]) for q in query_params] + sig_parts.append("output_format") if method != "GET": sig_parts += [py_var_name(prop["name"]) for prop in body_props] lines.append(f"def {func_name}({', '.join(sig_parts)}):") @@ -412,7 +420,10 @@ def generate_command_code( lines.append( f' result = ctx.obj["client"].request({", ".join(call_args)})' ) - lines.append(" _output(result)") + if is_table: + lines.append(" _table_output(result, output_format=output_format)") + else: + lines.append(" _yaml_output(result, output_format=output_format)") return lines lines.append(" body = {}") @@ -437,7 +448,10 @@ def generate_command_code( lines.append( f' result = ctx.obj["client"].request("{method}", url, json_body=body)' ) - lines.append(" _output(result)") + if is_table: + lines.append(" _table_output(result, output_format=output_format)") + else: + lines.append(" _yaml_output(result, output_format=output_format)") return lines @@ -467,7 +481,7 @@ def generate_all(spec: dict) -> str: import click - from judgment_cli.ui import output as _output + from judgment_cli.ui import table_output as _table_output, yaml_output as _yaml_output """) diff --git a/src/judgment_cli/generated_commands.py b/src/judgment_cli/generated_commands.py index d681e6c..8129e7f 100644 --- a/src/judgment_cli/generated_commands.py +++ b/src/judgment_cli/generated_commands.py @@ -7,7 +7,7 @@ import click -from judgment_cli.ui import output as _output +from judgment_cli.ui import table_output as _table_output, yaml_output as _yaml_output # ──────────────────────────────────────────────────────────────────── @@ -23,15 +23,16 @@ def agent_threads_group() -> None: @agent_threads_group.command("get") @click.argument("project_id") @click.argument("thread_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def agent_threads_get(ctx, project_id, thread_id): +def agent_threads_get(ctx, output_format, project_id, thread_id): 'Get an agent thread.\n\n\x08\nGet one agent thread conversation, including its transcript, metadata, active run status, and timestamps.' url = "/agent-threads/get" body = {} body["project_id"] = project_id body["thread_id"] = thread_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @agent_threads_group.command("list") @@ -41,8 +42,9 @@ def agent_threads_get(ctx, project_id, thread_id): @click.option("--limit", "limit", default=None, type=float, help='Maximum number of threads to return (1–100).') @click.option("--cursor-updated-at", "cursor_updated_at", default=None, help='Pagination cursor: `updated_at` value from a previous `next_cursor`.') @click.option("--cursor-thread-id", "cursor_thread_id", default=None, help='Pagination cursor: `thread_id` value from a previous `next_cursor`.') +@click.option("-o", "--output", "output_format", type=click.Choice(["table", "yaml", "json"]), default="table", help="Output format.") @click.pass_context -def agent_threads_list(ctx, project_id, agent_kind, judge_id, limit, cursor_updated_at, cursor_thread_id): +def agent_threads_list(ctx, output_format, project_id, agent_kind, judge_id, limit, cursor_updated_at, cursor_thread_id): "List agent thread conversations.\n\n\x08\nList the authenticated user's agent thread conversations in a project (global_copilot or custom_agent). Returns each thread's title, type, message count, active run status, and timestamps." url = "/agent-threads/list" body = {} @@ -58,7 +60,7 @@ def agent_threads_list(ctx, project_id, agent_kind, judge_id, limit, cursor_upda if cursor_thread_id is not None: body["cursor_thread_id"] = cursor_thread_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _table_output(result, output_format=output_format) # ──────────────────────────────────────────────────────────────────── @@ -80,8 +82,9 @@ def automations_group() -> None: @click.option("--actions", "actions", default=None, help='JSON object describing what happens when the automation fires. All top-level keys are optional — include only the actions you want configured.\n\n**Shape:**\n```\n{\n "notification": {\n "enabled": ?,\n "communication_methods": ["email" | "slack" | "pagerduty"],\n "email_addresses": ["", ...]?,\n "pagerduty_config": {"routing_key":"","severity":"critical"|"error"|"warning"|"info"}?\n }?,\n "dataset_addition": {\n "enabled": ?,\n "dataset_name": "",\n "metadata_fields": ?\n }?,\n "behavior_evaluation": {\n "enabled": ?,\n "behavior_judge_names": ["", ...]\n }?\n}\n```\n\nSlack notifications are configured per-organization in the Judgment UI; pass `"slack"` in `communication_methods` to use them.') @click.option("--cooldown-period", "cooldown_period", default=None, help='JSON object describing the minimum wait between triggers. Omit to leave the cooldown unset; if provided, both `value` and `unit` are required.\n\n**Shape:** `{ "value": , "unit": "seconds" | "minutes" | "hours" | "days" }`\n\nExample: `{ "value": 15, "unit": "minutes" }` (at least 15 min between triggers)') @click.option("--trigger-frequency", "trigger_frequency", default=None, help='JSON object describing the rate-limit window. Omit to leave unset; if provided, all three fields are required.\n\n**Shape:** `{ "count": , "period": , "period_unit": "seconds" | "minutes" | "hours" | "days" }`\n\nExample: `{ "count": 5, "period": 1, "period_unit": "hours" }` (max 5 triggers per 1 hour)') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def automations_create(ctx, project_id, name, description, conditions, combine_type, actions, cooldown_period, trigger_frequency): +def automations_create(ctx, output_format, project_id, name, description, conditions, combine_type, actions, cooldown_period, trigger_frequency): 'Create an automation.\n\n\x08\nCreate an automation (rule) in a project. An automation watches behavior/latency/cost metrics and fires actions when its conditions match. Requires the developer role.' url = "/automations/create" body = {} @@ -98,47 +101,50 @@ def automations_create(ctx, project_id, name, description, conditions, combine_t if trigger_frequency is not None: body["trigger_frequency"] = json.loads(trigger_frequency) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @automations_group.command("delete") @click.argument("project_id") @click.argument("rule_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def automations_delete(ctx, project_id, rule_id): +def automations_delete(ctx, output_format, project_id, rule_id): 'Delete an automation.\n\n\x08\nDelete an automation. Requires the admin role.' url = "/automations/delete" body = {} body["project_id"] = project_id body["rule_id"] = rule_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @automations_group.command("get") @click.argument("project_id") @click.argument("rule_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def automations_get(ctx, project_id, rule_id): +def automations_get(ctx, output_format, project_id, rule_id): """Get an automation by ID.""" url = "/automations/detail" body = {} body["project_id"] = project_id body["rule_id"] = rule_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @automations_group.command("list") @click.argument("project_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["table", "yaml", "json"]), default="table", help="Output format.") @click.pass_context -def automations_list(ctx, project_id): +def automations_list(ctx, output_format, project_id): """List automations.""" url = "/automations/list" body = {} body["project_id"] = project_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _table_output(result, output_format=output_format) @automations_group.command("update") @@ -152,8 +158,9 @@ def automations_list(ctx, project_id): @click.option("--active", "active", default=None, type=bool, help='Enable (true) or disable (false) the automation without modifying other fields.') @click.option("--cooldown-period", "cooldown_period", default=None, help='JSON 2-tuple `[period, unit]` describing the minimum wait between triggers. Omit to leave unchanged.\n\n**Shape:** `[, ]`\n\nExample: `[15, "minutes"]` (at least 15 min between triggers)') @click.option("--trigger-frequency", "trigger_frequency", default=None, help='JSON 3-tuple `[count, period, unit]` describing the rate-limit window. Omit to leave unchanged.\n\n**Shape:** `[, , ]`\n\nExample: `[5, 1, "hours"]` (max 5 triggers per 1 hour)') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def automations_update(ctx, project_id, rule_id, name, description, conditions, combine_type, actions, active, cooldown_period, trigger_frequency): +def automations_update(ctx, output_format, project_id, rule_id, name, description, conditions, combine_type, actions, active, cooldown_period, trigger_frequency): 'Update an automation.\n\n\x08\nUpdate an existing automation. All fields other than the IDs are optional — only supplied fields are applied. Use `active: true/false` to enable or disable without changing other fields. Requires the developer role.' url = "/automations/update" body = {} @@ -176,7 +183,7 @@ def automations_update(ctx, project_id, rule_id, name, description, conditions, if trigger_frequency is not None: body["trigger_frequency"] = json.loads(trigger_frequency) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) # ──────────────────────────────────────────────────────────────────── @@ -198,8 +205,9 @@ def behaviors_group() -> None: @click.option("--category-ids", "category_ids", multiple=True, help='UUIDs of categories to attach the behavior to. Pass an array of category UUIDs.') @click.option("--advanced-settings", "advanced_settings", default=None, help='JSON object overriding the judge\'s online-evaluation configuration. All four fields are required when this is supplied.\n\n**Shape:**\n```\n{\n "online_evaluation_mode": "continuous" | "on_demand",\n "online_sampling_rate": ,\n "online_span_triggers": [\n {"field":"span_name"|"span_attribute","operator":"contains"|"equals"|"exists","value":"","key":""?}\n ],\n "online_session_scoring": \n}\n```\n\n`continuous` runs the judge automatically on qualifying spans; `on_demand` requires a manual `judgment traces evaluate` call. `online_sampling_rate` is a percent (0–100) of matching spans to score.') @click.option("--judge-id", "judge_id", default=None, help='Attach the new behavior to an existing judge instead of creating one. The judge must be `score_type=binary` and have no existing behaviors.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def behaviors_create_binary(ctx, project_id, name, prompt, description, model, category_ids, advanced_settings, judge_id): +def behaviors_create_binary(ctx, output_format, project_id, name, prompt, description, model, category_ids, advanced_settings, judge_id): 'Create a binary (yes/no) behavior.\n\n\x08\nCreate a binary behavior. The judge LLM uses your prompt to decide true/false on each qualifying span.' url = "/behaviors/create-binary" body = {} @@ -217,7 +225,7 @@ def behaviors_create_binary(ctx, project_id, name, prompt, description, model, c if judge_id is not None: body["judge_id"] = judge_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @behaviors_group.command("create-classifier") @@ -229,8 +237,9 @@ def behaviors_create_binary(ctx, project_id, name, prompt, description, model, c @click.option("--category-ids", "category_ids", multiple=True, help='UUIDs of categories to attach the behavior to. Pass an array of category UUIDs.') @click.option("--advanced-settings", "advanced_settings", default=None, help='JSON object overriding the judge\'s online-evaluation configuration. All four fields are required when this is supplied.\n\n**Shape:**\n```\n{\n "online_evaluation_mode": "continuous" | "on_demand",\n "online_sampling_rate": ,\n "online_span_triggers": [\n {"field":"span_name"|"span_attribute","operator":"contains"|"equals"|"exists","value":"","key":""?}\n ],\n "online_session_scoring": \n}\n```\n\n`continuous` runs the judge automatically on qualifying spans; `on_demand` requires a manual `judgment traces evaluate` call. `online_sampling_rate` is a percent (0–100) of matching spans to score.') @click.option("--judge-id", "judge_id", default=None, help='Attach the new behavior to an existing judge instead of creating one. The judge must be `score_type=categorical` and have no existing behaviors.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def behaviors_create_classifier(ctx, project_id, name, prompt, options, model, category_ids, advanced_settings, judge_id): +def behaviors_create_classifier(ctx, output_format, project_id, name, prompt, options, model, category_ids, advanced_settings, judge_id): 'Create a classifier (multi-label) behavior.\n\n\x08\nCreate a classifier behavior. The judge LLM picks one of the supplied options for each qualifying span.' url = "/behaviors/create-classifier" body = {} @@ -247,7 +256,7 @@ def behaviors_create_classifier(ctx, project_id, name, prompt, options, model, c if judge_id is not None: body["judge_id"] = judge_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @behaviors_group.command("delete") @@ -255,8 +264,9 @@ def behaviors_create_classifier(ctx, project_id, name, prompt, options, model, c @click.argument("behavior_id") @click.option("--delete-scorer", "delete_scorer", default=None, type=bool, help='When true, also delete the underlying prompt scorer if no other behaviors reference it.') @click.option("--delete-all-values", "delete_all_values", default=None, type=bool, help='For classifier behaviors, when true deletes every category row for this judge (not just the provided behavior_id). Ignored for binary behaviors.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def behaviors_delete(ctx, project_id, behavior_id, delete_scorer, delete_all_values): +def behaviors_delete(ctx, output_format, project_id, behavior_id, delete_scorer, delete_all_values): """Delete a behavior.""" url = "/behaviors/delete" body = {} @@ -267,7 +277,7 @@ def behaviors_delete(ctx, project_id, behavior_id, delete_scorer, delete_all_val if delete_all_values is not None: body["delete_all_values"] = delete_all_values result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @behaviors_group.command("get") @@ -275,8 +285,9 @@ def behaviors_delete(ctx, project_id, behavior_id, delete_scorer, delete_all_val @click.argument("behavior_id") @click.option("--start-date", "start_date", default=None, help='Optional ISO 8601 start date for stats.') @click.option("--end-date", "end_date", default=None, help='Optional ISO 8601 end date for stats.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def behaviors_get(ctx, project_id, behavior_id, start_date, end_date): +def behaviors_get(ctx, output_format, project_id, behavior_id, start_date, end_date): """Get a behavior with judge details and stats.""" url = "/behaviors/detail" body = {} @@ -287,27 +298,29 @@ def behaviors_get(ctx, project_id, behavior_id, start_date, end_date): if end_date is not None: body["end_date"] = end_date result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @behaviors_group.command("list") @click.argument("project_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["table", "yaml", "json"]), default="table", help="Output format.") @click.pass_context -def behaviors_list(ctx, project_id): +def behaviors_list(ctx, output_format, project_id): 'List behaviors.\n\n\x08\nList every behavior in a project along with rolled-up trace counts and last-seen stats.' url = "/behaviors/list" body = {} body["project_id"] = project_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _table_output(result, output_format=output_format) @behaviors_group.command("update") @click.argument("project_id") @click.argument("behavior_id") @click.option("--description", "description", default=None, help='New human-readable description for the behavior.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def behaviors_update(ctx, project_id, behavior_id, description): +def behaviors_update(ctx, output_format, project_id, behavior_id, description): """Update a behavior’s description.""" url = "/behaviors/update" body = {} @@ -316,7 +329,7 @@ def behaviors_update(ctx, project_id, behavior_id, description): if description is not None: body["description"] = description result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) # ──────────────────────────────────────────────────────────────────── @@ -331,21 +344,23 @@ def docs_group() -> None: @docs_group.command("get-page") @click.argument("path") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def docs_get_page(ctx, path): +def docs_get_page(ctx, path, output_format): 'Read a documentation page.\n\n\x08\nFetch the rendered text of a documentation page by its path. Use `docs.search` first to find candidate paths.' url = "/docs/page" params = {} params["path"] = path result = ctx.obj["client"].request("GET", url, params=params) - _output(result) + _yaml_output(result, output_format=output_format) @docs_group.command("search") @click.argument("query") @click.option("--match-count", "match_count", default=None, type=float, help='Maximum results to return (1–20). Defaults to 8 when omitted.') +@click.option("-o", "--output", "output_format", type=click.Choice(["table", "yaml", "json"]), default="table", help="Output format.") @click.pass_context -def docs_search(ctx, query, match_count): +def docs_search(ctx, output_format, query, match_count): 'Search docs.\n\n\x08\nHybrid (vector + lexical) search over the public Judgment documentation. Returns the top matching headings with full URLs.' url = "/docs/search" body = {} @@ -353,7 +368,7 @@ def docs_search(ctx, query, match_count): if match_count is not None: body["match_count"] = match_count result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _table_output(result, output_format=output_format) # ──────────────────────────────────────────────────────────────────── @@ -378,8 +393,9 @@ def judges_group() -> None: @click.option("--min-score", "min_score", default=None, type=float, help='Lower bound for `numeric` judges. Defaults to 0.') @click.option("--max-score", "max_score", default=None, type=float, help='Upper bound for `numeric` judges. Defaults to 1.') @click.option("--judge-type", "judge_type", default=None) +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def judges_create(ctx, project_id, name, judge_description, description, model, prompt, score_type, categories, min_score, max_score, judge_type): +def judges_create(ctx, output_format, project_id, name, judge_description, description, model, prompt, score_type, categories, min_score, max_score, judge_type): 'Create a prompt judge.\n\n\x08\nCreate a new prompt judge in a project. The judge runs the supplied prompt against the configured LLM model to score spans.' url = "/judges/create" body = {} @@ -401,14 +417,15 @@ def judges_create(ctx, project_id, name, judge_description, description, model, if judge_type is not None: body["judge_type"] = judge_type result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @judges_group.command("delete") @click.argument("project_id") @click.option("--judge-ids", "judge_ids", multiple=True, required=True, help='Judge UUIDs to delete.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def judges_delete(ctx, project_id, judge_ids): +def judges_delete(ctx, output_format, project_id, judge_ids): 'Delete judges.\n\n\x08\nDelete one or more judges by ID. Behaviors that reference these judges are also removed.' url = "/judges/delete" body = {} @@ -416,56 +433,60 @@ def judges_delete(ctx, project_id, judge_ids): if judge_ids: body["judge_ids"] = list(judge_ids) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @judges_group.command("get") @click.argument("project_id") @click.argument("judge_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def judges_get(ctx, project_id, judge_id): +def judges_get(ctx, output_format, project_id, judge_id): 'Get a judge by ID.\n\n\x08\nReturn full detail (including all versions) for a single judge.' url = "/judges/get" body = {} body["project_id"] = project_id body["judge_id"] = judge_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @judges_group.command("get-settings") @click.argument("project_id") @click.argument("judge_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def judges_get_settings(ctx, project_id, judge_id): +def judges_get_settings(ctx, output_format, project_id, judge_id): """Get a judge’s online-evaluation settings.""" url = "/judges/settings" body = {} body["project_id"] = project_id body["judge_id"] = judge_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @judges_group.command("list") @click.argument("project_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["table", "yaml", "json"]), default="table", help="Output format.") @click.pass_context -def judges_list(ctx, project_id): +def judges_list(ctx, output_format, project_id): 'List judges in a project.\n\n\x08\nList every judge in a project, including prompt, code, and custom (uploaded) judges. Returns each judge with its current configuration and online-evaluation settings.' url = "/judges/list" body = {} body["project_id"] = project_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _table_output(result, output_format=output_format) @judges_group.command("models") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def judges_models(ctx): +def judges_models(ctx, output_format): 'List judge models.\n\n\x08\nList the models available for use as the LLM backing a judge.' url = "/judges/models" result = ctx.obj["client"].request("GET", url) - _output(result) + _yaml_output(result, output_format=output_format) @judges_group.command("set-tag") @@ -475,8 +496,9 @@ def judges_models(ctx): @click.option("--minor-version", "minor_version", required=True, type=float, help='Judge version to tag.') @click.argument("tag") @click.option("--action", "action", required=True, type=click.Choice(['add', 'remove'])) +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def judges_set_tag(ctx, project_id, judge_id, major_version, minor_version, tag, action): +def judges_set_tag(ctx, output_format, project_id, judge_id, major_version, minor_version, tag, action): 'Add or remove a version tag on a judge.\n\n\x08\nAdd or remove a tag (e.g. `prod`) on a specific version of a judge. Use `action: "add"` to set the tag and `action: "remove"` to clear it.' url = "/judges/set-tag" body = {} @@ -487,7 +509,7 @@ def judges_set_tag(ctx, project_id, judge_id, major_version, minor_version, tag, body["tag"] = tag body["action"] = action result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @judges_group.command("update") @@ -507,8 +529,9 @@ def judges_set_tag(ctx, project_id, judge_id, major_version, minor_version, tag, @click.option("--source-minor-version", "source_minor_version", default=None, type=float, help='Minor version to copy unspecified fields from. Defaults to the latest version.') @click.option("--agent-prompts", "agent_prompts", default=None, help='For agent judges only: replacement list of named sub-prompts (`{name, prompt}`).') @click.option("--new-behaviors", "new_behaviors", default=None, help='New behaviors to attach to this judge. Each entry: `{value, description?, category_ids?}`.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def judges_update(ctx, project_id, judge_id, judge_description, score_type, description, model, prompt, categories, min_score, max_score, target_major_version, target_minor_version, source_major_version, source_minor_version, agent_prompts, new_behaviors): +def judges_update(ctx, output_format, project_id, judge_id, judge_description, score_type, description, model, prompt, categories, min_score, max_score, target_major_version, target_minor_version, source_major_version, source_minor_version, agent_prompts, new_behaviors): 'Update a judge.\n\n\x08\nUpdate a judge — model, prompt, description, score type, categories, score bounds, agent prompts, or version tags. Pass `target_major_version`/`target_minor_version` to update a specific version; otherwise the latest version is updated.' url = "/judges/update" body = {} @@ -543,7 +566,7 @@ def judges_update(ctx, project_id, judge_id, judge_description, score_type, desc if new_behaviors is not None: body["new_behaviors"] = json.loads(new_behaviors) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @judges_group.command("update-settings") @@ -553,8 +576,9 @@ def judges_update(ctx, project_id, judge_id, judge_description, score_type, desc @click.option("--sampling-rate", "sampling_rate", required=True, type=float, help='Percent (0–100) of qualifying spans to score.') @click.option("--span-triggers", "span_triggers", default=None, help='JSON array of span filters that restrict which spans the judge evaluates. Pass `[]` to evaluate all spans.\n\n**Shape:**\n```\n[\n {\n "field": "span_name" | "span_attribute",\n "operator": "contains" | "equals" | "exists",\n "value": "",\n "key": ""?\n },\n ...\n]\n```\n\nUse `field: "span_name"` to match on span names; `field: "span_attribute"` with `key: ""` to match on a span attribute\'s value. Triggers are ANDed together — a span must match every entry to be evaluated.') @click.option("--session-scoring", "session_scoring", default=None, type=bool, help='When true, run the judge at session granularity instead of per-span.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def judges_update_settings(ctx, project_id, judge_id, evaluation_mode, sampling_rate, span_triggers, session_scoring): +def judges_update_settings(ctx, output_format, project_id, judge_id, evaluation_mode, sampling_rate, span_triggers, session_scoring): 'Update a judge’s online-evaluation settings.\n\n\x08\nUpdate how often and on which spans a judge runs online. Pass `evaluation_mode: continuous` with a sampling rate to evaluate automatically, or `on_demand` to require manual `judgment traces evaluate` calls.' url = "/judges/update-settings" body = {} @@ -567,7 +591,7 @@ def judges_update_settings(ctx, project_id, judge_id, evaluation_mode, sampling_ if session_scoring is not None: body["session_scoring"] = session_scoring result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) # ──────────────────────────────────────────────────────────────────── @@ -582,47 +606,51 @@ def projects_group() -> None: @projects_group.command("add-favorite") @click.argument("project_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def projects_add_favorite(ctx, project_id): +def projects_add_favorite(ctx, output_format, project_id): 'Add project to favorites.\n\n\x08\nMark a project as a favorite for your user so it appears pinned in the UI.' url = "/projects/add-favorite" body = {} body["project_id"] = project_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @projects_group.command("create") @click.argument("project_name") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def projects_create(ctx, project_name): +def projects_create(ctx, output_format, project_name): 'Create project.\n\n\x08\nCreate a new project in your organization. Requires the developer role.' url = "/projects/create" body = {} body["project_name"] = project_name result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @projects_group.command("list") +@click.option("-o", "--output", "output_format", type=click.Choice(["table", "yaml", "json"]), default="table", help="Output format.") @click.pass_context -def projects_list(ctx): +def projects_list(ctx, output_format): 'List projects.\n\n\x08\nList every project in your organization that you have access to.' url = "/projects" result = ctx.obj["client"].request("GET", url) - _output(result) + _table_output(result, output_format=output_format) @projects_group.command("remove-favorite") @click.argument("project_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def projects_remove_favorite(ctx, project_id): +def projects_remove_favorite(ctx, output_format, project_id): "Remove project from favorites.\n\n\x08\nRemove a project from your user's favorites." url = "/projects/remove-favorite" body = {} body["project_id"] = project_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) # ──────────────────────────────────────────────────────────────────── @@ -640,8 +668,9 @@ def prompts_group() -> None: @click.argument("prompt_name") @click.argument("prompt") @click.option("--tags", "tags", multiple=True, help='Optional tags (e.g. `production`, `staging`) to apply to the new commit. Tags move from any previous commit to this one.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def prompts_commit(ctx, project_id, prompt_name, prompt, tags): +def prompts_commit(ctx, output_format, project_id, prompt_name, prompt, tags): 'Commit a new prompt version.\n\n\x08\nAppend a new commit to a prompt. If the prompt does not yet exist it is created. Optionally apply tags to the new commit in the same call.' url = "/prompts/commit" body = {} @@ -651,7 +680,7 @@ def prompts_commit(ctx, project_id, prompt_name, prompt, tags): if tags: body["tags"] = list(tags) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @prompts_group.command("get") @@ -659,8 +688,9 @@ def prompts_commit(ctx, project_id, prompt_name, prompt, tags): @click.argument("prompt_name") @click.option("--commit-id", "commit_id", default=None, help='Specific commit SHA to fetch. Mutually exclusive with `tag`. When neither is provided the latest commit is returned.') @click.option("--tag", "tag", default=None, help='Tag to fetch (e.g. `production`). Mutually exclusive with `commit_id`.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def prompts_get(ctx, project_id, prompt_name, commit_id, tag): +def prompts_get(ctx, output_format, project_id, prompt_name, commit_id, tag): 'Fetch a prompt commit.\n\n\x08\nFetch a prompt by name. By default returns the latest commit; pass `commit_id` to pin a specific commit, or `tag` to resolve a named tag (e.g. `production`).' url = "/prompts/get" body = {} @@ -671,19 +701,20 @@ def prompts_get(ctx, project_id, prompt_name, commit_id, tag): if tag is not None: body["tag"] = tag result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @prompts_group.command("list") @click.argument("project_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["table", "yaml", "json"]), default="table", help="Output format.") @click.pass_context -def prompts_list(ctx, project_id): +def prompts_list(ctx, output_format, project_id): 'List prompts in a project.\n\n\x08\nList every prompt in a project with its latest commit timestamp and total version count.' url = "/prompts/list" body = {} body["project_id"] = project_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _table_output(result, output_format=output_format) @prompts_group.command("tag") @@ -691,8 +722,9 @@ def prompts_list(ctx, project_id): @click.argument("prompt_name") @click.argument("commit_id") @click.option("--tags", "tags", multiple=True, required=True, help='Tag names to add. Each tag is unique per prompt — re-tagging moves the tag to the new commit.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def prompts_tag(ctx, project_id, prompt_name, commit_id, tags): +def prompts_tag(ctx, output_format, project_id, prompt_name, commit_id, tags): 'Tag a prompt commit.\n\n\x08\nAttach one or more tags to a specific commit. Re-tagging moves the tag from any previous commit to the new one.' url = "/prompts/tag" body = {} @@ -702,15 +734,16 @@ def prompts_tag(ctx, project_id, prompt_name, commit_id, tags): if tags: body["tags"] = list(tags) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @prompts_group.command("untag") @click.argument("project_id") @click.argument("prompt_name") @click.option("--tags", "tags", multiple=True, required=True, help='Tag names to remove from this prompt.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def prompts_untag(ctx, project_id, prompt_name, tags): +def prompts_untag(ctx, output_format, project_id, prompt_name, tags): 'Remove tags from a prompt.\n\n\x08\nRemove one or more tags from a prompt. Returns the commit IDs that previously held the removed tags.' url = "/prompts/untag" body = {} @@ -719,21 +752,22 @@ def prompts_untag(ctx, project_id, prompt_name, tags): if tags: body["tags"] = list(tags) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @prompts_group.command("versions") @click.argument("project_id") @click.argument("prompt_name") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def prompts_versions(ctx, project_id, prompt_name): +def prompts_versions(ctx, output_format, project_id, prompt_name): 'List every commit of a prompt.\n\n\x08\nList every commit of a prompt in chronological order (newest first), including tags and authoring metadata.' url = "/prompts/versions" body = {} body["project_id"] = project_id body["prompt_name"] = prompt_name result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) # ──────────────────────────────────────────────────────────────────── @@ -749,15 +783,16 @@ def sessions_group() -> None: @sessions_group.command("get") @click.argument("project_id") @click.argument("session_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def sessions_get(ctx, project_id, session_id): +def sessions_get(ctx, output_format, project_id, session_id): """Get session detail.""" url = "/sessions/detail" body = {} body["project_id"] = project_id body["session_id"] = session_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @sessions_group.command("search") @@ -766,8 +801,9 @@ def sessions_get(ctx, project_id, session_id): @click.option("--time-range", "time_range", default=None, help='`{"start_time":|null,"end_time":|null}`. Either bound may be null to leave that side open. Invalid timestamps return 400.') @click.option("--pagination", "pagination", required=True, help='`{"limit":,"cursorSortValue":|null,"cursorItemId":|null}`.\n\nFirst page: pass null for both cursor fields. Each response returns `nextCursor:{sort_value,session_id}` (or null when `hasMore=false`); copy those into `cursorSortValue` and `cursorItemId` for the next page.') @click.option("--sort-by", "sort_by", default=None, help='`{"field":,"direction":"asc"|"desc"}` where `sort_field` is one of: `created_at`, `num_traces`, `latency`, `llm_cost`. Default when omitted: `{"field":"created_at","direction":"desc"}`.') +@click.option("-o", "--output", "output_format", type=click.Choice(["table", "yaml", "json"]), default="table", help="Output format.") @click.pass_context -def sessions_search(ctx, project_id, filters, time_range, pagination, sort_by): +def sessions_search(ctx, output_format, project_id, filters, time_range, pagination, sort_by): 'Search sessions.\n\n\x08\nFilter, sort, time-bound, and paginate sessions in a project.' url = "/sessions/search" body = {} @@ -779,35 +815,37 @@ def sessions_search(ctx, project_id, filters, time_range, pagination, sort_by): if sort_by is not None: body["sort_by"] = json.loads(sort_by) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _table_output(result, output_format=output_format) @sessions_group.command("trace-behaviors") @click.argument("project_id") @click.argument("session_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def sessions_trace_behaviors(ctx, project_id, session_id): +def sessions_trace_behaviors(ctx, output_format, project_id, session_id): """List behaviors observed across a session’s traces.""" url = "/sessions/trace-behaviors" body = {} body["project_id"] = project_id body["session_id"] = session_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @sessions_group.command("trace-ids") @click.argument("project_id") @click.argument("session_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def sessions_trace_ids(ctx, project_id, session_id): +def sessions_trace_ids(ctx, output_format, project_id, session_id): """List trace IDs in a session.""" url = "/sessions/trace-ids" body = {} body["project_id"] = project_id body["session_id"] = session_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) # ──────────────────────────────────────────────────────────────────── @@ -824,8 +862,9 @@ def traces_group() -> None: @click.argument("project_id") @click.argument("trace_id") @click.option("--tags", "tags", multiple=True, required=True, help='String tags to attach to the trace. Tags are additive — existing tags on the trace are preserved.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def traces_add_tags(ctx, project_id, trace_id, tags): +def traces_add_tags(ctx, output_format, project_id, trace_id, tags): 'Add tags to a trace.\n\n\x08\nAttach one or more string tags to an existing trace. Tags are additive — existing tags are preserved.' url = "/traces/add-tags" body = {} @@ -834,21 +873,22 @@ def traces_add_tags(ctx, project_id, trace_id, tags): if tags: body["tags"] = list(tags) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @traces_group.command("behaviors") @click.argument("project_id") @click.argument("trace_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def traces_behaviors(ctx, project_id, trace_id): +def traces_behaviors(ctx, output_format, project_id, trace_id): """List behaviors observed on a trace.""" url = "/traces/behaviors" body = {} body["project_id"] = project_id body["trace_id"] = trace_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @traces_group.command("evaluate") @@ -856,8 +896,9 @@ def traces_behaviors(ctx, project_id, trace_id): @click.option("--evaluate-all", "evaluate_all", default=None, type=bool, help='When true, re-evaluate every trace in the project. Mutually exclusive with `trace_ids`.') @click.option("--trace-ids", "trace_ids", multiple=True, help='Trace UUIDs to re-evaluate. Mutually exclusive with `evaluate_all`.') @click.option("--specific-judge-names", "specific_judge_names", multiple=True, help='Restrict evaluation to judges with these names. Omit to run every applicable judge.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def traces_evaluate(ctx, project_id, evaluate_all, trace_ids, specific_judge_names): +def traces_evaluate(ctx, output_format, project_id, evaluate_all, trace_ids, specific_judge_names): 'Re-evaluate traces.\n\n\x08\nQueue traces for re-evaluation by the project’s judges. Pass `trace_ids` to re-evaluate specific traces, or `evaluate_all: true` to re-evaluate every trace in the project.' url = "/traces/evaluate" body = {} @@ -869,21 +910,22 @@ def traces_evaluate(ctx, project_id, evaluate_all, trace_ids, specific_judge_nam if specific_judge_names: body["specific_judge_names"] = list(specific_judge_names) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @traces_group.command("get") @click.argument("project_id") @click.argument("trace_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def traces_get(ctx, project_id, trace_id): +def traces_get(ctx, output_format, project_id, trace_id): """Get a trace by ID.""" url = "/traces/detail" body = {} body["project_id"] = project_id body["trace_id"] = trace_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @traces_group.command("search") @@ -892,8 +934,9 @@ def traces_get(ctx, project_id, trace_id): @click.option("--sort-by", "sort_by", default=None, help='`{"field":,"direction":"asc"|"desc"}` where `sort_field` is one of: `created_at`, `span_name`, `duration`, `llm_cost`. Default when omitted: `{"field":"created_at","direction":"desc"}`. Any sort other than `created_at` desc requires `time_range.start_time` and a window between `start_time` and `end_time` of at most 7 days; use `created_at` desc sorting for broader ranges.') @click.option("--time-range", "time_range", default=None, help='`{"start_time":|null,"end_time":|null}`. Either bound may be null to leave that side open. Invalid timestamps return 400. For any sort other than `created_at` desc, `start_time` is required and the window between `start_time` and `end_time` must be at most 7 days.') @click.option("--pagination", "pagination", required=True, help='`{"limit":,"cursorSortValue":|null,"cursorItemId":|null}`.\n\nFirst page: pass null for both cursor fields. Each response returns `nextCursor:{sort_value,trace_id}` (or null when `hasMore=false`); copy those into `cursorSortValue` and `cursorItemId` for the next page.') +@click.option("-o", "--output", "output_format", type=click.Choice(["table", "yaml", "json"]), default="table", help="Output format.") @click.pass_context -def traces_search(ctx, project_id, filters, sort_by, time_range, pagination): +def traces_search(ctx, output_format, project_id, filters, sort_by, time_range, pagination): 'Search traces.\n\n\x08\nFilter, sort, time-bound, and paginate traces in a project. See each body field for the exact JSON shape it expects.' url = "/traces/search" body = {} @@ -906,49 +949,52 @@ def traces_search(ctx, project_id, filters, sort_by, time_range, pagination): body["time_range"] = json.loads(time_range) body["pagination"] = json.loads(pagination) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _table_output(result, output_format=output_format) @traces_group.command("span") @click.argument("project_id") @click.option("--spans", "spans", required=True, help='Up to 20 trace/span ID pairs to fetch span details for in a single request.') +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def traces_span(ctx, project_id, spans): +def traces_span(ctx, output_format, project_id, spans): 'Get span details.\n\n\x08\nFetch full details (inputs/outputs/attributes) for up to 20 specific spans across one or more traces.' url = "/traces/span" body = {} body["project_id"] = project_id body["spans"] = json.loads(spans) result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @traces_group.command("spans") @click.argument("project_id") @click.argument("trace_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def traces_spans(ctx, project_id, trace_id): +def traces_spans(ctx, output_format, project_id, trace_id): """List a trace’s spans.""" url = "/traces/spans" body = {} body["project_id"] = project_id body["trace_id"] = trace_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) @traces_group.command("tags") @click.argument("project_id") @click.argument("trace_id") +@click.option("-o", "--output", "output_format", type=click.Choice(["yaml", "json"]), default="yaml", help="Output format.") @click.pass_context -def traces_tags(ctx, project_id, trace_id): +def traces_tags(ctx, output_format, project_id, trace_id): """List a trace’s tags.""" url = "/traces/tags" body = {} body["project_id"] = project_id body["trace_id"] = trace_id result = ctx.obj["client"].request("POST", url, json_body=body) - _output(result) + _yaml_output(result, output_format=output_format) def register_commands(cli: click.Group) -> None: diff --git a/src/judgment_cli/ui.py b/src/judgment_cli/ui.py index 12e15d2..36bfd83 100644 --- a/src/judgment_cli/ui.py +++ b/src/judgment_cli/ui.py @@ -17,6 +17,105 @@ def output(data: object) -> None: click.echo(json.dumps(data, indent=2, default=str)) +def table_output(data: object, *, output_format: str = "table") -> None: + """Render list response as table, yaml, or json based on --output flag.""" + if output_format == "json": + output(data) + return + if output_format == "yaml": + yaml_output(data) + return + + items: list | None = None + extra: dict = {} + + if isinstance(data, list): + items = data + elif isinstance(data, dict): + for k, v in data.items(): + if isinstance(v, list): + items = v + extra = {ek: ev for ek, ev in data.items() if ek != k} + break + + if items is None: + output(data) + return + + if not items: + click.echo("(no items)") + return + + if not isinstance(items[0], dict): + for item in items: + click.echo(str(item)) + return + + _render_table(items) + + for k, v in extra.items(): + if v is not None and v != {} and v != []: + click.echo(f"\n{k}: {json.dumps(v, default=str)}") + + +def _render_table(items: list[dict]) -> None: + import io + from rich.console import Console + from rich.table import Table + import rich.box + + def _is_scalar(v: object) -> bool: + return v is None or isinstance(v, (str, int, float, bool)) + + all_cols = list(items[0].keys()) + cols = [c for c in all_cols if all(_is_scalar(item.get(c)) for item in items)] + hidden = len(all_cols) - len(cols) + + table = Table(show_header=True, header_style="bold cyan", box=rich.box.SIMPLE) + for col in cols: + table.add_column(col, overflow="fold", max_width=48, no_wrap=False) + + for item in items: + row = [] + for c in cols: + v = item.get(c) + if v is None: + row.append("-") + elif isinstance(v, bool): + row.append("yes" if v else "no") + else: + s = str(v) + row.append(s[:80] + "…" if len(s) > 80 else s) + table.add_row(*row) + + if hidden: + table.caption = f"{hidden} nested column(s) hidden — use -o json for full data" + + buf = io.StringIO() + Console(file=buf, force_terminal=True).print(table) + rendered = buf.getvalue() + + import os + import subprocess + env = os.environ.copy() + env.setdefault("LESS", "FRX") + pager = env.get("PAGER", "less") + try: + proc = subprocess.Popen(pager, shell=True, stdin=subprocess.PIPE, text=True, env=env) + proc.communicate(rendered) + except Exception: + sys.stdout.write(rendered) + + +def yaml_output(data: object, *, output_format: str = "yaml") -> None: + """Render response as YAML, or JSON if --output json.""" + if output_format == "json": + output(data) + return + import yaml + click.echo(yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False).rstrip()) + + def success(message: str) -> None: click.echo(message) diff --git a/tests/test_generated_commands.py b/tests/test_generated_commands.py index 369ead6..4c9be8b 100644 --- a/tests/test_generated_commands.py +++ b/tests/test_generated_commands.py @@ -97,7 +97,7 @@ def project_id() -> str: traces" if no project has all three. This makes the suite portable across orgs — no project ID needs to be hard-coded. """ - payload = _run("projects", "list") + payload = _run("projects", "list", "-o", "json") assert isinstance(payload, dict) projects = payload.get("projects") or [] if not projects: @@ -124,8 +124,9 @@ def project_id() -> str: "[]", "--pagination", pagination, + "-o", "json", ) - threads = _run("agent-threads", "list", pid, "--limit", "1") + threads = _run("agent-threads", "list", pid, "--limit", "1", "-o", "json") if ( isinstance(sessions, dict) and (sessions.get("data") or []) @@ -144,7 +145,7 @@ def trace_id(project_id: str) -> str: pagination = json.dumps( {"limit": 1, "cursorSortValue": None, "cursorItemId": None} ) - payload = _run("traces", "search", project_id, "--pagination", pagination) + payload = _run("traces", "search", project_id, "--pagination", pagination, "-o", "json") assert isinstance(payload, dict) traces = payload.get("data") or [] if not traces: @@ -157,7 +158,7 @@ def trace_id(project_id: str) -> str: @pytest.fixture(scope="session") def span_pair(project_id: str, trace_id: str) -> tuple[str, str]: """``(trace_id, span_id)`` for a real span — needed for ``traces span``.""" - payload = _run("traces", "spans", project_id, trace_id) + payload = _run("traces", "spans", project_id, trace_id, "-o", "json") spans = payload if isinstance(payload, list) else payload.get("spans") or payload.get("data") or [] if not spans: pytest.skip(f"Trace {trace_id} has no spans.") @@ -181,6 +182,7 @@ def session_id(project_id: str) -> str: "[]", "--pagination", pagination, + "-o", "json", ) assert isinstance(payload, dict) sessions = payload.get("data") or [] @@ -193,7 +195,7 @@ def session_id(project_id: str) -> str: @pytest.fixture(scope="session") def thread_id(project_id: str) -> str: - payload = _run("agent-threads", "list", project_id, "--limit", "1") + payload = _run("agent-threads", "list", project_id, "--limit", "1", "-o", "json") assert isinstance(payload, dict) threads = payload.get("threads") or [] if not threads: @@ -230,14 +232,14 @@ def test_root_help_lists_every_group(): def test_agent_threads_list(project_id: str): """Covers ``agent-threads list``.""" - payload = _run("agent-threads", "list", project_id, "--limit", "5") + payload = _run("agent-threads", "list", project_id, "--limit", "5", "-o", "json") assert isinstance(payload, dict) assert isinstance(payload.get("threads"), list) def test_agent_threads_get(project_id: str, thread_id: str): """Covers ``agent-threads get``.""" - payload = _run("agent-threads", "get", project_id, thread_id) + payload = _run("agent-threads", "get", project_id, thread_id, "-o", "json") assert isinstance(payload, dict) assert payload.get("id") == thread_id or payload.get("thread", {}).get("id") == thread_id or "messages" in payload or "transcript" in payload @@ -249,7 +251,7 @@ def test_agent_threads_get(project_id: str, thread_id: str): def test_automations_list(project_id: str): """Covers ``automations list``.""" - payload = _run("automations", "list", project_id) + payload = _run("automations", "list", project_id, "-o", "json") assert isinstance(payload, dict) assert isinstance(payload.get("automations"), list) @@ -270,6 +272,7 @@ def test_automations_lifecycle(project_id: str): conditions, "--combine-type", "all", + "-o", "json", ) rule_id = ( (created.get("rule_id") if isinstance(created, dict) else None) @@ -278,7 +281,7 @@ def test_automations_lifecycle(project_id: str): assert rule_id, f"could not extract rule_id from create response: {created!r}" try: - got = _run("automations", "get", project_id, rule_id) + got = _run("automations", "get", project_id, rule_id, "-o", "json") assert isinstance(got, dict) updated = _run( @@ -288,6 +291,7 @@ def test_automations_lifecycle(project_id: str): rule_id, "--description", "updated by cli e2e tests", + "-o", "json", ) assert isinstance(updated, dict) finally: @@ -301,7 +305,7 @@ def test_automations_lifecycle(project_id: str): def test_behaviors_list(project_id: str): """Covers ``behaviors list``.""" - payload = _run("behaviors", "list", project_id) + payload = _run("behaviors", "list", project_id, "-o", "json") assert isinstance(payload, dict) assert isinstance(payload.get("behaviors"), list) @@ -328,12 +332,13 @@ def test_behaviors_binary_lifecycle(project_id: str): "Is the response correct? Return true or false.", "--advanced-settings", _OFFLINE_SETTINGS, + "-o", "json", ) behavior_id = _extract_behavior_id(created) assert behavior_id, f"could not extract behavior id from {created!r}" try: - got = _run("behaviors", "get", project_id, behavior_id) + got = _run("behaviors", "get", project_id, behavior_id, "-o", "json") assert isinstance(got, dict) updated = _run( @@ -343,6 +348,7 @@ def test_behaviors_binary_lifecycle(project_id: str): behavior_id, "--description", "updated by cli e2e tests", + "-o", "json", ) assert isinstance(updated, dict) finally: @@ -377,12 +383,13 @@ def test_behaviors_classifier_lifecycle(project_id: str): options, "--advanced-settings", _OFFLINE_SETTINGS, + "-o", "json", ) behavior_id = _extract_behavior_id(created) assert behavior_id, f"could not extract behavior id from {created!r}" try: - got = _run("behaviors", "get", project_id, behavior_id) + got = _run("behaviors", "get", project_id, behavior_id, "-o", "json") assert isinstance(got, dict) finally: _run( @@ -426,7 +433,7 @@ def _extract_behavior_id(response: object) -> str | None: def test_docs_search(): """Covers ``docs search``.""" - payload = _run("docs", "search", "getting started", "--match-count", "3") + payload = _run("docs", "search", "getting started", "--match-count", "3", "-o", "json") assert isinstance(payload, dict) assert isinstance(payload.get("results"), list) @@ -450,7 +457,7 @@ def test_docs_search(): def test_judges_models(): """Covers ``judges models``.""" - payload = _run("judges", "models") + payload = _run("judges", "models", "-o", "json") assert isinstance(payload, dict) assert isinstance(payload.get("models"), list) assert payload["models"], "expected at least one judge model" @@ -458,7 +465,7 @@ def test_judges_models(): def test_judges_list(project_id: str): """Covers ``judges list``.""" - payload = _run("judges", "list", project_id) + payload = _run("judges", "list", project_id, "-o", "json") assert isinstance(payload, dict) assert isinstance(payload.get("judges"), list) @@ -467,7 +474,7 @@ def test_judges_lifecycle(project_id: str): """Covers ``judges create``, ``get``, ``get-settings``, ``update``, ``update-settings``, ``set-tag`` (add + remove), and ``delete``.""" # Pick any available model so the create call succeeds. - models = _run("judges", "models").get("models") + models = _run("judges", "models", "-o", "json").get("models") assert models, "no judge models available" model_id = models[0].get("id") or models[0].get("model_name") or models[0].get("name") assert model_id, f"no model id field on {models[0]!r}" @@ -486,15 +493,16 @@ def test_judges_lifecycle(project_id: str): "0", "--max-score", "1", + "-o", "json", ) judge_id = _extract_judge_id(created) assert judge_id, f"could not extract judge id from {created!r}" try: - got = _run("judges", "get", project_id, judge_id) + got = _run("judges", "get", project_id, judge_id, "-o", "json") assert isinstance(got, dict) - settings = _run("judges", "get-settings", project_id, judge_id) + settings = _run("judges", "get-settings", project_id, judge_id, "-o", "json") assert isinstance(settings, dict) _run( @@ -576,7 +584,7 @@ def _extract_judge_id(response: object) -> str | None: def test_projects_list(): """Covers ``projects list``.""" - payload = _run("projects", "list") + payload = _run("projects", "list", "-o", "json") assert isinstance(payload, dict) assert isinstance(payload.get("projects"), list) @@ -589,15 +597,15 @@ def test_projects_create_and_favorite(): UI as needed (search for ``cli-e2e-project-`` prefix). """ name = _unique("cli-e2e-project") - created = _run("projects", "create", name) + created = _run("projects", "create", name, "-o", "json") assert isinstance(created, dict) project = created.get("project") or created new_pid = project.get("project_id") or project.get("id") assert new_pid, f"could not extract new project id from {created!r}" - fav = _run("projects", "add-favorite", new_pid) + fav = _run("projects", "add-favorite", new_pid, "-o", "json") assert isinstance(fav, dict) - unfav = _run("projects", "remove-favorite", new_pid) + unfav = _run("projects", "remove-favorite", new_pid, "-o", "json") assert isinstance(unfav, dict) @@ -619,26 +627,27 @@ def test_sessions_search(project_id: str): "[]", "--pagination", pagination, + "-o", "json", ) assert isinstance(payload, dict) def test_sessions_get(project_id: str, session_id: str): """Covers ``sessions get``.""" - payload = _run("sessions", "get", project_id, session_id) + payload = _run("sessions", "get", project_id, session_id, "-o", "json") assert isinstance(payload, dict) def test_sessions_trace_ids(project_id: str, session_id: str): """Covers ``sessions trace-ids``.""" - payload = _run("sessions", "trace-ids", project_id, session_id) + payload = _run("sessions", "trace-ids", project_id, session_id, "-o", "json") assert isinstance(payload, dict) assert "trace_ids" in payload def test_sessions_trace_behaviors(project_id: str, session_id: str): """Covers ``sessions trace-behaviors``.""" - payload = _run("sessions", "trace-behaviors", project_id, session_id) + payload = _run("sessions", "trace-behaviors", project_id, session_id, "-o", "json") assert isinstance(payload, (dict, list)) @@ -653,20 +662,20 @@ def test_traces_search(project_id: str): {"limit": 5, "cursorSortValue": None, "cursorItemId": None} ) payload = _run( - "traces", "search", project_id, "--pagination", pagination + "traces", "search", project_id, "--pagination", pagination, "-o", "json" ) assert isinstance(payload, dict) def test_traces_get(project_id: str, trace_id: str): """Covers ``traces get``.""" - payload = _run("traces", "get", project_id, trace_id) + payload = _run("traces", "get", project_id, trace_id, "-o", "json") assert isinstance(payload, dict) def test_traces_spans(project_id: str, trace_id: str): """Covers ``traces spans``.""" - payload = _run("traces", "spans", project_id, trace_id) + payload = _run("traces", "spans", project_id, trace_id, "-o", "json") assert isinstance(payload, (dict, list)) @@ -674,19 +683,19 @@ def test_traces_span(project_id: str, span_pair: tuple[str, str]): """Covers ``traces span``.""" tid, sid = span_pair spans = json.dumps([{"trace_id": tid, "span_id": sid}]) - payload = _run("traces", "span", project_id, "--spans", spans) + payload = _run("traces", "span", project_id, "--spans", spans, "-o", "json") assert isinstance(payload, (dict, list)) def test_traces_tags(project_id: str, trace_id: str): """Covers ``traces tags``.""" - payload = _run("traces", "tags", project_id, trace_id) + payload = _run("traces", "tags", project_id, trace_id, "-o", "json") assert isinstance(payload, (dict, list)) def test_traces_behaviors(project_id: str, trace_id: str): """Covers ``traces behaviors``.""" - payload = _run("traces", "behaviors", project_id, trace_id) + payload = _run("traces", "behaviors", project_id, trace_id, "-o", "json") assert isinstance(payload, (dict, list)) @@ -697,7 +706,7 @@ def test_traces_add_tags(project_id: str, trace_id: str): the trace with churn-y test tags. """ tag = _unique("cli-e2e-tag") - payload = _run("traces", "add-tags", project_id, trace_id, "--tags", tag) + payload = _run("traces", "add-tags", project_id, trace_id, "--tags", tag, "-o", "json") assert isinstance(payload, (dict, list)) @@ -716,5 +725,6 @@ def test_traces_evaluate(project_id: str, trace_id: str): trace_id, "--specific-judge-names", "__cli_e2e_nonexistent_judge__", + "-o", "json", ) assert isinstance(payload, (dict, list, str)) diff --git a/uv.lock b/uv.lock index 0c63cc2..4495890 100644 --- a/uv.lock +++ b/uv.lock @@ -173,7 +173,7 @@ wheels = [ [[package]] name = "judgment-cli" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -182,6 +182,8 @@ dependencies = [ { name = "pathspec" }, { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "platformdirs", version = "4.9.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyyaml" }, + { name = "rich" }, ] [package.optional-dependencies] @@ -199,9 +201,50 @@ requires-dist = [ { name = "pathspec", specifier = ">=0.11" }, { name = "platformdirs", specifier = ">=4.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", specifier = ">=13.0" }, ] provides-extras = ["dev"] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -304,6 +347,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "tomli" version = "2.4.1"