From 79632ac5da89140fb6c3c9d5a63c975818a0c06f Mon Sep 17 00:00:00 2001 From: acuanico-tr-galt Date: Tue, 2 Jun 2026 14:40:42 +0800 Subject: [PATCH 1/3] Updated version for 1.15.0 Release --- CHANGELOG.MD | 7 +++++++ README.md | 8 ++++---- trcli/__init__.py | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 3dfd1c6..f4d9849 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -6,6 +6,13 @@ This project adheres to [Semantic Versioning](https://semver.org/). Version numb - **MINOR**: New features that are backward-compatible. - **PATCH**: Bug fixes or minor changes that do not affect backward compatibility. +## [1.15.0] + +_released 06-12-2026 + +### Added + - Added new data extraction commands for cases, suites, sections and plans for AI automation + ## [1.14.3] _released 05-28-2026 diff --git a/README.md b/README.md index 87d825a..485f2b1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ trcli ``` You should get something like this: ``` -TestRail CLI v1.14.3 +TestRail CLI v1.15.0 Copyright 2025 Gurock Software GmbH - www.gurock.com Supported and loaded modules: - parse_junit: JUnit XML Files (& Similar) @@ -52,7 +52,7 @@ CLI general reference -------- ```shell $ trcli --help -TestRail CLI v1.14.3 +TestRail CLI v1.15.0 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli [OPTIONS] COMMAND [ARGS]... @@ -2180,7 +2180,7 @@ Options: ### Reference ```shell $ trcli add_run --help -TestRail CLI v1.14.3 +TestRail CLI v1.15.0 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli add_run [OPTIONS] @@ -2390,7 +2390,7 @@ providing you with a solid base of test cases, which you can further expand on T ### Reference ```shell $ trcli parse_openapi --help -TestRail CLI v1.14.3 +TestRail CLI v1.15.0 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli parse_openapi [OPTIONS] diff --git a/trcli/__init__.py b/trcli/__init__.py index f38fc51..6b0872c 100644 --- a/trcli/__init__.py +++ b/trcli/__init__.py @@ -1 +1 @@ -__version__ = "1.14.3" +__version__ = "1.15.0" From 743eaeded01150d4859c57782d0b40ff21aff6c4 Mon Sep 17 00:00:00 2001 From: "Chris.Weber" Date: Tue, 10 Mar 2026 00:18:04 -0500 Subject: [PATCH 2/3] feat: add read commands for plans, suites, and cases (#401) --- CHANGELOG.MD | 3 +- README.md | 73 +++++++++++++++ tests/test_cmd_get_case.py | 105 ++++++++++++++++++++++ tests/test_cmd_get_cases.py | 149 +++++++++++++++++++++++++++++++ tests/test_cmd_get_plan.py | 90 +++++++++++++++++++ tests/test_cmd_get_plans.py | 105 ++++++++++++++++++++++ tests/test_cmd_get_suites.py | 91 +++++++++++++++++++ trcli/api/api_request_handler.py | 21 +++++ trcli/commands/cmd_get_case.py | 68 ++++++++++++++ trcli/commands/cmd_get_cases.py | 118 ++++++++++++++++++++++++ trcli/commands/cmd_get_plan.py | 65 ++++++++++++++ trcli/commands/cmd_get_plans.py | 58 ++++++++++++ trcli/commands/cmd_get_suites.py | 68 ++++++++++++++ 13 files changed, 1012 insertions(+), 2 deletions(-) create mode 100644 tests/test_cmd_get_case.py create mode 100644 tests/test_cmd_get_cases.py create mode 100644 tests/test_cmd_get_plan.py create mode 100644 tests/test_cmd_get_plans.py create mode 100644 tests/test_cmd_get_suites.py create mode 100644 trcli/commands/cmd_get_case.py create mode 100644 trcli/commands/cmd_get_cases.py create mode 100644 trcli/commands/cmd_get_plan.py create mode 100644 trcli/commands/cmd_get_plans.py create mode 100644 trcli/commands/cmd_get_suites.py diff --git a/CHANGELOG.MD b/CHANGELOG.MD index f4d9849..d6cb332 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -67,7 +67,6 @@ _released 03-18-2026 ### Fixed - Cannot add empty runs via add_run command due to empty test case validation. - ## [1.13.3] _released 03-05-2026 @@ -454,4 +453,4 @@ _released 10-07-2022 ## [1.0] _released 16-04-2022 -- **Initial Public Release:** The first public release of `trcli`. \ No newline at end of file +- **Initial Public Release:** The first public release of `trcli`. diff --git a/README.md b/README.md index 485f2b1..bddd581 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The TestRail CLI currently supports: - **Auto-generating test cases from OpenAPI specifications** - **Creating new test runs for results to be uploaded to** - **Managing project labels for better organization and categorization** +- **Reading plans, suites, and cases from TestRail as JSON** To see further documentation about the TestRail CLI, please refer to the [TestRail CLI documentation pages](https://support.gurock.com/hc/en-us/articles/7146548750868-TestRail-CLI) @@ -2480,6 +2481,78 @@ expand your test cases to cover specific business logic and workflows. |---------------------------------------|---------------------------------------------------| | `VERB /path -> status_code (summary)` | `GET /pet/{petId} -> 200 (Successful operation) ` | +Reading data from TestRail +----------------- + +The TestRail CLI provides read-only commands to retrieve plans, suites, and cases from TestRail as JSON. +These commands output JSON directly to stdout, making them easy to integrate with other tools +via piping (e.g., `jq`). + +### Commands + +| Command | Description | +|---------|-------------| +| `get_plans` | List all test plans for a project | +| `get_plan` | Get a single test plan by ID | +| `get_suites` | List all test suites for a project | +| `get_cases` | List test cases for a project and suite | +| `get_case` | Get a single test case by ID | + +### Usage Examples + +**List all test plans for a project:** +```shell +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_plans --project-id 1 +``` + +**Get a single test plan:** +```shell +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_plan --plan-id 42 +``` + +**List all test suites for a project:** +```shell +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_suites --project-id 1 +``` + +**List test cases (with optional section filter):** +```shell +# All cases in a suite +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_cases --project-id 1 --suite-id 3 + +# Cases in a specific section +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_cases --project-id 1 --suite-id 3 --section-id 10 +``` + +**Get a single test case:** +```shell +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_case --case-id 5001 +``` + +**Using environment variables instead of flags:** +```shell +export TR_CLI_HOST=https://example.testrail.io +export TR_CLI_USERNAME=user@example.com +export TR_CLI_KEY=YOUR_API_KEY + +trcli get_plans --project-id 1 +trcli get_case --case-id 5001 +``` + +**Piping output to jq:** +```shell +trcli get_cases --project-id 1 --suite-id 3 | jq '.[].title' +``` + +All read commands output a JSON array or object to stdout. Errors and warnings are printed to stderr, +so they will not interfere with piped JSON output. + Parameter sources ----------------- You can choose to set parameters from different sources, like a default config file, diff --git a/tests/test_cmd_get_case.py b/tests/test_cmd_get_case.py new file mode 100644 index 0000000..fddc0ee --- /dev/null +++ b/tests/test_cmd_get_case.py @@ -0,0 +1,105 @@ +from unittest import mock + +import pytest +from click.testing import CliRunner + +from trcli.api.api_client import APIClientResult +from trcli.cli import cli as trcli_cli + + +class TestCmdGetCase: + """Tests for the get_case CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_case", + "--case-id", "99", + ] + + @mock.patch("trcli.commands.cmd_get_case._create_api_client") + def test_happy_path_returns_json(self, mock_create_client): + """Successful API response prints JSON to stdout.""" + case_data = {"id": 99, "title": "My Test Case", "section_id": 5} + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=case_data, error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert '"My Test Case"' in result.output + assert '"id": 99' in result.output + mock_client.send_get.assert_called_once_with("get_case/99") + + @mock.patch("trcli.commands.cmd_get_case._create_api_client") + def test_api_error_message_exits_with_code_1(self, mock_create_client): + """API error message is output with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=-1, response_text="", error_message="Connection refused" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Connection refused" in result.output + + @mock.patch("trcli.commands.cmd_get_case._create_api_client") + def test_api_non_200_status_exits_with_code_1(self, mock_create_client): + """Non-200 status code prints error with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=404, response_text="", error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "404" in result.output + + def test_missing_case_id_exits_nonzero(self): + """Missing --case-id triggers a Click error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_case", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code != 0 + + def test_missing_host_exits_nonzero(self): + """Missing -h triggers an error.""" + args = [ + "-u", "user@example.com", + "-p", "password123", + "get_case", + "--case-id", "99", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--host is required" in result.output + + def test_missing_password_and_key_exits_nonzero(self): + """Missing both -p and -k triggers an error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "get_case", + "--case-id", "99", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--password or --key is required" in result.output diff --git a/tests/test_cmd_get_cases.py b/tests/test_cmd_get_cases.py new file mode 100644 index 0000000..e950579 --- /dev/null +++ b/tests/test_cmd_get_cases.py @@ -0,0 +1,149 @@ +from unittest import mock + +import pytest +from click.testing import CliRunner + +from trcli.api.api_client import APIClientResult +from trcli.cli import cli as trcli_cli + + +class TestCmdGetCases: + """Tests for the get_cases CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_cases", + "--project-id", "1", + "--suite-id", "10", + ] + + @mock.patch("trcli.commands.cmd_get_cases._create_api_client") + def test_happy_path_returns_json(self, mock_create_client): + """Successful API response prints JSON to stdout.""" + cases_data = [{"id": 100, "title": "Login test"}, {"id": 101, "title": "Logout test"}] + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=cases_data, error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert '"Login test"' in result.output + assert '"Logout test"' in result.output + + @mock.patch("trcli.commands.cmd_get_cases._create_api_client") + def test_happy_path_paginated(self, mock_create_client): + """Paginated API response collects all pages.""" + page1 = { + "cases": [{"id": 1, "title": "Case 1"}], + "_links": {"next": "get_cases/1&suite_id=10&offset=1"}, + } + page2 = { + "cases": [{"id": 2, "title": "Case 2"}], + "_links": {"next": None}, + } + mock_client = mock_create_client.return_value + mock_client.send_get.side_effect = [ + APIClientResult(status_code=200, response_text=page1, error_message=""), + APIClientResult(status_code=200, response_text=page2, error_message=""), + ] + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert '"Case 1"' in result.output + assert '"Case 2"' in result.output + assert mock_client.send_get.call_count == 2 + + @mock.patch("trcli.commands.cmd_get_cases._create_api_client") + def test_with_section_id_filter(self, mock_create_client): + """Optional --section-id is appended to the API URL.""" + cases_data = [{"id": 100, "title": "Filtered case"}] + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=cases_data, error_message="" + ) + + args = self.BASE_ARGS + ["--section-id", "5"] + runner = CliRunner() + result = runner.invoke(trcli_cli, args, catch_exceptions=False) + + assert result.exit_code == 0 + call_args = mock_client.send_get.call_args[0][0] + assert "section_id=5" in call_args + + @mock.patch("trcli.commands.cmd_get_cases._create_api_client") + def test_api_error_message_exits_with_code_1(self, mock_create_client): + """API error message is output with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=-1, response_text="", error_message="Timeout" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Timeout" in result.output + + @mock.patch("trcli.commands.cmd_get_cases._create_api_client") + def test_api_non_200_status_exits_with_code_1(self, mock_create_client): + """Non-200 status code prints error with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=404, response_text="", error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "404" in result.output + + def test_missing_project_id_exits_nonzero(self): + """Missing --project-id triggers a Click error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_cases", + "--suite-id", "10", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code != 0 + + def test_missing_suite_id_exits_nonzero(self): + """Missing --suite-id triggers a Click error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_cases", + "--project-id", "1", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code != 0 + + def test_missing_host_exits_nonzero(self): + """Missing -h triggers an error.""" + args = [ + "-u", "user@example.com", + "-p", "password123", + "get_cases", + "--project-id", "1", + "--suite-id", "10", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--host is required" in result.output diff --git a/tests/test_cmd_get_plan.py b/tests/test_cmd_get_plan.py new file mode 100644 index 0000000..3fd6b6a --- /dev/null +++ b/tests/test_cmd_get_plan.py @@ -0,0 +1,90 @@ +from unittest import mock + +import pytest +from click.testing import CliRunner + +from trcli.cli import cli as trcli_cli + + +class TestCmdGetPlan: + """Tests for the get_plan CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_plan", + "--plan-id", "42", + ] + + @mock.patch("trcli.commands.cmd_get_plan.ApiRequestHandler") + @mock.patch("trcli.commands.cmd_get_plan.APIClient") + def test_happy_path_returns_json(self, mock_api_client_cls, mock_handler_cls): + """Successful API response prints JSON to stdout.""" + plan_data = {"id": 42, "name": "My Plan", "entries": []} + mock_handler = mock_handler_cls.return_value + mock_handler.get_plan.return_value = (plan_data, None) + mock_api_client_cls.build_uploader_metadata.return_value = {} + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert '"My Plan"' in result.output + assert '"id": 42' in result.output + mock_handler.get_plan.assert_called_once() + + @mock.patch("trcli.commands.cmd_get_plan.ApiRequestHandler") + @mock.patch("trcli.commands.cmd_get_plan.APIClient") + def test_api_error_exits_with_code_1(self, mock_api_client_cls, mock_handler_cls): + """API error is output with exit code 1.""" + mock_handler = mock_handler_cls.return_value + mock_handler.get_plan.return_value = (None, "Plan not found") + mock_api_client_cls.build_uploader_metadata.return_value = {} + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Plan not found" in result.output + + def test_missing_plan_id_exits_nonzero(self): + """Missing --plan-id triggers a Click error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_plan", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code != 0 + + def test_missing_host_exits_nonzero(self): + """Missing -h triggers an error.""" + args = [ + "-u", "user@example.com", + "-p", "password123", + "get_plan", + "--plan-id", "42", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "server address" in result.output + + def test_missing_password_and_key_exits_nonzero(self): + """Missing both -p and -k triggers an error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "get_plan", + "--plan-id", "42", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "password" in result.output or "key" in result.output diff --git a/tests/test_cmd_get_plans.py b/tests/test_cmd_get_plans.py new file mode 100644 index 0000000..8e0d949 --- /dev/null +++ b/tests/test_cmd_get_plans.py @@ -0,0 +1,105 @@ +from unittest import mock + +import pytest +from click.testing import CliRunner + +from trcli.cli import cli as trcli_cli + + +class TestCmdGetPlans: + """Tests for the get_plans CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "--project-id", "1", + "get_plans", + ] + + @mock.patch("trcli.commands.cmd_get_plans.ApiRequestHandler") + @mock.patch("trcli.commands.cmd_get_plans.APIClient") + def test_happy_path_returns_json(self, mock_api_client_cls, mock_handler_cls): + """Successful API response prints JSON to stdout.""" + plans_data = [{"id": 1, "name": "Plan A"}, {"id": 2, "name": "Plan B"}] + mock_handler = mock_handler_cls.return_value + mock_handler.get_plans.return_value = (plans_data, None) + mock_api_client_cls.build_uploader_metadata.return_value = {} + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert '"Plan A"' in result.output + assert '"Plan B"' in result.output + mock_handler.get_plans.assert_called_once() + + @mock.patch("trcli.commands.cmd_get_plans.ApiRequestHandler") + @mock.patch("trcli.commands.cmd_get_plans.APIClient") + def test_api_error_exits_with_code_1(self, mock_api_client_cls, mock_handler_cls): + """API error message is output with exit code 1.""" + mock_handler = mock_handler_cls.return_value + mock_handler.get_plans.return_value = (None, "Could not connect to TestRail") + mock_api_client_cls.build_uploader_metadata.return_value = {} + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Could not connect to TestRail" in result.output + + def test_missing_project_id_exits_nonzero(self): + """Missing --project-id triggers an error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_plans", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "project ID" in result.output or "project-id" in result.output.lower() + + def test_missing_host_exits_nonzero(self): + """Missing -h triggers an error.""" + args = [ + "-u", "user@example.com", + "-p", "password123", + "--project-id", "1", + "get_plans", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "server address" in result.output + + def test_missing_username_exits_nonzero(self): + """Missing -u triggers an error.""" + args = [ + "-h", "https://test.testrail.com", + "-p", "password123", + "--project-id", "1", + "get_plans", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "username" in result.output + + def test_missing_password_and_key_exits_nonzero(self): + """Missing both -p and -k triggers an error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "--project-id", "1", + "get_plans", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "password" in result.output or "key" in result.output diff --git a/tests/test_cmd_get_suites.py b/tests/test_cmd_get_suites.py new file mode 100644 index 0000000..107ccbc --- /dev/null +++ b/tests/test_cmd_get_suites.py @@ -0,0 +1,91 @@ +from unittest import mock + +import pytest +from click.testing import CliRunner + +from trcli.api.api_client import APIClientResult +from trcli.cli import cli as trcli_cli + + +class TestCmdGetSuites: + """Tests for the get_suites CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_suites", + "--project-id", "1", + ] + + @mock.patch("trcli.commands.cmd_get_suites._create_api_client") + def test_happy_path_returns_json(self, mock_create_client): + """Successful API response prints JSON to stdout.""" + suites_data = [{"id": 1, "name": "Suite A"}, {"id": 2, "name": "Suite B"}] + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=suites_data, error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert '"Suite A"' in result.output + assert '"Suite B"' in result.output + mock_client.send_get.assert_called_once_with("get_suites/1") + + @mock.patch("trcli.commands.cmd_get_suites._create_api_client") + def test_api_error_message_exits_with_code_1(self, mock_create_client): + """API error message is output with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=-1, response_text="", error_message="Connection refused" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Connection refused" in result.output + + @mock.patch("trcli.commands.cmd_get_suites._create_api_client") + def test_api_non_200_status_exits_with_code_1(self, mock_create_client): + """Non-200 status code prints error with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=403, response_text="", error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "403" in result.output + + def test_missing_project_id_exits_nonzero(self): + """Missing --project-id triggers a Click error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_suites", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code != 0 + + def test_missing_host_exits_nonzero(self): + """Missing -h triggers an error.""" + args = [ + "-u", "user@example.com", + "-p", "password123", + "get_suites", + "--project-id", "1", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--host is required" in result.output diff --git a/trcli/api/api_request_handler.py b/trcli/api/api_request_handler.py index 7af9b2f..63c4b25 100644 --- a/trcli/api/api_request_handler.py +++ b/trcli/api/api_request_handler.py @@ -184,6 +184,15 @@ def resolve_suite_id_using_name(self, project_id: int) -> Tuple[int, str]: def get_suite_ids(self, project_id: int) -> Tuple[List[int], str]: return self.suite_handler.get_suite_ids(project_id) + def get_plans(self, project_id: int) -> Tuple[List[dict], str]: + """Get all plans for a project (paginated).""" + return self.__get_all_plans(project_id) + + def get_plan(self, plan_id: int) -> Tuple[dict, str]: + """Get a single plan by ID.""" + response = self.client.send_get(f"get_plan/{plan_id}") + return response.response_text, response.error_message + def add_suites(self, project_id: int) -> Tuple[List[Dict], str]: return self.suite_handler.add_suites(project_id, verify_callback=self.response_verifier.verify_returned_data) @@ -544,6 +553,18 @@ def fetch(): return self._cache.get_or_fetch(cache_key, fetch, params) + def __get_all_plans(self, project_id) -> Tuple[List[dict], str]: + """ + Get all plans from all pages (with caching) + """ + cache_key = f"get_plans/{project_id}" + params = (project_id,) + + def fetch(): + return self.__get_all_entities("plans", f"get_plans/{project_id}", entities=[]) + + return self._cache.get_or_fetch(cache_key, fetch, params) + def __get_all_entities(self, entity: str, link=None, entities=[]) -> Tuple[List[Dict], str]: """ Get all entities from all pages if number of entities is too big to return in single response. diff --git a/trcli/commands/cmd_get_case.py b/trcli/commands/cmd_get_case.py new file mode 100644 index 0000000..fed3d10 --- /dev/null +++ b/trcli/commands/cmd_get_case.py @@ -0,0 +1,68 @@ +import json +import sys + +import click + +import trcli +from trcli.api.api_client import APIClient +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment + + +def _create_api_client(environment: Environment) -> APIClient: + """Create an APIClient from the environment settings.""" + client_kwargs = { + "verbose_logging_function": environment.vlog, + "logging_function": environment.log, + "verify": not environment.insecure, + "proxy": environment.proxy, + "proxy_user": environment.proxy_user, + "noproxy": environment.noproxy, + "uploader_metadata": APIClient.build_uploader_metadata(version=trcli.__version__), + } + if environment.timeout: + client_kwargs["timeout"] = environment.timeout + + api_client = APIClient(environment.host, **client_kwargs) + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + return api_client + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.option( + "--case-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Test case ID to fetch.", +) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, **kwargs): + """Fetch a single test case by ID.""" + environment.cmd = "get_case" + environment.set_parameters(context) + + if not environment.host: + click.echo("Error: --host is required.", err=True) + sys.exit(1) + if not environment.username: + click.echo("Error: --username is required.", err=True) + sys.exit(1) + if not environment.password and not environment.key: + click.echo("Error: --password or --key is required.", err=True) + sys.exit(1) + + client = _create_api_client(environment) + response = client.send_get(f"get_case/{environment.case_id}") + + if response.error_message: + click.echo(f"Error: {response.error_message}", err=True) + sys.exit(1) + + if response.status_code != 200: + click.echo(f"Error: API returned status {response.status_code}", err=True) + sys.exit(1) + + click.echo(json.dumps(response.response_text, indent=2)) diff --git a/trcli/commands/cmd_get_cases.py b/trcli/commands/cmd_get_cases.py new file mode 100644 index 0000000..307abc6 --- /dev/null +++ b/trcli/commands/cmd_get_cases.py @@ -0,0 +1,118 @@ +import json +import sys + +import click + +import trcli +from trcli.api.api_client import APIClient +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment + + +def _create_api_client(environment: Environment) -> APIClient: + """Create an APIClient from the environment settings.""" + client_kwargs = { + "verbose_logging_function": environment.vlog, + "logging_function": environment.log, + "verify": not environment.insecure, + "proxy": environment.proxy, + "proxy_user": environment.proxy_user, + "noproxy": environment.noproxy, + "uploader_metadata": APIClient.build_uploader_metadata(version=trcli.__version__), + } + if environment.timeout: + client_kwargs["timeout"] = environment.timeout + + api_client = APIClient(environment.host, **client_kwargs) + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + return api_client + + +def _check_auth(environment: Environment): + """Validate required auth parameters are present.""" + if not environment.host: + click.echo("Error: --host is required.", err=True) + sys.exit(1) + if not environment.username: + click.echo("Error: --username is required.", err=True) + sys.exit(1) + if not environment.password and not environment.key: + click.echo("Error: --password or --key is required.", err=True) + sys.exit(1) + + +def _get_all_pages(client: APIClient, entity_key: str, initial_link: str): + """Fetch all pages for a paginated endpoint. + + Returns (data_list, error_message). + """ + response = client.send_get(initial_link) + if response.error_message: + return None, response.error_message + if response.status_code != 200: + return None, f"API returned status {response.status_code}" + + # Non-paginated (legacy): response is a plain list + if isinstance(response.response_text, list): + return response.response_text, None + + # Paginated: response is a dict with entity key and _links + entities = response.response_text.get(entity_key, []) + links = response.response_text.get("_links", {}) + while links.get("next") is not None: + next_link = links["next"] + response = client.send_get(next_link) + if response.error_message: + return None, response.error_message + if isinstance(response.response_text, list): + entities.extend(response.response_text) + break + entities.extend(response.response_text.get(entity_key, [])) + links = response.response_text.get("_links", {}) + + return entities, None + + +@click.command("get_cases", context_settings=CONTEXT_SETTINGS) +@click.option( + "--project-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Project ID to list cases for.", +) +@click.option( + "--suite-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Suite ID to list cases for.", +) +@click.option( + "--section-id", + type=click.IntRange(min=1), + default=None, + metavar="", + help="Optional section ID to filter cases.", +) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, **kwargs): + """List test cases for a project and suite.""" + environment.cmd = "get_cases" + environment.set_parameters(context) + _check_auth(environment) + + client = _create_api_client(environment) + + link = f"get_cases/{environment.project_id}&suite_id={environment.suite_id}" + if environment.section_id: + link += f"§ion_id={environment.section_id}" + + data, error = _get_all_pages(client, "cases", link) + if error: + click.echo(f"Error: {error}", err=True) + sys.exit(1) + + click.echo(json.dumps(data, indent=2)) diff --git a/trcli/commands/cmd_get_plan.py b/trcli/commands/cmd_get_plan.py new file mode 100644 index 0000000..029747f --- /dev/null +++ b/trcli/commands/cmd_get_plan.py @@ -0,0 +1,65 @@ +import json +import sys + +import click + +import trcli +from trcli.api.api_client import APIClient +from trcli.api.api_request_handler import ApiRequestHandler +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.data_classes.dataclass_testrail import TestRailSuite + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.option( + "--plan-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="ID of the test plan to retrieve.", +) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, *args, **kwargs): + """Retrieve a single test plan by ID.""" + environment.cmd = "get_plan" + environment.set_parameters(context) + + if not environment.host: + click.echo("Please provide a TestRail server address with the -h argument.", err=True) + sys.exit(1) + if not environment.username: + click.echo("Please provide a valid TestRail username using the -u argument.", err=True) + sys.exit(1) + if not environment.password and not environment.key: + click.echo("Please provide either a password using the -p argument or an API key using the -k argument.", err=True) + sys.exit(1) + if not environment.plan_id: + click.echo("Please provide a plan ID using the --plan-id argument.", err=True) + sys.exit(1) + + uploader_metadata = APIClient.build_uploader_metadata(version=trcli.__version__) + api_client = APIClient( + host_name=environment.host, + verify=not environment.insecure, + verbose_logging_function=environment.vlog, + logging_function=environment.log, + uploader_metadata=uploader_metadata, + ) + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + + minimal_suite = TestRailSuite(name="Plan Query", testsections=[]) + api_request_handler = ApiRequestHandler( + environment=environment, + api_client=api_client, + suites_data=minimal_suite, + ) + + data, error_message = api_request_handler.get_plan(environment.plan_id) + if error_message: + click.echo(error_message, err=True) + sys.exit(1) + + click.echo(json.dumps(data, indent=2)) diff --git a/trcli/commands/cmd_get_plans.py b/trcli/commands/cmd_get_plans.py new file mode 100644 index 0000000..76d6dd2 --- /dev/null +++ b/trcli/commands/cmd_get_plans.py @@ -0,0 +1,58 @@ +import json +import sys + +import click + +import trcli +from trcli.api.api_client import APIClient +from trcli.api.api_request_handler import ApiRequestHandler +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.data_classes.dataclass_testrail import TestRailSuite + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, *args, **kwargs): + """List all test plans for a project.""" + environment.cmd = "get_plans" + environment.set_parameters(context) + + if not environment.host: + click.echo("Please provide a TestRail server address with the -h argument.", err=True) + sys.exit(1) + if not environment.username: + click.echo("Please provide a valid TestRail username using the -u argument.", err=True) + sys.exit(1) + if not environment.password and not environment.key: + click.echo("Please provide either a password using the -p argument or an API key using the -k argument.", err=True) + sys.exit(1) + if not environment.project_id: + click.echo("Please provide a project ID using the --project-id argument.", err=True) + sys.exit(1) + + uploader_metadata = APIClient.build_uploader_metadata(version=trcli.__version__) + api_client = APIClient( + host_name=environment.host, + verify=not environment.insecure, + verbose_logging_function=environment.vlog, + logging_function=environment.log, + uploader_metadata=uploader_metadata, + ) + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + + minimal_suite = TestRailSuite(name="Plans Query", testsections=[]) + api_request_handler = ApiRequestHandler( + environment=environment, + api_client=api_client, + suites_data=minimal_suite, + ) + + data, error_message = api_request_handler.get_plans(environment.project_id) + if error_message: + click.echo(error_message, err=True) + sys.exit(1) + + click.echo(json.dumps(data, indent=2)) diff --git a/trcli/commands/cmd_get_suites.py b/trcli/commands/cmd_get_suites.py new file mode 100644 index 0000000..90ed303 --- /dev/null +++ b/trcli/commands/cmd_get_suites.py @@ -0,0 +1,68 @@ +import json +import sys + +import click + +import trcli +from trcli.api.api_client import APIClient +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment + + +def _create_api_client(environment: Environment) -> APIClient: + """Create an APIClient from the environment settings.""" + client_kwargs = { + "verbose_logging_function": environment.vlog, + "logging_function": environment.log, + "verify": not environment.insecure, + "proxy": environment.proxy, + "proxy_user": environment.proxy_user, + "noproxy": environment.noproxy, + "uploader_metadata": APIClient.build_uploader_metadata(version=trcli.__version__), + } + if environment.timeout: + client_kwargs["timeout"] = environment.timeout + + api_client = APIClient(environment.host, **client_kwargs) + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + return api_client + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.option( + "--project-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Project ID to list suites for.", +) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, **kwargs): + """List all test suites for a project.""" + environment.cmd = "get_suites" + environment.set_parameters(context) + + if not environment.host: + click.echo("Error: --host is required.", err=True) + sys.exit(1) + if not environment.username: + click.echo("Error: --username is required.", err=True) + sys.exit(1) + if not environment.password and not environment.key: + click.echo("Error: --password or --key is required.", err=True) + sys.exit(1) + + client = _create_api_client(environment) + response = client.send_get(f"get_suites/{environment.project_id}") + + if response.error_message: + click.echo(f"Error: {response.error_message}", err=True) + sys.exit(1) + + if response.status_code != 200: + click.echo(f"Error: API returned status {response.status_code}", err=True) + sys.exit(1) + + click.echo(json.dumps(response.response_text, indent=2)) From 4c5015aec0e5fec5314b2701c8e523b1b90d3255 Mon Sep 17 00:00:00 2001 From: "Chris.Weber" Date: Wed, 3 Jun 2026 14:36:15 -0500 Subject: [PATCH 3/3] refactor: harden read command behavior --- README.md | 10 ++- tests/test_cmd_get_case.py | 10 +-- tests/test_cmd_get_cases.py | 20 +++-- tests/test_cmd_get_plan.py | 35 ++++---- tests/test_cmd_get_plans.py | 45 +++++----- tests/test_cmd_get_sections.py | 116 +++++++++++++++++++++++++ tests/test_cmd_get_suites.py | 10 +-- tests/test_data/cli_test_data.py | 6 ++ tests/test_read_command_helpers.py | 104 ++++++++++++++++++++++ trcli/api/api_request_handler.py | 21 ----- trcli/cli.py | 2 - trcli/commands/cmd_get_case.py | 55 ++---------- trcli/commands/cmd_get_cases.py | 100 ++++----------------- trcli/commands/cmd_get_plan.py | 52 ++--------- trcli/commands/cmd_get_plans.py | 59 +++---------- trcli/commands/cmd_get_sections.py | 42 +++++++++ trcli/commands/cmd_get_suites.py | 55 ++---------- trcli/commands/read_command_helpers.py | 101 +++++++++++++++++++++ trcli/constants.py | 6 ++ 19 files changed, 500 insertions(+), 349 deletions(-) create mode 100644 tests/test_cmd_get_sections.py create mode 100644 tests/test_read_command_helpers.py create mode 100644 trcli/commands/cmd_get_sections.py create mode 100644 trcli/commands/read_command_helpers.py diff --git a/README.md b/README.md index bddd581..5da4166 100644 --- a/README.md +++ b/README.md @@ -2484,7 +2484,7 @@ expand your test cases to cover specific business logic and workflows. Reading data from TestRail ----------------- -The TestRail CLI provides read-only commands to retrieve plans, suites, and cases from TestRail as JSON. +The TestRail CLI provides read-only commands to retrieve plans, suites, sections, and cases from TestRail as JSON. These commands output JSON directly to stdout, making them easy to integrate with other tools via piping (e.g., `jq`). @@ -2495,6 +2495,7 @@ via piping (e.g., `jq`). | `get_plans` | List all test plans for a project | | `get_plan` | Get a single test plan by ID | | `get_suites` | List all test suites for a project | +| `get_sections` | List test sections for a project or suite | | `get_cases` | List test cases for a project and suite | | `get_case` | Get a single test case by ID | @@ -2518,6 +2519,12 @@ trcli --host https://example.testrail.io --username user@example.com --key YOUR_ get_suites --project-id 1 ``` +**List test sections (with optional suite filter):** +```shell +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_sections --project-id 1 --suite-id 3 +``` + **List test cases (with optional section filter):** ```shell # All cases in a suite @@ -2542,6 +2549,7 @@ export TR_CLI_USERNAME=user@example.com export TR_CLI_KEY=YOUR_API_KEY trcli get_plans --project-id 1 +trcli get_sections --project-id 1 --suite-id 3 trcli get_case --case-id 5001 ``` diff --git a/tests/test_cmd_get_case.py b/tests/test_cmd_get_case.py index fddc0ee..bb5a4cd 100644 --- a/tests/test_cmd_get_case.py +++ b/tests/test_cmd_get_case.py @@ -1,3 +1,4 @@ +import json from unittest import mock import pytest @@ -18,7 +19,7 @@ class TestCmdGetCase: "--case-id", "99", ] - @mock.patch("trcli.commands.cmd_get_case._create_api_client") + @mock.patch("trcli.commands.cmd_get_case.create_api_client") def test_happy_path_returns_json(self, mock_create_client): """Successful API response prints JSON to stdout.""" case_data = {"id": 99, "title": "My Test Case", "section_id": 5} @@ -31,11 +32,10 @@ def test_happy_path_returns_json(self, mock_create_client): result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) assert result.exit_code == 0 - assert '"My Test Case"' in result.output - assert '"id": 99' in result.output + assert json.loads(result.output) == case_data mock_client.send_get.assert_called_once_with("get_case/99") - @mock.patch("trcli.commands.cmd_get_case._create_api_client") + @mock.patch("trcli.commands.cmd_get_case.create_api_client") def test_api_error_message_exits_with_code_1(self, mock_create_client): """API error message is output with exit code 1.""" mock_client = mock_create_client.return_value @@ -49,7 +49,7 @@ def test_api_error_message_exits_with_code_1(self, mock_create_client): assert result.exit_code == 1 assert "Connection refused" in result.output - @mock.patch("trcli.commands.cmd_get_case._create_api_client") + @mock.patch("trcli.commands.cmd_get_case.create_api_client") def test_api_non_200_status_exits_with_code_1(self, mock_create_client): """Non-200 status code prints error with exit code 1.""" mock_client = mock_create_client.return_value diff --git a/tests/test_cmd_get_cases.py b/tests/test_cmd_get_cases.py index e950579..fb47ab5 100644 --- a/tests/test_cmd_get_cases.py +++ b/tests/test_cmd_get_cases.py @@ -1,3 +1,4 @@ +import json from unittest import mock import pytest @@ -19,7 +20,7 @@ class TestCmdGetCases: "--suite-id", "10", ] - @mock.patch("trcli.commands.cmd_get_cases._create_api_client") + @mock.patch("trcli.commands.cmd_get_cases.create_api_client") def test_happy_path_returns_json(self, mock_create_client): """Successful API response prints JSON to stdout.""" cases_data = [{"id": 100, "title": "Login test"}, {"id": 101, "title": "Logout test"}] @@ -32,10 +33,9 @@ def test_happy_path_returns_json(self, mock_create_client): result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) assert result.exit_code == 0 - assert '"Login test"' in result.output - assert '"Logout test"' in result.output + assert json.loads(result.output) == cases_data - @mock.patch("trcli.commands.cmd_get_cases._create_api_client") + @mock.patch("trcli.commands.cmd_get_cases.create_api_client") def test_happy_path_paginated(self, mock_create_client): """Paginated API response collects all pages.""" page1 = { @@ -56,11 +56,13 @@ def test_happy_path_paginated(self, mock_create_client): result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) assert result.exit_code == 0 - assert '"Case 1"' in result.output - assert '"Case 2"' in result.output + assert json.loads(result.output) == [ + {"id": 1, "title": "Case 1"}, + {"id": 2, "title": "Case 2"}, + ] assert mock_client.send_get.call_count == 2 - @mock.patch("trcli.commands.cmd_get_cases._create_api_client") + @mock.patch("trcli.commands.cmd_get_cases.create_api_client") def test_with_section_id_filter(self, mock_create_client): """Optional --section-id is appended to the API URL.""" cases_data = [{"id": 100, "title": "Filtered case"}] @@ -77,7 +79,7 @@ def test_with_section_id_filter(self, mock_create_client): call_args = mock_client.send_get.call_args[0][0] assert "section_id=5" in call_args - @mock.patch("trcli.commands.cmd_get_cases._create_api_client") + @mock.patch("trcli.commands.cmd_get_cases.create_api_client") def test_api_error_message_exits_with_code_1(self, mock_create_client): """API error message is output with exit code 1.""" mock_client = mock_create_client.return_value @@ -91,7 +93,7 @@ def test_api_error_message_exits_with_code_1(self, mock_create_client): assert result.exit_code == 1 assert "Timeout" in result.output - @mock.patch("trcli.commands.cmd_get_cases._create_api_client") + @mock.patch("trcli.commands.cmd_get_cases.create_api_client") def test_api_non_200_status_exits_with_code_1(self, mock_create_client): """Non-200 status code prints error with exit code 1.""" mock_client = mock_create_client.return_value diff --git a/tests/test_cmd_get_plan.py b/tests/test_cmd_get_plan.py index 3fd6b6a..803d970 100644 --- a/tests/test_cmd_get_plan.py +++ b/tests/test_cmd_get_plan.py @@ -1,8 +1,10 @@ +import json from unittest import mock import pytest from click.testing import CliRunner +from trcli.api.api_client import APIClientResult from trcli.cli import cli as trcli_cli @@ -17,30 +19,29 @@ class TestCmdGetPlan: "--plan-id", "42", ] - @mock.patch("trcli.commands.cmd_get_plan.ApiRequestHandler") - @mock.patch("trcli.commands.cmd_get_plan.APIClient") - def test_happy_path_returns_json(self, mock_api_client_cls, mock_handler_cls): + @mock.patch("trcli.commands.cmd_get_plan.create_api_client") + def test_happy_path_returns_json(self, mock_create_client): """Successful API response prints JSON to stdout.""" plan_data = {"id": 42, "name": "My Plan", "entries": []} - mock_handler = mock_handler_cls.return_value - mock_handler.get_plan.return_value = (plan_data, None) - mock_api_client_cls.build_uploader_metadata.return_value = {} + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=plan_data, error_message="" + ) runner = CliRunner() result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) assert result.exit_code == 0 - assert '"My Plan"' in result.output - assert '"id": 42' in result.output - mock_handler.get_plan.assert_called_once() + assert json.loads(result.output) == plan_data + mock_client.send_get.assert_called_once_with("get_plan/42") - @mock.patch("trcli.commands.cmd_get_plan.ApiRequestHandler") - @mock.patch("trcli.commands.cmd_get_plan.APIClient") - def test_api_error_exits_with_code_1(self, mock_api_client_cls, mock_handler_cls): + @mock.patch("trcli.commands.cmd_get_plan.create_api_client") + def test_api_error_exits_with_code_1(self, mock_create_client): """API error is output with exit code 1.""" - mock_handler = mock_handler_cls.return_value - mock_handler.get_plan.return_value = (None, "Plan not found") - mock_api_client_cls.build_uploader_metadata.return_value = {} + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=400, response_text={"error": "Plan not found"}, error_message="Plan not found" + ) runner = CliRunner() result = runner.invoke(trcli_cli, self.BASE_ARGS) @@ -73,7 +74,7 @@ def test_missing_host_exits_nonzero(self): result = runner.invoke(trcli_cli, args) assert result.exit_code == 1 - assert "server address" in result.output + assert "--host is required" in result.output def test_missing_password_and_key_exits_nonzero(self): """Missing both -p and -k triggers an error.""" @@ -87,4 +88,4 @@ def test_missing_password_and_key_exits_nonzero(self): result = runner.invoke(trcli_cli, args) assert result.exit_code == 1 - assert "password" in result.output or "key" in result.output + assert "--password or --key is required" in result.output diff --git a/tests/test_cmd_get_plans.py b/tests/test_cmd_get_plans.py index 8e0d949..504991c 100644 --- a/tests/test_cmd_get_plans.py +++ b/tests/test_cmd_get_plans.py @@ -1,8 +1,10 @@ +import json from unittest import mock import pytest from click.testing import CliRunner +from trcli.api.api_client import APIClientResult from trcli.cli import cli as trcli_cli @@ -13,34 +15,33 @@ class TestCmdGetPlans: "-h", "https://test.testrail.com", "-u", "user@example.com", "-p", "password123", - "--project-id", "1", "get_plans", + "--project-id", "1", ] - @mock.patch("trcli.commands.cmd_get_plans.ApiRequestHandler") - @mock.patch("trcli.commands.cmd_get_plans.APIClient") - def test_happy_path_returns_json(self, mock_api_client_cls, mock_handler_cls): + @mock.patch("trcli.commands.cmd_get_plans.create_api_client") + def test_happy_path_returns_json(self, mock_create_client): """Successful API response prints JSON to stdout.""" plans_data = [{"id": 1, "name": "Plan A"}, {"id": 2, "name": "Plan B"}] - mock_handler = mock_handler_cls.return_value - mock_handler.get_plans.return_value = (plans_data, None) - mock_api_client_cls.build_uploader_metadata.return_value = {} + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=plans_data, error_message="" + ) runner = CliRunner() result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) assert result.exit_code == 0 - assert '"Plan A"' in result.output - assert '"Plan B"' in result.output - mock_handler.get_plans.assert_called_once() + assert json.loads(result.output) == plans_data + mock_client.send_get.assert_called_once_with("get_plans/1") - @mock.patch("trcli.commands.cmd_get_plans.ApiRequestHandler") - @mock.patch("trcli.commands.cmd_get_plans.APIClient") - def test_api_error_exits_with_code_1(self, mock_api_client_cls, mock_handler_cls): + @mock.patch("trcli.commands.cmd_get_plans.create_api_client") + def test_api_error_exits_with_code_1(self, mock_create_client): """API error message is output with exit code 1.""" - mock_handler = mock_handler_cls.return_value - mock_handler.get_plans.return_value = (None, "Could not connect to TestRail") - mock_api_client_cls.build_uploader_metadata.return_value = {} + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=-1, response_text="", error_message="Could not connect to TestRail" + ) runner = CliRunner() result = runner.invoke(trcli_cli, self.BASE_ARGS) @@ -59,7 +60,7 @@ def test_missing_project_id_exits_nonzero(self): runner = CliRunner() result = runner.invoke(trcli_cli, args) - assert result.exit_code == 1 + assert result.exit_code == 2 assert "project ID" in result.output or "project-id" in result.output.lower() def test_missing_host_exits_nonzero(self): @@ -67,22 +68,22 @@ def test_missing_host_exits_nonzero(self): args = [ "-u", "user@example.com", "-p", "password123", - "--project-id", "1", "get_plans", + "--project-id", "1", ] runner = CliRunner() result = runner.invoke(trcli_cli, args) assert result.exit_code == 1 - assert "server address" in result.output + assert "--host is required" in result.output def test_missing_username_exits_nonzero(self): """Missing -u triggers an error.""" args = [ "-h", "https://test.testrail.com", "-p", "password123", - "--project-id", "1", "get_plans", + "--project-id", "1", ] runner = CliRunner() result = runner.invoke(trcli_cli, args) @@ -95,11 +96,11 @@ def test_missing_password_and_key_exits_nonzero(self): args = [ "-h", "https://test.testrail.com", "-u", "user@example.com", - "--project-id", "1", "get_plans", + "--project-id", "1", ] runner = CliRunner() result = runner.invoke(trcli_cli, args) assert result.exit_code == 1 - assert "password" in result.output or "key" in result.output + assert "--password or --key is required" in result.output diff --git a/tests/test_cmd_get_sections.py b/tests/test_cmd_get_sections.py new file mode 100644 index 0000000..4df1bea --- /dev/null +++ b/tests/test_cmd_get_sections.py @@ -0,0 +1,116 @@ +import json +from unittest import mock + +from click.testing import CliRunner + +from trcli.api.api_client import APIClientResult +from trcli.cli import cli as trcli_cli + + +class TestCmdGetSections: + """Tests for the get_sections CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_sections", + "--project-id", "1", + ] + + @mock.patch("trcli.commands.cmd_get_sections.create_api_client") + def test_happy_path_returns_json(self, mock_create_client): + sections_data = [{"id": 10, "name": "Login"}, {"id": 11, "name": "Checkout"}] + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=sections_data, error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == sections_data + mock_client.send_get.assert_called_once_with("get_sections/1") + + @mock.patch("trcli.commands.cmd_get_sections.create_api_client") + def test_with_suite_id_filter(self, mock_create_client): + sections_data = [{"id": 10, "name": "Suite Section"}] + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=sections_data, error_message="" + ) + + args = self.BASE_ARGS + ["--suite-id", "5"] + runner = CliRunner() + result = runner.invoke(trcli_cli, args, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == sections_data + mock_client.send_get.assert_called_once_with("get_sections/1&suite_id=5") + + @mock.patch("trcli.commands.cmd_get_sections.create_api_client") + def test_paginated_response_collects_all_pages(self, mock_create_client): + page1 = { + "sections": [{"id": 1, "name": "Section 1"}], + "_links": {"next": "get_sections/1&offset=250"}, + } + page2 = { + "sections": [{"id": 2, "name": "Section 2"}], + "_links": {"next": None}, + } + mock_client = mock_create_client.return_value + mock_client.send_get.side_effect = [ + APIClientResult(status_code=200, response_text=page1, error_message=""), + APIClientResult(status_code=200, response_text=page2, error_message=""), + ] + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == [ + {"id": 1, "name": "Section 1"}, + {"id": 2, "name": "Section 2"}, + ] + assert mock_client.send_get.call_count == 2 + + @mock.patch("trcli.commands.cmd_get_sections.create_api_client") + def test_api_error_message_exits_with_code_1(self, mock_create_client): + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=-1, response_text="", error_message="Connection refused" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Connection refused" in result.output + + def test_missing_project_id_exits_nonzero(self): + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_sections", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 2 + assert "project-id" in result.output.lower() + + def test_missing_host_exits_nonzero(self): + args = [ + "-u", "user@example.com", + "-p", "password123", + "get_sections", + "--project-id", + "1", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--host is required" in result.output diff --git a/tests/test_cmd_get_suites.py b/tests/test_cmd_get_suites.py index 107ccbc..61fffcf 100644 --- a/tests/test_cmd_get_suites.py +++ b/tests/test_cmd_get_suites.py @@ -1,3 +1,4 @@ +import json from unittest import mock import pytest @@ -18,7 +19,7 @@ class TestCmdGetSuites: "--project-id", "1", ] - @mock.patch("trcli.commands.cmd_get_suites._create_api_client") + @mock.patch("trcli.commands.cmd_get_suites.create_api_client") def test_happy_path_returns_json(self, mock_create_client): """Successful API response prints JSON to stdout.""" suites_data = [{"id": 1, "name": "Suite A"}, {"id": 2, "name": "Suite B"}] @@ -31,11 +32,10 @@ def test_happy_path_returns_json(self, mock_create_client): result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) assert result.exit_code == 0 - assert '"Suite A"' in result.output - assert '"Suite B"' in result.output + assert json.loads(result.output) == suites_data mock_client.send_get.assert_called_once_with("get_suites/1") - @mock.patch("trcli.commands.cmd_get_suites._create_api_client") + @mock.patch("trcli.commands.cmd_get_suites.create_api_client") def test_api_error_message_exits_with_code_1(self, mock_create_client): """API error message is output with exit code 1.""" mock_client = mock_create_client.return_value @@ -49,7 +49,7 @@ def test_api_error_message_exits_with_code_1(self, mock_create_client): assert result.exit_code == 1 assert "Connection refused" in result.output - @mock.patch("trcli.commands.cmd_get_suites._create_api_client") + @mock.patch("trcli.commands.cmd_get_suites.create_api_client") def test_api_non_200_status_exits_with_code_1(self, mock_create_client): """Non-200 status code prints error with exit code 1.""" mock_client = mock_create_client.return_value diff --git a/tests/test_data/cli_test_data.py b/tests/test_data/cli_test_data.py index 6cb09ea..aabc602 100644 --- a/tests/test_data/cli_test_data.py +++ b/tests/test_data/cli_test_data.py @@ -71,6 +71,12 @@ " - parse_robot: Robot Framework XML Files\n" " - parse_openapi: OpenAPI YML Files\n" " - add_run: Create a new test run\n" + " - get_plans: List test plans as JSON\n" + " - get_plan: Get a test plan as JSON\n" + " - get_suites: List test suites as JSON\n" + " - get_sections: List test sections as JSON\n" + " - get_cases: List test cases as JSON\n" + " - get_case: Get a test case as JSON\n" " - labels: Manage labels (projects, cases, and tests)\n" " - references: Manage references (cases and runs)\n" ) diff --git a/tests/test_read_command_helpers.py b/tests/test_read_command_helpers.py new file mode 100644 index 0000000..53d36f8 --- /dev/null +++ b/tests/test_read_command_helpers.py @@ -0,0 +1,104 @@ +from unittest import mock + +from trcli.api.api_client import APIClientResult +from trcli.cli import Environment +from trcli.commands.read_command_helpers import ( + build_endpoint, + create_api_client, + fetch_all_pages, + fetch_single_resource, +) + + +def test_build_endpoint_omits_none_values(): + assert build_endpoint("get_cases", 1, suite_id=2, section_id=None) == "get_cases/1&suite_id=2" + + +def test_fetch_single_resource_returns_error_for_non_200(): + client = mock.Mock() + client.send_get.return_value = APIClientResult(status_code=404, response_text="", error_message="") + + data, error = fetch_single_resource(client, "get_case/99") + + assert data is None + assert error == "API returned status 404" + + +def test_fetch_all_pages_collects_paginated_response(): + client = mock.Mock() + client.send_get.side_effect = [ + APIClientResult( + status_code=200, + response_text={"cases": [{"id": 1}], "_links": {"next": "get_cases/1&offset=250"}}, + error_message="", + ), + APIClientResult( + status_code=200, + response_text={"cases": [{"id": 2}], "_links": {"next": None}}, + error_message="", + ), + ] + + data, error = fetch_all_pages(client, "cases", "get_cases/1") + + assert error == "" + assert data == [{"id": 1}, {"id": 2}] + assert client.send_get.call_args_list == [mock.call("get_cases/1"), mock.call("get_cases/1&offset=250")] + + +def test_fetch_all_pages_supports_legacy_list_response(): + client = mock.Mock() + client.send_get.return_value = APIClientResult( + status_code=200, + response_text=[{"id": 1}, {"id": 2}], + error_message="", + ) + + data, error = fetch_all_pages(client, "suites", "get_suites/1") + + assert error == "" + assert data == [{"id": 1}, {"id": 2}] + + +def test_fetch_all_pages_returns_error_from_later_page(): + client = mock.Mock() + client.send_get.side_effect = [ + APIClientResult( + status_code=200, + response_text={"cases": [{"id": 1}], "_links": {"next": "get_cases/1&offset=250"}}, + error_message="", + ), + APIClientResult(status_code=-1, response_text="", error_message="Connection refused"), + ] + + data, error = fetch_all_pages(client, "cases", "get_cases/1") + + assert data is None + assert error == "Connection refused" + + +@mock.patch("trcli.commands.read_command_helpers.APIClient") +def test_create_api_client_passes_connection_options(mock_api_client_cls): + environment = Environment() + environment.host = "https://test.testrail.com" + environment.username = "user@example.com" + environment.password = "password" + environment.key = "api-key" + environment.insecure = True + environment.proxy = "http://proxy.example.com:8080" + environment.proxy_user = "proxy-user:proxy-password" + environment.noproxy = "localhost,127.0.0.1" + environment.timeout = 12.5 + + client = create_api_client(environment) + + mock_api_client_cls.assert_called_once() + _, kwargs = mock_api_client_cls.call_args + assert kwargs["verify"] is False + assert kwargs["proxy"] == "http://proxy.example.com:8080" + assert kwargs["proxy_user"] == "proxy-user:proxy-password" + assert kwargs["noproxy"] == "localhost,127.0.0.1" + assert kwargs["timeout"] == 12.5 + assert client.username == "user@example.com" + assert client.password == "password" + assert client.api_key == "api-key" diff --git a/trcli/api/api_request_handler.py b/trcli/api/api_request_handler.py index 63c4b25..7af9b2f 100644 --- a/trcli/api/api_request_handler.py +++ b/trcli/api/api_request_handler.py @@ -184,15 +184,6 @@ def resolve_suite_id_using_name(self, project_id: int) -> Tuple[int, str]: def get_suite_ids(self, project_id: int) -> Tuple[List[int], str]: return self.suite_handler.get_suite_ids(project_id) - def get_plans(self, project_id: int) -> Tuple[List[dict], str]: - """Get all plans for a project (paginated).""" - return self.__get_all_plans(project_id) - - def get_plan(self, plan_id: int) -> Tuple[dict, str]: - """Get a single plan by ID.""" - response = self.client.send_get(f"get_plan/{plan_id}") - return response.response_text, response.error_message - def add_suites(self, project_id: int) -> Tuple[List[Dict], str]: return self.suite_handler.add_suites(project_id, verify_callback=self.response_verifier.verify_returned_data) @@ -553,18 +544,6 @@ def fetch(): return self._cache.get_or_fetch(cache_key, fetch, params) - def __get_all_plans(self, project_id) -> Tuple[List[dict], str]: - """ - Get all plans from all pages (with caching) - """ - cache_key = f"get_plans/{project_id}" - params = (project_id,) - - def fetch(): - return self.__get_all_entities("plans", f"get_plans/{project_id}", entities=[]) - - return self._cache.get_or_fetch(cache_key, fetch, params) - def __get_all_entities(self, entity: str, link=None, entities=[]) -> Tuple[List[Dict], str]: """ Get all entities from all pages if number of entities is too big to return in single response. diff --git a/trcli/cli.py b/trcli/cli.py index 2433471..a16b79e 100755 --- a/trcli/cli.py +++ b/trcli/cli.py @@ -313,8 +313,6 @@ class TRCLI(click.MultiCommand): def __init__(self, *args, **kwargs): # Use invoke_without_command=True to be able to print # short tool description when starting without parameters - print(TOOL_VERSION) - # Check for updates (non-blocking) try: update_message = check_for_updates(__version__) diff --git a/trcli/commands/cmd_get_case.py b/trcli/commands/cmd_get_case.py index fed3d10..532cb38 100644 --- a/trcli/commands/cmd_get_case.py +++ b/trcli/commands/cmd_get_case.py @@ -1,32 +1,7 @@ -import json -import sys - import click -import trcli -from trcli.api.api_client import APIClient from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment - - -def _create_api_client(environment: Environment) -> APIClient: - """Create an APIClient from the environment settings.""" - client_kwargs = { - "verbose_logging_function": environment.vlog, - "logging_function": environment.log, - "verify": not environment.insecure, - "proxy": environment.proxy, - "proxy_user": environment.proxy_user, - "noproxy": environment.noproxy, - "uploader_metadata": APIClient.build_uploader_metadata(version=trcli.__version__), - } - if environment.timeout: - client_kwargs["timeout"] = environment.timeout - - api_client = APIClient(environment.host, **client_kwargs) - api_client.username = environment.username - api_client.password = environment.password - api_client.api_key = environment.key - return api_client +from trcli.commands.read_command_helpers import check_auth, create_api_client, emit_json, fail, fetch_single_resource @click.command(context_settings=CONTEXT_SETTINGS) @@ -43,26 +18,10 @@ def cli(environment: Environment, context: click.Context, **kwargs): """Fetch a single test case by ID.""" environment.cmd = "get_case" environment.set_parameters(context) + check_auth(environment) - if not environment.host: - click.echo("Error: --host is required.", err=True) - sys.exit(1) - if not environment.username: - click.echo("Error: --username is required.", err=True) - sys.exit(1) - if not environment.password and not environment.key: - click.echo("Error: --password or --key is required.", err=True) - sys.exit(1) - - client = _create_api_client(environment) - response = client.send_get(f"get_case/{environment.case_id}") - - if response.error_message: - click.echo(f"Error: {response.error_message}", err=True) - sys.exit(1) - - if response.status_code != 200: - click.echo(f"Error: API returned status {response.status_code}", err=True) - sys.exit(1) - - click.echo(json.dumps(response.response_text, indent=2)) + client = create_api_client(environment) + data, error = fetch_single_resource(client, f"get_case/{environment.case_id}") + if error: + fail(error) + emit_json(data) diff --git a/trcli/commands/cmd_get_cases.py b/trcli/commands/cmd_get_cases.py index 307abc6..faf7ff9 100644 --- a/trcli/commands/cmd_get_cases.py +++ b/trcli/commands/cmd_get_cases.py @@ -1,77 +1,14 @@ -import json -import sys - import click -import trcli -from trcli.api.api_client import APIClient from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment - - -def _create_api_client(environment: Environment) -> APIClient: - """Create an APIClient from the environment settings.""" - client_kwargs = { - "verbose_logging_function": environment.vlog, - "logging_function": environment.log, - "verify": not environment.insecure, - "proxy": environment.proxy, - "proxy_user": environment.proxy_user, - "noproxy": environment.noproxy, - "uploader_metadata": APIClient.build_uploader_metadata(version=trcli.__version__), - } - if environment.timeout: - client_kwargs["timeout"] = environment.timeout - - api_client = APIClient(environment.host, **client_kwargs) - api_client.username = environment.username - api_client.password = environment.password - api_client.api_key = environment.key - return api_client - - -def _check_auth(environment: Environment): - """Validate required auth parameters are present.""" - if not environment.host: - click.echo("Error: --host is required.", err=True) - sys.exit(1) - if not environment.username: - click.echo("Error: --username is required.", err=True) - sys.exit(1) - if not environment.password and not environment.key: - click.echo("Error: --password or --key is required.", err=True) - sys.exit(1) - - -def _get_all_pages(client: APIClient, entity_key: str, initial_link: str): - """Fetch all pages for a paginated endpoint. - - Returns (data_list, error_message). - """ - response = client.send_get(initial_link) - if response.error_message: - return None, response.error_message - if response.status_code != 200: - return None, f"API returned status {response.status_code}" - - # Non-paginated (legacy): response is a plain list - if isinstance(response.response_text, list): - return response.response_text, None - - # Paginated: response is a dict with entity key and _links - entities = response.response_text.get(entity_key, []) - links = response.response_text.get("_links", {}) - while links.get("next") is not None: - next_link = links["next"] - response = client.send_get(next_link) - if response.error_message: - return None, response.error_message - if isinstance(response.response_text, list): - entities.extend(response.response_text) - break - entities.extend(response.response_text.get(entity_key, [])) - links = response.response_text.get("_links", {}) - - return entities, None +from trcli.commands.read_command_helpers import ( + build_endpoint, + check_auth, + create_api_client, + emit_json, + fail, + fetch_all_pages, +) @click.command("get_cases", context_settings=CONTEXT_SETTINGS) @@ -102,17 +39,18 @@ def cli(environment: Environment, context: click.Context, **kwargs): """List test cases for a project and suite.""" environment.cmd = "get_cases" environment.set_parameters(context) - _check_auth(environment) - - client = _create_api_client(environment) + check_auth(environment) - link = f"get_cases/{environment.project_id}&suite_id={environment.suite_id}" - if environment.section_id: - link += f"§ion_id={environment.section_id}" + client = create_api_client(environment) + link = build_endpoint( + "get_cases", + environment.project_id, + suite_id=environment.suite_id, + section_id=environment.section_id, + ) - data, error = _get_all_pages(client, "cases", link) + data, error = fetch_all_pages(client, "cases", link) if error: - click.echo(f"Error: {error}", err=True) - sys.exit(1) + fail(error) - click.echo(json.dumps(data, indent=2)) + emit_json(data) diff --git a/trcli/commands/cmd_get_plan.py b/trcli/commands/cmd_get_plan.py index 029747f..e91fa58 100644 --- a/trcli/commands/cmd_get_plan.py +++ b/trcli/commands/cmd_get_plan.py @@ -1,13 +1,7 @@ -import json -import sys - import click -import trcli -from trcli.api.api_client import APIClient -from trcli.api.api_request_handler import ApiRequestHandler from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment -from trcli.data_classes.dataclass_testrail import TestRailSuite +from trcli.commands.read_command_helpers import check_auth, create_api_client, emit_json, fail, fetch_single_resource @click.command(context_settings=CONTEXT_SETTINGS) @@ -24,42 +18,10 @@ def cli(environment: Environment, context: click.Context, *args, **kwargs): """Retrieve a single test plan by ID.""" environment.cmd = "get_plan" environment.set_parameters(context) + check_auth(environment) - if not environment.host: - click.echo("Please provide a TestRail server address with the -h argument.", err=True) - sys.exit(1) - if not environment.username: - click.echo("Please provide a valid TestRail username using the -u argument.", err=True) - sys.exit(1) - if not environment.password and not environment.key: - click.echo("Please provide either a password using the -p argument or an API key using the -k argument.", err=True) - sys.exit(1) - if not environment.plan_id: - click.echo("Please provide a plan ID using the --plan-id argument.", err=True) - sys.exit(1) - - uploader_metadata = APIClient.build_uploader_metadata(version=trcli.__version__) - api_client = APIClient( - host_name=environment.host, - verify=not environment.insecure, - verbose_logging_function=environment.vlog, - logging_function=environment.log, - uploader_metadata=uploader_metadata, - ) - api_client.username = environment.username - api_client.password = environment.password - api_client.api_key = environment.key - - minimal_suite = TestRailSuite(name="Plan Query", testsections=[]) - api_request_handler = ApiRequestHandler( - environment=environment, - api_client=api_client, - suites_data=minimal_suite, - ) - - data, error_message = api_request_handler.get_plan(environment.plan_id) - if error_message: - click.echo(error_message, err=True) - sys.exit(1) - - click.echo(json.dumps(data, indent=2)) + client = create_api_client(environment) + data, error = fetch_single_resource(client, f"get_plan/{environment.plan_id}") + if error: + fail(error) + emit_json(data) diff --git a/trcli/commands/cmd_get_plans.py b/trcli/commands/cmd_get_plans.py index 76d6dd2..10b68ff 100644 --- a/trcli/commands/cmd_get_plans.py +++ b/trcli/commands/cmd_get_plans.py @@ -1,58 +1,27 @@ -import json -import sys - import click -import trcli -from trcli.api.api_client import APIClient -from trcli.api.api_request_handler import ApiRequestHandler from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment -from trcli.data_classes.dataclass_testrail import TestRailSuite +from trcli.commands.read_command_helpers import check_auth, create_api_client, emit_json, fail, fetch_all_pages @click.command(context_settings=CONTEXT_SETTINGS) +@click.option( + "--project-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Project ID to list plans for.", +) @click.pass_context @pass_environment def cli(environment: Environment, context: click.Context, *args, **kwargs): """List all test plans for a project.""" environment.cmd = "get_plans" environment.set_parameters(context) + check_auth(environment) - if not environment.host: - click.echo("Please provide a TestRail server address with the -h argument.", err=True) - sys.exit(1) - if not environment.username: - click.echo("Please provide a valid TestRail username using the -u argument.", err=True) - sys.exit(1) - if not environment.password and not environment.key: - click.echo("Please provide either a password using the -p argument or an API key using the -k argument.", err=True) - sys.exit(1) - if not environment.project_id: - click.echo("Please provide a project ID using the --project-id argument.", err=True) - sys.exit(1) - - uploader_metadata = APIClient.build_uploader_metadata(version=trcli.__version__) - api_client = APIClient( - host_name=environment.host, - verify=not environment.insecure, - verbose_logging_function=environment.vlog, - logging_function=environment.log, - uploader_metadata=uploader_metadata, - ) - api_client.username = environment.username - api_client.password = environment.password - api_client.api_key = environment.key - - minimal_suite = TestRailSuite(name="Plans Query", testsections=[]) - api_request_handler = ApiRequestHandler( - environment=environment, - api_client=api_client, - suites_data=minimal_suite, - ) - - data, error_message = api_request_handler.get_plans(environment.project_id) - if error_message: - click.echo(error_message, err=True) - sys.exit(1) - - click.echo(json.dumps(data, indent=2)) + client = create_api_client(environment) + data, error = fetch_all_pages(client, "plans", f"get_plans/{environment.project_id}") + if error: + fail(error) + emit_json(data) diff --git a/trcli/commands/cmd_get_sections.py b/trcli/commands/cmd_get_sections.py new file mode 100644 index 0000000..0e59fc0 --- /dev/null +++ b/trcli/commands/cmd_get_sections.py @@ -0,0 +1,42 @@ +import click + +from trcli.cli import CONTEXT_SETTINGS, Environment, pass_environment +from trcli.commands.read_command_helpers import ( + build_endpoint, + check_auth, + create_api_client, + emit_json, + fail, + fetch_all_pages, +) + + +@click.command("get_sections", context_settings=CONTEXT_SETTINGS) +@click.option( + "--project-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Project ID to list sections for.", +) +@click.option( + "--suite-id", + type=click.IntRange(min=1), + default=None, + metavar="", + help="Optional suite ID to list sections for.", +) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, **kwargs): + """List sections for a project.""" + environment.cmd = "get_sections" + environment.set_parameters(context) + check_auth(environment) + + client = create_api_client(environment) + link = build_endpoint("get_sections", environment.project_id, suite_id=environment.suite_id) + data, error = fetch_all_pages(client, "sections", link) + if error: + fail(error) + emit_json(data) diff --git a/trcli/commands/cmd_get_suites.py b/trcli/commands/cmd_get_suites.py index 90ed303..75738c9 100644 --- a/trcli/commands/cmd_get_suites.py +++ b/trcli/commands/cmd_get_suites.py @@ -1,32 +1,7 @@ -import json -import sys - import click -import trcli -from trcli.api.api_client import APIClient from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment - - -def _create_api_client(environment: Environment) -> APIClient: - """Create an APIClient from the environment settings.""" - client_kwargs = { - "verbose_logging_function": environment.vlog, - "logging_function": environment.log, - "verify": not environment.insecure, - "proxy": environment.proxy, - "proxy_user": environment.proxy_user, - "noproxy": environment.noproxy, - "uploader_metadata": APIClient.build_uploader_metadata(version=trcli.__version__), - } - if environment.timeout: - client_kwargs["timeout"] = environment.timeout - - api_client = APIClient(environment.host, **client_kwargs) - api_client.username = environment.username - api_client.password = environment.password - api_client.api_key = environment.key - return api_client +from trcli.commands.read_command_helpers import check_auth, create_api_client, emit_json, fail, fetch_all_pages @click.command(context_settings=CONTEXT_SETTINGS) @@ -43,26 +18,10 @@ def cli(environment: Environment, context: click.Context, **kwargs): """List all test suites for a project.""" environment.cmd = "get_suites" environment.set_parameters(context) + check_auth(environment) - if not environment.host: - click.echo("Error: --host is required.", err=True) - sys.exit(1) - if not environment.username: - click.echo("Error: --username is required.", err=True) - sys.exit(1) - if not environment.password and not environment.key: - click.echo("Error: --password or --key is required.", err=True) - sys.exit(1) - - client = _create_api_client(environment) - response = client.send_get(f"get_suites/{environment.project_id}") - - if response.error_message: - click.echo(f"Error: {response.error_message}", err=True) - sys.exit(1) - - if response.status_code != 200: - click.echo(f"Error: API returned status {response.status_code}", err=True) - sys.exit(1) - - click.echo(json.dumps(response.response_text, indent=2)) + client = create_api_client(environment) + data, error = fetch_all_pages(client, "suites", f"get_suites/{environment.project_id}") + if error: + fail(error) + emit_json(data) diff --git a/trcli/commands/read_command_helpers.py b/trcli/commands/read_command_helpers.py new file mode 100644 index 0000000..ebd1cb4 --- /dev/null +++ b/trcli/commands/read_command_helpers.py @@ -0,0 +1,101 @@ +import json +import sys +from urllib.parse import urlencode + +import click + +import trcli +from trcli.api.api_client import APIClient, APIClientResult +from trcli.cli import Environment + + +def create_api_client(environment: Environment) -> APIClient: + client_kwargs = { + "verbose_logging_function": environment.vlog, + "logging_function": environment.log, + "verify": not environment.insecure, + "proxy": environment.proxy, + "proxy_user": environment.proxy_user, + "noproxy": environment.noproxy, + "uploader_metadata": APIClient.build_uploader_metadata(version=trcli.__version__), + } + if environment.timeout: + client_kwargs["timeout"] = environment.timeout + + api_client = APIClient(environment.host, **client_kwargs) + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + return api_client + + +def check_auth(environment: Environment): + if not environment.host: + click.echo("Error: --host is required.", err=True) + sys.exit(1) + if not environment.username: + click.echo("Error: --username is required.", err=True) + sys.exit(1) + if not environment.password and not environment.key: + click.echo("Error: --password or --key is required.", err=True) + sys.exit(1) + + +def build_endpoint(endpoint: str, resource_id: int, **params) -> str: + query = urlencode({key: value for key, value in params.items() if value is not None}) + link = f"{endpoint}/{resource_id}" + if query: + link = f"{link}&{query}" + return link + + +def response_error(response: APIClientResult) -> str: + if response.error_message: + return response.error_message + if response.status_code != 200: + return f"API returned status {response.status_code}" + return "" + + +def fetch_single_resource(client: APIClient, link: str): + response = client.send_get(link) + error = response_error(response) + if error: + return None, error + return response.response_text, "" + + +def fetch_all_pages(client: APIClient, entity_key: str, initial_link: str): + response = client.send_get(initial_link) + error = response_error(response) + if error: + return None, error + + if isinstance(response.response_text, list): + return response.response_text, "" + + entities = response.response_text.get(entity_key, []) + links = response.response_text.get("_links", {}) + while links.get("next") is not None: + response = client.send_get(links["next"]) + error = response_error(response) + if error: + return None, error + + if isinstance(response.response_text, list): + entities.extend(response.response_text) + break + + entities.extend(response.response_text.get(entity_key, [])) + links = response.response_text.get("_links", {}) + + return entities, "" + + +def emit_json(data): + click.echo(json.dumps(data, indent=2)) + + +def fail(error: str): + click.echo(f"Error: {error}", err=True) + sys.exit(1) diff --git a/trcli/constants.py b/trcli/constants.py index c32fa68..39ed320 100644 --- a/trcli/constants.py +++ b/trcli/constants.py @@ -113,6 +113,12 @@ - parse_robot: Robot Framework XML Files - parse_openapi: OpenAPI YML Files - add_run: Create a new test run + - get_plans: List test plans as JSON + - get_plan: Get a test plan as JSON + - get_suites: List test suites as JSON + - get_sections: List test sections as JSON + - get_cases: List test cases as JSON + - get_case: Get a test case as JSON - labels: Manage labels (projects, cases, and tests) - references: Manage references (cases and runs)"""