Skip to content

model.base: Copy Qualifier and Extension data in update_nss_from - #624

Open
sankalpsthakur wants to merge 2 commits into
eclipse-basyx:developfrom
sankalpsthakur:fix/602-namespaceset-update-nss
Open

sankalpsthakur wants to merge 2 commits into
eclipse-basyx:developfrom
sankalpsthakur:fix/602-namespaceset-update-nss

Conversation

@sankalpsthakur

@sankalpsthakur sankalpsthakur commented Sep 5, 2026 •

Copy link
Copy Markdown

Matching Qualifier and Extension objects in NamespaceSet.update_nss_from()
were looked up and then left unchanged, so a later copy of the same object
silently kept the old value. Referable already copies in place.

Qualifier.update_from() and Extension.update_from() now copy the
non-identity fields onto the object that already lives in the target set.

Fixes #602

AI/LLM disclosure

AI coding tools assisted with the change and this description.

`NamespaceSet.update_nss_from()` is how backends refresh a namespace
from a newer copy of the same objects. Matching Referable objects get
their attributes copied in place. Matching Qualifier and Extension
objects were only looked up; their value and the rest of the payload
stayed on the old object. A PUT of a Submodel with the same qualifier
type therefore kept the previous value.

Backends already treat a Qualifier of the same type and an Extension of
the same name as the same object, so those fields have to be copied
onto the object that already lives in the target set.

Fixes eclipse-basyx#602
@zrgt
zrgt self-requested a review October 6, 2026 13:19
Comment thread sdk/basyx/aas/model/base.py Outdated
Comment thread sdk/basyx/aas/model/base.py Outdated
Comment thread sdk/basyx/aas/model/base.py Outdated
Comment thread sdk/basyx/aas/model/base.py
Comment thread sdk/basyx/aas/model/base.py
Comment thread sdk/test/model/test_base.py Outdated
Comment thread sdk/test/model/test_base.py Outdated
`Extension.update_from()` and `Qualifier.update_from()` left `name`
and `type` as they were, so they did not produce a full copy of the
other version of the object. The setters of both attributes reject
a value that is already present in the parent namespace, which
includes the object's own current value. As `update_nss_from()`
matches the objects by exactly that attribute, it is only assigned
if it actually differs.

`Extension.update_from()` also took over the `refers_to` set of the
other object, so that both extensions shared the same set instance
afterwards.
@sankalpsthakur

Copy link
Copy Markdown
Author

@zrgt addressed in 0b16e07 (name copied, tests merged into the existing cases). Could you re-review?

@zrgt zrgt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed both commits. The direction is right — #602 is a real bug and update_nss_from silently dropping qualifier/extension data needs fixing — but two defects in the new code should be addressed before merge, and the second commit's approach treats a symptom rather than the cause.

Blocking

  1. update_from() is now called inside the except KeyError guard (line 2447). A KeyError from the type/name setter is read as "object not present", the element is removed from other and destroyed, and the caller gets an unrelated AASd-021. Reproduced below; develop handles the same input cleanly.
  2. semantic_id is assigned before supplemental_semantic_id (lines 1972 and 1797), so any update that clears semantic_id raises AASd-118 and leaves the object half-updated. Reproduced below.

Approach

The if self.type != other.type / if self.name != other.name guards from the second commit are dead code for every namespace in the SDK (all keys are case-sensitive) and raise KeyError in the only case where they do execute. The cause is that Qualifier.type and Extension.name lack the no-op early return Referable._set_id_short has, and their duplicate check does not exclude self. Fixing the setters removes the need for both guards and makes renames work.

Tests cover neither bug: no differing type/name case, and no assertion on supplemental_semantic_id or value_id.

I also left one comment on the # Todo: Implement function including tests removal — four pre-existing defects remain in the removal loop (one of them a reproducible KeyError on any multi-key NamespaceSet), so removing the marker asserts more than currently holds.

Verified on the PR head: python -m unittest, ruff check, mypy basyx test and the Sphinx build with -n -W all pass; copyright years and commit format match CONTRIBUTING.md. Every repro above was run against the PR head and compared with develop.


Generated by Claude Code

