Bonsai: stop supporting library-only files, do not repair them

Per Moult's feedback: if the IFC is invalid, our default position is to not
support it, not to patch around it. A library file with no IfcProject is
invalid IFC (Project Context concept template requires exactly one
IfcProject), and it is not ubiquitous: every library file bonsai ships under
bim/data/libraries has an IfcProject with the IfcProjectLibrary declared to
it via IfcRelDeclares. The single #8183 report is an outlier, not a common
authoring pattern worth accommodating.

Remove tool.Project.ensure_project_context() and open_library_file() (the
load-time repair added in the previous commit here) and revert
SelectLibraryFile's three load sites to plain ifcopenshell.open. Simplify
get_root_context() back to returning ifc_file.by_type("IfcProject")[0]
directly, no IfcProjectLibrary fallback: a file without IfcProject now raises
IndexError instead of being silently treated as valid. AddProjectLibrary's
nest-under-library branch is now dead code (root_context is always an
IfcProject) and is removed. The one-line enum dedup guard from the original
commit here is also removed: since get_root_context can only return an
IfcProject or raise, an IfcProject id can never collide with a library id, so
the guard has nothing left to guard against.

Rework test_project_library_data.py: drop the invalid _make_library_only_file
fixture and its tests, which asserted an unsupported model as correct
behaviour. Replace with a single spec-valid fixture matching bonsai's own
shipped library files (IfcProject + IfcProjectLibrary declared to it), used
for the ci-bonsai-daily regression test and the refresh/add-library
operators, plus one explicit test that get_root_context raises for a file
without IfcProject, documenting that this input is intentionally
unsupported rather than silently tolerated.

Verified live in headless Blender (isolated profile, source-loaded, never
the real profile): confirmed the removed methods are gone, that a
library-only file now raises instead of being handled, that
ProjectLibraryData/refresh_library/add_project_library all work correctly
on a spec-valid model with unique enum keys, and spot-checked that every
library file under bim/data/libraries already has an IfcProject.

