Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions openapi.json
Comment thread
antonio-amjr marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -1598,6 +1598,74 @@
}
}
},
"/api/v1/test_run_executions/{id}/pics_export": {
"get": {
"tags": [
"test_run_executions"
],
"summary": "Pics Export",
"description": "Export the PICS actually used by a test run execution.\n\nReturns the PICS that were in effect when the execution ran (the\nexecution's own execution_pics override when set, otherwise the\nproject's PICS at the time of the request), as a zip archive containing\none PICS XML file per cluster. The XML format matches what is already\naccepted by the PUT /projects/{id}/upload_pics endpoint, so the exported\nfiles can be re-imported as-is.\n\nArgs:\n id (int): ID of the TestRunExecution the PICS export is requested for\n\nRaises:\n HTTPException: If there's no TestRunExecution with the given ID, or\n if no PICS were used by the execution\n\nReturns:\n StreamingResponse: .zip file containing one PICS XML file per cluster",
"operationId": "pics_export_api_v1_test_run_executions__id__pics_export_get",
"parameters": [
{
"required": true,
"schema": {
"title": "Id",
"type": "integer"
},
"name": "id",
"in": "path"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
}
},
"headers": {
"Content-Disposition": {
"description": "Suggests a filename for the downloaded ZIP file",
"schema": {
"type": "string"
},
"example": "attachment; filename=\"archive.zip\""
}
}
},
"404": {
"description": "Test Run Execution not found, or no PICS were used by it",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"detail": {
"type": "string"
}
}
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
},
"/api/v1/test_run_executions/file_upload/": {
"post": {
"tags": [
Expand Down
60 changes: 52 additions & 8 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,8 @@
import click
import pytest

from th_cli.exceptions import (
APIError,
CLIError,
ConfigurationError,
handle_api_error,
handle_file_error,
)
from th_cli.api_lib_autogen.exceptions import UnexpectedResponse

from th_cli.exceptions import APIError, CLIError, ConfigurationError, handle_api_error, handle_file_error

# ---------------------------------------------------------------------------
# CLIError
Expand Down Expand Up @@ -182,6 +175,57 @@ def test_status_code_preserved(self):
handle_api_error(e, "op")
assert exc_info.value.status_code == 422

def test_dict_content_with_detail_string_is_unwrapped(self):
"""FastAPI's {"detail": "..."} bodies should surface just the message,
not the raw dict repr."""
e = self._make_unexpected_response(404, {"detail": "No PICS were used by this test run execution"})
with pytest.raises(APIError) as exc_info:
handle_api_error(e, "op")
assert exc_info.value.content == "No PICS were used by this test run execution"
assert "{" not in exc_info.value.format_message()

def test_dict_content_with_validation_error_list_is_joined(self):
"""FastAPI 422 validation errors have detail as a list of error objects,
rendered as "<field path>: <message>" per entry."""
e = self._make_unexpected_response(
422,
{
"detail": [
{"loc": ["query", "id"], "msg": "field required", "type": "value_error.missing"},
{"loc": ["query", "limit"], "msg": "value is not a valid integer", "type": "type_error.integer"},
]
},
)
with pytest.raises(APIError) as exc_info:
handle_api_error(e, "op")
assert exc_info.value.content == "query.id: field required; query.limit: value is not a valid integer"

def test_dict_content_with_validation_error_list_distinguishes_same_message(self):
"""Two different fields failing with the SAME message must still be
distinguishable by field path - joining bare msgs alone would collapse
them into an ambiguous, indistinguishable string."""
e = self._make_unexpected_response(
422,
{
"detail": [
{"loc": ["body", "config", "th_config", "timeout"], "msg": "field required"},
{"loc": ["body", "config", "network", "wifi", "ssid"], "msg": "field required"},
]
},
)
with pytest.raises(APIError) as exc_info:
handle_api_error(e, "op")
assert (
exc_info.value.content
== "config.th_config.timeout: field required; config.network.wifi.ssid: field required"
)

def test_dict_content_without_detail_key_falls_back_to_dict(self):
e = self._make_unexpected_response(500, {"error": "something else"})
with pytest.raises(APIError) as exc_info:
handle_api_error(e, "op")
assert exc_info.value.content == {"error": "something else"}


# ---------------------------------------------------------------------------
# handle_file_error
Expand Down
152 changes: 152 additions & 0 deletions tests/test_test_run_execution_pics_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#
# Copyright (c) 2026 Project CHIP Authors
#
# 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.
#
"""Tests for the `test-run-execution pics-export` command."""

import os
from unittest.mock import Mock, patch

import pytest
from click.testing import CliRunner

from th_cli.api_lib_autogen import models as api_models
from th_cli.api_lib_autogen.exceptions import UnexpectedResponse
from th_cli.commands.test_run_execution import test_run_execution
from th_cli.exceptions import ConfigurationError


@pytest.mark.unit
@pytest.mark.cli
class TestPicsExportCommand:
"""Test cases for the `test-run-execution pics-export` command."""

def test_pics_export_writes_default_filename(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None:
"""When no --output-file is given, the file is named after the execution title."""
api = mock_sync_apis.test_run_executions_api
api.pics_export_api_v1_test_run_executions__id__pics_export_get.return_value = b"zip-bytes"
api.read_test_run_execution_api_v1_test_run_executions__id__get.return_value = api_models.TestRunExecution(
id=1, title="My Execution!", state=api_models.TestStateEnum.passed, project_id=1
)

with cli_runner.isolated_filesystem():
with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis):
result = cli_runner.invoke(test_run_execution, ["pics-export", "--id", "1"])

assert result.exit_code == 0
assert os.path.exists("MyExecution-pics.zip")
with open("MyExecution-pics.zip", "rb") as f:
assert f.read() == b"zip-bytes"

api.pics_export_api_v1_test_run_executions__id__pics_export_get.assert_called_once_with(id=1)

def test_pics_export_writes_custom_output_file(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None:
"""When --output-file is given, the export is written there and read_test_run_execution
is not called to derive a filename."""
api = mock_sync_apis.test_run_executions_api
api.pics_export_api_v1_test_run_executions__id__pics_export_get.return_value = b"zip-bytes"

with cli_runner.isolated_filesystem():
with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis):
result = cli_runner.invoke(test_run_execution, ["pics-export", "--id", "1", "--output-file", "out.zip"])

assert result.exit_code == 0
assert os.path.exists("out.zip")

api.read_test_run_execution_api_v1_test_run_executions__id__get.assert_not_called()

def test_pics_export_no_content(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None:
"""If the API ever returns an empty-but-successful response, a message is
printed and no file is written (the backend normally 404s instead, see
test_pics_export_no_pics_used_api_error)."""
api = mock_sync_apis.test_run_executions_api
api.pics_export_api_v1_test_run_executions__id__pics_export_get.return_value = None

with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis):
result = cli_runner.invoke(test_run_execution, ["pics-export", "--id", "1"])

assert result.exit_code == 0
assert "No PICS content was returned for this test run execution." in result.output

def test_pics_export_no_pics_used_api_error(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None:
"""When the execution used no PICS, the backend 404s with a JSON body
(as FastAPI does) and the CLI surfaces the plain detail message,
not the raw dict repr."""
api = mock_sync_apis.test_run_executions_api
api.pics_export_api_v1_test_run_executions__id__pics_export_get.side_effect = UnexpectedResponse(
status_code=404,
content={"detail": "No PICS were used by this test run execution"},
)

with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis):
result = cli_runner.invoke(test_run_execution, ["pics-export", "--id", "1"])

error_text = (
"Error: Failed to fetch test run execution PICS export (Status: 404)"
" - No PICS were used by this test run execution"
)
assert result.exit_code == 1
assert error_text in result.output
assert "{" not in result.output

def test_pics_export_configuration_error(self, cli_runner: CliRunner) -> None:
"""A ConfigurationError from get_client is surfaced to the user."""
with patch(
"th_cli.commands.test_run_execution.get_client",
side_effect=ConfigurationError("Could not connect to server"),
):
result = cli_runner.invoke(test_run_execution, ["pics-export", "--id", "1"])

assert result.exit_code == 1
assert "Error: Could not connect to server" in result.output

def test_pics_export_api_error(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None:
"""An UnexpectedResponse from the API is surfaced to the user."""
api = mock_sync_apis.test_run_executions_api
api.pics_export_api_v1_test_run_executions__id__pics_export_get.side_effect = UnexpectedResponse(
status_code=404,
content=b"Test run execution not found",
)

with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis):
result = cli_runner.invoke(test_run_execution, ["pics-export", "--id", "999"])

error_text = (
"Error: Failed to fetch test run execution PICS export (Status: 404) - Test run execution not found"
)
assert result.exit_code == 1
assert error_text in result.output

def test_pics_export_requires_id(self, cli_runner: CliRunner) -> None:
"""The --id parameter is required."""
result = cli_runner.invoke(test_run_execution, ["pics-export"])

assert result.exit_code != 0
assert "Missing option" in result.output or "--id" in result.output

def test_pics_export_write_failure_raises_cli_error(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None:
"""A file-write failure (e.g. unwritable directory) after a successful export
request is surfaced as a clean CLIError, not a raw traceback."""
api = mock_sync_apis.test_run_executions_api
api.pics_export_api_v1_test_run_executions__id__pics_export_get.return_value = b"zip-bytes"

with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis):
with patch("builtins.open", side_effect=OSError("Permission denied")):
result = cli_runner.invoke(
test_run_execution, ["pics-export", "--id", "1", "--output-file", "/no/such/dir/out.zip"]
)

assert result.exit_code == 1
assert "Failed to write PICS export file '/no/such/dir/out.zip'" in result.output
assert "Permission denied" in result.output
25 changes: 25 additions & 0 deletions th_cli/api_lib_autogen/api/test_run_executions_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,18 @@ def _build_for_download_grouped_log_api_v1_test_run_executions__id__grouped_log_
type_=bytes, method="GET", url="/api/v1/test_run_executions/{id}/grouped-log", path_params=path_params
)

def _build_for_pics_export_api_v1_test_run_executions__id__pics_export_get(
self, id: int
) -> Coroutine[Any, Any, bytes]:
"""
Pics Export
"""
path_params = {"id": str(id)}

return self.api_client.request(
type_=bytes, method="GET", url="/api/v1/test_run_executions/{id}/pics_export", path_params=path_params
)

def _build_for_upload_file_api_v1_test_run_executions_file_upload__post(
self, body: m.BodyUploadFileApiV1TestRunExecutionsFileUploadPost
) -> Coroutine[Any, Any, dict[str, Any]]:
Expand Down Expand Up @@ -502,6 +514,12 @@ async def download_grouped_log_api_v1_test_run_executions__id__grouped_log_get(s
"""
return await self._build_for_download_grouped_log_api_v1_test_run_executions__id__grouped_log_get(id=id)

async def pics_export_api_v1_test_run_executions__id__pics_export_get(self, id: int) -> bytes:
"""
Pics Export
"""
return await self._build_for_pics_export_api_v1_test_run_executions__id__pics_export_get(id=id)

async def upload_file_api_v1_test_run_executions_file_upload__post(
self, body: m.BodyUploadFileApiV1TestRunExecutionsFileUploadPost
) -> dict[str, Any]:
Expand Down Expand Up @@ -685,6 +703,13 @@ def download_grouped_log_api_v1_test_run_executions__id__grouped_log_get(self, i
coroutine = self._build_for_download_grouped_log_api_v1_test_run_executions__id__grouped_log_get(id=id)
return get_event_loop().run_until_complete(coroutine)

def pics_export_api_v1_test_run_executions__id__pics_export_get(self, id: int) -> bytes:
"""
Pics Export
"""
coroutine = self._build_for_pics_export_api_v1_test_run_executions__id__pics_export_get(id=id)
return get_event_loop().run_until_complete(coroutine)

def upload_file_api_v1_test_run_executions_file_upload__post(
self, body: m.BodyUploadFileApiV1TestRunExecutionsFileUploadPost
) -> dict[str, Any]:
Expand Down
12 changes: 8 additions & 4 deletions th_cli/api_lib_autogen/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,14 @@ def close(self) -> None:
@overload
async def request(
self, *, type_: Type[T], method: str, url: str, path_params: dict[str, Any] | None = None, **kwargs: Any
) -> T: ...
) -> T:
...

@overload
async def request(
self, *, type_: None, method: str, url: str, path_params: dict[str, Any] | None = None, **kwargs: Any
) -> None: ...
) -> None:
...

async def request(
self, *, type_: Any, method: str, url: str, path_params: dict[str, Any] | None = None, **kwargs: Any
Expand All @@ -97,10 +99,12 @@ async def request(
return await self.send(request, type_)

@overload
def request_sync(self, *, type_: Type[T], **kwargs: Any) -> T: ...
def request_sync(self, *, type_: Type[T], **kwargs: Any) -> T:
...

@overload
def request_sync(self, *, type_: None, **kwargs: Any) -> None: ...
def request_sync(self, *, type_: None, **kwargs: Any) -> None:
...

def request_sync(self, *, type_: Any, **kwargs: Any) -> Any:
"""
Expand Down
Loading
Loading