From 989e45a33a5d987ac4794a02f82b440635554f73 Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho Date: Mon, 22 Jun 2026 11:38:42 -0300 Subject: [PATCH 1/3] feat: add project export and import CLI commands (#1025) Add 'project export' and 'project import' subcommands to the CLI, wiring up the existing backend endpoints: - GET /api/v1/projects/{id}/export - POST /api/v1/projects/import project export: - Downloads the project config as a JSON file - Defaults to '-project-config.json' if no output file is given - Accepts --output-file / -o to specify a custom path project import: - Accepts --file / -f pointing to a previously exported JSON file - Sends the file bytes to the backend and prints the new project ID Also adds full unit test coverage in tests/test_project_commands.py (TestExportProjectCommand and TestImportProjectCommand). Closes #1025 --- tests/test_project_commands.py | 208 +++++++++++++++++++++++++++++++++ th_cli/commands/project.py | 84 ++++++++++++- 2 files changed, 291 insertions(+), 1 deletion(-) diff --git a/tests/test_project_commands.py b/tests/test_project_commands.py index 70c1879..e473bfe 100644 --- a/tests/test_project_commands.py +++ b/tests/test_project_commands.py @@ -519,3 +519,211 @@ def test_update_project_help_message(self, cli_runner: CliRunner) -> None: assert "update" in result.output assert "--id" in result.output assert "--config" in result.output + + +@pytest.mark.unit +@pytest.mark.cli +class TestExportProjectCommand: + """Test cases for the project export command.""" + + def test_export_project_success_default_filename( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_project: api_models.Project, + temp_dir: Path, + ) -> None: + """Test successful project export with auto-generated filename.""" + # Arrange + project_create = api_models.ProjectCreate( + name="Test Project", + config=sample_project.config, + pics=api_models.PICS(clusters={}), + ) + mock_sync_apis.projects_api.export_project_config_api_v1_projects__id__export_get.return_value = ( + project_create + ) + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + with cli_runner.isolated_filesystem(temp_dir=temp_dir): + # Act + result = cli_runner.invoke(project, ["export", "--id", "1"]) + + # Assert + assert result.exit_code == 0 + assert "exported to" in result.output + mock_sync_apis.projects_api.export_project_config_api_v1_projects__id__export_get.assert_called_once_with(id=1) + + def test_export_project_success_custom_filename( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_project: api_models.Project, + temp_dir: Path, + ) -> None: + """Test successful project export to a specified output file.""" + # Arrange + project_create = api_models.ProjectCreate( + name="Test Project", + config=sample_project.config, + pics=api_models.PICS(clusters={}), + ) + mock_sync_apis.projects_api.export_project_config_api_v1_projects__id__export_get.return_value = ( + project_create + ) + output_path = str(temp_dir / "my_export.json") + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + # Act + result = cli_runner.invoke(project, ["export", "--id", "1", "--output-file", output_path]) + + # Assert + assert result.exit_code == 0 + assert f"exported to '{output_path}'" in result.output + assert Path(output_path).exists() + saved = json.loads(Path(output_path).read_text()) + assert saved["name"] == "Test Project" + + def test_export_project_file_content_is_valid_json( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_project: api_models.Project, + temp_dir: Path, + ) -> None: + """Test that the exported file contains valid JSON matching the project config.""" + # Arrange + project_create = api_models.ProjectCreate( + name="My Device", + config=sample_project.config, + pics=api_models.PICS(clusters={}), + ) + mock_sync_apis.projects_api.export_project_config_api_v1_projects__id__export_get.return_value = ( + project_create + ) + output_path = str(temp_dir / "export.json") + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(project, ["export", "--id", "42", "--output-file", output_path]) + + assert result.exit_code == 0 + data = json.loads(Path(output_path).read_text()) + assert data["name"] == "My Device" + assert "config" in data + + def test_export_project_api_error( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + ) -> None: + """Test project export with API error.""" + # Arrange + mock_sync_apis.projects_api.export_project_config_api_v1_projects__id__export_get.side_effect = ( + UnexpectedResponse(status_code=404, content=b"Not Found") + ) + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(project, ["export", "--id", "99"]) + + assert result.exit_code == 1 + assert "Error: Failed to export project ID '99' (Status: 404) - Not Found" in result.output + + def test_export_project_help_message(self, cli_runner: CliRunner) -> None: + """Test the help message for the export command.""" + result = cli_runner.invoke(project, ["export", "--help"]) + + assert result.exit_code == 0 + assert "--id" in result.output + assert "--output-file" in result.output + + +@pytest.mark.unit +@pytest.mark.cli +class TestImportProjectCommand: + """Test cases for the project import command.""" + + def test_import_project_success( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_project: api_models.Project, + temp_dir: Path, + ) -> None: + """Test successful project import from a JSON file.""" + # Arrange + import_file = temp_dir / "import.json" + import_file.write_text( + json.dumps({"name": "Imported Project", "config": sample_project.config, "pics": {"clusters": {}}}) + ) + mock_sync_apis.projects_api.importproject_config_api_v1_projects_import_post.return_value = sample_project + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(project, ["import", "--file", str(import_file)]) + + # Assert + assert result.exit_code == 0 + assert f"Project '{sample_project.name}' imported with ID {sample_project.id}" in result.output + mock_sync_apis.projects_api.importproject_config_api_v1_projects_import_post.assert_called_once() + + def test_import_project_file_not_found( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + ) -> None: + """Test project import with a non-existent file (Click validates exists=True).""" + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(project, ["import", "--file", "nonexistent.json"]) + + assert result.exit_code == 2 + assert "does not exist" in result.output + + def test_import_project_api_error( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_project: api_models.Project, + temp_dir: Path, + ) -> None: + """Test project import with API error.""" + # Arrange + import_file = temp_dir / "import.json" + import_file.write_text( + json.dumps({"name": "Imported Project", "config": sample_project.config, "pics": {"clusters": {}}}) + ) + mock_sync_apis.projects_api.importproject_config_api_v1_projects_import_post.side_effect = ( + UnexpectedResponse(status_code=422, content=b"Unprocessable Entity") + ) + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(project, ["import", "--file", str(import_file)]) + + assert result.exit_code == 1 + assert "422" in result.output + + def test_import_project_passes_file_bytes_to_api( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_project: api_models.Project, + temp_dir: Path, + ) -> None: + """Test that the import command sends the file bytes to the API correctly.""" + # Arrange + payload = {"name": "Byte Check Project", "config": sample_project.config, "pics": {"clusters": {}}} + import_file = temp_dir / "import.json" + import_file.write_text(json.dumps(payload)) + mock_sync_apis.projects_api.importproject_config_api_v1_projects_import_post.return_value = sample_project + + with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis): + cli_runner.invoke(project, ["import", "--file", str(import_file)]) + + call_args = mock_sync_apis.projects_api.importproject_config_api_v1_projects_import_post.call_args + body = call_args.kwargs["body"] + assert body.import_file == import_file.read_bytes() + + def test_import_project_help_message(self, cli_runner: CliRunner) -> None: + """Test the help message for the import command.""" + result = cli_runner.invoke(project, ["import", "--help"]) + + assert result.exit_code == 0 + assert "--file" in result.output diff --git a/th_cli/commands/project.py b/th_cli/commands/project.py index 5fe6ed3..549c0ec 100644 --- a/th_cli/commands/project.py +++ b/th_cli/commands/project.py @@ -15,6 +15,7 @@ # import json from contextlib import contextmanager +from pathlib import Path from typing import Any import click @@ -22,7 +23,7 @@ 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, 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, @@ -215,6 +216,49 @@ def delete(id: int, yes: bool) -> None: _delete_project(sync_apis, id) +# Click command to export a project config +@project.command( + "export", + short_help=colorize_help("Export a project config to a JSON file"), +) +@click.option( + "--id", + "-i", + type=int, + required=True, + help=colorize_help("Project ID to export"), +) +@click.option( + "--output-file", + "-o", + type=click.Path(file_okay=True, dir_okay=False), + required=False, + help=colorize_help("Output JSON file path (defaults to -project-config.json)"), +) +def export(id: int, output_file: str | None) -> None: + """Export a project config to a JSON file""" + with get_sync_apis("export") as sync_apis: + _export_project(sync_apis, id, output_file) + + +# Click command to import a project config from a JSON file +@project.command( + "import", + short_help=colorize_help("Import a project config from a JSON file"), +) +@click.option( + "--file", + "-f", + type=click.Path(file_okay=True, dir_okay=False, exists=True), + required=True, + help=colorize_help("JSON file previously exported with 'project export'"), +) +def import_project(file: str) -> None: + """Import a project config from a JSON file""" + with get_sync_apis("import") as sync_apis: + _import_project(sync_apis, 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 @@ -383,3 +427,41 @@ def _delete_project(sync_apis: SyncApis, id: int) -> None: click.echo(colorize_success(f"Project {id} was deleted.")) except UnexpectedResponse as e: handle_api_error(e, f"delete project ID '{id}'") + + +def _export_project(sync_apis: SyncApis, id: int, output_file: str | None) -> None: + """Export a project config to a JSON file""" + try: + project_create: ProjectCreate = sync_apis.projects_api.export_project_config_api_v1_projects__id__export_get( + id=id + ) + except UnexpectedResponse as e: + handle_api_error(e, f"export project ID '{id}'") + + export_data = project_create.model_dump() + + if not output_file: + safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in (project_create.name or f"project-{id}")) + output_file = f"{safe_name}-project-config.json" + + try: + Path(output_file).write_text(json.dumps(export_data, indent=2)) + click.echo(colorize_success(f"Project {id} exported to '{output_file}'")) + except OSError as e: + raise CLIError(f"Failed to write export file '{output_file}': {e}") + + +def _import_project(sync_apis: SyncApis, file: str) -> None: + """Import a project config from a JSON file""" + try: + file_bytes = Path(file).read_bytes() + except OSError as e: + handle_file_error(FileNotFoundError(e), "import file") + + body = BodyImportprojectConfigApiV1ProjectsImportPost(import_file=file_bytes) + + try: + response: Project = sync_apis.projects_api.importproject_config_api_v1_projects_import_post(body=body) + 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}'") From 6ee14181d6988d6f937db8e8b703086910c349a0 Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho Date: Mon, 22 Jun 2026 11:43:49 -0300 Subject: [PATCH 2/3] fix: resolve unit test failures in project export/import and logger tests - Add missing 'import json' to test_project_commands.py - Update test_run_tests_logger_configuration to include enable_log_streaming=True in the expected configure_logger_for_run call, matching the actual call signature --- tests/test_project_commands.py | 3 ++- tests/test_run_tests.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_project_commands.py b/tests/test_project_commands.py index e473bfe..662589e 100644 --- a/tests/test_project_commands.py +++ b/tests/test_project_commands.py @@ -13,8 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Tests for the project commands (create, delete, list, update).""" +"""Tests for the project commands (create, delete, list, update, export, import).""" +import json from pathlib import Path from unittest.mock import Mock, patch diff --git a/tests/test_run_tests.py b/tests/test_run_tests.py index 3761518..1dc400e 100644 --- a/tests/test_run_tests.py +++ b/tests/test_run_tests.py @@ -593,7 +593,7 @@ def test_run_tests_logger_configuration( # Assert assert result.exit_code == 0 - mock_configure_logger.assert_called_once_with(title="Custom Logger Test") + mock_configure_logger.assert_called_once_with(title="Custom Logger Test", enable_log_streaming=True) assert "Log output in: /path/to/test_logs/custom_run.log" in result.output def test_run_tests_default_title_generation( From f80ee6db6224abd7aaa94ef30187f7178248a59b Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho Date: Mon, 22 Jun 2026 11:54:21 -0300 Subject: [PATCH 3/3] fix: apply code review suggestions from PR #98 - Use model_dump_json(indent=2) instead of model_dump() + json.dumps() to correctly serialize Pydantic models with non-standard types - Split OSError handling in _import_project into FileNotFoundError and generic OSError to avoid broken error messages from wrapping OSError in FileNotFoundError --- th_cli/commands/project.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/th_cli/commands/project.py b/th_cli/commands/project.py index 549c0ec..d8db990 100644 --- a/th_cli/commands/project.py +++ b/th_cli/commands/project.py @@ -438,14 +438,12 @@ def _export_project(sync_apis: SyncApis, id: int, output_file: str | None) -> No except UnexpectedResponse as e: handle_api_error(e, f"export project ID '{id}'") - export_data = project_create.model_dump() - if not output_file: safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in (project_create.name or f"project-{id}")) output_file = f"{safe_name}-project-config.json" try: - Path(output_file).write_text(json.dumps(export_data, indent=2)) + Path(output_file).write_text(project_create.model_dump_json(indent=2)) click.echo(colorize_success(f"Project {id} exported to '{output_file}'")) except OSError as e: raise CLIError(f"Failed to write export file '{output_file}': {e}") @@ -455,8 +453,10 @@ def _import_project(sync_apis: SyncApis, file: str) -> None: """Import a project config from a JSON file""" try: file_bytes = Path(file).read_bytes() + except FileNotFoundError as e: + handle_file_error(e, "import file") except OSError as e: - handle_file_error(FileNotFoundError(e), "import file") + raise CLIError(f"Failed to read import file '{file}': {e}") body = BodyImportprojectConfigApiV1ProjectsImportPost(import_file=file_bytes)