Bonsai: refuse class-mismatched type assignment

Schema-illegal IfcDoor->IfcWallType pairings parse cleanly but propagate
into operators that fan out by type and eventually crash the wrapper.
Block the pairing at its source: API guard in ifcopenshell.api.type.
assign_type, per-object partition in BIM_OT_assign_type + DuplicateType,
new tool.Type.is_relating_type_compatible helper, AST forward-compat
guard. Files in the wild are still loaded unchanged.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-24 08:47:57 +02:00
parent 59383f5010
commit 10ee5aef4f
9 changed files with 416 additions and 4 deletions
@@ -0,0 +1,104 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST contracts for the class-mismatched-type-assignment guard.
Two structural invariants that no behavioural test can pin on its own:
1. ``ifcopenshell.api.type.assign_type`` MUST reference
``ifcopenshell.util.type.get_applicable_entities`` (or
``get_applicable_types``) — the schema-aware applicability lookup that
produces the canonical class-pairing whitelist. A drift here means the
API stops rejecting class-mismatched pairs.
2. ``bonsai.bim.module.type.operator`` MUST reference
``tool.Type.is_relating_type_compatible`` — the single source of truth
for partition / WARNING / CANCELLED behaviour in the Bonsai operator
layer. A drift here re-opens the fan-out hole that silently writes
schema-corrupt typings into the selection when one (active) object's
class drove the picker but other selected objects don't match.
"""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.type
BONSAI_ROOT = Path(__file__).resolve().parents[4] / "bonsai"
IFCOPENSHELL_API_ASSIGN_TYPE = (
Path(__file__).resolve().parents[5] / "ifcopenshell-python" / "ifcopenshell" / "api" / "type" / "assign_type.py"
)
BONSAI_TYPE_OPERATOR = BONSAI_ROOT / "bim" / "module" / "type" / "operator.py"
def _attribute_chain(node: ast.AST) -> str:
"""Render an ``ast.Attribute``/``ast.Name`` chain as a dotted string,
e.g. ``tool.Type.is_relating_type_compatible``. Returns ``""`` if the
chain bottoms out on something other than a Name (e.g. a subscript)."""
parts: list[str] = []
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
if isinstance(node, ast.Name):
parts.append(node.id)
return ".".join(reversed(parts))
return ""
def _all_attribute_chains(tree: ast.Module) -> set[str]:
chains: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Attribute):
chain = _attribute_chain(node)
if chain:
chains.add(chain)
return chains
def test_api_assign_type_calls_applicability_lookup() -> None:
"""Pin Layer B: ``ifcopenshell.api.type.assign_type`` references
``ifcopenshell.util.type.get_applicable_entities`` (the source of truth
for which occurrence classes a given type class may type)."""
tree = ast.parse(IFCOPENSHELL_API_ASSIGN_TYPE.read_text(encoding="utf-8"))
chains = _all_attribute_chains(tree)
sentinel = "ifcopenshell.util.type.get_applicable_entities"
assert sentinel in chains, (
f"{IFCOPENSHELL_API_ASSIGN_TYPE.name} no longer references {sentinel}. "
"The API-layer guard against class-mismatched type assignment is gone."
)
def test_bonsai_type_operator_module_references_compatibility_helper() -> None:
"""Pin Layer C: the Bonsai type operator module references
``tool.Type.is_relating_type_compatible``. Every operator in this file
that fans assign_type calls across a multi-selection must filter
through this helper to avoid writing mismatched typings on objects the
panel picker didn't validate."""
tree = ast.parse(BONSAI_TYPE_OPERATOR.read_text(encoding="utf-8"))
chains = _all_attribute_chains(tree)
sentinel = "tool.Type.is_relating_type_compatible"
assert sentinel in chains, (
f"{BONSAI_TYPE_OPERATOR.name} no longer references {sentinel}. "
"Operator-layer partition that prevents schema-illegal type "
"assignment across multi-selection has been removed."
)
@@ -0,0 +1,176 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Contract test for BIM_OT_assign_type's per-object class-compatibility
partition.
When the user multi-selects mixed classes (e.g. a wall + a door), the panel
picker filters the class dropdown by the active object's class only.
Historically the operator then fanned out across the whole selection without
re-checking each occurrence, producing schema-corrupt IFC files (IfcDoor
typed by IfcWallType). The partition added in this change must:
1. Assign the type only to compatible occurrences.
2. Surface skipped classes through ``self.report({'WARNING'}, ...)``.
3. ``return {'CANCELLED'}`` and emit an ERROR when nothing in the selection
is compatible — no mutation must reach ``core.assign_type``.
"""
from unittest import mock
import pytest
pytestmark = pytest.mark.type
@pytest.fixture(autouse=True)
def _require_real_bpy():
import types as _types
import bpy
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
@pytest.fixture
def fresh_ifc():
import ifcopenshell
from bonsai.bim.ifc import IfcStore
previous = IfcStore.file
IfcStore.file = ifcopenshell.file(schema="IFC4")
try:
yield IfcStore.file
finally:
IfcStore.file = previous
def _make_object(name, element):
"""Build a real bpy.types.Object linked to an IFC entity via
tool.Ifc.link, so tool.Ifc.get_entity(obj) resolves correctly."""
import bpy
import bonsai.tool as tool
obj = bpy.data.objects.new(name, None)
tool.Ifc.link(element, obj)
return obj
def _execute_assign(op, context):
"""Drive ``AssignType._execute`` directly. Bypasses the framework's
transaction wrapping so a unit test can observe the partition without
setting up the full Blender harness."""
return op._execute(context)
@pytest.fixture
def neutralised_side_effects():
"""Patch the helpers ``AssignType._execute`` calls outside the partition
logic (addon prefs, drawing context lookup, drawing target-view branch),
so the test asserts only the partition / report / return-code contract."""
with mock.patch("bonsai.bim.module.type.operator.tool.Blender.get_addon_preferences") as prefs:
prefs.return_value = mock.Mock(occurrence_name_style="OCCURRENCE")
yield
def _build_context_with_no_active_drawing():
"""Return a Mock ``context`` whose ``scene.DocProperties.active_drawing_id``
is 0, skipping the drawing-target-view block in ``_execute``."""
context = mock.Mock()
context.scene.DocProperties.active_drawing_id = 0
return context
def _fake_operator_with_report():
"""Build a Mock that satisfies the attribute reads ``AssignType._execute``
makes on ``self`` (``relating_type``, ``related_object``, ``report``)."""
op = mock.MagicMock()
op.relating_type = 0
op.related_object = ""
op.report = mock.Mock()
return op
def test_mixed_selection_assigns_only_compatible_objects(fresh_ifc, neutralised_side_effects):
"""Wall + door selected, IfcWallType picked: wall gets typed, door is
skipped with a WARNING, and the operator returns success.
``core.assign_type`` is mocked: it would otherwise run the
representation-switch and material plumbing on stub Blender objects.
The contract under test is the partition / report logic, not the
downstream representation pipeline."""
import ifcopenshell.api.root
from bonsai.bim.module.type.operator import AssignType
wall_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWall")
door_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcDoor")
wall_type = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWallType")
wall_obj = _make_object("Wall", wall_elem)
door_obj = _make_object("Door", door_elem)
op = _fake_operator_with_report()
op.relating_type = wall_type.id()
with mock.patch(
"bonsai.bim.module.type.operator.tool.Blender.get_selected_objects", return_value=[wall_obj, door_obj]
), mock.patch("bonsai.bim.module.type.operator.core.assign_type") as mock_assign:
result = AssignType._execute(op, _build_context_with_no_active_drawing())
assert result != {"CANCELLED"}, "operator must succeed when at least one object is compatible"
typed_elements = {call.kwargs["element"] for call in mock_assign.call_args_list}
assert typed_elements == {
wall_elem
}, f"only the compatible wall element should reach core.assign_type, got {typed_elements}"
warning_calls = [c for c in op.report.call_args_list if c.args[0] == {"WARNING"}]
assert warning_calls, "skipped occurrence class must surface as a WARNING"
assert any("IfcDoor" in c.args[1] for c in warning_calls)
def test_all_incompatible_selection_returns_cancelled_without_mutation(fresh_ifc, neutralised_side_effects):
"""Door alone selected, IfcWallType picked: nothing to assign. Operator
must return CANCELLED, emit an ERROR, and never call core.assign_type."""
import ifcopenshell.api.root
import ifcopenshell.util.element
from bonsai.bim.module.type.operator import AssignType
door_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcDoor")
wall_type = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWallType")
door_obj = _make_object("Door", door_elem)
op = _fake_operator_with_report()
op.relating_type = wall_type.id()
with mock.patch(
"bonsai.bim.module.type.operator.tool.Blender.get_selected_objects", return_value=[door_obj]
), mock.patch("bonsai.bim.module.type.operator.core.assign_type") as mock_assign:
result = AssignType._execute(op, _build_context_with_no_active_drawing())
assert result == {"CANCELLED"}
assert mock_assign.call_count == 0
error_calls = [c for c in op.report.call_args_list if c.args[0] == {"ERROR"}]
assert error_calls, "all-incompatible selection must surface as an ERROR"
assert ifcopenshell.util.element.get_type(door_elem) is None
+42
View File
@@ -172,6 +172,48 @@ class TestHasMaterialUsage(NewFile):
assert subject.has_material_usage(element) is True
class TestIsRelatingTypeCompatible(NewFile):
def test_matched_pair_ifc4(self):
ifc = ifcopenshell.file()
door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
door_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorType")
assert subject.is_relating_type_compatible(door, door_type) is True
def test_mismatched_pair_ifc4(self):
ifc = ifcopenshell.file()
door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType")
assert subject.is_relating_type_compatible(door, wall_type) is False
def test_legacy_style_pairing_allowed_in_ifc4(self):
ifc = ifcopenshell.file()
door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
door_style = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorStyle")
assert subject.is_relating_type_compatible(door, door_style) is True
def test_legacy_style_pairing_refused_in_ifc4x3(self):
ifc = ifcopenshell.file(schema="IFC4X3")
door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
try:
door_style = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorStyle")
except Exception:
# IfcDoorStyle was removed in IFC4X3 — exclusion holds trivially.
return
assert subject.is_relating_type_compatible(door, door_style) is False
def test_untypable_occurrence_returns_false(self):
ifc = ifcopenshell.file()
opening = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcOpeningElement")
any_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorType")
assert subject.is_relating_type_compatible(opening, any_type) is False
def test_proxy_type_pairing(self):
ifc = ifcopenshell.file()
proxy = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcBuildingElementProxy")
proxy_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcBuildingElementProxyType")
assert subject.is_relating_type_compatible(proxy, proxy_type) is True
class TestRunGeometryAddRepresentation(NewFile):
def test_nothing(self):
pass