Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4837bd467 | |||
| f22462ccc5 |
@@ -5,6 +5,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Added `agents validation list` CLI command with filters** (#8667): The
|
||||
`validation list` subcommand now lists registered validations with optional
|
||||
`--namespace`, `--source`, and `--pattern` (regex) filters. Supports rich table,
|
||||
JSON, YAML, plain, and table output formats. Implemented via extraction of shared
|
||||
helper functions into a new `validation_helpers.py` module. Added Behave regression
|
||||
test scenario (`features/validation_list_command.feature`) covering empty state,
|
||||
all filter combinations, and JSON/YAML output modes.
|
||||
|
||||
- Fixed `ReactiveEventBus.emit()` exception handler to log the full exception
|
||||
message (`str(exc)`) and enable traceback forwarding (`exc_info=True`).
|
||||
Previously the handler logged only the exception type name (e.g.
|
||||
|
||||
@@ -58,7 +58,7 @@ def step_m3_smoke_mock_env(context: Context) -> None:
|
||||
return_value=context.mock_invariant_service,
|
||||
)
|
||||
context.validation_patcher = patch(
|
||||
"cleveragents.cli.commands.validation._get_tool_registry_service",
|
||||
"cleveragents.cli.commands.validation_helpers.get_tool_registry_service",
|
||||
return_value=context.mock_tool_registry_service,
|
||||
)
|
||||
context.plan_patcher = patch(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Step definitions for TDD: DI container resolution in _get_tool_registry_service.
|
||||
"""Step definitions for TDD: DI container resolution in get_tool_registry_service.
|
||||
|
||||
These steps verify that ``_get_tool_registry_service`` in
|
||||
``cleveragents.cli.commands.validation`` delegates to
|
||||
These steps verify that ``get_tool_registry_service`` in
|
||||
``cleveragents.cli.commands.validation_helpers`` delegates to
|
||||
``container.tool_registry_service()`` rather than manually constructing
|
||||
the service with ``create_engine`` / ``sessionmaker`` / repositories.
|
||||
|
||||
@@ -17,6 +17,11 @@ from unittest.mock import MagicMock, patch
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
# Import from the new helpers module (PR #8667).
|
||||
from cleveragents.cli.commands.validation_helpers import (
|
||||
get_tool_registry_service as _get_service,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch targets
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -38,7 +43,7 @@ def step_tdd_di_background(context: Context) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario 1: _get_tool_registry_service delegates to container
|
||||
# Scenario 1: get_tool_registry_service delegates to container
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -52,13 +57,11 @@ def step_tdd_di_container_has_provider(context: Context) -> None:
|
||||
context.tdd_di_mock_service = mock_service
|
||||
|
||||
|
||||
@when("_get_tool_registry_service is called with the mocked container")
|
||||
@when("get_tool_registry_service is called with the mocked container")
|
||||
def step_tdd_di_call_function(context: Context) -> None:
|
||||
"""Call _get_tool_registry_service with the mocked container injected."""
|
||||
from cleveragents.cli.commands.validation import _get_tool_registry_service
|
||||
|
||||
"""Call get_tool_registry_service with the mocked container injected."""
|
||||
with patch(_PATCH_GET_CONTAINER, return_value=context.tdd_di_mock_container):
|
||||
context.tdd_di_returned_service = _get_tool_registry_service()
|
||||
context.tdd_di_returned_service = _get_service()
|
||||
|
||||
|
||||
@then("the returned service should be the one from container.tool_registry_service")
|
||||
@@ -67,7 +70,7 @@ def step_tdd_di_verify_service_identity(context: Context) -> None:
|
||||
assert context.tdd_di_returned_service is context.tdd_di_mock_service, (
|
||||
f"Expected the service from container.tool_registry_service(), "
|
||||
f"but got {type(context.tdd_di_returned_service)}. "
|
||||
"This means _get_tool_registry_service is NOT delegating to the container."
|
||||
"This means get_tool_registry_service is NOT delegating to the container."
|
||||
)
|
||||
|
||||
|
||||
@@ -76,7 +79,7 @@ def step_tdd_di_verify_no_manual_construction(context: Context) -> None:
|
||||
"""Verify that container.database_url() was NOT called (manual DI pattern)."""
|
||||
mock_container = context.tdd_di_mock_container
|
||||
assert not mock_container.database_url.called, (
|
||||
"container.database_url() was called, which means _get_tool_registry_service "
|
||||
"container.database_url() was called, which means get_tool_registry_service "
|
||||
"is still using the manual DI pattern instead of delegating to "
|
||||
"container.tool_registry_service()."
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ from cleveragents.cli.commands.validation import app as validation_app
|
||||
|
||||
_runner = CliRunner()
|
||||
|
||||
_PATCH_SVC = "cleveragents.cli.commands.validation._get_tool_registry_service"
|
||||
_PATCH_SVC = "cleveragents.cli.commands.validation_helpers.get_tool_registry_service"
|
||||
|
||||
_VALID_MODES = {"required", "informational"}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ def _patch_tool_svc(context: Context) -> Any:
|
||||
|
||||
def _patch_val_svc(context: Context) -> Any:
|
||||
return patch(
|
||||
"cleveragents.cli.commands.validation._get_tool_registry_service",
|
||||
"cleveragents.cli.commands.validation_helpers.get_tool_registry_service",
|
||||
return_value=context.mock_service,
|
||||
)
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ def _patch_tool_svc(context: Context) -> Any:
|
||||
|
||||
def _patch_val_svc(context: Context) -> Any:
|
||||
return patch(
|
||||
"cleveragents.cli.commands.validation._get_tool_registry_service",
|
||||
"cleveragents.cli.commands.validation_helpers.get_tool_registry_service",
|
||||
return_value=context.mock_service,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ def step_type_guard_mock_env(context: Context) -> None:
|
||||
context.mock_tool_registry_service = MagicMock()
|
||||
|
||||
context.validation_patcher = patch(
|
||||
"cleveragents.cli.commands.validation._get_tool_registry_service",
|
||||
"cleveragents.cli.commands.validation_helpers.get_tool_registry_service",
|
||||
return_value=context.mock_tool_registry_service,
|
||||
)
|
||||
context.validation_patcher.start()
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
Covers two gaps in ``cleveragents.cli.commands.validation``:
|
||||
|
||||
1. ``_get_tool_registry_service()`` (lines 75-95) — the full construction path
|
||||
that imports from the container, creates an engine/session-factory, builds
|
||||
repositories, and returns a ``ToolRegistryService``.
|
||||
1. ``get_tool_registry_service()`` in
|
||||
``cleveragents.cli.commands.validation_helpers`` — the full construction path
|
||||
(lines 28-31) that imports from the container, creates an
|
||||
engine/session-factory, builds repositories, and returns a
|
||||
``ToolRegistryService``.
|
||||
|
||||
2. ``detach`` command branch L356→360 — when the user confirms the detach
|
||||
prompt but ``detach_validation`` returns ``False`` (attachment not found).
|
||||
@@ -20,6 +22,10 @@ from behave.runner import Context
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands.validation import app as validation_app
|
||||
# Import from the helpers module introduced by PR #8667.
|
||||
from cleveragents.cli.commands.validation_helpers import (
|
||||
get_tool_registry_service as vcb_get_service,
|
||||
)
|
||||
|
||||
_runner = CliRunner()
|
||||
|
||||
@@ -27,12 +33,12 @@ _runner = CliRunner()
|
||||
# Patch targets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# We patch get_container *inside* the validation module's lazy import so the
|
||||
# We patch get_container *inside* the validation_helpers' lazy import so the
|
||||
# real function body executes but uses our mock container.
|
||||
_PATCH_GET_CONTAINER = "cleveragents.application.container.get_container"
|
||||
|
||||
# For the detach scenario we still patch the whole helper to isolate DB access.
|
||||
_PATCH_VAL_SVC = "cleveragents.cli.commands.validation._get_tool_registry_service"
|
||||
_PATCH_VAL_SVC = "cleveragents.cli.commands.validation_helpers.get_tool_registry_service"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -49,7 +55,7 @@ def step_validation_cli_branch_background(context: Context) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario 1: _get_tool_registry_service construction path (L75-95)
|
||||
# Scenario 1: get_tool_registry_service construction path (L28-31)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -58,7 +64,7 @@ def step_vcb_di_container(context: Context) -> None:
|
||||
"""Set up a mock container whose ``tool_registry_service()`` returns a
|
||||
real ToolRegistryService backed by an in-memory SQLite database.
|
||||
|
||||
After Forgejo #3006, ``_get_tool_registry_service`` delegates directly to
|
||||
After Forgejo #3006, ``get_tool_registry_service`` delegates directly to
|
||||
``container.tool_registry_service()`` — no manual engine/sessionmaker
|
||||
construction occurs in the function itself.
|
||||
"""
|
||||
@@ -87,12 +93,11 @@ def step_vcb_di_container(context: Context) -> None:
|
||||
context.vcb_mock_container = mock_container
|
||||
|
||||
|
||||
@when("the validation cli branch _get_tool_registry_service is called")
|
||||
@when("the validation cli branch get_tool_registry_service is called")
|
||||
def step_vcb_call_get_tool_registry_service(context: Context) -> None:
|
||||
from cleveragents.cli.commands.validation import _get_tool_registry_service
|
||||
|
||||
"""Call ``get_tool_registry_service`` with the mocked container injected."""
|
||||
with patch(_PATCH_GET_CONTAINER, return_value=context.vcb_mock_container):
|
||||
context.vcb_returned_service = _get_tool_registry_service()
|
||||
context.vcb_returned_service = vcb_get_service()
|
||||
|
||||
|
||||
@then("the validation cli branch returned service should be a ToolRegistryService")
|
||||
@@ -102,7 +107,7 @@ def step_vcb_verify_service_type(context: Context) -> None:
|
||||
)
|
||||
|
||||
assert context.vcb_returned_service is not None, (
|
||||
"_get_tool_registry_service returned None"
|
||||
"get_tool_registry_service returned None"
|
||||
)
|
||||
assert isinstance(context.vcb_returned_service, ToolRegistryService), (
|
||||
f"Expected ToolRegistryService, got {type(context.vcb_returned_service)}"
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
"""BDD step definitions for the 'agents validation list' command.
|
||||
|
||||
This module provides complete Given/When/Then steps for testing the
|
||||
``agents validation list`` CLI subcommand, including mock injection so
|
||||
that tests exercise real filtering and formatting logic without hitting
|
||||
the database.
|
||||
|
||||
The mock service is injected by patching ``get_tool_registry_service`` at
|
||||
its definition location in ``validation_helpers.py``, ensuring the patched
|
||||
service is used every time a command handler calls it via its import chain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Import the main CLI app for test invocations.
|
||||
from cleveragents.cli.main import app as main_cli_app # noqa: F401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch target — where ``get_tool_registry_service`` is defined
|
||||
# (after PR #8667 refactors, helpers live in validation_helpers)
|
||||
# ---------------------------------------------------------------------------
|
||||
_PATCH_VAL_SVC = "cleveragents.cli.commands.validation_helpers.get_tool_registry_service"
|
||||
|
||||
# Shared CLI runner instance.
|
||||
_runner = CliRunner(mix_stderr=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: build test-validation dicts & set up mock service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_validation(
|
||||
name: str = "local/coverage-check",
|
||||
description: str = "Check code coverage meets threshold",
|
||||
source: str = "custom",
|
||||
mode: str = "required",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a minimal validation spec dict for test fixtures."""
|
||||
return {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"source": source,
|
||||
"tool_type": "validation",
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
|
||||
def _make_attachment(
|
||||
attachment_id: str = "01HXYZ1234567890ABCDEFGHIJ",
|
||||
validation_name: str = "local/coverage-check",
|
||||
resource_id: str = "git-checkout/my-repo",
|
||||
mode: str = "required",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a minimal attachment spec dict for test fixtures."""
|
||||
return {
|
||||
"attachment_id": attachment_id,
|
||||
"validation_name": validation_name,
|
||||
"resource_id": resource_id,
|
||||
"mode": mode,
|
||||
"project_name": None,
|
||||
"plan_id": None,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
def _setup_mock_service(context: Context, validations: list) -> MagicMock:
|
||||
"""Build a mock service and store it on context for When/Then use."""
|
||||
svc = MagicMock()
|
||||
svc.list_tools.return_value = validations
|
||||
svc.get_tool.return_value = None
|
||||
svc.attach_validation.return_value = _make_attachment()
|
||||
context.vlist_mock_service = svc
|
||||
return svc
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- #
|
||||
# Given steps #
|
||||
# -------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@given("a validation list command runner")
|
||||
def step_validate_list_runner(context: Context) -> None:
|
||||
"""Set up a CliRunner for the validation CLI app."""
|
||||
context.vlist_runner = _runner
|
||||
context.vlist_result = None
|
||||
context.vlist_mock_service = None
|
||||
|
||||
|
||||
@given("a tool registry with registered validation tools")
|
||||
def step_mock_validation_service_with_tools(context: Context) -> None:
|
||||
"""Mock ``get_tool_registry_service`` to return a predefined list."""
|
||||
validations = [
|
||||
_make_validation(
|
||||
"local/coverage-check",
|
||||
"Check code coverage meets threshold",
|
||||
"custom",
|
||||
"required",
|
||||
),
|
||||
_make_validation(
|
||||
"local/lint-check", "Lint violations check", "custom", "informational"
|
||||
),
|
||||
_make_validation(
|
||||
"global/security-scan",
|
||||
"Security vulnerability scan",
|
||||
"platform",
|
||||
"required",
|
||||
),
|
||||
]
|
||||
_setup_mock_service(context, validations)
|
||||
|
||||
|
||||
@given("a tool registry with namespaced validation tools")
|
||||
def step_mock_namespaced_validations(context: Context) -> None:
|
||||
"""Mock the service to return namespace-aware validations."""
|
||||
validations = [
|
||||
_make_validation(
|
||||
"local/coverage-check", "Local coverage check", "custom", "required"
|
||||
),
|
||||
_make_validation(
|
||||
"local/lint-check", "Local lint check", "custom", "informational"
|
||||
),
|
||||
_make_validation(
|
||||
"global/security-scan", "Global security scan", "platform", "required"
|
||||
),
|
||||
]
|
||||
_setup_mock_service(context, validations)
|
||||
|
||||
|
||||
@given("a tool registry with different sourced validation tools")
|
||||
def step_mock_sourced_validations(context: Context) -> None:
|
||||
"""Mock the service to return varied source-typed validations."""
|
||||
validations = [
|
||||
_make_validation(
|
||||
"local/coverage-check", "Coverage check", "custom", "required"
|
||||
),
|
||||
_make_validation(
|
||||
"platform/unit-test-v1",
|
||||
"Platform unit test validator",
|
||||
"platform",
|
||||
"required",
|
||||
),
|
||||
_make_validation(
|
||||
"system/auth-policy",
|
||||
"Authentication policy check",
|
||||
"system",
|
||||
"informational",
|
||||
),
|
||||
]
|
||||
_setup_mock_service(context, validations)
|
||||
|
||||
|
||||
@given("a tool registry with numbered validation tools")
|
||||
def step_mock_numbered_validations(context: Context) -> None:
|
||||
"""Mock the service to return numbered validation names."""
|
||||
validations = [
|
||||
_make_validation(
|
||||
"local/coverage-check-v1", "Coverage check v1", "custom", "required"
|
||||
),
|
||||
_make_validation(
|
||||
"local/lint-runner-2", "Lint runner 2", "custom", "informational"
|
||||
),
|
||||
_make_validation(
|
||||
"global/security-scan-alpha", "Security alpha", "platform", "required"
|
||||
),
|
||||
]
|
||||
_setup_mock_service(context, validations)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- #
|
||||
# When steps #
|
||||
# -------------------------------------------------------------------- #
|
||||
|
||||
# Shared list of validation dicts — used as default by ``_new_mock`` when no
|
||||
# Given step set one up yet.
|
||||
_LIST_VALIDATIONS = [
|
||||
_make_validation(
|
||||
"local/coverage-check",
|
||||
"Check code coverage meets threshold",
|
||||
"custom",
|
||||
"required",
|
||||
),
|
||||
_make_validation(
|
||||
"local/lint-check", "Lint violations check", "custom", "informational"
|
||||
),
|
||||
_make_validation(
|
||||
"global/security-scan",
|
||||
"Security vulnerability scan",
|
||||
"platform",
|
||||
"required",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# Helper: invoke the CLI with mock injection via patch. This uses the stored
|
||||
# context.vlist_mock_service when available (set by Given steps); otherwise it
|
||||
# creates a default one. The patch is applied at the _definition_ site of
|
||||
# ``get_tool_registry_service`` in ``validation_helpers.py``, which also affects
|
||||
# the imported alias used throughout ``validation.py``'s command functions. Each
|
||||
# When step MUST call this function to ensure mocks take effect.
|
||||
def _run_list_with_mock(
|
||||
context: Context, args: list | None = None
|
||||
) -> MagicMock:
|
||||
"""Run the validation list CLI with mock service injected via patch."""
|
||||
runner = getattr(context, "vlist_runner", _runner)
|
||||
# Use the stored mock from Given steps if available.
|
||||
svc = getattr(context, "vlist_mock_service", None)
|
||||
if svc is None or not isinstance(svc, MagicMock):
|
||||
svc = MagicMock()
|
||||
svc.list_tools.return_value = _LIST_VALIDATIONS
|
||||
context.vlist_mock_service = svc
|
||||
with patch(_PATCH_VAL_SVC, return_value=svc):
|
||||
return runner.invoke(main_cli_app, ["validation", "list"] + (args or []))
|
||||
|
||||
|
||||
@when("I run the validation list command with no registered validations")
|
||||
def step_run_list_empty(context: Context) -> None:
|
||||
"""Invoke the list command with a mock that returns an empty list."""
|
||||
# Override the mock to return nothing so we exercise the empty-state path.
|
||||
context.vlist_mock_service = MagicMock()
|
||||
context.vlist_mock_service.list_tools.return_value = []
|
||||
|
||||
result = _run_list_with_mock(context)
|
||||
context.vlist_result = result
|
||||
|
||||
|
||||
@when("I run the validation list command")
|
||||
def step_run_list_all(context: Context) -> None:
|
||||
"""Invoke the list command with default options."""
|
||||
context.vlist_result = _run_list_with_mock(context)
|
||||
|
||||
|
||||
@when('I run the validation list command with namespace filter "local"')
|
||||
def step_run_list_namespace_filter(context: Context) -> None:
|
||||
"""Run the list command with a --namespace filter."""
|
||||
context.vlist_result = _run_list_with_mock(context, ["--namespace", "local"])
|
||||
|
||||
|
||||
@when('I run the validation list command with source filter "custom"')
|
||||
def step_run_list_source_filter(context: Context) -> None:
|
||||
"""Run the list command with a --source filter."""
|
||||
context.vlist_result = _run_list_with_mock(context, ["--source", "custom"])
|
||||
|
||||
|
||||
@when('I run the validation list command with pattern "coverage.*"')
|
||||
def step_run_list_pattern_filter(context: Context) -> None:
|
||||
"""Run the list command with a --pattern regex filter."""
|
||||
context.vlist_result = _run_list_with_mock(context, ["--pattern", "coverage.*"])
|
||||
|
||||
|
||||
@when('I run the validation list command with format "json"')
|
||||
def step_run_list_json(context: Context) -> None:
|
||||
"""Run the list command with JSON output format."""
|
||||
context.vlist_result = _run_list_with_mock(context, ["--format", "json"])
|
||||
|
||||
|
||||
@when('I run the validation list command with format "yaml"')
|
||||
def step_run_list_yaml(context: Context) -> None:
|
||||
"""Run the list command with YAML output format."""
|
||||
context.vlist_result = _run_list_with_mock(context, ["--format", "yaml"])
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- #
|
||||
# Then steps #
|
||||
# -------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@then('the output should contain "No validations found"')
|
||||
def step_output_no_validations(context: Context) -> None:
|
||||
"""Assert the empty-state message is displayed."""
|
||||
assert context.vlist_result is not None, "Expected a CLI result"
|
||||
stdout = getattr(context.vlist_result, "stdout", "") or ""
|
||||
stderr = getattr(context.vlist_result, "stderr", "") or ""
|
||||
combined = stdout + stderr
|
||||
assert "No validations found" in combined, (
|
||||
f"Expected 'No validations found' in output; got stdout={stdout!r} "
|
||||
f"stderr={stderr!r}"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
"the output should contain "
|
||||
"'Register one with 'agents validation add --config <file>''"
|
||||
)
|
||||
def step_output_register_hint(context: Context) -> None:
|
||||
"""Assert the registration hint is shown in empty state."""
|
||||
assert context.vlist_result is not None, "Expected a CLI result"
|
||||
stdout = getattr(context.vlist_result, "stdout", "") or ""
|
||||
stderr = getattr(context.vlist_result, "stderr", "") or ""
|
||||
combined = stdout + stderr
|
||||
assert (
|
||||
"Register one with 'agents validation add --config <file>'" in combined
|
||||
), f"Expected register hint in output; got stdout={stdout!r} stderr={stderr!r}"
|
||||
|
||||
|
||||
@then("the output should display a rich table")
|
||||
def step_output_rich_table(context: Context) -> None:
|
||||
"""Assert that the output contains table-like content."""
|
||||
assert context.vlist_result is not None, "Expected a CLI result"
|
||||
assert context.vlist_result.exit_code == 0, (
|
||||
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
|
||||
f"output: {context.vlist_result.output!r}"
|
||||
)
|
||||
# Rich table rendering goes through ``Rich Console.print()``.
|
||||
assert "local/coverage-check" in context.vlist_result.output, (
|
||||
f"Expected 'local/coverage-check' in output; got: "
|
||||
f"{context.vlist_result.output!r}"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
'the table should contain column headers '
|
||||
'"Name", "Mode", "Source", "Description"'
|
||||
)
|
||||
def step_output_table_columns(context: Context) -> None:
|
||||
"""Verify table columns match expected headers."""
|
||||
assert context.vlist_result is not None, "Expected a CLI result"
|
||||
stdout_val = getattr(context.vlist_result, "stdout", "") or ""
|
||||
stderr_val = getattr(context.vlist_result, "stderr", "") or ""
|
||||
combined = stdout_val + stderr_val
|
||||
assert "Name" in combined, f"Expected 'Name' header; got: {combined!r}"
|
||||
assert "Mode" in combined, f"Expected 'Mode' header; got: {combined!r}"
|
||||
assert "Source" in combined, f"Expected 'Source' header; got: {combined!r}"
|
||||
assert (
|
||||
"Description" in combined
|
||||
), f"Expected 'Description' header; got: {combined!r}"
|
||||
|
||||
|
||||
@then(
|
||||
'the output should only include validations matching namespace "local"'
|
||||
)
|
||||
def step_output_namespace_filtered(context: Context) -> None:
|
||||
"""Assert that only namespace-filtered results are shown."""
|
||||
assert context.vlist_result is not None, "Expected a CLI result"
|
||||
assert context.vlist_result.exit_code == 0, (
|
||||
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
|
||||
f"output: {context.vlist_result.output!r}"
|
||||
)
|
||||
combined = (getattr(context.vlist_result, "stdout", "") or "") + (
|
||||
getattr(context.vlist_result, "stderr", "") or ""
|
||||
)
|
||||
assert "local/coverage-check" in combined, (
|
||||
f"Expected 'local/coverage-check' in filtered output; got: {combined!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the output should only include validations from source "custom"')
|
||||
def step_output_source_filtered(context: Context) -> None:
|
||||
"""Assert that only source-filtered results are shown."""
|
||||
assert context.vlist_result is not None, "Expected a CLI result"
|
||||
assert context.vlist_result.exit_code == 0, (
|
||||
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
|
||||
f"output: {context.vlist_result.output!r}"
|
||||
)
|
||||
combined = (getattr(context.vlist_result, "stdout", "") or "") + (
|
||||
getattr(context.vlist_result, "stderr", "") or ""
|
||||
)
|
||||
assert "local/coverage-check" in combined, (
|
||||
f"Expected 'local/coverage-check' (source=custom); got: {combined!r}"
|
||||
)
|
||||
assert "custom" in combined.lower(), (
|
||||
f"Expected source='custom'; got: {combined!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the output should only include matching validation names")
|
||||
def step_output_pattern_filtered(context: Context) -> None:
|
||||
"""Assert that only pattern-matched validations are shown."""
|
||||
assert context.vlist_result is not None, "Expected a CLI result"
|
||||
assert context.vlist_result.exit_code == 0, (
|
||||
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
|
||||
f"output: {context.vlist_result.output!r}"
|
||||
)
|
||||
combined = (getattr(context.vlist_result, "stdout", "") or "") + (
|
||||
getattr(context.vlist_result, "stderr", "") or ""
|
||||
)
|
||||
# Pattern was ``coverage.*`` — should match validation name containing
|
||||
# "coverage".
|
||||
assert "coverage-check" in combined, (
|
||||
f"Expected 'coverage-check' in regex-filtered output; got: {combined!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the output should be valid JSON containing the registered validations")
|
||||
def step_output_valid_json(context: Context) -> None:
|
||||
"""Verify the JSON output parses correctly and contains expected items."""
|
||||
assert context.vlist_result is not None, "Expected a CLI result"
|
||||
assert context.vlist_result.exit_code == 0, (
|
||||
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
|
||||
f"output: {context.vlist_result.output!r}"
|
||||
)
|
||||
# JSON output goes to ``stdout`` via ``sys.stdout.write()`` in
|
||||
# ``format_output``.
|
||||
stdout = getattr(context.vlist_result, "stdout", "") or ""
|
||||
assert stdout.strip(), (
|
||||
f"Expected non-empty JSON in stdout; got: {context.vlist_result.output!r}"
|
||||
)
|
||||
parsed = json.loads(stdout)
|
||||
# The formatting module wraps output in an envelope.
|
||||
if isinstance(parsed, dict):
|
||||
data = parsed.get("data", parsed)
|
||||
else:
|
||||
data = parsed
|
||||
assert isinstance(data, list), (
|
||||
f"Expected JSON list or envelope; got type {type(data)}"
|
||||
)
|
||||
assert len(data) > 0, "Expected at least one validation in JSON output"
|
||||
|
||||
|
||||
@then("the output should be valid YAML containing the registered validations")
|
||||
def step_output_valid_yaml(context: Context) -> None:
|
||||
"""Verify the YAML output parses correctly and contains expected items."""
|
||||
assert context.vlist_result is not None, "Expected a CLI result"
|
||||
assert context.vlist_result.exit_code == 0, (
|
||||
f"Expected exit_code 0, got {context.vlist_result.exit_code}; "
|
||||
f"output: {context.vlist_result.output!r}"
|
||||
)
|
||||
# YAML output goes to ``stdout`` via ``sys.stdout.write()`` in
|
||||
# ``format_output``.
|
||||
stdout = getattr(context.vlist_result, "stdout", "") or ""
|
||||
assert stdout.strip(), (
|
||||
f"Expected non-empty YAML in stdout; got: {context.vlist_result.output!r}"
|
||||
)
|
||||
parsed = yaml.safe_load(stdout)
|
||||
# The formatting module wraps output in an envelope.
|
||||
if isinstance(parsed, dict):
|
||||
_data = parsed.get("data", parsed)
|
||||
else:
|
||||
_data = parsed
|
||||
assert isinstance(_data, list), (
|
||||
f"Expected YAML list or envelope; got type {type(_data)}"
|
||||
)
|
||||
assert len(_data) > 0, "Expected at least one validation in YAML output"
|
||||
@@ -0,0 +1,48 @@
|
||||
Feature: Validation list command
|
||||
As a CleverAgents user
|
||||
I want to list all registered validations through the CLI
|
||||
So that I can inspect my validation configurations
|
||||
|
||||
@tdd_issue_8621
|
||||
Scenario: List validations shows empty state with no registrations
|
||||
Given a validation list command runner
|
||||
When I run the validation list command with no registered validations
|
||||
Then the output should contain "No validations found"
|
||||
And the output should contain "Register one with 'agents validation add --config <file>'"
|
||||
|
||||
@tdd_issue_8621
|
||||
Scenario: List validations displays all registrations in rich table format
|
||||
Given a tool registry with registered validation tools
|
||||
When I run the validation list command
|
||||
Then the output should display a rich table
|
||||
And the table should contain column headers "Name", "Mode", "Source", "Description"
|
||||
|
||||
@tdd_issue_8621
|
||||
Scenario: List validations filters by namespace
|
||||
Given a tool registry with namespaced validation tools
|
||||
When I run the validation list command with namespace filter "local"
|
||||
Then the output should only include validations matching namespace "local"
|
||||
|
||||
@tdd_issue_8621
|
||||
Scenario: List validations filters by source
|
||||
Given a tool registry with different sourced validation tools
|
||||
When I run the validation list command with source filter "custom"
|
||||
Then the output should only include validations from source "custom"
|
||||
|
||||
@tdd_issue_8621
|
||||
Scenario: List validations filters by regex pattern
|
||||
Given a tool registry with numbered validation tools
|
||||
When I run the validation list command with pattern "coverage.*"
|
||||
Then the output should only include matching validation names
|
||||
|
||||
@tdd_issue_8621
|
||||
Scenario: List validations outputs JSON format
|
||||
Given a tool registry with registered validation tools
|
||||
When I run the validation list command with format "json"
|
||||
Then the output should be valid JSON containing the registered validations
|
||||
|
||||
@tdd_issue_8621
|
||||
Scenario: List validations outputs YAML format
|
||||
Given a tool registry with registered validation tools
|
||||
When I run the validation list command with format "yaml"
|
||||
Then the output should be valid YAML containing the registered validations
|
||||
@@ -8,6 +8,7 @@ subtypes) and their lifecycle attachments to resources.
|
||||
| Command | Description |
|
||||
|--------------------------------|------------------------------------------|
|
||||
| ``agents validation add`` | Register validation from YAML config |
|
||||
| ``agents validation list`` | List all registered validations |
|
||||
| ``agents validation attach`` | Attach validation to a resource |
|
||||
| ``agents validation detach`` | Detach a validation attachment |
|
||||
|
||||
@@ -49,14 +50,21 @@ Based on implementation_plan.md -- Task C1.tool.cli.
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from cleveragents.cli.bootstrap import ensure_cli_database_bootstrapped
|
||||
from cleveragents.cli.commands.validation_helpers import (
|
||||
attachment_dict,
|
||||
compile_pattern,
|
||||
get_tool_registry_service,
|
||||
get_validation_name,
|
||||
print_validation,
|
||||
validation_spec_dict,
|
||||
)
|
||||
from cleveragents.cli.formatting import OutputFormat, format_output
|
||||
from cleveragents.core.exceptions import (
|
||||
CleverAgentsError,
|
||||
@@ -74,97 +82,6 @@ console = Console()
|
||||
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
|
||||
|
||||
|
||||
def _get_tool_registry_service() -> Any:
|
||||
"""Get the ToolRegistryService from the DI container.
|
||||
|
||||
Delegates to get_container().tool_registry_service() so that
|
||||
service wiring is managed exclusively by the container (Forgejo #3006).
|
||||
"""
|
||||
from cleveragents.application.container import get_container
|
||||
|
||||
ensure_cli_database_bootstrapped()
|
||||
|
||||
return get_container().tool_registry_service()
|
||||
|
||||
|
||||
def _validation_spec_dict(tool: Any) -> dict[str, Any]:
|
||||
"""Return validation data as a dict for CLI rendering."""
|
||||
if hasattr(tool, "as_cli_dict"):
|
||||
return dict(tool.as_cli_dict())
|
||||
|
||||
if isinstance(tool, dict):
|
||||
result: dict[str, Any] = {
|
||||
"name": tool.get("name", ""),
|
||||
"description": tool.get("description", ""),
|
||||
"source": tool.get("source", ""),
|
||||
"tool_type": tool.get("tool_type", "validation"),
|
||||
"mode": tool.get("mode", "required"),
|
||||
}
|
||||
if tool.get("wraps"):
|
||||
result["wraps"] = tool["wraps"]
|
||||
if tool.get("transform"):
|
||||
result["transform"] = tool["transform"]
|
||||
return result
|
||||
|
||||
return {"name": str(tool)}
|
||||
|
||||
|
||||
def _attachment_dict(attachment: Any) -> dict[str, Any]:
|
||||
"""Convert an attachment to a plain dict for output formatting."""
|
||||
if isinstance(attachment, dict):
|
||||
return {
|
||||
"attachment_id": attachment.get("attachment_id", ""),
|
||||
"validation_name": attachment.get("validation_name", ""),
|
||||
"resource_id": attachment.get("resource_id", ""),
|
||||
"mode": attachment.get("mode", "required"),
|
||||
"project_name": attachment.get("project_name"),
|
||||
"plan_id": attachment.get("plan_id"),
|
||||
"created_at": attachment.get("created_at", ""),
|
||||
}
|
||||
return {
|
||||
"attachment_id": getattr(attachment, "attachment_id", ""),
|
||||
"validation_name": getattr(attachment, "validation_name", ""),
|
||||
"resource_id": getattr(attachment, "resource_id", ""),
|
||||
"mode": getattr(attachment, "mode", "required"),
|
||||
"project_name": getattr(attachment, "project_name", None),
|
||||
"plan_id": getattr(attachment, "plan_id", None),
|
||||
"created_at": str(getattr(attachment, "created_at", "")),
|
||||
}
|
||||
|
||||
|
||||
def _print_validation(
|
||||
tool: Any,
|
||||
title: str = "Validation",
|
||||
fmt: str = OutputFormat.RICH.value,
|
||||
) -> None:
|
||||
"""Print validation details in the requested format."""
|
||||
data = _validation_spec_dict(tool)
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
console.print(format_output(data, fmt))
|
||||
return
|
||||
|
||||
name = data.get("name", "")
|
||||
desc = data.get("description", "")
|
||||
source = data.get("source", "")
|
||||
mode = data.get("mode", "required")
|
||||
|
||||
details = (
|
||||
f"[bold]Name:[/bold] {name}\n"
|
||||
f"[bold]Description:[/bold] {desc}\n"
|
||||
f"[bold]Source:[/bold] {source}\n"
|
||||
f"[bold]Mode:[/bold] {mode}"
|
||||
)
|
||||
|
||||
wraps = data.get("wraps")
|
||||
if wraps:
|
||||
details += f"\n[bold]Wraps:[/bold] {wraps}"
|
||||
transform = data.get("transform")
|
||||
if transform:
|
||||
details += f"\n[bold]Transform:[/bold] {transform}"
|
||||
|
||||
console.print(Panel(details, title=title, expand=False))
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def add(
|
||||
config: Annotated[
|
||||
@@ -233,17 +150,17 @@ def add(
|
||||
config_dict["mode"] = ValidationMode.INFORMATIONAL.value
|
||||
|
||||
validation = Validation.from_config(config_dict)
|
||||
service = _get_tool_registry_service()
|
||||
service = get_tool_registry_service()
|
||||
|
||||
if update:
|
||||
existing = service.get_tool(validation.name)
|
||||
if existing is not None:
|
||||
registered = service.update_tool(validation)
|
||||
_print_validation(registered, title="Validation Updated", fmt=fmt)
|
||||
print_validation(registered, title="Validation Updated", fmt=fmt)
|
||||
return
|
||||
|
||||
registered = service.register_tool(validation)
|
||||
_print_validation(registered, title="Validation Registered", fmt=fmt)
|
||||
print_validation(registered, title="Validation Registered", fmt=fmt)
|
||||
|
||||
except FileNotFoundError as exc:
|
||||
console.print(f"[red]Config file error:[/red] {exc}")
|
||||
@@ -262,6 +179,92 @@ def add(
|
||||
raise typer.Abort() from exc
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_validations(
|
||||
namespace: Annotated[
|
||||
str | None,
|
||||
typer.Option("--namespace", "-n", help="Filter by namespace"),
|
||||
] = None,
|
||||
source: Annotated[
|
||||
str | None,
|
||||
typer.Option("--source", "-s", help="Filter by source type"),
|
||||
] = None,
|
||||
pattern: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--pattern",
|
||||
"-p",
|
||||
help="Filter validation names by regex pattern",
|
||||
),
|
||||
] = None,
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help=_FORMAT_HELP),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""List validations with optional filters.
|
||||
|
||||
Examples:
|
||||
agents validation list
|
||||
agents validation list --namespace local
|
||||
agents validation list --source custom
|
||||
agents validation list --pattern "coverage-.*"
|
||||
agents validation list --format json
|
||||
"""
|
||||
try:
|
||||
service = get_tool_registry_service()
|
||||
validations = service.list_tools(
|
||||
namespace=namespace,
|
||||
tool_type="validation",
|
||||
source=source,
|
||||
)
|
||||
|
||||
# Apply regex filter if provided
|
||||
if pattern:
|
||||
compiled_pattern = compile_pattern(pattern)
|
||||
validations = [
|
||||
v
|
||||
for v in validations
|
||||
if compiled_pattern.search(get_validation_name(v))
|
||||
]
|
||||
|
||||
if not validations:
|
||||
console.print("[yellow]No validations found.[/yellow]")
|
||||
console.print("Register one with 'agents validation add --config <file>'")
|
||||
return
|
||||
|
||||
# Non-rich formats use the formatting helper
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data = [dict(validation_spec_dict(v)) for v in validations]
|
||||
console.print(format_output(data, fmt))
|
||||
return
|
||||
|
||||
# Rich table
|
||||
table = Table(title=f"Validations ({len(validations)} total)")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Mode", style="blue")
|
||||
table.add_column("Source", style="magenta")
|
||||
table.add_column("Description", style="dim")
|
||||
|
||||
for validation in validations:
|
||||
spec = validation_spec_dict(validation)
|
||||
desc = str(spec.get("description", ""))
|
||||
if len(desc) > 40:
|
||||
desc = desc[:37] + "..."
|
||||
table.add_row(
|
||||
str(spec.get("name", "")),
|
||||
str(spec.get("mode", "required")),
|
||||
str(spec.get("source", "")),
|
||||
desc,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
except CleverAgentsError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc.message}")
|
||||
raise typer.Abort() from exc
|
||||
|
||||
|
||||
@app.command(
|
||||
"attach",
|
||||
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
||||
@@ -324,7 +327,7 @@ def attach(
|
||||
raise typer.Abort()
|
||||
key = token[2:].replace(
|
||||
"-", "_"
|
||||
) # --coverage-threshold → coverage_threshold
|
||||
) # --coverage-threshold -> coverage_threshold
|
||||
if i + 1 >= len(raw_extra) or raw_extra[i + 1].startswith("--"):
|
||||
console.print(
|
||||
f"[red]Missing value for option:[/red] {token} "
|
||||
@@ -335,7 +338,7 @@ def attach(
|
||||
extra_args[key] = val
|
||||
i += 2
|
||||
|
||||
service = _get_tool_registry_service()
|
||||
service = get_tool_registry_service()
|
||||
attachment = service.attach_validation(
|
||||
validation_name=validation_name,
|
||||
resource_id=resource,
|
||||
@@ -344,7 +347,7 @@ def attach(
|
||||
args=extra_args,
|
||||
)
|
||||
|
||||
att_data = _attachment_dict(attachment)
|
||||
att_data = attachment_dict(attachment)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
console.print(format_output(att_data, fmt))
|
||||
@@ -398,7 +401,7 @@ def detach(
|
||||
console.print("[yellow]Aborted.[/yellow]")
|
||||
raise typer.Abort()
|
||||
|
||||
service = _get_tool_registry_service()
|
||||
service = get_tool_registry_service()
|
||||
removed = service.detach_validation(attachment_id)
|
||||
|
||||
if not removed:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Helper functions for validation CLI commands.
|
||||
|
||||
This module contains utility functions for validation command processing,
|
||||
including formatting, filtering, and data transformation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from cleveragents.cli.bootstrap import ensure_cli_database_bootstrapped
|
||||
from cleveragents.cli.formatting import OutputFormat, format_output
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def get_tool_registry_service() -> Any:
|
||||
"""Get the ToolRegistryService from the DI container.
|
||||
|
||||
Delegates to get_container().tool_registry_service() so that
|
||||
service wiring is managed exclusively by the container (Forgejo #3006).
|
||||
"""
|
||||
from cleveragents.application.container import get_container
|
||||
|
||||
ensure_cli_database_bootstrapped()
|
||||
|
||||
return get_container().tool_registry_service()
|
||||
|
||||
|
||||
def validation_spec_dict(tool: Any) -> dict[str, Any]:
|
||||
"""Return validation data as a dict for CLI rendering."""
|
||||
if hasattr(tool, "as_cli_dict"):
|
||||
return dict(tool.as_cli_dict())
|
||||
|
||||
if isinstance(tool, dict):
|
||||
result: dict[str, Any] = {
|
||||
"name": tool.get("name", ""),
|
||||
"description": tool.get("description", ""),
|
||||
"source": tool.get("source", ""),
|
||||
"tool_type": tool.get("tool_type", "validation"),
|
||||
"mode": tool.get("mode", "required"),
|
||||
}
|
||||
if tool.get("wraps"):
|
||||
result["wraps"] = tool["wraps"]
|
||||
if tool.get("transform"):
|
||||
result["transform"] = tool["transform"]
|
||||
return result
|
||||
|
||||
return {"name": str(tool)}
|
||||
|
||||
|
||||
def get_validation_name(v: Any) -> str:
|
||||
"""Return the name string for a validation object or dict."""
|
||||
if isinstance(v, dict):
|
||||
return str(v.get("name", ""))
|
||||
return str(getattr(v, "name", ""))
|
||||
|
||||
|
||||
def attachment_dict(attachment: Any) -> dict[str, Any]:
|
||||
"""Convert an attachment to a plain dict for output formatting."""
|
||||
if isinstance(attachment, dict):
|
||||
return {
|
||||
"attachment_id": attachment.get("attachment_id", ""),
|
||||
"validation_name": attachment.get("validation_name", ""),
|
||||
"resource_id": attachment.get("resource_id", ""),
|
||||
"mode": attachment.get("mode", "required"),
|
||||
"project_name": attachment.get("project_name"),
|
||||
"plan_id": attachment.get("plan_id"),
|
||||
"created_at": attachment.get("created_at", ""),
|
||||
}
|
||||
return {
|
||||
"attachment_id": getattr(attachment, "attachment_id", ""),
|
||||
"validation_name": getattr(attachment, "validation_name", ""),
|
||||
"resource_id": getattr(attachment, "resource_id", ""),
|
||||
"mode": getattr(attachment, "mode", "required"),
|
||||
"project_name": getattr(attachment, "project_name", None),
|
||||
"plan_id": getattr(attachment, "plan_id", None),
|
||||
"created_at": str(getattr(attachment, "created_at", "")),
|
||||
}
|
||||
|
||||
|
||||
def print_validation(
|
||||
tool: Any,
|
||||
title: str = "Validation",
|
||||
fmt: str = OutputFormat.RICH.value,
|
||||
) -> None:
|
||||
"""Print validation details in the requested format."""
|
||||
data = validation_spec_dict(tool)
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
console.print(format_output(data, fmt))
|
||||
return
|
||||
|
||||
name = data.get("name", "")
|
||||
desc = data.get("description", "")
|
||||
source = data.get("source", "")
|
||||
mode = data.get("mode", "required")
|
||||
|
||||
details = (
|
||||
f"[bold]Name:[/bold] {name}\n"
|
||||
f"[bold]Description:[/bold] {desc}\n"
|
||||
f"[bold]Source:[/bold] {source}\n"
|
||||
f"[bold]Mode:[/bold] {mode}"
|
||||
)
|
||||
|
||||
wraps = data.get("wraps")
|
||||
if wraps:
|
||||
details += f"\n[bold]Wraps:[/bold] {wraps}"
|
||||
transform = data.get("transform")
|
||||
if transform:
|
||||
details += f"\n[bold]Transform:[/bold] {transform}"
|
||||
|
||||
console.print(Panel(details, title=title, expand=False))
|
||||
|
||||
|
||||
def compile_pattern(pattern: str) -> re.Pattern[str]:
|
||||
"""Compile a regex pattern, raising an error if invalid."""
|
||||
try:
|
||||
return re.compile(pattern)
|
||||
except re.error as exc:
|
||||
console.print(f"[red]Invalid regex pattern:[/red] {pattern}")
|
||||
raise ValueError(f"Invalid regex pattern: {pattern}") from exc
|
||||
Reference in New Issue
Block a user