diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9a3dad6..3827499 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,7 +5,7 @@ name: Docs on: push: - branches: ["add-docs"] + branches: ["master"] jobs: sphinx: diff --git a/capella_rm_bridge/__main__.py b/capella_rm_bridge/__main__.py index 273c830..d86e04c 100644 --- a/capella_rm_bridge/__main__.py +++ b/capella_rm_bridge/__main__.py @@ -19,7 +19,8 @@ from . import auditing -CHANGE_PATH = pathlib.Path("change-set.yaml") +CHANGE_FOLDER_PATH = pathlib.Path("change-sets") +CHANGE_FILENAME = "change-set.yaml" CHANGE_HISTORY_PATH = pathlib.Path("change.history") COMMIT_MSG_PATH = pathlib.Path("commit-message.txt") ERROR_PATH = pathlib.Path("change-errors.txt") @@ -31,6 +32,19 @@ def create_errors_statement(errors: cabc.Iterable[str]) -> str: return "\n".join(errors) +def write_change_set(change: str, module: dict[str, t.Any]) -> pathlib.Path: + """Create a change-set.yaml underneath the change-sets folder.""" + CHANGE_FOLDER_PATH.mkdir(parents=True, exist_ok=True) + mid = module["id"].replace("/", "~") + path = CHANGE_FOLDER_PATH / f"{mid}-{CHANGE_FILENAME}" + if path.is_file(): + path.unlink(missing_ok=True) + + path.write_text(change, encoding="utf8") + LOGGER.info("Change-set file %s written.", str(path)) + return path + + @click.command() @click.option( "-c", @@ -69,11 +83,12 @@ def create_errors_statement(errors: cabc.Iterable[str]) -> str: help="Pull the latest changes from remote.", ) @click.option( - "--no-safe-mode", + "--force", is_flag=True, default=False, - help="Modifications are still done to a RequirementModule if an error in " - "another, independent module in the snapshot was identified.", + help="If a non RequirementModule-error was encountered only the object " + "and all related objects will be skipped. Intact objects will still be " + "synchronized", ) @click.option( "--gather-logs/--no-gather-logs", @@ -104,7 +119,7 @@ def main( dry_run: bool, push: bool, pull: bool, - no_safe_mode: bool, + force: bool, gather_logs: bool, save_change_history: bool, save_error_log: bool, @@ -144,34 +159,35 @@ def main( model, tconfig, module, - safe_mode=not no_safe_mode, + force=force, gather_logs=gather_logs, ) if change_set: change = decl.dump(change_set) - CHANGE_PATH.write_text(change, encoding="utf8") + change_path = write_change_set(change, module) with auditing.ChangeAuditor(model) as changed_objs: - decl.apply(model, CHANGE_PATH) + decl.apply(model, change_path) reporter.store_change( changed_objs, module["id"], module["category"] ) + if force or not errors: + commit_message = reporter.create_commit_message(snapshot["metadata"]) + print(commit_message) + if reporter.store and not dry_run: + model.save( + push=push, commit_msg=commit_message, push_options=["skip.ci"] + ) + if errors: error_statement = create_errors_statement(errors) print(error_statement) if save_error_log: ERROR_PATH.write_text(error_statement, encoding="utf8") LOGGER.info("Change-errors file %s written.", ERROR_PATH) - sys.exit(1) - else: - commit_message = reporter.create_commit_message(snapshot["metadata"]) - COMMIT_MSG_PATH.write_text(commit_message, encoding="utf8") - print(commit_message) - if reporter.store and not dry_run: - model.save(push=push, commit_msg=commit_message) report = reporter.get_change_report() if report and save_change_history: diff --git a/capella_rm_bridge/auditing.py b/capella_rm_bridge/auditing.py index 8647531..f0cad50 100644 --- a/capella_rm_bridge/auditing.py +++ b/capella_rm_bridge/auditing.py @@ -232,14 +232,14 @@ def __audit(self, event: str, args: tuple[t.Any, ...]) -> None: oval = getattr(obj, attr_name) nrepr = self._get_value_repr(value) orepr = self._get_value_repr(oval) - params = (obj.uuid, attr_name, nrepr, orepr) + events = [EventType(obj.uuid, attr_name, nrepr, orepr)] elif event.endswith("setitem"): assert len(args) == 4 obj, attr_name, index, value = args nrepr = self._get_value_repr(value) oval = getattr(obj, attr_name)[index] orepr = self._get_value_repr(oval) - params = (obj.uuid, attr_name, nrepr, orepr) + events = [EventType(obj.uuid, attr_name, nrepr, orepr)] elif event.endswith("delete"): assert len(args) == 3 obj, attr_name, index = args @@ -248,22 +248,34 @@ def __audit(self, event: str, args: tuple[t.Any, ...]) -> None: if index is not None: oval = oval[index] - assert isinstance(oval, common.GenericElement) - orepr = self._get_value_repr(oval) - params = (obj.uuid, attr_name, orepr, oval.uuid) + if not isinstance(oval, common.GenericElement): + assert isinstance(oval, common.ElementList) + events = [] + assert EventType is Deletion + for elt in oval: + event_type = EventType( + obj.uuid, + attr_name, + self._get_value_repr(elt), + elt.uuid, + ) + events.append(event_type) + else: + orepr = self._get_value_repr(oval) + events = [EventType(obj.uuid, attr_name, orepr, oval.uuid)] elif event.endswith("insert"): assert len(args) == 4 obj, attr_name, _, value = args nrepr = self._get_value_repr(value) assert isinstance(value, common.GenericElement) - params = (obj.uuid, attr_name, nrepr, value.uuid) + events = [EventType(obj.uuid, attr_name, nrepr, value.uuid)] elif event.endswith("create"): assert len(args) == 3 obj, attr_name, value = args repr = self._get_value_repr(value) - params = (obj.uuid, attr_name, repr, value.uuid) + events = [EventType(obj.uuid, attr_name, repr, value.uuid)] - self.context.append(EventType(*params)) + self.context.extend(events) def _get_value_repr(self, value: t.Any) -> str | t.Any: if hasattr(value, "_short_repr_"): @@ -363,7 +375,7 @@ def store_change( def _assign_module(self, change: Change) -> LiveDocID | TrackerID | None: try: obj = self.model.by_uuid(change.parent) - while not isinstance(obj, reqif.RequirementsModule): + while not isinstance(obj, reqif.CapellaModule): obj = obj.parent return obj.identifier except (KeyError, AttributeError): @@ -408,7 +420,7 @@ def create_commit_message(self, tool_metadata: dict[str, str]) -> str: main_message = generate_main_message(self.categories.items()) main = "\n".join((main_message, "\n".join(list_lines) + "\n")) - summary = f"{summary} from rev.{tool_metadata['revision']} [skip ci]\n" + summary = f"{summary} from rev.{tool_metadata['revision']}\n" rm_bridge_dependencies = get_dependencies() dependencies = "\n".join( ( @@ -439,20 +451,20 @@ def _count_changes( return ext_count, mod_count, del_count, type_count def _is_reqtype_change(self, change: Change) -> bool: - if isinstance(change, Modification): + if isinstance(change, (Modification, Deletion)): obj = self.model.by_uuid(change.parent) - else: + elif isinstance(change, Extension): obj = self.model.by_uuid(change.uuid) return type(obj) in { reqif.AttributeDefinition, reqif.AttributeDefinitionEnumeration, reqif.DataTypeDefinition, - reqif.EnumDataTypeDefinition, + reqif.EnumerationDataTypeDefinition, reqif.EnumValue, reqif.ModuleType, reqif.RelationType, - reqif.RequirementsTypesFolder, + reqif.CapellaTypesFolder, reqif.RequirementType, } diff --git a/capella_rm_bridge/changeset/__init__.py b/capella_rm_bridge/changeset/__init__.py index d74aded..93b0d9e 100644 --- a/capella_rm_bridge/changeset/__init__.py +++ b/capella_rm_bridge/changeset/__init__.py @@ -24,8 +24,16 @@ ERROR_MESSAGE_PREFIX = "Skipping module: {module_id}" -def _wrap_errors(module_id: int | str, errors: cabc.Sequence[str]) -> str: - start = ERROR_MESSAGE_PREFIX.format(module_id=module_id) +def _wrap_errors( + module_id: int | str, + errors: cabc.Sequence[str], + include_start: bool = True, +) -> str: + if include_start: + start = ERROR_MESSAGE_PREFIX.format(module_id=module_id) + else: + start = f"Encountered error(s) in {module_id!r}" + first_sep = len(start) * "=" last_sep = len(errors[-1]) * "=" return "\n".join((start, first_sep, *errors, last_sep)) @@ -35,6 +43,7 @@ def calculate_change_set( model: capellambse.MelodyModel, config: actiontypes.TrackerConfig, snapshot: actiontypes.TrackerSnapshot, + force: bool = False, safe_mode: bool = True, gather_logs: bool = True, ) -> tuple[list[dict[str, t.Any]], list[str]]: @@ -54,6 +63,10 @@ def calculate_change_set( calculated ``ChangeSet``. snapshot A snapshot of a tracker or live-document from the external tool. + force + If ``True`` a ``ChangeSet`` will be rendered even if an error + occurred during the change-calculation loop. All related objects + will be skipped. safe_mode If ``True`` no ``ChangeSet`` will be rendered iff atleast one error occurred during the change-calculation loop. If ``False`` @@ -83,14 +96,21 @@ def calculate_change_set( snapshot, model, config, gather_logs=gather_logs ) if tchange.errors: - message = _wrap_errors(module_id, tchange.errors) + message = _wrap_errors( + module_id, tchange.errors, include_start=not force + ) errors.append(message) else: actions.extend(tchange.actions) + + if force and tchange.actions: + for action in tchange.actions: + if action not in actions: + actions.append(action) except ( actiontypes.InvalidTrackerConfig, actiontypes.InvalidSnapshotModule, - change.MissingRequirementsModule, + change.MissingCapellaModule, ) as error: if gather_logs: message = _wrap_errors(module_id, [error.args[0]]) @@ -99,6 +119,8 @@ def calculate_change_set( prefix = ERROR_MESSAGE_PREFIX.format(module_id=module_id) LOGGER.error("%s. %s", prefix, error.args[0]) + if force: + safe_mode = False if safe_mode and any(errors): actions = [] return actions, errors diff --git a/capella_rm_bridge/changeset/change.py b/capella_rm_bridge/changeset/change.py index 35bfefb..fc3f22e 100644 --- a/capella_rm_bridge/changeset/change.py +++ b/capella_rm_bridge/changeset/change.py @@ -35,7 +35,7 @@ } -WorkItem = t.Union[reqif.Requirement, reqif.RequirementsFolder] +WorkItem = t.Union[reqif.Requirement, reqif.Folder] RMIdentifier = t.NewType("RMIdentifier", str) @@ -45,8 +45,8 @@ class _AttributeValueBuilder(t.NamedTuple): value: act.Primitive | None -class MissingRequirementsModule(Exception): - """A ``RequirementsModule`` with matching UUID could not be found.""" +class MissingCapellaModule(Exception): + """A ``CapellaModule`` with matching UUID could not be found.""" class TrackerChange: @@ -55,6 +55,7 @@ class TrackerChange: _location_changed: set[RMIdentifier] _req_deletions: dict[helpers.UUIDString, dict[str, t.Any]] _reqtype_ids: set[RMIdentifier] + _faulty_attribute_definitions: set[str] errors: list[str] tracker: cabc.Mapping[str, t.Any] @@ -133,6 +134,7 @@ def __init__( self._location_changed = set[RMIdentifier]() self._req_deletions = {} + self._faulty_attribute_definitions = set[str]() self.errors = [] self.__reqtype_action: dict[str, t.Any] | None = None @@ -209,13 +211,13 @@ def check_requirements_module(self) -> dict[str, t.Any]: self.req_module = self.reqfinder.reqmodule(module_uuid) except KeyError as error: raise act.InvalidTrackerConfig( - "The given module configuration is missing 'UUID' of the " + "The given module configuration is missing UUID of the " "target RequirementsModule" ) from error if self.req_module is None: - raise MissingRequirementsModule( - f"No RequirementsModule with UUID '{module_uuid}' found in " + raise MissingCapellaModule( + f"No RequirementsModule with UUID {module_uuid!r} found in " + repr(self.model.info) ) @@ -223,7 +225,7 @@ def check_requirements_module(self) -> dict[str, t.Any]: identifier = self.tracker["id"] except KeyError as error: raise act.InvalidSnapshotModule( - "In the snapshot the module is missing an 'id' key" + "In the snapshot the module is missing an id key" ) from error base = {"parent": decl.UUIDReference(self.req_module.uuid)} @@ -250,7 +252,7 @@ def invalidate_deletion(self, requirement: WorkItem) -> None: delete action was removed. """ key = "requirements" - if isinstance(requirement, reqif.RequirementsFolder): + if isinstance(requirement, reqif.Folder): key = "folders" try: @@ -407,15 +409,15 @@ def attribute_definition_create_action( name, below=self.reqt_folder ) if etdef is None: + promise_id = f"EnumerationDataTypeDefinition {name}" if name not in self.data_type_definitions: + self._faulty_attribute_definitions.add(promise_id) raise act.InvalidAttributeDefinition( - f"Invalid {cls.__name__} found: '{name}'. Missing its " + f"Invalid {cls.__name__} found: {name!r}. Missing its " "datatype definition in `data_types`." ) - data_type_ref = decl.Promise( - f"EnumerationDataTypeDefinition {name}" - ) + data_type_ref = decl.Promise(promise_id) else: data_type_ref = decl.UUIDReference(etdef.uuid) @@ -481,7 +483,7 @@ def yield_requirements_create_actions( base["folders"] = [] child: act.WorkItem for child in item["children"]: - key = "folders" if child.get("children") else "requirements" + key = "folders" if "children" in child else "requirements" creq = self.reqfinder.work_item_by_identifier(child["id"]) if creq is None: child_actions = self.yield_requirements_create_actions( @@ -591,7 +593,14 @@ def attribute_value_create_action( deftype, attr_def_id, below=self.reqt_folder ) if definition is None: - definition_ref = decl.Promise(f"{deftype} {attr_def_id}") + promise_id = f"{deftype} {attr_def_id}" + if promise_id in self._faulty_attribute_definitions: + raise act.InvalidFieldValue( + f"Invalid field found: No AttributeDefinition {name!r} " + "promised." + ) + else: + definition_ref = decl.Promise(promise_id) else: definition_ref = decl.UUIDReference(definition.uuid) @@ -615,7 +624,13 @@ def check_attribute_value_is_valid( "Unknown field type '%s' for %s: %r", deftype, name, value ) if deftype == "Enum": - options = self.data_type_definitions[name] + options = self.data_type_definitions.get(name) + if options is None: + raise act.InvalidFieldValue( + f"Invalid field found: {name!r}. Missing its " + "datatype definition in `data_types`." + ) + is_faulty = not matches_type or not set(value) & set(options) key = "values" else: @@ -624,7 +639,7 @@ def check_attribute_value_is_valid( if is_faulty: raise act.InvalidFieldValue( - f"Invalid field found: {key} '{value}' for '{name}'" + f"Invalid field found: {key} {value!r} for {name!r}" ) return _AttributeValueBuilder(deftype, key, value) @@ -743,9 +758,14 @@ def requirement_type_mod_action( ) assert isinstance(reqtype, reqif.RequirementType) - mods = _compare_simple_attributes( - reqtype, item, filter=("attributes",) - ) + try: + mods = _compare_simple_attributes( + reqtype, item, filter=("attributes",) + ) + except AttributeError as error: + self._handle_user_error( + f"Invalid workitem '{identifier}'. {error.args[0]}" + ) attr_defs_deletions: list[decl.UUIDReference] = [ decl.UUIDReference(adef.uuid) @@ -804,16 +824,23 @@ def yield_requirements_mod_actions( from it. """ base = {"parent": decl.UUIDReference(req.uuid)} - mods = _compare_simple_attributes( - req, item, filter=("id", "type", "attributes", "children") - ) + try: + mods = _compare_simple_attributes( + req, item, filter=("id", "type", "attributes", "children") + ) + except AttributeError as error: + self._handle_user_error( + f"Invalid workitem '{item['id']}'. {error.args[0]}" + ) + return + req_type_id = RMIdentifier(item.get("type", "")) attributes_deletions = list[dict[str, t.Any]]() if req_type_id != req.type.identifier: if req_type_id and req_type_id not in self.requirement_types: raise act.InvalidWorkItemType( "Faulty workitem in snapshot: " - f"Unknown workitem-type '{req_type_id}'" + f"Unknown workitem-type {req_type_id!r}" ) reqtype = self.reqfinder.reqtype_by_identifier( @@ -888,12 +915,12 @@ def yield_requirements_mod_actions( cf_creations: list[dict[str, t.Any] | decl.UUIDReference] = [] containers = [cr_creations, cf_creations] child_mods: list[dict[str, t.Any]] = [] - if isinstance(req, reqif.RequirementsFolder): + if isinstance(req, reqif.Folder): child_req_ids = set[RMIdentifier]() child_folder_ids = set[RMIdentifier]() for child in children: cid = RMIdentifier(str(child["id"])) - if child.get("children", []): + if "children" in child: key = "folders" child_folder_ids.add(cid) else: @@ -918,6 +945,7 @@ def yield_requirements_mod_actions( f"Invalid workitem '{child['id']}'. " + error.args[0] ) + continue if creq.parent != req: container.append(decl.UUIDReference(creq.uuid)) @@ -1072,9 +1100,16 @@ def attribute_definition_mod_action( return None return {"parent": decl.UUIDReference(attrdef.uuid), "modify": mods} except KeyError: - return self.attribute_definition_create_action( - name, data, reqtype.identifier - ) + try: + return self.attribute_definition_create_action( + name, data, reqtype.identifier + ) + except act.InvalidAttributeDefinition as error: + self._handle_user_error( + f"In RequirementType {reqtype.long_name!r}: " + + error.args[0] + ) + return None def make_requirement_delete_actions( @@ -1137,7 +1172,7 @@ def _compare_simple_attributes( converter = type_conversion.get(name, lambda i: i) converted_value = converter(value) - if getattr(req, name, None) != converted_value: + if getattr(req, name) != converted_value: mods[name] = value return mods diff --git a/capella_rm_bridge/changeset/find.py b/capella_rm_bridge/changeset/find.py index 60a21ac..e88ef1b 100644 --- a/capella_rm_bridge/changeset/find.py +++ b/capella_rm_bridge/changeset/find.py @@ -35,25 +35,29 @@ def _get( LOGGER.info("No %s found with %s: %r", types, attr, value) return None - def reqmodule(self, uuid: str) -> reqif.RequirementsModule | None: - """Try to return the ``RequirementsModule``.""" - return self._get(uuid, reqif.XT_MODULE, attr="uuid") + def reqmodule(self, uuid: str) -> reqif.CapellaModule | None: + """Try to return the ``CapellaModule``.""" + return self._get(uuid, reqif.CapellaModule.__name__, attr="uuid") def reqtypesfolder_by_identifier( self, identifier: int | str, below: crosslayer.BaseArchitectureLayer - | reqif.RequirementsModule + | reqif.CapellaModule | None = None, - ) -> reqif.RequirementsTypesFolder | None: + ) -> reqif.CapellaTypesFolder | None: """Try to return the ``RequirementTypesFolder``.""" - return self._get(str(identifier), reqif.XT_REQ_TYPES_F, below=below) + return self._get( + str(identifier), reqif.CapellaTypesFolder.__name__, below=below + ) def reqtype_by_identifier( self, identifier: int | str, below: reqif.ReqIFElement | None = None - ) -> reqif.RequirementsType | None: + ) -> reqif.RequirementType | None: """Try to return a ``RequirementType``.""" - return self._get(str(identifier), reqif.XT_REQ_TYPE, below=below) + return self._get( + str(identifier), reqif.RequirementType.__name__, below=below + ) def attribute_definition_by_identifier( self, xtype: str, identifier: str, below: reqif.ReqIFElement | None @@ -65,21 +69,27 @@ def work_item_by_identifier( self, identifier: int | str, below: reqif.ReqIFElement | None = None, - ) -> reqif.Requirement | reqif.RequirementsFolder | None: + ) -> reqif.Requirement | reqif.Folder | None: """Try to return a ``Requirement``/``RequirementsFolder``.""" return self._get( - str(identifier), reqif.XT_REQUIREMENT, reqif.XT_FOLDER, below=below + str(identifier), + reqif.Requirement.__name__, + reqif.Folder.__name__, + below=below, ) def enum_data_type_definition_by_long_name( self, long_name: str, below: reqif.ReqIFElement | None - ) -> reqif.EnumDataTypeDefinition | None: - """Try to return an ``EnumDataTypeDefinition``. + ) -> reqif.EnumerationDataTypeDefinition | None: + """Try to return an ``EnumerationDataTypeDefinition``. The object is matched with given ``long_name``. """ return self._get( - long_name, reqif.XT_REQ_TYPE_ENUM, attr="long_name", below=below + long_name, + reqif.EnumerationDataTypeDefinition.__name__, + attr="long_name", + below=below, ) def enum_value_by_long_name( @@ -89,5 +99,6 @@ def enum_value_by_long_name( The object is matched with given ``long_name``. """ - xt = reqif.XT_REQ_TYPE_ATTR_ENUM - return self._get(long_name, xt, attr="long_name", below=below) + return self._get( + long_name, reqif.EnumValue.__name__, attr="long_name", below=below + ) diff --git a/tests/data/changesets/create.yaml b/tests/data/changesets/create.yaml index df2a203..c7b49da 100644 --- a/tests/data/changesets/create.yaml +++ b/tests/data/changesets/create.yaml @@ -14,8 +14,6 @@ values: - long_name: Unset promise_id: EnumValue Type Unset - - long_name: Folder - promise_id: EnumValue Type Folder - long_name: Functional promise_id: EnumValue Type Functional promise_id: EnumerationDataTypeDefinition Type diff --git a/tests/data/model/RM Bridge.capella b/tests/data/model/RM Bridge.capella index 1b7fd01..7de9981 100644 --- a/tests/data/model/RM Bridge.capella +++ b/tests/data/model/RM Bridge.capella @@ -1839,8 +1839,6 @@ The predator is far away id="686e198b-8baf-49f9-9d85-24571bd05d93" ReqIFLongName="Type"> - diff --git a/tests/data/snapshots/snapshot.yaml b/tests/data/snapshots/snapshot.yaml index c0b8664..49368b6 100644 --- a/tests/data/snapshots/snapshot.yaml +++ b/tests/data/snapshots/snapshot.yaml @@ -12,7 +12,6 @@ modules: data_types: # Enumeration Data Type Definitions Type: - Unset - - Folder - Functional Release: - Feature Rel. 1 @@ -52,9 +51,6 @@ modules: text:

Test Description

type: system_requirement # WorkItemType ID NOT name - attributes: - Type: [Folder] # artifact from CODEBEAMER and cleaned in RM Bridge - children: # Folder b/c non-empty children - id: REQ-002 long_name: Function Requirement @@ -70,8 +66,6 @@ modules: - id: REQ-003 long_name: Kinds type: software_requirement - attributes: - Type: [Folder] children: - id: REQ-004 long_name: Kind Requirement diff --git a/tests/data/snapshots/snapshot1.yaml b/tests/data/snapshots/snapshot1.yaml index 263a3f8..b981e46 100644 --- a/tests/data/snapshots/snapshot1.yaml +++ b/tests/data/snapshots/snapshot1.yaml @@ -12,7 +12,6 @@ modules: data_types: Type: - Unset - - Folder - Functional - Non-Functional Release: @@ -70,8 +69,6 @@ modules: long_name: Functional Requirements text:

Brand new

type: software_requirement - attributes: - Type: [Folder] children: - id: REQ-004 long_name: Function Requirement diff --git a/tests/data/snapshots/snapshot2.yaml b/tests/data/snapshots/snapshot2.yaml index a28d121..b661822 100644 --- a/tests/data/snapshots/snapshot2.yaml +++ b/tests/data/snapshots/snapshot2.yaml @@ -12,7 +12,6 @@ modules: data_types: Type: - Unset - - Folder - Non-Functional requirement_types: system_requirement: diff --git a/tests/test_auditing.py b/tests/test_auditing.py index 6d775c9..e10d92f 100644 --- a/tests/test_auditing.py +++ b/tests/test_auditing.py @@ -336,6 +336,8 @@ def test_create_commit_message(migration_model: capellambse.MelodyModel): req_uuid = "163394f5-c1ba-4712-a238-b0b143c66aed" reqtypesfolder_uuid = "a15e8b60-bf39-47ba-b7c7-74ceecb25c9c" dtdef_uuid = "686e198b-8baf-49f9-9d85-24571bd05d93" + req = migration_model.by_uuid(req_uuid) + req.parent.requirements.remove(req) changes: list[auditing.Change] = [ auditing.Deletion( parent="9a9b5a8f-a6ad-4610-9e88-3b5e9c943c19", @@ -372,7 +374,7 @@ def test_create_commit_message(migration_model: capellambse.MelodyModel): commit_message = reporter.create_commit_message(tool_metadata) assert commit_message.startswith( - "Updated model with RM content from rev.123 [skip ci]\n" + "Updated model with RM content from rev.123\n" "\n" "Synchronized 1 category1 and 1 category2:\n" "- 1: created: 1; updated: 1; deleted: 1; type-changes: 0\n" diff --git a/tests/test_changeset.py b/tests/test_changeset.py index cf1639b..740bdb5 100644 --- a/tests/test_changeset.py +++ b/tests/test_changeset.py @@ -115,7 +115,7 @@ def test_init_on_missing_module_raises_MissingRequirementsModule( """ del clean_model.la.requirement_modules[0] - with pytest.raises(change.MissingRequirementsModule): + with pytest.raises(change.MissingCapellaModule): self.tracker_change(clean_model, TEST_SNAPSHOT["modules"][0]) def test_init_on_missing_module_id_raises_InvalidSnapshotModule( @@ -207,7 +207,7 @@ def test_faulty_attribute_values_log_InvalidFieldValue_as_error( first_child = titem["children"][0] first_child["attributes"][attr] = faulty_value # type:ignore[index] message_end = ( - f"Invalid field found: {key} '{faulty_value}' for '{attr}'" + f"Invalid field found: {key} {faulty_value!r} for {attr!r}" ) with caplog.at_level(logging.ERROR): @@ -229,7 +229,7 @@ def test_InvalidFieldValue_errors_are_gathered( ] = faulty_value # type:ignore[index] messages.append( "Invalid workitem 'REQ-002'. " - f"Invalid field found: {key} '{faulty_value}' for '{attr}'" + f"Invalid field found: {key} {faulty_value!r} for {attr!r}" ) change = self.tracker_change(clean_model, tracker, gather_logs=True) @@ -268,6 +268,22 @@ def test_InvalidAttributeDefinition_errors_are_gathered( assert change.errors == [INVALID_ATTR_DEF_ERROR_MSG] + def test_requirements_with_empty_children_are_rendered_as_folders( + self, clean_model: capellambse.MelodyModel + ) -> None: + tracker = copy.deepcopy(self.tracker) + tracker["items"][0]["children"][1]["children"][0]["children"] = [] + + change_set = self.tracker_change( + clean_model, tracker, gather_logs=True + ) + + assert change_set and (change := change_set.actions[0]["extend"]) + assert ( + change["folders"][0]["folders"][0]["folders"][0]["identifier"] + == "REQ-004" + ) + @pytest.mark.integtest def test_calculate_change_sets( self, clean_model: capellambse.MelodyModel @@ -342,7 +358,7 @@ def test_faulty_attribute_values_log_InvalidFieldValue_as_error( first_child = titem["children"][0] first_child["attributes"][attr] = faulty_value message_end = ( - f"Invalid field found: {key} '{faulty_value}' for '{attr}'" + f"Invalid field found: {key} {faulty_value!r} for {attr!r}" ) with caplog.at_level(logging.ERROR): @@ -350,6 +366,72 @@ def test_faulty_attribute_values_log_InvalidFieldValue_as_error( assert caplog.messages[0].endswith(message_end) + def test_faulty_data_types_log_InvalidAttributeDefinition_as_error( + self, + migration_model: capellambse.MelodyModel, + caplog: pytest.LogCaptureFixture, + ) -> None: + tracker = copy.deepcopy(self.tracker) + reqtype = tracker["requirement_types"]["system_requirement"] + reqtype["attributes"]["Not-Defined"] = { # type: ignore[call-overload] + "type": "Enum", + "data_type": "Not-Defined", + } + + with caplog.at_level(logging.ERROR): + self.tracker_change(migration_model, tracker, gather_logs=False) + + assert caplog.messages[0].endswith(INVALID_ATTR_DEF_ERROR_MSG) + + def test_faulty_simple_attributes_log_AttributeError( + self, + migration_model: capellambse.MelodyModel, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Test logging ``AttributeError`` on faulty simple attributes.""" + tracker = copy.deepcopy(self.tracker) + titem = tracker["items"][0] + titem["imagination"] = 1 + message_end = ( + "Invalid module 'project/space/example title'. Invalid " + "workitem 'REQ-001'. imagination isn't defined on Folder" + ) + + with caplog.at_level(logging.ERROR): + self.tracker_change(migration_model, tracker, gather_logs=False) + + assert caplog.messages[0].endswith(message_end) + + def test_InvalidAttributeDefinition_errors_are_gathered( + self, migration_model: capellambse.MelodyModel + ) -> None: + """Test faulty field data are gathered in errors.""" + tracker = copy.deepcopy(self.tracker) + reqtype = tracker["requirement_types"]["system_requirement"] + reqtype["attributes"]["Not-Defined"] = { # type: ignore[call-overload] + "type": "Enum", + "data_type": "Not-Defined", + } + + change = self.tracker_change( + migration_model, tracker, gather_logs=True + ) + + assert change.errors == [INVALID_ATTR_DEF_ERROR_MSG] + + def test_requirements_with_empty_children_are_rendered_as_folders( + self, clean_model: capellambse.MelodyModel + ) -> None: + tracker = copy.deepcopy(self.tracker) + tracker["items"][1]["children"][0]["children"] = [] + + change_set = self.tracker_change( + clean_model, tracker, gather_logs=True + ) + + assert change_set and (change := change_set.actions[0]["extend"]) + assert change["folders"][1]["folders"][0]["identifier"] == "REQ-004" + @pytest.mark.integtest def test_calculate_change_sets( self, migration_model: capellambse.MelodyModel @@ -491,7 +573,7 @@ def test_missing_module_UUID_logs_InvalidTrackerConfig_error( config = copy.deepcopy(TEST_CONFIG["modules"][0]) del config["capella-uuid"] # type:ignore[misc] message = ( - "The given module configuration is missing 'UUID' of the target " + "The given module configuration is missing UUID of the target " "RequirementsModule" ) @@ -538,7 +620,7 @@ def test_missing_module_id_logs_InvalidSnapshotModule_error( tconfig = TEST_CONFIG["modules"][0] message = ( "Skipping module: MISSING ID. " - "In the snapshot the module is missing an 'id' key" + "In the snapshot the module is missing an id key" ) with caplog.at_level(logging.ERROR): @@ -555,7 +637,7 @@ def test_init_errors_are_gathered( config = copy.deepcopy(TEST_CONFIG["modules"][0]) del config["capella-uuid"] # type:ignore[misc] message = ( - "The given module configuration is missing 'UUID' of the target " + "The given module configuration is missing UUID of the target " "RequirementsModule" ) @@ -566,13 +648,51 @@ def test_init_errors_are_gathered( assert errors[0].startswith(self.SKIP_MESSAGE) assert message in errors[0] + def test_forced_calculation_produces_change_set_on_AttributeDefinition_error( + self, clean_model: capellambse.MelodyModel + ) -> None: + """Test that an invalid AttributeDefinition will not prohibit.""" + snapshot = copy.deepcopy(TEST_SNAPSHOT["modules"][0]) + missing_enumdt = "Release" + del snapshot["data_types"][missing_enumdt] # type: ignore[attr-defined] + tconfig = TEST_CONFIG["modules"][0] + message = ( + "In RequirementType 'System Requirement': Invalid " + "AttributeDefinitionEnumeration found: 'Release'. Missing its " + "datatype definition in `data_types`.\n" + "Invalid workitem 'REQ-002'. Invalid field found: 'Release'. " + "Missing its datatype definition in `data_types`." + ) + + change_sets, errors = calculate_change_set( + clean_model, tconfig, snapshot, gather_logs=True, force=True + ) + + assert (change_set := change_sets[0]) + for rtfolder in change_set["extend"]["requirement_types_folders"]: + for req_type in rtfolder["requirement_types"]: + for attr_def in req_type["attribute_definitions"]: + assert attr_def["long_name"] != missing_enumdt + + folder = change_set["extend"]["folders"][0] + for folder in folder["folders"]: + for attr_value in folder.get("attributes", []): + assert ( + missing_enumdt not in attr_value["definition"].identifier + ) + + assert errors[0].startswith( + "Encountered error(s) in 'project/space/example title'" + ) + assert message in errors[0] + def test_snapshot_errors_from_ChangeSet_calculation_are_gathered( self, clean_model: capellambse.MelodyModel ) -> None: snapshot = copy.deepcopy(TEST_SNAPSHOT["modules"][0]) titem = snapshot["items"][0] first_child = titem["children"][0] - titem["attributes"]["Test"] = 1 # type: ignore[index] + titem["attributes"] = {"Test": 1} # type: ignore[index] messages = [ "Invalid workitem 'REQ-001'. " "Invalid field found: field name 'Test' not defined in " @@ -584,9 +704,10 @@ def test_snapshot_errors_from_ChangeSet_calculation_are_gathered( ] = faulty_value # type:ignore[index] messages.append( "Invalid workitem 'REQ-002'. " - + f"Invalid field found: {key} '{faulty_value}' for '{attr}'" + + f"Invalid field found: {key} {faulty_value!r} for {attr!r}" ) del titem["children"][1]["type"] + titem["children"][1]["attributes"] = {"Test": 1} tconfig = TEST_CONFIG["modules"][0] messages.append( "Invalid workitem 'REQ-003'. Missing type but attributes found"