extension.update_from(other_object) # type: ignore
else:
raise TypeError("Type not implemented")
except KeyError:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enabling update_from() here puts it inside this try, so a KeyError raised inside update_from is now misread as "object is not in NamespaceSet" — and the element gets destroyed.

class Q(model.Qualifiable):
    def __init__(self, values=()):
        super().__init__()
        self.set1 = model.NamespaceSet(self, [("type", False)], values)

a = Q([model.Qualifier("type1", model.datatypes.Int, 1)])
b = Q([model.Qualifier("TYPE1", model.datatypes.Int, 99)])
a.set1.update_nss_from(b.set1)

The case-insensitive lookup matches, update_from runs self.type = other.type, and the type setter raises KeyError("Object with type 'TYPE1' is already present in the parent Namespace"). It is swallowed, the object lands in objects_to_add, other.remove(obj) succeeds and self.add(obj) raises:

AASConstraintViolation: Object with attribute (name='type', value='TYPE1') is already present in this set of objects (Constraint AASd-021)
a.set1 = [('type1', 1)]   # stale
b.set1 = []               # qualifier gone

On develop the same input is a clean no-op. The try should cover only the backend lookup — use backend.get(...) and call update_from() outside it. Same applies to the pre-existing referable.update_from() path.


Generated by Claude Code

self.value = other.value
self.value_id = other.value_id
self.kind = other.kind
self.semantic_id = other.semantic_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ordering bug: semantic_id is assigned before supplemental_semantic_id, so an update that clears semantic_id hits the AASd-118 guard in the setter (base.py:1660).

q = model.Qualifier("q", model.datatypes.String, "old",
                    semantic_id=S, supplemental_semantic_id=[T])
model.Submodel("urn:sm").add_qualifier(q)
q.update_from(model.Qualifier("q", model.datatypes.String, "new", semantic_id=None))
# AASConstraintViolation: semantic_id can not be removed while there is at least one
# supplemental_semantic_id (Constraint AASd-118)

self.value is already "new" when it raises, so the object is left half-updated. Clear supplemental_semantic_id first, then assign semantic_id, then the new supplemental list. Same at line 1797 in Extension.update_from.


Generated by Claude Code

:param other: The object to update from
"""
# The type setter rejects the current type as a duplicate, so only assign a changed type
if self.type != other.type:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard is dead code in the SDK, and broken in the one case where it does run.

Every production set is case-sensitive (submodel.py:95,96,176,178 use ("type", True) / ("name", True)) and update_nss_from looks the object up by exactly that key, so the found qualifier's type always equals other.type — the branch can never be taken. In a case-insensitive set it is taken, and raises KeyError (see my comment on line 2447).

The actual defect is in the setter: Qualifier.type (line 1936) and Extension.name lack the no-op short-circuit that Referable._set_id_short has —

if id_short == self.id_short:
    return

— and their set_.contains_id("type", type_) check does not exclude self. Fixing the setters makes renames work and lets both guards go away.


Generated by Claude Code


:param other: The object to update from
"""
# The name setter rejects the current name as a duplicate, so only assign a changed name

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment states an intentional contract where there is a setter bug: the setter does not "reject the current name", it simply fails to exclude self from set_.contains_id("name", name). Contrast Referable._set_id_short, which returns early on a no-op.

Worth rewording (same text at line 1964 for type), otherwise whoever fixes the setters later will not notice these two guards become dead.


Generated by Claude Code

Internal function to update the object's attributes from a different version of the exact same object.

This function should not be used directly. It is typically used by backend implementations (database adapters,
protocol clients, etc.) to update the object's data, after ``update_nss_from()`` has been called.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"after update_nss_from() has been called" inverts the call direction: update_nss_from (line 2409) is the only caller of this method (lines 2436 and 2444), so it runs during update_nss_from, not after it.

The sentence is copied from Referable.update_from, where it is equally wrong, but these are new Sphinx-published docstrings — worth getting right here. Same text at line 1960.


Generated by Claude Code

# Redundant to the line above. However, this way, we make sure that we really update the _name
self._name = name

