diff --git a/tests/test_project_commands.py b/tests/test_project_commands.py index 662589e..15ba9de 100644 --- a/tests/test_project_commands.py +++ b/tests/test_project_commands.py @@ -728,3 +728,215 @@ def test_import_project_help_message(self, cli_runner: CliRunner) -> None: assert result.exit_code == 0 assert "--file" in result.output + + +@pytest.mark.unit +@pytest.mark.cli +class TestProjectLogsCommand: + """Test cases for the project logs command.""" + + def test_logs_flat_success_default_filename( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_project: api_models.Project, + temp_dir: Path, + ) -> None: + """Test successful flat log download with auto-generated filename.""" + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = b"log content" + mock_sync_apis.projects_api.read_project_api_v1_projects__id__get.return_value = sample_project + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + with cli_runner.isolated_filesystem(temp_dir=temp_dir): + result = cli_runner.invoke(project, ["logs", "--id", "1"]) + + assert result.exit_code == 0 + assert "saved to" in result.output + assert "flat" in result.output + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.assert_called_once_with( + id=1, grouped=False + ) + + def test_logs_grouped_success_default_filename( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_project: api_models.Project, + temp_dir: Path, + ) -> None: + """Test successful grouped log download with auto-generated filename.""" + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = b"zip content" + mock_sync_apis.projects_api.read_project_api_v1_projects__id__get.return_value = sample_project + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + with cli_runner.isolated_filesystem(temp_dir=temp_dir): + result = cli_runner.invoke(project, ["logs", "--id", "1", "--grouped"]) + + assert result.exit_code == 0 + assert "grouped" in result.output + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.assert_called_once_with( + id=1, grouped=True + ) + + def test_logs_custom_output_file( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_project: api_models.Project, + temp_dir: Path, + ) -> None: + """Test log download to a specified output file.""" + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = b"zip content" + output_path = str(temp_dir / "my_logs.zip") + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(project, ["logs", "--id", "1", "--output-file", output_path]) + + assert result.exit_code == 0 + assert f"saved to '{output_path}'" in result.output + assert Path(output_path).read_bytes() == b"zip content" + + def test_logs_file_content_is_written( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_project: api_models.Project, + temp_dir: Path, + ) -> None: + """Test that the downloaded bytes are written correctly to disk.""" + expected_bytes = b"PK\x03\x04fakezipdata" + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = expected_bytes + output_path = str(temp_dir / "output.zip") + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + cli_runner.invoke(project, ["logs", "--id", "42", "--output-file", output_path]) + + assert Path(output_path).read_bytes() == expected_bytes + + def test_logs_api_error_project_not_found( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + ) -> None: + """A 404 for a missing project gives a friendly, non-JSON message.""" + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.side_effect = ( + UnexpectedResponse(status_code=404, content={"detail": "Project not found"}) + ) + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(project, ["logs", "--id", "99"]) + + assert result.exit_code == 1 + assert "Project ID '99' not found." in result.output + + def test_logs_api_error_non_404_uses_generic_handler( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + ) -> None: + """Non-404 API errors still go through the generic error handler.""" + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.side_effect = ( + UnexpectedResponse(status_code=500, content=b"Internal Server Error") + ) + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(project, ["logs", "--id", "99"]) + + assert result.exit_code == 1 + assert "500" in result.output + + def test_logs_no_executions_returns_error_and_writes_no_file( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + temp_dir: Path, + ) -> None: + """A project with no test run executions surfaces a friendly message + instead of writing an empty/unusable zip file to disk. Mirrors the + dict-shaped content the real API client delivers via response.json().""" + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.side_effect = ( + UnexpectedResponse( + status_code=404, + content={"detail": "Project 11 has no test run executions to download logs for"}, + ) + ) + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + with cli_runner.isolated_filesystem(temp_dir=temp_dir): + result = cli_runner.invoke(project, ["logs", "--id", "11"]) + + assert result.exit_code == 1 + assert "Nothing to download" in result.output + assert list(Path(".").glob("*.zip")) == [] + + def test_logs_no_executions_with_string_content_fallback( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + temp_dir: Path, + ) -> None: + """Also handles JSON-string content, in case the client ever + surfaces the raw response body instead of a parsed dict.""" + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.side_effect = ( + UnexpectedResponse( + status_code=404, + content='{"detail": "Project 11 has no test run executions to download logs for"}', + ) + ) + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + with cli_runner.isolated_filesystem(temp_dir=temp_dir): + result = cli_runner.invoke(project, ["logs", "--id", "11"]) + + assert result.exit_code == 1 + assert "Nothing to download" in result.output + assert list(Path(".").glob("*.zip")) == [] + + def test_logs_prints_progress_notice_before_download( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_project: api_models.Project, + temp_dir: Path, + ) -> None: + """A heads-up is printed before the request, since building the zip + server-side can take a while for large projects.""" + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = b"log content" + mock_sync_apis.projects_api.read_project_api_v1_projects__id__get.return_value = sample_project + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + with cli_runner.isolated_filesystem(temp_dir=temp_dir): + result = cli_runner.invoke(project, ["logs", "--id", "1"]) + + assert result.exit_code == 0 + assert "Downloading logs for project 1" in result.output + assert "may take a while" in result.output + + def test_logs_falls_back_to_default_filename_when_project_name_lookup_fails( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + temp_dir: Path, + ) -> None: + """Logs are still written even if fetching the project name fails.""" + mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = b"zip content" + mock_sync_apis.projects_api.read_project_api_v1_projects__id__get.side_effect = RuntimeError( + "network glitch" + ) + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + with cli_runner.isolated_filesystem(temp_dir=temp_dir): + result = cli_runner.invoke(project, ["logs", "--id", "7"]) + + assert result.exit_code == 0 + assert "saved to 'project-7-logs.zip'" in result.output + assert Path("project-7-logs.zip").read_bytes() == b"zip content" + + def test_logs_help_message(self, cli_runner: CliRunner) -> None: + """Test the help message for the logs command.""" + result = cli_runner.invoke(project, ["logs", "--help"]) + + assert result.exit_code == 0 + assert "--id" in result.output + assert "--grouped" in result.output + assert "--output-file" in result.output diff --git a/th_cli/api_lib_autogen/api/projects_api.py b/th_cli/api_lib_autogen/api/projects_api.py index 3633a95..8cf761b 100644 --- a/th_cli/api_lib_autogen/api/projects_api.py +++ b/th_cli/api_lib_autogen/api/projects_api.py @@ -211,6 +211,22 @@ def _build_for_importproject_config_api_v1_projects_import_post( type_=m.Project, method="POST", url="/api/v1/projects/import", data=data, files=files ) + def _build_for_download_project_logs_api_v1_projects__id__logs_get( + self, id: int, grouped: bool | None = None + ) -> Coroutine[Any, Any, bytes]: + """ + Download Project Logs + """ + path_params = {"id": str(id)} + + query_params = {} + if grouped is not None: + query_params["grouped"] = str(grouped) + + return self.api_client.request( + type_=bytes, method="GET", url="/api/v1/projects/{id}/logs", path_params=path_params, params=query_params + ) + class AsyncProjectsApi(_ProjectsApi): async def read_projects_api_v1_projects__get( @@ -303,6 +319,12 @@ async def importproject_config_api_v1_projects_import_post( """ return await self._build_for_importproject_config_api_v1_projects_import_post(body=body) + async def download_project_logs_api_v1_projects__id__logs_get(self, id: int, grouped: bool | None = None) -> bytes: + """ + Download Project Logs + """ + return await self._build_for_download_project_logs_api_v1_projects__id__logs_get(id=id, grouped=grouped) + class SyncProjectsApi(_ProjectsApi): def read_projects_api_v1_projects__get( @@ -407,3 +429,10 @@ def importproject_config_api_v1_projects_import_post( """ coroutine = self._build_for_importproject_config_api_v1_projects_import_post(body=body) return get_event_loop().run_until_complete(coroutine) + + def download_project_logs_api_v1_projects__id__logs_get(self, id: int, grouped: bool | None = None) -> bytes: + """ + Download Project Logs + """ + coroutine = self._build_for_download_project_logs_api_v1_projects__id__logs_get(id=id, grouped=grouped) + return get_event_loop().run_until_complete(coroutine) diff --git a/th_cli/api_lib_autogen/api/test_run_executions_api.py b/th_cli/api_lib_autogen/api/test_run_executions_api.py index ccf7abe..464663a 100644 --- a/th_cli/api_lib_autogen/api/test_run_executions_api.py +++ b/th_cli/api_lib_autogen/api/test_run_executions_api.py @@ -358,24 +358,6 @@ def _build_for_import_test_run_execution_api_v1_test_run_executions_import_post( files=files, ) - def _build_for_generate_summary_log_api_v1_test_run_executions__id__performance_summary_post( - self, id: int, project_id: int - ) -> Coroutine[Any, Any, dict[str, Any]]: - """ - Generate Summary Log - """ - path_params = {"id": str(id)} - - query_params = {"project_id": str(project_id)} - - return self.api_client.request( - type_=dict[str, Any], - method="POST", - url="/api/v1/test_run_executions/{id}/performance_summary", - path_params=path_params, - params=query_params, - ) - class AsyncTestRunExecutionsApi(_TestRunExecutionsApi): async def read_test_run_executions_api_v1_test_run_executions__get( @@ -548,16 +530,6 @@ async def import_test_run_execution_api_v1_test_run_executions_import_post( body=body, project_id=project_id ) - async def generate_summary_log_api_v1_test_run_executions__id__performance_summary_post( - self, id: int, project_id: int - ) -> dict[str, Any]: - """ - Generate Summary Log - """ - return await self._build_for_generate_summary_log_api_v1_test_run_executions__id__performance_summary_post( - id=id, project_id=project_id - ) - class SyncTestRunExecutionsApi(_TestRunExecutionsApi): def read_test_run_executions_api_v1_test_run_executions__get( @@ -743,14 +715,3 @@ def import_test_run_execution_api_v1_test_run_executions_import_post( body=body, project_id=project_id ) return get_event_loop().run_until_complete(coroutine) - - def generate_summary_log_api_v1_test_run_executions__id__performance_summary_post( - self, id: int, project_id: int - ) -> dict[str, Any]: - """ - Generate Summary Log - """ - coroutine = self._build_for_generate_summary_log_api_v1_test_run_executions__id__performance_summary_post( - id=id, project_id=project_id - ) - return get_event_loop().run_until_complete(coroutine) diff --git a/th_cli/api_lib_autogen/api_client.py b/th_cli/api_lib_autogen/api_client.py index 843482c..4d7d04c 100644 --- a/th_cli/api_lib_autogen/api_client.py +++ b/th_cli/api_lib_autogen/api_client.py @@ -80,14 +80,12 @@ 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 @@ -99,12 +97,10 @@ 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: """ @@ -114,13 +110,15 @@ def request_sync(self, *, type_: Any, **kwargs: Any) -> Any: async def send(self, request: Request, type_: Type[T]) -> T | str: response = await self.middleware(request, self.send_inner) - if response.status_code in [200, 201]: + if response.status_code in [200, 201, 204]: try: # Use Pydantic v2 TypeAdapter for validation + if type_ is None or response.status_code == 204: + return None adapter = TypeAdapter(type_) if type_ == bytes: return adapter.validate_python(response.content) - return adapter.validate_python(response.json()) if type_ else response.text + return adapter.validate_python(response.json()) except Exception as e: raise ResponseHandlingException(e) raise UnexpectedResponse.for_response(response) diff --git a/th_cli/api_lib_autogen/models.py b/th_cli/api_lib_autogen/models.py index 351767b..31a9e68 100644 --- a/th_cli/api_lib_autogen/models.py +++ b/th_cli/api_lib_autogen/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: -# filename: openapi.json -# timestamp: 2026-04-08T20:07:43+00:00 +# filename: tmp1nhhp82y.json +# timestamp: 2026-07-07T19:42:35+00:00 from __future__ import annotations @@ -438,9 +438,9 @@ class TestRunExecutionToExport(BaseModel): started_at: Annotated[datetime | None, Field(title="Started At")] = None completed_at: Annotated[datetime | None, Field(title="Completed At")] = None archived_at: Annotated[datetime | None, Field(title="Archived At")] = None - test_suite_executions: Annotated[ - list[TestSuiteExecutionToExport] | None, Field(title="Test Suite Executions") - ] = None + test_suite_executions: Annotated[list[TestSuiteExecutionToExport] | None, Field(title="Test Suite Executions")] = ( + None + ) created_at: Annotated[datetime, Field(title="Created At")] log: Annotated[list[TestRunLogEntry], Field(title="Log")] operator: OperatorToExport | None = None diff --git a/th_cli/commands/project.py b/th_cli/commands/project.py index d8db990..8911535 100644 --- a/th_cli/commands/project.py +++ b/th_cli/commands/project.py @@ -23,7 +23,13 @@ from th_cli.api_lib_autogen.api_client import SyncApis from th_cli.api_lib_autogen.exceptions import UnexpectedResponse -from th_cli.api_lib_autogen.models import PICS, BodyImportprojectConfigApiV1ProjectsImportPost, Project, ProjectCreate, ProjectUpdate +from th_cli.api_lib_autogen.models import ( + PICS, + BodyImportprojectConfigApiV1ProjectsImportPost, + Project, + ProjectCreate, + ProjectUpdate, +) from th_cli.client import get_client from th_cli.colorize import ( colorize_cmd_help, @@ -40,6 +46,7 @@ TABLE_FORMAT = "{:<5} {:25} {:28}" + # Click command group for project management @click.group( short_help=colorize_help("Manage projects"), @@ -132,11 +139,7 @@ def create(name: str, config: str | None, pics_config_folder: str | None) -> Non help=colorize_help("Print JSON response for more details"), ) @click.option( - "--config", - "-c", - is_flag=True, - default=False, - help=colorize_help("Print project configuration in JSON format") + "--config", "-c", is_flag=True, default=False, help=colorize_help("Print project configuration in JSON format") ) def list_projects( id: int | None, @@ -259,6 +262,37 @@ def import_project(file: str) -> None: _import_project(sync_apis, file) +# Click command to download all logs for a project +@project.command( + "logs", + short_help=colorize_help("Download all logs for a project"), +) +@click.option( + "--id", + "-i", + type=int, + required=True, + help=colorize_help("Project ID"), +) +@click.option( + "--grouped", + is_flag=True, + default=False, + help=colorize_help("Download grouped logs (organized by test case state) instead of flat log files"), +) +@click.option( + "--output-file", + "-o", + type=click.Path(file_okay=True, dir_okay=False), + required=False, + help=colorize_help("Output zip file path (defaults to -logs.zip)"), +) +def logs(id: int, grouped: bool, output_file: str | None) -> None: + """Download all logs for a project as a single zip archive""" + with get_sync_apis("logs") as sync_apis: + _download_project_logs(sync_apis, id, grouped, output_file) + + def _create_project(sync_apis: SyncApis, name: str, config: str | None, pics_config_folder: str | None) -> None: """Create a new project""" # Get default config @@ -465,3 +499,51 @@ def _import_project(sync_apis: SyncApis, file: str) -> None: click.echo(colorize_success(f"Project '{response.name}' imported with ID {response.id}")) except UnexpectedResponse as e: handle_api_error(e, f"import project from '{file}'") + + +def _download_project_logs(sync_apis: SyncApis, id: int, grouped: bool, output_file: str | None) -> None: + """Download all logs for a project as a single zip archive""" + click.echo( + f"Downloading logs for project {id}... this may take a while for " + "projects with many or large test run executions." + ) + try: + log_bytes: bytes = sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get( + id=id, grouped=grouped + ) + except UnexpectedResponse as e: + if e.status_code == 404: + if isinstance(e.content, dict): + detail = e.content.get("detail", "") + else: + content = e.content.decode("utf-8", errors="ignore") if isinstance(e.content, bytes) else e.content + detail = "" + try: + parsed = json.loads(content) if content else {} + if isinstance(parsed, dict): + detail = parsed.get("detail", "") + except json.JSONDecodeError: + pass + if "no test run executions" in detail: + raise CLIError(f"Project {id} has no test run executions yet. Nothing to download.") + raise CLIError(f"Project ID '{id}' not found.") + handle_api_error(e, f"download logs for project ID '{id}'") + + if not output_file: + try: + project = sync_apis.projects_api.read_project_api_v1_projects__id__get(id=id) + safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in (project.name or f"project-{id}")) + except Exception: + # The logs have already been downloaded successfully at this point; + # any failure fetching the project name (network glitch, unexpected + # response shape, etc.) should just fall back to a safe default + # filename instead of discarding the downloaded bytes. + safe_name = f"project-{id}" + output_file = f"{safe_name}-logs.zip" + + try: + Path(output_file).write_bytes(log_bytes) + mode = "grouped" if grouped else "flat" + click.echo(colorize_success(f"Project {id} logs ({mode}) saved to '{output_file}'")) + except OSError as e: + raise CLIError(f"Failed to write logs file '{output_file}': {e}") diff --git a/th_cli/commands/run_tests.py b/th_cli/commands/run_tests.py index ef74a6e..6fa9e51 100644 --- a/th_cli/commands/run_tests.py +++ b/th_cli/commands/run_tests.py @@ -42,7 +42,15 @@ from th_cli.exceptions import CLIError, handle_api_error from th_cli.test_run.camera.two_way_talk_handler import TwoWayTalkHandler from th_cli.test_run.websocket import TestRunSocket -from th_cli.utils import DEFAULT_CLI_PROJECT_NAME, build_test_selection, convert_nested_to_dict, load_json_config, load_tc_params_mapping, merge_configs, read_pics_config +from th_cli.utils import ( + DEFAULT_CLI_PROJECT_NAME, + build_test_selection, + convert_nested_to_dict, + load_json_config, + load_tc_params_mapping, + merge_configs, + read_pics_config, +) from th_cli.validation import validate_directory_path, validate_file_path, validate_tc_params_file, validate_test_ids # Constants @@ -310,7 +318,7 @@ async def run_tests( finally: # Stop log streaming test_logging.stop_log_streaming() - + if client: await client.aclose() if _webrtc_handler: diff --git a/th_cli/test_run/logging.py b/th_cli/test_run/logging.py index 1ed327b..bf3363b 100644 --- a/th_cli/test_run/logging.py +++ b/th_cli/test_run/logging.py @@ -35,16 +35,16 @@ def configure_logger_for_run(title: str, enable_log_streaming: bool = False) -> str: """Configure logger for a test run. - + Args: title: Title of the test run enable_log_streaming: Whether to enable real-time log streaming - + Returns: Path to the log file """ global _log_stream_handler - + # Reset (Remove all sinks from logger) logger.remove() @@ -63,15 +63,14 @@ def configure_logger_for_run(title: str, enable_log_streaming: bool = False) -> _log_stream_handler = LogStreamHandler(port=8998) viewer_url = _log_stream_handler.start(test_run_title=title, log_file_path=log_path) + # Add custom sink that forwards logs to the stream handler def stream_sink(message): """Custom sink that forwards logs to the HTTP stream.""" try: record = message.record _log_stream_handler.add_log_entry( - message=record["message"], - level=record["level"].name, - timestamp=record["time"].isoformat() + message=record["message"], level=record["level"].name, timestamp=record["time"].isoformat() ) except Exception: # Silently fail to avoid disrupting logging @@ -91,7 +90,7 @@ def stream_sink(message): def stop_log_streaming(): """Stop the log streaming server if it's running.""" global _log_stream_handler - + if _log_stream_handler: try: _log_stream_handler.stop() diff --git a/th_cli/utils.py b/th_cli/utils.py index e63b5de..2c804ad 100644 --- a/th_cli/utils.py +++ b/th_cli/utils.py @@ -39,12 +39,14 @@ def __print_json(object: Any) -> None: click.echo(colorize_dump(__json_string(object))) + def __print_config(project: Any) -> None: if isinstance(project, list): raise CLIError("Please provide a single project ID (using --id) to print the configuration.") config = project.config if project.config is not None else {} click.echo(colorize_dump(json.dumps(config, indent=4, default=str))) + def __json_string(object: Any) -> str: if object is None: return "None" @@ -432,9 +434,7 @@ def _normalise(tc_id: str) -> str: raw = parse_and_validate_tc_params_file(mapping_path) # Build a normalised-key → params lookup. - normalised_mapping: dict[str, dict[str, Any]] = { - _normalise(key): value for key, value in raw.items() - } + normalised_mapping: dict[str, dict[str, Any]] = {_normalise(key): value for key, value in raw.items()} # Match each requested test ID against the normalised mapping. merged_params: dict[str, Any] = {} @@ -502,7 +502,7 @@ def get_versions() -> dict: sync_apis = SyncApis(client) version_api = sync_apis.version_api versions_info = version_api.get_test_harness_backend_version_api_v1_version_get() - return versions_info.model_dump(mode='json') + return versions_info.model_dump(mode="json") except CLIError: raise # Re-raise CLI Errors as-is except UnexpectedResponse: diff --git a/th_cli/validation.py b/th_cli/validation.py index 51cf86c..17a0e69 100644 --- a/th_cli/validation.py +++ b/th_cli/validation.py @@ -139,8 +139,7 @@ def parse_and_validate_tc_params_file(file_path: str) -> dict[str, dict[str, Any data: Any = json.load(f) except json.JSONDecodeError as e: raise CLIError( - f"Invalid JSON in TC params mapping file '{file_path}': " - f"{e.msg} (line {e.lineno}, column {e.colno})" + f"Invalid JSON in TC params mapping file '{file_path}': " f"{e.msg} (line {e.lineno}, column {e.colno})" ) except OSError as e: raise CLIError(f"Failed to read TC params mapping file '{file_path}': {e}")