diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d63406b65c1d466c69cd6e90593bc8ce3095734..89a998e7b8b2e8997bdf333f83cda2463de4219a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### +* `apiutils.EntityMergeConflictError` class for unresesolvable merge conflicts + when merging two entities + ### Changed ### +* `apiutils.merge_entities` now raises an `EntityMergeConflictError` in case of + unresolvable merge conflicts. + ### Deprecated ### ### Removed ### diff --git a/src/caosdb/apiutils.py b/src/caosdb/apiutils.py index 0862cf9f32575b9773bc16d845bb459d67b0140c..f3195b8e152f0cb13e5dab3e3a449b7bb36623b4 100644 --- a/src/caosdb/apiutils.py +++ b/src/caosdb/apiutils.py @@ -27,12 +27,13 @@ Some simplified functions for generation of records etc. """ +import logging import sys import tempfile import warnings + from collections.abc import Iterable from subprocess import call - from typing import Optional, Any, Dict, List from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, @@ -40,8 +41,13 @@ from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, from caosdb.common.models import (Container, Entity, File, Property, Query, Record, RecordType, execute_query, get_config, SPECIAL_ATTRIBUTES) +from caosdb.exceptions import CaosDBException -import logging + +class EntityMergeConflictError(CaosDBException): + """An error that is raised in case of an unresolvable conflict when merging + two entities. + """ def new_record(record_type, name=None, description=None, @@ -365,14 +371,15 @@ def empty_diff(old_entity: Entity, new_entity: Entity, compare_referenced_record return True -def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_empty_diffs=True, force=False): - """ - Merge entity_b into entity_a such that they have the same parents and properties. +def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_empty_diffs=True, + force=False): + """Merge entity_b into entity_a such that they have the same parents and properties. - datatype, unit, value, name and description will only be changed in entity_a if they - are None for entity_a and set for entity_b. If there is a corresponding value - for entity_a different from None a RuntimeError will be raised informing of an - unresolvable merge conflict. + datatype, unit, value, name and description will only be changed in entity_a + if they are None for entity_a and set for entity_b. If there is a + corresponding value for entity_a different from None, an + EntityMergeConflictError will be raised to inform about an unresolvable merge + conflict. The merge operation is done in place. @@ -392,13 +399,18 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp force : bool, optional If True, in case `entity_a` and `entity_b` have the same properties, the values of `entity_a` are replaced by those of `entity_b` in the merge. - If `False`, a RuntimeError is raised instead. Default is False. + If `False`, an EntityMergeConflictError is raised instead. Default is False. Returns ------- entity_a : Entity The initial entity_a after the in-place merge + Raises + ------ + EntityMergeConflictError + In case of an unresolvable merge conflict. + """ logging.warning( @@ -433,8 +445,8 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp setattr(entity_a.get_property(key), attribute, diff_r2["properties"][key][attribute]) else: - raise RuntimeError( - f"Merge conflict:\nEntity a ({entity_a.id}, {entity_a.name}) " + raise EntityMergeConflictError( + f"Entity a ({entity_a.id}, {entity_a.name}) " f"has a Property '{key}' with {attribute}=" f"{diff_r2['properties'][key][attribute]}\n" f"Entity b ({entity_b.id}, {entity_b.name}) " @@ -463,7 +475,9 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp # force overwrite setattr(entity_a, special_attribute, sa_b) else: - raise RuntimeError("Merge conflict.") + raise EntityMergeConflictError( + f"Conflict in special attribute {special_attribute}:\n" + f"A: {sa_a}\nB: {sa_b}") return entity_a diff --git a/unittests/test_apiutils.py b/unittests/test_apiutils.py index 8026b7cd97e5e552eddf61b3f89c546933eaf579..9b87a743b201213feb49cfc660e006f2c6217387 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -30,7 +30,8 @@ import pytest import caosdb as db import caosdb.apiutils from caosdb.apiutils import (apply_to_ids, compare_entities, create_id_query, - empty_diff, resolve_reference, merge_entities) + empty_diff, EntityMergeConflictError, + resolve_reference, merge_entities) from caosdb.common.models import SPECIAL_ATTRIBUTES @@ -307,7 +308,7 @@ def test_merge_bug_conflict(): r3 = db.Record() r3.add_property(name="C", value=4, datatype="INTEGER") - with pytest.raises(RuntimeError) as excinfo: + with pytest.raises(EntityMergeConflictError): merge_entities(r3, r2) @@ -402,15 +403,13 @@ def test_wrong_merge_conflict_reference(): rec_a.add_property(name=title_prop.name, value="Some dataset title") # this does not compare referenced records, so it will fail - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError): merge_entities(rec_a, rec_b, merge_references_with_empty_diffs=False) - assert "Merge conflict" in str(re.value) # ... as should this, of course rec_b.get_property(license_rt.name).value.name = "Another license" - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError) as re: merge_entities(rec_a, rec_b) - assert "Merge conflict" in str(re.value) def test_empty_diff(): @@ -484,9 +483,8 @@ def test_force_merge(): recA = db.Record(name="A") recB = db.Record(name="B") - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError): merge_entities(recA, recB) - assert "Merge conflict" in str(re.value) merge_entities(recA, recB, force=True) assert "B" == recA.name @@ -499,9 +497,11 @@ def test_force_merge(): recB = db.Record() recB.description = "something else" - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError) as emce: merge_entities(recA, recB) - assert "Merge conflict" in str(re.value) + assert str(emce.value) == """Conflict in special attribute description: +A: something +B: something else""" merge_entities(recA, recB, force=True) assert recA.description == "something else" @@ -514,9 +514,8 @@ def test_force_merge(): recB = db.Record() recB.add_property(name="propA", value="something else") - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError): merge_entities(recA, recB) - assert "Merge conflict" in str(re.value) merge_entities(recA, recB, force=True) assert recA.get_property("propA").value == "something else" @@ -540,9 +539,8 @@ def test_force_merge(): rtB = db.RecordType() rtB.add_property(name="propA", datatype=db.TEXT) - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError): merge_entities(rtA, rtB) - assert "Merge conflict" in str(re.value) merge_entities(rtA, rtB, force=True) assert rtA.get_property("propA").datatype == db.TEXT @@ -555,9 +553,8 @@ def test_force_merge(): recB = db.Record() recB.add_property(name="propA", value=5, unit="cm") - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError): merge_entities(recA, recB) - assert "Merge conflict" in str(re.value) merge_entities(recA, recB, force=True) assert recA.get_property("propA").unit == "cm" # unchanged