def update_from(self, other: "Extension") -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extension.update_from and Qualifier.update_from share four verbatim lines (value_type, value, semantic_id, supplemental_semantic_id), identical comments included. The AASd-118 ordering fix has to be applied in both places and will drift.

HasSemantics is their common base — a HasSemantics.update_from copying semantic_id/supplemental_semantic_id, overridden to add the subtype-specific fields, removes the duplication and also covers other HasSemantics members of NamespaceSets, which get no implementation at all today.


Generated by Claude Code

# extension.update_from(other_object) # TODO: What should happend here? Remove noqa when done
extension.update_from(other_object) # type: ignore
else:
raise TypeError("Type not implemented")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_NSO is bound to Union[Referable, Qualifier, HasSemantics, Extension] (line 571), and UniqueSemanticIdNamespace._get_object(HasSemantics, "semantic_id", ...) treats sets as holding bare HasSemantics — so this raises TypeError("Type not implemented") for a legitimate member type, aborting Referable.update_from on its owner.

Not introduced by this PR, but the removed # Todo: Implement function including tests marker was covering it. Either handle the generic HasSemantics case or narrow _NSO.


Generated by Claude Code

)

# Todo: Implement function including tests
def update_nss_from(self, other: "NamespaceSet"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping the # Todo: Implement function including tests marker asserts this function is complete and tested, but four defects remain in the removal loop below (all pre-existing, none introduced here):

  1. Line 2461 — item appended once per backend key, then removed twice. Reproduced on this branch and on develop:

    ns = NamespaceSet(self, [("id_short", False), ("semantic_id", True)], [Property("Prop1", Int, semantic_id=S)])
    ns.update_nss_from(empty_set)   # KeyError: 'PROP1'

    Both keys match, Prop1 is appended to objects_to_remove twice, and the second self.remove(item) raises. Dedup objects_to_remove or break after the first matching key.

  2. Line 2458 — case_sensitive_other is unpacked at 2453 and never used. The backend_other lookup uses self's flag. With self keyed ("type", True) and other keyed ("type", False), every lookup misses and every element of self is deleted. ruff does not catch it because it is a tuple-unpacking target.

  3. Line 2455 — if attr_name is attr_name_other compares dict-key strings by identity. It works only because all current call sites pass interned literals; a dynamically built attribute name makes the whole removal pass silently never run.

  4. Line 2457 — if not backend_other.get(...) tests the found element's truthiness instead of is None, so any member with a falsy __bool__/__len__ is deleted although it exists in other. Latent today, but is None is what is meant.

Suggestion: either fix these in this PR, or keep a narrower Todo so the gap stays visible.


Generated by Claude Code

# ExampleNamespaceReferable also indexes semantic_id, so Qualifier/Extension
# objects belong in namespaces that only key on type/name.
qualifier_ns1 = self._namespace_class_qualifier()
qualifier1 = model.Qualifier(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests do not cover either of the two bugs above.

  • Every qualifier/extension pair here already has an identical type/name, so the if self.type != other.type branch (line 1965) and if self.name != other.name (1790) have zero coverage — which is why they ship green despite raising KeyError when taken.
  • self.supplemental_semantic_id = other.supplemental_semantic_id (1797/1973) and self.value_id = other.value_id (1970) are never asserted, which is why the AASd-118 ordering defect ships green.

A case with Qualifier(semantic_id=S, supplemental_semantic_id=[T]) updated from a qualifier with semantic_id=None, and one with a genuinely differing type/name, both fail today.


Generated by Claude Code

self.set1 = model.NamespaceSet(self, [("type", False)], values)


class ExampleNamespaceExtension(model.HasExtension):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExampleNamespaceExtension is instantiated directly in the test body rather than exposed as _namespace_class_extension, the way _namespace_class_qualifier already is.

ModelOrderedNamespaceTest overrides only _namespace_class, so the inherited run replays the same ~80 lines of qualifier/extension assertions against plain NamespaceSets a second time — runtime spent for no extra coverage, while OrderedNamespaceSet qualifier/extension behaviour stays untested. Adding _namespace_class_extension and overriding both in the ordered subclass follows the existing pattern.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants