From 6058b6543b7e12c3f763bfe53f81280765eed59d Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Thu, 28 Aug 2025 17:18:11 +0300 Subject: [PATCH 01/17] added helper method which can help to handle error and wrong status codes --- trcli/api/api_client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/trcli/api/api_client.py b/trcli/api/api_client.py index 9beecf66..1941b011 100644 --- a/trcli/api/api_client.py +++ b/trcli/api/api_client.py @@ -27,6 +27,13 @@ class APIClientResult: response_text: Union[Dict, str, List] error_message: str + def error_or_bad_code(self, expected_code=200) -> str: + if self.error_message: + return self.error_message + if self.status_code != expected_code: + return FAULT_MAPPING.get("api_request_has_failed").format(status_code=self.status_code) + return "" + class APIClient: """ From ca1fa85b0a12cc0c32db7733dd05d4d23543435d Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Thu, 28 Aug 2025 17:18:56 +0300 Subject: [PATCH 02/17] added field for new param --- trcli/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/trcli/cli.py b/trcli/cli.py index 3fa81ccb..5c5a1af5 100755 --- a/trcli/cli.py +++ b/trcli/cli.py @@ -72,6 +72,7 @@ def __init__(self, cmd="parse_junit"): self.proxy = None # Add proxy related attributes self.noproxy = None self.proxy_user = None + self.update_cases = False # Flag to indicate if cases should be updated @property def case_fields(self): From 2a80ccd1345a6a7ed97c3a6acfd0b287e9d2cc26 Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Thu, 28 Aug 2025 17:20:07 +0300 Subject: [PATCH 03/17] replaced with better way to validate cli params --- trcli/commands/cmd_add_run.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/trcli/commands/cmd_add_run.py b/trcli/commands/cmd_add_run.py index b2d270b4..1536a5bd 100644 --- a/trcli/commands/cmd_add_run.py +++ b/trcli/commands/cmd_add_run.py @@ -1,3 +1,5 @@ +from datetime import datetime + import click import yaml @@ -40,6 +42,13 @@ def write_run_to_file(environment: Environment, run_id: int): f.write(yaml.dump(data, default_flow_style=False)) environment.log("Done.") +def parse_date(value: str): + try: + dt = datetime.strptime(value, "%m/%d/%Y") + return int(dt.timestamp()) + except ValueError: + raise click.BadParameter(f"Invalid date format: '{value}'. Expected MM/DD/YYYY.") + @click.command(context_settings=CONTEXT_SETTINGS) @click.option("--title", metavar="", help="Title of Test Run to be created or updated in TestRail.") @@ -60,14 +69,14 @@ def write_run_to_file(environment: Environment, run_id: int): "--run-start-date", metavar="", default=None, - type=lambda x: [int(i) for i in x.split("/") if len(x.split("/")) == 3], + type=parse_date, help="The expected or scheduled start date of this test run in MM/DD/YYYY format" ) @click.option( "--run-end-date", metavar="", default=None, - type=lambda x: [int(i) for i in x.split("/") if len(x.split("/")) == 3], + type=parse_date, help="The expected or scheduled end date of this test run in MM/DD/YYYY format" ) @click.option( From 9c090aac0ac29abd1c46bf92b8b1f36124797098 Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Thu, 28 Aug 2025 17:21:14 +0300 Subject: [PATCH 04/17] added classes with constants for different types of messages --- trcli/constants.py | 94 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 9 deletions(-) diff --git a/trcli/constants.py b/trcli/constants.py index 70b96045..ee672d6d 100644 --- a/trcli/constants.py +++ b/trcli/constants.py @@ -13,6 +13,7 @@ missing_title="Please give your Test Run a title using the --title argument.", ) +#TODO Very inconvenient to use and maintain, consider refactoring and replacing with constants. FAULT_MAPPING = dict( invalid_file="Provided file is not a valid file.", missing_host="Please provide a TestRail server address with the -h argument.", @@ -63,7 +64,8 @@ proxy_bypass_error= "Failed to bypass the proxy for host. Please check the settings.", proxy_invalid_configuration= "The provided proxy configuration is invalid. Please check the proxy URL and format.", ssl_error_on_proxy= "SSL error encountered while using the HTTPS proxy. Please check the proxy's SSL certificate.", - no_proxy_match_error= "The host {host} does not match any NO_PROXY rules. Ensure the correct domains or IP addresses are specified for bypassing the proxy." + no_proxy_match_error= "The host {host} does not match any NO_PROXY rules. Ensure the correct domains or IP addresses are specified for bypassing the proxy.", + api_request_has_failed="API request has failed with status code {status_code}." ) COMMAND_FAULT_MAPPING = dict( @@ -108,16 +110,90 @@ class SuiteModes(enum.IntEnum): single_suite_baselines = 2 multiple_suites = 3 +class SkippingMessage: + SKIP_TEST_RUN_AND_RESULTS = "Skipping test run and results upload as per user request." + NO_TEST_CASES_TO_UPDATE = "No test cases to update. Skipping." + NO_TEST_CASES_TO_ADD = "No new test cases to add. Skipping." + + +class ProcessingMessages: + """ + Messages displayed during processing. + To make life easier: + F in constant name means that the constant is f-string + and following part of the name is arg(s) name(s) for formating + Number of F shows how many args are needed + """ + ADDING_SUITE_F_PROJECT_NAME = "Adding missing suite to project {project_name}." + CHECKING_PROJECT = "Checking project. " + CHECKING_SECTIONS = "Checking for missing sections in the suite. " + ADDING_SECTIONS = "Adding missing sections to the suite." + ADDING = "Adding. " + UPDATING = "Updating. " + UPDATING_TEST_RUN = "Updating test run. " + CREATING_TEST_RUN = "Creating test run. " + CLOSING_TEST_RUN = "Closing test run. " + CONTINUE_AS_STANDALONE_RUN = "No plan ID found in the existing run. Continue as standalone run. " + UPLOADING_ATTACHMENTS_FF_ATTACHMENTS_RESULTS = "Uploading {attachments} attachments for {results} test results." + + +class SuccessMessages: + """ + Messages displayed on successful operations. + To make life easier: + F in constant name means that the constant is f-string + and following part of the name is arg(s) name(s) for formating + Number of F shows how many args are needed + """ + ADDED_CASES_FF_AMOUNT_ELAPSED = "Submitted {amount} test cases in {elapsed:.1f} secs." + UPDATED_CASES_AMOUNT_FF_ACTUAL_EXPECTED = "Updated amount {actual}/{expected} test cases." + UPDATED_RUN_FF_LINK_RUN_ID = "Test run: {link}/index.php?/runs/view/{run_id}" + ADDED_RESULTS_FF_AMOUNT_ELAPSED = "Submitted {amount} test results in {elapsed:.1f} secs." + CLOSED_RUN = "Run closed successfully." + COMPLETED_IN_F_ELAPSED = "Completed in {elapsed:.1f} secs." + DONE = "Done." + + +class ErrorMessages: + """ + Messages displayed on errors. + To make life easier: + F in constant name means that the constant is f-string + and following part of the name is arg(s) name(s) for formating + Number of F shows how many args are needed + """ + CAN_NOT_RESOLVE_SUITE_F_ERROR = "Can not resolve suite: \n{error}" + CAN_NOT_ADD_SUITE_F_ERROR = "Can not add suite: \n{error}" + NO_SUITE_ID = "Suite ID is not provided and no suite found by name or created." + CREATING_UPDATING_TESTRUN_F_ERROR = "Error creating or updating test run: \n{error}" + SORTING_TEST_CASES_F_ERROR = "Error checking existing and missing test cases: \n{error}" + UPLOADING_SECTIONS_F_ERROR = "Error uploading sections: \n{error}" + CASES_UPDATE_F_ERROR = "Error updating test cases: \n{error}" + ADDING_TEST_CASES_F_ERROR = "Error adding test cases: \n{error}" + CLOSING_TEST_RUN_F_ERROR = "Error closing test run: \n{error}" + RETRIEVING_RUN_INFO_FF_RUN_ID_ERROR = "Error retrieving run by ID {run_id}: \n{error}" + RETRIEVING_TESTS_IN_IN_RUN_F_ERROR = "Error retrieving tests in run: \n{error}" + RETRIEVING_RUN_ID_IN_PLAN_F_ERROR ="Error retrieving run entry ID in plan: {error}" + FAILED_TO_DEFINE_AUTOMATION_ID_FIELD = "Failed to define automation_id field system name." + class RevertMessages: - suite_deleted = "Deleted created suite" - suite_not_deleted = "Unable to delete created suite: {error}" - section_deleted = "Deleted created section" - section_not_deleted = "Unable to delete created section: {error}" - test_cases_deleted = "Deleted created test cases" - test_cases_not_deleted = "Unable to delete created test cases: {error}" - run_deleted = "Deleted created run" - run_not_deleted = "Unable to delete created run: {error}" + """ + Messages displayed when reverting created entities in TestRail. + To make life easier: + F in constant name means that the constant is f-string + and following part of the name is arg(s) name(s) for formating + Number of F shows how many args are needed + """ + SUITE_DELETED = "Deleted created suite" + SUITE_NOT_DELETED_FF_SUITE_ID_ERROR = "Unable to delete created suite id {suite_id}: {error}" + SECTION_DELETED = "Deleted created section" + SECTION_NOT_DELETED_FF_SECTION_ID_ERROR = "Unable to delete created section {section_id}: {error}" + TEST_CASES_DELETED = "Deleted created test cases" + TEST_CASES_NOT_DELETED_F_ERROR = "Unable to delete created test cases: {error}" + RUN_DELETED = "Deleted created run" + RUN_NOT_DELETED_FF_RUN_ID_ERROR = "Unable to delete created run with id {run_id}: {error}" + OLD_SYSTEM_NAME_AUTOMATION_ID = "custom_automation_id" # field name mismatch on testrail side (can not reproduce in cloud version TestRail v9.1.2) From 68c8b92882d5e0488f48f7dda7b0050e9b244f9d Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Thu, 28 Aug 2025 17:23:44 +0300 Subject: [PATCH 05/17] new data class for test run and changes in existing: supporting subsections, removed redundant methods, updated fields meta --- trcli/data_classes/dataclass_testrail.py | 38 ++++++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/trcli/data_classes/dataclass_testrail.py b/trcli/data_classes/dataclass_testrail.py index 238d679e..8abbef85 100644 --- a/trcli/data_classes/dataclass_testrail.py +++ b/trcli/data_classes/dataclass_testrail.py @@ -40,6 +40,7 @@ class TestRailResult: custom_step_results: List[TestRailSeparatedStep] = field(default_factory=list, skip_if_default=True) def __post_init__(self): + #TODO Remove result handling, it's redundant and this is not the place for it if self.junit_result_unparsed is not None: self.status_id = self.calculate_status_id_from_junit_element( self.junit_result_unparsed @@ -126,7 +127,7 @@ class TestRailCase: title: str section_id: int = field(default=None, skip_if_default=True) - case_id: int = field(default=None, skip_if_default=True) + case_id: int = field(default=None, skip_if_default=True, metadata={"serde_skip": True}) estimate: str = field(default=None, skip_if_default=True) template_id: int = field(default=None, skip_if_default=True) type_id: int = field(default=None, skip_if_default=True) @@ -134,7 +135,7 @@ class TestRailCase: refs: str = field(default=None, skip_if_default=True) case_fields: Optional[dict] = field(default_factory=dict, skip=True) result: TestRailResult = field(default=None, metadata={"serde_skip": True}) - custom_automation_id: str = field(default=None, skip_if_default=True) + custom_automation_id: Optional[str] = field(default=None, metadata={"serde_skip": True}) # Uncomment if we want to support separated steps in cases in the future # custom_steps_separated: List[TestRailSeparatedStep] = field(default_factory=list, skip_if_default=True) @@ -151,11 +152,8 @@ def __post_init__(self): class_name=self.__class__.__name__, reason="Title is empty.", ) - # Fallback logic for custom_case_automation_id via dynamic attribute assignment if self.custom_automation_id: self.custom_automation_id = self.custom_automation_id.strip() - elif hasattr(self, "custom_case_automation_id") and self.custom_case_automation_id: - self.custom_automation_id = self.custom_case_automation_id.strip() def add_global_case_fields(self, case_fields: dict) -> None: """Add global case fields without overriding the existing case-specific fields @@ -210,6 +208,9 @@ class TestRailSection: default_factory=list, metadata={"serde_skip": True} ) + sub_sections: List = field( + default_factory=list, metadata={"serde_skip": True}) + def __getitem__(self, item): return getattr(self, item) @@ -241,8 +242,33 @@ def __post_init__(self): self.name = f"{self.source} {current_time}" if self.name is None else self.name +@serialize +@deserialize +@dataclass +class TestRun: + """Class for creating Test Rail test run""" + + name: str = field(default=None, skip_if_default=True) + description: str = field(default=None, skip_if_default=True) + suite_id: int = field(default=None, skip_if_default=True) + milestone_id: int = field(default=None, skip_if_default=True) + assignedto_id: int = field(default=None, skip_if_default=True) + include_all: bool = field(default=False, skip_if_default=False) + case_ids: List[int] = field(default_factory=list, skip_if_default=True) + refs: str = field(default=None, skip_if_default=True) + start_on: str = field(default=None, skip_if_default=True) + due_on: str = field(default=None, skip_if_default=True) + run_fields: Optional[dict] = field(default_factory=dict, skip=True) + + def to_dict(self) -> dict: + case_dict = to_dict(self) + case_dict.update(self.run_fields) + return case_dict + + @dataclass class ProjectData: project_id: int + name: str suite_mode: int - error_message: str + error_message: str \ No newline at end of file From 3b78b6a3eb824b251bdebc9029019d6f13d71f72 Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Thu, 28 Aug 2025 17:24:18 +0300 Subject: [PATCH 06/17] added nested suites support --- trcli/readers/junit_xml.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/trcli/readers/junit_xml.py b/trcli/readers/junit_xml.py index 36bc64fe..8f6fe049 100644 --- a/trcli/readers/junit_xml.py +++ b/trcli/readers/junit_xml.py @@ -188,11 +188,7 @@ def _resolve_case_fields(self, result_fields, case_fields): def _parse_test_cases(self, section) -> List[TestRailCase]: test_cases = [] - for case in section: - """ - TODO: use section.iterchildren(JUnitTestCase) to get only testcases belonging to the section - required for nested suites - """ + for case in section.iterchildren(JUnitTestCase): automation_id = f"{case.classname}.{case.name}" case_id, case_name = self._extract_case_id_and_name(case) result_steps, attachments, result_fields, comments, case_fields, sauce_session = self._parse_case_properties( @@ -246,12 +242,9 @@ def _parse_sections(self, suite) -> List[TestRailSection]: if isinstance(section, JUnitTestSuite): if not len(section): continue - """ - TODO: Handle nested suites if needed (add sub_sections to data class TestRailSection) + inner_suites = section.testsuites() sub_sections = self._parse_sections(inner_suites) - then sub_sections=sub_sections - """ properties = self._extract_section_properties(section, processed_props) test_cases = self._parse_test_cases(section) self.env.log(f"Processed {len(test_cases)} test cases in section {section.name}.") @@ -259,6 +252,7 @@ def _parse_sections(self, suite) -> List[TestRailSection]: section.name, testcases=test_cases, properties=properties, + sub_sections=sub_sections )) return sections From 2a2845f6f03e3124dcc859e570053a30639ccf42 Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Thu, 28 Aug 2025 17:25:40 +0300 Subject: [PATCH 07/17] added new boolean param to allow updating existing cases --- trcli/commands/results_parser_helpers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/trcli/commands/results_parser_helpers.py b/trcli/commands/results_parser_helpers.py index f3a3c28a..8cf9f396 100644 --- a/trcli/commands/results_parser_helpers.py +++ b/trcli/commands/results_parser_helpers.py @@ -95,6 +95,13 @@ def results_parser_options(f): help="List of result fields and values for test results creation. " "Usage: --result-fields custom_field_a:value1 --result-fields custom_field_b:3", ) + @click.option( + "--update-cases", + is_flag=True, + default=False, + metavar="", + help="Use this option if you want to update already existing test cases with data provided from parsed testsuite." + ) @click.option("--allow-ms", is_flag=True, help="Allows using milliseconds for elapsed times.") @functools.wraps(f) def wrapper_common_options(*args, **kwargs): From eb647512eac182d95d06334059813aca3265af23 Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Thu, 28 Aug 2025 17:27:15 +0300 Subject: [PATCH 08/17] renaming --- tests/test_data/results_provider_test_data.py | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/test_data/results_provider_test_data.py b/tests/test_data/results_provider_test_data.py index 73ec6dc2..dfe54fc3 100644 --- a/tests/test_data/results_provider_test_data.py +++ b/tests/test_data/results_provider_test_data.py @@ -83,10 +83,10 @@ ( "delete_suite", [ - RevertMessages.run_deleted, - RevertMessages.test_cases_deleted, - RevertMessages.section_deleted, - RevertMessages.suite_not_deleted.format( + RevertMessages.RUN_DELETED, + RevertMessages.TEST_CASES_DELETED, + RevertMessages.SECTION_DELETED, + RevertMessages.SUITE_NOT_DELETED_FF_SUITE_ID_ERROR.format( error="No permissions to delete suite." ), ], @@ -94,34 +94,34 @@ ( "delete_sections", [ - RevertMessages.run_deleted, - RevertMessages.test_cases_deleted, - RevertMessages.section_not_deleted.format( + RevertMessages.RUN_DELETED, + RevertMessages.TEST_CASES_DELETED, + RevertMessages.SECTION_NOT_DELETED_FF_SECTION_ID_ERROR.format( error="No permissions to delete sections." ), - RevertMessages.suite_deleted, + RevertMessages.SUITE_DELETED, ], ), ( "delete_cases", [ - RevertMessages.run_deleted, - RevertMessages.test_cases_not_deleted.format( + RevertMessages.RUN_DELETED, + RevertMessages.TEST_CASES_NOT_DELETED_F_ERROR.format( error="No permissions to delete cases." ), - RevertMessages.section_deleted, - RevertMessages.suite_deleted, + RevertMessages.SECTION_DELETED, + RevertMessages.SUITE_DELETED, ], ), ( "delete_run", [ - RevertMessages.run_not_deleted.format( + RevertMessages.RUN_NOT_DELETED_FF_RUN_ID_ERROR.format( error="No permissions to delete run." ), - RevertMessages.test_cases_deleted, - RevertMessages.section_deleted, - RevertMessages.suite_deleted, + RevertMessages.TEST_CASES_DELETED, + RevertMessages.SECTION_DELETED, + RevertMessages.SUITE_DELETED, ], ), ] @@ -137,9 +137,9 @@ ( "delete_sections", [ - RevertMessages.run_deleted, - RevertMessages.test_cases_deleted, - RevertMessages.section_not_deleted.format( + RevertMessages.RUN_DELETED, + RevertMessages.TEST_CASES_DELETED, + RevertMessages.SECTION_NOT_DELETED_FF_SECTION_ID_ERROR.format( error="No permissions to delete sections." ), ], @@ -147,21 +147,21 @@ ( "delete_cases", [ - RevertMessages.run_deleted, - RevertMessages.test_cases_not_deleted.format( + RevertMessages.RUN_DELETED, + RevertMessages.TEST_CASES_NOT_DELETED_F_ERROR.format( error="No permissions to delete cases." ), - RevertMessages.section_deleted, + RevertMessages.SECTION_DELETED, ], ), ( "delete_run", [ - RevertMessages.run_not_deleted.format( + RevertMessages.RUN_NOT_DELETED_FF_RUN_ID_ERROR.format( error="No permissions to delete run." ), - RevertMessages.test_cases_deleted, - RevertMessages.section_deleted, + RevertMessages.TEST_CASES_DELETED, + RevertMessages.SECTION_DELETED, ], ), ] From 0212c2f8a72edd0e59158e97ced11a205814c600 Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Thu, 28 Aug 2025 17:29:32 +0300 Subject: [PATCH 09/17] refactored and newly created packages --- trcli/api/api_request_handler_v2.py | 401 +++++++++++ trcli/api/api_request_helpers.py | 660 +++++++++++++++++++ trcli/api/project_based_client_v2.py | 183 +++++ trcli/api/results_uploader_v2.py | 305 +++++++++ trcli/data_providers/api_data_provider_v2.py | 250 +++++++ 5 files changed, 1799 insertions(+) create mode 100644 trcli/api/api_request_handler_v2.py create mode 100644 trcli/api/api_request_helpers.py create mode 100644 trcli/api/project_based_client_v2.py create mode 100644 trcli/api/results_uploader_v2.py create mode 100644 trcli/data_providers/api_data_provider_v2.py diff --git a/trcli/api/api_request_handler_v2.py b/trcli/api/api_request_handler_v2.py new file mode 100644 index 00000000..58d12463 --- /dev/null +++ b/trcli/api/api_request_handler_v2.py @@ -0,0 +1,401 @@ + +from concurrent.futures import ThreadPoolExecutor + +from typing import Optional,List, Tuple, Dict + +from trcli.api.api_request_helpers import SectionHandler, CaseHandler, ProjectHandler, SuiteHandler, RunHandler, \ + PlanHandler, TestHandler, ResultHandler, AttachmentHandler, FuturesHandler, EntityException, FutureActions +from trcli.api.api_client import APIClient +from trcli.api.api_response_verify import ApiResponseVerify +from trcli.cli import Environment +from trcli.constants import ProjectErrors, RevertMessages, ProcessingMessages +from trcli.data_classes.dataclass_testrail import TestRailCase, ProjectData +from trcli.data_providers.api_data_provider_v2 import ApiDataProvider +from trcli.settings import MAX_WORKERS_ADD_RESULTS, MAX_WORKERS_ADD_CASE + + +class ApiRequestHandler: + """ + Sends requests based on DataProvider data. + Server as container for keeping all necessary handlers + """ + def __init__(self, environment: Environment, api_client: APIClient, provider: ApiDataProvider): + self._environment = environment + self._client = api_client + self._data_provider = provider + self._response_verifier = ApiResponseVerify(self._environment.verify) + self._section_handler = SectionHandler(environment, self._client, self._data_provider) + self._case_handler = CaseHandler(environment, self._client, self._data_provider) + self._project_handler = ProjectHandler(environment, self._client) + self._suite_handler = SuiteHandler(environment, self._client, self._data_provider) + self._run_handler = RunHandler(self._client, self._data_provider) + self._plan_handler = PlanHandler(self._client, self._data_provider) + self._test_handler = TestHandler(environment, self._client, self._data_provider) + self._result_handler = ResultHandler(self._client, self._data_provider) + self._attachment_handler = AttachmentHandler(self._client) + self._futures_handler = FuturesHandler(environment) + + def get_project_data(self, project_name: Optional[str], project_id: Optional[int] = None) -> ProjectData: + """ + Get project data by name or id. + Return logic with setting error into id field is inherited from twilight genius, + left as is. I apologize... + :project_name: Project name + :project_id: Project id + :returns: ProjectData + """ + try: + if project_id is not None: + return self._project_handler.get_project_data_by_id(project_id) + return self._project_handler.get_project_data_by_name(project_name) + except EntityException as e: + return ProjectData( + project_id=ProjectErrors.other_error, + suite_mode=-1, + error_message=e.message, + name=project_name if project_name else "") + + def define_automation_id_field(self, project_id: int) -> Optional[str]: + """ + Defines the automation_id field for the project. + :project_id: The ID of the project + :returns: The system name of the automation_id field if available, otherwise None. + """ + return self._project_handler.define_automation_id_field(project_id) + + def resolve_suite_id_using_name(self) -> Tuple[Optional[int], str]: + """ + Get suite ID matching suite name on data provider or returns None if unable to match any suite. + :returns: tuple with id of the suite and error message + """ + try: + entities = self._section_handler.entities + suite_name = self._data_provider.suites_input.name + suite = next(filter(lambda x: x["name"] == suite_name, entities), None) + if suite: + suite_id = suite["id"] + return suite_id, "" + return None, "" + except EntityException as e: + return None, e.message + except KeyError: + return None, "Invalid response structure: missing 'name' or 'id' in suite data" + + + def check_suite_id(self) -> Tuple[bool, str]: + """ + Check if suite from DataProvider exist using get_suites endpoint + :returns: True if exists in suites. False if not. + """ + try: + entities = self._suite_handler.entities + except EntityException as e: + return False, e.message + suite_id = self._data_provider.suite_id + matched = next((suite for suite in entities if suite["id"] == suite_id), None) + if matched: + return True, "" + return False, "" + + def add_suite(self) -> Tuple[Optional[int], str]: + """ + Adds suite that doesn't have ID's in DataProvider. + :returns: Tuple with suite id and error string. + """ + try: + suite_id, error_message = self._suite_handler.add_suite() + return suite_id, error_message + except KeyError as e: + return None, "Can not retrieve created suite id from response - " + str(e) + + def get_suites_ids(self) -> Tuple[List[int], str]: + """ + Get suite IDs for requested project_id. + : returns: tuple with list of suite ids and error string + """ + try: + entities = self._suite_handler.entities + except EntityException as e: + return [], e.message + return [suite["id"] for suite in entities], "" + + def add_run(self) -> Tuple[Optional[int], str]: + """ + Creates a new test run. + :returns: Tuple with run id and error string. + """ + try: + return self._run_handler.add_run() + except EntityException as e: + return None, e.message + + def add_run_to_plan(self) -> Tuple[Optional[int], str]: + """ + Adds a test run to a test plan. + Returns a tuple of (run_id, error_message). + """ + try: + return self._plan_handler.add_run_to_plan(self._data_provider.test_plan_id) + except (KeyError, IndexError, TypeError, ValueError): + return None, "Invalid response structure: missing run ID" + + def get_cases_ids_in_run(self) -> Tuple[List[int], str]: + """ + Get all tests ids in the run specified in data provider. + :returns: Tuple with list of tests and error message + """ + try: + entities = self._test_handler.entities + return [test["case_id"] for test in entities], "" + except KeyError as e: + return [], f"Invalid response structure getting all tests in run: missing case_id - {str(e)}" + except EntityException as e: + return [], e.message + + def get_run(self) -> Tuple[dict, str]: + """ + :returns: Tuple with run (id specified in data provider) data and error message + """ + return self._run_handler.get_run_by_id(self._data_provider.test_run_id) + + def get_run_entry_id_in_plan(self, plan_id: int) -> Tuple[Optional[str], str]: + """ + Get entry ID of a run in a test plan. + :plan_id: ID of the test plan + :returns: Tuple with entry ID and error message + """ + plan_entity, error_message = self._plan_handler.get_plan_by_id(plan_id) + if error_message: + return None, error_message + run_id = self._data_provider.test_run_id + try: + entry_id = next( + ( + run["entry_id"] + for entry in plan_entity["entries"] + for run in entry["runs"] + if run["id"] == run_id), None) + + except (KeyError, IndexError, TypeError, ValueError): + return None, "Invalid response structure: missing entry_id" + + if entry_id is None: + return None, f"Entry id of run: {run_id} was not found in plan: {plan_id}.", + return entry_id, "" + + def update_run_in_plan_entry(self) -> str: + """ + Updates an existing run in a test plan entry. + :returns: Tuple with updated run data and error message + """ + return self._plan_handler.update_run_in_plan_entry(self._data_provider.test_run_id) + + def update_plan_entry(self, plan_id: int, entry_id: int) -> str: + """ + Updates a test plan entry. + :plan_id: ID of the test plan + :entry_id: ID of the test plan entry + :returns: Error message if any, otherwise an empty string. + """ + return self._plan_handler.update_plan_entry(plan_id, entry_id) + + def update_run(self) -> str: + """ + Updates an existing run. + :returns: Error message if any, otherwise an empty string. + """ + return self._run_handler.update_run(self._data_provider.test_run_id) + + def delete_created_run(self) -> str: + """ + Deletes a created run specified in data provider. + :returns: String indicating the result of the deletion attempt + or empty string if no run to delete. + """ + if created_run_id:= self._data_provider.created_test_run_id: + error = self._run_handler.delete_run(created_run_id) + if error: + return RevertMessages.RUN_NOT_DELETED_FF_RUN_ID_ERROR.format(run_id=created_run_id, error=error) + return RevertMessages.RUN_DELETED + return "" + + def close_run(self) -> str: + """ + Closes an existing test run. + :returns: Error message if any, otherwise empty string. + """ + return self._run_handler.close_run(self._data_provider.test_run_id) + + def add_missing_sections(self) -> Tuple[List[int], str]: + """ + Adds sections that are missing in TestRail. + Runs update_data in data_provider for successfully created resources. + :returns: Tuple with list of dict created resources and error string. + """ + try: + return self._section_handler.create_missing_sections() + except EntityException as e: + return [], e.message + except KeyError as e: + return [], f"Invalid response structure adding missing sections: {str(e)}" + + def sort_missing_and_existing_cases(self) -> str: + """ + Collects existing test cases from TestRail and sort test cases from provided suite existing/missing. + Update corresponding data provider fields with the results. + """ + try: + self._case_handler.sort_missing_and_existing_cases() + except EntityException as e: + return e.message + except KeyError as e: + return f"Invalid response structure adding missing sections: {str(e)}" + return "" + + def delete_created_cases(self) -> str: + """ Deletes created cases specified in data provider. + :returns: String indicating the result of the deletion attempt, + or empty string if no cases to delete. + """ + if ids:=self._data_provider.created_cases_ids: + error = self._case_handler.delete_cases(ids) + if error: + return RevertMessages.TEST_CASES_NOT_DELETED_F_ERROR.format(error=error) + return RevertMessages.TEST_CASES_DELETED + return "" + + def delete_created_sections(self) -> str: + """ Deletes created sections specified in data provider. + :returns: String indicating the result of the deletion attempt, + or empty string if no sections to delete. + """ + if added_sections_ids:= self._data_provider.created_sections_ids: + for section_id in added_sections_ids: + error = self._section_handler.delete_section(section_id) + if error: + return (RevertMessages. + SECTION_NOT_DELETED_FF_SECTION_ID_ERROR.format(section_id=section_id, error=error)) + return RevertMessages.SECTION_DELETED + return "" + + def delete_created_suite(self) -> str: + """ Deletes created suite specified in data provider. + :returns: String indicating the result of the deletion attempt. + """ + if suite_id := self._data_provider.created_suite_id: + error = self._suite_handler.delete_suite(suite_id) + if error: + return RevertMessages.SUITE_NOT_DELETED_FF_SUITE_ID_ERROR.format(error=error) + return RevertMessages.SUITE_DELETED + return "" + + def update_existing_cases(self, cases_to_update: List[TestRailCase]) -> str: + """ + Update cases that have ID's in DataProvider. + Runs update_data in data_provider for successfully created resources. + :cases_to_update: List of TestRailCase objects to update + :returns: Error string or empty if not error. + """ + with self._environment.get_progress_bar( + results_amount=len(cases_to_update), prefix="Updating test cases" + ) as progress_bar, ThreadPoolExecutor(max_workers=MAX_WORKERS_ADD_CASE) as executor: + futures = { + executor.submit( + self._case_handler.update_case, case + ): case for case in cases_to_update + } + error_message = self._futures_handler.handle_futures( + futures=futures, action=FutureActions.UPDATE_CASE, progress_bar=progress_bar + ) + + return error_message + + + def add_cases(self, cases_to_add: List[TestRailCase]) -> str: + """ + Add cases that doesn't have ID in DataProvider. + Runs update_data in data_provider for successfully created resources. + :returns: Tuple with number of added cases and error string or empty if not error. + """ + with self._environment.get_progress_bar( + results_amount=len(cases_to_add), prefix="Adding test cases" + ) as progress_bar, ThreadPoolExecutor(max_workers=MAX_WORKERS_ADD_CASE) as executor: + futures = { + executor.submit( + self._case_handler.add_case, case + ): case for case in cases_to_add + } + error_message = self._futures_handler.handle_futures( + futures=futures, action=FutureActions.ADD_CASE, progress_bar=progress_bar + ) + + return error_message + + def add_results(self) -> Tuple[int, str]: + """ + Adds one or more new test results. + :returns: Tuple with dict created resources and error string. + """ + data_chunks = self._data_provider.get_results_for_cases() + total_results = sum(len(chunk["results"]) for chunk in data_chunks) + + with self._environment.get_progress_bar( + results_amount=total_results, prefix="Adding results" + ) as progress_bar, ThreadPoolExecutor(max_workers=MAX_WORKERS_ADD_RESULTS) as executor: + futures = { + executor.submit( + self._result_handler.add_result_for_cases, body + ): body for body in data_chunks + } + responses, error_message = self._futures_handler.handle_futures( + futures=futures, action=FutureActions.ADD_RESULTS, progress_bar=progress_bar) + + flat_results = [r for chunk in responses for r in chunk] + attachments = self._collect_results_with_attachments(data_chunks) + if attachments: + self._log_and_upload_attachments(attachments, flat_results) + else: + self._environment.log("No attachments found to upload.") + + return progress_bar.n, error_message + + @staticmethod + def _collect_results_with_attachments(data_chunks: List[Dict]) -> List[Dict]: + """Extract results that have attachments from result chunks.""" + return [ + result + for chunk in data_chunks + for result in chunk["results"] + if result.get("attachments") + ] + + def _log_and_upload_attachments(self, report_results: List[Dict], results: List[Dict]) -> None: + """Log attachment summary and trigger upload.""" + attachment_count = sum(len(r["attachments"]) for r in report_results) + self._environment.log(ProcessingMessages.UPLOADING_ATTACHMENTS_FF_ATTACHMENTS_RESULTS + .format(amount=attachment_count, results=len(report_results))) + self._upload_attachments(report_results, results) + + def _upload_attachments(self, report_results: List[Dict], results: List[Dict]): + """ Getting test result id and upload attachments for it. """ + try: + self._test_handler.clean_cache() # Ensure we have the latest tests in run after adding results + tests_in_run = self._test_handler.entities + except EntityException as e: + self._environment.elog(f"Unable to upload attachments due to API request error: {e.message}") + return + for report_result in report_results: + case_id = report_result["case_id"] + test_id = next((test["id"] for test in tests_in_run if test["case_id"] == case_id), None) + result_id = next((result["id"] for result in results if result["test_id"] == test_id), None) + + for file_path in report_result.get("attachments", []): + try: + with open(file_path, "rb") as file: + error = self._attachment_handler.add_attachment_to_result(result_id, file) + if error: + self._environment.elog( + f"Error uploading attachment for case {case_id}: {error}" + ) + except Exception as e: + self._environment.elog(f"Error uploading attachment for case {case_id}: {e}") \ No newline at end of file diff --git a/trcli/api/api_request_helpers.py b/trcli/api/api_request_helpers.py new file mode 100644 index 00000000..0515c7a5 --- /dev/null +++ b/trcli/api/api_request_helpers.py @@ -0,0 +1,660 @@ +import html +from abc import abstractmethod, ABC +from concurrent.futures import Future, as_completed +from enum import Enum +from typing import Dict, Callable, Any, Tuple, List, Optional + +from serde import to_dict +from tqdm import tqdm + +from trcli.api.api_client import APIClient +from trcli.api.api_response_verify import ApiResponseVerify +from trcli.cli import Environment +from trcli.constants import ProjectErrors, FAULT_MAPPING, OLD_SYSTEM_NAME_AUTOMATION_ID, \ + UPDATED_SYSTEM_NAME_AUTOMATION_ID +from trcli.data_classes.data_parsers import MatchersParser +from trcli.data_classes.dataclass_testrail import ProjectData, TestRailCase, TestRailSection +from trcli.data_providers.api_data_provider_v2 import ApiDataProvider + + +class FutureActions(Enum): + """Enum for feature actions""" + ADD_CASE = "adding case" + UPDATE_CASE = "updating case" + ADD_RESULTS = "adding results" + + +class EntityException(Exception): + """Custom exception type for entity-level errors like section/case getting entities failures.""" + def __init__(self, message=""): + self.message = message + super().__init__(self.message) + + +class FuturesHandler: + """ + Handles futures for different actions like adding/updating cases or adding results. + Don't waste time here, it's just boring concurrency handling code (mostly copied from previous implementation) + with variations for different returns from futures. + """ + def __init__(self, env: Environment): + self._environment = env + + def handle_futures(self, futures, action: FutureActions, progress_bar: tqdm): + if action in {FutureActions.ADD_CASE, FutureActions.UPDATE_CASE}: + return self._handle_add_update_futures(futures, action, progress_bar) + elif action == FutureActions.ADD_RESULTS: + return self._handle_futures_for_results(futures, action, progress_bar) + else: + raise ValueError(f"Unsupported action: {action}") + + @staticmethod + def _retrieve_results_after_cancelling(futures) -> list: + responses = [] + for future in as_completed(futures): + if not future.cancelled(): + response, error_message = future.result() + if not error_message: + responses.append(response) + return responses + + def _handle_add_update_futures(self, futures, action: FutureActions, progress_bar: tqdm) -> str: + extract_result = lambda result, _: (None, result) # future returns error string only + get_progress_increment = lambda _: 1 + _, error_message = self._handle_futures_generic( + futures, action, progress_bar, extract_result, get_progress_increment + ) + return error_message + + def _handle_futures_for_results( + self, futures, action: FutureActions, progress_bar: tqdm + ) -> Tuple[list, str]: + extract_result = lambda result, _: result # future returns (response, error) + get_progress_increment = lambda args: len(args["results"]) + + responses, error_message = self._handle_futures_generic( + futures, action, progress_bar, extract_result, get_progress_increment + ) + + # If there was an error, collect successful responses from finished futures + if error_message: + responses = self._retrieve_results_after_cancelling(futures) + + return responses, error_message + + def _handle_futures_generic( + self, + futures: Dict[Future, Any], + action: FutureActions, + progress_bar: tqdm, + extract_result: Callable[[Any, Any], Tuple[Any, str]], + get_progress_increment: Callable[[Any], int], + ) -> Tuple[List[Any], str]: + """ + Generic handler for futures. Lets you specify how to extract response/error + and how much to update progress bar per completed future. + """ + responses: List[Any] = [] + error_message = "" + + try: + for future in as_completed(futures): + arguments = futures[future] + + try: + result = future.result() + except Exception as e: + error_message = str(e) + self._environment.elog(f"\nUnexpected error during {action.value}: {error_message}") + self._cancel_running_futures(futures, action) + break + + response, error_message = extract_result(result, arguments) + + if response is not None: + responses.append(response) + + progress_bar.update(get_progress_increment(arguments)) + + if error_message: + self._cancel_running_futures(futures, action) + break + else: + progress_bar.set_postfix_str(s="Done.") + + except KeyboardInterrupt: + self._cancel_running_futures(futures, action) + raise + + return responses, error_message + + def _cancel_running_futures(self, futures, action: FutureActions): + self._environment.elog(f"\nAborting: {action.value}. Trying to cancel scheduled tasks.") + for future in futures: + future.cancel() + + +class ApiEntities(ABC): + """Base class for API entities with pagination support""" + + def __init__(self, env: Environment, api_client: APIClient): + self._environment = env + self._client = api_client + self._response_verifier = ApiResponseVerify(self._environment.verify) + self._suffix = api_client.VERSION + self._entities: Optional[List] = None + + @property + def entities(self) -> List[dict]: + """Get cached entities, initializing cache if not already done.""" + self._ensure_entities_cache_loaded() + return self._entities + + @abstractmethod + def _init_entities_cache(self) -> Tuple[List[dict], str]: + """Initialize the cache of entities. + Should be implemented in subclasses to fetch entities from the API. + :returns: Tuple with list of entities and error message + """ + pass + + def clean_cache(self) -> None: + """Clear the cached entities""" + self._entities = None + + def _normalize_link(self, link: str) -> str: + return link.replace(self._suffix, "") if link.startswith(self._suffix) else link + + def _get_all_entities( + self, entity_key: str, link: str, collected: Optional[List[Dict]] = None + ) -> Tuple[List[Dict], str]: + """Recursively fetch paginated entities from TestRail API""" + collected = collected or [] + + response = self._client.send_get(self._normalize_link(link)) + + if error_message:= response.error_or_bad_code(): + return [], error_message + + data = response.response_text + + # Handle legacy (non-paginated) structure + if isinstance(data, list): + return data, "" + + collected.extend(data.get(entity_key, [])) + + next_link = data.get("_links", {}).get("next") + if next_link: + next_link = next_link.replace("limit=0", "limit=250") + return self._get_all_entities(entity_key, next_link, collected) + + return collected, "" + + def _ensure_entities_cache_loaded(self): + if self._entities is None: + self._entities, error = self._init_entities_cache() + if error: + raise EntityException(error) + + +class ProjectHandler(ApiEntities): + def __init__(self, env: Environment, api_client: APIClient): + super().__init__(env, api_client) + + def _init_entities_cache(self) -> Tuple[List[dict], str]: + """ + Get all cases from all pages + """ + return self._get_all_entities('projects', f"get_projects") + + def get_project_data_by_id(self, project_id: int) -> ProjectData: + """ + Gets project data by id. + :project_id: project id + :returns: Project data with error or not. + """ + entities = self.entities + matched = next((project for project in entities if project["id"] == project_id), None) + if matched: + return ProjectData( + project_id=int(matched["id"]), + suite_mode=int(matched["suite_mode"]), + error_message="", + name=matched["name"] + ) + return ProjectData( + project_id=ProjectErrors.not_existing_project, + suite_mode=-1, + error_message=FAULT_MAPPING["project_doesnt_exists"], + name="" + ) + + def get_project_data_by_name(self, project_name: str) -> Optional[ProjectData]: + """ + Gets project data by name. + :project_name: project name + :returns: Project data with error or not. + """ + entities = self.entities + available_projects = [project for project in entities if project["name"] == project_name] + + if not available_projects: + return ProjectData( + project_id=ProjectErrors.not_existing_project, + suite_mode=-1, + error_message=FAULT_MAPPING["project_doesnt_exists"], + name=project_name + ) + + if len(available_projects) == 1: + project = available_projects[0] + return ProjectData( + project_id=int(project["id"]), + suite_mode=int(project["suite_mode"]), + error_message="", + name=project_name + ) + + return ProjectData( + project_id=ProjectErrors.multiple_project_same_name, + suite_mode=-1, + error_message=FAULT_MAPPING["more_than_one_project"], + name=project_name + ) + + def define_automation_id_field(self, project_id: int) -> Optional[str]: + """ + Defines the automation_id field for the project. + :project_id: The ID of the project + :returns: The system name of the automation_id field if available, otherwise None. + """ + response = self._client.send_get("get_case_fields") + + if error_message := response.error_or_bad_code(): + self._environment.elog(f"Can not define automation_id field: {error_message}") + return None + + # Don't know how to handle if both fields are in the system. + # Assuming it won't happen or must be solved on user's side. + fields: List[dict] = response.response_text or [] + automation_id_field = next( + ( + field for field in fields + if field.get("system_name") in {OLD_SYSTEM_NAME_AUTOMATION_ID, UPDATED_SYSTEM_NAME_AUTOMATION_ID} + ), + None, + ) + + if not automation_id_field: + self._environment.elog(FAULT_MAPPING["automation_id_unavailable"]) + return None + + if not automation_id_field.get("is_active", False): + self._environment.elog(FAULT_MAPPING["automation_id_unavailable"]) + return None + + # Below code just copied and slightly refactored with no understanding what it checks, only assumptions. + + # If no configs are defined, the field is globally available + if not automation_id_field.get("configs"): + return automation_id_field.get("system_name") + + # Safely check configs + for config in automation_id_field.get("configs", []): + context = config.get("context", {}) + if context.get("is_global") or project_id in context.get("project_ids", []): + return automation_id_field.get("system_name") + + self._environment.elog(FAULT_MAPPING["automation_id_unavailable"]) + return None + + +class SuiteHandler(ApiEntities): + def __init__(self, env: Environment, api_client: APIClient, provider: ApiDataProvider): + super().__init__(env, api_client) + self._provider = provider + + def _init_entities_cache(self) -> Tuple[List[dict], str]: + project_id = self._provider.project_id + return self._get_all_entities("suites", f"get_suites/{project_id}") + + def add_suite(self) -> Tuple[Optional[int], str]: + project_id = self._provider.project_id + body = to_dict(self._provider.suites_input) + response = self._client.send_post(f"add_suite/{project_id}", body) + + if error_message:= response.error_or_bad_code(): + return None, error_message + + if not self._response_verifier.verify_returned_data(body, response.response_text): + return None, FAULT_MAPPING["data_verification_error"] + + suite_id = response.response_text.get("id") + return suite_id, "" + + def delete_suite(self, suite_id: int) -> str: + response = self._client.send_post(f"delete_suite/{suite_id}", payload={}) + return response.error_or_bad_code() + + +class TestHandler(ApiEntities): + def __init__(self, env: Environment, api_client: APIClient, provider: ApiDataProvider): + super().__init__(env, api_client) + self._provider = provider + + def _init_entities_cache(self) -> Tuple[List[dict], str]: + """ + Get all tests from all pages + """ + run_id = self._provider.test_run_id + return self._get_all_entities('tests', f"get_tests/{run_id}") + + +class RunHandler: + def __init__(self, api_client: APIClient, provider: ApiDataProvider): + self._client = api_client + self._provider = provider + + def get_run_by_id(self, run_id: int) -> Tuple[dict, str]: + response = self._client.send_get(f"get_run/{run_id}") + + if error_message:= response.error_or_bad_code(): + return {}, error_message + + return response.response_text, "" + + def add_run(self) -> Tuple[Optional[int], str]: + project_id = self._provider.project_id + response = self._client.send_post(f"add_run/{project_id}", self._provider.test_run.to_dict()) + + if error_message:= response.error_or_bad_code(): + return None, error_message + return response.response_text.get("id"), "" + + def update_run(self, run_id: int) -> str: + response = self._client.send_post(f"update_run/{run_id}", self._provider.test_run.to_dict()) + return response.error_or_bad_code() + + def delete_run(self, run_id: int) -> str: + response = self._client.send_post(f"delete_run/{run_id}", payload={}) + return response.error_or_bad_code() + + def close_run(self, run_id: int) -> str: + """ + Closes an existing test run and archives its tests & results. + :run_id: run id + :returns: Error message if any, otherwise an empty string + """ + body = {"run_id": run_id} + response = self._client.send_post(f"close_run/{run_id}", body) + return response.error_or_bad_code() + + +class PlanHandler: + def __init__(self, api_client: APIClient, provider: ApiDataProvider): + self._client = api_client + self._provider = provider + + def get_plan_by_id(self, plan_id: int) -> Tuple[dict, str]: + response = self._client.send_get(f"get_plan/{plan_id}") + + if error_message:= response.error_or_bad_code(): + return {}, error_message + + return response.response_text, "" + + def update_plan_entry(self, plan_id: int, entry_id: int) -> str: + run = self._provider.test_run.to_dict() + response = self._client.send_post(f"update_plan_entry/{plan_id}/{entry_id}", run) + return response.error_or_bad_code() + + def update_run_in_plan_entry(self, run_id) -> str: + run = self._provider.test_run.to_dict() + response = self._client.send_post(f"update_run_in_plan_entry/{run_id}", run) + return response.error_or_bad_code() + + def add_run_to_plan(self, plan_id: int) -> Tuple[Optional[int], str]: + """ + Adds a test run to a test plan. + Returns a tuple of (run_id, error_message). + """ + run = self._provider.test_run + config_ids = self._provider.config_ids + + # Prepare entry data depending on whether config_ids exist + entry_data = ( + { + "name": run.name, + "suite_id": run.suite_id, + "config_ids": config_ids, + "runs": [run.to_dict()], + } + if config_ids + else run + ) + + response = self._client.send_post(f"add_plan_entry/{plan_id}", entry_data) + + if error_message:= response.error_or_bad_code(): + return None, error_message + + run_id = int(response.response_text["runs"][0]["id"]) + return run_id, "" + + +class SectionHandler(ApiEntities): + def __init__(self, env: Environment, api_client: APIClient, provider: ApiDataProvider): + super().__init__(env, api_client) + self._provider = provider + + def _init_entities_cache(self) -> Tuple[List[dict], str]: + project_id = self._provider.project_id + suite_id = self._provider.suite_id + return self._get_all_entities("sections", f"get_sections/{project_id}&suite_id={suite_id}") + + def _is_section_missed(self,section: TestRailSection, parent_id: Optional[int] = None) -> bool: + entities = self.entities + matched = next((s for s in entities if s["parent_id"] == parent_id and s["name"] == section.name), None) + if not matched: + return True + section.section_id = matched["id"] + return any(self._is_section_missed(sub, section.section_id) for sub in section.sub_sections) + + def create_missing_sections(self) -> Tuple[List[int], str]: + added_ids = [] + for section in self._provider.suites_input.testsections: + created, error = self._create_section_tree(section, section.parent_id) + added_ids.extend(created) + if error: + return list(reversed(added_ids)), error + # important to reverse the order if we need to delete sections later in case rollback + return list(reversed(added_ids)), "" + + def _create_section_tree(self, section: TestRailSection, parent_id: Optional[int] = None) -> Tuple[List[int], str]: + """ + Creates a section tree recursively. + :section: TestRailSection object to create + :parent_id: ID of the parent section, None for root sections + :returns: Tuple with list of created section IDs and error message if any + """ + project_id = self._provider.project_id + added = [] + # level = "Section" if parent_id is None else "Subsection" + + entities = self.entities + existing = next( + (s for s in entities if s["parent_id"] == parent_id and s["name"] == section.name), + None + ) + + if existing: + section.section_id = existing["id"] + # self._environment.log(f"{level} exists: {section.name} (ID: {section.section_id})") + else: + section.parent_id = parent_id + body = to_dict(section) + response = self._client.send_post(f"add_section/{project_id}", body) + + if error_message:= response.error_or_bad_code(): + # self._environment.elog(f"Failed to create {level}: {error_message}") + return added, error_message + + if not self._response_verifier.verify_returned_data(body, response.response_text): + return added, FAULT_MAPPING["data_verification_error"] + + section.section_id = response.response_text["id"] + added.append(section.section_id) + # self._environment.log(f"{level} created: {section.name} (ID: {section.section_id})") + + for sub in section.sub_sections: + sub_added, error_message = self._create_section_tree(sub, section.section_id) + added.extend(sub_added) + if error_message: + return added, error_message + + return added, "" + + def delete_section(self, section_id: int) -> str: + """ + Deletes a section by its ID. + :section_id: ID of the section to delete + :returns: Error message if any, otherwise an empty string + """ + response = self._client.send_post(f"delete_section/{section_id}", payload={}) + return response.error_or_bad_code() + + +class CaseHandler(ApiEntities): + """ + Class might have two implementations of case matching - by automation_id or by case_id. + For now as is... + """ + def __init__(self, env: Environment, api_client: APIClient, provider: ApiDataProvider): + super().__init__(env, api_client) + self._provider = provider + + def _init_entities_cache(self): + project_id = self._provider.project_id + suite_id = self._provider.suite_id + return self._get_all_entities("cases", f"get_cases/{project_id}&suite_id={suite_id}") + + def sort_missing_and_existing_cases(self) -> None: + if self._environment.case_matcher == MatchersParser.AUTO: + self._sort_missing_and_existing_cases_by_aut_id() + else: + self._sort_missing_and_existing_cases_by_id() + + def update_case(self, case: TestRailCase) -> str: + case_id = case.case_id + body = case.to_dict() + response = self._client.send_post(f"update_case/{case_id}", payload=body) + if error_message:= response.error_or_bad_code(): + return error_message + self._provider.updated_cases_ids.append(case_id) + return "" + + def add_case(self, case: TestRailCase) -> str: + section_id = case.section_id + body = case.to_dict() + response = self._client.send_post(f"add_case/{section_id}", payload=body) + + if error_message:= response.error_or_bad_code(): + return error_message + + case.case_id = response.response_text.get("id") + + if case.case_id is None: + return "Case id is not returned by server" + + self._provider.created_cases_ids.append(case.case_id) + case.result.case_id = case.case_id + + if not self._response_verifier.verify_returned_data(added_data=body, returned_data=response.response_text): + return FAULT_MAPPING["data_verification_error"] + return "" + + def delete_cases(self, added_cases: List[int]) -> str: + """ + Delete cases given add_cases response + :added_cases: List of case IDs to delete + :returns: Error message if any, otherwise an empty string + """ + suite_id = self._provider.suite_id + body = {"case_ids": added_cases} + response = self._client.send_post(f"delete_cases/{suite_id}", payload=body) + return response.error_or_bad_code() + + def _sort_missing_and_existing_cases_by_aut_id(self) -> None: + existing_case_map = self._map_cases_by_automation_id() + map_by_aut_id = self._provider.collect_all_testcases_by_automation_id() + for aut_id, case in map_by_aut_id.items(): + if aut_id not in existing_case_map: + self._provider.missing_cases.append(case) + else: + case.case_id = existing_case_map[aut_id]["id"] + case.section_id = existing_case_map[aut_id]["section_id"] + case.result.case_id = case.case_id + self._provider.existing_cases.append(case) + + def _sort_missing_and_existing_cases_by_id(self) -> None: + case_map = self._map_cases_by_id() + for case in self._provider.collect_all_testcases(): + if not case.case_id or case.case_id not in case_map: + self._provider.missing_cases.append(case) + else: + case.section_id = case_map[case.case_id]["section_id"] + case.result.case_id = case.case_id + self._provider.existing_cases.append(case) + + def _map_cases_by_automation_id(self) -> Dict[str, dict]: + case_map = {} + entities = self.entities + for case in entities: + # field name may vary based on TestRail version and setup + aut_id = case.get(self._provider.automation_id_system_name) + if aut_id: + case_map[html.unescape(aut_id)] = case + return case_map + + def _map_cases_by_id(self) -> Dict[int, dict]: + case_map = {} + entities = self.entities + for case in entities: + case_id = case.get("id") + if case_id: + case_map[case_id] = case + return case_map + + +class ResultHandler: + def __init__(self, api_client: APIClient, provider: ApiDataProvider): + self._client = api_client + self._provider = provider + + def add_result_for_cases(self, body: dict) -> Tuple[Dict, str]: + """ + Adds results for cases in the specified in data provider run. + :body: Dictionary containing results to be added + :returns: Tuple with response data and error message if any + """ + run_id = self._provider.test_run_id + response = self._client.send_post(f"add_results_for_cases/{run_id}", body) + if error_message:= response.error_or_bad_code(): + return {}, error_message + return response.response_text, "" + + +class AttachmentHandler: + def __init__(self, api_client: APIClient): + self._client = api_client + + def add_attachment_to_result(self, result_id: int, file_path: str) -> str: + """ + Adds an attachment to a test result. + :result_id: ID of the test result + :file_path: Path to the file to be attached + :returns: Error message if any, otherwise an empty string + """ + response = self._client.send_post(f"add_attachment_to_result/{result_id}", files={"attachment": file_path}) + return response.error_or_bad_code() diff --git a/trcli/api/project_based_client_v2.py b/trcli/api/project_based_client_v2.py new file mode 100644 index 00000000..8e64341f --- /dev/null +++ b/trcli/api/project_based_client_v2.py @@ -0,0 +1,183 @@ +from typing import Optional, Tuple + +from trcli.api.api_client import APIClient +from trcli.api.api_request_handler_v2 import ApiRequestHandler +from trcli.cli import Environment +from trcli.constants import ProjectErrors, FAULT_MAPPING, SuiteModes, PROMPT_MESSAGES, ProcessingMessages, \ + SuccessMessages, ErrorMessages +from trcli.data_classes.data_parsers import MatchersParser +from trcli.data_classes.dataclass_testrail import TestRailSuite, ProjectData +from trcli.data_providers.api_data_provider_v2 import ApiDataProvider + + +class ProjectBasedClient: + """ + Class to be used to interact with the TestRail Api at a project level. + Initialized with environment object and result file parser object (any parser derived from FileParser). + """ + + def __init__(self, environment: Environment, suite: TestRailSuite): + self.project: Optional[ProjectData] = None + self.environment = environment + self._data_provider = ApiDataProvider(suite, self.environment) + self._api_request_handler = ApiRequestHandler( + environment=self.environment, + api_client=self._instantiate_api_client(), + provider=self._data_provider, + ) + + def _instantiate_api_client(self) -> APIClient: + """ + Instantiate api client with needed attributes taken from environment. + """ + verbose_logging_function = self.environment.vlog + logging_function = self.environment.log + proxy = self.environment.proxy # Will be None if --proxy is not defined + noproxy = self.environment.noproxy # Will be None if --noproxy is not defined + proxy_user = self.environment.proxy_user + if self.environment.timeout: + api_client = APIClient( + self.environment.host, + verbose_logging_function=verbose_logging_function, + logging_function=logging_function, + timeout=self.environment.timeout, + verify=not self.environment.insecure, + proxy=proxy, + proxy_user=proxy_user, + noproxy=noproxy + ) + else: + api_client = APIClient( + self.environment.host, + logging_function=logging_function, + verbose_logging_function=verbose_logging_function, + verify=not self.environment.insecure, + proxy=proxy, + proxy_user=proxy_user, + noproxy=noproxy + ) + api_client.username = self.environment.username + api_client.password = self.environment.password + api_client.api_key = self.environment.key + api_client.proxy = self.environment.proxy + api_client.proxy_user = self.environment.proxy_user + api_client.noproxy = self.environment.noproxy + + return api_client + + def resolve_project(self) -> None: + """ + Gets and checks project settings. + """ + self.environment.log(ProcessingMessages.CHECKING_PROJECT, new_line=False) + self.project = self._api_request_handler.get_project_data( + self.environment.project, self.environment.project_id + ) + self._validate_project_id() + self._data_provider.project_id = self.project.project_id + + # Removed conformation check environment.auto_creation_response from previous implementation + # Probably was here by mistake, following code creates nothing... + if self.environment.case_matcher == MatchersParser.AUTO: + automation_id_name = self._api_request_handler.define_automation_id_field(self.project.project_id) + if not automation_id_name: + self._exit_with_error(ErrorMessages.FAILED_TO_DEFINE_AUTOMATION_ID_FIELD) + + self._data_provider.update_custom_automation_id_system_name(automation_id_name) + + self.environment.log(SuccessMessages.DONE) + + def resolve_suite(self) -> None: + if not self.project: + self.resolve_project() + if self.environment.suite_id: + self._handle_suite_by_id() + else: + self._handle_suite_by_name() + + def _validate_project_id(self) -> None: + error_messages = { + ProjectErrors.not_existing_project: self.project.error_message, + ProjectErrors.other_error: FAULT_MAPPING["error_checking_project"].format( + error_message=self.project.error_message + ), + ProjectErrors.multiple_project_same_name: FAULT_MAPPING["error_checking_project"].format( + error_message=self.project.error_message + ), + } + + if self.project.project_id in error_messages: + self._exit_with_error(self.project.error_message) + + def _handle_suite_by_id(self) -> None: + """Handles suite selection when suite ID is provided.""" + self._data_provider.update_suite_id(self.environment.suite_id) + existing, error_message = self._api_request_handler.check_suite_id() + + if error_message: + self._exit_with_error(ErrorMessages.CAN_NOT_RESOLVE_SUITE_F_ERROR.format(error=error_message)) + if not existing: + self._exit_with_error( + f"Not existing suite ID provided: {self.environment.suite_id} " + f"for project: {self.project.name} with id: {self.project.project_id}." + ) + + def _handle_suite_by_name(self) -> None: + """Handles suite selection when suite name is provided.""" + suite_mode = self.project.suite_mode + project_id = self.project.project_id + + if suite_mode not in (SuiteModes.multiple_suites, SuiteModes.single_suite_baselines): + self._exit_with_error( + FAULT_MAPPING["unknown_suite_mode"].format(suite_mode=suite_mode) + ) + + suite_id, error_message = self._api_request_handler.resolve_suite_id_using_name() + if error_message: + self._exit_with_error(ErrorMessages.CAN_NOT_RESOLVE_SUITE_F_ERROR.format(error=error_message)) + + if suite_id: + self._data_provider.update_suite_id(suite_id) + self.environment.log(f"Using suite ID: {suite_id} for project (ID): {project_id}.") + return + + if suite_mode == SuiteModes.multiple_suites: + suite_id, error_message = self._prompt_user_and_add_suite() + if error_message: + self._exit_with_error(ErrorMessages.CAN_NOT_ADD_SUITE_F_ERROR.format(error=error_message)) + self._data_provider.update_suite_id(suite_id, is_created=True) + return + + if suite_mode == SuiteModes.single_suite_baselines: + suite_ids, error_message = self._api_request_handler.get_suites_ids() + if error_message: + self._exit_with_error(ErrorMessages.CAN_NOT_RESOLVE_SUITE_F_ERROR.format(error=error_message)) + if len(suite_ids) != 1: + self._exit_with_error( + FAULT_MAPPING["not_unique_suite_id_single_suite_baselines"].format( + project_name=self.environment.project + ) + ) + self._data_provider.update_suite_id(suite_ids[0]) + + if self._data_provider.suites_input.suite_id is None: + self._exit_with_error(ErrorMessages.NO_SUITE_ID) + + def _prompt_user_and_add_suite(self) -> Tuple[Optional[int], str]: + prompt_message = PROMPT_MESSAGES["create_new_suite"].format( + suite_name=self._data_provider.suites_input.name, + project_name=self.environment.project, + ) + adding_message = ProcessingMessages.ADDING_SUITE_F_PROJECT_NAME.format(project_name=self.environment.project) + fault_message = FAULT_MAPPING["no_user_agreement"].format(type="suite") + + if not self.environment.get_prompt_response_for_auto_creation(prompt_message): + self._exit_with_error(fault_message) + + self.environment.log(adding_message) + return self._api_request_handler.add_suite() + + def _exit_with_error(self, message: str) -> None: + """Logs error and exits.""" + self.environment.elog(message) + exit(1) diff --git a/trcli/api/results_uploader_v2.py b/trcli/api/results_uploader_v2.py new file mode 100644 index 00000000..e2ab3631 --- /dev/null +++ b/trcli/api/results_uploader_v2.py @@ -0,0 +1,305 @@ +import time +from typing import Tuple, List, Optional + +from trcli.api.project_based_client_v2 import ProjectBasedClient +from trcli.cli import Environment +from trcli.constants import PROMPT_MESSAGES, FAULT_MAPPING, SuccessMessages, ProcessingMessages, SkippingMessage, \ + ErrorMessages + +from trcli.data_classes.dataclass_testrail import TestRailSuite +from trcli.data_providers.api_data_provider_v2 import DataProviderException + + +class ResultsUploader(ProjectBasedClient): + """ + Class to be used to upload the results to TestRail. + Initialized with environment object and result file parser object (any parser derived from FileParser). + """ + def __init__(self, environment: Environment, suite: TestRailSuite, skip_run: bool = False): + super().__init__(environment, suite) + self.skip_run = skip_run + self._start_time = None + + def upload_results(self) -> None: + """ + Does all the job needed to upload the results parsed from result files to TestRail. + If needed missing items like suite/section/test case would be added to TestRail. + Exits with result code 1 printing proper message to the user in case of a failure + or with result code 0 if succeeds. + #FIXME Would be nice to rename method to 'upload_suite' or 'upload', which is more logical, + #FIXME because results upload is separate action and can be skipped. + """ + self._start_time = time.time() + + self.resolve_project() + self.resolve_suite() + self._resolve_sections_and_cases_upload() + + if self.skip_run: + stop = time.time() + self.environment.log(SkippingMessage.SKIP_TEST_RUN_AND_RESULTS) + self.environment.log(SuccessMessages.COMPLETED_IN_F_ELAPSED.format(elapsed=(stop - self._start_time))) + return + + self._resolve_run_and_results_upload() + stop = time.time() + self.environment.log(SuccessMessages.COMPLETED_IN_F_ELAPSED.format(elapsed=(stop - self._start_time))) + + def create_or_update_test_run(self) -> int: + """ + Create or update a test run. + - If run_id is provided → update the run. + - If plan_id is provided → add the run to a plan. + - Otherwise → create a standalone run. + returns created or updated run ID. + Exits with result code set to 1 in case of a failure. + + Moved this method from ancestor class to this place. It seems more logical, because + this class is responsible for results upload and run is needed to upload results. + """ + try: + self._data_provider.suite_id + except DataProviderException: + self.resolve_suite() + + self._data_provider.add_run() + + if self._data_provider.test_run_id: + error_message = self._update_test_run() + else: + run_id, error_message = self._create_test_run() + self._data_provider.update_test_run_id_if_created(run_id) + + if error_message: + self.environment.elog(ErrorMessages.CREATING_UPDATING_TESTRUN_F_ERROR.format(error=error_message)) + self._rollback_and_exit() + + return self._data_provider.test_run_id + + def _resolve_sections_and_cases_upload(self) -> None: + error_message = self._api_request_handler.sort_missing_and_existing_cases() + + if error_message: + self.environment.elog(ErrorMessages.SORTING_TEST_CASES_F_ERROR.format(error=error_message)) + self._rollback_and_exit() + + has_missing_cases = bool(self._data_provider.missing_cases) + should_update_cases = self.environment.update_cases + + if has_missing_cases or should_update_cases: + self._handle_sections_upload() + + if should_update_cases: + self._update_existing_test_cases() + else: + self.environment.log(SkippingMessage.NO_TEST_CASES_TO_UPDATE) + + if has_missing_cases: + self._add_missing_test_cases() + else: + self.environment.log(SkippingMessage.NO_TEST_CASES_TO_ADD) + + def _handle_sections_upload(self) -> None: + #no needs to check if any missing section, it will upload only missing + added_sections, error_message = self._upload_sections() + self._data_provider.created_sections_ids = added_sections + if error_message: + self.environment.elog(ErrorMessages.UPLOADING_SECTIONS_F_ERROR.format(error=error_message)) + self._rollback_and_exit() + + # Update section IDs when sections are found or created. + # if --update-cases option specified, for existing cases section or subsection id will be updated as well + self._data_provider.update_cases_section_ids() + + def _upload_sections(self) -> Tuple[List[int], str]: + """ + Uploads missing sections to the suite if user agrees to do so. + Exits with result code set to 1 in case of a failure. + returns a tuple with list of created section IDs and error message (if any). + """ + prompt_message = PROMPT_MESSAGES["create_missing_sections"].format(project_name=self.project.name) + fault_message = FAULT_MAPPING["no_user_agreement"].format(type="sections") + + if not self.environment.get_prompt_response_for_auto_creation(prompt_message): + self.environment.elog(fault_message) + self._rollback_and_exit() + + self.environment.log(ProcessingMessages.ADDING_SECTIONS) + return self._api_request_handler.add_missing_sections() + + def _update_existing_test_cases(self) -> None: + cases_to_update = self._data_provider.existing_cases + self.environment.log(ProcessingMessages.UPDATING, new_line=False) + error_message = self._api_request_handler.update_existing_cases(cases_to_update) + + if error_message: + self.environment.elog(ErrorMessages.CASES_UPDATE_F_ERROR.format(error=error_message)) + # not sure about roll back here + + actual_amount = len(self._data_provider.updated_cases_ids) + success_message = (SuccessMessages.UPDATED_CASES_AMOUNT_FF_ACTUAL_EXPECTED + .format(actual=actual_amount,expected=len(cases_to_update))) + self.environment.log(success_message) + + def _add_missing_test_cases(self) -> None: + """ + Uploads missing test cases to the suite if user agrees to do so. + Exits with result code set to 1 in case of a failure. + """ + cases_to_add = self._data_provider.missing_cases + + prompt_message = PROMPT_MESSAGES["create_missing_test_cases"].format( + project_name=self.environment.project + ) + fault_message = FAULT_MAPPING["no_user_agreement"].format(type="test cases") + + if not self.environment.get_prompt_response_for_auto_creation(prompt_message): + self.environment.elog(fault_message) + self._rollback_and_exit() + + start_time = time.time() + self.environment.log(ProcessingMessages.ADDING, new_line=False) + error_message = self._api_request_handler.add_cases(cases_to_add) + + if error_message: + self.environment.elog(ErrorMessages.ADDING_TEST_CASES_F_ERROR.format(error=error_message)) + self._rollback_and_exit() + + success_message = SuccessMessages.ADDED_CASES_FF_AMOUNT_ELAPSED.format( + amount=len(self._data_provider.created_cases_ids), + elapsed=(start_time - time.time()) + ) + self.environment.log(success_message) + + def _resolve_run_and_results_upload(self) -> None: + self.create_or_update_test_run() + + start = time.time() + results_amount, error_message = self._api_request_handler.add_results() + if error_message: + self.environment.elog(error_message) + self._rollback_and_exit() + + stop = time.time() + + if results_amount: + success_message = SuccessMessages.ADDED_RESULTS_FF_AMOUNT_ELAPSED.format( + amount=results_amount, + elapsed=(stop - start) + ) + self.environment.log(success_message) + + self._close_test_run_if_required() + + def _close_test_run_if_required(self) -> None: + """ + Closes the test run if the user requested it. + Exits with result code set to 1 in case of a failure. + """ + if self.environment.close_run: + self.environment.log(ProcessingMessages.CLOSING_TEST_RUN, new_line=False) + error_message = self._api_request_handler.close_run() + if error_message: + self.environment.elog(ErrorMessages.CLOSING_TEST_RUN_F_ERROR.format(error=error_message)) + exit(1) + self.environment.log(SuccessMessages.CLOSED_RUN) + + + def _create_test_run(self) -> Tuple[Optional[int], str]: + """ + Creates a new test run in TestRail. + """ + self.environment.log(ProcessingMessages.CREATING_TEST_RUN, new_line=False) + if self._data_provider.test_plan_id: + return self._api_request_handler.add_run_to_plan() + + return self._api_request_handler.add_run() + + def _update_test_run(self) -> str: + """ + Updates an existing test run in TestRail. + If the run is part of a plan, it updates the run entry in the plan. + If the run is standalone, it updates the run directly. + returns an error message if any issue occurs, otherwise returns an empty string. + """ + + # 1. Get run + existing_run, error_message = self._api_request_handler.get_run() + + if error_message: + run_id = self._data_provider.test_run_id + return ErrorMessages.RETRIEVING_RUN_INFO_FF_RUN_ID_ERROR.format(run_id=run_id, error=error_message) + + # 2. Get cases in run + self.environment.log(ProcessingMessages.UPDATING_TEST_RUN, new_line=False) + run_cases_ids, error_message = self._api_request_handler.get_cases_ids_in_run() + + if error_message: + return ErrorMessages.RETRIEVING_TESTS_IN_IN_RUN_F_ERROR.format(error=error_message) + + self._data_provider.merge_run_case_ids(run_cases_ids) + + # 3. Update description + existing_run_description = existing_run.get("description", "") + self._data_provider.update_test_run_description(existing_run_description) + + plan_id = existing_run.get("plan_id") + config_ids = existing_run.get("config_ids") + + # 4. Standalone run + if not plan_id: + self.environment.log(ProcessingMessages.CONTINUE_AS_STANDALONE_RUN, new_line=False) + error_message = self._api_request_handler.update_run() + return error_message + + # 5. Plan run with configs + if config_ids: + error_message = self._api_request_handler.update_run_in_plan_entry() + return error_message + + # 6. Plan run without configs → need entry ID + entry_id, error_message = self._api_request_handler.get_run_entry_id_in_plan(plan_id) + + if error_message: + return ErrorMessages.RETRIEVING_RUN_ID_IN_PLAN_F_ERROR.format(error=error_message) + + error_message = self._api_request_handler.update_plan_entry(plan_id, entry_id) + + if not error_message: + run_id = self._data_provider.test_run_id + link = self.environment.host.rstrip('/') + self.environment.log(SuccessMessages.UPDATED_RUN_FF_LINK_RUN_ID.format(limk=link, run_id=run_id)) + + return error_message + + def _rollback_and_exit(self) -> None: + """ + Roll back created entities and terminate the process. + """ + logs = self._rollback_changes() + if logs: + self.environment.log("\n".join(logs)) + + stop = time.time() + self.environment.log(SuccessMessages.COMPLETED_IN_F_ELAPSED.format(elapsed=(stop - self._start_time))) + exit(1) + + def _rollback_changes(self) -> List[str]: + """ + Attempts to roll back created entities (run, cases, sections, suite). + Returns a list of error messages for any failures. + """ + rollback_actions = { + "run": self._api_request_handler.delete_created_run, + "cases": self._api_request_handler.delete_created_cases, + "sections": self._api_request_handler.delete_created_sections, + "suite": self._api_request_handler.delete_created_suite, + } + + log_messages: List[str] = [] + for entity, action in rollback_actions.items(): + log_message = action() + + if log_message and log_message.strip(): + log_messages.append(log_message) + return log_messages diff --git a/trcli/data_providers/api_data_provider_v2.py b/trcli/data_providers/api_data_provider_v2.py new file mode 100644 index 00000000..6f3454d5 --- /dev/null +++ b/trcli/data_providers/api_data_provider_v2.py @@ -0,0 +1,250 @@ +from typing import List, Dict, Optional + +from trcli.cli import Environment +from trcli.data_classes.dataclass_testrail import TestRailSuite, TestRun, TestRailCase, TestRailSection + + +class DataProviderException(Exception): + """Custom exception type for data-provider-level errors like None field/not initiated data failures.""" + def __init__(self, message=""): + self.message = message + super().__init__(self.message) + + +class ApiDataProvider: + """ + ApiDataProvider is responsible for preparing and storing data + """ + def __init__(self, suites_input: TestRailSuite, environment: Environment): + self.suites_input = suites_input + self._environment = environment + + self.case_fields = self._environment.case_fields + self.run_description = self._environment.run_description + self.result_fields = self._environment.result_fields + self._update_parent_section() + self._add_global_case_fields() + + self._project_id: Optional[int] = None + self._test_run: Optional[TestRun] = None + self.test_run_id: Optional[int] = None + self.test_plan_id: Optional[int] = None + self.config_ids: Optional[List[int]] = None + self._automation_id_system_name: Optional[str] = None + + self.existing_cases: List[TestRailCase] = [] + self.missing_cases: List[TestRailCase] = [] + + self.created_suite_id: Optional[int] = None + self.created_sections_ids: Optional[List[int]] = None + self.updated_cases_ids: List[int] = [] + self.created_cases_ids: List[int] = [] + self.created_test_run_id: Optional[int] = None + + + @property + def project_id(self) -> int: + """Return project ID.""" + if self._project_id is None: + raise DataProviderException("Project ID is not initialized. Resolve project and update project id first.") + return self._project_id + + @project_id.setter + def project_id(self, project_id: int) -> None: + """Set project ID.""" + self._project_id = project_id + + @property + def suite_id(self) -> int: + """Return suite ID.""" + if self.suites_input.suite_id is None: + raise DataProviderException("Suite ID is not initialized. Resolve suite and update suite id first.") + return self.suites_input.suite_id + + @property + def test_run(self) -> TestRun: + """Return test run object.""" + if self._test_run is None: + raise DataProviderException("Test run is not initialized. Call add_run() first.") + return self._test_run + + @property + def automation_id_system_name(self) -> Optional[str]: + """Return automation ID system name.""" + if self._automation_id_system_name is None: + raise DataProviderException("Automation ID system name is not initialized") + return self._automation_id_system_name + + def collect_all_testcases(self) -> List[TestRailCase]: + return [c for s in self.suites_input.testsections for c in self._recurse_cases(s)] + + def collect_all_testcases_by_automation_id(self) -> Dict[str,TestRailCase]: + """Collect all test cases by their automation IDs.""" + all_cases = self.collect_all_testcases() + cases_by_automation_id = {} + for case in all_cases: + if aut_id:= case.case_fields.get(self.automation_id_system_name): + cases_by_automation_id[aut_id] = case + return cases_by_automation_id + + def update_suite_id(self, suite_id: int, is_created: bool=False) -> None: + """Update suite_id in the input suite.""" + self.suites_input.suite_id = suite_id + for section in self._collect_all_sections(): + section.suite_id = suite_id + + if self._test_run is not None: + self._test_run.suite_id = suite_id + if is_created: + self.created_suite_id = suite_id + + def update_cases_section_ids(self): + """Update case section IDs with actual section IDs.""" + for section in self._collect_all_sections(): + if section.section_id is None: + raise DataProviderException(f"Section ID is not initialized for section: {section.name}.") + for case in section.testcases: + case.section_id = section.section_id + + def update_test_run_description(self, description: str) -> None: + """Update test run description.""" + if self._test_run is None: + raise DataProviderException("Test run is not initialized. Call add_run() first.") + self.test_run.description = description + + def update_test_run_id_if_created(self, run_id: int) -> None: + self.created_test_run_id = run_id + self.test_run_id = run_id + + def update_custom_automation_id_system_name(self, automation_id_system_name: str) -> None: + """ + Update custom_automation_id field name with actual system name in all test cases. + Needs to be explained: + I don't like that we have custom_automation_id field in TestRailCase dataclass, + because it is not standard field in TestRail and might have another name as we see. + #TODO But I decided leave it as is, otherwise the field must be removed from data class + #TODO and needs to change couple lines of code in parser. + Here I just update custom_case fields with actual system name of that field and value. + So later when case data object serialized to dict it will have correct field name. + I added {"serde_skip": True} in data class to ignore for serialization if the name is different, + but it seems not necessary, since if the field doesn't exist in TestRail, + fortunately it will be just dropped during the POST. + Kind reminder: currently parser sets custom_automation_id whatever MatchersParser.AUTO or not. + For future filtering by custom_automation_id you should use the value stored in case_fields + and self.automation_id_system_name as key. + """ + self._automation_id_system_name = automation_id_system_name + for case in self.collect_all_testcases(): + automation_id = case.custom_automation_id + case.case_fields[self.automation_id_system_name] = automation_id + + def add_run(self) -> None: + run_name = self._environment.title + if self._environment.special_parser == "saucectl": + run_name += f" ({self.suites_input.name})" + + run = TestRun(name=run_name, + description=self._environment.run_description, + milestone_id=self._environment.milestone_id, + assignedto_id=self._environment.run_assigned_to_id, + include_all=bool(self._environment.run_include_all), + refs=self._environment.run_refs, + start_on=self._environment.run_start_date, + due_on=self._environment.run_end_date) + + if run.suite_id is None: + run.suite_id = self.suite_id + + if not run.case_ids: + run.case_ids = [case.case_id for case in self.collect_all_testcases() if case.case_id is not None] + + properties = [ + str(prop) + for section in self.suites_input.testsections + for prop in section.properties + if prop.description is not None + ] + + if run.description: + properties.insert(0, f"{run.description}") + run.description = "\n".join(properties) + + if self._environment.run_id: + self.test_run_id = self._environment.run_id + + if self._environment.plan_id: + self.test_plan_id = self._environment.plan_id + + if self._environment.config_ids: + self.config_ids = self._environment.config_ids + + self._test_run = run + + def merge_run_case_ids(self, run_case_ids: List[int]) -> None: + """Merge existing run case IDs with the ones from the suite.""" + if self.test_run is None: + self.add_run() + if self.test_run.case_ids is None: + self.test_run.case_ids = [] + self.test_run.case_ids = list(set(self.test_run.case_ids + run_case_ids)) + + def get_results_for_cases(self): + """Return bodies for adding results for cases. Returns bodies for results that already have case ID.""" + bodies = [] + testcases = self.collect_all_testcases() + for case in testcases: + if case.case_id is not None: + case.result.add_global_result_fields(self.result_fields) + case.result.case_id = case.case_id + bodies.append(case.result.to_dict()) + + result_bulks = self._divide_list_into_bulks( + bodies, + bulk_size=self._environment.batch_size, + ) + return [{"results": result_bulk} for result_bulk in result_bulks] + + def check_section_names_duplicates(self): + """ + Check if section names in result xml file are duplicated. + #TODO I don't see a reason to use this method, since TestRail allows to have sections with the same name. + In our case the first found with specified name will be selected. + If we want to prevent name duplication we must validate it at parser level. + #TODO Now I just leave it here, latter will be removed. + """ + sections_names = [sections.name for sections in self.suites_input.testsections] + + if len(sections_names) == len(set(sections_names)): + return False + else: + return True + + @staticmethod + def _divide_list_into_bulks(input_list: List, bulk_size: int) -> List: + return [ + input_list[i : i + bulk_size] for i in range(0, len(input_list), bulk_size) + ] + + def _collect_all_sections(self) -> List[TestRailSection]: + return [s for s in self.suites_input.testsections] + \ + [ss for s in self.suites_input.testsections for ss in self._recurse_sections(s)] + + def _recurse_cases(self, section: TestRailSection) -> List[TestRailCase]: + return list(section.testcases) + [tc for ss in section.sub_sections for tc in self._recurse_cases(ss)] + + def _recurse_sections(self, section: TestRailSection) -> List[TestRailSection]: + return list(section.sub_sections) + [ss for s in section.sub_sections for ss in self._recurse_sections(s)] + + def _add_global_case_fields(self): + """Update case fields with global case fields.""" + # not sure should we check if case_fields have custom_automation_id + # which must be unique for each case and shouldn't be global + # may be cli should prevent that + + for case in self.collect_all_testcases(): + case.add_global_case_fields(self.case_fields) + + def _update_parent_section(self): + if parent_section_id:= self._environment.section_id: + for section in self.suites_input.testsections: + section.parent_id = parent_section_id \ No newline at end of file From 8127bca8039d7af7b6a570d2d37870921b51bedc Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Fri, 29 Aug 2025 11:21:28 +0300 Subject: [PATCH 10/17] added missing sections check for confirmation, excluded cases section id update if update is not allowed --- trcli/api/api_request_handler_v2.py | 13 +++++++++++++ trcli/api/api_request_helpers.py | 20 +++++++++++++------- trcli/api/results_uploader_v2.py | 18 +++++++++++++++++- trcli/constants.py | 1 + trcli/data_providers/api_data_provider_v2.py | 11 +++++++++++ 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/trcli/api/api_request_handler_v2.py b/trcli/api/api_request_handler_v2.py index 58d12463..993a4a16 100644 --- a/trcli/api/api_request_handler_v2.py +++ b/trcli/api/api_request_handler_v2.py @@ -226,6 +226,19 @@ def close_run(self) -> str: """ return self._run_handler.close_run(self._data_provider.test_run_id) + def has_missing_sections(self) -> Tuple[bool, str]: + """ + Check if there are sections missing in TestRail compared to the data provider. + iterates through all sections, if it finds first missing section,stops and returns True. + :returns: Tuple with boolean indicating if there are missing sections and error string. + """ + try: + return self._section_handler.has_missing_sections(), "" + except EntityException as e: + return False, e.message + except KeyError as e: + return False, f"Invalid response structure checking for missing sections: {str(e)}" + def add_missing_sections(self) -> Tuple[List[int], str]: """ Adds sections that are missing in TestRail. diff --git a/trcli/api/api_request_helpers.py b/trcli/api/api_request_helpers.py index 0515c7a5..5f8a6096 100644 --- a/trcli/api/api_request_helpers.py +++ b/trcli/api/api_request_helpers.py @@ -453,13 +453,11 @@ def _init_entities_cache(self) -> Tuple[List[dict], str]: suite_id = self._provider.suite_id return self._get_all_entities("sections", f"get_sections/{project_id}&suite_id={suite_id}") - def _is_section_missed(self,section: TestRailSection, parent_id: Optional[int] = None) -> bool: - entities = self.entities - matched = next((s for s in entities if s["parent_id"] == parent_id and s["name"] == section.name), None) - if not matched: - return True - section.section_id = matched["id"] - return any(self._is_section_missed(sub, section.section_id) for sub in section.sub_sections) + def has_missing_sections(self) -> bool: + for section in self._provider.suites_input.testsections: + if self._is_section_missed(section): + return True + return False def create_missing_sections(self) -> Tuple[List[int], str]: added_ids = [] @@ -471,6 +469,14 @@ def create_missing_sections(self) -> Tuple[List[int], str]: # important to reverse the order if we need to delete sections later in case rollback return list(reversed(added_ids)), "" + def _is_section_missed(self,section: TestRailSection, parent_id: Optional[int] = None) -> bool: + entities = self.entities + matched = next((s for s in entities if s["parent_id"] == parent_id and s["name"] == section.name), None) + if not matched: + return True + parent_id = matched["id"] + return any(self._is_section_missed(sub, parent_id) for sub in section.sub_sections) + def _create_section_tree(self, section: TestRailSection, parent_id: Optional[int] = None) -> Tuple[List[int], str]: """ Creates a section tree recursively. diff --git a/trcli/api/results_uploader_v2.py b/trcli/api/results_uploader_v2.py index e2ab3631..e2408f8b 100644 --- a/trcli/api/results_uploader_v2.py +++ b/trcli/api/results_uploader_v2.py @@ -100,7 +100,7 @@ def _resolve_sections_and_cases_upload(self) -> None: self.environment.log(SkippingMessage.NO_TEST_CASES_TO_ADD) def _handle_sections_upload(self) -> None: - #no needs to check if any missing section, it will upload only missing + self._confirmation_for_sections_upload() added_sections, error_message = self._upload_sections() self._data_provider.created_sections_ids = added_sections if error_message: @@ -127,6 +127,22 @@ def _upload_sections(self) -> Tuple[List[int], str]: self.environment.log(ProcessingMessages.ADDING_SECTIONS) return self._api_request_handler.add_missing_sections() + def _confirmation_for_sections_upload(self) -> None: + has_missing_sections, error_message = self._api_request_handler.has_missing_sections() + if error_message: + self.environment.elog(ErrorMessages.CHECKING_MISSING_SECTIONS_F_ERROR.format(error=error_message)) + self._rollback_and_exit() + + if not has_missing_sections: + return + + prompt_message = PROMPT_MESSAGES["create_missing_sections"].format(project_name=self.project.name) + fault_message = FAULT_MAPPING["no_user_agreement"].format(type="sections") + + if not self.environment.get_prompt_response_for_auto_creation(prompt_message): + self.environment.elog(fault_message) + self._rollback_and_exit() + def _update_existing_test_cases(self) -> None: cases_to_update = self._data_provider.existing_cases self.environment.log(ProcessingMessages.UPDATING, new_line=False) diff --git a/trcli/constants.py b/trcli/constants.py index ee672d6d..0ccd544c 100644 --- a/trcli/constants.py +++ b/trcli/constants.py @@ -162,6 +162,7 @@ class ErrorMessages: and following part of the name is arg(s) name(s) for formating Number of F shows how many args are needed """ + CHECKING_MISSING_SECTIONS_F_ERROR = "Error checking existing and missing sections: \n{error}" CAN_NOT_RESOLVE_SUITE_F_ERROR = "Can not resolve suite: \n{error}" CAN_NOT_ADD_SUITE_F_ERROR = "Can not add suite: \n{error}" NO_SUITE_ID = "Suite ID is not provided and no suite found by name or created." diff --git a/trcli/data_providers/api_data_provider_v2.py b/trcli/data_providers/api_data_provider_v2.py index 6f3454d5..d8fe09f6 100644 --- a/trcli/data_providers/api_data_provider_v2.py +++ b/trcli/data_providers/api_data_provider_v2.py @@ -100,12 +100,23 @@ def update_suite_id(self, suite_id: int, is_created: bool=False) -> None: def update_cases_section_ids(self): """Update case section IDs with actual section IDs.""" + case_ids_to_update = self._collect_cases_id_from_existing_cases() for section in self._collect_all_sections(): + if section.section_id is None: raise DataProviderException(f"Section ID is not initialized for section: {section.name}.") + for case in section.testcases: + #no need to update section_id for existing cases if update is not allowed + if not self._environment.update_cases: + if case.case_id in case_ids_to_update: + continue case.section_id = section.section_id + + def _collect_cases_id_from_existing_cases(self) -> set[int]: + return {case.case_id for case in self.existing_cases if case.case_id is not None} + def update_test_run_description(self, description: str) -> None: """Update test run description.""" if self._test_run is None: From f3bd0a26625e31aa07789f117e14b6d1f9a370ca Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Fri, 29 Aug 2025 13:26:45 +0300 Subject: [PATCH 11/17] removed redundant method --- trcli/api/results_uploader_v2.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/trcli/api/results_uploader_v2.py b/trcli/api/results_uploader_v2.py index e2408f8b..ae4eff9f 100644 --- a/trcli/api/results_uploader_v2.py +++ b/trcli/api/results_uploader_v2.py @@ -100,34 +100,22 @@ def _resolve_sections_and_cases_upload(self) -> None: self.environment.log(SkippingMessage.NO_TEST_CASES_TO_ADD) def _handle_sections_upload(self) -> None: - self._confirmation_for_sections_upload() - added_sections, error_message = self._upload_sections() + self._get_confirmation_for_sections_upload() + self.environment.log(ProcessingMessages.ADDING_SECTIONS) + added_sections, error_message = self._api_request_handler.add_missing_sections() self._data_provider.created_sections_ids = added_sections + if error_message: self.environment.elog(ErrorMessages.UPLOADING_SECTIONS_F_ERROR.format(error=error_message)) self._rollback_and_exit() - # Update section IDs when sections are found or created. # if --update-cases option specified, for existing cases section or subsection id will be updated as well self._data_provider.update_cases_section_ids() - def _upload_sections(self) -> Tuple[List[int], str]: - """ - Uploads missing sections to the suite if user agrees to do so. - Exits with result code set to 1 in case of a failure. - returns a tuple with list of created section IDs and error message (if any). - """ - prompt_message = PROMPT_MESSAGES["create_missing_sections"].format(project_name=self.project.name) - fault_message = FAULT_MAPPING["no_user_agreement"].format(type="sections") - - if not self.environment.get_prompt_response_for_auto_creation(prompt_message): - self.environment.elog(fault_message) - self._rollback_and_exit() - self.environment.log(ProcessingMessages.ADDING_SECTIONS) return self._api_request_handler.add_missing_sections() - def _confirmation_for_sections_upload(self) -> None: + def _get_confirmation_for_sections_upload(self) -> None: has_missing_sections, error_message = self._api_request_handler.has_missing_sections() if error_message: self.environment.elog(ErrorMessages.CHECKING_MISSING_SECTIONS_F_ERROR.format(error=error_message)) From 348c0d61b191a53756ca0283e311a27a91a4902a Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Sat, 30 Aug 2025 10:22:54 +0300 Subject: [PATCH 12/17] fixed few bugs related to suite resolving, related refactoring as wall --- trcli/api/api_request_handler_v2.py | 2 +- trcli/api/project_based_client_v2.py | 85 ++++++++++++++++++---------- trcli/constants.py | 19 ++++++- 3 files changed, 71 insertions(+), 35 deletions(-) diff --git a/trcli/api/api_request_handler_v2.py b/trcli/api/api_request_handler_v2.py index 993a4a16..4f027f70 100644 --- a/trcli/api/api_request_handler_v2.py +++ b/trcli/api/api_request_handler_v2.py @@ -69,7 +69,7 @@ def resolve_suite_id_using_name(self) -> Tuple[Optional[int], str]: :returns: tuple with id of the suite and error message """ try: - entities = self._section_handler.entities + entities = self._suite_handler.entities suite_name = self._data_provider.suites_input.name suite = next(filter(lambda x: x["name"] == suite_name, entities), None) if suite: diff --git a/trcli/api/project_based_client_v2.py b/trcli/api/project_based_client_v2.py index 8e64341f..612169f8 100644 --- a/trcli/api/project_based_client_v2.py +++ b/trcli/api/project_based_client_v2.py @@ -4,7 +4,7 @@ from trcli.api.api_request_handler_v2 import ApiRequestHandler from trcli.cli import Environment from trcli.constants import ProjectErrors, FAULT_MAPPING, SuiteModes, PROMPT_MESSAGES, ProcessingMessages, \ - SuccessMessages, ErrorMessages + SuccessMessages, ErrorMessages, ErrorMessagesSuites from trcli.data_classes.data_parsers import MatchersParser from trcli.data_classes.dataclass_testrail import TestRailSuite, ProjectData from trcli.data_providers.api_data_provider_v2 import ApiDataProvider @@ -115,53 +115,76 @@ def _handle_suite_by_id(self) -> None: existing, error_message = self._api_request_handler.check_suite_id() if error_message: - self._exit_with_error(ErrorMessages.CAN_NOT_RESOLVE_SUITE_F_ERROR.format(error=error_message)) + self._exit_with_error(ErrorMessagesSuites.CAN_NOT_RESOLVE_SUITE_F_ERROR.format(error=error_message)) + if not existing: self._exit_with_error( - f"Not existing suite ID provided: {self.environment.suite_id} " - f"for project: {self.project.name} with id: {self.project.project_id}." - ) + ErrorMessagesSuites.NOT_EXISTING_F_SUITE_ID.format(suite_id=self.environment.suite_id)) def _handle_suite_by_name(self) -> None: """Handles suite selection when suite name is provided.""" suite_mode = self.project.suite_mode project_id = self.project.project_id - if suite_mode not in (SuiteModes.multiple_suites, SuiteModes.single_suite_baselines): + if suite_mode not in ( + SuiteModes.multiple_suites, + SuiteModes.single_suite_baselines, + SuiteModes.single_suite, + ): + self._exit_with_error(ErrorMessagesSuites.UNKNOWN_SUITE_MODE_F_MODE.format(mode=suite_mode)) + + if suite_mode == SuiteModes.single_suite: + self._handle_single_suite() + return + + if suite_mode == SuiteModes.single_suite_baselines: + self._handle_single_suite_baselines() + return + + if suite_mode == SuiteModes.multiple_suites: + self._handle_multiple_suites(project_id) + return + + def _handle_single_suite(self) -> None: + suite_ids, error_message = self._api_request_handler.get_suites_ids() + if error_message: self._exit_with_error( - FAULT_MAPPING["unknown_suite_mode"].format(suite_mode=suite_mode) + ErrorMessagesSuites.CAN_NOT_RESOLVE_SUITE_F_ERROR.format(error=error_message) ) + if not suite_ids: + self._exit_with_error(ErrorMessagesSuites.NO_SUITES_IN_SINGLE_SUITE_MODE) - suite_id, error_message = self._api_request_handler.resolve_suite_id_using_name() + self._data_provider.update_suite_id(suite_ids[0]) + + def _handle_single_suite_baselines(self) -> None: + suite_ids, error_message = self._api_request_handler.get_suites_ids() if error_message: - self._exit_with_error(ErrorMessages.CAN_NOT_RESOLVE_SUITE_F_ERROR.format(error=error_message)) + self._exit_with_error( + ErrorMessagesSuites.CAN_NOT_RESOLVE_SUITE_F_ERROR.format(error=error_message) + ) + if len(suite_ids) > 1: + self._exit_with_error(ErrorMessagesSuites.ONE_OR_MORE_BASE_LINE_CREATED) + self._data_provider.update_suite_id(suite_ids[0]) + + def _handle_multiple_suites(self, project_id: int) -> None: + # First try resolving by name + suite_id, error_message = self._api_request_handler.resolve_suite_id_using_name() + if error_message: + self._exit_with_error( + ErrorMessagesSuites.CAN_NOT_RESOLVE_SUITE_F_ERROR.format(error=error_message) + ) if suite_id: self._data_provider.update_suite_id(suite_id) - self.environment.log(f"Using suite ID: {suite_id} for project (ID): {project_id}.") return - if suite_mode == SuiteModes.multiple_suites: - suite_id, error_message = self._prompt_user_and_add_suite() - if error_message: - self._exit_with_error(ErrorMessages.CAN_NOT_ADD_SUITE_F_ERROR.format(error=error_message)) - self._data_provider.update_suite_id(suite_id, is_created=True) - return - - if suite_mode == SuiteModes.single_suite_baselines: - suite_ids, error_message = self._api_request_handler.get_suites_ids() - if error_message: - self._exit_with_error(ErrorMessages.CAN_NOT_RESOLVE_SUITE_F_ERROR.format(error=error_message)) - if len(suite_ids) != 1: - self._exit_with_error( - FAULT_MAPPING["not_unique_suite_id_single_suite_baselines"].format( - project_name=self.environment.project - ) - ) - self._data_provider.update_suite_id(suite_ids[0]) - - if self._data_provider.suites_input.suite_id is None: - self._exit_with_error(ErrorMessages.NO_SUITE_ID) + # Otherwise, prompt user to add a suite + suite_id, error_message = self._prompt_user_and_add_suite() + if error_message: + self._exit_with_error( + ErrorMessagesSuites.CAN_NOT_ADD_SUITE_F_ERROR.format(error=error_message) + ) + self._data_provider.update_suite_id(suite_id, is_created=True) def _prompt_user_and_add_suite(self) -> Tuple[Optional[int], str]: prompt_message = PROMPT_MESSAGES["create_new_suite"].format( diff --git a/trcli/constants.py b/trcli/constants.py index 0ccd544c..a72f1388 100644 --- a/trcli/constants.py +++ b/trcli/constants.py @@ -153,6 +153,22 @@ class SuccessMessages: COMPLETED_IN_F_ELAPSED = "Completed in {elapsed:.1f} secs." DONE = "Done." +class ErrorMessagesSuites: + """ + Messages displayed on errors related to suite resolving. + F in constant name means that the constant is f-string + and following part of the name is arg(s) name(s) for formating + Number of F shows how many args are needed + """ + UNKNOWN_SUITE_MODE_F_MODE = "Suite mode: '{mode}' not recognised." + CAN_NOT_RESOLVE_SUITE_F_ERROR = "Can not resolve suite: \n{error}" + NO_SUITES_IN_SINGLE_SUITE_MODE = "No suites found in single suite mode." + CAN_NOT_ADD_SUITE_F_ERROR = "Can not add suite: \n{error}" + ONE_OR_MORE_BASE_LINE_CREATED = "One or more baselines created under '{project_name}' (single suite " + "with baseline project). Please provide suite ID by " + "specifying --suite-id." + NOT_EXISTING_F_SUITE_ID = "Not existing suite id specified --suite-id: {suite_id}" + class ErrorMessages: """ @@ -163,9 +179,6 @@ class ErrorMessages: Number of F shows how many args are needed """ CHECKING_MISSING_SECTIONS_F_ERROR = "Error checking existing and missing sections: \n{error}" - CAN_NOT_RESOLVE_SUITE_F_ERROR = "Can not resolve suite: \n{error}" - CAN_NOT_ADD_SUITE_F_ERROR = "Can not add suite: \n{error}" - NO_SUITE_ID = "Suite ID is not provided and no suite found by name or created." CREATING_UPDATING_TESTRUN_F_ERROR = "Error creating or updating test run: \n{error}" SORTING_TEST_CASES_F_ERROR = "Error checking existing and missing test cases: \n{error}" UPLOADING_SECTIONS_F_ERROR = "Error uploading sections: \n{error}" From f858f97363c6227dccc0e1305225509937f23985 Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Sat, 30 Aug 2025 15:50:09 +0300 Subject: [PATCH 13/17] slightly refactored test run add/ update --- trcli/api/results_uploader_v2.py | 42 ++++++++++++-------- trcli/constants.py | 21 +++++++--- trcli/data_providers/api_data_provider_v2.py | 5 ++- 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/trcli/api/results_uploader_v2.py b/trcli/api/results_uploader_v2.py index ae4eff9f..4056b7ab 100644 --- a/trcli/api/results_uploader_v2.py +++ b/trcli/api/results_uploader_v2.py @@ -1,10 +1,10 @@ import time -from typing import Tuple, List, Optional +from typing import List from trcli.api.project_based_client_v2 import ProjectBasedClient from trcli.cli import Environment from trcli.constants import PROMPT_MESSAGES, FAULT_MAPPING, SuccessMessages, ProcessingMessages, SkippingMessage, \ - ErrorMessages + ErrorMessages, ErrorMessagesRun from trcli.data_classes.dataclass_testrail import TestRailSuite from trcli.data_providers.api_data_provider_v2 import DataProviderException @@ -67,11 +67,10 @@ def create_or_update_test_run(self) -> int: if self._data_provider.test_run_id: error_message = self._update_test_run() else: - run_id, error_message = self._create_test_run() - self._data_provider.update_test_run_id_if_created(run_id) + error_message = self._create_test_run() if error_message: - self.environment.elog(ErrorMessages.CREATING_UPDATING_TESTRUN_F_ERROR.format(error=error_message)) + self.environment.elog(error_message) self._rollback_and_exit() return self._data_provider.test_run_id @@ -204,20 +203,29 @@ def _close_test_run_if_required(self) -> None: self.environment.log(ProcessingMessages.CLOSING_TEST_RUN, new_line=False) error_message = self._api_request_handler.close_run() if error_message: - self.environment.elog(ErrorMessages.CLOSING_TEST_RUN_F_ERROR.format(error=error_message)) + self.environment.elog(ErrorMessagesRun.CLOSING_TEST_RUN_F_ERROR.format(error=error_message)) exit(1) self.environment.log(SuccessMessages.CLOSED_RUN) - def _create_test_run(self) -> Tuple[Optional[int], str]: + def _create_test_run(self) -> str: """ Creates a new test run in TestRail. """ self.environment.log(ProcessingMessages.CREATING_TEST_RUN, new_line=False) + if self._data_provider.test_plan_id: - return self._api_request_handler.add_run_to_plan() + run_id, error_message = self._api_request_handler.add_run_to_plan() + else: + run_id, error_message = self._api_request_handler.add_run() + + if error_message or (run_id is None): + error_message = error_message or "No run ID returned." + return ErrorMessagesRun.CREATING_TEST_RUN_F_ERROR.format(error=error_message) - return self._api_request_handler.add_run() + self._data_provider.update_test_run_id(run_id, is_created=True) + self._log_run_link() + return "" def _update_test_run(self) -> str: """ @@ -226,20 +234,19 @@ def _update_test_run(self) -> str: If the run is standalone, it updates the run directly. returns an error message if any issue occurs, otherwise returns an empty string. """ - # 1. Get run existing_run, error_message = self._api_request_handler.get_run() if error_message: run_id = self._data_provider.test_run_id - return ErrorMessages.RETRIEVING_RUN_INFO_FF_RUN_ID_ERROR.format(run_id=run_id, error=error_message) + return ErrorMessagesRun.RETRIEVING_RUN_INFO_FF_RUN_ID_ERROR.format(run_id=run_id, error=error_message) # 2. Get cases in run self.environment.log(ProcessingMessages.UPDATING_TEST_RUN, new_line=False) run_cases_ids, error_message = self._api_request_handler.get_cases_ids_in_run() if error_message: - return ErrorMessages.RETRIEVING_TESTS_IN_IN_RUN_F_ERROR.format(error=error_message) + return ErrorMessagesRun.RETRIEVING_TESTS_IN_IN_RUN_F_ERROR.format(error=error_message) self._data_provider.merge_run_case_ids(run_cases_ids) @@ -265,17 +272,20 @@ def _update_test_run(self) -> str: entry_id, error_message = self._api_request_handler.get_run_entry_id_in_plan(plan_id) if error_message: - return ErrorMessages.RETRIEVING_RUN_ID_IN_PLAN_F_ERROR.format(error=error_message) + return ErrorMessagesRun.RETRIEVING_RUN_ID_IN_PLAN_F_ERROR.format(error=error_message) error_message = self._api_request_handler.update_plan_entry(plan_id, entry_id) if not error_message: - run_id = self._data_provider.test_run_id - link = self.environment.host.rstrip('/') - self.environment.log(SuccessMessages.UPDATED_RUN_FF_LINK_RUN_ID.format(limk=link, run_id=run_id)) + self._log_run_link() return error_message + def _log_run_link(self) -> None: + run_id = self._data_provider.test_run_id + link = self.environment.host.rstrip('/') + self.environment.log(SuccessMessages.RUN_FF_LINK_RUN_ID.format(link=link, run_id=run_id)) + def _rollback_and_exit(self) -> None: """ Roll back created entities and terminate the process. diff --git a/trcli/constants.py b/trcli/constants.py index a72f1388..5087f16d 100644 --- a/trcli/constants.py +++ b/trcli/constants.py @@ -147,7 +147,7 @@ class SuccessMessages: """ ADDED_CASES_FF_AMOUNT_ELAPSED = "Submitted {amount} test cases in {elapsed:.1f} secs." UPDATED_CASES_AMOUNT_FF_ACTUAL_EXPECTED = "Updated amount {actual}/{expected} test cases." - UPDATED_RUN_FF_LINK_RUN_ID = "Test run: {link}/index.php?/runs/view/{run_id}" + RUN_FF_LINK_RUN_ID = "Test run: {link}/index.php?/runs/view/{run_id}" ADDED_RESULTS_FF_AMOUNT_ELAPSED = "Submitted {amount} test results in {elapsed:.1f} secs." CLOSED_RUN = "Run closed successfully." COMPLETED_IN_F_ELAPSED = "Completed in {elapsed:.1f} secs." @@ -169,6 +169,20 @@ class ErrorMessagesSuites: "specifying --suite-id." NOT_EXISTING_F_SUITE_ID = "Not existing suite id specified --suite-id: {suite_id}" +class ErrorMessagesRun: + """ + Messages displayed on errors related to test run. + F in constant name means that the constant is f-string + and following part of the name is arg(s) name(s) for formating + Number of F shows how many args are needed + """ + CREATING_TEST_RUN_F_ERROR = "Error creating test run: \n{error}" + UPDATING_TEST_RUN_F_ERROR = "Error updating test run: \n{error}" + RETRIEVING_RUN_INFO_FF_RUN_ID_ERROR = "Error retrieving run by ID {run_id}: \n{error}" + RETRIEVING_TESTS_IN_IN_RUN_F_ERROR = "Error retrieving tests in run: \n{error}" + RETRIEVING_RUN_ID_IN_PLAN_F_ERROR ="Error retrieving run entry ID in plan: {error}" + CLOSING_TEST_RUN_F_ERROR = "Error closing test run: \n{error}" + class ErrorMessages: """ @@ -179,15 +193,10 @@ class ErrorMessages: Number of F shows how many args are needed """ CHECKING_MISSING_SECTIONS_F_ERROR = "Error checking existing and missing sections: \n{error}" - CREATING_UPDATING_TESTRUN_F_ERROR = "Error creating or updating test run: \n{error}" SORTING_TEST_CASES_F_ERROR = "Error checking existing and missing test cases: \n{error}" UPLOADING_SECTIONS_F_ERROR = "Error uploading sections: \n{error}" CASES_UPDATE_F_ERROR = "Error updating test cases: \n{error}" ADDING_TEST_CASES_F_ERROR = "Error adding test cases: \n{error}" - CLOSING_TEST_RUN_F_ERROR = "Error closing test run: \n{error}" - RETRIEVING_RUN_INFO_FF_RUN_ID_ERROR = "Error retrieving run by ID {run_id}: \n{error}" - RETRIEVING_TESTS_IN_IN_RUN_F_ERROR = "Error retrieving tests in run: \n{error}" - RETRIEVING_RUN_ID_IN_PLAN_F_ERROR ="Error retrieving run entry ID in plan: {error}" FAILED_TO_DEFINE_AUTOMATION_ID_FIELD = "Failed to define automation_id field system name." diff --git a/trcli/data_providers/api_data_provider_v2.py b/trcli/data_providers/api_data_provider_v2.py index d8fe09f6..152969bc 100644 --- a/trcli/data_providers/api_data_provider_v2.py +++ b/trcli/data_providers/api_data_provider_v2.py @@ -123,8 +123,9 @@ def update_test_run_description(self, description: str) -> None: raise DataProviderException("Test run is not initialized. Call add_run() first.") self.test_run.description = description - def update_test_run_id_if_created(self, run_id: int) -> None: - self.created_test_run_id = run_id + def update_test_run_id(self, run_id: int, is_created=False) -> None: + if is_created: + self.created_test_run_id = run_id self.test_run_id = run_id def update_custom_automation_id_system_name(self, automation_id_system_name: str) -> None: From 67978417796d2766d8848cc1c93aec4c517629ba Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Sat, 30 Aug 2025 16:41:03 +0300 Subject: [PATCH 14/17] replaced refference --- trcli/api/project_based_client_v2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trcli/api/project_based_client_v2.py b/trcli/api/project_based_client_v2.py index 612169f8..7cea27b4 100644 --- a/trcli/api/project_based_client_v2.py +++ b/trcli/api/project_based_client_v2.py @@ -189,9 +189,9 @@ def _handle_multiple_suites(self, project_id: int) -> None: def _prompt_user_and_add_suite(self) -> Tuple[Optional[int], str]: prompt_message = PROMPT_MESSAGES["create_new_suite"].format( suite_name=self._data_provider.suites_input.name, - project_name=self.environment.project, + project_name=self.project.name, ) - adding_message = ProcessingMessages.ADDING_SUITE_F_PROJECT_NAME.format(project_name=self.environment.project) + adding_message = ProcessingMessages.ADDING_SUITE_F_PROJECT_NAME.format(project_name=self.project.name) fault_message = FAULT_MAPPING["no_user_agreement"].format(type="suite") if not self.environment.get_prompt_response_for_auto_creation(prompt_message): From 526585dea29f9c027a86b1c5ce703483565aa0f8 Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Mon, 1 Sep 2025 13:09:46 +0300 Subject: [PATCH 15/17] moved instantiate_api_client to more appropriate place, changed some constants, some log messages are improved --- trcli/api/api_request_handler_v2.py | 7 +-- trcli/api/api_request_helpers.py | 40 +++++++++++++++++ trcli/api/project_based_client_v2.py | 55 +++--------------------- trcli/api/results_uploader_v2.py | 12 +++--- trcli/constants.py | 8 ++-- trcli/data_classes/dataclass_testrail.py | 26 +++++------ 6 files changed, 74 insertions(+), 74 deletions(-) diff --git a/trcli/api/api_request_handler_v2.py b/trcli/api/api_request_handler_v2.py index 4f027f70..2a9824cf 100644 --- a/trcli/api/api_request_handler_v2.py +++ b/trcli/api/api_request_handler_v2.py @@ -4,7 +4,8 @@ from typing import Optional,List, Tuple, Dict from trcli.api.api_request_helpers import SectionHandler, CaseHandler, ProjectHandler, SuiteHandler, RunHandler, \ - PlanHandler, TestHandler, ResultHandler, AttachmentHandler, FuturesHandler, EntityException, FutureActions + PlanHandler, TestHandler, ResultHandler, AttachmentHandler, FuturesHandler, EntityException, FutureActions, \ + instantiate_api_client from trcli.api.api_client import APIClient from trcli.api.api_response_verify import ApiResponseVerify from trcli.cli import Environment @@ -19,9 +20,9 @@ class ApiRequestHandler: Sends requests based on DataProvider data. Server as container for keeping all necessary handlers """ - def __init__(self, environment: Environment, api_client: APIClient, provider: ApiDataProvider): + def __init__(self, environment: Environment, provider: ApiDataProvider): self._environment = environment - self._client = api_client + self._client: APIClient = instantiate_api_client(self._environment) self._data_provider = provider self._response_verifier = ApiResponseVerify(self._environment.verify) self._section_handler = SectionHandler(environment, self._client, self._data_provider) diff --git a/trcli/api/api_request_helpers.py b/trcli/api/api_request_helpers.py index 5f8a6096..721a8f24 100644 --- a/trcli/api/api_request_helpers.py +++ b/trcli/api/api_request_helpers.py @@ -17,6 +17,46 @@ from trcli.data_providers.api_data_provider_v2 import ApiDataProvider +def instantiate_api_client(environment: Environment) -> APIClient: + """ + Instantiate api client with needed attributes taken from environment. + """ + verbose_logging_function = environment.vlog + logging_function = environment.log + proxy = environment.proxy # Will be None if --proxy is not defined + noproxy = environment.noproxy # Will be None if --noproxy is not defined + proxy_user = environment.proxy_user + if environment.timeout: + api_client = APIClient( + environment.host, + verbose_logging_function=verbose_logging_function, + logging_function=logging_function, + timeout=environment.timeout, + verify=not environment.insecure, + proxy=proxy, + proxy_user=proxy_user, + noproxy=noproxy + ) + else: + api_client = APIClient( + environment.host, + logging_function=logging_function, + verbose_logging_function=verbose_logging_function, + verify=not environment.insecure, + proxy=proxy, + proxy_user=proxy_user, + noproxy=noproxy + ) + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + api_client.proxy = environment.proxy + api_client.proxy_user = environment.proxy_user + api_client.noproxy = environment.noproxy + + return api_client + + class FutureActions(Enum): """Enum for feature actions""" ADD_CASE = "adding case" diff --git a/trcli/api/project_based_client_v2.py b/trcli/api/project_based_client_v2.py index 7cea27b4..8b0c44de 100644 --- a/trcli/api/project_based_client_v2.py +++ b/trcli/api/project_based_client_v2.py @@ -1,6 +1,5 @@ from typing import Optional, Tuple -from trcli.api.api_client import APIClient from trcli.api.api_request_handler_v2 import ApiRequestHandler from trcli.cli import Environment from trcli.constants import ProjectErrors, FAULT_MAPPING, SuiteModes, PROMPT_MESSAGES, ProcessingMessages, \ @@ -20,50 +19,7 @@ def __init__(self, environment: Environment, suite: TestRailSuite): self.project: Optional[ProjectData] = None self.environment = environment self._data_provider = ApiDataProvider(suite, self.environment) - self._api_request_handler = ApiRequestHandler( - environment=self.environment, - api_client=self._instantiate_api_client(), - provider=self._data_provider, - ) - - def _instantiate_api_client(self) -> APIClient: - """ - Instantiate api client with needed attributes taken from environment. - """ - verbose_logging_function = self.environment.vlog - logging_function = self.environment.log - proxy = self.environment.proxy # Will be None if --proxy is not defined - noproxy = self.environment.noproxy # Will be None if --noproxy is not defined - proxy_user = self.environment.proxy_user - if self.environment.timeout: - api_client = APIClient( - self.environment.host, - verbose_logging_function=verbose_logging_function, - logging_function=logging_function, - timeout=self.environment.timeout, - verify=not self.environment.insecure, - proxy=proxy, - proxy_user=proxy_user, - noproxy=noproxy - ) - else: - api_client = APIClient( - self.environment.host, - logging_function=logging_function, - verbose_logging_function=verbose_logging_function, - verify=not self.environment.insecure, - proxy=proxy, - proxy_user=proxy_user, - noproxy=noproxy - ) - api_client.username = self.environment.username - api_client.password = self.environment.password - api_client.api_key = self.environment.key - api_client.proxy = self.environment.proxy - api_client.proxy_user = self.environment.proxy_user - api_client.noproxy = self.environment.noproxy - - return api_client + self._api_request_handler = ApiRequestHandler(self.environment, self._data_provider) def resolve_project(self) -> None: """ @@ -90,10 +46,12 @@ def resolve_project(self) -> None: def resolve_suite(self) -> None: if not self.project: self.resolve_project() + self.environment.log(ProcessingMessages.CHECKING_SUITE, new_line=False) if self.environment.suite_id: self._handle_suite_by_id() else: self._handle_suite_by_name() + self.environment.log(SuccessMessages.DONE) def _validate_project_id(self) -> None: error_messages = { @@ -111,6 +69,7 @@ def _validate_project_id(self) -> None: def _handle_suite_by_id(self) -> None: """Handles suite selection when suite ID is provided.""" + self.environment.log(ProcessingMessages.RESOLVING_SUITE_BY_ID, new_line=False) self._data_provider.update_suite_id(self.environment.suite_id) existing, error_message = self._api_request_handler.check_suite_id() @@ -123,8 +82,8 @@ def _handle_suite_by_id(self) -> None: def _handle_suite_by_name(self) -> None: """Handles suite selection when suite name is provided.""" + self.environment.log(ProcessingMessages.RESOLVING_SUITE_BY_NAME, new_line=False) suite_mode = self.project.suite_mode - project_id = self.project.project_id if suite_mode not in ( SuiteModes.multiple_suites, @@ -142,7 +101,7 @@ def _handle_suite_by_name(self) -> None: return if suite_mode == SuiteModes.multiple_suites: - self._handle_multiple_suites(project_id) + self._handle_multiple_suites() return def _handle_single_suite(self) -> None: @@ -167,7 +126,7 @@ def _handle_single_suite_baselines(self) -> None: self._data_provider.update_suite_id(suite_ids[0]) - def _handle_multiple_suites(self, project_id: int) -> None: + def _handle_multiple_suites(self) -> None: # First try resolving by name suite_id, error_message = self._api_request_handler.resolve_suite_id_using_name() if error_message: diff --git a/trcli/api/results_uploader_v2.py b/trcli/api/results_uploader_v2.py index 4056b7ab..e5612b8c 100644 --- a/trcli/api/results_uploader_v2.py +++ b/trcli/api/results_uploader_v2.py @@ -90,13 +90,9 @@ def _resolve_sections_and_cases_upload(self) -> None: if should_update_cases: self._update_existing_test_cases() - else: - self.environment.log(SkippingMessage.NO_TEST_CASES_TO_UPDATE) if has_missing_cases: self._add_missing_test_cases() - else: - self.environment.log(SkippingMessage.NO_TEST_CASES_TO_ADD) def _handle_sections_upload(self) -> None: self._get_confirmation_for_sections_upload() @@ -111,9 +107,6 @@ def _handle_sections_upload(self) -> None: # if --update-cases option specified, for existing cases section or subsection id will be updated as well self._data_provider.update_cases_section_ids() - self.environment.log(ProcessingMessages.ADDING_SECTIONS) - return self._api_request_handler.add_missing_sections() - def _get_confirmation_for_sections_upload(self) -> None: has_missing_sections, error_message = self._api_request_handler.has_missing_sections() if error_message: @@ -133,6 +126,11 @@ def _get_confirmation_for_sections_upload(self) -> None: def _update_existing_test_cases(self) -> None: cases_to_update = self._data_provider.existing_cases self.environment.log(ProcessingMessages.UPDATING, new_line=False) + + if not cases_to_update: + self.environment.log(SkippingMessage.NO_TEST_CASES_TO_UPDATE) + return + error_message = self._api_request_handler.update_existing_cases(cases_to_update) if error_message: diff --git a/trcli/constants.py b/trcli/constants.py index 5087f16d..86764983 100644 --- a/trcli/constants.py +++ b/trcli/constants.py @@ -113,7 +113,6 @@ class SuiteModes(enum.IntEnum): class SkippingMessage: SKIP_TEST_RUN_AND_RESULTS = "Skipping test run and results upload as per user request." NO_TEST_CASES_TO_UPDATE = "No test cases to update. Skipping." - NO_TEST_CASES_TO_ADD = "No new test cases to add. Skipping." class ProcessingMessages: @@ -126,10 +125,13 @@ class ProcessingMessages: """ ADDING_SUITE_F_PROJECT_NAME = "Adding missing suite to project {project_name}." CHECKING_PROJECT = "Checking project. " + CHECKING_SUITE = "Checking suite. " + RESOLVING_SUITE_BY_ID = "Resolving suite by id. " + RESOLVING_SUITE_BY_NAME = "Resolving suite by name. " CHECKING_SECTIONS = "Checking for missing sections in the suite. " ADDING_SECTIONS = "Adding missing sections to the suite." - ADDING = "Adding. " - UPDATING = "Updating. " + ADDING = "Adding new cases. " + UPDATING = "Updating exiting cases. " UPDATING_TEST_RUN = "Updating test run. " CREATING_TEST_RUN = "Creating test run. " CLOSING_TEST_RUN = "Closing test run. " diff --git a/trcli/data_classes/dataclass_testrail.py b/trcli/data_classes/dataclass_testrail.py index 8abbef85..6aa55375 100644 --- a/trcli/data_classes/dataclass_testrail.py +++ b/trcli/data_classes/dataclass_testrail.py @@ -126,15 +126,15 @@ class TestRailCase: """Class for creating Test Rail test case""" title: str - section_id: int = field(default=None, skip_if_default=True) - case_id: int = field(default=None, skip_if_default=True, metadata={"serde_skip": True}) - estimate: str = field(default=None, skip_if_default=True) - template_id: int = field(default=None, skip_if_default=True) - type_id: int = field(default=None, skip_if_default=True) - milestone_id: int = field(default=None, skip_if_default=True) - refs: str = field(default=None, skip_if_default=True) + section_id: Optional[int] = field(default=None, skip_if_default=True) + case_id: Optional[int] = field(default=None, skip_if_default=True, metadata={"serde_skip": True}) + estimate: Optional[str] = field(default=None, skip_if_default=True) + template_id: Optional[int] = field(default=None, skip_if_default=True) + type_id: Optional[int] = field(default=None, skip_if_default=True) + milestone_id: Optional[int] = field(default=None, skip_if_default=True) + refs: Optional[str] = field(default=None, skip_if_default=True) case_fields: Optional[dict] = field(default_factory=dict, skip=True) - result: TestRailResult = field(default=None, metadata={"serde_skip": True}) + result: Optional[TestRailResult] = field(default=None, metadata={"serde_skip": True}) custom_automation_id: Optional[str] = field(default=None, metadata={"serde_skip": True}) # Uncomment if we want to support separated steps in cases in the future # custom_steps_separated: List[TestRailSeparatedStep] = field(default_factory=list, skip_if_default=True) @@ -197,10 +197,10 @@ class TestRailSection: """Class for creating Test Rail test section""" name: str - suite_id: int = field(default=None, skip_if_default=True) - parent_id: int = field(default=None, skip_if_default=True) - description: str = field(default=None, skip_if_default=True) - section_id: int = field(default=None, metadata={"serde_skip": True}) + suite_id: Optional[int] = field(default=None, skip_if_default=True) + parent_id: Optional[int] = field(default=None, skip_if_default=True) + description: Optional[str] = field(default=None, skip_if_default=True) + section_id: Optional[int] = field(default=None, metadata={"serde_skip": True}) testcases: List[TestRailCase] = field( default_factory=list, metadata={"serde_skip": True} ) @@ -230,7 +230,7 @@ class TestRailSuite: """Class for creating Test Rail Suite fields""" name: str - suite_id: int = field(default=None, skip_if_default=True) + suite_id: Optional[int] = field(default=None, skip_if_default=True) description: str = field(default=None, skip_if_default=True) testsections: List[TestRailSection] = field( default_factory=list, metadata={"serde_skip": True} From c27750b839e897fcb11ab8389e96b0d7dd51e0fd Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Tue, 2 Sep 2025 10:23:52 +0300 Subject: [PATCH 16/17] Removed redundant methods, updated types hinting --- trcli/data_classes/dataclass_testrail.py | 70 ++++++------------------ 1 file changed, 17 insertions(+), 53 deletions(-) diff --git a/trcli/data_classes/dataclass_testrail.py b/trcli/data_classes/dataclass_testrail.py index 6aa55375..3a3e063a 100644 --- a/trcli/data_classes/dataclass_testrail.py +++ b/trcli/data_classes/dataclass_testrail.py @@ -27,58 +27,22 @@ def __init__(self, content: str): class TestRailResult: """Class for creating Test Rail result for cases""" - case_id: int = field(default=None, skip_if_default=True) - status_id: int = field(default=None, skip_if_default=True) - comment: str = field(default=None, skip_if_default=True) - version: str = field(default=None, skip_if_default=True) - elapsed: str = field(default=None, skip_if_default=True) - defects: str = field(default=None, skip_if_default=True) - assignedto_id: int = field(default=None, skip_if_default=True) + case_id: Optional[int] = field(default=None, skip_if_default=True) + status_id: Optional[int] = field(default=None, skip_if_default=True) + comment: Optional[str] = field(default=None, skip_if_default=True) + version: Optional[str] = field(default=None, skip_if_default=True) + elapsed: Optional[str] = field(default=None, skip_if_default=True) + defects: Optional[str] = field(default=None, skip_if_default=True) + assignedto_id: Optional[int] = field(default=None, skip_if_default=True) attachments: Optional[List[str]] = field(default_factory=list, skip_if_default=True) result_fields: Optional[dict] = field(default_factory=dict, skip=True) junit_result_unparsed: List = field(default=None, metadata={"serde_skip": True}) custom_step_results: List[TestRailSeparatedStep] = field(default_factory=list, skip_if_default=True) def __post_init__(self): - #TODO Remove result handling, it's redundant and this is not the place for it - if self.junit_result_unparsed is not None: - self.status_id = self.calculate_status_id_from_junit_element( - self.junit_result_unparsed - ) - self.comment = self.get_comment_from_junit_element( - self.junit_result_unparsed - ) if self.elapsed is not None: self.elapsed = self.proper_format_for_elapsed(self.elapsed) - @staticmethod - def calculate_status_id_from_junit_element(junit_result: List) -> int: - """ - Calculate id for first result. In junit no result mean pass - 1 - Passed - 3 - Untested - 4 - Retest - 5 - Failed - """ - if len(junit_result) == 0: - return 1 - test_result_tag = junit_result[0]._tag.lower() - if test_result_tag == "skipped": - return 4 - elif test_result_tag == "error" or "failure": - return 5 - - @staticmethod - def get_comment_from_junit_element(junit_result: List) -> str: - if len(junit_result) == 0: - return "" - elif not any( - [junit_result[0].type, junit_result[0].message, junit_result[0].text] - ): - return "" - else: - return f"Type: {junit_result[0].type or ''}\nMessage: {junit_result[0].message or ''}\nText: {junit_result[0].text or ''}" - @staticmethod def proper_format_for_elapsed(elapsed): try: @@ -231,11 +195,11 @@ class TestRailSuite: name: str suite_id: Optional[int] = field(default=None, skip_if_default=True) - description: str = field(default=None, skip_if_default=True) + description: Optional[str] = field(default=None, skip_if_default=True) testsections: List[TestRailSection] = field( default_factory=list, metadata={"serde_skip": True} ) - source: str = field(default=None, metadata={"serde_skip": True}) + source: Optional[str] = field(default=None, metadata={"serde_skip": True}) def __post_init__(self): current_time = strftime("%d-%m-%y %H:%M:%S", gmtime()) @@ -248,16 +212,16 @@ def __post_init__(self): class TestRun: """Class for creating Test Rail test run""" - name: str = field(default=None, skip_if_default=True) - description: str = field(default=None, skip_if_default=True) - suite_id: int = field(default=None, skip_if_default=True) - milestone_id: int = field(default=None, skip_if_default=True) - assignedto_id: int = field(default=None, skip_if_default=True) + name: Optional[str] = field(default=None, skip_if_default=True) + description: Optional[str] = field(default=None, skip_if_default=True) + suite_id: Optional[int] = field(default=None, skip_if_default=True) + milestone_id: Optional[int] = field(default=None, skip_if_default=True) + assignedto_id: Optional[int] = field(default=None, skip_if_default=True) include_all: bool = field(default=False, skip_if_default=False) case_ids: List[int] = field(default_factory=list, skip_if_default=True) - refs: str = field(default=None, skip_if_default=True) - start_on: str = field(default=None, skip_if_default=True) - due_on: str = field(default=None, skip_if_default=True) + refs: Optional[str] = field(default=None, skip_if_default=True) + start_on: Optional[str] = field(default=None, skip_if_default=True) + due_on: Optional[str] = field(default=None, skip_if_default=True) run_fields: Optional[dict] = field(default_factory=dict, skip=True) def to_dict(self) -> dict: From fac7f3bca5597e5effc5eeecbd59b71aa11f8738 Mon Sep 17 00:00:00 2001 From: "r.unucek" Date: Tue, 2 Sep 2025 13:43:08 +0300 Subject: [PATCH 17/17] refactored test update run, encapsulated add_run for api data provider --- trcli/api/results_uploader_v2.py | 67 +++++++++-------- trcli/data_providers/api_data_provider_v2.py | 75 ++++++-------------- 2 files changed, 57 insertions(+), 85 deletions(-) diff --git a/trcli/api/results_uploader_v2.py b/trcli/api/results_uploader_v2.py index e5612b8c..7090389b 100644 --- a/trcli/api/results_uploader_v2.py +++ b/trcli/api/results_uploader_v2.py @@ -26,8 +26,6 @@ def upload_results(self) -> None: If needed missing items like suite/section/test case would be added to TestRail. Exits with result code 1 printing proper message to the user in case of a failure or with result code 0 if succeeds. - #FIXME Would be nice to rename method to 'upload_suite' or 'upload', which is more logical, - #FIXME because results upload is separate action and can be skipped. """ self._start_time = time.time() @@ -62,8 +60,6 @@ def create_or_update_test_run(self) -> int: except DataProviderException: self.resolve_suite() - self._data_provider.add_run() - if self._data_provider.test_run_id: error_message = self._update_test_run() else: @@ -205,7 +201,6 @@ def _close_test_run_if_required(self) -> None: exit(1) self.environment.log(SuccessMessages.CLOSED_RUN) - def _create_test_run(self) -> str: """ Creates a new test run in TestRail. @@ -228,55 +223,65 @@ def _create_test_run(self) -> str: def _update_test_run(self) -> str: """ Updates an existing test run in TestRail. - If the run is part of a plan, it updates the run entry in the plan. - If the run is standalone, it updates the run directly. - returns an error message if any issue occurs, otherwise returns an empty string. + + - Retrieves the run and its cases. + - Updates the run depending on whether it belongs to a plan (with or without configs) + or is standalone. + Returns an error message if any issue occurs, otherwise an empty string. """ - # 1. Get run + # 1: Retrieve run --- existing_run, error_message = self._api_request_handler.get_run() - if error_message: - run_id = self._data_provider.test_run_id - return ErrorMessagesRun.RETRIEVING_RUN_INFO_FF_RUN_ID_ERROR.format(run_id=run_id, error=error_message) + return ErrorMessagesRun.RETRIEVING_RUN_INFO_FF_RUN_ID_ERROR.format( + run_id=self._data_provider.test_run_id, + error=error_message, + ) - # 2. Get cases in run + # 2: Retrieve cases --- self.environment.log(ProcessingMessages.UPDATING_TEST_RUN, new_line=False) - run_cases_ids, error_message = self._api_request_handler.get_cases_ids_in_run() - + run_case_ids, error_message = self._api_request_handler.get_cases_ids_in_run() if error_message: return ErrorMessagesRun.RETRIEVING_TESTS_IN_IN_RUN_F_ERROR.format(error=error_message) - self._data_provider.merge_run_case_ids(run_cases_ids) + self._data_provider.merge_run_case_ids(run_case_ids) - # 3. Update description - existing_run_description = existing_run.get("description", "") - self._data_provider.update_test_run_description(existing_run_description) + # 3: Update description --- + self._data_provider.test_run.description = existing_run.get("description", "") # Retain the current description plan_id = existing_run.get("plan_id") config_ids = existing_run.get("config_ids") - # 4. Standalone run + # 4: Decide update strategy --- if not plan_id: - self.environment.log(ProcessingMessages.CONTINUE_AS_STANDALONE_RUN, new_line=False) - error_message = self._api_request_handler.update_run() - return error_message - - # 5. Plan run with configs + return self._update_as_standalone_run() if config_ids: - error_message = self._api_request_handler.update_run_in_plan_entry() - return error_message + return self._update_as_plan_run_with_configs() + return self._update_as_plan_run_without_configs(plan_id) - # 6. Plan run without configs → need entry ID - entry_id, error_message = self._api_request_handler.get_run_entry_id_in_plan(plan_id) + def _update_as_standalone_run(self) -> str: + """Update a run that is not part of any plan.""" + self.environment.log(ProcessingMessages.CONTINUE_AS_STANDALONE_RUN, new_line=False) + error_message = self._api_request_handler.update_run() + if not error_message: + self._log_run_link() + return error_message + def _update_as_plan_run_with_configs(self) -> str: + """Update a run that is part of a plan with configs.""" + error_message = self._api_request_handler.update_run_in_plan_entry() + if not error_message: + self._log_run_link() + return error_message + + def _update_as_plan_run_without_configs(self, plan_id: int) -> str: + """Update a run that is part of a plan without configs, requires entry_id.""" + entry_id, error_message = self._api_request_handler.get_run_entry_id_in_plan(plan_id) if error_message: return ErrorMessagesRun.RETRIEVING_RUN_ID_IN_PLAN_F_ERROR.format(error=error_message) error_message = self._api_request_handler.update_plan_entry(plan_id, entry_id) - if not error_message: self._log_run_link() - return error_message def _log_run_link(self) -> None: diff --git a/trcli/data_providers/api_data_provider_v2.py b/trcli/data_providers/api_data_provider_v2.py index 152969bc..ee2ed55c 100644 --- a/trcli/data_providers/api_data_provider_v2.py +++ b/trcli/data_providers/api_data_provider_v2.py @@ -5,12 +5,15 @@ class DataProviderException(Exception): - """Custom exception type for data-provider-level errors like None field/not initiated data failures.""" - def __init__(self, message=""): - self.message = message - super().__init__(self.message) + """Custom exception for data-provider-level errors (e.g. uninitialized values).""" + pass +def _require(value, message: str): + if value is None: + raise DataProviderException(message) + return value + class ApiDataProvider: """ ApiDataProvider is responsible for preparing and storing data @@ -20,16 +23,16 @@ def __init__(self, suites_input: TestRailSuite, environment: Environment): self._environment = environment self.case_fields = self._environment.case_fields - self.run_description = self._environment.run_description self.result_fields = self._environment.result_fields + self.test_run_id: Optional[int] = self._environment.run_id + self.test_plan_id: Optional[int] = self._environment.plan_id + self.config_ids: Optional[List[int]] = self._environment.config_ids + self._update_parent_section() self._add_global_case_fields() self._project_id: Optional[int] = None self._test_run: Optional[TestRun] = None - self.test_run_id: Optional[int] = None - self.test_plan_id: Optional[int] = None - self.config_ids: Optional[List[int]] = None self._automation_id_system_name: Optional[str] = None self.existing_cases: List[TestRailCase] = [] @@ -45,9 +48,7 @@ def __init__(self, suites_input: TestRailSuite, environment: Environment): @property def project_id(self) -> int: """Return project ID.""" - if self._project_id is None: - raise DataProviderException("Project ID is not initialized. Resolve project and update project id first.") - return self._project_id + return _require(self._project_id, "Project ID is not initialized. Resolve project first.") @project_id.setter def project_id(self, project_id: int) -> None: @@ -57,15 +58,13 @@ def project_id(self, project_id: int) -> None: @property def suite_id(self) -> int: """Return suite ID.""" - if self.suites_input.suite_id is None: - raise DataProviderException("Suite ID is not initialized. Resolve suite and update suite id first.") - return self.suites_input.suite_id + return _require(self.suites_input.suite_id, "Suite ID is not initialized. Resolve suite first.") @property def test_run(self) -> TestRun: """Return test run object.""" if self._test_run is None: - raise DataProviderException("Test run is not initialized. Call add_run() first.") + self._add_run() return self._test_run @property @@ -113,16 +112,6 @@ def update_cases_section_ids(self): continue case.section_id = section.section_id - - def _collect_cases_id_from_existing_cases(self) -> set[int]: - return {case.case_id for case in self.existing_cases if case.case_id is not None} - - def update_test_run_description(self, description: str) -> None: - """Update test run description.""" - if self._test_run is None: - raise DataProviderException("Test run is not initialized. Call add_run() first.") - self.test_run.description = description - def update_test_run_id(self, run_id: int, is_created=False) -> None: if is_created: self.created_test_run_id = run_id @@ -134,8 +123,8 @@ def update_custom_automation_id_system_name(self, automation_id_system_name: str Needs to be explained: I don't like that we have custom_automation_id field in TestRailCase dataclass, because it is not standard field in TestRail and might have another name as we see. - #TODO But I decided leave it as is, otherwise the field must be removed from data class - #TODO and needs to change couple lines of code in parser. + But I decided leave it as is, otherwise the field must be removed from data class + and needs to change couple lines of code in parser. Here I just update custom_case fields with actual system name of that field and value. So later when case data object serialized to dict it will have correct field name. I added {"serde_skip": True} in data class to ignore for serialization if the name is different, @@ -150,7 +139,7 @@ def update_custom_automation_id_system_name(self, automation_id_system_name: str automation_id = case.custom_automation_id case.case_fields[self.automation_id_system_name] = automation_id - def add_run(self) -> None: + def _add_run(self) -> None: run_name = self._environment.title if self._environment.special_parser == "saucectl": run_name += f" ({self.suites_input.name})" @@ -181,23 +170,13 @@ def add_run(self) -> None: properties.insert(0, f"{run.description}") run.description = "\n".join(properties) - if self._environment.run_id: - self.test_run_id = self._environment.run_id - - if self._environment.plan_id: - self.test_plan_id = self._environment.plan_id - - if self._environment.config_ids: - self.config_ids = self._environment.config_ids - self._test_run = run def merge_run_case_ids(self, run_case_ids: List[int]) -> None: """Merge existing run case IDs with the ones from the suite.""" - if self.test_run is None: - self.add_run() if self.test_run.case_ids is None: self.test_run.case_ids = [] + self.test_run.case_ids = list(set(self.test_run.case_ids + run_case_ids)) def get_results_for_cases(self): @@ -216,21 +195,6 @@ def get_results_for_cases(self): ) return [{"results": result_bulk} for result_bulk in result_bulks] - def check_section_names_duplicates(self): - """ - Check if section names in result xml file are duplicated. - #TODO I don't see a reason to use this method, since TestRail allows to have sections with the same name. - In our case the first found with specified name will be selected. - If we want to prevent name duplication we must validate it at parser level. - #TODO Now I just leave it here, latter will be removed. - """ - sections_names = [sections.name for sections in self.suites_input.testsections] - - if len(sections_names) == len(set(sections_names)): - return False - else: - return True - @staticmethod def _divide_list_into_bulks(input_list: List, bulk_size: int) -> List: return [ @@ -241,6 +205,9 @@ def _collect_all_sections(self) -> List[TestRailSection]: return [s for s in self.suites_input.testsections] + \ [ss for s in self.suites_input.testsections for ss in self._recurse_sections(s)] + def _collect_cases_id_from_existing_cases(self) -> set[int]: + return {case.case_id for case in self.existing_cases if case.case_id is not None} + def _recurse_cases(self, section: TestRailSection) -> List[TestRailCase]: return list(section.testcases) + [tc for ss in section.sub_sections for tc in self._recurse_cases(ss)]