diff --git a/testbench2robotframework/__init__.py b/testbench2robotframework/__init__.py index 5328d03..43913c2 100644 --- a/testbench2robotframework/__init__.py +++ b/testbench2robotframework/__init__.py @@ -17,4 +17,4 @@ from .testbench2robotframework import testbench2robotframework # noqa: F401 -__version__ = "0.8.1a1" +__version__ = "0.9.0a1" diff --git a/testbench2robotframework/cli.py b/testbench2robotframework/cli.py index ab4c174..3baadaf 100644 --- a/testbench2robotframework/cli.py +++ b/testbench2robotframework/cli.py @@ -119,6 +119,15 @@ def testbench2robotframework_cli(): help="""TestBench root subdivision which's direct children correspond to Robot Framework libraries.""", ) +@click.option( + "--metadata", + multiple=True, + callback=parse_subdivision_mapping, + help="""Add extra metadata to the settings of the generated Robot Framework test suite. + Provide entries as key:value pairs, where *key* is the metadata name and *value* is the corresponding value. + Values may also be Python expressions. + The special variable '$tcs' gives access to the TestBench Python model of the test case set.""", +) @click.option( "--resource-regex", multiple=True, @@ -157,6 +166,7 @@ def generate_tests( # noqa: PLR0913 resource_directory_regex: str, library_root: tuple[str], log_suite_numbering: bool, + metadata: dict[str, str], output_directory: Path, resource_directory: Path, resource_regex: tuple[str], @@ -187,7 +197,7 @@ def generate_tests( # noqa: PLR0913 configuration["log-suite-numbering"] = True else: configuration["log-suite-numbering"] = configuration.get("log-suite-numbering", False) - + configuration["metadata"] = metadata or configuration.get("metadata", {}) configuration["compound-interaction-logging"] = ( compound_interaction_logging or configuration.get("compound-interaction-logging", "GROUP") ) diff --git a/testbench2robotframework/config.py b/testbench2robotframework/config.py index 6d2db3c..272573b 100644 --- a/testbench2robotframework/config.py +++ b/testbench2robotframework/config.py @@ -193,6 +193,7 @@ class Configuration: library_root: list[str] log_suite_numbering: bool loggingConfiguration: LoggingConfig + metadata: dict[str, str] output_directory: str phasePattern: str referenceBehaviour: ReferenceBehaviour @@ -227,6 +228,7 @@ def from_dict(cls, dictionary) -> Configuration: "file":dictionary.get("file-logging", {}) } ), + metadata=dictionary.get("metadata", {}), compound_interaction_logging=CompoundInteractionLogging(dictionary.get("compound-interaction-logging", "GROUP").upper()), resource_directory=dictionary.get("resource-directory", "").replace( "\\", "/" diff --git a/testbench2robotframework/model.py b/testbench2robotframework/model.py index 97d37dc..016937f 100644 --- a/testbench2robotframework/model.py +++ b/testbench2robotframework/model.py @@ -1651,3 +1651,35 @@ class CycleForUpdate: status: Optional[ProjectStatus] = None testingIntelligence: Optional[bool] = None startDate: Optional[OptionalLocalDateTime] = None + + + +@dataclass +class AllModels: + ProjectMember: ProjectMember + ProjectDetails: ProjectDetails + TOVDetails: TOVDetails + CycleDetails: CycleDetails + UserDetails: UserDetails + TestStructureTree: TestStructureTree + TestCaseDetails: TestCaseDetails + InteractionDetails: InteractionDetails + ParameterSummary: ParameterSummary + TestCaseSpecificationDetails: TestCaseSpecificationDetails + TestCaseExecutionDetails: TestCaseExecutionDetails + TestStructureSpecification: TestStructureSpecification + TestStructureAutomation: TestStructureAutomation + TestStructureExecution: TestStructureExecution + AttachedFilter: AttachedFilter + TestStructureTreeNode: TestStructureTreeNode + TestCaseExecutionSummary: TestCaseExecutionSummary + TestCaseSetExecutionSummary: TestCaseSetExecutionSummary + ActivityStatus: ActivityStatus + ExecStatus: ExecStatus + VerdictStatus: VerdictStatus + SpecStatus: SpecStatus + DataTypeSummary: DataTypeSummary + TestCaseSetDetails: TestCaseSetDetails + TestCaseSetSpecificationSummary: TestCaseSetSpecificationSummary + TestCaseSpecificationSummary: TestCaseSpecificationSummary + TestCaseSummary: TestCaseSummary \ No newline at end of file diff --git a/testbench2robotframework/testbench2rf.py b/testbench2robotframework/testbench2rf.py index be1c0a8..595b500 100644 --- a/testbench2robotframework/testbench2rf.py +++ b/testbench2robotframework/testbench2rf.py @@ -455,9 +455,15 @@ def _create_cbr_parameters( def _get_interaction_import_prefix(self, interaction: RFInteractionCall) -> str: for resource_regex in self.config.resource_regex: - resource_name_match = re.search(resource_regex, interaction.import_prefix, flags=re.IGNORECASE) + if not interaction.import_prefix: + continue + resource_name_match = re.search( + resource_regex, interaction.import_prefix, flags=re.IGNORECASE + ) if resource_name_match: - return (self.config.fully_qualified or False) * f"{resource_name_match.group('resourceName').strip()}." + return ( + self.config.fully_qualified or False + ) * f"{resource_name_match.group('resourceName').strip()}." return "" def _get_interaction_indent(self, interaction: RFInteractionCall) -> str: @@ -715,15 +721,19 @@ def _create_resource_path(self, resource: str) -> str: resource_name_index = self._get_resource_path_index(resource) cropped_interaction_path = [] if resource_dir_index is None: - return f"{resource_name}.resource" - cropped_interaction_path.extend( - splitted_interaction_path[resource_dir_index + 1 : resource_name_index] - ) - resource_path = Path( - self.config.resource_directory, - *cropped_interaction_path, - f"{resource_name}.resource", - ).as_posix() + resource_path = Path( + self.config.resource_directory, + f"{resource_name}.resource", + ).as_posix() + else: + cropped_interaction_path.extend( + splitted_interaction_path[resource_dir_index + 1 : resource_name_index] + ) + resource_path = Path( + self.config.resource_directory, + *cropped_interaction_path, + f"{resource_name}.resource", + ).as_posix() resource_path = self.config.subdivisionsMapping.resources.get(resource_name, resource_path) resource_path = re.sub( r"^{resourceDirectory}", self.config.resource_directory, resource_path @@ -815,6 +825,21 @@ def _create_setting_section(self) -> SettingSection: setting_section.body.extend(self._create_rf_resource_imports(subdivisions)) setting_section.body.extend(self._create_rf_unknown_imports(subdivisions)) setting_section_meta_data = self.test_case_set.metadata + for md_name, md_expression in self.config.metadata.items(): + from .utils import safe_eval + md_expression = re.sub(r"\$tcs", "tcs", md_expression) + try: + setting_section_meta_data[md_name] = safe_eval( + md_expression, {"tcs": self.test_case_set.details} + ) + except ValueError as ve: + logger.warning( + f"Value '{md_expression}' from the custom metadata setting could not be evaluated: {ve}" + ) + except Exception as e: + logger.error( + f"Error while evaluating the custom metadata setting '{md_expression}': {e}" + ) setting_section.body.extend( [ create_meta_data(metadata_name, metadata_value) diff --git a/testbench2robotframework/testbench2robotframework.py b/testbench2robotframework/testbench2robotframework.py index 6869d26..09bde21 100644 --- a/testbench2robotframework/testbench2robotframework.py +++ b/testbench2robotframework/testbench2robotframework.py @@ -15,6 +15,7 @@ def testbench2robotframework(testbench_report: str, config: dict): setup_logger(configuration) logger.debug("Configuration loaded.") testbench_report = Path(testbench_report) + temp_dir = None try: if is_zip_file(testbench_report): temp_dir = tempfile.TemporaryDirectory(dir=Path.cwd()) @@ -41,6 +42,6 @@ def testbench2robotframework(testbench_report: str, config: dict): return write_test_suites(test_suites, configuration) except Exception as exception: - if temp_dir: + if temp_dir is not None: temp_dir.cleanup() raise exception diff --git a/testbench2robotframework/utils.py b/testbench2robotframework/utils.py index e91e11c..73f2cf4 100644 --- a/testbench2robotframework/utils.py +++ b/testbench2robotframework/utils.py @@ -1,3 +1,4 @@ +import ast import re import shutil import sys @@ -96,6 +97,57 @@ def _get_padded_index(self, tse) -> str: return index.zfill(max_length) +def safe_eval(expr: str, names: dict): + tree = ast.parse(expr, mode="eval") + allowed_nodes = ( + ast.Expression, + ast.Call, + ast.Attribute, + ast.Load, + ast.Name, + ast.ListComp, + ast.GeneratorExp, + ast.comprehension, + ast.List, + ast.Tuple, + ast.Constant, + ast.Subscript, + ast.JoinedStr, + ast.FormattedValue, + ast.BinOp, + ast.UnaryOp, + ast.Compare, + ast.BoolOp, + ast.Eq, + ast.NotEq, + ast.Gt, + ast.Lt, + ast.GtE, + ast.LtE, + ast.In, + ast.And, + ast.Or, + ast.Not, + ast.Add, + ast.Sub, + ast.Mult, + ast.Div, + ast.Mod, + ast.Store, + ast.keyword, + ) + for node in ast.walk(tree): + if not isinstance(node, allowed_nodes): + raise ValueError(f"Disallowed expression: {type(node).__name__}") + if isinstance(node, ast.Attribute) and node.attr.startswith("_"): + raise ValueError(f"Access to private attribute {node.attr} is not allowed") + if isinstance(node, ast.Name) and node.id == "__import__": + raise ValueError("Use of __import__ is forbidden") + + code = compile(tree, "", "eval") + return eval(code, {"__builtins__": {}}, names) + + def get_directory(json_report_path: Optional[str]) -> str: if json_report_path is None: return ""