This change was made with the assistance of an AI tool.
This commit is contained in:
Petru Conduraru
2026-07-24 08:41:18 +03:00
parent 23c74891e0
commit 56d0e0d189
4 changed files with 38 additions and 138 deletions
@@ -165,9 +165,6 @@ class ProjectLibraryData:
root = tool.Project.get_root_context(library_file)
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
for library_id, data in cls.data["project_libraries"].items():
# Defensive guard only; see tool.Project.ensure_project_context for the fix.
if library_id == root.id():
continue
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
return results
@@ -215,7 +215,7 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
filepath = self.get_filepath()
ifc_file = tool.Ifc.get()
library_file: ifcopenshell.file
library_file = tool.Project.open_library_file(filepath)
library_file = ifcopenshell.open(filepath)
if library_file.schema_identifier != ifc_file.schema_identifier:
self.report(
{"ERROR"},
@@ -237,14 +237,14 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
def rollback(self, data):
if data["old_filepath"]:
IfcStore.library_path = data["old_filepath"]
IfcStore.library_file = tool.Project.open_library_file(data["old_filepath"])
IfcStore.library_file = ifcopenshell.open(data["old_filepath"])
else:
IfcStore.library_path = ""
IfcStore.library_file = None
def commit(self, data):
IfcStore.library_path = data["filepath"]
IfcStore.library_file = tool.Project.open_library_file(data["filepath"])
IfcStore.library_file = ifcopenshell.open(data["filepath"])
def draw(self, context):
self.layout.prop(self, "append_all", text="Append Entire Library")
@@ -809,10 +809,7 @@ class AddProjectLibrary(bpy.types.Operator):
assert library_file
root_context = tool.Project.get_root_context(library_file)
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
if root_context.is_a("IfcProject"):
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
else:
ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context)
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
ProjectLibraryData.load() # Update enum.
props.selected_project_library = str(project_library.id())
props.is_editing_project_library = True
+7 -47
View File
@@ -38,8 +38,6 @@ from typing import (
import bpy
import ifcopenshell
import ifcopenshell.api.document
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.shape_builder
@@ -392,10 +390,9 @@ class Project(bonsai.core.tool.Project):
) -> Union[ifcopenshell.entity_instance, None]:
"""Return the IfcContext that declares or nests ``project_library``.
Every IfcProjectLibrary should be either nested under another library or
declared to the file's IfcProject (see ensure_project_context()). Returns
``None`` only as a defensive fallback for malformed data with neither
relationship, which should not occur on a normalized file.
Every IfcProjectLibrary in a supported (spec-valid) file is either nested
under another library or declared to the file's IfcProject. Returns ``None``
only as a defensive fallback for malformed data with neither relationship.
"""
if nests := project_library.Nests:
return nests[0].RelatingObject
@@ -409,48 +406,11 @@ class Project(bonsai.core.tool.Project):
Per the IFC Project Context concept template, every project data set (this
includes library files) shall contain exactly one IfcProject; there is no
such thing as a spec-valid file rooted on IfcProjectLibrary alone. Files
opened through open_library_file() are normalized by ensure_project_context()
so an IfcProject is always present here. The IfcProjectLibrary fallback below
only guards callers that bypass that normalization (e.g. a file opened
directly with ifcopenshell.open); it is not a legitimate IFC structure and
should not be relied upon. Caller is responsible for the IFC2X3 guard;
IfcContext does not exist in that schema.
such thing as a spec-valid file rooted on IfcProjectLibrary alone. A file
without an IfcProject is not supported and this raises IndexError. Caller is
responsible for the IFC2X3 guard; IfcContext does not exist in that schema.
"""
if projects := ifc_file.by_type("IfcProject"):
return projects[0]
return ifc_file.by_type("IfcProjectLibrary")[0]
@classmethod
def ensure_project_context(cls, ifc_file: ifcopenshell.file) -> None:
"""Repair a file that is missing the IfcProject the IFC spec requires.
Some externally authored library files only contain IfcProjectLibrary, with
no IfcProject (see #8183). Per the Project Context concept template, all
project data sets shall contain a single IfcProject, and IfcProjectLibrary
instances are assigned to it via IfcRelDeclares. Rather than treating such a
file as though a bare IfcProjectLibrary were a legitimate root context, create
the missing IfcProject and declare the file's root-level IfcProjectLibrary
instances to it, so the in-memory model becomes spec-valid.
"""
if ifc_file.schema == "IFC2X3" or ifc_file.by_type("IfcProject"):
return
root_libraries = [lib for lib in ifc_file.by_type("IfcProjectLibrary") if not lib.Nests and not lib.HasContext]
if not root_libraries:
return
project = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject", name="Unnamed")
ifcopenshell.api.project.assign_declaration(ifc_file, definitions=root_libraries, relating_context=project)
@classmethod
def open_library_file(cls, filepath: str) -> ifcopenshell.file:
"""Open a library file, repairing a missing IfcProject if needed.
See ensure_project_context() for why this repair is necessary rather than
treating a library-only file as spec-valid.
"""
library_file = ifcopenshell.open(filepath)
cls.ensure_project_context(library_file)
return library_file
return ifc_file.by_type("IfcProject")[0]
@classmethod
def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict:
@@ -32,13 +32,14 @@ from test.bim.bootstrap import NewIfc
pytestmark = pytest.mark.project
def _make_valid_library_file(*, with_child: bool = False) -> ifcopenshell.file:
def _make_library_file(*, with_child: bool = False) -> ifcopenshell.file:
"""Build a spec-valid IFC4 library file: IfcProject + IfcProjectLibrary declared to it.
Per the IFC Project Context concept template, every project data set (library
files included) shall contain exactly one IfcProject, and IfcProjectLibrary
instances are assigned to it via IfcRelDeclares. ``with_child=True`` also nests a
sub-library under the root via IfcRelNests, mirroring real authored library files.
instances are assigned to it via IfcRelDeclares. This matches how every library
file shipped in bonsai/bim/data/libraries is actually authored. ``with_child=True``
also nests a sub-library under the root via IfcRelNests.
"""
library_file = ifcopenshell.api.project.create_file(version="IFC4")
project = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProject", name="Demo Project")
@@ -50,94 +51,37 @@ def _make_valid_library_file(*, with_child: bool = False) -> ifcopenshell.file:
return library_file
def _make_malformed_library_file(*, with_child: bool = False) -> ifcopenshell.file:
"""Build an IFC4 file containing only an IfcProjectLibrary, no IfcProject.
class TestLibraryFile(NewIfc):
"""Project-library UI code operating on a spec-valid model (IfcProject root).
This is NOT spec-valid IFC (IfcProject is mandatory per the Project Context
concept template) but mirrors real externally authored files that omit it, such
as the one reported in #8183. Used to exercise the repair path
(tool.Project.ensure_project_context / open_library_file), not as an example of
a legitimate model.
A file containing only IfcProjectLibrary and no IfcProject is not valid IFC and
is not supported; see test_get_root_context_raises_for_a_file_without_a_project.
"""
library_file = ifcopenshell.api.project.create_file(version="IFC4")
root = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
if with_child:
child = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="ChildLib")
ifcopenshell.api.nest.assign_object(library_file, [child], root)
return library_file
class TestEnsureProjectContext(NewIfc):
"""tool.Project.ensure_project_context() repairs files missing the required IfcProject."""
def test_repairs_malformed_file_by_declaring_root_library_to_a_new_project(self):
library_file = _make_malformed_library_file()
root = library_file.by_type("IfcProjectLibrary")[0]
tool.Project.ensure_project_context(library_file)
projects = library_file.by_type("IfcProject")
assert len(projects) == 1
assert root.HasContext
assert root.HasContext[0].RelatingContext == projects[0]
def test_only_declares_root_level_libraries_not_nested_children(self):
library_file = _make_malformed_library_file(with_child=True)
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
tool.Project.ensure_project_context(library_file)
assert root.HasContext
assert not child.HasContext
assert child.Nests and child.Nests[0].RelatingObject == root
def test_is_a_noop_when_project_already_present(self):
library_file = _make_valid_library_file()
before = set(library_file.by_type("IfcProject"))
tool.Project.ensure_project_context(library_file)
assert set(library_file.by_type("IfcProject")) == before
def test_is_a_noop_when_no_project_library_either(self):
library_file = ifcopenshell.api.project.create_file(version="IFC4")
tool.Project.ensure_project_context(library_file)
assert not library_file.by_type("IfcProject")
def test_open_library_file_repairs_a_malformed_file_from_disk(self, tmp_path):
library_file = _make_malformed_library_file()
filepath = tmp_path / "malformed_library.ifc"
library_file.write(str(filepath))
opened = tool.Project.open_library_file(str(filepath))
assert len(opened.by_type("IfcProject")) == 1
assert opened.by_type("IfcProjectLibrary")[0].HasContext
class TestValidLibraryFile(NewIfc):
"""Downstream project-library UI code operating on a spec-valid model (IfcProject root)."""
def test_get_root_context_returns_the_project(self):
library_file = _make_valid_library_file()
library_file = _make_library_file()
project = library_file.by_type("IfcProject")[0]
root = tool.Project.get_root_context(library_file)
assert root == project
def test_get_root_context_raises_for_a_file_without_a_project(self):
library_file = ifcopenshell.api.project.create_file(version="IFC4")
ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
with pytest.raises(IndexError):
tool.Project.get_root_context(library_file)
def test_get_parent_library_returns_project_for_declared_root_library(self):
library_file = _make_valid_library_file()
library_file = _make_library_file()
project = library_file.by_type("IfcProject")[0]
root = library_file.by_type("IfcProjectLibrary")[0]
assert tool.Project.get_parent_library(root) == project
def test_get_project_hierarchy_roots_libraries_under_the_project(self):
library_file = _make_valid_library_file(with_child=True)
library_file = _make_library_file(with_child=True)
project = library_file.by_type("IfcProject")[0]
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
@@ -147,12 +91,14 @@ class TestValidLibraryFile(NewIfc):
assert root in hierarchy[project]
assert child in hierarchy[root]
def test_project_library_data_loads_with_a_single_unique_root_entry(self):
def test_project_library_data_loads_with_unique_enum_keys(self):
# Regression test for the ci-bonsai-daily failure: parent_libraries_enum()
# must never emit two entries with the same STEP id (Blender's EnumProperty
# requires unique keys). Reproduced here on a repaired, spec-valid file rather
# than an invalid library-only one.
IfcStore.library_file = _make_valid_library_file()
# requires unique keys). The original failure only occurred because the root
# context could incorrectly resolve to an IfcProjectLibrary that was also
# collected by project_libraries(); on a spec-valid model root is always the
# IfcProject, whose id never collides with a library id.
IfcStore.library_file = _make_library_file(with_child=True)
try:
ProjectLibraryData.is_loaded = False
ProjectLibraryData.load()
@@ -165,10 +111,10 @@ class TestValidLibraryFile(NewIfc):
IfcStore.library_file = None
ProjectLibraryData.is_loaded = False
def test_refresh_library_succeeds_on_valid_library_file(self):
def test_refresh_library_succeeds(self):
import bpy
IfcStore.library_file = _make_valid_library_file(with_child=True)
IfcStore.library_file = _make_library_file(with_child=True)
try:
result = bpy.ops.bim.refresh_library()
assert result == {"FINISHED"}
@@ -179,7 +125,7 @@ class TestValidLibraryFile(NewIfc):
def test_add_project_library_declares_new_library_under_the_project_root(self):
import bpy
IfcStore.library_file = _make_valid_library_file()
IfcStore.library_file = _make_library_file()
library_file = IfcStore.library_file
try:
project = library_file.by_type("IfcProject")[0]