diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..92249e6 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,29 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: '.python-version' + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: uv sync --dev + + - name: Run tests + run: uv run pytest tests/ -v diff --git a/Dockerfile b/Dockerfile index 8db7cc8..db66dd8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,8 @@ RUN chmod +x ./perfecto-mcp && \ USER perfecto-mcp ENV MCP_DOCKER=true +# Telemetry defaults to the Perforce gRPC collector. Override the destination +# with OTEL_EXPORTER_OTLP_ENDPOINT, or set OTEL_SDK_DISABLED=true to turn it off. # Command to run the application ENTRYPOINT ["./perfecto-mcp"] diff --git a/build.py b/build.py index 59a6809..2578413 100644 --- a/build.py +++ b/build.py @@ -89,6 +89,17 @@ def get_icon_file(system: str) -> str: def run_pyinstaller(name: str, icon: str): + hiddenimports = [ + "tools.ai_scriptless", + "tools.ai_scriptless.commands", + "tools.ai_scriptless.elements", + "tools.ai_scriptless.item_key", + "tools.ai_scriptless.persistence", + "tools.ai_scriptless.script", + "tools.ai_scriptless.step_path", + "tools.ai_scriptless.tree", + "tools.ai_scriptless.variables", + ] PyInstaller.__main__.run([ 'main.py', '--onefile', @@ -99,6 +110,19 @@ def run_pyinstaller(name: str, icon: str): f'--icon={icon}', '--clean', '--noconfirm', + *[f'--hidden-import={module}' for module in hiddenimports], + '--hidden-import=opentelemetry.sdk.trace', + '--hidden-import=opentelemetry.sdk.trace.export', + '--hidden-import=opentelemetry.sdk.resources', + '--hidden-import=opentelemetry.sdk.metrics', + '--hidden-import=opentelemetry.sdk.metrics.export', + '--hidden-import=opentelemetry.exporter.otlp.proto.grpc.trace_exporter', + '--hidden-import=opentelemetry.exporter.otlp.proto.grpc.metric_exporter', + '--hidden-import=opentelemetry.exporter.otlp.proto.http.trace_exporter', + '--hidden-import=opentelemetry.exporter.otlp.proto.http.metric_exporter', + '--hidden-import=opentelemetry.propagate', + '--collect-submodules=opentelemetry', + '--collect-all=grpc', ]) diff --git a/config/perfecto.py b/config/perfecto.py index 2f59883..1cbde76 100644 --- a/config/perfecto.py +++ b/config/perfecto.py @@ -15,6 +15,14 @@ HELP_BASE_CONTENT_URL = "https://help.perfecto.io/perfecto-help/content/" +def get_cloud_app_url(cloud_name: str) -> str: + return f"https://{cloud_name}.app.perfectomobile.com" + + +def get_ai_scriptless_lab_url(cloud_name: str) -> str: + return f"{get_cloud_app_url(cloud_name)}/lab/scriptless-mobile/" + + def get_tenant_management_api_url(cloud_name: str) -> str: return f"https://{cloud_name}.app.perfectomobile.com/tenant-management-webapp/rest/v1/tenant-management/tenants/current" @@ -71,5 +79,20 @@ def get_ai_scriptless_api_url(cloud_name: str) -> str: return f"https://{cloud_name}.app.perfectomobile.com/native-automation-webapp/rest/v1/native-automation" +def get_ai_scriptless_draft_api_url(cloud_name: str) -> str: + return f"https://{cloud_name}.app.perfectomobile.com/native-automation-webapp/rest/v1/draft-management/draft" + + def get_ai_scriptless_execution_api_url(cloud_name: str) -> str: return f"https://{cloud_name}.perfectomobile.com/scriptless-mobile-engine/script-executor/api/executions" + + +def get_ai_scriptless_command_repository_url(cloud_name: str) -> str: + return f"https://{cloud_name}.perfectomobile.com/scriptless-mobile-engine/command-repository/api" + + +def get_repository_management_api_url(cloud_name: str) -> str: + return ( + f"https://{cloud_name}.app.perfectomobile.com" + "/repository-management-webapp/rest/v2/repository-management" + ) diff --git a/formatters/ai_scriptless.py b/formatters/ai_scriptless.py index 5f49d97..90bd271 100644 --- a/formatters/ai_scriptless.py +++ b/formatters/ai_scriptless.py @@ -1,5 +1,43 @@ from typing import List, Any, Optional +import copy + +from models.ai_scriptless import ( + CommandCatalogEntry, + CommandDefinitionSummary, + ScriptFlowElement, + ScriptParameter, + ScriptVariableSummary, + SnapshotListResult, + SnapshotSummary, + TestStructure, +) +from tools.ai_scriptless.elements import normalize_if_statement_aliases + +PRIMARY_AI_COMMAND_IDS = ( + "ai_user-action", + "ai_validation", + "ai_visual-comparison", +) + + +def command_selection_policy_info() -> List[str]: + """Context returned with list_commands so agents load policy when choosing command_ids.""" + return [ + "Command selection policy (when authoring tests with add_command / modify_command):", + "Default: use only these primary AI command_ids: " + + ", ".join(PRIMARY_AI_COMMAND_IDS) + ".", + " • ai_user-action — user interactions (open browser/app, navigate to URL, tap, type, dismiss overlays); " + "argument: action (natural language).", + " • ai_validation — checkpoints and assertions; argument: validation (natural language).", + " • ai_visual-comparison — visual/baseline comparison; argument: name.", + "Prefer ai_user-action for navigation (e.g. open browser and go to URL), not browser_goto / browser_open.", + "Do not use browser_*, touch_tap, webpage.element_*, checkpoint_text, etc. unless the user explicitly " + "requests a non-AI command or agreed that AI commands cannot meet a documented requirement.", + "Structural helpers (add_logical_step, add_loop, add_condition, comment, wait) are OK; " + "keep observable steps AI-driven when possible.", + "Call get_command_definitions only for the AI command_ids you will use.", + ] def format_ai_scriptless_tests_filter_values(tests: dict[str, Any], params: Optional[dict] = None) -> dict[str, Any]: filter_values = { @@ -57,3 +95,382 @@ def format_ai_scriptless_tests(tests: dict[str, Any], params: Optional[dict] = N offset = len(formatted_ai_scriptless_tests) - 1 return formatted_ai_scriptless_tests[skip:offset] + + +def _command_id(command: Optional[str], subcommand: Optional[str]) -> Optional[str]: + if not command: + return None + sub = subcommand or "" + if sub: + return f"{command}_{sub}".replace("/", "_") + return command.replace("/", "_") + + +def _definitions_map(command_definitions: Optional[list]) -> dict[str, dict[str, Any]]: + if not command_definitions: + return {} + return {item["commandId"]: item for item in command_definitions if "commandId" in item} + + +def _argument_value(element: dict[str, Any], argument_name: str) -> Optional[str]: + for argument in element.get("arguments", []): + if argument.get("name") == argument_name: + data = argument.get("data", {}) + return data.get("value") + return None + + +def _definition_display_name(command_id: Optional[str], definitions_map: dict[str, dict[str, Any]]) -> Optional[str]: + if not command_id or command_id not in definitions_map: + return None + definition = definitions_map[command_id] + display = definition.get("data", {}).get("display", {}) + return display.get("name") or definition.get("name") + + +def _definition_data(definition: dict[str, Any]) -> dict[str, Any]: + data = definition.get("data", definition) + return data if isinstance(data, dict) else {} + + +def _iter_definition_parameters(definition: dict[str, Any]): + seen: set[str] = set() + for key in ("parameters", "mandatoryParameters", "optionalParameters"): + for param in _definition_data(definition).get(key) or []: + if not isinstance(param, dict): + continue + param_name = param.get("name") or param.get("parameterName") + if not param_name or param_name in seen: + continue + seen.add(param_name) + yield param + + +def _should_include_parameter_in_step_display(param: dict[str, Any]) -> bool: + display = param.get("display") or {} + if display.get("editorLevel") != "PUBLIC": + return False + in_report = display.get("inReport", param.get("inReport")) + return in_report is True + + +def _parameter_display_label(param: dict[str, Any]) -> str: + display = param.get("display") or {} + param_name = param.get("name") or param.get("parameterName") or "" + return display.get("name") or param_name + + +def _format_argument_display_value(element: dict[str, Any], argument_name: str) -> Optional[str]: + for argument in element.get("arguments", []): + if argument.get("name") != argument_name: + continue + data = argument.get("data", {}) + value = data.get("value") + if value is None: + return None + if data.get("secured"): + return "" + return str(value) + return None + + +def _command_step_display_name( + element: dict[str, Any], + command_id: Optional[str], + definitions_map: dict[str, dict[str, Any]], +) -> Optional[str]: + if not command_id or command_id not in definitions_map: + return None + + base_name = _definition_display_name(command_id, definitions_map) + if not base_name: + return None + + segments: list[str] = [] + definition = definitions_map[command_id] + for param in _iter_definition_parameters(definition): + if not _should_include_parameter_in_step_display(param): + continue + param_name = param.get("name") or param.get("parameterName") + if not param_name: + continue + value = _format_argument_display_value(element, param_name) + if value is None or value == "": + continue + segments.append(f"{_parameter_display_label(param)}: {value}") + + if not segments: + return base_name + return f"{base_name} ({', '.join(segments)})" + + +def _ai_primary_step_display_name( + element: dict[str, Any], + command_id: Optional[str], + definitions_map: dict[str, dict[str, Any]], + argument_name: str, +) -> Optional[str]: + primary_text = _argument_value(element, argument_name) + if primary_text: + return str(primary_text) + return _definition_display_name(command_id, definitions_map) + + +def _step_display_name(element: dict[str, Any], definitions_map: dict[str, dict[str, Any]]) -> str: + element_type = element.get("@type", "") + command = element.get("command") + subcommand = element.get("subcommand") + command_id = _command_id(command, subcommand) + + if command == "ai" and subcommand == "user-action": + display_name = _ai_primary_step_display_name( + element, command_id, definitions_map, "action", + ) + if display_name: + return display_name + + if command == "ai" and subcommand == "validation": + display_name = _ai_primary_step_display_name( + element, command_id, definitions_map, "validation", + ) + if display_name: + return display_name + + if element_type == "Loop": + iterator = element.get("iterator", {}) + count = iterator.get("count") + if count is not None: + return f"Loop ({count})" + return "Loop" + + if element_type == "IfStatement": + expression = element.get("expression") or element.get("label") + if expression: + return f"Condition ({expression})" + return "Condition" + + if element_type == "LogicalStep": + label = element.get("label") + if label: + return label + return "Step" + + if element_type == "Branch": + clause = element.get("clause", "") + return clause.title() if clause else "Branch" + + display_name = _command_step_display_name(element, command_id, definitions_map) + if not display_name: + display_name = _definition_display_name(command_id, definitions_map) + if display_name: + return display_name + + if command: + if subcommand: + return f"{command}/{subcommand}" + return command + + return element_type or "Unknown" + + +def _format_flow_element( + element: dict[str, Any], + definitions_map: dict[str, dict[str, Any]], + step_path: str, +) -> ScriptFlowElement: + element_type = element.get("@type", "") + command = element.get("command") + subcommand = element.get("subcommand") + children: List[ScriptFlowElement] = [] + + if element_type == "IfStatement": + for branch_index, branch in enumerate(element.get("branches", [])): + branch_path = f"{step_path}.b{branch_index}" + branch_label = branch.get("clause", "Branch") + branch_children = [ + _format_flow_element(child, definitions_map, f"{branch_path}.{child_index}") + for child_index, child in enumerate(branch.get("flowElements", [])) + ] + children.append(ScriptFlowElement( + type="Branch", + name=branch_label.title() if branch_label else "Branch", + active=branch.get("active", True), + step_path=branch_path, + children=branch_children, + )) + else: + for child_index, child in enumerate(element.get("flowElements", [])): + children.append( + _format_flow_element(child, definitions_map, f"{step_path}.{child_index}") + ) + + return ScriptFlowElement( + type=element_type, + name=_step_display_name(element, definitions_map), + command=command, + subcommand=subcommand, + active=element.get("active", True), + step_path=step_path, + children=children, + ) + + +def _format_root_flow_elements( + flow_elements: list[dict[str, Any]], + definitions_map: dict[str, dict[str, Any]], +) -> List[ScriptFlowElement]: + return [ + _format_flow_element(element, definitions_map, str(index)) + for index, element in enumerate(flow_elements) + ] + + +def format_test_structure(payload: dict[str, Any], params: Optional[dict] = None) -> TestStructure: + item_key = params.get("item_key", "") if params else "" + script = copy.deepcopy(payload.get("script", {})) + normalize_if_statement_aliases(script) + definitions_map = _definitions_map(payload.get("commandDefinitions")) + + parameters = [] + for parameter in script.get("parameters", []): + data = parameter.get("data", {}) + parameters.append(ScriptParameter( + name=data.get("name", parameter.get("name", "")), + type=data.get("@type", "Unknown"), + )) + + flow_elements = _format_root_flow_elements(script.get("flowElements", []), definitions_map) + + info = script.get("info", {}) + return TestStructure( + item_key=item_key, + parameters=parameters, + model_version=info.get("modelVersion"), + flow_elements=flow_elements, + ) + + +def _flatten_command_catalog(node: dict[str, Any], category: Optional[str] = None) -> List[CommandCatalogEntry]: + entries: List[CommandCatalogEntry] = [] + node_name = node.get("name") + node_category = node_name if node.get("children") is not None else category + + if "commandId" in node: + entries.append(CommandCatalogEntry( + command_id=node["commandId"], + name=node.get("name", node["commandId"]), + path=node.get("path", ""), + status=node.get("status"), + category=category, + )) + + for child in node.get("children", []): + entries.extend(_flatten_command_catalog(child, node_category)) + + return entries + + +def format_command_catalog(catalog: dict[str, Any], params: Optional[dict] = None) -> List[CommandCatalogEntry]: + return _flatten_command_catalog(catalog) + + +def _parameter_names(parameters: Optional[list]) -> List[str]: + if not parameters: + return [] + return [param.get("name", param.get("parameterName", "")) for param in parameters if param.get("name") or param.get("parameterName")] + + +def _variable_type_label(data: dict[str, Any]) -> str: + data_type = data.get("@type", "Unknown") + if data_type == "StringData": + return "secured_string" if data.get("secured") else "string" + mapping = { + "BooleanData": "boolean", + "IntegerData": "number", + "HandsetData": "device", + "MediaData": "media", + "TableData": "datatable", + } + return mapping.get(data_type, data_type) + + +def format_test_variables(variables: Any, params: Optional[dict] = None) -> List[ScriptVariableSummary]: + if not isinstance(variables, list): + return [] + + formatted: List[ScriptVariableSummary] = [] + for variable in variables: + if not isinstance(variable, dict): + continue + data = variable.get("data", {}) + value = data.get("value") + if data.get("secured") and value: + value = "" + formatted.append(ScriptVariableSummary( + name=data.get("name", ""), + type=_variable_type_label(data), + value=value, + secured=bool(data.get("secured")), + set_at_runtime=variable.get("@type") == "Parameter", + )) + return formatted + + +def format_snapshots_list(response: Any, params: Optional[dict] = None) -> SnapshotListResult: + snapshots = response.get("snapshots", []) if isinstance(response, dict) else response + if not isinstance(snapshots, list): + snapshots = [] + + formatted: List[SnapshotSummary] = [] + for snapshot in snapshots: + if not isinstance(snapshot, dict): + continue + key = snapshot.get("key", "") + creation = snapshot.get("creationTime") or snapshot.get("createdTime") or {} + created_time = creation.get("formatted") if isinstance(creation, dict) else creation + formatted.append(SnapshotSummary( + key=key, + version=snapshot.get("version"), + comment=snapshot.get("comment"), + created_by=snapshot.get("createdBy"), + created_time=created_time, + is_current=key == "", + )) + + formatted.sort(key=lambda entry: (0 if entry.is_current else 1, entry.key)) + + test_id = params.get("test_id") if params else None + return SnapshotListResult( + test_id=test_id, + count=len(formatted), + snapshots=formatted, + notes=[ + "Every POST script save (including save_test without comment) adds a new UUID entry to snapshot history.", + "The '' entry marks the live editable script; use view_test_structure with test_id for its structure.", + "Open historical versions with view_snapshot using a UUID key from this list, not ''.", + "The comment argument on save_test/save_test_as labels '' (UI: Save with comment); it does not skip version creation.", + ], + ) + + +def format_command_definitions(response: Any, params: Optional[dict] = None) -> List[CommandDefinitionSummary]: + definitions = response if isinstance(response, list) else response.get("definitions", response.get("items", [])) + if not isinstance(definitions, list): + definitions = [definitions] if definitions else [] + + summaries: List[CommandDefinitionSummary] = [] + for definition in definitions: + if not isinstance(definition, dict): + continue + command_id = definition.get("commandId", "") + data = definition.get("data", definition) + display = data.get("display", {}) + summaries.append(CommandDefinitionSummary( + command_id=command_id, + name=display.get("name") or definition.get("name") or command_id, + mandatory_parameters=_parameter_names(data.get("mandatoryParameters")), + optional_parameters=_parameter_names(data.get("optionalParameters")), + help_text=display.get("helpText") or data.get("helpText"), + raw=definition, + )) + return summaries diff --git a/formatters/user.py b/formatters/user.py index 50397b3..d8c2aba 100644 --- a/formatters/user.py +++ b/formatters/user.py @@ -1,5 +1,6 @@ from typing import List, Any, Optional +from config.perfecto import get_cloud_app_url from models.user import User @@ -7,13 +8,17 @@ def format_users(users: dict[str, Any], params: Optional[dict] = None) -> List[U first_name = users.get('firstName') or '' last_name = users.get('lastName') or '' display_name = f"{first_name} {last_name}".strip() or users.get("username", "Unknown") - + cloud_name = (params or {}).get("cloud_name") or "" + cloud_url = get_cloud_app_url(cloud_name) if cloud_name else "" + formatted_users = [ User( username=users.get("username") or "unknown", display_name=display_name, first_name=first_name, last_name=last_name, + cloud_name=cloud_name, + cloud_url=cloud_url, ) ] return formatted_users diff --git a/main.py b/main.py index f4e930f..016e47b 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ from config.token import PerfectoToken, PerfectoTokenError from config.version import __version__, __executable__, __bundle__, __uvx__, get_version from server import register_tools +from telemetry import init_telemetry PERFECTO_SECURITY_TOKEN_FILE_NAME = "perfecto-security-token.txt" PERFECTO_SECURITY_TOKEN_FILE_PATH = os.getenv(SECURITY_TOKEN_FILE_ENV_NAME) @@ -63,6 +64,7 @@ def get_token() -> PerfectoToken: def run(log_level: str = "CRITICAL"): + init_telemetry("perfecto-mcp", __version__) token = get_token() instructions = """ diff --git a/models/ai_scriptless.py b/models/ai_scriptless.py new file mode 100644 index 0000000..97c4820 --- /dev/null +++ b/models/ai_scriptless.py @@ -0,0 +1,72 @@ +from typing import Any, List, Optional + +from pydantic import BaseModel, Field + + +class ScriptFlowElement(BaseModel): + type: str = Field(description="Perfecto @type (Action, Validation, Loop, etc.)") + name: str = Field(description="Display name for the step") + command: Optional[str] = Field(description="Command namespace", default=None) + subcommand: Optional[str] = Field(description="Command subcommand", default=None) + active: bool = Field(description="Whether the step is enabled (not excluded)", default=True) + step_path: Optional[str] = Field( + description="Dot-separated positional path (e.g. 0, 2.0, 5.b0.1); derived from tree position", + default=None, + ) + children: List["ScriptFlowElement"] = Field(description="Nested flow elements", default_factory=list) + + +class ScriptParameter(BaseModel): + name: str = Field(description="Parameter name") + type: str = Field(description="Parameter data type") + + +class ScriptVariableSummary(BaseModel): + name: str = Field(description="Variable name") + type: str = Field(description="Variable type (string, number, boolean, secured_string, etc.)") + value: Optional[Any] = Field(description="Variable value when readable", default=None) + secured: bool = Field(description="Whether the value is secured", default=False) + set_at_runtime: bool = Field(description="True when value is provided at execution time", default=False) + + +class TestStructure(BaseModel): + item_key: str = Field(description="Script itemKey") + parameters: List[ScriptParameter] = Field(description="Test parameters", default_factory=list) + model_version: Optional[str] = Field(description="Script model version", default=None) + flow_elements: List[ScriptFlowElement] = Field(description="Root flow elements", default_factory=list) + + +class CommandCatalogEntry(BaseModel): + command_id: str = Field(description="Command identifier for definitions API") + name: str = Field(description="Display name") + path: str = Field(description="Catalog path") + status: Optional[str] = Field(description="Command status (GA, DRAFT, etc.)", default=None) + category: Optional[str] = Field(description="Parent category name", default=None) + + +class SnapshotSummary(BaseModel): + key: str = Field(description="Snapshot identifier (UUID for history, or '' for the live script marker)") + version: Optional[str] = Field(description="Snapshot version label", default=None) + comment: Optional[str] = Field(description="User comment from save with comment (typically on '' only)", default=None) + created_by: Optional[str] = Field(description="User who created the snapshot", default=None) + created_time: Optional[str] = Field(description="Creation timestamp", default=None) + is_current: bool = Field(description="True when key is '' (live script marker, not openable via view_snapshot)", default=False) + + +class SnapshotListResult(BaseModel): + test_id: Optional[str] = Field(description="Test itemKey queried", default=None) + count: int = Field(description="Number of snapshot entries returned") + snapshots: List[SnapshotSummary] = Field(description="Snapshot entries including '' marker", default_factory=list) + notes: List[str] = Field(description="Behavior notes for interpreting snapshot history", default_factory=list) + + +class CommandDefinitionSummary(BaseModel): + command_id: str = Field(description="Command identifier") + name: str = Field(description="Display name") + mandatory_parameters: List[str] = Field(description="Required parameter names", default_factory=list) + optional_parameters: List[str] = Field(description="Optional parameter names", default_factory=list) + help_text: Optional[str] = Field(description="Help text", default=None) + raw: Optional[dict[str, Any]] = Field(description="Full definition payload", default=None) + + +ScriptFlowElement.model_rebuild() \ No newline at end of file diff --git a/models/user.py b/models/user.py index 68b2c59..46c7bf6 100644 --- a/models/user.py +++ b/models/user.py @@ -5,4 +5,6 @@ class User(BaseModel): username: str = Field(description="The unique identifier for the user") display_name: str = Field(description="Display name of the user") first_name: str = Field(description="First name of the user") - last_name: str = Field(description="Last name of the user") \ No newline at end of file + last_name: str = Field(description="Last name of the user") + cloud_name: str = Field(description="Perfecto cloud name from MCP configuration (PERFECTO_CLOUD_NAME)") + cloud_url: str = Field(description="Perfecto cloud portal URL (https://{cloud_name}.app.perfectomobile.com)") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f7764f6..3dd5cd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,15 @@ [project] name = "perfecto-mcp" -version = "1.0.1" +version = "1.1.0" description = "MCP server for Perfecto Cloud Platform" readme = "README.md" requires-python = ">=3.11" dependencies = [ "httpx[http2]>=0.28.1", "mcp[cli]>=1.17.0", + "opentelemetry-api>=1.20.0", + "opentelemetry-sdk>=1.20.0", + "opentelemetry-exporter-otlp>=1.20.0", "pyinstaller>=6.0.0", "pydantic>=2.11.7", "pydantic-core>=2.33.2", @@ -18,12 +21,20 @@ dependencies = [ perfecto-mcp = "main:main" [tool.setuptools] -py-modules = ["main", "server"] +py-modules = ["main", "server", "telemetry"] include-package-data = true +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] + [tool.setuptools.packages.find] where = ["."] include = ["tools", "config", "models", "formatters", "resources"] [tool.setuptools.package-data] "resources" = ["*.png"] + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/telemetry.py b/telemetry.py new file mode 100644 index 0000000..2a362b8 --- /dev/null +++ b/telemetry.py @@ -0,0 +1,271 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +import logging +import os +import time +from typing import Any, Awaitable, Callable + +import httpx + +logger = logging.getLogger(__name__) + +try: + from opentelemetry import metrics, trace # noqa: F401 — must be module-level for patching + _OTEL_API_AVAILABLE = True +except ImportError: + trace = None # type: ignore[assignment] + metrics = None # type: ignore[assignment] + _OTEL_API_AVAILABLE = False + +_call_counter = None +_duration_histogram = None + +DEFAULT_OTLP_ENDPOINT = "https://grpc.public.prd.shared.perforce.com" +DEFAULT_OTLP_PROTOCOL = "grpc" + + +def _get_otlp_protocol() -> str: + return os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", DEFAULT_OTLP_PROTOCOL) + + +def _create_trace_exporter(): + if _get_otlp_protocol() == "grpc": + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + else: + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + return OTLPSpanExporter() + + +def _create_metric_exporter(): + if _get_otlp_protocol() == "grpc": + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter + else: + from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter + return OTLPMetricExporter() + + +def init_telemetry(service_name: str, service_version: str) -> None: + global _call_counter, _duration_histogram + + if not _OTEL_API_AVAILABLE: + return + if os.getenv("OTEL_SDK_DISABLED", "").lower() == "true": + return + try: + # Lazy SDK imports: defer heavy setup until init_telemetry() and tolerate a + # missing SDK (ImportError) without breaking module import or startup. + from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_VERSION, Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + resource = Resource.create({ + SERVICE_NAME: service_name, + SERVICE_VERSION: service_version, + }) + provider = TracerProvider(resource=resource) + + # Default export destination/protocol for shipped releases (gRPC + Perforce + # collector). PAG or local dev may override via OTEL_EXPORTER_OTLP_ENDPOINT + # and OTEL_EXPORTER_OTLP_PROTOCOL; OTEL_SDK_DISABLED=true disables telemetry. + if not os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"): + os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = DEFAULT_OTLP_ENDPOINT + + try: + provider.add_span_processor(BatchSpanProcessor(_create_trace_exporter())) + except Exception: + logger.debug("OTLP trace exporter setup failed", exc_info=True) + + trace.set_tracer_provider(provider) + logger.debug("OTel TracerProvider initialised (service=%s, version=%s)", service_name, service_version) + + try: + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + + readers = [] + try: + readers.append(PeriodicExportingMetricReader(_create_metric_exporter())) + except Exception: + logger.debug("OTLP metric exporter setup failed", exc_info=True) + + meter_provider = MeterProvider(resource=resource, metric_readers=readers) + metrics.set_meter_provider(meter_provider) + + meter = metrics.get_meter("perfecto-mcp") + _call_counter = meter.create_counter( + "mcp.tool.calls", + unit="{call}", + description="Number of MCP tool calls", + ) + _duration_histogram = meter.create_histogram( + "mcp.tool.duration", + unit="s", + description="MCP tool call duration in seconds", + ) + logger.debug("OTel MeterProvider initialised") + except ImportError: + pass + except Exception: + logger.debug("OTel metrics init failed", exc_info=True) + + except ImportError: + pass + except Exception: + logger.debug("OTel init failed; continuing without tracing", exc_info=True) + + +def _get_meta(ctx: Any) -> dict: + try: + return ctx.request_context.request.params.meta or {} + except Exception: + return {} + + +def _extract_trace_context(meta: dict): + if not meta: + return None + try: + from opentelemetry.propagate import extract + carrier = {} + if "traceparent" in meta: + carrier["traceparent"] = meta["traceparent"] + if "tracestate" in meta: + carrier["tracestate"] = meta["tracestate"] + return extract(carrier) if carrier else None + except Exception: + return None + + +def _get_client_info(ctx: Any): + try: + info = ctx.request_context.session.client_params.clientInfo + return info.name, info.version + except Exception: + return None, None + + +def _get_session_id(ctx: Any) -> str | None: + try: + session_id = ctx.session_id + return str(session_id) if session_id is not None else None + except Exception: + return None + + +def _record_span_error(span: Any, error_type: str) -> None: + try: + span.set_attribute("error.type", error_type) + except Exception: + pass + try: + from opentelemetry.trace import Status, StatusCode + span.set_status(Status(StatusCode.ERROR)) + except Exception: + pass + + +def _http_status_to_error_type(status_code: int) -> str: + if status_code in (401, 403): + return "auth_failed" + if status_code == 404: + return "not_found" + if status_code == 429: + return "rate_limited" + if status_code >= 500: + return "server_error" + return f"http_{status_code}" + + +def _record_metrics(tool_name: str, action: str, elapsed: float, error_type: str | None) -> None: + attrs: dict[str, str] = {"gen_ai.tool.name": tool_name, "mcp.tool.action": action} + if error_type is not None: + attrs["error.type"] = error_type + try: + if _call_counter is not None: + _call_counter.add(1, attrs) + if _duration_histogram is not None: + _duration_histogram.record(elapsed, attrs) + except Exception: + pass + + +async def run_tool( + tool_name: str, + action: str, + ctx: Any, + dispatch: Callable[[], Awaitable[Any]], +) -> Any: + if trace is None: + return await dispatch() + + try: + meta = _get_meta(ctx) + parent_ctx = _extract_trace_context(meta) + tracer = trace.get_tracer("perfecto-mcp") + span_cm = tracer.start_as_current_span( + f"tools/call {tool_name}", + context=parent_ctx, + kind=trace.SpanKind.SERVER, + record_exception=False, + set_status_on_exception=False, + ) + except Exception: + return await dispatch() + + with span_cm as span: + try: + span.set_attribute("mcp.method.name", "tools/call") + span.set_attribute("gen_ai.tool.name", tool_name) + span.set_attribute("gen_ai.operation.name", "execute_tool") + span.set_attribute("mcp.tool.action", action) + client_name, client_version = _get_client_info(ctx) + if client_name is not None: + span.set_attribute("user_agent.name", client_name) + if client_version is not None: + span.set_attribute("user_agent.version", client_version) + session_id = _get_session_id(ctx) + if session_id is not None: + span.set_attribute("mcp.session.id", session_id) + except Exception: + pass + + start = time.perf_counter() + error_type: str | None = None + result = None + try: + result = await dispatch() + except httpx.TimeoutException: + error_type = "timeout" + _record_span_error(span, error_type) + raise + except httpx.HTTPStatusError as e: + error_type = _http_status_to_error_type(e.response.status_code) + _record_span_error(span, error_type) + raise + except Exception: + error_type = "tool_error" + _record_span_error(span, error_type) + raise + finally: + elapsed = time.perf_counter() - start + metric_error_type = error_type or ( + "api_error" if result is not None and getattr(result, "error", None) else None + ) + _record_metrics(tool_name, action, elapsed, metric_error_type) + + if result is not None and getattr(result, "error", None): + _record_span_error(span, "api_error") + return result diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f1d39c6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import pytest + +from config.token import PerfectoToken + + +@pytest.fixture +def perfecto_token() -> PerfectoToken: + return PerfectoToken("test-token", "demo") diff --git a/tests/test_ai_scriptless_commands.py b/tests/test_ai_scriptless_commands.py new file mode 100644 index 0000000..6370716 --- /dev/null +++ b/tests/test_ai_scriptless_commands.py @@ -0,0 +1,46 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from formatters.ai_scriptless import PRIMARY_AI_COMMAND_IDS +from tools.ai_scriptless.commands import COMMAND_SPECS, get_command_spec + + +class TestCommandSpecRegistry: + def test_known_commands_are_registered(self): + for command_id in ( + *PRIMARY_AI_COMMAND_IDS, + "comment", + "wait", + "handset_ready", + "touch_tap", + "checkpoint_text", + "checkpoint_image", + ): + assert command_id in COMMAND_SPECS + + def test_validation_commands_use_ignore_error_policy(self): + for command_id in ("ai_validation", "checkpoint_text", "checkpoint_image"): + assert get_command_spec(command_id).error_policy == "IGNORE" + + def test_unknown_command_falls_back_to_handset_dut(self): + spec = get_command_spec("custom_unknown_step") + assert spec.element_type == "Action" + assert spec.default_arguments_merged() == {"handsetId": ("VARIABLE", "DUT")} + + def test_wait_spec_normalizes_duration_alias(self): + spec = get_command_spec("wait") + normalized = spec.normalize_argument_names({"waitDuration": "5"}) + assert normalized == {"duration": "5"} diff --git a/tests/test_ai_scriptless_flow_element_counts.py b/tests/test_ai_scriptless_flow_element_counts.py new file mode 100644 index 0000000..ef78919 --- /dev/null +++ b/tests/test_ai_scriptless_flow_element_counts.py @@ -0,0 +1,122 @@ +""" +numOfFlowElements on nested containers (Branch, LogicalStep, Loop, IfStatement). + +Perfecto wire format (observed on beta API): +- Script / LogicalStep / Loop / Branch: len(flowElements) +- IfStatement: 3 + direct children across all branches (not recursive) +- Leaf Action/Validation: no numOfFlowElements field +""" + +from __future__ import annotations + +from tools.ai_scriptless.elements import ( + build_flow_element, + build_if_statement, + build_logical_step, + build_loop, + new_empty_script, +) +from tools.ai_scriptless.tree import ( + delete_element_by_path, + insert_flow_element, + update_flow_element_counts, +) + + +def _comment(text: str) -> dict: + return build_flow_element("comment", {"text": text}) + + +class TestNestedFlowElementCounts: + def test_empty_if_statement_counts(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + update_flow_element_counts(script) + ifs = script["flowElements"][0] + assert ifs["numOfFlowElements"] == 3 + assert ifs["branches"][0]["numOfFlowElements"] == 0 + assert ifs["branches"][0]["empty"] is True + + def test_insert_into_then_updates_branch_and_if_counts(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + insert_flow_element(script, _comment("a"), parent_path="0.b0") + ifs = script["flowElements"][0] + assert ifs["branches"][0]["numOfFlowElements"] == 1 + assert ifs["branches"][0]["empty"] is False + assert ifs["numOfFlowElements"] == 4 + + def test_insert_into_else_increments_if_count(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + insert_flow_element(script, _comment("else"), parent_path="0.b1") + ifs = script["flowElements"][0] + assert ifs["branches"][1]["numOfFlowElements"] == 1 + assert ifs["numOfFlowElements"] == 4 + + def test_two_direct_then_children_gives_if_num_five(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + insert_flow_element(script, _comment("a"), parent_path="0.b0") + insert_flow_element(script, _comment("b"), parent_path="0.b0") + ifs = script["flowElements"][0] + assert ifs["branches"][0]["numOfFlowElements"] == 2 + assert ifs["numOfFlowElements"] == 5 + + def test_nested_if_counts_direct_child_only_not_recursive(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement("outer", "Outer")] + inner = build_if_statement("inner", "Inner") + insert_flow_element(script, inner, parent_path="0.b0") + insert_flow_element(script, _comment("deep"), parent_path="0.b0.0.b0") + outer = script["flowElements"][0] + inner_ifs = outer["branches"][0]["flowElements"][0] + assert outer["numOfFlowElements"] == 4 + assert outer["branches"][0]["numOfFlowElements"] == 1 + assert inner_ifs["numOfFlowElements"] == 4 + assert inner_ifs["branches"][0]["numOfFlowElements"] == 1 + + def test_logical_step_count_matches_children(self): + script = new_empty_script() + group = build_logical_step("G") + group["flowElements"] = [_comment("a"), build_loop(2)] + script["flowElements"] = [group] + update_flow_element_counts(script) + assert group["numOfFlowElements"] == 2 + assert group["flowElements"][1]["numOfFlowElements"] == 0 + + def test_delete_from_then_decrements_counts(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + insert_flow_element(script, _comment("a"), parent_path="0.b0") + insert_flow_element(script, _comment("b"), parent_path="0.b0") + delete_element_by_path(script, "0.b0.0") + ifs = script["flowElements"][0] + assert ifs["branches"][0]["numOfFlowElements"] == 1 + assert ifs["numOfFlowElements"] == 4 + + def test_parent_path_insert_updates_logical_step_count(self): + script = new_empty_script() + script["flowElements"] = [build_logical_step("G")] + insert_flow_element(script, _comment("inside"), parent_path="0") + group = script["flowElements"][0] + assert group["numOfFlowElements"] == 1 + + def test_leaf_actions_have_no_num_of_flow_elements(self): + script = new_empty_script() + insert_flow_element(script, _comment("x")) + leaf = script["flowElements"][0] + assert "numOfFlowElements" not in leaf or leaf.get("numOfFlowElements") is None + + def test_persist_prep_matches_perfecto_formula(self): + """Simulate wrong counts; update_flow_element_counts repairs before persist.""" + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + insert_flow_element(script, _comment("a"), parent_path="0.b0") + insert_flow_element(script, _comment("b"), parent_path="0.b0") + ifs = script["flowElements"][0] + ifs["numOfFlowElements"] = 3 + ifs["branches"][0]["numOfFlowElements"] = 0 + update_flow_element_counts(script) + assert ifs["numOfFlowElements"] == 5 + assert ifs["branches"][0]["numOfFlowElements"] == 2 diff --git a/tests/test_ai_scriptless_formatters.py b/tests/test_ai_scriptless_formatters.py new file mode 100644 index 0000000..e809f8f --- /dev/null +++ b/tests/test_ai_scriptless_formatters.py @@ -0,0 +1,399 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from formatters.ai_scriptless import ( + _command_id, + _command_step_display_name, + _definitions_map, + _step_display_name, + command_selection_policy_info, + format_ai_scriptless_tests, + format_ai_scriptless_tests_filter_values, + format_command_catalog, + format_command_definitions, + format_snapshots_list, + format_test_structure, + format_test_variables, +) +from tools.ai_scriptless_script import ( + build_flow_element, + build_if_statement, + build_logical_step, + new_empty_script, +) + + +def _ai_validation_command_definition() -> dict: + return { + "commandId": "ai_validation", + "name": "AI Validation", + "data": { + "display": {"name": "AI Validation"}, + "mandatoryParameters": [ + { + "name": "validation", + "display": { + "name": "Validation", + "editorLevel": "PUBLIC", + "inReport": True, + }, + }, + ], + "optionalParameters": [ + { + "name": "handsetId", + "display": { + "name": "Device ID", + "editorLevel": "PUBLIC", + "inReport": False, + }, + }, + ], + }, + } + + +def _comment_command_definition() -> dict: + return { + "commandId": "comment", + "name": "Comment", + "data": { + "display": {"name": "Comment"}, + "mandatoryParameters": [ + { + "name": "text", + "display": { + "name": "Text", + "editorLevel": "PUBLIC", + "inReport": True, + }, + }, + ], + }, + } + + +def _tree_api_payload() -> dict: + script = new_empty_script() + tap = build_flow_element("ai_user-action", {"action": "Tap login"}) + tap["uuid"] = "ignored-in-formatter" + group = build_logical_step("Setup") + group["flowElements"] = [build_flow_element("comment", {"text": "inside group"})] + condition = build_if_statement("x == 1", "Check x") + condition["branches"][0]["flowElements"] = [ + build_flow_element("ai_validation", {"validation": "OK"}), + ] + script["flowElements"] = [tap, group, condition] + return { + "script": script, + "commandDefinitions": [ + _comment_command_definition(), + ], + } + + +def _tests_tree_response() -> dict: + return { + "items": [ + { + "visibility": "PRIVATE", + "items": [ + { + "type": "CONTAINER", + "items": [ + { + "type": "SIMPLE", + "key": "PRIVATE:My Folder/Login.xml", + "name": "Login.xml", + "createdBy": "alice", + "modifiedBy": "bob", + "creationTime": {"formatted": "2024-01-01"}, + "modificationTime": {"formatted": "2024-01-02"}, + }, + ], + }, + ], + }, + ], + } + + +class TestCommandSelectionPolicy: + def test_includes_primary_ai_command_ids(self): + lines = command_selection_policy_info() + assert any("ai_user-action" in line for line in lines) + assert any("ai_validation" in line for line in lines) + assert any("ai_visual-comparison" in line for line in lines) + + +class TestFormatTestStructure: + def test_formats_nested_flow_with_step_paths(self): + structure = format_test_structure( + _tree_api_payload(), + params={"item_key": "PRIVATE:My Folder/Login.xml"}, + ) + assert structure.item_key == "PRIVATE:My Folder/Login.xml" + assert structure.parameters[0].name == "DUT" + assert structure.flow_elements[0].name == "Tap login" + assert structure.flow_elements[0].step_path == "0" + assert structure.flow_elements[1].name == "Setup" + assert structure.flow_elements[1].children[0].step_path == "1.0" + assert structure.flow_elements[2].name == "Condition (x == 1)" + then_branch = structure.flow_elements[2].children[0] + assert then_branch.type == "Branch" + assert then_branch.step_path == "2.b0" + assert then_branch.children[0].step_path == "2.b0.0" + assert then_branch.children[0].name == "OK" + + def test_uses_definition_display_name_for_non_ai_commands(self): + structure = format_test_structure(_tree_api_payload()) + comment_step = structure.flow_elements[1].children[0] + assert comment_step.name == "Comment (Text: inside group)" + + +class TestCommandStepDisplayName: + def test_ai_validation_uses_validation_argument_as_display_name(self): + element = build_flow_element("ai_validation", {"validation": "Screen visible"}) + assert _step_display_name(element, {}) == "Screen visible" + + def test_ai_validation_without_validation_text_uses_base_name_not_device_id(self): + element = build_flow_element("ai_validation") + definitions_map = _definitions_map([_ai_validation_command_definition()]) + assert _step_display_name(element, definitions_map) == "AI Validation" + assert _command_step_display_name(element, "ai_validation", definitions_map) == "AI Validation" + + def test_ai_user_action_without_action_text_uses_base_name_not_device_id(self): + element = build_flow_element("ai_user-action") + definitions_map = _definitions_map([ + { + "commandId": "ai_user-action", + "name": "AI User Action", + "data": { + "display": {"name": "AI User Action"}, + "mandatoryParameters": [ + { + "name": "action", + "display": { + "name": "Action", + "editorLevel": "PUBLIC", + "inReport": True, + }, + }, + ], + "optionalParameters": [ + { + "name": "handsetId", + "display": { + "name": "Device ID", + "editorLevel": "PUBLIC", + "inReport": False, + }, + }, + ], + }, + }, + ]) + assert _step_display_name(element, definitions_map) == "AI User Action" + + def test_serializes_public_parameters_like_perfecto_ui(self): + element = build_flow_element("comment", {"text": "Hello Comment!"}) + definitions_map = _definitions_map([_comment_command_definition()]) + assert _command_step_display_name(element, "comment", definitions_map) == ( + "Comment (Text: Hello Comment!)" + ) + + def test_skips_non_public_parameters(self): + element = build_flow_element("wait", {"duration": "5"}) + definitions_map = _definitions_map([ + { + "commandId": "wait", + "name": "Wait", + "data": { + "display": {"name": "Wait"}, + "mandatoryParameters": [ + { + "name": "duration", + "display": { + "name": "Duration", + "editorLevel": "PUBLIC", + "inReport": True, + }, + }, + ], + "optionalParameters": [ + { + "name": "handsetId", + "display": { + "name": "Device", + "editorLevel": "PRIVATE", + "inReport": False, + }, + }, + ], + }, + }, + ]) + assert _command_step_display_name(element, "wait", definitions_map) == "Wait (Duration: 5)" + + def test_skips_public_parameters_when_in_report_is_false(self): + element = build_flow_element("wait", {"duration": "5"}) + definitions_map = _definitions_map([ + { + "commandId": "wait", + "name": "Wait", + "data": { + "display": {"name": "Wait"}, + "mandatoryParameters": [ + { + "name": "duration", + "display": { + "name": "Duration", + "editorLevel": "PUBLIC", + "inReport": False, + }, + }, + ], + }, + }, + ]) + assert _command_step_display_name(element, "wait", definitions_map) == "Wait" + + def test_returns_base_name_when_public_parameter_has_no_value(self): + element = build_flow_element("comment") + definitions_map = _definitions_map([_comment_command_definition()]) + assert _command_step_display_name(element, "comment", definitions_map) == "Comment" + + def test_falls_back_to_base_name_without_definition(self): + element = build_flow_element("comment", {"text": "orphan"}) + assert _command_step_display_name(element, "comment", {}) is None + + +class TestFormatCommandCatalog: + def test_flattens_nested_catalog(self): + catalog = { + "name": "Root", + "children": [ + { + "name": "AI", + "children": [ + { + "commandId": "ai_user-action", + "name": "User action", + "path": "/ai/user-action", + "status": "GA", + }, + ], + }, + ], + } + entries = format_command_catalog(catalog) + assert len(entries) == 1 + assert entries[0].command_id == "ai_user-action" + assert entries[0].category == "AI" + + +class TestFormatTestVariables: + def test_formats_secured_and_runtime_variables(self): + variables = [ + { + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "plain", + "value": "hello", + "secured": False, + }, + }, + { + "@type": "Parameter", + "data": { + "@type": "StringData", + "name": "secret", + "value": "hidden", + "secured": True, + }, + }, + ] + formatted = format_test_variables(variables) + assert formatted[0].name == "plain" + assert formatted[0].value == "hello" + assert formatted[0].set_at_runtime is False + assert formatted[1].value == "" + assert formatted[1].set_at_runtime is True + + def test_returns_empty_list_for_invalid_input(self): + assert format_test_variables(None) == [] + assert format_test_variables("bad") == [] + + +class TestFormatSnapshotsList: + def test_marks_current_entry_and_sorts_first(self): + result = format_snapshots_list( + { + "snapshots": [ + {"key": "uuid-1", "createdBy": "alice"}, + {"key": "", "comment": "latest"}, + ], + }, + params={"test_id": "PRIVATE:Folder/Test.xml"}, + ) + assert result.test_id == "PRIVATE:Folder/Test.xml" + assert result.count == 2 + assert result.snapshots[0].is_current is True + assert result.snapshots[0].key == "" + assert len(result.notes) >= 1 + + +class TestFormatCommandDefinitions: + def test_parses_definition_payload(self): + definitions = format_command_definitions({ + "definitions": [ + { + "commandId": "ai_validation", + "data": { + "display": {"name": "AI validation", "helpText": "Assert with AI"}, + "mandatoryParameters": [{"name": "validation"}], + "optionalParameters": [{"name": "handsetId"}], + }, + }, + ], + }) + assert definitions[0].command_id == "ai_validation" + assert definitions[0].name == "AI validation" + assert definitions[0].mandatory_parameters == ["validation"] + assert definitions[0].optional_parameters == ["handsetId"] + + +class TestFormatTestsTree: + def test_extracts_filter_values(self): + values = format_ai_scriptless_tests_filter_values(_tests_tree_response()) + assert "Login" in values["test_name"] + assert "alice" in values["owner_list"] + assert "bob" in values["owner_list"] + + def test_applies_pagination_and_visibility_filter(self): + formatted = format_ai_scriptless_tests( + _tests_tree_response(), + params={"page_size": 10, "skip": 0, "filters": {"visibility": "PRIVATE"}}, + ) + assert len(formatted) == 1 + assert "PRIVATE:My Folder/Login.xml" in formatted[0] + assert "name:Login" in formatted[0] + + +class TestFormatterCommandId: + def test_replaces_slashes_in_subcommand(self): + assert _command_id("webpage", "element/click") == "webpage_element_click" diff --git a/tests/test_ai_scriptless_if_statement_tree.py b/tests/test_ai_scriptless_if_statement_tree.py new file mode 100644 index 0000000..9611466 --- /dev/null +++ b/tests/test_ai_scriptless_if_statement_tree.py @@ -0,0 +1,341 @@ +""" +IfStatement tree contract: branches[] vs thenClause/elseClause. + +Documents failure modes without normalization and expected behavior after +normalize_if_statement_aliases + tree mutations. +""" + +from __future__ import annotations + +import asyncio +import copy + +import pytest + +from formatters.ai_scriptless import _format_root_flow_elements, format_test_structure +from models.result import BaseResult +from tools.ai_scriptless import persistence +from tools.ai_scriptless.elements import ( + build_branch, + build_flow_element, + build_if_statement, + build_logical_step, + new_empty_script, + normalize_if_statement_aliases, + strip_non_api_script_fields, +) +from tools.ai_scriptless.persistence import _persist_script +from tools.ai_scriptless.tree import ( + delete_element_by_path, + find_element_by_path, + find_step_path_for_element, + insert_flow_element, + move_element_by_path, +) + + +def _comment(text: str) -> dict: + return build_flow_element("comment", {"text": text}) + + +def _api_style_if_statement( + *, + then_in_branch: list | None = None, + then_in_clause: list | None = None, + else_in_branch: list | None = None, + else_in_clause: list | None = None, + label: str = "API", +) -> dict: + """Perfecto-like payload: branches and clauses are separate objects.""" + then_branch = build_branch("THEN") + else_branch = build_branch("ELSE") + then_clause = build_branch("THEN") + else_clause = build_branch("ELSE") + if then_in_branch is not None: + then_branch["flowElements"] = list(then_in_branch) + if then_in_clause is not None: + then_clause["flowElements"] = list(then_in_clause) + if else_in_branch is not None: + else_branch["flowElements"] = list(else_in_branch) + if else_in_clause is not None: + else_clause["flowElements"] = list(else_in_clause) + return { + "@type": "IfStatement", + "branches": [then_branch, else_branch], + "thenClause": then_clause, + "elseClause": else_clause, + "label": label, + "active": True, + } + + +def _script_with_if_statement(ifs: dict) -> dict: + script = new_empty_script() + script["flowElements"] = [ifs] + return script + + +def _then_child_commands(ifs: dict) -> list[str | None]: + return [ + el.get("command") + for el in ifs["branches"][0].get("flowElements", []) + ] + + +def _clause_child_commands(ifs: dict) -> list[str | None]: + return [ + el.get("command") + for el in ifs["thenClause"].get("flowElements", []) + ] + + +class TestIfStatementBuilderContract: + def test_new_builder_aliases_clauses_to_branches(self): + ifs = build_if_statement("x", "Check") + assert ifs["thenClause"] is ifs["branches"][0] + assert ifs["elseClause"] is ifs["branches"][1] + + def test_mutation_through_branch_updates_clause(self): + ifs = build_if_statement() + ifs["branches"][0]["flowElements"].append(_comment("a")) + assert len(ifs["thenClause"]["flowElements"]) == 1 + + +class TestIfStatementWithoutNormalize: + """Documents broken or inconsistent behavior when API payload is not normalized.""" + + def test_insert_into_then_branch_does_not_update_then_clause(self): + ifs = _api_style_if_statement() + script = _script_with_if_statement(ifs) + insert_flow_element(script, _comment("orphan"), parent_path="0.b0") + assert len(ifs["branches"][0]["flowElements"]) == 1 + assert len(ifs["thenClause"]["flowElements"]) == 0 + + def test_formatter_reads_branches_not_clauses(self): + ifs = _api_style_if_statement( + then_in_clause=[_comment("only in clause")], + ) + script = _script_with_if_statement(ifs) + formatted = _format_root_flow_elements(script["flowElements"], {}) + then_branch = formatted[0].children[0] + assert then_branch.children == [] + + def test_find_step_path_ignores_then_clause_object(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("c")]) + script = _script_with_if_statement(ifs) + assert find_step_path_for_element(script, ifs["thenClause"]) is None + + def test_deepcopy_breaks_nothing_if_already_aliased(self): + ifs = build_if_statement() + ifs["branches"][0]["flowElements"].append(_comment("x")) + copied = copy.deepcopy(_script_with_if_statement(ifs)) + condition = copied["flowElements"][0] + assert condition["thenClause"] is condition["branches"][0] + assert len(condition["thenClause"]["flowElements"]) == 1 + + def test_deepcopy_keeps_duplicate_trees_separate(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("c")]) + copied = copy.deepcopy(_script_with_if_statement(ifs)) + condition = copied["flowElements"][0] + assert condition["thenClause"] is not condition["branches"][0] + assert len(condition["branches"][0]["flowElements"]) == 0 + assert len(condition["thenClause"]["flowElements"]) == 1 + + +class TestNormalizeIfStatementAliases: + def test_syncs_children_from_then_clause_when_branch_empty(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("from clause")]) + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + assert ifs["thenClause"] is ifs["branches"][0] + assert _then_child_commands(ifs) == ["comment"] + + def test_syncs_children_from_else_clause_when_branch_empty(self): + ifs = _api_style_if_statement(else_in_clause=[_comment("else")]) + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + assert ifs["elseClause"] is ifs["branches"][1] + assert len(ifs["branches"][1]["flowElements"]) == 1 + + def test_prefers_branch_when_both_have_same_length_different_children(self): + ifs = _api_style_if_statement( + then_in_branch=[_comment("branch wins")], + then_in_clause=[_comment("clause loses")], + ) + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + texts = [ + el["arguments"][0]["data"]["value"] + for el in ifs["branches"][0]["flowElements"] + ] + assert texts == ["branch wins"] + assert ifs["thenClause"] is ifs["branches"][0] + + def test_prefers_clause_when_it_has_more_children(self): + ifs = _api_style_if_statement( + then_in_branch=[_comment("one")], + then_in_clause=[_comment("one"), _comment("two")], + ) + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + assert len(ifs["branches"][0]["flowElements"]) == 2 + assert ifs["thenClause"] is ifs["branches"][0] + + def test_nested_if_inside_then_branch(self): + inner = _api_style_if_statement(then_in_clause=[_comment("inner")]) + outer = build_if_statement("outer", "Outer") + outer["branches"][0]["flowElements"] = [inner] + outer["thenClause"] = build_branch("THEN") + script = _script_with_if_statement(outer) + normalize_if_statement_aliases(script) + nested = outer["branches"][0]["flowElements"][0] + assert nested["thenClause"] is nested["branches"][0] + assert len(nested["branches"][0]["flowElements"]) == 1 + + def test_nested_if_inside_logical_step(self): + inner = _api_style_if_statement(then_in_clause=[_comment("deep")]) + group = build_logical_step("G") + group["flowElements"] = [inner] + script = new_empty_script() + script["flowElements"] = [group] + normalize_if_statement_aliases(script) + nested = group["flowElements"][0] + assert nested["thenClause"] is nested["branches"][0] + + +class TestIfStatementTreeAfterNormalize: + @pytest.fixture + def script(self) -> dict: + ifs = _api_style_if_statement() + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + return script + + def test_insert_into_then_updates_clause(self, script: dict): + insert_flow_element(script, _comment("step"), parent_path="0.b0") + ifs = script["flowElements"][0] + assert _then_child_commands(ifs) == ["comment"] + assert _clause_child_commands(ifs) == ["comment"] + + def test_delete_from_then_clears_clause(self, script: dict): + insert_flow_element(script, _comment("step"), parent_path="0.b0") + delete_element_by_path(script, "0.b0.0") + ifs = script["flowElements"][0] + assert _then_child_commands(ifs) == [] + assert _clause_child_commands(ifs) == [] + + def test_move_from_then_to_else(self, script: dict): + insert_flow_element(script, _comment("movable"), parent_path="0.b0") + move_element_by_path(script, "0.b0.0", parent_path="0.b1") + ifs = script["flowElements"][0] + assert _then_child_commands(ifs) == [] + assert [el.get("command") for el in ifs["branches"][1]["flowElements"]] == ["comment"] + assert ifs["elseClause"] is ifs["branches"][1] + + def test_find_paths_after_insert(self, script: dict): + insert_flow_element(script, _comment("step"), parent_path="0.b0") + ifs = script["flowElements"][0] + child = ifs["branches"][0]["flowElements"][0] + assert find_step_path_for_element(script, child) == "0.b0.0" + assert find_step_path_for_element(script, ifs["branches"][0]) == "0.b0" + assert find_step_path_for_element(script, ifs["thenClause"]) == "0.b0" + + def test_find_element_round_trip(self, script: dict): + insert_flow_element(script, _comment("step"), parent_path="0.b0") + located = find_element_by_path(script, "0.b0.0") + assert located is not None + _, _, element = located + assert element["command"] == "comment" + + def test_insert_nested_condition_in_then(self, script: dict): + inner = build_if_statement("inner", "Inner") + insert_flow_element(script, inner, parent_path="0.b0") + nested = script["flowElements"][0]["branches"][0]["flowElements"][0] + assert nested["thenClause"] is nested["branches"][0] + insert_flow_element(script, _comment("deep"), parent_path="0.b0.0.b0") + assert len(nested["branches"][0]["flowElements"]) == 1 + + +class TestIfStatementPersistPreparation: + def test_persist_deepcopy_keeps_alias_and_children(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + if endpoint and "draft-management" in endpoint: + return BaseResult(result={"key": "d1"}) + captured["save_script"] = kwargs["json"]["script"] + return BaseResult(result={"status": "success"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + ifs = _api_style_if_statement() + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + insert_flow_element(script, _comment("persist me"), parent_path="0.b0") + + asyncio.run( + _persist_script(perfecto_token, "PRIVATE:F/T.xml", script, script) + ) + + saved = captured["save_script"]["flowElements"][0] + assert len(saved["branches"][0]["flowElements"]) == 1 + assert len(saved["thenClause"]["flowElements"]) == 1 + assert saved["branches"][0]["flowElements"][0]["command"] == "comment" + + def test_persist_renormalizes_branch_only_edits(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + if endpoint and "draft-management" in endpoint: + return BaseResult(result={"key": "d1"}) + captured["save_script"] = kwargs["json"]["script"] + return BaseResult(result={"status": "success"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + ifs = _api_style_if_statement() + script = _script_with_if_statement(ifs) + insert_flow_element(script, _comment("branch only"), parent_path="0.b0") + assert len(ifs["thenClause"]["flowElements"]) == 0 + + asyncio.run( + _persist_script(perfecto_token, "PRIVATE:F/T.xml", script, script) + ) + + saved = captured["save_script"]["flowElements"][0] + assert len(saved["thenClause"]["flowElements"]) == 1 + assert saved["thenClause"] is saved["branches"][0] + + def test_strip_non_api_fields_with_aliased_branches_is_safe(self): + ifs = build_if_statement() + ifs["branches"][0]["uuid"] = "remove-me" + ifs["branches"][0]["flowElements"] = [_comment("x")] + script = _script_with_if_statement(ifs) + strip_non_api_script_fields(script) + assert "uuid" not in ifs["branches"][0] + assert len(ifs["thenClause"]["flowElements"]) == 1 + + +class TestIfStatementFormatterAfterNormalize: + def test_formatter_shows_then_children_after_normalize(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("visible")]) + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + structure = format_test_structure({"script": script}, {"item_key": "PRIVATE:F/T.xml"}) + then_branch = structure.flow_elements[0].children[0] + assert len(then_branch.children) == 1 + assert then_branch.children[0].command == "comment" + + def test_formatter_on_raw_api_payload_without_normalize_hides_clause_only_children(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("hidden")]) + script = _script_with_if_statement(ifs) + formatted = _format_root_flow_elements(script["flowElements"], {}) + then_branch = formatted[0].children[0] + assert then_branch.children == [] + + def test_format_test_structure_normalizes_before_display(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("visible via normalize")]) + script = _script_with_if_statement(ifs) + structure = format_test_structure({"script": script}, {"item_key": "PRIVATE:F/T.xml"}) + then_branch = structure.flow_elements[0].children[0] + assert len(then_branch.children) == 1 diff --git a/tests/test_ai_scriptless_manager.py b/tests/test_ai_scriptless_manager.py new file mode 100644 index 0000000..d2e19f7 --- /dev/null +++ b/tests/test_ai_scriptless_manager.py @@ -0,0 +1,1438 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import asyncio +import copy +import json + +import httpx +import pytest + +from config.perfecto import SUPPORT_MESSAGE +from models.result import BaseResult +from tools import ai_scriptless_manager +from tools.ai_scriptless.elements import ( + build_flow_element, + build_if_statement, + build_logical_step, + new_empty_script, +) +from tools.ai_scriptless_manager import AiScriptlessManager, STEP_PATH_REFRESH_NOTES + +TEST_ID = "PRIVATE:Folder/Test.xml" + + +def _tests_tree_response() -> dict: + return { + "items": [ + { + "visibility": "PRIVATE", + "items": [ + { + "type": "CONTAINER", + "items": [ + { + "type": "SIMPLE", + "key": "PRIVATE:My Folder/Login.xml", + "name": "Login.xml", + "createdBy": "alice", + "modifiedBy": "bob", + "creationTime": {"formatted": "2024-01-01"}, + "modificationTime": {"formatted": "2024-01-02"}, + }, + ], + }, + ], + }, + ], + } + + +def _script_payload() -> dict: + script = new_empty_script() + script["flowElements"] = [build_flow_element("wait")] + return {"script": script, "commandDefinitions": []} + + +def _script_with_logical_group() -> dict: + group = build_logical_step("Setup") + group["flowElements"] = [] + script = new_empty_script() + script["flowElements"] = [group] + script["numOfFlowElements"] = 1 + return script + + +def _script_with_steps(*command_ids: str) -> dict: + script = new_empty_script() + script["flowElements"] = [build_flow_element(command_id) for command_id in command_ids] + script["numOfFlowElements"] = len(script["flowElements"]) + return script + + +def _mock_load_and_mutate(monkeypatch, initial_script: dict | None = None, captured: dict | None = None): + base_script = copy.deepcopy(initial_script or new_empty_script()) + + async def fake_load_and_mutate(_token, test_id, mutator, snapshot_comment=None): + script = copy.deepcopy(base_script) + try: + mutator(script) + except ValueError as exc: + return BaseResult(error=str(exc)) + if captured is not None: + captured["script"] = script + captured["test_id"] = test_id + captured["snapshot_comment"] = snapshot_comment + return BaseResult(result={"item_key": test_id, "draft_key": "draft-1", "status": "ok"}) + + monkeypatch.setattr(ai_scriptless_manager, "load_and_mutate", fake_load_and_mutate) + + +def _assert_step_path_notes(result: BaseResult) -> None: + assert result.error is None + for note in STEP_PATH_REFRESH_NOTES: + assert note in result.result["notes"] + + +class TestExecuteTestDeviceMapping: + def test_real_device_accepts_snake_case_device_id(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-1"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "real", + {"device_id": "DEVICE-123"}, + )) + + assert result.error is None + assert captured["json"]["params"]["DUT"] == "DEVICE-123" + assert captured["json"]["testKey"] == "PRIVATE:Folder/Test.xml" + + def test_real_device_accepts_perfecto_device_id_key(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-1"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "real", + {"deviceId": "DEVICE-456"}, + )) + + assert result.error is None + assert captured["json"]["params"]["DUT"] == "DEVICE-456" + + def test_real_device_requires_device_id(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "real", + {}, + )) + assert "device_id could not be found" in result.error + + def test_virtual_device_serializes_capabilities_json(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-2"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "virtual", + { + "platform_name": "Android", + "manufacturer": "Google", + "model": "Pixel 8", + "platform_version": "14", + }, + )) + + assert result.error is None + dut = json.loads(captured["json"]["params"]["DUT"]) + assert dut["platformName"] == "Android" + assert dut["manufacturer"] == "Google" + + def test_virtual_device_sends_null_for_unspecified_capabilities(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-3"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "virtual", + {"platform_name": "Android"}, + )) + + assert result.error is None + dut = json.loads(captured["json"]["params"]["DUT"]) + assert dut["platformName"] == "Android" + assert dut["manufacturer"] is None + assert dut["model"] is None + + def test_invalid_device_type_returns_error(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "unknown", + {"device_id": "x"}, + )) + assert result.error == "Invalid device_type or device_under_test value." + + def test_desktop_device_serializes_capabilities_json(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-desktop"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + TEST_ID, + "desktop", + { + "platform_name": "Windows", + "platform_version": "11", + "browser_name": "Chrome", + "browser_version": "120", + "resolution": "1920x1080", + "location": "US", + }, + )) + + assert result.error is None + dut = json.loads(captured["json"]["params"]["DUT"]) + assert dut["platformName"] == "Windows" + assert dut["browserName"] == "Chrome" + assert dut["resolution"] == "1920x1080" + + def test_desktop_device_sends_null_for_unspecified_capabilities(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-desktop-partial"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + TEST_ID, + "desktop", + {"platform_name": "Windows"}, + )) + + assert result.error is None + dut = json.loads(captured["json"]["params"]["DUT"]) + assert dut["platformName"] == "Windows" + assert dut["browserName"] is None + assert dut["location"] is None + + +class TestListAndReadOperations: + def test_list_tests_returns_paginated_items(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request( + _token, + method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + captured["endpoint"] = endpoint + from formatters.ai_scriptless import format_ai_scriptless_tests + formatted = format_ai_scriptless_tests( + _tests_tree_response(), + result_formatter_params, + ) + return BaseResult(result=formatted) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + monkeypatch.setattr( + ai_scriptless_manager.perfecto, + "get_ai_scriptless_api_url", + lambda _cloud: "https://demo.app.perfectomobile.com/perfectomobile/ai-scriptless/api", + ) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_tests({"page_index": 1, "visibility": "PRIVATE"})) + + assert result.error is None + assert result.result.count == 1 + assert "PRIVATE:My Folder/Login.xml" in result.result.items[0] + assert result.info is not None + assert captured["endpoint"].endswith("/scripts/tree") + + def test_list_tests_propagates_api_error(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **_kwargs): + return BaseResult(error="tree unavailable") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_tests({})) + assert result.error == "tree unavailable" + + def test_list_filter_values_returns_requested_filters(self, perfecto_token, monkeypatch): + async def fake_api_request( + _token, + _method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + from formatters.ai_scriptless import format_ai_scriptless_tests_filter_values + formatted = format_ai_scriptless_tests_filter_values(_tests_tree_response()) + return BaseResult(result=formatted) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_filter_values(["test_name", "owner_list"])) + + assert result.error is None + assert "Login" in result.result["test_name"] + assert "alice" in result.result["owner_list"] + + def test_list_filter_values_rejects_unknown_filter(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **kwargs): + from formatters.ai_scriptless import format_ai_scriptless_tests_filter_values + return BaseResult(result=format_ai_scriptless_tests_filter_values(_tests_tree_response())) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_filter_values(["bad_filter"])) + + assert "invalid filter_names" in result.error + assert result.warning is not None + + def test_list_commands_appends_selection_policy(self, perfecto_token, monkeypatch): + catalog = { + "name": "Root", + "children": [ + { + "commandId": "ai_user-action", + "name": "User action", + "path": "/ai/user-action", + }, + ], + } + + async def fake_api_request( + _token, + _method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + from formatters.ai_scriptless import format_command_catalog + return BaseResult(result=format_command_catalog(catalog)) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_commands()) + + assert result.error is None + assert result.result[0].command_id == "ai_user-action" + assert any("ai_user-action" in line for line in result.info) + + def test_list_commands_checkpoint_query_param(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["endpoint"] = endpoint + return BaseResult(result=[]) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_commands(checkpoint=True)) + + assert result.error is None + assert "checkpoint=true" in captured["endpoint"] + + def test_view_test_structure_formats_script(self, perfecto_token, monkeypatch): + async def fake_api_request( + _token, + _method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + from formatters.ai_scriptless import format_test_structure + return BaseResult(result=format_test_structure( + _script_payload(), + result_formatter_params, + )) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.view_test_structure(TEST_ID)) + + assert result.error is None + assert result.result.item_key == TEST_ID + assert result.result.flow_elements[0].step_path == "0" + assert result.info is not None + + def test_view_test_structure_requires_test_id(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.view_test_structure("")) + assert result.error == "test_id is required (itemKey from list_tests)" + + def test_get_command_definitions_posts_ids(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + from formatters.ai_scriptless import format_command_definitions + return BaseResult(result=format_command_definitions({ + "definitions": [{ + "commandId": "wait", + "data": {"display": {"name": "Wait"}, "mandatoryParameters": [], "optionalParameters": []}, + }], + })) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.get_command_definitions(["wait"])) + + assert result.error is None + assert captured["json"]["commandIds"] == ["wait"] + assert result.result[0].command_id == "wait" + + def test_view_snapshot_fetches_historical_snapshot(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request( + _token, + method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + captured["endpoint"] = endpoint + from formatters.ai_scriptless import format_test_structure + return BaseResult(result=format_test_structure( + _script_payload(), + result_formatter_params, + )) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.view_snapshot("PRIVATE:Folder/Test.xml@uuid-1")) + + assert result.error is None + assert "snapshots?itemKey=" in captured["endpoint"] + assert result.result.flow_elements[0].step_path == "0" + + +class TestManagerValidation: + def test_add_command_requires_test_id(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command("", "ai_user-action")) + assert result.error == "test_id is required" + + def test_add_command_requires_command_id(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command("PRIVATE:Folder/Test.xml", "")) + assert "command_id is required" in result.error + + def test_view_snapshot_rejects_current_marker(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.view_snapshot("")) + assert "not a historical snapshot" in result.error + + def test_modify_command_requires_arguments(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.modify_command(TEST_ID, "0", {})) + assert result.error == "arguments is required" + + def test_save_test_requires_test_id(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.save_test("")) + assert result.error == "test_id is required" + + def test_create_test_requires_name(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.create_test("")) + assert result.error == "name is required" + + def test_save_test_as_requires_name(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.save_test_as(TEST_ID, "")) + assert result.error == "name is required" + + +class TestCommandMutations: + def test_add_command_inserts_and_returns_step_path(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command( + TEST_ID, + "wait", + arguments={"duration": "3"}, + )) + + _assert_step_path_notes(result) + assert result.result["command_id"] == "wait" + assert result.result["step_path"] == "0" + assert len(captured["script"]["flowElements"]) == 1 + assert captured["script"]["flowElements"][0]["command"] == "wait" + + def test_modify_command_updates_arguments(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait"), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.modify_command( + TEST_ID, + "0", + {"duration": "5"}, + )) + + _assert_step_path_notes(result) + args = { + a["name"]: a["data"]["value"] + for a in captured["script"]["flowElements"][0]["arguments"] + } + assert args["duration"] == "5" + + def test_modify_command_returns_error_for_missing_step_path(self, perfecto_token, monkeypatch): + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait")) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.modify_command(TEST_ID, "9", {"duration": "1"})) + + assert result.error == "step_path not found: 9" + + def test_delete_command_removes_step(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait", "comment"), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.delete_command(TEST_ID, "0")) + + _assert_step_path_notes(result) + assert len(captured["script"]["flowElements"]) == 1 + assert captured["script"]["flowElements"][0]["command"] == "comment" + + def test_set_command_enabled(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait"), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.set_command_enabled(TEST_ID, "0", False)) + + _assert_step_path_notes(result) + assert result.result["active"] is False + assert captured["script"]["flowElements"][0]["active"] is False + + def test_move_command_requires_target_path(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_command(TEST_ID, "0")) + assert result.error == "after_path or parent_path is required" + + def test_move_command_reorders_within_root(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait", "comment"), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_command(TEST_ID, "0", after_path="0")) + + _assert_step_path_notes(result) + commands = [e["command"] for e in captured["script"]["flowElements"]] + assert commands == ["comment", "wait"] + + def test_add_command_after_path(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait", "comment"), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command( + TEST_ID, + "ai_user-action", + arguments={"action": "Tap"}, + after_path="0", + )) + + _assert_step_path_notes(result) + assert result.result["step_path"] == "1" + assert result.result["command_id"] == "ai_user-action" + assert len(captured["script"]["flowElements"]) == 3 + assert captured["script"]["flowElements"][1]["command"] == "ai" + + def test_add_command_inside_logical_step(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_logical_group(), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command( + TEST_ID, + "comment", + arguments={"text": "inside"}, + parent_path="0", + )) + + _assert_step_path_notes(result) + nested = captured["script"]["flowElements"][0]["flowElements"] + assert len(nested) == 1 + assert nested[0]["command"] == "comment" + assert result.result["step_path"] == "0.0" + + def test_add_command_propagates_load_and_mutate_error(self, perfecto_token, monkeypatch): + async def fake_load_and_mutate(*_args, **_kwargs): + return BaseResult(error="persist failed") + + monkeypatch.setattr(ai_scriptless_manager, "load_and_mutate", fake_load_and_mutate) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command(TEST_ID, "wait")) + assert result.error == "persist failed" + + def test_move_command_into_logical_step(self, perfecto_token, monkeypatch): + captured: dict = {} + group = build_logical_step("Setup") + script = new_empty_script() + script["flowElements"] = [group, build_flow_element("wait")] + script["numOfFlowElements"] = 2 + _mock_load_and_mutate(monkeypatch, script, captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_command(TEST_ID, "1", parent_path="0")) + + _assert_step_path_notes(result) + assert captured["script"]["flowElements"][0]["flowElements"][0]["command"] == "wait" + assert len(captured["script"]["flowElements"]) == 1 + + +class TestStructureMutations: + def test_add_logical_step(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_logical_step(TEST_ID, label="Setup")) + + _assert_step_path_notes(result) + assert result.result["structure_type"] == "LogicalStep" + assert captured["script"]["flowElements"][0]["@type"] == "LogicalStep" + + def test_add_logical_step_inside_container(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_logical_group(), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_logical_step(TEST_ID, label="Nested", parent_path="0")) + + _assert_step_path_notes(result) + nested = captured["script"]["flowElements"][0]["flowElements"] + assert len(nested) == 1 + assert nested[0]["@type"] == "LogicalStep" + assert result.result["step_path"] == "0.0" + + def test_add_loop_rejects_invalid_count(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_loop(TEST_ID, count=0)) + assert result.error == "count must be at least 1" + + def test_add_loop_returns_count(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_loop(TEST_ID, count=3)) + + _assert_step_path_notes(result) + assert result.result["count"] == 3 + assert captured["script"]["flowElements"][0]["iterator"]["count"] == 3 + + def test_add_loop_inside_logical_step(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_logical_group(), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_loop(TEST_ID, count=2, parent_path="0")) + + _assert_step_path_notes(result) + nested = captured["script"]["flowElements"][0]["flowElements"] + assert len(nested) == 1 + assert nested[0]["@type"] == "Loop" + assert nested[0]["iterator"]["count"] == 2 + assert result.result["step_path"] == "0.0" + + def test_add_condition_with_expression(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_condition(TEST_ID, expression="x == 1", label="Check")) + + _assert_step_path_notes(result) + assert result.result["expression"] == "x == 1" + assert captured["script"]["flowElements"][0]["@type"] == "IfStatement" + + def test_add_condition_inside_then_branch(self, perfecto_token, monkeypatch): + captured: dict = {} + script = new_empty_script() + script["flowElements"] = [build_if_statement("x == 1", "Check")] + _mock_load_and_mutate(monkeypatch, script, captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_condition( + TEST_ID, + expression="y == 2", + label="Nested", + parent_path="0.b0", + )) + + _assert_step_path_notes(result) + then_branch = captured["script"]["flowElements"][0]["branches"][0] + assert then_branch["flowElements"][0]["@type"] == "IfStatement" + assert result.result["step_path"] == "0.b0.0" + + def test_set_condition_expression(self, perfecto_token, monkeypatch): + captured: dict = {} + script = new_empty_script() + script["flowElements"] = [build_if_statement("old", "If")] + _mock_load_and_mutate(monkeypatch, script, captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.set_condition_expression(TEST_ID, "0", "new == true")) + + _assert_step_path_notes(result) + assert result.result["expression"] == "new == true" + assert captured["script"]["flowElements"][0]["expression"] == "new == true" + + def test_set_condition_expression_requires_expression(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.set_condition_expression(TEST_ID, "0", "")) + assert result.error == "expression is required" + + +class TestVariableOperations: + def test_list_test_variables(self, perfecto_token, monkeypatch): + script = new_empty_script() + script["variables"] = [{ + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "token", + "value": "abc", + "secured": False, + "description": None, + "displayName": None, + }, + }] + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": script}) + + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_test_variables(TEST_ID)) + + assert result.error is None + assert len(result.result) == 1 + assert result.result[0].name == "token" + + def test_list_test_variables_propagates_fetch_error(self, perfecto_token, monkeypatch): + async def fake_fetch(_token, _test_id): + return BaseResult(error="script missing") + + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_test_variables(TEST_ID)) + assert result.error == "script missing" + + def test_add_test_variable(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_test_variable( + TEST_ID, "count", "number", 42, set_at_runtime=True, + )) + + assert result.error is None + assert result.result["name"] == "count" + assert result.result["type"] == "number" + assert result.result["set_at_runtime"] is True + assert captured["script"]["variables"][0]["@type"] == "Parameter" + + def test_modify_test_variable_requires_change(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.modify_test_variable(TEST_ID, "token")) + assert "At least one of value, variable_type, or set_at_runtime is required" in result.error + + def test_modify_test_variable(self, perfecto_token, monkeypatch): + captured: dict = {} + script = new_empty_script() + script["variables"] = [{ + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "token", + "value": "old", + "secured": False, + "description": None, + "displayName": None, + }, + }] + _mock_load_and_mutate(monkeypatch, script, captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.modify_test_variable(TEST_ID, "token", value="new")) + + assert result.error is None + assert captured["script"]["variables"][0]["data"]["value"] == "new" + + def test_delete_test_variable(self, perfecto_token, monkeypatch): + captured: dict = {} + script = new_empty_script() + script["variables"] = [{ + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "token", + "value": "x", + "secured": False, + "description": None, + "displayName": None, + }, + }] + _mock_load_and_mutate(monkeypatch, script, captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.delete_test_variable(TEST_ID, "token")) + + assert result.error is None + assert captured["script"]["variables"] == [] + + +class TestRepositoryOperations: + def test_create_test_persists_empty_script(self, perfecto_token, monkeypatch): + persisted: dict = {} + + async def fake_persist(_token, item_key, script, saved_script=None, snapshot_comment=None): + persisted["item_key"] = item_key + persisted["flow_count"] = len(script.get("flowElements", [])) + return BaseResult(result={"draft_key": "d-1", "status": "ok"}) + + monkeypatch.setattr(ai_scriptless_manager, "persist_script", fake_persist) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.create_test("Login", folder="My Folder")) + + assert result.error is None + assert persisted["item_key"] == "PRIVATE:My Folder/Login.xml" + assert persisted["flow_count"] == 0 + assert result.info is not None + + def test_save_test_passes_snapshot_comment(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.save_test(TEST_ID, comment="checkpoint")) + + assert result.error is None + assert captured["snapshot_comment"] == "checkpoint" + + def test_save_test_as_fetches_and_persists_under_new_key(self, perfecto_token, monkeypatch): + source_script = _script_with_steps("wait") + persisted: dict = {} + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": source_script}) + + async def fake_persist(_token, item_key, script, saved_script=None, snapshot_comment=None): + persisted["item_key"] = item_key + persisted["flow_count"] = len(script.get("flowElements", [])) + persisted["comment"] = snapshot_comment + return BaseResult(result={"draft_key": "d-2", "status": "ok"}) + + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + monkeypatch.setattr(ai_scriptless_manager, "persist_script", fake_persist) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.save_test_as( + TEST_ID, + "Copy", + folder="Archive", + visibility="PUBLIC", + comment="branched", + )) + + assert result.error is None + assert persisted["item_key"] == "PUBLIC:Archive/Copy.xml" + assert persisted["flow_count"] == 1 + assert persisted["comment"] == "branched" + + def test_save_test_as_propagates_fetch_error(self, perfecto_token, monkeypatch): + async def fake_fetch(_token, _test_id): + return BaseResult(error="source missing") + + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.save_test_as(TEST_ID, "Copy")) + assert result.error == "source missing" + + def test_move_test_propagates_api_error(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **_kwargs): + return BaseResult(error="move denied") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_test(TEST_ID, "Archive")) + assert result.error == "move denied" + + def test_list_snapshots_rejects_invalid_item_key(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_snapshots("invalid")) + assert "Invalid itemKey format" in result.error + + def test_move_test_builds_target_item_key(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["method"] = method + captured["json"] = kwargs.get("json") + return BaseResult(result={"status": "moved"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_test(TEST_ID, "Archive", visibility="PUBLIC")) + + assert result.error is None + assert captured["method"] == "PATCH" + assert captured["json"]["folderType"] == "PRIVATE" + assert captured["json"]["targetFolderType"] == "PUBLIC" + assert result.result["target_item_key"] == "PUBLIC:Archive/Test.xml" + assert result.result["source_item_key"] == TEST_ID + + def test_move_test_rejects_invalid_item_key(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_test("not-an-item-key", "Archive")) + assert "Invalid itemKey format" in result.error + + def test_delete_test_calls_repository_api(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["method"] = method + captured["params"] = kwargs.get("params") + return BaseResult(result={"status": "deleted"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.delete_test(TEST_ID)) + + assert result.error is None + assert captured["method"] == "DELETE" + assert captured["params"]["itemKey"] == TEST_ID + + def test_delete_test_propagates_api_error(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **_kwargs): + return BaseResult(error="delete denied") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.delete_test(TEST_ID)) + assert result.error == "delete denied" + + def test_list_snapshots_posts_search_body(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["method"] = method + captured["json"] = kwargs.get("json") + return BaseResult(result=[]) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_snapshots(TEST_ID)) + + assert result.error is None + assert captured["method"] == "POST" + assert captured["json"]["folderType"] == "PRIVATE" + assert captured["json"]["keyDetails"]["artifactId"] == "Folder/Test.xml" + + def test_list_snapshots_propagates_api_error(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **_kwargs): + return BaseResult(error="snapshots unavailable") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_snapshots(TEST_ID)) + assert result.error == "snapshots unavailable" + + def test_get_command_definitions_requires_ids(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.get_command_definitions([])) + assert result.error == "command_ids is required and must not be empty" + + +def _dispatcher_action_cases() -> list[tuple[str, dict]]: + test_id = TEST_ID + return [ + ("list_tests", {"page_index": 1}), + ("list_filter_values", {"filter_names": ["owner_list"]}), + ("execute_test", { + "test_id": test_id, + "device_type": "real", + "device_under_test": {"device_id": "DEV-9"}, + }), + ("view_test_structure", {"test_id": test_id}), + ("list_commands", {"checkpoint": True}), + ("get_command_definitions", {"command_ids": ["wait"]}), + ("add_command", {"test_id": test_id, "command_id": "comment", "arguments": {"text": "hi"}}), + ("modify_command", {"test_id": test_id, "step_path": "0", "arguments": {"duration": "2"}}), + ("delete_command", {"test_id": test_id, "step_path": "0"}), + ("set_command_enabled", {"test_id": test_id, "step_path": "0", "enabled": False}), + ("save_test", {"test_id": test_id, "comment": "saved"}), + ("create_test", {"name": "New", "folder": "Folder"}), + ("save_test_as", {"test_id": test_id, "name": "Copy", "folder": "Archive"}), + ("add_logical_step", {"test_id": test_id, "label": "Group"}), + ("add_loop", {"test_id": test_id, "count": 2}), + ("add_condition", {"test_id": test_id, "expression": "true", "label": "If"}), + ("set_condition_expression", {"test_id": test_id, "step_path": "1", "expression": "false"}), + ("move_command", {"test_id": test_id, "step_path": "0", "after_path": "0"}), + ("delete_test", {"test_id": test_id}), + ("move_test", {"test_id": test_id, "folder": "Moved"}), + ("list_snapshots", {"test_id": test_id}), + ("view_snapshot", {"snapshot_id": f"{test_id}@hist-1"}), + ("list_test_variables", {"test_id": test_id}), + ("add_test_variable", {"test_id": test_id, "name": "v", "value": "1"}), + ("modify_test_variable", {"test_id": test_id, "name": "token", "value": "new"}), + ("delete_test_variable", {"test_id": test_id, "name": "token"}), + ] + + +def _setup_dispatcher_mocks(monkeypatch, captured: dict | None = None, persisted: dict | None = None): + script = new_empty_script() + script["flowElements"] = [ + build_flow_element("wait"), + build_if_statement("x == 1", "If"), + ] + script["numOfFlowElements"] = 2 + script["variables"] = [{ + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "token", + "value": "old", + "secured": False, + "description": None, + "displayName": None, + }, + }] + + async def fake_api_request( + _token, + method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + if result_formatter is None: + return BaseResult(result={"status": "ok", "executionId": "exec-1"}) + + formatter_name = getattr(result_formatter, "__name__", "") + if formatter_name == "format_ai_scriptless_tests": + raw = _tests_tree_response() + elif formatter_name == "format_ai_scriptless_tests_filter_values": + raw = _tests_tree_response() + elif formatter_name == "format_test_structure": + raw = _script_payload() + elif formatter_name == "format_command_catalog": + raw = { + "name": "Root", + "children": [{"commandId": "wait", "name": "Wait", "path": "/wait"}], + } + elif formatter_name == "format_command_definitions": + raw = { + "definitions": [{ + "commandId": "wait", + "data": { + "display": {"name": "Wait"}, + "mandatoryParameters": [], + "optionalParameters": [], + }, + }], + } + elif formatter_name == "format_snapshots_list": + raw = {"snapshots": [{"key": "", "comment": "live"}]} + else: + raw = {} + + return BaseResult(result=result_formatter(raw, result_formatter_params)) + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": copy.deepcopy(script)}) + + async def fake_persist(_token, item_key, payload, saved_script=None, snapshot_comment=None): + if persisted is not None: + persisted["item_key"] = item_key + persisted["comment"] = snapshot_comment + persisted["flow_count"] = len(payload.get("flowElements", [])) + return BaseResult(result={"draft_key": "d-1", "status": "ok"}) + + async def fake_load_and_mutate(_token, test_id, mutator, snapshot_comment=None): + payload = copy.deepcopy(script) + try: + mutator(payload) + except ValueError as exc: + return BaseResult(error=str(exc)) + if captured is not None: + captured["script"] = payload + captured["snapshot_comment"] = snapshot_comment + return BaseResult(result={"item_key": test_id, "draft_key": "draft-1", "status": "ok"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + monkeypatch.setattr(ai_scriptless_manager, "persist_script", fake_persist) + monkeypatch.setattr(ai_scriptless_manager, "load_and_mutate", fake_load_and_mutate) + + +class TestAiScriptlessDispatcher: + def test_unknown_action_returns_error(self, perfecto_token): + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action="not_a_real_action", args={}, ctx=None)) + assert "not found in AI Scriptless manager tool" in result.error + + def test_routes_add_command(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="add_command", + args={ + "test_id": TEST_ID, + "command_id": "wait", + "arguments": {"duration": "1"}, + }, + ctx=None, + )) + + assert result.error is None + assert result.result["command_id"] == "wait" + assert len(captured["script"]["flowElements"]) == 1 + + def test_routes_list_test_variables(self, perfecto_token, monkeypatch): + script = new_empty_script() + script["variables"] = [{ + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "flag", + "value": "on", + "secured": False, + "description": None, + "displayName": None, + }, + }] + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": script}) + + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="list_test_variables", + args={"test_id": TEST_ID}, + ctx=None, + )) + + assert result.error is None + assert result.result[0].name == "flag" + + def test_defaults_none_args_to_empty_dict(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **_kwargs): + return BaseResult(error="tree unavailable") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action="list_tests", args=None, ctx=None)) + assert result.error == "tree unavailable" + + def test_routes_move_command(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait", "comment"), captured) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="move_command", + args={"test_id": TEST_ID, "step_path": "0", "after_path": "0"}, + ctx=None, + )) + + assert result.error is None + commands = [e["command"] for e in captured["script"]["flowElements"]] + assert commands == ["comment", "wait"] + + def test_routes_delete_command(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait", "comment"), captured) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="delete_command", + args={"test_id": TEST_ID, "step_path": "0"}, + ctx=None, + )) + + assert result.error is None + assert len(captured["script"]["flowElements"]) == 1 + + def test_routes_execute_test(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-dispatch"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="execute_test", + args={ + "test_id": TEST_ID, + "device_type": "real", + "device_under_test": {"device_id": "DEV-1"}, + }, + ctx=None, + )) + + assert result.error is None + assert captured["json"]["params"]["DUT"] == "DEV-1" + + def test_routes_create_test(self, perfecto_token, monkeypatch): + persisted: dict = {} + + async def fake_persist(_token, item_key, script, saved_script=None, snapshot_comment=None): + persisted["item_key"] = item_key + return BaseResult(result={"draft_key": "d-1", "status": "ok"}) + + monkeypatch.setattr(ai_scriptless_manager, "persist_script", fake_persist) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="create_test", + args={"name": "Smoke", "folder": "QA"}, + ctx=None, + )) + + assert result.error is None + assert persisted["item_key"] == "PRIVATE:QA/Smoke.xml" + + @pytest.mark.parametrize("action,args,expected_error", [ + ("modify_command", {"test_id": TEST_ID, "step_path": "0"}, "arguments is required"), + ("delete_test", {"test_id": ""}, "test_id is required"), + ("move_test", {"test_id": "", "folder": "Archive"}, "test_id is required"), + ]) + def test_dispatcher_validation_errors(self, perfecto_token, action, args, expected_error): + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action=action, args=args, ctx=None)) + if expected_error: + assert expected_error in result.error + + def test_http_status_error_returns_formatted_error(self, perfecto_token, monkeypatch): + async def raise_http_error(*_args, **_kwargs): + request = httpx.Request("GET", "https://demo.perfectomobile.com/tree") + response = httpx.Response(503, request=request) + raise httpx.HTTPStatusError("service unavailable", request=request, response=response) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", raise_http_error) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action="list_tests", args={}, ctx=None)) + + assert result.error is not None + assert result.error.startswith("Error:") + assert "HTTPStatusError" in result.error + + def test_unexpected_exception_includes_support_message(self, perfecto_token, monkeypatch): + async def raise_runtime_error(*_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", raise_runtime_error) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action="list_tests", args={}, ctx=None)) + + assert "boom" in result.error + assert SUPPORT_MESSAGE in result.error + + def test_view_test_structure_end_to_end_with_formatter(self, perfecto_token, monkeypatch): + """api_request mock returns raw payload; formatter runs inside manager call chain.""" + async def fake_api_request( + _token, + _method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + from formatters.ai_scriptless import format_test_structure + return BaseResult(result=format_test_structure( + _script_payload(), + result_formatter_params, + )) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="view_test_structure", + args={"test_id": TEST_ID}, + ctx=None, + )) + + assert result.error is None + assert result.result.item_key == TEST_ID + assert result.result.flow_elements[0].step_path == "0" + assert any("AI Scriptless UI" in line for line in result.info) + + def test_routes_list_filter_values(self, perfecto_token, monkeypatch): + _setup_dispatcher_mocks(monkeypatch) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="list_filter_values", + args={"filter_names": ["test_name", "owner_list"]}, + ctx=None, + )) + + assert result.error is None + assert "Login" in result.result["test_name"] + + def test_routes_save_test_as(self, perfecto_token, monkeypatch): + persisted: dict = {} + _setup_dispatcher_mocks(monkeypatch, persisted=persisted) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="save_test_as", + args={ + "test_id": TEST_ID, + "name": "Branch", + "folder": "Copies", + "visibility": "PUBLIC", + "comment": "v2", + }, + ctx=None, + )) + + assert result.error is None + assert persisted["item_key"] == "PUBLIC:Copies/Branch.xml" + assert persisted["comment"] == "v2" + + def test_routes_add_test_variable(self, perfecto_token, monkeypatch): + captured: dict = {} + _setup_dispatcher_mocks(monkeypatch, captured=captured) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="add_test_variable", + args={ + "test_id": TEST_ID, + "name": "retry", + "variable_type": "number", + "value": 3, + "set_at_runtime": True, + }, + ctx=None, + )) + + assert result.error is None + assert result.result["name"] == "retry" + names = [v["data"]["name"] for v in captured["script"]["variables"]] + assert "retry" in names + + @pytest.mark.parametrize("action,args", _dispatcher_action_cases()) + def test_dispatcher_routes_registered_action(self, perfecto_token, monkeypatch, action, args): + _setup_dispatcher_mocks(monkeypatch) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action=action, args=args, ctx=None)) + + assert "not found in AI Scriptless manager tool" not in (result.error or "") + + +def _register_tool(token): + class _McpStub: + def __init__(self): + self.tools: dict = {} + + def tool(self, *, name, description): + def decorator(fn): + self.tools[name] = fn + return fn + return decorator + + mcp = _McpStub() + ai_scriptless_manager.register(mcp, token) + return mcp.tools["perfecto_ai_scriptless"] diff --git a/tests/test_ai_scriptless_persistence.py b/tests/test_ai_scriptless_persistence.py new file mode 100644 index 0000000..cdd7382 --- /dev/null +++ b/tests/test_ai_scriptless_persistence.py @@ -0,0 +1,225 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import asyncio +import json + +from models.result import BaseResult +from tools.ai_scriptless import persistence +from tools.ai_scriptless.elements import build_branch, build_flow_element, new_empty_script +from tools.ai_scriptless.persistence import load_and_mutate, persist_script +from tools.ai_scriptless.tree import insert_flow_element +from tools.ai_scriptless.script import Script + + +class TestPersistScript: + def test_persist_calls_draft_then_script_save(self, perfecto_token, monkeypatch): + calls: list[dict] = [] + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + calls.append({"method": method, "endpoint": endpoint, "json": kwargs.get("json")}) + if endpoint and "draft-management" in endpoint: + return BaseResult(result={"key": "draft-abc"}) + return BaseResult(result={"status": "saved"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + script = new_empty_script() + script["flowElements"] = [build_flow_element("wait")] + result = asyncio.run( + persist_script(perfecto_token, "PRIVATE:Folder/Test.xml", script) + ) + + assert result.error is None + assert result.result["draft_key"] == "draft-abc" + assert result.result["flow_element_count"] == 1 + assert len(calls) == 2 + assert calls[0]["method"] == "POST" + assert "draft-management" in calls[0]["endpoint"] + assert calls[1]["method"] == "POST" + assert calls[1]["endpoint"].endswith("/script") + assert calls[1]["json"]["draftKey"] == "draft-abc" + assert calls[1]["json"]["itemKey"] == "PRIVATE:Folder/Test.xml" + + def test_persist_strips_uuid_before_draft_payload(self, perfecto_token, monkeypatch): + captured_draft: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + if endpoint and "draft-management" in endpoint: + captured_draft["data"] = json.loads(kwargs["json"]["data"]) + return BaseResult(result={"key": "draft-abc"}) + return BaseResult(result={"status": "saved"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + script = new_empty_script() + element = build_flow_element("comment", {"text": "x"}) + element["uuid"] = "remove-me" + script["flowElements"] = [element] + + asyncio.run(persist_script(perfecto_token, "PRIVATE:Folder/Test.xml", script)) + + unsaved = captured_draft["data"]["unsavedScript"] + assert "uuid" not in unsaved["flowElements"][0] + + def test_persist_accepts_script_aggregate(self, perfecto_token, monkeypatch): + async def fake_api_request(_token, method, endpoint=None, **kwargs): + if endpoint and "draft-management" in endpoint: + return BaseResult(result={"key": "draft-abc"}) + return BaseResult(result={"status": "saved"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + script = Script.empty() + script.flow_elements.append(build_flow_element("wait")) + result = asyncio.run( + persist_script(perfecto_token, "PRIVATE:Folder/Test.xml", script) + ) + + assert result.error is None + assert result.result["flow_element_count"] == 1 + + def test_persist_includes_snapshot_comment_on_save(self, perfecto_token, monkeypatch): + save_payload: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + if endpoint and "draft-management" in endpoint: + return BaseResult(result={"key": "draft-abc"}) + save_payload["json"] = kwargs.get("json") + return BaseResult(result={"status": "saved"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + asyncio.run( + persist_script( + perfecto_token, + "PRIVATE:Folder/Test.xml", + new_empty_script(), + snapshot_comment="release candidate", + ) + ) + + assert save_payload["json"]["snapshotComment"] == "release candidate" + + def test_persist_fails_when_draft_key_missing(self, perfecto_token, monkeypatch): + async def fake_api_request(_token, method, endpoint=None, **kwargs): + return BaseResult(result={}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + result = asyncio.run( + persist_script(perfecto_token, "PRIVATE:Folder/Test.xml", new_empty_script()) + ) + + assert result.error == "Draft creation failed: missing draft key in response" + + +class TestLoadAndMutate: + def test_load_and_mutate_applies_mutator_and_persists(self, perfecto_token, monkeypatch): + script = new_empty_script() + persisted: dict = {} + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": script}) + + async def fake_persist(_token, item_key, mutated_script, saved_script, snapshot_comment=None): + persisted["item_key"] = item_key + persisted["flow_count"] = len(mutated_script.get("flowElements", [])) + persisted["saved_count"] = len(saved_script.get("flowElements", [])) + return BaseResult(result={"status": "ok"}) + + monkeypatch.setattr(persistence, "fetch_script_payload", fake_fetch) + monkeypatch.setattr(persistence, "_persist_script", fake_persist) + + def mutator(current_script: dict) -> None: + current_script.setdefault("flowElements", []).append(build_flow_element("wait")) + + result = asyncio.run( + load_and_mutate(perfecto_token, "PRIVATE:Folder/Test.xml", mutator) + ) + + assert result.error is None + assert persisted["item_key"] == "PRIVATE:Folder/Test.xml" + assert persisted["flow_count"] == 1 + assert persisted["saved_count"] == 0 + + def test_load_and_mutate_normalizes_if_statement_aliases(self, perfecto_token, monkeypatch): + then_branch = build_branch("THEN") + else_branch = build_branch("ELSE") + then_clause = build_branch("THEN") + api_script = new_empty_script() + api_script["flowElements"] = [{ + "@type": "IfStatement", + "branches": [then_branch, else_branch], + "thenClause": then_clause, + "elseClause": build_branch("ELSE"), + "label": "Probe", + "active": True, + }] + persisted: dict = {} + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": api_script}) + + async def fake_persist(_token, item_key, mutated_script, saved_script, snapshot_comment=None): + persisted["mutated"] = mutated_script + return BaseResult(result={"status": "ok"}) + + monkeypatch.setattr(persistence, "fetch_script_payload", fake_fetch) + monkeypatch.setattr(persistence, "_persist_script", fake_persist) + + def mutator(script: dict) -> None: + insert_flow_element( + script, + build_flow_element("comment", {"text": "then child"}), + parent_path="0.b0", + ) + + result = asyncio.run( + load_and_mutate(perfecto_token, "PRIVATE:Folder/Test.xml", mutator) + ) + + assert result.error is None + condition = persisted["mutated"]["flowElements"][0] + assert condition["thenClause"] is condition["branches"][0] + assert len(condition["thenClause"]["flowElements"]) == 1 + + def test_load_and_mutate_returns_validation_error(self, perfecto_token, monkeypatch): + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": new_empty_script()}) + + monkeypatch.setattr(persistence, "fetch_script_payload", fake_fetch) + + def mutator(_script: dict) -> None: + raise ValueError("step_path not found: 9") + + result = asyncio.run( + load_and_mutate(perfecto_token, "PRIVATE:Folder/Test.xml", mutator) + ) + + assert result.error == "step_path not found: 9" + + def test_load_and_mutate_propagates_fetch_error(self, perfecto_token, monkeypatch): + async def fake_fetch(_token, _test_id): + return BaseResult(error="not found") + + monkeypatch.setattr(persistence, "fetch_script_payload", fake_fetch) + + result = asyncio.run( + load_and_mutate(perfecto_token, "PRIVATE:Folder/Test.xml", lambda _s: None) + ) + + assert result.error == "not found" diff --git a/tests/test_ai_scriptless_script.py b/tests/test_ai_scriptless_script.py new file mode 100644 index 0000000..d19fa55 --- /dev/null +++ b/tests/test_ai_scriptless_script.py @@ -0,0 +1,512 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import copy + +import pytest + +from formatters.ai_scriptless import PRIMARY_AI_COMMAND_IDS +from tools.ai_scriptless.elements import build_branch +from tools.ai_scriptless_script import ( + add_script_variable, + build_flow_element, + build_if_statement, + build_item_key, + build_logical_step, + build_loop, + build_move_test_body, + build_snapshot_search_body, + command_id_from_element, + delete_element_by_path, + delete_script_variable, + find_element_by_path, + find_step_path_for_element, + format_test_ui_location, + folder_type, + insert_flow_element, + modify_script_variable, + move_element_by_path, + new_empty_script, + parse_command_id, + set_condition_expression, + set_element_enabled, + split_item_key, + item_key_file_name, + strip_non_api_script_fields, + update_element_arguments, + validate_step_path, +) + + +def _sample_script() -> dict: + """Script with root steps, a logical group, and an if/else branch for tree tests.""" + tap = build_flow_element("ai_user-action", {"action": "Tap login"}) + wait = build_flow_element("wait", {"duration": "2"}) + group = build_logical_step("Setup") + group["flowElements"] = [build_flow_element("comment", {"text": "inside group"})] + condition = build_if_statement("x == 1", "Check x") + then_branch = condition["branches"][0] + then_branch["flowElements"] = [build_flow_element("ai_validation", {"validation": "OK"})] + script = new_empty_script() + script["flowElements"] = [tap, group, wait, condition] + script["numOfFlowElements"] = 4 + return script + + +class TestItemKey: + def test_build_item_key_adds_xml_extension(self): + assert build_item_key("PRIVATE", "My Folder", "Login") == "PRIVATE:My Folder/Login.xml" + + def test_build_item_key_preserves_existing_xml_extension(self): + assert build_item_key("PUBLIC", "Shared", "Login.xml") == "PUBLIC:Shared/Login.xml" + + def test_build_item_key_strips_slashes_from_folder(self): + assert build_item_key("PRIVATE", "/My Folder/", "Test") == "PRIVATE:My Folder/Test.xml" + + def test_split_item_key_round_trip(self): + item_key = "GROUP:Team/Regression/Smoke.xml" + visibility, path = split_item_key(item_key) + assert visibility == "GROUP" + assert path == "Team/Regression/Smoke.xml" + + def test_split_item_key_rejects_missing_visibility(self): + with pytest.raises(ValueError, match="Invalid itemKey format"): + split_item_key("NoVisibilityPrefix") + + def test_folder_type_maps_public_and_group(self): + assert folder_type("PRIVATE") == "PRIVATE" + assert folder_type("GROUP") == "GROUP" + assert folder_type("PUBLIC") == "PUBLIC" + assert folder_type("UNKNOWN") == "PUBLIC" + + def test_item_key_file_name(self): + assert item_key_file_name("PRIVATE:Folder/My Test.xml") == "My Test.xml" + + def test_format_test_ui_location_with_folder(self): + item_key = "PRIVATE:My Folder/Login.xml" + assert format_test_ui_location(item_key) == ( + '"My Tests" → folder "My Folder" → test "Login"' + ) + + def test_format_test_ui_location_without_folder(self): + item_key = "PUBLIC:RootTest.xml" + assert format_test_ui_location(item_key) == '"Public Tests" → test "RootTest"' + + +class TestRepositoryApiBodies: + def test_build_snapshot_search_body(self): + body = build_snapshot_search_body("PRIVATE:My Folder/Login.xml") + assert body == { + "repositoryType": "SCRIPTS", + "keyDetails": {"artifactId": "My Folder/Login.xml", "version": "v0"}, + "folderType": "PRIVATE", + } + + def test_build_move_test_body(self): + body = build_move_test_body("PRIVATE:Old/Login.xml", "Archive", "PUBLIC") + assert body["repositoryType"] == "SCRIPTS" + assert body["keyDetails"] == {"artifactId": "Old/Login.xml", "version": "v0"} + assert body["folderType"] == "PRIVATE" + assert body["targetKeyDetails"] == {"artifactId": "Archive/Login.xml", "version": "v0"} + assert body["targetFolderType"] == "PUBLIC" + assert body["copy"] is False + + +class TestCommandBuilding: + def test_parse_command_id_ai_prefix(self): + assert parse_command_id("ai_user-action") == ("ai", "user-action") + assert parse_command_id("ai_validation") == ("ai", "validation") + + def test_parse_command_id_legacy_format(self): + assert parse_command_id("touch_tap") == ("touch", "tap") + assert parse_command_id("wait") == ("wait", "") + + def test_build_flow_element_ai_action(self): + element = build_flow_element("ai_user-action", {"action": "Open app"}) + assert element["@type"] == "Action" + assert element["command"] == "ai" + assert element["subcommand"] == "user-action" + assert element["errorPolicy"] == "ABORT" + assert element["active"] is True + + def test_build_flow_element_ai_validation(self): + element = build_flow_element("ai_validation", {"validation": "Screen visible"}) + assert element["@type"] == "Validation" + assert element["errorPolicy"] == "IGNORE" + + def test_build_flow_element_default_handset_argument(self): + element = build_flow_element("touch_tap") + handset_args = [arg for arg in element["arguments"] if arg["name"] == "handsetId"] + assert len(handset_args) == 1 + assert handset_args[0]["data"]["dataSource"] == "VARIABLE" + assert handset_args[0]["data"]["value"] == "DUT" + + def test_build_arguments_normalizes_wait_alias(self): + element = build_flow_element("wait", {"waitDuration": "5"}) + duration_args = [arg for arg in element["arguments"] if arg["name"] == "duration"] + assert len(duration_args) == 1 + assert duration_args[0]["data"]["value"] == "5" + assert all(arg["name"] != "waitDuration" for arg in element["arguments"]) + + def test_command_id_from_element(self): + element = build_flow_element("ai_user-action", {"action": "Tap"}) + assert command_id_from_element(element) == "ai_user-action" + + def test_update_element_arguments_merges_values(self): + element = build_flow_element("ai_user-action", {"action": "Old"}) + update_element_arguments(element, {"action": "New"}) + action_args = [arg for arg in element["arguments"] if arg["name"] == "action"] + assert action_args[0]["data"]["value"] == "New" + + +LEGACY_COMMAND_IDS = ( + "comment", + "wait", + "handset_ready", + "touch_tap", + "checkpoint_text", + "checkpoint_image", +) + + +def _assert_command_id_round_trip(command_id: str) -> None: + element = build_flow_element(command_id) + assert command_id_from_element(element) == command_id + command, subcommand = parse_command_id(command_id) + assert element["command"] == command + assert (element.get("subcommand") or "") == subcommand + + +class TestCommandIdRoundTrip: + """Catalog command_id must survive build_flow_element and command_id_from_element.""" + + @pytest.mark.parametrize("command_id", PRIMARY_AI_COMMAND_IDS) + def test_primary_ai_command_ids(self, command_id: str) -> None: + _assert_command_id_round_trip(command_id) + + @pytest.mark.parametrize("command_id", LEGACY_COMMAND_IDS) + def test_legacy_and_structural_command_ids(self, command_id: str) -> None: + _assert_command_id_round_trip(command_id) + + def test_round_trip_with_explicit_arguments(self) -> None: + command_id = "ai_user-action" + element = build_flow_element(command_id, {"action": "Tap login"}) + assert command_id_from_element(element) == command_id + assert element["command"] == "ai" + assert element["subcommand"] == "user-action" + + def test_parse_and_build_agree_on_wire_fields(self) -> None: + for command_id in (*PRIMARY_AI_COMMAND_IDS, *LEGACY_COMMAND_IDS): + command, subcommand = parse_command_id(command_id) + element = build_flow_element(command_id) + assert element["command"] == command + assert (element.get("subcommand") or "") == subcommand + + +class TestStructureBuilders: + def test_new_empty_script_has_dut_parameter(self): + script = new_empty_script() + assert script["@type"] == "Script" + assert script["flowElements"] == [] + assert script["numOfFlowElements"] == 0 + dut = script["parameters"][0]["data"] + assert dut["name"] == "DUT" + assert dut["@type"] == "HandsetData" + + def test_build_logical_step(self): + step = build_logical_step("Group A") + assert step["@type"] == "LogicalStep" + assert step["label"] == "Group A" + assert step["flowElements"] == [] + + def test_build_loop(self): + loop = build_loop(3) + assert loop["@type"] == "Loop" + assert loop["iterator"]["count"] == 3 + + def test_build_if_statement_has_branches(self): + condition = build_if_statement("flag", "Flag check") + assert condition["@type"] == "IfStatement" + assert condition["expression"] == "flag" + assert len(condition["branches"]) == 2 + assert condition["branches"][0]["clause"] == "THEN" + assert condition["branches"][1]["clause"] == "ELSE" + + def test_build_if_statement_clauses_alias_branches(self): + condition = build_if_statement("flag", "Flag check") + assert condition["thenClause"] is condition["branches"][0] + assert condition["elseClause"] is condition["branches"][1] + child = build_flow_element("comment", {"text": "in then"}) + condition["branches"][0]["flowElements"].append(child) + assert len(condition["thenClause"]["flowElements"]) == 1 + + def test_normalize_if_statement_aliases_api_payload(self): + from tools.ai_scriptless.elements import normalize_if_statement_aliases + + then_branch = build_branch("THEN") + else_branch = build_branch("ELSE") + then_clause = build_branch("THEN") + then_clause["flowElements"] = [build_flow_element("comment", {"text": "from clause"})] + script = { + "flowElements": [{ + "@type": "IfStatement", + "branches": [then_branch, else_branch], + "thenClause": then_clause, + "elseClause": build_branch("ELSE"), + }], + } + normalize_if_statement_aliases(script) + condition = script["flowElements"][0] + assert condition["thenClause"] is condition["branches"][0] + assert condition["elseClause"] is condition["branches"][1] + assert len(condition["branches"][0]["flowElements"]) == 1 + assert condition["branches"][0]["flowElements"][0]["command"] == "comment" + + def test_normalize_if_statement_aliases_nested(self): + from tools.ai_scriptless.elements import normalize_if_statement_aliases + + inner = build_if_statement() + inner["branches"] = [build_branch("THEN"), build_branch("ELSE")] + inner["thenClause"] = build_branch("THEN") + inner["elseClause"] = build_branch("ELSE") + group = build_logical_step("group") + group["flowElements"] = [inner] + script = {"flowElements": [group]} + normalize_if_statement_aliases(script) + nested = group["flowElements"][0] + assert nested["thenClause"] is nested["branches"][0] + + +class TestScriptVariables: + def test_add_and_find_variable(self): + script = new_empty_script() + entry = add_script_variable(script, "counter", "number", 0) + assert entry["@type"] == "Variable" + assert entry["data"]["name"] == "counter" + assert entry["data"]["value"] == 0 + assert len(script["variables"]) == 1 + + def test_add_variable_rejects_duplicate(self): + script = new_empty_script() + add_script_variable(script, "token", "string", "abc") + with pytest.raises(ValueError, match="variable already exists"): + add_script_variable(script, "token", "string", "xyz") + + def test_add_variable_rejects_dut_name(self): + script = new_empty_script() + with pytest.raises(ValueError, match="DUT is a test parameter"): + add_script_variable(script, "DUT", "string", "x") + + def test_add_variable_rejects_invalid_name(self): + script = new_empty_script() + with pytest.raises(ValueError, match="cannot begin with a number"): + add_script_variable(script, "1bad", "string", "x") + + def test_modify_script_variable(self): + script = new_empty_script() + add_script_variable(script, "flag", "boolean", True) + modify_script_variable(script, "flag", value=False) + assert script["variables"][0]["data"]["value"] is False + + def test_modify_script_variable_set_at_runtime(self): + script = new_empty_script() + add_script_variable(script, "env", "string", "dev") + modify_script_variable(script, "env", set_at_runtime=True) + assert script["variables"][0]["@type"] == "Parameter" + + def test_delete_script_variable(self): + script = new_empty_script() + add_script_variable(script, "temp", "string", "x") + delete_script_variable(script, "temp") + assert script["variables"] == [] + + def test_coerce_boolean_and_number_types(self): + script = new_empty_script() + add_script_variable(script, "enabled", "boolean", "true") + add_script_variable(script, "retries", "number", "3") + assert script["variables"][0]["data"]["value"] is True + assert script["variables"][1]["data"]["value"] == 3 + + +class TestStepPathValidation: + def test_accepts_valid_paths(self): + for path in ("0", "2.0", "5.b0", "5.b0.1", "1.b1.2"): + validate_step_path(path) + + def test_rejects_empty_or_spaced_paths(self): + with pytest.raises(ValueError, match="step_path must be"): + validate_step_path("") + with pytest.raises(ValueError, match="step_path must be"): + validate_step_path("1. 2") + + def test_rejects_invalid_segments(self): + with pytest.raises(ValueError, match="invalid step_path"): + validate_step_path("a.b") + + +class TestScriptTreeNavigation: + def test_find_element_by_path_root_and_nested(self): + script = _sample_script() + root = find_element_by_path(script, "0") + assert root is not None + _, index, element = root + assert index == 0 + assert element["command"] == "ai" + + nested = find_element_by_path(script, "1.0") + assert nested is not None + _, _, element = nested + assert element["command"] == "comment" + + def test_find_element_by_path_if_branch(self): + script = _sample_script() + branch = find_element_by_path(script, "3.b0") + assert branch is not None + _, _, element = branch + assert element["@type"] == "Branch" + assert element["clause"] == "THEN" + + branch_child = find_element_by_path(script, "3.b0.0") + assert branch_child is not None + _, _, element = branch_child + assert element["subcommand"] == "validation" + + def test_find_element_by_path_returns_none_for_missing(self): + script = _sample_script() + assert find_element_by_path(script, "99") is None + assert find_element_by_path(script, "3.b9") is None + + def test_find_step_path_for_element_round_trip(self): + script = _sample_script() + for expected_path in ("0", "1", "1.0", "3.b0", "3.b0.0"): + located = find_element_by_path(script, expected_path) + assert located is not None + _, _, element = located + assert find_step_path_for_element(script, element) == expected_path + + def test_find_element_by_path_returns_parent_list_and_index(self): + script = _sample_script() + parent_list, index, element = find_element_by_path(script, "0") + assert parent_list is script["flowElements"] + assert index == 0 + assert element is script["flowElements"][0] + + group = script["flowElements"][1] + parent_list, index, element = find_element_by_path(script, "1.0") + assert parent_list is group["flowElements"] + assert index == 0 + assert element is group["flowElements"][0] + + condition = script["flowElements"][3] + parent_list, index, branch = find_element_by_path(script, "3.b0") + assert parent_list is condition["branches"] + assert index == 0 + assert branch is condition["branches"][0] + + +class TestScriptTreeMutations: + def test_insert_flow_element_at_root_end(self): + script = new_empty_script() + element = build_flow_element("wait") + insert_flow_element(script, element) + assert script["numOfFlowElements"] == 1 + assert script["flowElements"][0]["command"] == "wait" + + def test_insert_flow_element_after_path(self): + script = _sample_script() + new_step = build_flow_element("comment", {"text": "inserted"}) + insert_flow_element(script, new_step, after_path="0") + assert find_element_by_path(script, "1") is not None + _, _, element = find_element_by_path(script, "1") + assert element["command"] == "comment" + assert find_step_path_for_element(script, script["flowElements"][0]) == "0" + + def test_insert_flow_element_inside_container(self): + script = _sample_script() + new_step = build_flow_element("comment", {"text": "in group"}) + insert_flow_element(script, new_step, parent_path="1") + located = find_element_by_path(script, "1.1") + assert located is not None + _, _, element = located + assert element["command"] == "comment" + + def test_insert_rejects_non_container_parent(self): + script = _sample_script() + new_step = build_flow_element("wait") + with pytest.raises(ValueError, match="must reference a container"): + insert_flow_element(script, new_step, parent_path="0") + + def test_delete_element_by_path(self): + script = _sample_script() + delete_element_by_path(script, "2") + assert script["numOfFlowElements"] == 3 + assert find_element_by_path(script, "2") is not None + _, _, element = find_element_by_path(script, "2") + assert element["@type"] == "IfStatement" + + def test_set_element_enabled(self): + script = _sample_script() + set_element_enabled(script, "0", False) + _, _, element = find_element_by_path(script, "0") + assert element["active"] is False + + def test_set_condition_expression(self): + script = _sample_script() + set_condition_expression(script, "3", "y > 0") + _, _, element = find_element_by_path(script, "3") + assert element["expression"] == "y > 0" + + def test_set_condition_expression_rejects_non_if(self): + script = _sample_script() + with pytest.raises(ValueError, match="must reference an IfStatement"): + set_condition_expression(script, "0", "x") + + def test_move_element_within_root(self): + script = _sample_script() + move_element_by_path(script, "0", after_path="2") + _, _, first = find_element_by_path(script, "0") + _, _, moved = find_element_by_path(script, "2") + assert first["@type"] == "LogicalStep" + assert moved["command"] == "ai" + assert moved["subcommand"] == "user-action" + + def test_move_element_into_container(self): + script = _sample_script() + move_element_by_path(script, "2", parent_path="1") + located = find_element_by_path(script, "1.1") + assert located is not None + _, _, element = located + assert element["command"] == "wait" + _, _, root_condition = find_element_by_path(script, "2") + assert root_condition["@type"] == "IfStatement" + + +class TestStripNonApiScriptFields: + def test_removes_uuid_from_tree(self): + script = _sample_script() + script["flowElements"][0]["uuid"] = "step-uuid-1" + script["flowElements"][3]["branches"][0]["uuid"] = "branch-uuid" + strip_non_api_script_fields(script) + assert "uuid" not in script["flowElements"][0] + assert "uuid" not in script["flowElements"][3]["branches"][0] + + def test_strip_does_not_mutate_unrelated_fields(self): + script = _sample_script() + original = copy.deepcopy(script) + script["flowElements"][0]["uuid"] = "temp" + strip_non_api_script_fields(script) + script["flowElements"][0].pop("uuid", None) + assert script["flowElements"][0] == original["flowElements"][0] diff --git a/tests/test_ai_scriptless_script_aggregate.py b/tests/test_ai_scriptless_script_aggregate.py new file mode 100644 index 0000000..b4d08df --- /dev/null +++ b/tests/test_ai_scriptless_script_aggregate.py @@ -0,0 +1,101 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from tools.ai_scriptless.elements import build_flow_element, build_logical_step +from tools.ai_scriptless.script import Script + + +def _sample_script() -> Script: + tap = build_flow_element("ai_user-action", {"action": "Tap login"}) + wait = build_flow_element("wait", {"duration": "2"}) + group = build_logical_step("Setup") + group["flowElements"] = [build_flow_element("comment", {"text": "inside group"})] + script = Script.empty() + script.flow_elements.extend([tap, group, wait]) + script.to_dict()["numOfFlowElements"] = 3 + return script + + +class TestScriptFactory: + def test_empty_matches_new_empty_script_shape(self): + script = Script.empty() + payload = script.to_dict() + assert payload["@type"] == "Script" + assert payload["flowElements"] == [] + assert payload["variables"] == [] + assert payload["parameters"][0]["data"]["name"] == "DUT" + + def test_from_dict_copies_payload(self): + original = Script.empty().to_dict() + original["variables"].append({"name": "x"}) + wrapped = Script.from_dict(original) + wrapped.variables.clear() + assert len(original["variables"]) == 1 + + def test_wrap_mutates_shared_dict(self): + payload = Script.empty().to_dict() + wrapped = Script.wrap(payload) + wrapped.add_variable("count", "number", 1) + assert len(payload["variables"]) == 1 + + +class TestScriptTreeOperations: + def test_insert_and_find_step_path(self): + script = _sample_script() + element = build_flow_element("comment", {"text": "new"}) + script.insert_flow_element(element, after_path="0") + assert script.find_step_path_for_element(element) == "1" + assert script.flow_element_count == 4 + + def test_delete_element_by_path(self): + script = _sample_script() + script.delete_element_by_path("2") + assert script.flow_element_count == 2 + assert script.find_element_by_path("2") is None + + def test_set_element_enabled(self): + script = _sample_script() + script.set_element_enabled("0", False) + located = script.find_element_by_path("0") + assert located is not None + assert located[2]["active"] is False + + +class TestScriptVariables: + def test_add_list_modify_delete_variable(self): + script = Script.empty() + script.add_variable("token", "string", "abc") + assert len(script.list_variables()) == 1 + script.modify_variable("token", value="xyz") + assert script.find_variable("token")[1]["data"]["value"] == "xyz" + script.delete_variable("token") + assert script.find_variable("token") is None + + +class TestScriptPersistPrep: + def test_prepare_for_persist_strips_uuid_and_updates_counts(self): + script = _sample_script() + script.flow_elements[0]["uuid"] = "client-only" + script.prepare_for_persist() + assert "uuid" not in script.flow_elements[0] + assert script.to_dict()["numOfFlowElements"] == script.flow_element_count + + def test_copy_is_independent(self): + script = _sample_script() + clone = script.copy() + clone.delete_element_by_path("0") + assert script.flow_element_count == 3 + assert clone.flow_element_count == 2 diff --git a/tests/test_ai_scriptless_value_objects.py b/tests/test_ai_scriptless_value_objects.py new file mode 100644 index 0000000..8890f7b --- /dev/null +++ b/tests/test_ai_scriptless_value_objects.py @@ -0,0 +1,70 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import pytest + +from tools.ai_scriptless.item_key import ItemKey, build_item_key, split_item_key +from tools.ai_scriptless.step_path import StepPath +from tools.ai_scriptless.tree import find_element_by_path +from tools.ai_scriptless.elements import build_flow_element, new_empty_script + + +class TestItemKey: + def test_build_and_parse_round_trip(self): + key = ItemKey.build("PRIVATE", "My Folder", "Login") + assert str(key) == "PRIVATE:My Folder/Login.xml" + assert key.visibility == "PRIVATE" + assert key.path == "My Folder/Login.xml" + assert key.file_name == "Login.xml" + assert key.folder_path == "My Folder" + assert key.folder_type == "PRIVATE" + + def test_with_folder_preserves_file_name(self): + source = ItemKey.parse("PRIVATE:Old/Login.xml") + target = source.with_folder("Archive", "PUBLIC") + assert str(target) == "PUBLIC:Archive/Login.xml" + + def test_legacy_helpers_match_item_key(self): + built = build_item_key("GROUP", "Team", "Smoke") + assert built == str(ItemKey.build("GROUP", "Team", "Smoke")) + assert split_item_key(built) == ("GROUP", "Team/Smoke.xml") + + +class TestStepPath: + def test_parse_and_str_round_trip(self): + path = StepPath.parse("5.b0.1") + assert path.parts == ("5", "b0", "1") + assert str(path) == "5.b0.1" + + def test_build_nested_paths(self): + root = StepPath.root_index(2) + branch = root.branch(0) + child = branch.child_index(1) + assert str(child) == "2.b0.1" + + @pytest.mark.parametrize("invalid", ["", "1. 2", "a.b"]) + def test_parse_rejects_invalid_paths(self, invalid: str): + with pytest.raises(ValueError): + StepPath.parse(invalid) + + def test_find_element_by_path_accepts_step_path_object(self): + script = new_empty_script() + script["flowElements"] = [build_flow_element("wait")] + located = find_element_by_path(script, StepPath.root_index(0)) + assert located is not None + _, index, element = located + assert index == 0 + assert element["command"] == "wait" diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..c1f8077 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,293 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +import asyncio +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from models.result import BaseResult +from telemetry import ( + DEFAULT_OTLP_ENDPOINT, + DEFAULT_OTLP_PROTOCOL, + _create_metric_exporter, + _create_trace_exporter, + _get_otlp_protocol, + _record_metrics, + init_telemetry, + run_tool, +) + + +def _make_ctx(meta=None): + ctx = MagicMock() + ctx.request_context.request.params.meta = meta or {} + ctx.request_context.session.client_params = None + ctx.session_id = None + return ctx + + +def _make_ctx_with_client(name="claude-code", version="1.2.3"): + ctx = MagicMock() + ctx.request_context.request.params.meta = {} + ctx.request_context.session.client_params.clientInfo.name = name + ctx.request_context.session.client_params.clientInfo.version = version + ctx.session_id = "session-abc" + return ctx + + +def _make_tracer_and_span(): + span = MagicMock() + cm = MagicMock() + cm.__enter__ = MagicMock(return_value=span) + cm.__exit__ = MagicMock(return_value=False) + tracer = MagicMock() + tracer.start_as_current_span.return_value = cm + return tracer, span + + +class TestRecordMetrics: + def test_omits_error_type_on_success(self): + counter = MagicMock() + histogram = MagicMock() + with patch("telemetry._call_counter", counter), patch("telemetry._duration_histogram", histogram): + _record_metrics("perfecto_user", "read_user", 0.5, None) + expected = {"gen_ai.tool.name": "perfecto_user", "mcp.tool.action": "read_user"} + counter.add.assert_called_once_with(1, expected) + histogram.record.assert_called_once_with(0.5, expected) + + def test_includes_error_type_on_failure(self): + counter = MagicMock() + histogram = MagicMock() + with patch("telemetry._call_counter", counter), patch("telemetry._duration_histogram", histogram): + _record_metrics("perfecto_user", "read_user", 0.5, "timeout") + expected = { + "gen_ai.tool.name": "perfecto_user", + "mcp.tool.action": "read_user", + "error.type": "timeout", + } + counter.add.assert_called_once_with(1, expected) + histogram.record.assert_called_once_with(0.5, expected) + + +class TestRunTool: + def test_returns_dispatch_result(self): + ctx = _make_ctx() + tracer, _ = _make_tracer_and_span() + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + result = BaseResult(result=[{"id": 1}]) + returned = asyncio.run(run_tool("perfecto_user", "read_user", ctx, AsyncMock(return_value=result))) + assert returned is result + + def test_sets_span_attributes(self): + ctx = _make_ctx() + tracer, span = _make_tracer_and_span() + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + asyncio.run(run_tool("perfecto_user", "read_user", ctx, AsyncMock(return_value=BaseResult()))) + span.set_attribute.assert_any_call("gen_ai.tool.name", "perfecto_user") + span.set_attribute.assert_any_call("mcp.tool.action", "read_user") + span.set_attribute.assert_any_call("mcp.method.name", "tools/call") + span.set_attribute.assert_any_call("gen_ai.operation.name", "execute_tool") + + def test_sets_client_info_attributes(self): + ctx = _make_ctx_with_client("claude-code", "1.2.3") + tracer, span = _make_tracer_and_span() + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + asyncio.run(run_tool("perfecto_user", "read_user", ctx, AsyncMock(return_value=BaseResult()))) + span.set_attribute.assert_any_call("user_agent.name", "claude-code") + span.set_attribute.assert_any_call("user_agent.version", "1.2.3") + span.set_attribute.assert_any_call("mcp.session.id", "session-abc") + + def test_span_kind_server(self): + ctx = _make_ctx() + tracer, _ = _make_tracer_and_span() + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + asyncio.run(run_tool("perfecto_user", "read_user", ctx, AsyncMock(return_value=BaseResult()))) + _, kwargs = tracer.start_as_current_span.call_args + assert kwargs.get("kind") == t.SpanKind.SERVER + + def test_reraises_httpx_401(self): + ctx = _make_ctx() + tracer, span = _make_tracer_and_span() + response = MagicMock() + response.status_code = 401 + err = httpx.HTTPStatusError("unauthorized", request=MagicMock(), response=response) + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + with pytest.raises(httpx.HTTPStatusError): + asyncio.run(run_tool("t", "a", ctx, AsyncMock(side_effect=err))) + span.set_attribute.assert_any_call("error.type", "auth_failed") + + def test_reraises_httpx_404(self): + ctx = _make_ctx() + tracer, span = _make_tracer_and_span() + response = MagicMock() + response.status_code = 404 + err = httpx.HTTPStatusError("not found", request=MagicMock(), response=response) + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + with pytest.raises(httpx.HTTPStatusError): + asyncio.run(run_tool("t", "a", ctx, AsyncMock(side_effect=err))) + span.set_attribute.assert_any_call("error.type", "not_found") + + def test_reraises_httpx_500(self): + ctx = _make_ctx() + tracer, span = _make_tracer_and_span() + response = MagicMock() + response.status_code = 500 + err = httpx.HTTPStatusError("server error", request=MagicMock(), response=response) + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + with pytest.raises(httpx.HTTPStatusError): + asyncio.run(run_tool("t", "a", ctx, AsyncMock(side_effect=err))) + span.set_attribute.assert_any_call("error.type", "server_error") + + def test_reraises_httpx_timeout(self): + ctx = _make_ctx() + tracer, span = _make_tracer_and_span() + err = httpx.TimeoutException("timed out") + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + with pytest.raises(httpx.TimeoutException): + asyncio.run(run_tool("t", "a", ctx, AsyncMock(side_effect=err))) + span.set_attribute.assert_any_call("error.type", "timeout") + + def test_reraises_generic_exception(self): + ctx = _make_ctx() + tracer, span = _make_tracer_and_span() + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + with pytest.raises(ValueError): + asyncio.run(run_tool("t", "a", ctx, AsyncMock(side_effect=ValueError("boom")))) + span.set_attribute.assert_any_call("error.type", "tool_error") + + def test_marks_span_failed_on_result_error(self): + ctx = _make_ctx() + tracer, span = _make_tracer_and_span() + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + result = BaseResult(error="api returned error") + returned = asyncio.run(run_tool("t", "a", ctx, AsyncMock(return_value=result))) + assert returned is result + span.set_attribute.assert_any_call("error.type", "api_error") + + def test_survives_span_setup_failure(self): + ctx = _make_ctx() + with patch("telemetry.trace") as t: + t.get_tracer.side_effect = RuntimeError("otel broken") + result = BaseResult(result=[{"id": 1}]) + returned = asyncio.run(run_tool("t", "a", ctx, AsyncMock(return_value=result))) + assert returned is result + + def test_survives_broken_ctx(self): + ctx = _make_ctx(meta=None) + tracer, _ = _make_tracer_and_span() + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + result = BaseResult(result=[]) + returned = asyncio.run(run_tool("t", "a", ctx, AsyncMock(return_value=result))) + assert returned is result + + def test_omits_client_info_when_absent(self): + ctx = _make_ctx() + tracer, span = _make_tracer_and_span() + with patch("telemetry.trace") as t: + t.get_tracer.return_value = tracer + asyncio.run(run_tool("perfecto_user", "read_user", ctx, AsyncMock(return_value=BaseResult()))) + attribute_names = [c[0][0] for c in span.set_attribute.call_args_list] + assert "user_agent.name" not in attribute_names + assert "user_agent.version" not in attribute_names + + +class TestInitTelemetry: + def test_does_not_raise_without_sdk(self): + init_telemetry("perfecto-mcp", "1.0.0") + + def test_does_not_raise_with_disabled_flag(self, monkeypatch): + monkeypatch.setenv("OTEL_SDK_DISABLED", "true") + init_telemetry("perfecto-mcp", "1.0.0") + + def test_does_not_raise_with_endpoint_set(self, monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + init_telemetry("perfecto-mcp", "1.0.0") + + def test_defaults_endpoint_to_perforce_grpc(self, monkeypatch): + monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) + monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False) + init_telemetry("perfecto-mcp", "1.0.0") + assert os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] == DEFAULT_OTLP_ENDPOINT + assert DEFAULT_OTLP_ENDPOINT == "https://grpc.public.prd.shared.perforce.com" + + def test_explicit_endpoint_not_overridden(self, monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False) + init_telemetry("perfecto-mcp", "1.0.0") + assert os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://localhost:4317" + + def test_defaults_protocol_to_grpc(self, monkeypatch): + monkeypatch.delenv("OTEL_EXPORTER_OTLP_PROTOCOL", raising=False) + assert _get_otlp_protocol() == DEFAULT_OTLP_PROTOCOL + assert DEFAULT_OTLP_PROTOCOL == "grpc" + + def test_http_protocol_env(self, monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + assert _get_otlp_protocol() == "http/protobuf" + + def test_does_not_raise_with_http_protocol(self, monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + init_telemetry("perfecto-mcp", "1.0.0") + + def test_grpc_trace_exporter_selected(self, monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") + with patch( + "opentelemetry.exporter.otlp.proto.grpc.trace_exporter.OTLPSpanExporter", + return_value=MagicMock(), + ) as grpc_exporter: + _create_trace_exporter() + grpc_exporter.assert_called_once() + + def test_http_trace_exporter_selected(self, monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + with patch( + "opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter", + return_value=MagicMock(), + ) as http_exporter: + _create_trace_exporter() + http_exporter.assert_called_once() + + def test_grpc_metric_exporter_selected(self, monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") + with patch( + "opentelemetry.exporter.otlp.proto.grpc.metric_exporter.OTLPMetricExporter", + return_value=MagicMock(), + ) as grpc_exporter: + _create_metric_exporter() + grpc_exporter.assert_called_once() + + def test_http_metric_exporter_selected(self, monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json") + with patch( + "opentelemetry.exporter.otlp.proto.http.metric_exporter.OTLPMetricExporter", + return_value=MagicMock(), + ) as http_exporter: + _create_metric_exporter() + http_exporter.assert_called_once() diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..a6f5657 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Perfecto MCP tool managers and AI Scriptless domain package.""" diff --git a/tools/ai_scriptless/__init__.py b/tools/ai_scriptless/__init__.py new file mode 100644 index 0000000..7f960c0 --- /dev/null +++ b/tools/ai_scriptless/__init__.py @@ -0,0 +1,125 @@ +from tools.ai_scriptless.commands import ( + COMMAND_SPECS, + CommandSpec, + command_id_from_element, + get_command_spec, + parse_command_id, +) +from tools.ai_scriptless.elements import ( + build_arguments, + build_branch, + build_flow_element, + build_if_statement, + build_logical_step, + build_loop, + new_empty_script, + strip_non_api_script_fields, + update_element_arguments, +) +from tools.ai_scriptless.item_key import ( + VISIBILITY_UI_ROOT, + ItemKey, + build_item_key, + build_move_test_body, + build_snapshot_search_body, + folder_type, + format_test_ui_location, + item_key_file_name, + split_item_key, +) +from tools.ai_scriptless.persistence import ( + _persist_script, + fetch_script_payload, + load_and_mutate, + persist_script, + script_write_lock, +) +from tools.ai_scriptless.script import Script, ScriptInput, coerce_script_dict +from tools.ai_scriptless.step_path import ( + STEP_PATH_PATTERN, + StepPath, + coerce_step_path, +) +from tools.ai_scriptless.tree import ( + CONTAINER_TYPES, + delete_element_by_path, + find_container_by_path, + find_element_by_path, + find_step_path_for_element, + insert_flow_element, + move_element_by_path, + set_condition_expression, + set_element_enabled, + update_flow_element_counts, + validate_step_path, +) +from tools.ai_scriptless.variables import ( + SUPPORTED_VARIABLE_TYPES, + VARIABLE_TYPE_ALIASES, + add_script_variable, + build_variable_data, + build_variable_entry, + delete_script_variable, + find_variable, + list_script_variables, + modify_script_variable, + validate_variable_name, +) + +__all__ = [ + "COMMAND_SPECS", + "CONTAINER_TYPES", + "CommandSpec", + "ItemKey", + "STEP_PATH_PATTERN", + "Script", + "ScriptInput", + "SUPPORTED_VARIABLE_TYPES", + "VARIABLE_TYPE_ALIASES", + "VISIBILITY_UI_ROOT", + "_persist_script", + "add_script_variable", + "build_arguments", + "build_branch", + "build_flow_element", + "build_if_statement", + "build_item_key", + "build_logical_step", + "build_loop", + "build_move_test_body", + "build_snapshot_search_body", + "build_variable_data", + "build_variable_entry", + "StepPath", + "coerce_script_dict", + "coerce_step_path", + "command_id_from_element", + "delete_element_by_path", + "delete_script_variable", + "fetch_script_payload", + "find_container_by_path", + "find_element_by_path", + "find_step_path_for_element", + "find_variable", + "folder_type", + "format_test_ui_location", + "get_command_spec", + "insert_flow_element", + "item_key_file_name", + "list_script_variables", + "load_and_mutate", + "modify_script_variable", + "move_element_by_path", + "new_empty_script", + "persist_script", + "parse_command_id", + "script_write_lock", + "set_condition_expression", + "set_element_enabled", + "split_item_key", + "strip_non_api_script_fields", + "update_element_arguments", + "update_flow_element_counts", + "validate_step_path", + "validate_variable_name", +] diff --git a/tools/ai_scriptless/commands.py b/tools/ai_scriptless/commands.py new file mode 100644 index 0000000..df5ecfc --- /dev/null +++ b/tools/ai_scriptless/commands.py @@ -0,0 +1,102 @@ +from dataclasses import dataclass, field +from typing import Any + +_HANDSET_DUT: dict[str, tuple[str, Any]] = {"handsetId": ("VARIABLE", "DUT")} + + +@dataclass(frozen=True) +class CommandSpec: + command_id: str + element_type: str + default_arguments: dict[str, tuple[str, Any]] + argument_aliases: dict[str, str] = field(default_factory=dict) + + @property + def error_policy(self) -> str: + return "IGNORE" if self.element_type == "Validation" else "ABORT" + + def default_arguments_merged(self) -> dict[str, tuple[str, Any]]: + return dict(self.default_arguments) + + def normalize_argument_names(self, arguments: dict[str, Any]) -> dict[str, Any]: + normalized: dict[str, Any] = {} + for name, value in arguments.items(): + normalized[self.argument_aliases.get(name, name)] = value + return normalized + + def drop_superseded_aliases(self, arguments: dict[str, Any]) -> None: + for alias, canonical in self.argument_aliases.items(): + if alias != canonical and canonical in arguments: + arguments.pop(alias, None) + + +def parse_command_id(command_id: str) -> tuple[str, str]: + if command_id.startswith("ai_"): + return "ai", command_id[3:] + if "_" in command_id: + command, subcommand = command_id.split("_", 1) + return command, subcommand + return command_id, "" + + +def command_id_from_element(element: dict[str, Any]) -> str: + command = element.get("command", "") + subcommand = element.get("subcommand") or "" + if subcommand: + return f"{command}_{subcommand}" + return command + + +def infer_element_type(command: str, subcommand: str) -> str: + if command == "ai" and subcommand == "validation": + return "Validation" + if command == "checkpoint": + return "Validation" + return "Action" + + +def _command_spec( + command_id: str, + element_type: str, + default_arguments: dict[str, tuple[str, Any]], + argument_aliases: dict[str, str] | None = None, +) -> CommandSpec: + return CommandSpec( + command_id=command_id, + element_type=element_type, + default_arguments=default_arguments, + argument_aliases=argument_aliases or {}, + ) + + +COMMAND_SPECS: dict[str, CommandSpec] = { + spec.command_id: spec + for spec in ( + _command_spec("ai_user-action", "Action", {**_HANDSET_DUT, "action": ("CONSTANT", "")}), + _command_spec("ai_validation", "Validation", {**_HANDSET_DUT, "validation": ("CONSTANT", "")}), + _command_spec("ai_visual-comparison", "Action", {**_HANDSET_DUT, "name": ("CONSTANT", "")}), + _command_spec("comment", "Action", {"text": ("CONSTANT", "")}), + _command_spec( + "wait", + "Action", + {"duration": ("CONSTANT", "1")}, + argument_aliases={"waitDuration": "duration"}, + ), + _command_spec("handset_ready", "Action", dict(_HANDSET_DUT)), + _command_spec("touch_tap", "Action", dict(_HANDSET_DUT)), + _command_spec("checkpoint_text", "Validation", dict(_HANDSET_DUT)), + _command_spec("checkpoint_image", "Validation", dict(_HANDSET_DUT)), + ) +} + + +def get_command_spec(command_id: str) -> CommandSpec: + registered = COMMAND_SPECS.get(command_id) + if registered is not None: + return registered + command, subcommand = parse_command_id(command_id) + return CommandSpec( + command_id=command_id, + element_type=infer_element_type(command, subcommand), + default_arguments=dict(_HANDSET_DUT), + ) diff --git a/tools/ai_scriptless/elements.py b/tools/ai_scriptless/elements.py new file mode 100644 index 0000000..e7e58b0 --- /dev/null +++ b/tools/ai_scriptless/elements.py @@ -0,0 +1,203 @@ +from typing import Any, Optional + +from tools.ai_scriptless.commands import ( + command_id_from_element, + get_command_spec, + parse_command_id, +) + + +def _make_argument(name: str, value: Any, data_source: str = "CONSTANT") -> dict[str, Any]: + if data_source == "VARIABLE": + data: dict[str, Any] = { + "@type": "VariableArgumentData", + "dataSource": "VARIABLE", + "value": value, + } + else: + data = { + "@type": "ConstantArgumentData", + "dataSource": "CONSTANT", + "secured": False, + "value": value, + } + return {"@type": "FunctionArgument", "name": name, "data": data} + + +def build_arguments(command_id: str, arguments: Optional[dict[str, Any]]) -> list[dict[str, Any]]: + spec = get_command_spec(command_id) + merged = spec.default_arguments_merged() + if arguments: + for name, value in spec.normalize_argument_names(arguments).items(): + if isinstance(value, dict) and "data_source" in value: + merged[name] = (value["data_source"], value.get("value")) + else: + merged[name] = ("CONSTANT", value) + spec.drop_superseded_aliases(merged) + return [_make_argument(name, value, source) for name, (source, value) in merged.items()] + + +def build_flow_element(command_id: str, arguments: Optional[dict[str, Any]] = None) -> dict[str, Any]: + command, subcommand = parse_command_id(command_id) + spec = get_command_spec(command_id) + return { + "@type": spec.element_type, + "validations": [], + "errorPolicy": spec.error_policy, + "command": command, + "subcommand": subcommand, + "arguments": build_arguments(command_id, arguments), + "comment": None, + "status": None, + "active": True, + } + + +def build_branch(clause: str) -> dict[str, Any]: + return { + "@type": "Branch", + "clause": clause, + "flowElements": [], + "numOfFlowElements": 0, + "empty": True, + "active": True, + "comment": None, + "status": None, + } + + +def build_logical_step(label: Optional[str] = None) -> dict[str, Any]: + return { + "@type": "LogicalStep", + "flowElements": [], + "active": True, + "label": label or "", + "comment": None, + "status": None, + } + + +def build_loop(count: int = 1) -> dict[str, Any]: + return { + "@type": "Loop", + "iterator": {"@type": "RepeatIterator", "count": count}, + "flowElements": [], + "active": True, + "comment": None, + "status": None, + } + + +def build_if_statement(expression: Optional[str] = None, label: Optional[str] = None) -> dict[str, Any]: + then_branch = build_branch("THEN") + else_branch = build_branch("ELSE") + statement: dict[str, Any] = { + "@type": "IfStatement", + "branches": [then_branch, else_branch], + # thenClause/elseClause must alias branches[0]/[1] — tree navigation and API layout share the same Branch objects. + "thenClause": then_branch, + "elseClause": else_branch, + "label": label or "", + "numOfFlowElements": 3, + "comment": None, + "status": None, + "active": True, + } + if expression: + statement["expression"] = expression + return statement + + +def new_empty_script() -> dict[str, Any]: + return { + "@type": "Script", + "parameters": [{ + "@type": "Parameter", + "data": { + "@type": "HandsetData", + "key": None, + "value": None, + "secured": False, + "description": None, + "displayName": None, + "name": "DUT", + }, + }], + "info": {"@type": "ScriptInfo", "description": "", "modelVersion": "1.0"}, + "options": {"@type": "ScriptOptions", "automaticAllocation": True}, + "variables": [], + "flowElements": [], + "numOfFlowElements": 0, + } + + +def normalize_if_statement_aliases(script: dict[str, Any]) -> None: + """Re-alias thenClause/elseClause to branches[0]/[1] after API load. + + Perfecto returns duplicate Branch trees; tree navigation edits branches[] only. + On save, the API canonicalizes from branches[] (thenClause is reconciled on read). + When counts match but content differs, branches[] wins — same rule as sync_branch_children. + If only thenClause has children and branches[0] is empty, copy clause children first + or Perfecto will drop them on persist. + """ + + def sync_branch_children(branch: dict[str, Any], clause: Optional[dict[str, Any]]) -> None: + if clause is None or clause is branch: + return + branch_children = branch.get("flowElements", []) + clause_children = clause.get("flowElements", []) + if not branch_children and clause_children: + branch["flowElements"] = clause_children + elif branch_children and clause_children and branch_children is not clause_children: + if len(clause_children) > len(branch_children): + branch["flowElements"] = clause_children + + def walk(flow_elements: list[dict[str, Any]]) -> None: + for element in flow_elements: + element_type = element.get("@type") + if element_type in ("LogicalStep", "Loop"): + walk(element.get("flowElements", [])) + elif element_type == "IfStatement": + branches = element.get("branches", []) + if len(branches) >= 2: + sync_branch_children(branches[0], element.get("thenClause")) + sync_branch_children(branches[1], element.get("elseClause")) + element["thenClause"] = branches[0] + element["elseClause"] = branches[1] + for branch in branches: + walk(branch.get("flowElements", [])) + + walk(script.get("flowElements", [])) + + +def strip_non_api_script_fields(script: dict[str, Any]) -> None: + def walk_element(element: dict[str, Any]) -> None: + element.pop("uuid", None) + for child in element.get("flowElements", []): + walk_element(child) + if element.get("@type") == "IfStatement": + for branch in element.get("branches", []): + walk_element(branch) + for clause_key in ("thenClause", "elseClause"): + clause = element.get(clause_key) + if clause: + walk_element(clause) + + for element in script.get("flowElements", []): + walk_element(element) + + +def update_element_arguments(element: dict[str, Any], arguments: dict[str, Any]) -> None: + command_id = command_id_from_element(element) + spec = get_command_spec(command_id) + existing = {argument["name"]: argument for argument in element.get("arguments", [])} + for name, value in spec.normalize_argument_names(arguments).items(): + if isinstance(value, dict) and "data_source" in value: + source = value["data_source"] + argument_value = value.get("value") + else: + source = "CONSTANT" + argument_value = value + existing[name] = _make_argument(name, argument_value, source) + spec.drop_superseded_aliases(existing) + element["arguments"] = list(existing.values()) diff --git a/tools/ai_scriptless/item_key.py b/tools/ai_scriptless/item_key.py new file mode 100644 index 0000000..3267999 --- /dev/null +++ b/tools/ai_scriptless/item_key.py @@ -0,0 +1,112 @@ +from dataclasses import dataclass +from typing import Any + + +VISIBILITY_UI_ROOT = { + "PRIVATE": "My Tests", + "PUBLIC": "Public Tests", + "GROUP": "Group Tests", +} + + +@dataclass(frozen=True) +class ItemKey: + value: str + + @classmethod + def parse(cls, item_key: str) -> "ItemKey": + visibility, _, path = item_key.partition(":") + if not path: + raise ValueError(f"Invalid itemKey format (expected VISIBILITY:path): {item_key}") + return cls(item_key) + + @classmethod + def build(cls, visibility: str, folder: str, name: str) -> "ItemKey": + test_name = name if name.endswith(".xml") else f"{name}.xml" + folder_path = folder.strip("/") + if folder_path: + return cls(f"{visibility}:{folder_path}/{test_name}") + return cls(f"{visibility}:{test_name}") + + @property + def visibility(self) -> str: + return self.value.partition(":")[0] + + @property + def path(self) -> str: + _, _, path = self.value.partition(":") + return path + + @property + def file_name(self) -> str: + return self.path.rsplit("/", 1)[-1] + + @property + def folder_path(self) -> str: + path = self.path + return path.rsplit("/", 1)[0] if "/" in path else "" + + @property + def folder_type(self) -> str: + return folder_type(self.visibility) + + def with_folder(self, folder: str, visibility: str) -> "ItemKey": + return ItemKey.build(visibility, folder.strip("/"), self.file_name.removesuffix(".xml")) + + def __str__(self) -> str: + return self.value + + +def build_item_key(visibility: str, folder: str, name: str) -> str: + return str(ItemKey.build(visibility, folder, name)) + + +def split_item_key(item_key: str) -> tuple[str, str]: + parsed = ItemKey.parse(item_key) + return parsed.visibility, parsed.path + + +def folder_type(visibility: str) -> str: + if visibility in ("PRIVATE", "GROUP"): + return visibility + return "PUBLIC" + + +def item_key_file_name(item_key: str) -> str: + return ItemKey.parse(item_key).file_name + + +def format_test_ui_location(item_key: str) -> str: + """Map itemKey to folder/test labels shown in the AI Scriptless Open Test UI.""" + key = ItemKey.parse(item_key) + root = VISIBILITY_UI_ROOT.get(key.visibility, key.visibility) + file_name = key.file_name.removesuffix(".xml") + if key.folder_path: + return f'"{root}" → folder "{key.folder_path}" → test "{file_name}"' + return f'"{root}" → test "{file_name}"' + + +def build_snapshot_search_body(test_id: str) -> dict[str, Any]: + key = ItemKey.parse(test_id) + return { + "repositoryType": "SCRIPTS", + "keyDetails": {"artifactId": key.path, "version": "v0"}, + "folderType": key.folder_type, + } + + +def build_move_test_body( + test_id: str, + folder: str, + visibility: str, +) -> dict[str, Any]: + source = ItemKey.parse(test_id) + target = source.with_folder(folder, visibility) + return { + "repositoryType": "SCRIPTS", + "keyDetails": {"artifactId": source.path, "version": "v0"}, + "folderType": source.folder_type, + "targetKeyDetails": {"artifactId": target.path, "version": "v0"}, + "targetFolderType": target.folder_type, + "copy": False, + } diff --git a/tools/ai_scriptless/persistence.py b/tools/ai_scriptless/persistence.py new file mode 100644 index 0000000..ff8259c --- /dev/null +++ b/tools/ai_scriptless/persistence.py @@ -0,0 +1,159 @@ +import asyncio +import copy +import json +from contextlib import asynccontextmanager +from typing import Any, Optional +from urllib.parse import quote + +from config import perfecto +from config.token import PerfectoToken +from models.result import BaseResult +from tools.ai_scriptless.elements import normalize_if_statement_aliases, strip_non_api_script_fields +from tools.ai_scriptless.script import ScriptInput, coerce_script_dict +from tools.ai_scriptless.tree import update_flow_element_counts +from tools.utils import api_request + + +async def fetch_script_payload(token: PerfectoToken, test_id: str) -> BaseResult: + script_url = perfecto.get_ai_scriptless_api_url(token.cloud_name) + script_url = script_url + f"/script?itemKey={quote(test_id, safe='')}" + return await api_request(token, "GET", endpoint=script_url) + + +_script_write_locks: dict[str, asyncio.Lock] = {} +_script_write_locks_guard = asyncio.Lock() + + +async def _get_script_write_lock(item_key: str) -> asyncio.Lock: + async with _script_write_locks_guard: + lock = _script_write_locks.get(item_key) + if lock is None: + lock = asyncio.Lock() + _script_write_locks[item_key] = lock + return lock + + +@asynccontextmanager +async def script_write_lock(item_key: str): + lock = await _get_script_write_lock(item_key) + async with lock: + yield + + +async def _persist_script( + token: PerfectoToken, + item_key: str, + script: ScriptInput, + saved_script: Optional[ScriptInput] = None, + snapshot_comment: Optional[str] = None, +) -> BaseResult: + working_script = copy.deepcopy(coerce_script_dict(script)) + baseline_script = copy.deepcopy(coerce_script_dict(saved_script or script)) + normalize_if_statement_aliases(working_script) + strip_non_api_script_fields(working_script) + strip_non_api_script_fields(baseline_script) + update_flow_element_counts(working_script) + + draft_url = perfecto.get_ai_scriptless_draft_api_url(token.cloud_name) + draft_data = json.dumps({ + "unsavedScript": working_script, + "savedScript": baseline_script, + }) + draft_result = await api_request( + token, + "POST", + endpoint=draft_url, + json={ + "path": item_key, + "type": "MOBILE_IDE_SCRIPT", + "data": draft_data, + }, + ) + if draft_result.error: + return draft_result + draft_key = draft_result.result.get("key") + if not draft_key: + return BaseResult(error="Draft creation failed: missing draft key in response") + + script_url = perfecto.get_ai_scriptless_api_url(token.cloud_name) + "/script" + save_body: dict[str, Any] = { + "script": working_script, + "itemKey": item_key, + "draftKey": draft_key, + } + if snapshot_comment: + save_body["snapshotComment"] = snapshot_comment + save_result = await api_request( + token, + "POST", + endpoint=script_url, + json=save_body, + ) + if save_result.error: + return save_result + + result: dict[str, Any] = { + "item_key": item_key, + "draft_key": draft_key, + "status": save_result.result.get("status", "success") if isinstance(save_result.result, dict) else "success", + "flow_element_count": len(working_script.get("flowElements", [])), + } + if snapshot_comment: + result["snapshot_comment"] = snapshot_comment + result["notes"] = [ + "Perfecto adds a new snapshot history entry on every script save.", + "Use list_snapshots with test_id to see version history after saving.", + ] + if snapshot_comment: + result["notes"].append( + "The comment labels the '' entry in list_snapshots (UI: Save with comment)." + ) + else: + result["notes"].append( + "Saving without comment still creates a history entry; pass comment to label the current version." + ) + return BaseResult(result=result) + + +async def persist_script( + token: PerfectoToken, + item_key: str, + script: ScriptInput, + saved_script: Optional[ScriptInput] = None, + snapshot_comment: Optional[str] = None, +) -> BaseResult: + async with script_write_lock(item_key): + return await _persist_script( + token, + item_key, + script, + saved_script, + snapshot_comment, + ) + + +async def load_and_mutate( + token: PerfectoToken, + test_id: str, + mutator, + snapshot_comment: Optional[str] = None, +) -> BaseResult: + async with script_write_lock(test_id): + payload_result = await fetch_script_payload(token, test_id) + if payload_result.error: + return payload_result + payload = payload_result.result + script = copy.deepcopy(payload.get("script", {})) + saved_script = copy.deepcopy(payload.get("script", {})) + normalize_if_statement_aliases(script) + try: + mutator(script) + except ValueError as exc: + return BaseResult(error=str(exc)) + return await _persist_script( + token, + test_id, + script, + saved_script, + snapshot_comment, + ) diff --git a/tools/ai_scriptless/script.py b/tools/ai_scriptless/script.py new file mode 100644 index 0000000..f52dc0e --- /dev/null +++ b/tools/ai_scriptless/script.py @@ -0,0 +1,154 @@ +import copy +from typing import Any, Optional, Union + +from tools.ai_scriptless.elements import new_empty_script, strip_non_api_script_fields +from tools.ai_scriptless.step_path import StepPathInput +from tools.ai_scriptless.tree import ( + delete_element_by_path, + find_container_by_path, + find_element_by_path, + find_step_path_for_element, + insert_flow_element, + move_element_by_path, + set_condition_expression, + set_element_enabled, + update_flow_element_counts, +) +from tools.ai_scriptless.variables import ( + add_script_variable, + delete_script_variable, + find_variable, + list_script_variables, + modify_script_variable, +) + +ScriptInput = Union["Script", dict[str, Any]] + + +def coerce_script_dict(script: ScriptInput) -> dict[str, Any]: + if isinstance(script, Script): + return script.to_dict() + return script + + +class Script: + """In-memory aggregate root for an AI Scriptless script payload.""" + + def __init__(self, data: dict[str, Any]) -> None: + self._data = data + + @classmethod + def empty(cls) -> "Script": + return cls(new_empty_script()) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Script": + return cls(copy.deepcopy(data)) + + @classmethod + def wrap(cls, data: dict[str, Any]) -> "Script": + """Wrap an existing dict for in-place mutation (e.g. load_and_mutate).""" + return cls(data) + + def to_dict(self) -> dict[str, Any]: + return self._data + + def copy(self) -> "Script": + return Script.from_dict(self._data) + + @property + def flow_elements(self) -> list[dict[str, Any]]: + return self._data.setdefault("flowElements", []) + + @property + def variables(self) -> list[dict[str, Any]]: + return self._data.setdefault("variables", []) + + @property + def flow_element_count(self) -> int: + return len(self.flow_elements) + + def prepare_for_persist(self) -> None: + strip_non_api_script_fields(self._data) + update_flow_element_counts(self._data) + + def find_step_path_for_element(self, target: dict[str, Any]) -> Optional[str]: + return find_step_path_for_element(self._data, target) + + def find_element_by_path( + self, + step_path: StepPathInput, + ) -> Optional[tuple[list[dict[str, Any]], int, dict[str, Any]]]: + return find_element_by_path(self._data, step_path) + + def find_container_by_path(self, step_path: StepPathInput) -> Optional[dict[str, Any]]: + return find_container_by_path(self._data, step_path) + + def insert_flow_element( + self, + element: dict[str, Any], + after_path: StepPathInput = None, + parent_path: StepPathInput = None, + ) -> None: + insert_flow_element(self._data, element, after_path=after_path, parent_path=parent_path) + + def delete_element_by_path(self, step_path: StepPathInput) -> None: + delete_element_by_path(self._data, step_path) + + def set_element_enabled(self, step_path: StepPathInput, enabled: bool) -> None: + set_element_enabled(self._data, step_path, enabled) + + def set_condition_expression(self, step_path: StepPathInput, expression: str) -> None: + set_condition_expression(self._data, step_path, expression) + + def move_element_by_path( + self, + step_path: StepPathInput, + after_path: StepPathInput = None, + parent_path: StepPathInput = None, + ) -> None: + move_element_by_path( + self._data, + step_path, + after_path=after_path, + parent_path=parent_path, + ) + + def list_variables(self) -> list[dict[str, Any]]: + return list_script_variables(self._data) + + def find_variable(self, variable_name: str) -> Optional[tuple[int, dict[str, Any]]]: + return find_variable(self._data, variable_name) + + def add_variable( + self, + name: str, + variable_type: str, + value: Any, + set_at_runtime: bool = False, + ) -> dict[str, Any]: + return add_script_variable( + self._data, + name, + variable_type, + value, + set_at_runtime=set_at_runtime, + ) + + def modify_variable( + self, + variable_name: str, + value: Optional[Any] = None, + variable_type: Optional[str] = None, + set_at_runtime: Optional[bool] = None, + ) -> dict[str, Any]: + return modify_script_variable( + self._data, + variable_name, + value=value, + variable_type=variable_type, + set_at_runtime=set_at_runtime, + ) + + def delete_variable(self, variable_name: str) -> None: + delete_script_variable(self._data, variable_name) diff --git a/tools/ai_scriptless/step_path.py b/tools/ai_scriptless/step_path.py new file mode 100644 index 0000000..61d4ba5 --- /dev/null +++ b/tools/ai_scriptless/step_path.py @@ -0,0 +1,58 @@ +import re +from dataclasses import dataclass +from typing import Optional, Union + +# Dot-separated positional paths (no spaces). Examples: 0, 2.0, 5.b0, 5.b0.1 +# Segments are either a 0-based index (0, 1, 2) or a branch marker (b0=Then, b1=Else). +STEP_PATH_PATTERN = re.compile(r"^(?:\d+|b\d+)(?:\.(?:\d+|b\d+))*$") + +StepPathInput = Union[str, "StepPath", None] + + +@dataclass(frozen=True) +class StepPath: + parts: tuple[str, ...] + + @classmethod + def parse(cls, step_path: str) -> "StepPath": + if not step_path or step_path.strip() != step_path or " " in step_path: + raise ValueError( + "step_path must be a dot-separated positional path without spaces " + "(e.g. 0, 2.0, 5.b0, 5.b0.1)" + ) + if not STEP_PATH_PATTERN.match(step_path): + raise ValueError( + f"invalid step_path: {step_path!r}. Use dot-separated indices, e.g. 0, 2.0, 5.b0.1" + ) + return cls(tuple(step_path.split("."))) + + @classmethod + def root_index(cls, index: int) -> "StepPath": + return cls((str(index),)) + + def child_index(self, index: int) -> "StepPath": + return StepPath((*self.parts, str(index))) + + def branch(self, branch_index: int) -> "StepPath": + return StepPath((*self.parts, f"b{branch_index}")) + + @property + def parent_prefix(self) -> str: + if not self.parts: + return "" + return ".".join(self.parts) + "." + + def __str__(self) -> str: + return ".".join(self.parts) + + +def coerce_step_path(step_path: StepPathInput) -> Optional[StepPath]: + if step_path is None: + return None + if isinstance(step_path, StepPath): + return step_path + return StepPath.parse(step_path) + + +def validate_step_path(step_path: str) -> None: + StepPath.parse(step_path) diff --git a/tools/ai_scriptless/tree.py b/tools/ai_scriptless/tree.py new file mode 100644 index 0000000..9cb46d0 --- /dev/null +++ b/tools/ai_scriptless/tree.py @@ -0,0 +1,220 @@ +from typing import Any, Optional + +from tools.ai_scriptless.step_path import StepPath, StepPathInput, coerce_step_path + + +CONTAINER_TYPES = frozenset({"LogicalStep", "Loop", "Branch"}) + + +def _update_branch_container(branch: dict[str, Any]) -> None: + children = branch.setdefault("flowElements", []) + for child in children: + _update_element_counts(child) + count = len(children) + branch["numOfFlowElements"] = count + if "empty" in branch: + branch["empty"] = count == 0 + + +def _update_element_counts(element: dict[str, Any]) -> None: + element_type = element.get("@type") + if element_type == "IfStatement": + branches = element.get("branches", []) + direct_in_branches = 0 + for branch in branches: + _update_branch_container(branch) + direct_in_branches += len(branch.get("flowElements", [])) + # Perfecto: 3 + direct step count in Then/Else (nested steps count inside their own IfStatement). + element["numOfFlowElements"] = 3 + direct_in_branches + elif element_type in ("LogicalStep", "Loop"): + children = element.setdefault("flowElements", []) + for child in children: + _update_element_counts(child) + element["numOfFlowElements"] = len(children) + elif element_type == "Branch": + _update_branch_container(element) + + +def validate_step_path(step_path: str) -> None: + StepPath.parse(step_path) + + +def find_step_path_for_element(script: dict[str, Any], target: dict[str, Any]) -> Optional[str]: + def walk( + flow_elements: list[dict[str, Any]], + parent: Optional[StepPath], + ) -> Optional[StepPath]: + for index, element in enumerate(flow_elements): + step_path = ( + StepPath.root_index(index) + if parent is None + else parent.child_index(index) + ) + if element is target: + return step_path + nested = walk(element.get("flowElements", []), step_path) + if nested: + return nested + if element.get("@type") == "IfStatement": + for branch_index, branch in enumerate(element.get("branches", [])): + branch_path = step_path.branch(branch_index) + if branch is target: + return branch_path + nested = walk(branch.get("flowElements", []), branch_path) + if nested: + return nested + return None + + located = walk(script.get("flowElements", []), None) + return str(located) if located is not None else None + + +def find_element_by_path( + script: dict[str, Any], + step_path: StepPathInput, +) -> Optional[tuple[list[dict[str, Any]], int, dict[str, Any]]]: + path = coerce_step_path(step_path) + if path is None: + return None + parts = path.parts + current_list = script.get("flowElements", []) + parent_list: Optional[list[dict[str, Any]]] = None + parent_index: Optional[int] = None + current_element: Optional[dict[str, Any]] = None + + for part_index, part in enumerate(parts): + is_last = part_index == len(parts) - 1 + + if part.startswith("b"): + if current_element is None or current_element.get("@type") != "IfStatement": + return None + branch_index = int(part[1:]) + branches = current_element.get("branches", []) + if branch_index >= len(branches): + return None + if is_last: + parent_list = branches + parent_index = branch_index + current_element = branches[branch_index] + else: + current_element = branches[branch_index] + current_list = current_element.get("flowElements", []) + continue + + index = int(part) + if index >= len(current_list): + return None + if is_last: + parent_list = current_list + parent_index = index + current_element = current_list[index] + else: + current_element = current_list[index] + next_part = parts[part_index + 1] + if not next_part.startswith("b"): + current_list = current_element.get("flowElements", []) + + if current_element is None or parent_list is None or parent_index is None: + return None + return parent_list, parent_index, current_element + + +def find_container_by_path(script: dict[str, Any], step_path: StepPathInput) -> Optional[dict[str, Any]]: + located = find_element_by_path(script, step_path) + if located is None: + return None + _, _, element = located + return element + + +def update_flow_element_counts(script: dict[str, Any]) -> None: + for element in script.get("flowElements", []): + _update_element_counts(element) + script["numOfFlowElements"] = len(script.get("flowElements", [])) + + +def insert_flow_element( + script: dict[str, Any], + element: dict[str, Any], + after_path: StepPathInput = None, + parent_path: StepPathInput = None, +) -> None: + if parent_path: + parent = find_container_by_path(script, parent_path) + if parent is None: + raise ValueError(f"parent_path not found: {parent_path}") + if parent.get("@type") not in CONTAINER_TYPES: + raise ValueError( + f"parent_path must reference a container (LogicalStep, Loop, Branch): {parent_path}" + ) + parent.setdefault("flowElements", []).append(element) + elif after_path: + located = find_element_by_path(script, after_path) + if located is None: + raise ValueError(f"after_path not found: {after_path}") + elements, index, _ = located + elements.insert(index + 1, element) + else: + script.setdefault("flowElements", []).append(element) + update_flow_element_counts(script) + + +def delete_element_by_path(script: dict[str, Any], step_path: StepPathInput) -> None: + located = find_element_by_path(script, step_path) + if located is None: + raise ValueError(f"step_path not found: {step_path}") + elements, index, _ = located + elements.pop(index) + update_flow_element_counts(script) + + +def set_element_enabled(script: dict[str, Any], step_path: StepPathInput, enabled: bool) -> None: + located = find_element_by_path(script, step_path) + if located is None: + raise ValueError(f"step_path not found: {step_path}") + _, _, element = located + element["active"] = enabled + + +def set_condition_expression(script: dict[str, Any], step_path: StepPathInput, expression: str) -> None: + located = find_element_by_path(script, step_path) + if located is None: + raise ValueError(f"step_path not found: {step_path}") + _, _, element = located + if element.get("@type") != "IfStatement": + raise ValueError(f"step_path must reference an IfStatement: {step_path}") + element["expression"] = expression + + +def move_element_by_path( + script: dict[str, Any], + step_path: StepPathInput, + after_path: StepPathInput = None, + parent_path: StepPathInput = None, +) -> None: + located = find_element_by_path(script, step_path) + if located is None: + raise ValueError(f"step_path not found: {step_path}") + source_list, source_index, element = located + source_list.pop(source_index) + + if parent_path: + parent = find_container_by_path(script, parent_path) + if parent is None: + raise ValueError(f"parent_path not found: {parent_path}") + if parent.get("@type") not in CONTAINER_TYPES: + raise ValueError( + f"parent_path must reference a container (LogicalStep, Loop, Branch): {parent_path}" + ) + parent.setdefault("flowElements", []).append(element) + elif after_path: + target = find_element_by_path(script, after_path) + if target is None: + raise ValueError(f"after_path not found: {after_path}") + target_list, target_index, _ = target + if target_list is source_list and source_index < target_index: + target_index -= 1 + target_list.insert(target_index + 1, element) + else: + script.setdefault("flowElements", []).append(element) + update_flow_element_counts(script) diff --git a/tools/ai_scriptless/variables.py b/tools/ai_scriptless/variables.py new file mode 100644 index 0000000..59379ba --- /dev/null +++ b/tools/ai_scriptless/variables.py @@ -0,0 +1,153 @@ +from typing import Any, Optional + + +VARIABLE_TYPE_ALIASES = { + "string": "StringData", + "secured_string": "StringData", + "number": "IntegerData", + "boolean": "BooleanData", + "device": "HandsetData", + "media": "MediaData", + "datatable": "TableData", +} + +SUPPORTED_VARIABLE_TYPES = frozenset({"string", "secured_string", "number", "boolean"}) + + +def validate_variable_name(name: str) -> None: + if not name or not name.strip(): + raise ValueError("name is required") + if name[0].isdigit(): + raise ValueError("name cannot begin with a number") + if not all(char.isalnum() or char == "_" for char in name): + raise ValueError("name may contain only letters, numbers, and underscore") + + +def _coerce_variable_value(variable_type: str, value: Any) -> Any: + if variable_type == "boolean": + if isinstance(value, bool): + return value + normalized = str(value).lower() + if normalized not in ("true", "false"): + raise ValueError("boolean value must be true or false") + return normalized == "true" + if variable_type == "number": + try: + numeric = int(value) + except (TypeError, ValueError) as exc: + raise ValueError("number value must be an integer") from exc + return numeric + return "" if value is None else str(value) + + +def build_variable_data(variable_type: str, name: str, value: Any) -> dict[str, Any]: + if variable_type not in SUPPORTED_VARIABLE_TYPES: + raise ValueError( + f"Unsupported variable type: {variable_type}. " + f"Supported types: {', '.join(sorted(SUPPORTED_VARIABLE_TYPES))}" + ) + validate_variable_name(name) + coerced_value = _coerce_variable_value(variable_type, value) + data_type = VARIABLE_TYPE_ALIASES[variable_type] + data: dict[str, Any] = { + "@type": data_type, + "description": None, + "displayName": None, + "name": name, + "secured": variable_type == "secured_string", + "value": coerced_value, + } + if data_type == "HandsetData": + data["key"] = None + if data_type == "TableData": + data["columns"] = [] + return data + + +def build_variable_entry( + name: str, + variable_type: str, + value: Any, + set_at_runtime: bool = False, +) -> dict[str, Any]: + return { + "@type": "Parameter" if set_at_runtime else "Variable", + "data": build_variable_data(variable_type, name, value), + } + + +def find_variable(script: dict[str, Any], variable_name: str) -> Optional[tuple[int, dict[str, Any]]]: + for index, variable in enumerate(script.get("variables", [])): + data = variable.get("data", {}) + if data.get("name") == variable_name: + return index, variable + return None + + +def list_script_variables(script: dict[str, Any]) -> list[dict[str, Any]]: + return list(script.get("variables", [])) + + +def add_script_variable( + script: dict[str, Any], + name: str, + variable_type: str, + value: Any, + set_at_runtime: bool = False, +) -> dict[str, Any]: + validate_variable_name(name) + if find_variable(script, name): + raise ValueError(f"variable already exists: {name}") + if name == "DUT": + raise ValueError("DUT is a test parameter, not a script variable") + entry = build_variable_entry(name, variable_type, value, set_at_runtime) + script.setdefault("variables", []).append(entry) + return entry + + +def modify_script_variable( + script: dict[str, Any], + variable_name: str, + value: Optional[Any] = None, + variable_type: Optional[str] = None, + set_at_runtime: Optional[bool] = None, +) -> dict[str, Any]: + located = find_variable(script, variable_name) + if located is None: + raise ValueError(f"variable not found: {variable_name}") + _, variable = located + current_type = _variable_type_from_data(variable.get("data", {})) + target_type = variable_type or current_type + if target_type not in SUPPORTED_VARIABLE_TYPES: + raise ValueError( + f"Unsupported variable type: {target_type}. " + f"Supported types: {', '.join(sorted(SUPPORTED_VARIABLE_TYPES))}" + ) + current_value = variable.get("data", {}).get("value") + target_value = current_value if value is None else value + variable["data"] = build_variable_data(target_type, variable_name, target_value) + if set_at_runtime is not None: + variable["@type"] = "Parameter" if set_at_runtime else "Variable" + return variable + + +def delete_script_variable(script: dict[str, Any], variable_name: str) -> None: + located = find_variable(script, variable_name) + if located is None: + raise ValueError(f"variable not found: {variable_name}") + index, _ = located + script.get("variables", []).pop(index) + + +def _variable_type_from_data(data: dict[str, Any]) -> str: + data_type = data.get("@type", "") + if data_type == "StringData": + return "secured_string" if data.get("secured") else "string" + reverse = { + "BooleanData": "boolean", + "IntegerData": "number", + "HandsetData": "device", + "MediaData": "media", + "TableData": "datatable", + } + return reverse.get(data_type, "string") diff --git a/tools/ai_scriptless_commands.py b/tools/ai_scriptless_commands.py new file mode 100644 index 0000000..e09ccfd --- /dev/null +++ b/tools/ai_scriptless_commands.py @@ -0,0 +1,20 @@ +"""Backward-compatible facade. Implementation lives in tools.ai_scriptless.commands.""" + +from tools.ai_scriptless.commands import * # noqa: F403 +from tools.ai_scriptless.commands import ( + COMMAND_SPECS, + CommandSpec, + command_id_from_element, + get_command_spec, + infer_element_type, + parse_command_id, +) + +__all__ = [ + "COMMAND_SPECS", + "CommandSpec", + "command_id_from_element", + "get_command_spec", + "infer_element_type", + "parse_command_id", +] diff --git a/tools/ai_scriptless_manager.py b/tools/ai_scriptless_manager.py index 05c613f..f19dc06 100644 --- a/tools/ai_scriptless_manager.py +++ b/tools/ai_scriptless_manager.py @@ -1,6 +1,7 @@ import json import traceback from typing import Optional, Any, Dict +from urllib.parse import quote import httpx from mcp.server.fastmcp import Context @@ -10,11 +11,75 @@ from config.perfecto import TOOLS_PREFIX, SUPPORT_MESSAGE from config.token import PerfectoToken, token_verify from formatters.ai_scriptless import format_ai_scriptless_tests, \ - format_ai_scriptless_tests_filter_values + format_ai_scriptless_tests_filter_values, command_selection_policy_info, \ + format_command_catalog, format_command_definitions, format_snapshots_list, \ + format_test_structure, format_test_variables from models.manager import Manager from models.result import BaseResult, PaginationResult +from telemetry import run_tool +from tools.ai_scriptless_script import ( + add_script_variable, + build_flow_element, + build_if_statement, + build_item_key, + build_logical_step, + build_loop, + build_move_test_body, + build_snapshot_search_body, + delete_script_variable, + delete_element_by_path, + fetch_script_payload, + find_element_by_path, + find_step_path_for_element, + insert_flow_element, + load_and_mutate, + modify_script_variable, + move_element_by_path, + new_empty_script, + persist_script, + script_write_lock, + set_condition_expression, + set_element_enabled, + split_item_key, + item_key_file_name, + format_test_ui_location, + update_element_arguments, +) from tools.utils import api_request +STEP_PATH_REFRESH_NOTES = [ + "step_path values are dot-separated positional paths (e.g. 0, 2.0, 5.b0.1); Perfecto does not persist them.", + "After this operation, step paths may have changed. Call view_test_structure before the next edit; " + "do not reuse step_path values from this response.", +] + +def _append_step_path_refresh_notes(result: BaseResult) -> BaseResult: + if result.error or not isinstance(result.result, dict): + return result + notes = result.result.setdefault("notes", []) + for note in STEP_PATH_REFRESH_NOTES: + if note not in notes: + notes.append(note) + return result + + +def _append_ui_access_info( + result: BaseResult, + cloud_name: str, + test_id: Optional[str] = None, +) -> BaseResult: + if result.error: + return result + lab_url = perfecto.get_ai_scriptless_lab_url(cloud_name) + lines = [f"AI Scriptless UI (no per-test deep link): [{lab_url}]({lab_url})"] + if test_id: + lines.append( + "If you need to open it in the UI: Tests → Open or Manage tests, then navigate to " + f"{format_test_ui_location(test_id)}" + ) + result.append_info(lines) + return result + class AiScriptlessManager(Manager): def __init__(self, token: Optional[PerfectoToken], ctx: Context): @@ -33,21 +98,23 @@ async def list_tests(self, args: dict[str, Any]) -> BaseResult: result_formatter_params={"page_size": page_size, "skip": skip, "filters": args}) + items = tests_result.result or [] page_result = PaginationResult( - items=tests_result.result, - count=len(tests_result.result), + items=items, + count=len(items), page=page_index, offset=skip, next_offset=skip + page_size, - has_more=page_size - len(tests_result.result) <= 0, + has_more=page_size - len(items) <= 0, ) - return BaseResult( + result = BaseResult( result=page_result, error=tests_result.error, warning=tests_result.warning, info=tests_result.info, ) + return _append_ui_access_info(result, self.token.cloud_name) @token_verify async def list_filter_values(self, filter_names: list[str]) -> BaseResult: @@ -143,6 +210,439 @@ async def execute_test(self, test_id: str, device_type: str, device_under_test: error="Invalid device_type or device_under_test value." ) + @token_verify + async def list_commands(self, checkpoint: bool = False) -> BaseResult: + commands_url = perfecto.get_ai_scriptless_command_repository_url(self.token.cloud_name) + commands_url = commands_url + "/commands" + if checkpoint: + commands_url = commands_url + "?checkpoint=true" + result = await api_request(self.token, "GET", endpoint=commands_url, + result_formatter=format_command_catalog) + if not result.error: + result.append_info(command_selection_policy_info()) + return result + + @token_verify + async def get_command_definitions(self, command_ids: list[str]) -> BaseResult: + if not command_ids: + return BaseResult(error="command_ids is required and must not be empty") + definitions_url = perfecto.get_ai_scriptless_command_repository_url(self.token.cloud_name) + definitions_url = definitions_url + "/commands/definitions" + return await api_request(self.token, "POST", endpoint=definitions_url, + json={"commandIds": command_ids}, + result_formatter=format_command_definitions) + + @token_verify + async def view_test_structure(self, test_id: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required (itemKey from list_tests)") + script_url = perfecto.get_ai_scriptless_api_url(self.token.cloud_name) + script_url = script_url + f"/script?itemKey={quote(test_id, safe='')}" + result = await api_request(self.token, "GET", endpoint=script_url, + result_formatter=format_test_structure, + result_formatter_params={"item_key": test_id}) + return _append_ui_access_info(result, self.token.cloud_name, test_id) + + @token_verify + async def add_command( + self, + test_id: str, + command_id: str, + arguments: Optional[dict[str, Any]] = None, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not command_id: + return BaseResult(error="command_id is required (from list_commands)") + + element = build_flow_element(command_id, arguments) + inserted_path: dict[str, Optional[str]] = {"step_path": None} + + def mutator(script: dict[str, Any]) -> None: + insert_flow_element(script, element, after_path=after_path, parent_path=parent_path) + inserted_path["step_path"] = find_step_path_for_element(script, element) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["step_path"] = inserted_path["step_path"] + result.result["command_id"] = command_id + return _append_step_path_refresh_notes(result) + + @token_verify + async def modify_command(self, test_id: str, step_path: str, arguments: dict[str, Any]) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not step_path: + return BaseResult(error="step_path is required (from view_test_structure)") + if not arguments: + return BaseResult(error="arguments is required") + + def mutator(script: dict[str, Any]) -> None: + located = find_element_by_path(script, step_path) + if located is None: + raise ValueError(f"step_path not found: {step_path}") + _, _, element = located + update_element_arguments(element, arguments) + + return _append_step_path_refresh_notes( + await load_and_mutate(self.token, test_id, mutator) + ) + + @token_verify + async def delete_command(self, test_id: str, step_path: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not step_path: + return BaseResult(error="step_path is required (from view_test_structure)") + + def mutator(script: dict[str, Any]) -> None: + delete_element_by_path(script, step_path) + + return _append_step_path_refresh_notes( + await load_and_mutate(self.token, test_id, mutator) + ) + + @token_verify + async def set_command_enabled(self, test_id: str, step_path: str, enabled: bool) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not step_path: + return BaseResult(error="step_path is required (from view_test_structure)") + + def mutator(script: dict[str, Any]) -> None: + set_element_enabled(script, step_path, enabled) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["step_path"] = step_path + result.result["active"] = enabled + return _append_step_path_refresh_notes(result) + + @token_verify + async def save_test(self, test_id: str, comment: Optional[str] = None) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + + def mutator(_script: dict[str, Any]) -> None: + pass + + return await load_and_mutate( + self.token, + test_id, + mutator, + snapshot_comment=comment, + ) + + @token_verify + async def create_test(self, name: str, folder: str = "My Folder", visibility: str = "PRIVATE") -> BaseResult: + if not name: + return BaseResult(error="name is required") + item_key = build_item_key(visibility, folder, name) + script = new_empty_script() + result = await persist_script(self.token, item_key, script) + return _append_ui_access_info(result, self.token.cloud_name, item_key) + + @token_verify + async def save_test_as( + self, + test_id: str, + name: str, + folder: str = "My Folder", + visibility: str = "PRIVATE", + comment: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not name: + return BaseResult(error="name is required") + async with script_write_lock(test_id): + payload_result = await fetch_script_payload(self.token, test_id) + if payload_result.error: + return payload_result + script = payload_result.result.get("script", {}) + item_key = build_item_key(visibility, folder, name) + result = _append_step_path_refresh_notes( + await persist_script(self.token, item_key, script, snapshot_comment=comment) + ) + return _append_ui_access_info(result, self.token.cloud_name, item_key) + + async def _add_structure( + self, + test_id: str, + element: dict[str, Any], + structure_type: str, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + inserted_path: dict[str, Optional[str]] = {"step_path": None} + + def mutator(script: dict[str, Any]) -> None: + insert_flow_element(script, element, after_path=after_path, parent_path=parent_path) + inserted_path["step_path"] = find_step_path_for_element(script, element) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["step_path"] = inserted_path["step_path"] + result.result["structure_type"] = structure_type + return _append_step_path_refresh_notes(result) + + @token_verify + async def add_logical_step( + self, + test_id: str, + label: Optional[str] = None, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + element = build_logical_step(label) + return await self._add_structure(test_id, element, "LogicalStep", after_path, parent_path) + + @token_verify + async def add_loop( + self, + test_id: str, + count: int = 1, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if count < 1: + return BaseResult(error="count must be at least 1") + element = build_loop(count) + result = await self._add_structure(test_id, element, "Loop", after_path, parent_path) + if not result.error: + result.result["count"] = count + return result + + @token_verify + async def add_condition( + self, + test_id: str, + expression: Optional[str] = None, + label: Optional[str] = None, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + element = build_if_statement(expression, label) + result = await self._add_structure(test_id, element, "IfStatement", after_path, parent_path) + if not result.error and expression: + result.result["expression"] = expression + return result + + @token_verify + async def set_condition_expression(self, test_id: str, step_path: str, expression: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not step_path: + return BaseResult(error="step_path is required (IfStatement path from view_test_structure)") + if not expression: + return BaseResult(error="expression is required") + + def mutator(script: dict[str, Any]) -> None: + set_condition_expression(script, step_path, expression) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["step_path"] = step_path + result.result["expression"] = expression + return _append_step_path_refresh_notes(result) + + @token_verify + async def move_command( + self, + test_id: str, + step_path: str, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not step_path: + return BaseResult(error="step_path is required") + if not after_path and not parent_path: + return BaseResult(error="after_path or parent_path is required") + + def mutator(script: dict[str, Any]) -> None: + move_element_by_path(script, step_path, after_path=after_path, parent_path=parent_path) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["step_path"] = step_path + return _append_step_path_refresh_notes(result) + + @token_verify + async def delete_test(self, test_id: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required (itemKey from list_tests)") + delete_url = perfecto.get_ai_scriptless_api_url(self.token.cloud_name) + "/repositories" + return await api_request( + self.token, + "DELETE", + endpoint=delete_url, + params={"itemKey": test_id}, + ) + + @token_verify + async def move_test( + self, + test_id: str, + folder: str, + visibility: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required (itemKey from list_tests)") + + try: + source_visibility, _ = split_item_key(test_id) + except ValueError as exc: + return BaseResult(error=str(exc)) + + target_visibility = visibility or source_visibility + move_url = perfecto.get_repository_management_api_url(self.token.cloud_name) + "/artifacts" + body = build_move_test_body(test_id, folder, target_visibility) + result = await api_request(self.token, "PATCH", endpoint=move_url, json=body) + if result.error: + return result + target_item_key = build_item_key( + target_visibility, + folder, + item_key_file_name(test_id).removesuffix(".xml"), + ) + result.result = { + "source_item_key": test_id, + "target_item_key": target_item_key, + "folder": folder, + "visibility": target_visibility, + } + return result + + @token_verify + async def list_snapshots(self, test_id: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required (itemKey from list_tests)") + try: + body = build_snapshot_search_body(test_id) + except ValueError as exc: + return BaseResult(error=str(exc)) + snapshots_url = ( + perfecto.get_repository_management_api_url(self.token.cloud_name) + "/snapshots/search" + ) + return await api_request( + self.token, + "POST", + endpoint=snapshots_url, + json=body, + result_formatter=format_snapshots_list, + result_formatter_params={"test_id": test_id}, + ) + + @token_verify + async def view_snapshot(self, snapshot_id: str) -> BaseResult: + if not snapshot_id: + return BaseResult(error="snapshot_id is required (key from list_snapshots)") + if snapshot_id == "": + return BaseResult( + error="snapshot_id '' is the live script marker, not a historical snapshot. " + "Use view_test_structure with test_id for the current editable script." + ) + return await api_request( + self.token, + "GET", + endpoint=perfecto.get_ai_scriptless_api_url(self.token.cloud_name) + + f"/snapshots?itemKey={quote(snapshot_id, safe='')}", + result_formatter=format_test_structure, + result_formatter_params={"item_key": snapshot_id}, + ) + + @token_verify + async def list_test_variables(self, test_id: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required (itemKey from list_tests)") + payload_result = await fetch_script_payload(self.token, test_id) + if payload_result.error: + return payload_result + script = payload_result.result.get("script", {}) + variables = format_test_variables(script.get("variables", [])) + return BaseResult(result=variables) + + @token_verify + async def add_test_variable( + self, + test_id: str, + name: str, + variable_type: str = "string", + value: Any = "", + set_at_runtime: bool = False, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not name: + return BaseResult(error="name is required") + + def mutator(script: dict[str, Any]) -> None: + add_script_variable(script, name, variable_type, value, set_at_runtime) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["name"] = name + result.result["type"] = variable_type + result.result["set_at_runtime"] = set_at_runtime + return result + + @token_verify + async def modify_test_variable( + self, + test_id: str, + name: str, + value: Optional[Any] = None, + variable_type: Optional[str] = None, + set_at_runtime: Optional[bool] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not name: + return BaseResult(error="name is required") + if value is None and variable_type is None and set_at_runtime is None: + return BaseResult(error="At least one of value, variable_type, or set_at_runtime is required") + + def mutator(script: dict[str, Any]) -> None: + modify_script_variable(script, name, value, variable_type, set_at_runtime) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["name"] = name + return result + + @token_verify + async def delete_test_variable(self, test_id: str, name: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not name: + return BaseResult(error="name is required") + + def mutator(script: dict[str, Any]) -> None: + delete_script_variable(script, name) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["name"] = name + return result + def register(mcp, token: Optional[PerfectoToken]): @mcp.tool( @@ -161,28 +661,153 @@ def register(mcp, token: Optional[PerfectoToken]): filter_names (list[str], values=['test_name', 'owner_list']): The filter name list. - execute_test: Execute a preconfigured AI Scriptless Test. args(dict): Dictionary with the following required parameters: - test_id (str): Test ID from list_tests() + test_id (str): Test ID from list_tests device_type (str, default='real', values=['real', 'virtual', 'desktop']: The device type. device_under_test (dict, required): Device configuration object. - When device_type='real': {device_id: str} (Get from list_real_devices()). - When device_type='virtual': {platform_name: str, manufacturer: str, model: str, platform_version: str} (Get from list_virtual_devices()). + When device_type='real': {device_id: str} (Get from list_real_devices). + When device_type='virtual': {platform_name: str, manufacturer: str, model: str, platform_version: str} (Get from list_virtual_devices). When device_type='desktop': {platform_name: str, platform_version: str, browser_name: str, - browser_version: str, resolution: str, location: str} (Get from list_desktop_devices()). + browser_version: str, resolution: str, location: str} (Get from list_desktop_devices). +- view_test_structure: View the hierarchical structure of an AI Scriptless test. Each step has step_path (dot-separated positional path, e.g. 0, 2.0, 5.b0.1). + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests (e.g. PRIVATE:My Folder/My Test.xml). +- list_commands: List available AI Scriptless commands from the command repository. + Returns the catalog in result and command selection policy in info (read info before add_command when authoring tests). + args(dict): Dictionary with the following optional parameters: + checkpoint (bool, default=false): If true, list checkpoint commands only. +- get_command_definitions: Get parameter definitions for one or more commands. + args(dict): Dictionary with the following required parameters: + command_ids (list[str]): Command IDs from list_commands (typically ai_user-action, ai_validation, ai_visual-comparison). +- add_command: Add a command to a test and persist it. + args(dict): Dictionary with the following parameters: + test_id (str, required): Test itemKey from list_tests. + command_id (str, required): Command ID from list_commands. + arguments (dict, optional): Command argument names to values. + after_path (str, optional): Insert after this step (step_path from view_test_structure). + parent_path (str, optional): Insert inside a container (step_path of LogicalStep, Loop, or Branch). +- modify_command: Update command arguments and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + step_path (str): Step path from view_test_structure (e.g. 0, 2.0, 5.b0.1). + arguments (dict): Argument names to new values. +- delete_command: Remove a command from a test and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + step_path (str): Step path from view_test_structure. +- set_command_enabled: Enable or disable (exclude/include) a command and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + step_path (str): Step path from view_test_structure. + enabled (bool): True to include, false to exclude. +- save_test: Persist the current test script. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + comment (str, optional): Labels the current version on '' in list_snapshots (UI: Save with comment). Every save also adds a UUID history entry. +- create_test: Create a new empty test with a DUT parameter. + args(dict): Dictionary with the following required parameters: + name (str): Test name without .xml extension. + folder (str, default='My Folder'): Target folder. + visibility (str, default='PRIVATE', values=['PUBLIC', 'PRIVATE']): Test visibility. +- save_test_as: Copy a test to a new itemKey and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Source test itemKey from list_tests. + name (str): New test name without .xml extension. + folder (str, default='My Folder'): Target folder. + visibility (str, default='PRIVATE', values=['PUBLIC', 'PRIVATE']): Test visibility. + comment (str, optional): Labels the current version on '' in list_snapshots for the saved copy. +- add_logical_step: Add a LogicalStep group container and persist. + args(dict): Dictionary with the following parameters: + test_id (str, required): Test itemKey from list_tests. + label (str, optional): Group label. + after_path (str, optional): Insert after this step path. + parent_path (str, optional): Insert inside a container step path. +- add_loop: Add a Loop container and persist. + args(dict): Dictionary with the following parameters: + test_id (str, required): Test itemKey from list_tests. + count (int, default=1): RepeatIterator count. + after_path (str, optional): Insert after this step path. + parent_path (str, optional): Insert inside a container step path. +- add_condition: Add an IfStatement condition with Then/Else branches and persist. + args(dict): Dictionary with the following parameters: + test_id (str, required): Test itemKey from list_tests. + expression (str, optional): Condition expression. + label (str, optional): Condition label. + after_path (str, optional): Insert after this step path. + parent_path (str, optional): Insert inside a container step path. +- set_condition_expression: Set the expression on an IfStatement and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + step_path (str): IfStatement path from view_test_structure (e.g. 5). + expression (str): Condition expression. +- move_command: Move a step to a new position and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + step_path (str): Step path to move. + after_path (str, optional): Insert after this sibling step path. + parent_path (str, optional): Move into this container step path (LogicalStep, Loop, or Branch). +- delete_test: Delete an AI Scriptless test from the repository. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. +- move_test: Move a test to another folder (same or different visibility). + args(dict): Dictionary with the following required parameters: + test_id (str): Source test itemKey from list_tests. + folder (str): Target folder path without visibility prefix (e.g. 'My Folder', 'MCP Archive', or 'My Folder/SubFolder'). The test file keeps its name. If the path does not exist, the API creates the nested folder segments automatically (the new folder may not appear as a CONTAINER in list_tests until it contains tests). + visibility (str, optional): Target visibility; defaults to the source test visibility. + Returns source_item_key and target_item_key; use target_item_key for view_test_structure, execute_test, and other actions after the move. +- list_snapshots: List snapshot history for a test (includes '' marker plus UUID historical versions). + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + Returns notes explaining that every save adds history entries and how '' vs UUID keys work. +- view_snapshot: View the hierarchical structure of a historical snapshot (same format as view_test_structure). + args(dict): Dictionary with the following required parameters: + snapshot_id (str): UUID key from list_snapshots (not ''; use view_test_structure for the live script). +- list_test_variables: List script variables configured on a test (distinct from DUT parameters). + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. +- add_test_variable: Add a script variable and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + name (str): Variable name (letters, numbers, underscore; cannot start with a number). + variable_type (str, default='string', values=['string', 'secured_string', 'number', 'boolean']): Variable type. + value (any, default=''): Variable value. + set_at_runtime (bool, default=false): When true, value is supplied at execution time. +- modify_test_variable: Update a script variable and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + name (str): Existing variable name. + value (any, optional): New value. + variable_type (str, optional): New type (string, secured_string, number, boolean). + set_at_runtime (bool, optional): Toggle runtime parameter behavior. +- delete_test_variable: Remove a script variable and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + name (str): Variable name to delete. Hints: +- LICENSE: AI Scriptless actions require a Perfecto AI license on your cloud (administrator opt-in via feature toggle). Without it, AI commands and related MCP operations will not work. Desktop web test authoring additionally requires the Desktop Web license. +- COVERAGE: DataTables, Scheduler (scheduled jobs), Embedded tests, and other advanced UI capabilities (folder management, rename test, restore snapshot, download as Appium, AI Assistant, Object Spy, per-step error policy, etc.) are not yet supported by this MCP tool. +- HELP: For product behavior and workarounds, use the perfecto_help tool: Filter by category_id='perfecto', subcategory_id_list=['ide']. +- UI_ACCESS: No per-test URL exists. Only UI entry: cloud_url/lab/scriptless-mobile/ (cloud_url from perfecto_user read_user). For debugging or unsupported MCP tasks, link the lab URL and tell the user to open the test via Tests → Open or Manage tests using the folder tree and test name from list_tests (itemKey is MCP-only; the UI shows folders and names, not itemKey). Never invent other scriptless URLs. +- When authoring or editing test steps, call list_commands first and follow the command selection policy in the info field. +- step_path is a dot-separated positional path without spaces (0-based indices; b0=Then branch, b1=Else). Example: root step 3 is "3"; first step inside Then of condition at 5 is "5.b0.0". Perfecto does not persist paths; they change when steps are inserted, moved, or deleted. Always call view_test_structure before the next structure edit; do not reuse step_path from a previous mutation response. +- Use parent_path on add_command with the step_path of a LogicalStep, Loop, or Branch from view_test_structure. +- Use add_logical_step, add_loop, and add_condition to build control-flow structures matching the UI toolbar Group, Loop, and Condition actions. +- Script variables (list_test_variables, add/modify/delete_test_variable) are stored in script.variables[] and are distinct from the DUT parameter in script.parameters[]. +- Snapshot behavior: every save creates a UUID history entry; comment on save_test labels ''. See list_snapshots notes for details. +- Edits persist immediately via the internal draft→script pipeline (each persist also adds snapshot history; save_test is available to re-persist unchanged content). - IMPORTANT: Always call list_filter_values first to get valid filter values before using any filters in list_tests. This ensures you're using the correct test name, list of owners users or other filter values that actually exist in the system. - If in any result has_next_page is true, ask the user if they want to see the next page or access all pages before making a subsequent call. - Before executing a test, follow this validation workflow: - 1. list_tests() (get and validate test_id). + 1. list_tests (get and validate test_id). 2. Get device configuration based on device_type: - - 'real': list_real_devices() (get device_id). - - 'virtual': list_virtual_devices() (get platform_name, manufacturer, model, platform_version). - - 'desktop': list_desktop_devices() (get platform_name, platform_version, browser_name, browser_version, resolution, location). - 3. On real device use read_real_device_info() (verify device is available and not in use). - 4. execute_test() (execute the test). - 5. list_report_executions() with report name equal to test name and list_live_executions() when the device it's in use (monitor execution progress). + - 'real': list_real_devices (get device_id). + - 'virtual': list_virtual_devices (get platform_name, manufacturer, model, platform_version). + - 'desktop': list_desktop_devices (get platform_name, platform_version, browser_name, browser_version, resolution, location). + 3. On real device use read_real_device_info (verify device is available and not in use). + 4. execute_test (execute the test). + 5. list_report_executions with report name equal to test name and list_live_executions when the device it's in use (monitor execution progress). - Always check before running a test_id if the device_type and device_under_test exist and is available (when it's a real device), not use device in use or malfunctioning. -- Always monitor a real device's operation while it's in use by checking the information with read_real_device_info(). +- Always monitor a real device's operation while it's in use by checking the information with read_real_device_info. - Always stop the execution by stopping the live execution (make sure it's the correct execution, such as the execution name or user ID). """ ) @@ -194,7 +819,8 @@ async def ai_scriptless( if args is None: args = {} ai_scriptless_manager = AiScriptlessManager(token, ctx) - try: + + async def _dispatch(): match action: case "list_tests": return await ai_scriptless_manager.list_tests(args) @@ -204,10 +830,133 @@ async def ai_scriptless( return await ai_scriptless_manager.execute_test(args.get("test_id", ""), args.get("device_type", ""), args.get("device_under_test", {})) + case "view_test_structure": + return await ai_scriptless_manager.view_test_structure(args.get("test_id", "")) + case "list_commands": + return await ai_scriptless_manager.list_commands(args.get("checkpoint", False)) + case "get_command_definitions": + return await ai_scriptless_manager.get_command_definitions(args.get("command_ids", [])) + case "add_command": + return await ai_scriptless_manager.add_command( + args.get("test_id", ""), + args.get("command_id", ""), + args.get("arguments"), + args.get("after_path"), + args.get("parent_path"), + ) + case "modify_command": + return await ai_scriptless_manager.modify_command( + args.get("test_id", ""), + args.get("step_path", ""), + args.get("arguments", {}), + ) + case "delete_command": + return await ai_scriptless_manager.delete_command( + args.get("test_id", ""), + args.get("step_path", ""), + ) + case "set_command_enabled": + return await ai_scriptless_manager.set_command_enabled( + args.get("test_id", ""), + args.get("step_path", ""), + args.get("enabled", True), + ) + case "save_test": + return await ai_scriptless_manager.save_test( + args.get("test_id", ""), + args.get("comment"), + ) + case "create_test": + return await ai_scriptless_manager.create_test( + args.get("name", ""), + args.get("folder", "My Folder"), + args.get("visibility", "PRIVATE"), + ) + case "save_test_as": + return await ai_scriptless_manager.save_test_as( + args.get("test_id", ""), + args.get("name", ""), + args.get("folder", "My Folder"), + args.get("visibility", "PRIVATE"), + args.get("comment"), + ) + case "add_logical_step": + return await ai_scriptless_manager.add_logical_step( + args.get("test_id", ""), + args.get("label"), + args.get("after_path"), + args.get("parent_path"), + ) + case "add_loop": + return await ai_scriptless_manager.add_loop( + args.get("test_id", ""), + args.get("count", 1), + args.get("after_path"), + args.get("parent_path"), + ) + case "add_condition": + return await ai_scriptless_manager.add_condition( + args.get("test_id", ""), + args.get("expression"), + args.get("label"), + args.get("after_path"), + args.get("parent_path"), + ) + case "set_condition_expression": + return await ai_scriptless_manager.set_condition_expression( + args.get("test_id", ""), + args.get("step_path", ""), + args.get("expression", ""), + ) + case "move_command": + return await ai_scriptless_manager.move_command( + args.get("test_id", ""), + args.get("step_path", ""), + args.get("after_path"), + args.get("parent_path"), + ) + case "delete_test": + return await ai_scriptless_manager.delete_test(args.get("test_id", "")) + case "move_test": + return await ai_scriptless_manager.move_test( + args.get("test_id", ""), + args.get("folder", ""), + args.get("visibility"), + ) + case "list_snapshots": + return await ai_scriptless_manager.list_snapshots(args.get("test_id", "")) + case "view_snapshot": + return await ai_scriptless_manager.view_snapshot(args.get("snapshot_id", "")) + case "list_test_variables": + return await ai_scriptless_manager.list_test_variables(args.get("test_id", "")) + case "add_test_variable": + return await ai_scriptless_manager.add_test_variable( + args.get("test_id", ""), + args.get("name", ""), + args.get("variable_type", "string"), + args.get("value", ""), + args.get("set_at_runtime", False), + ) + case "modify_test_variable": + return await ai_scriptless_manager.modify_test_variable( + args.get("test_id", ""), + args.get("name", ""), + args.get("value"), + args.get("variable_type"), + args.get("set_at_runtime"), + ) + case "delete_test_variable": + return await ai_scriptless_manager.delete_test_variable( + args.get("test_id", ""), + args.get("name", ""), + ) case _: return BaseResult( error=f"Action {action} not found in AI Scriptless manager tool" ) + + try: + return await run_tool(f"{TOOLS_PREFIX}_ai_scriptless", action, ctx, _dispatch) except httpx.HTTPStatusError: return BaseResult( error=f"Error: {traceback.format_exc()}" diff --git a/tools/ai_scriptless_script.py b/tools/ai_scriptless_script.py new file mode 100644 index 0000000..6c9f635 --- /dev/null +++ b/tools/ai_scriptless_script.py @@ -0,0 +1,4 @@ +"""Backward-compatible facade. Implementation lives in tools.ai_scriptless.""" + +from tools.ai_scriptless import * # noqa: F403 +from tools.ai_scriptless import __all__ as __all__ diff --git a/tools/device_manager.py b/tools/device_manager.py index d037de0..3ffa64c 100644 --- a/tools/device_manager.py +++ b/tools/device_manager.py @@ -12,6 +12,7 @@ from formatters.grid import format_grid_info from models.manager import Manager from models.result import BaseResult +from telemetry import run_tool from tools.utils import api_request @@ -83,7 +84,8 @@ async def devices( if args is None: args = {} device_manager = DeviceManager(token, ctx) - try: + + async def _dispatch(): match action: case "read_selenium_grid_info": return await device_manager.read_selenium_grid_info() @@ -99,6 +101,9 @@ async def devices( return BaseResult( error=f"Action {action} not found in device manager tool" ) + + try: + return await run_tool(f"{TOOLS_PREFIX}_devices", action, ctx, _dispatch) except httpx.HTTPStatusError: return BaseResult( error=f"Error: {traceback.format_exc()}" diff --git a/tools/execution_manager.py b/tools/execution_manager.py index bcaed03..b534787 100644 --- a/tools/execution_manager.py +++ b/tools/execution_manager.py @@ -12,6 +12,7 @@ from formatters.execution import format_executions from models.manager import Manager from models.result import BaseResult, PaginationResult +from telemetry import run_tool from tools.utils import api_request @@ -262,7 +263,8 @@ async def execution( if args is None: args = {} execution_manager = ExecutionManager(token, ctx) - try: + + async def _dispatch(): match action: case "list_live_executions": return await execution_manager.list_live_executions() @@ -280,6 +282,9 @@ async def execution( return BaseResult( error=f"Action {action} not found in execution manager tool" ) + + try: + return await run_tool(f"{TOOLS_PREFIX}_execution", action, ctx, _dispatch) except httpx.HTTPStatusError: return BaseResult( error=f"Error: {traceback.format_exc()}" diff --git a/tools/help_manager.py b/tools/help_manager.py index d2794f0..db345e9 100644 --- a/tools/help_manager.py +++ b/tools/help_manager.py @@ -15,6 +15,7 @@ format_read_real_devices_extended_command_info, format_help_info from models.manager import Manager from models.result import BaseResult +from telemetry import run_tool from tools.help_utils import convert_js_to_py_dict from tools.utils import http_request @@ -253,7 +254,8 @@ async def help_main( if args is None: args = {} help_manager = HelpManager(token, ctx) - try: + + async def _dispatch(): match action: case "list_help_categories": return await help_manager.list_help_categories() @@ -272,6 +274,9 @@ async def help_main( return BaseResult( error=f"Action {action} not found in help manager tool" ) + + try: + return await run_tool(f"{TOOLS_PREFIX}_help", action, ctx, _dispatch) except httpx.HTTPStatusError: return BaseResult( error=f"Error: {traceback.format_exc()}" diff --git a/tools/user_manager.py b/tools/user_manager.py index f6f816d..d8adb38 100644 --- a/tools/user_manager.py +++ b/tools/user_manager.py @@ -6,11 +6,12 @@ from pydantic import Field from config import perfecto -from config.perfecto import TOOLS_PREFIX, SUPPORT_MESSAGE +from config.perfecto import TOOLS_PREFIX, SUPPORT_MESSAGE, get_cloud_app_url from config.token import PerfectoToken, token_verify from formatters.user import format_users from models.manager import Manager from models.result import BaseResult +from telemetry import run_tool from tools.utils import api_request @@ -22,7 +23,17 @@ def __init__(self, token: Optional[PerfectoToken], ctx: Context): async def read_user(self) -> BaseResult: user_url = perfecto.get_user_management_api_url(self.token.cloud_name) user_url = user_url + "/current" - return await api_request(self.token, "GET", endpoint=user_url, result_formatter=format_users) + cloud_url = get_cloud_app_url(self.token.cloud_name) + result = await api_request( + self.token, + "GET", + endpoint=user_url, + result_formatter=format_users, + result_formatter_params={"cloud_name": self.token.cloud_name}, + ) + if not result.error: + result.append_info([f"Connected Perfecto cloud: [{cloud_url}]({cloud_url})"]) + return result def register(mcp, token: Optional[PerfectoToken]): @@ -31,7 +42,9 @@ def register(mcp, token: Optional[PerfectoToken]): description=""" Operations on user information. Actions: -- read_user: Read a current user information from Perfecto. +- read_user: Read the current user and connected Perfecto cloud environment (cloud_name, cloud_url from PERFECTO_CLOUD_NAME). +Hints: +- Always render cloud_url as a markdown link when presenting the environment to the user. """ ) async def user( @@ -42,7 +55,8 @@ async def user( if args is None: args = {} user_manager = UserManager(token, ctx) - try: + + async def _dispatch(): match action: case "read_user": return await user_manager.read_user() @@ -50,6 +64,9 @@ async def user( return BaseResult( error=f"Action {action} not found in user manager tool" ) + + try: + return await run_tool(f"{TOOLS_PREFIX}_user", action, ctx, _dispatch) except httpx.HTTPStatusError: return BaseResult( error=f"Error: {traceback.format_exc()}" diff --git a/tools/utils.py b/tools/utils.py index 49402d1..dc9d79a 100644 --- a/tools/utils.py +++ b/tools/utils.py @@ -2,6 +2,7 @@ Simple utilities for Perfecto MCP tools. """ import base64 +import json import os import platform import sys @@ -52,7 +53,13 @@ async def api_request(token: Optional[PerfectoToken], method: str, endpoint: str try: resp = await client.request(method, endpoint, headers=headers, **kwargs) resp.raise_for_status() - result = resp.json() + if not resp.content or not resp.content.strip(): + result = None + else: + try: + result = resp.json() + except json.JSONDecodeError: + result = resp.text error = None if isinstance(result, list) and len(result) > 0 and "userMessage" in result[0]: # It's an error final_result = None diff --git a/uv.lock b/uv.lock index ad5591c..52b12cd 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] [[package]] name = "altgraph" @@ -52,6 +57,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.3.0" @@ -73,6 +167,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "grpcio" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/ea/1c2fa386b718ff493225e61cfc052ef400b4d6ffc54cbe261026432624b5/grpcio-1.81.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:d71d30f2d92f67d944631c523713934fee37292469e182ebcd2c1dd8a64ce53f", size = 6093112, upload-time = "2026-06-11T12:44:52.131Z" }, + { url = "https://files.pythonhosted.org/packages/2b/18/acf45fa8bd1bc5d7b0c2fd3dc4c209379fbd5bb396b440b68a83342226b7/grpcio-1.81.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b137f4bf3ada9dc44d411478decc6ff09a79ed30b306cd2abaa98408c3588137", size = 12074277, upload-time = "2026-06-11T12:44:55.354Z" }, + { url = "https://files.pythonhosted.org/packages/48/d7/ee86a60699b7db039f772a2c4a7e4facc7138984ff42c0130933a0063884/grpcio-1.81.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3acb384427816dd5d470f47e62137b87f74da694faa8a50147012cf40df276a", size = 6640348, upload-time = "2026-06-11T12:44:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/26/ee/d2de5e47378ffc207d476c230fea3be4d2601edbce9995f4fe45535d4896/grpcio-1.81.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f9a0ebbe45c29b5e5866593c12b78bd9035f0f0f0d4bc8361680cd580d99db49", size = 7331842, upload-time = "2026-06-11T12:45:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/23/d6/abeda5c2b896a0b341584fe5ac411bbf72e197a9a374c355fb90965e08d2/grpcio-1.81.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a37165cc80b1a368384b383e63a4c38116a10467ae44c904d2d7468c4470ec2", size = 6842229, upload-time = "2026-06-11T12:45:04.76Z" }, + { url = "https://files.pythonhosted.org/packages/10/1c/1f0da7d590b4aeee006826ba568d0e419ca14b23e18f901a3da3e9fba613/grpcio-1.81.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6282caffb41ec326d4cb67ca9cf53b739d1b2f975a2acb498c7418e9f7d9a416", size = 7446096, upload-time = "2026-06-11T12:45:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/5c505d508f7c887aa7982d21443a4126597c80d34b0bcf40f9cec576d7f3/grpcio-1.81.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a35009284d0d3d5c2c9601c164a911b8b4331608d98a9a66d47d97bb2f522b70", size = 8445238, upload-time = "2026-06-11T12:45:10.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b2/524847365122ee509ca17bcc4e092198b700e94af7bfd5bb5e6dd9f3ee66/grpcio-1.81.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1b22c80559854b789a01fd89e8929b3798a156c0829b5282a8939f33ad4115ad", size = 7873989, upload-time = "2026-06-11T12:45:13.102Z" }, + { url = "https://files.pythonhosted.org/packages/18/fa/07c037c50b006909d1d13a5848774f8aa7b242f70dc03a035c64eea0e6db/grpcio-1.81.1-cp311-cp311-win32.whl", hash = "sha256:428bec0161b48d8cf583c068591bc0016d0d9cfff52462b72b3884861ea768c5", size = 4202223, upload-time = "2026-06-11T12:45:16.166Z" }, + { url = "https://files.pythonhosted.org/packages/41/ed/6bff15376920942fac6b95b9802752b837437172c9e8fc2d3170546b89cc/grpcio-1.81.1-cp311-cp311-win_amd64.whl", hash = "sha256:30e825f6848d9f18bba350ed6c75c1b02a0b5184474a31db9a32b1fa66fd8c79", size = 4941303, upload-time = "2026-06-11T12:45:18.724Z" }, + { url = "https://files.pythonhosted.org/packages/85/07/9a979c81738863a738dc23d65177056e71fbb2db817740ed870b33434e7a/grpcio-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115", size = 6053264, upload-time = "2026-06-11T12:45:21.017Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/539706ca0d3bd40dbad583dc56fd883da941f37556b629132da5762781b9/grpcio-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3", size = 12052560, upload-time = "2026-06-11T12:45:23.652Z" }, + { url = "https://files.pythonhosted.org/packages/e0/44/f257b7e0bd69c93b06c6cb8ac8d1b901ccb42bedabd83c1a4c77a71f8810/grpcio-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2", size = 6595983, upload-time = "2026-06-11T12:45:26.963Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f3/19782aa04c960968bef8c5539329d8e3bbc3364e2e46d19eb5e5cc5e43b7/grpcio-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7", size = 7303455, upload-time = "2026-06-11T12:45:29.707Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8c/dea020b6d91508cd84463917a63149ec196ee7db505d032ae43fcb3303b9/grpcio-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0", size = 6809167, upload-time = "2026-06-11T12:45:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/3030dd940408083bd32cd95d634777a71605ade4887154d93e8a89244946/grpcio-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3", size = 7412536, upload-time = "2026-06-11T12:45:35.403Z" }, + { url = "https://files.pythonhosted.org/packages/e0/dd/1172a9e42b168edcafefad6115346ef619a3fc02158bb170e66ced24bcdd/grpcio-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf", size = 8408276, upload-time = "2026-06-11T12:45:37.78Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/71437c7f3596e5246155c515852795a85a1a8d228190212432b13b97a95d/grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c", size = 7849660, upload-time = "2026-06-11T12:45:40.627Z" }, + { url = "https://files.pythonhosted.org/packages/65/40/7debc0da45d2efebafb82da75644be347497fe4ee250514b8cd3b86ae8bf/grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6", size = 4185819, upload-time = "2026-06-11T12:45:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" }, + { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/b6/58/19414622b1bf6981bc9c05a365bd548e71876c89000083b3af489251e9c0/grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b", size = 6055336, upload-time = "2026-06-11T12:46:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/2ec88adb92b0eba970dd0e0e7dd086341daa3c75eba4f735f9e44bf684b0/grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e", size = 12056279, upload-time = "2026-06-11T12:46:24.255Z" }, + { url = "https://files.pythonhosted.org/packages/41/36/e8c5f8c6ec71de73733695ebc809e98b178b534ec6d8eaa31a7ebab4ad4c/grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27", size = 6608225, upload-time = "2026-06-11T12:46:27.601Z" }, + { url = "https://files.pythonhosted.org/packages/30/22/96fc577a845ab093326d9ab1adb874bd4936c8cf98ac8ed2f3db13a0a2fb/grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854", size = 7306576, upload-time = "2026-06-11T12:46:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/61dab5d5969f28d97fb1009cead1df0a5cd987d3315e1b37f18a4449f8bc/grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6", size = 6812165, upload-time = "2026-06-11T12:46:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/6e501929d4f5f96462fd82fd9f0f06e5f9612207582b862868d68757b27d/grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5", size = 7422962, upload-time = "2026-06-11T12:46:36.511Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7e/f2157589e66daa78ebb3165942d05a08bdea93b9d11c2bc1e172aef89685/grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0", size = 8408176, upload-time = "2026-06-11T12:46:39.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/c6717fef716e00d235ffb96123baf6dce76d6004f6233fa767c502861460/grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190", size = 7846681, upload-time = "2026-06-11T12:46:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/84/3502e9f210a6a5c4438c8aca3f88edd2e04f6a27f3d41b26cf0a0024b096/grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f", size = 4264615, upload-time = "2026-06-11T12:46:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -164,6 +321,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -354,6 +520,118 @@ 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 = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/94/8637919a5d01f81dacf510234bc0110b944f4687a6e96b0a02adf2f6bdce/opentelemetry_exporter_otlp-1.42.1.tar.gz", hash = "sha256:2d9ebaed714377a67d224d46795ddcc11d2c877fa5de35fda70b6f3b010729a9", size = 6086, upload-time = "2026-05-21T16:32:51.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/4d/c26080295a36fd22e201fefd7cb9c22cd203189b1af8cd73b158382b7ad8/opentelemetry_exporter_otlp-1.42.1-py3-none-any.whl", hash = "sha256:aedd54545bb0587cd45210abdc8be545af9c01413f3307786e276df1e3c83bee", size = 6733, upload-time = "2026-05-21T16:32:31.261Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/87/ca7fc790dfdbcf4f9e9aab14a39ef1b7508ead13707e283de0b3131478d2/opentelemetry_exporter_otlp_proto_grpc-1.42.1.tar.gz", hash = "sha256:975c4461f167dd8ed8857d68d3b6b25f3d272eab896f6a9470d0f5b90e2faf15", size = 27140, upload-time = "2026-05-21T16:32:56.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2b/28ba5b128f47fe8c3bab541000d6feb4b5a9bd26623ca013406f01c0fb60/opentelemetry_exporter_otlp_proto_grpc-1.42.1-py3-none-any.whl", hash = "sha256:0ae1177e2038b18a929b3098215243631ef91136cba26b7e2b12790ceb7e87cc", size = 19617, upload-time = "2026-05-21T16:32:34.278Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -374,29 +652,67 @@ wheels = [ [[package]] name = "perfecto-mcp" -version = "1.0.1" +version = "1.1.0" source = { virtual = "." } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "lxml" }, { name = "mcp", extra = ["cli"] }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, { name = "pydantic" }, { name = "pydantic-core" }, { name = "pydantic-settings" }, { name = "pyinstaller" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" }, { name = "lxml", specifier = ">=5.3.0" }, { name = "mcp", extras = ["cli"], specifier = ">=1.17.0" }, + { name = "opentelemetry-api", specifier = ">=1.20.0" }, + { name = "opentelemetry-exporter-otlp", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.20.0" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pydantic-core", specifier = ">=2.33.2" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "pyinstaller", specifier = ">=6.0.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pydantic" version = "2.11.9" @@ -541,6 +857,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/26/23b4cfc77d7f808c69f59070e1e8293a579ec281a547c61562357160b346/pyinstaller_hooks_contrib-2025.9-py3-none-any.whl", hash = "sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038", size = 444283, upload-time = "2025-09-24T11:21:33.67Z" }, ] +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -601,6 +933,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rich" version = "14.1.0" @@ -810,6 +1157,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "uvicorn" version = "0.37.0"