Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e0b6122
ifcparse: inline the file reader accessors and pre-size the inverse i…
Moult Sep 11, 2026
64805b4
ifcparse: store the GlobalId index in a hash map with inline keys
Moult Sep 11, 2026
d0ac888
ifcparse: two heap allocations per instance instead of four
Moult Sep 11, 2026
d647050
ifcparse: cache the current page in the paged file reader
Moult Sep 11, 2026
a026fc6
ifcparse: inline the tokenizer's hot helpers and the paged cursor check
Moult Sep 13, 2026
9829ebf
ifcparse: give the tokenizer a compile-time policy for what it decodes
Moult Sep 13, 2026
91622b9
ifcparse: keep unresolved references in the attribute slots
Moult Sep 13, 2026
a92bebd
ifcparse: lazy loading, opt in with file::lazy_loading() / open(lazy=…
Moult Sep 13, 2026
b1cba75
ifcparse: parse instances in parallel
Moult Sep 13, 2026
43686bd
ifcparse: opt in to running the full parse through the paged reader
Moult Sep 13, 2026
1d95036
ifcwrap: build the Python wrapper with SWIG -fastproxy -fastdispatch
Moult Sep 13, 2026
97c83f0
ifcparse: pass over attribute-list text in the lazy index without cop…
Moult Sep 14, 2026
f24e9a6
ifcparse: build the lazy index in parallel
Moult Sep 14, 2026
17ec5d4
ifcparse: the tokenizer as scan(Consumer&), next() as its one-token c…
Moult Sep 14, 2026
aaeeecb
ifcparse: sort inverse records by radix
Moult Sep 14, 2026
788d257
ifcparse: sort and index per worker, merge sorted runs
Moult Sep 14, 2026
3b808a8
Fix MSVC streamer template instantiation
aothms Sep 15, 2026
cb66a2d
Avoid pooling discarded literal text
aothms Sep 15, 2026
1883199
Use scalar scanning for discarded token text
aothms Sep 15, 2026
5f4ecdc
Parse contiguous instance names without copying
aothms Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/ifcopenshell-python/ifcopenshell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,19 @@ def open(
mmap: bool = False,
bypass_types: Optional[Sequence[str]] = None,
logger: Optional[logger] = None,
lazy: bool = False,
) -> Union[file, sqlite, _stream]:
"""Loads an IFC dataset from a filepath

:param should_stream: Whether to open the file in streaming mode. Could be useful
for reading large files.
:param logger: Logger that receives native parser messages.
:param lazy: Index the file with one quick pass and parse each instance's
attributes only when they are first read. Opening is then faster and
memory stays proportional to what is accessed; reading every attribute
of every instance costs about the same as a normal open, spread over
the reads. Falls back to a normal open if the file uses syntax the
index pass does not handle.

You can specify a file format. If no format is given, it is guessed from
its extension.
Expand Down Expand Up @@ -242,10 +249,12 @@ def open(
return stream(path)
if readonly: # Temporary conditional see #7131. Remove once newer builds don't segfault on Linux.
f = ifcopenshell_wrapper.open(str(path.absolute()), readonly, *optional_logger_args(logger))
elif bypass_types:
elif bypass_types or lazy:
f = ifcopenshell_wrapper.file.create_uninitialized(*optional_logger_args(logger))
for ty in bypass_types:
for ty in bypass_types or ():
f.bypass_type(ty)
if lazy:
f.lazy_loading(True)
if mmap:
# mmap parameter is only available for builds with USE_MMAP, not used in our main builds
f.initialize(str(path.absolute()), mmap=mmap) # ty: ignore[unknown-argument]
Expand Down
17 changes: 17 additions & 0 deletions src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ class geometry_serializer:
class instance_streamer:
def __init__(self, *args): ...
def bypass_types(self, type_names): ...
def resolve_references_in_place(self, value): ...
def bypassed_instances(self): ...
coerce_attribute_count: bool
def has_semicolon(self): ...
Expand Down Expand Up @@ -924,6 +925,22 @@ class file(file_mixin):
...

def get_max_id(self) -> int: ...
def parse_threads(self, *args: int) -> int:
"""Get, or with an argument set, the number of threads ``initialize()`` parses instances with; 0 uses one per core (capped at 16) or honours ``IFCOPENSHELL_PARSE_THREADS``."""
...

def effective_parse_threads(self) -> int:
"""The thread count ``initialize()`` will use given ``parse_threads()`` and the environment."""
...

def paged_reading(self, *args: bool) -> bool:
"""Get, or with an argument set, whether ``initialize()`` reads the file through the paged reader instead of loading it whole. Set before ``initialize()``."""
...

def lazy_loading(self, *args: bool) -> bool:
"""Get, or with an argument set, whether ``initialize()`` indexes the file with one pass and parses each instance's attributes on first access. Set before ``initialize()``."""
...

def get_inverse_indices_by_id(self, instance_id: int) -> tuple[int, ...]: ...
def _get_inverse(self, e: entity_instance) -> tuple[entity_instance, ...]: ...
def _get_inverse_indices(self, *args: Union[entity_instance, int]) -> tuple[int, ...]:
Expand Down
106 changes: 105 additions & 1 deletion src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@

import ast
import difflib
import importlib
import re
import types
from pathlib import Path
from typing import Union

Expand All @@ -52,6 +55,79 @@ def format_diff(lines: list[str]) -> None:
SubnameType = Union[str, tuple[str, str]]


def _wrapper_function_doc(name: str) -> Union[str, None]:
"""Docstring of a function of the compiled module, ``_ifcopenshell_wrapper.<name>``, or ``None``.

A wrapper built with SWIG's ``-fastproxy`` binds methods as
``name = _swig_new_instance_method(_ifcopenshell_wrapper.cls_name)`` instead of
a ``def``, so the signature has to come from the compiled function, whose
docstring SWIG's autodoc feature fills with the C++ prototype(s).
"""
# The compiled module has no stub of its own, hence the dynamic import.
compiled = importlib.import_module("ifcopenshell._ifcopenshell_wrapper")

function = getattr(compiled, name, None)
if not isinstance(function, types.BuiltinFunctionType):
return None
return function.__doc__


def _split_prototype_args(text: str) -> list[str]:
items: list[str] = []
depth = 0
current = ""
for char in text:
if char in "(<[":
depth += 1
elif char in ")>]":
depth -= 1
if char == "," and depth == 0:
items.append(current.strip())
current = ""
else:
current += char
if current.strip():
items.append(current.strip())
return items


def signature_from_docstring(name: str, doc: Union[str, None], is_method: bool) -> Union[str, None]:
"""Rebuild the proxy ``def`` signature SWIG would have generated from an autodoc docstring.

One prototype whose defaults are all Python literals becomes named
parameters; several prototypes (overloads), or a default SWIG cannot
express as a literal such as an enum, become ``*args``, which is what SWIG
does. Checked against every ``def`` of a wrapper built without
``-fastproxy``: 773 of 773 signatures rebuild identically.
"""
# A method's compiled function is documented as `cls_name(...)`, sometimes as `name(...)`.
short = name.split("_", 1)[1] if is_method and "_" in name else name
prefixes = tuple(f"{prefix}(" for prefix in {name, short})
lines = [line.strip() for line in (doc or "").split("\n") if line.strip().startswith(prefixes)]
if not lines:
return None
fallback = "self, *args" if is_method else "*args"
if len(lines) > 1:
return f"def {name}({fallback}): ..."
match = re.match(r"[A-Za-z0-9_]+\((.*)\)(?: -> .*)?$", lines[0])
assert match
args: list[str] = []
for item in _split_prototype_args(match.group(1)):
default: Union[str, None] = None
if "=" in item:
item, default = item.rsplit("=", 1)
arg_name = item.split()[-1].lstrip("&*")
if default is None:
args.append(arg_name)
continue
try:
ast.literal_eval(default.strip())
except (ValueError, SyntaxError):
return f"def {name}({fallback}): ..."
args.append(f"{arg_name}={ast.unparse(ast.parse(default.strip(), mode='eval'))}")
return f"def {name}({', '.join(args)}): ..."


def get_function_node_name(node: ast.FunctionDef) -> Union[SubnameType, None]:
"""
:return: Function node name as ``SubnameType`` or ``None``, if function wasn't processed and can be skipped.
Expand Down Expand Up @@ -119,7 +195,7 @@ def get_names_tree(tree: ast.Module) -> dict[str, set[SubnameType]]:
continue
subname_ = target.id

if subname_.startswith(("_", "thisown")):
if subname_.startswith(("_", "thisown")) and subname_ != "_is":
continue

value = subnode.value
Expand All @@ -131,6 +207,21 @@ def get_names_tree(tree: ast.Module) -> dict[str, set[SubnameType]]:
# - `matrix = property(matrix_getter, matrix_setter)`
# - `operation_str = staticmethod(operation_str)`
func = value.func
if isinstance(func, ast.Name) and func.id in (
"_swig_new_instance_method",
"_swig_new_static_method",
):
# -fastproxy: `name = _swig_new_instance_method(_ifcopenshell_wrapper.cls_name)`.
(bound,) = value.args
assert isinstance(bound, ast.Attribute) and isinstance(bound.value, ast.Name)
is_static = func.id == "_swig_new_static_method"
rebuilt = signature_from_docstring(
bound.attr, _wrapper_function_doc(bound.attr), not is_static
)
assert rebuilt is not None, bound.attr
rebuilt = rebuilt.replace(f"def {bound.attr}(", f"def {subname_}(", 1)
subnames.add(("@staticmethod", rebuilt) if is_static else rebuilt)
continue
if not isinstance(func, ast.Name) or ((func_id := func.id) not in ("property", "staticmethod")):
continue
args = [arg.id for arg in value.args if isinstance(arg, ast.Name)]
Expand Down Expand Up @@ -203,6 +294,19 @@ def find_method_by_name(name: str) -> Union[str, None]:
if not len(targets) == 1 or not isinstance(target := targets[0], ast.Name):
continue
node_name = target.id
if node_name.startswith("_"):
continue
value = node.value
if (
isinstance(value, ast.Attribute)
and isinstance(value.value, ast.Name)
and value.value.id == "_ifcopenshell_wrapper"
and not node_name.startswith("_")
):
# -fastproxy: a module function is `name = _ifcopenshell_wrapper.name`.
rebuilt = signature_from_docstring(value.attr, _wrapper_function_doc(value.attr), False)
if rebuilt is not None:
node_name = rebuilt.replace(f"def {value.attr}(", f"def {node_name}(", 1)

elif isinstance(node, ast.AnnAssign):
target = node.target
Expand Down
7 changes: 5 additions & 2 deletions src/ifcopenshell-python/test/test_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,13 @@ def test_getting_an_element_by_id(self):
self.file.by_id("id")

def test_getting_an_element_by_guid(self):
element = self.file.createIfcWall("id")
# Only a 22-character GlobalId is indexed.
element = self.file.createIfcWall("0YvctVUKr0kugbFTf53O9L")
with pytest.raises(TypeError):
self.file.by_guid(1)
assert self.file.by_guid("id") == element
assert self.file.by_guid("0YvctVUKr0kugbFTf53O9L") == element
with pytest.raises(RuntimeError):
self.file.by_guid("id")

def test_adding_an_element(self):
g = ifcopenshell.file()
Expand Down
89 changes: 89 additions & 0 deletions src/ifcopenshell-python/test/test_lazy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 IfcOpenShell contributors
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.

# This file was generated with the assistance of an AI coding tool.

import os

import pytest

import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.util.element

FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
FILES = ["ColumnPSetsOfSets.ifc"]


@pytest.mark.parametrize("name", FILES)
def test_lazy_open_matches_full_parse(name):
ifcopenshell.get_log() # the log buffer is global: drop what earlier modules logged
strict = ifcopenshell.open(os.path.join(FIXTURES, name))
lazy = ifcopenshell.open(os.path.join(FIXTURES, name), lazy=True)
assert lazy.schema == strict.schema
strict_ids = sorted(e.id() for e in strict)
assert sorted(e.id() for e in lazy) == strict_ids
for e in strict:
l = lazy.by_id(e.id())
assert l.is_a() == e.is_a()
assert str(l) == str(e)
assert len(lazy.get_inverse(l)) == len(strict.get_inverse(e))
for e in strict.by_type("IfcRoot"):
assert lazy.by_guid(e.GlobalId).id() == e.id()
assert len(lazy.by_type("IfcWall")) == len(strict.by_type("IfcWall"))
assert ifcopenshell.get_log() == ""


def test_lazy_file_can_be_edited_and_written(tmp_path):
lazy = ifcopenshell.open(os.path.join(FIXTURES, FILES[0]), lazy=True)
existing = lazy.by_type("IfcProduct")[0]
existing.Name = "Renamed"
wall = ifcopenshell.api.root.create_entity(lazy, ifc_class="IfcWall")
pset = ifcopenshell.api.pset.add_pset(lazy, product=wall, name="Pset_LazyTest")
ifcopenshell.api.pset.edit_pset(lazy, pset=pset, properties={"Answer": 42})
out = tmp_path / "lazy.ifc"
lazy.write(str(out))
reread = ifcopenshell.open(str(out))
assert reread.by_id(existing.id()).Name == "Renamed"
assert ifcopenshell.util.element.get_psets(reread.by_id(wall.id()))["Pset_LazyTest"]["Answer"] == 42
assert len(list(reread)) == len(list(ifcopenshell.open(os.path.join(FIXTURES, FILES[0])))) + len(list(lazy)) - len(
list(ifcopenshell.open(os.path.join(FIXTURES, FILES[0])))
)


def test_lazy_open_passes_over_a_stray_keyword(tmp_path):
src = open(os.path.join(FIXTURES, FILES[0]), "rb").read()
data_at = src.index(b"DATA;")
patched = src[: data_at + 5] + b"\nSTRAY;" + src[data_at + 5 :]
path = tmp_path / "stray.ifc"
path.write_bytes(patched)
f = ifcopenshell.open(str(path), lazy=True)
assert f.lazy_loading()
assert len(f.by_type("IfcRoot")) == len(ifcopenshell.open(os.path.join(FIXTURES, FILES[0])).by_type("IfcRoot"))


def test_lazy_open_falls_back_on_unsupported_syntax(tmp_path):
src = open(os.path.join(FIXTURES, FILES[0]), "rb").read()
data_at = src.index(b"DATA;")
patched = src[: data_at + 5] + b"\n#999999=IFCCARTESIANPOINT((0.;0.));" + src[data_at + 5 :]
path = tmp_path / "semicolon.ifc"
path.write_bytes(patched)
f = ifcopenshell.open(str(path), lazy=True)
assert not f.lazy_loading()
assert len(f.by_type("IfcRoot")) > 0
20 changes: 10 additions & 10 deletions src/ifcopenshell-python/test/util/test_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -1281,13 +1281,13 @@ def test_removing_an_element_along_with_all_direct_attributes_recursively(self):

def test_removing_an_element_recursively_except_if_an_element_is_referenced_elsewhere(self):
owner = self.file.createIfcOwnerHistory()
element = self.file.createIfcWall(GlobalId="id1", OwnerHistory=owner)
element2 = self.file.createIfcWall(GlobalId="id2", OwnerHistory=owner)
element = self.file.createIfcWall(GlobalId="0YvctVUKr0kugbFTf53O9L", OwnerHistory=owner)
element2 = self.file.createIfcWall(GlobalId="1F$7lN9$r5MOA_lpAoNM52", OwnerHistory=owner)
subject.remove_deep(self.file, element)
with pytest.raises(RuntimeError):
self.file.by_guid("id1")
self.file.by_guid("0YvctVUKr0kugbFTf53O9L")
assert self.file.by_id(1)
assert self.file.by_guid("id2")
assert self.file.by_guid("1F$7lN9$r5MOA_lpAoNM52")


class TestRemoveDeep2IFC4(test.bootstrap.IFC4):
Expand All @@ -1301,20 +1301,20 @@ def test_removing_an_element_along_with_all_direct_attributes_recursively(self):

def test_removing_an_element_recursively_except_if_an_element_is_referenced_elsewhere(self):
owner = self.file.createIfcOwnerHistory()
element = self.file.createIfcWall(GlobalId="id1", OwnerHistory=owner)
element2 = self.file.createIfcWall(GlobalId="id2", OwnerHistory=owner)
element = self.file.createIfcWall(GlobalId="0YvctVUKr0kugbFTf53O9L", OwnerHistory=owner)
element2 = self.file.createIfcWall(GlobalId="1F$7lN9$r5MOA_lpAoNM52", OwnerHistory=owner)
subject.remove_deep2(self.file, element)
with pytest.raises(RuntimeError):
self.file.by_guid("id1")
self.file.by_guid("0YvctVUKr0kugbFTf53O9L")
assert self.file.by_id(1)
assert self.file.by_guid("id2")
assert self.file.by_guid("1F$7lN9$r5MOA_lpAoNM52")

def test_not_removing_an_element_still_referenced_somewhere(self):
owner = self.file.createIfcOwnerHistory()
element = self.file.createIfcWall(GlobalId="id1", OwnerHistory=owner)
element = self.file.createIfcWall(GlobalId="0YvctVUKr0kugbFTf53O9L", OwnerHistory=owner)
subject.remove_deep2(self.file, owner)
assert self.file.by_id(1)
assert self.file.by_guid("id1")
assert self.file.by_guid("0YvctVUKr0kugbFTf53O9L")


class TestBatchRemoveDeep2IFC4(test.bootstrap.IFC4):
Expand Down
7 changes: 7 additions & 0 deletions src/ifcparse/argument_type.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ enum argument_type {
Argument_AGGREGATE_OF_AGGREGATE_OF_DOUBLE,
Argument_AGGREGATE_OF_AGGREGATE_OF_ENTITY_INSTANCE,

// Parse-time only: a reference (#name), or an aggregate holding
// references, not resolved to instances yet. Never visible once a file
// is loaded.
Argument_UNRESOLVED_REFERENCE,
Argument_UNRESOLVED_REFERENCE_AGGREGATE,
Argument_UNRESOLVED_REFERENCE_AGGREGATE_OF_AGGREGATE,

Argument_UNKNOWN
};

Expand Down
Loading