This commit is contained in:
Andrej730
2025-05-06 11:11:02 +05:00
parent c6c852c7f6
commit b0f2785e99
4 changed files with 100 additions and 92 deletions
+2 -3
View File
@@ -209,6 +209,7 @@ NO_HEADER = ifcopenshell_wrapper.file_open_status.NO_HEADER
UNSUPPORTED_SCHEMA = ifcopenshell_wrapper.file_open_status.UNSUPPORTED_SCHEMA UNSUPPORTED_SCHEMA = ifcopenshell_wrapper.file_open_status.UNSUPPORTED_SCHEMA
INVALID_SYNTAX = ifcopenshell_wrapper.file_open_status.INVALID_SYNTAX INVALID_SYNTAX = ifcopenshell_wrapper.file_open_status.INVALID_SYNTAX
class file: class file:
"""Base class for containing IFC files. """Base class for containing IFC files.
@@ -471,9 +472,7 @@ class file:
def mvd(self): def mvd(self):
if not LARK_AVAILABLE: if not LARK_AVAILABLE:
return None return None
return MvdInfo( return MvdInfo(self.header)
self.header
)
def __getattr__(self, attr) -> Union[Any, Callable[..., ifcopenshell.entity_instance]]: def __getattr__(self, attr) -> Union[Any, Callable[..., ifcopenshell.entity_instance]]:
if attr[0:6] == "create": if attr[0:6] == "create":
@@ -1,6 +1,7 @@
try: try:
from lark import Lark, Transformer from lark import Lark, Transformer
from lark.exceptions import UnexpectedCharacters, UnexpectedEOF, UnexpectedToken from lark.exceptions import UnexpectedCharacters, UnexpectedEOF, UnexpectedToken
LARK_AVAILABLE = True LARK_AVAILABLE = True
except ImportError: except ImportError:
LARK_AVAILABLE = False LARK_AVAILABLE = False
@@ -9,7 +10,7 @@ from typing import Callable
import re import re
if LARK_AVAILABLE: if LARK_AVAILABLE:
mvd_grammar = r''' mvd_grammar = r"""
start: entry+ start: entry+
entry: "ViewDefinition" "[" simple_value_list "]" -> view_definition entry: "ViewDefinition" "[" simple_value_list "]" -> view_definition
@@ -36,9 +37,9 @@ if LARK_AVAILABLE:
%import common.WS %import common.WS
%ignore WS %ignore WS
''' """
parser = Lark(mvd_grammar, parser='lalr') parser = Lark(mvd_grammar, parser="lalr")
class DescriptionTransform(Transformer): class DescriptionTransform(Transformer):
def __init__(self): def __init__(self):
@@ -50,7 +51,7 @@ if LARK_AVAILABLE:
self._dynamic = {} self._dynamic = {}
def view_definition(self, args): def view_definition(self, args):
self.keywords.add('view_definitions') self.keywords.add("view_definitions")
self.view_definitions.extend(args[0]) self.view_definitions.extend(args[0])
def store_text_attribute(self, args, keyword): def store_text_attribute(self, args, keyword):
@@ -66,7 +67,7 @@ if LARK_AVAILABLE:
def option(self, args): def option(self, args):
if v := parse_semicolon_separated_kv(" ".join(" ".join(str(child) for child in args[0].children).split())): if v := parse_semicolon_separated_kv(" ".join(" ".join(str(child) for child in args[0].children).split())):
setattr(self, 'options', v) setattr(self, "options", v)
else: else:
self.store_text_attribute(args, "options") self.store_text_attribute(args, "options")
@@ -98,7 +99,7 @@ if LARK_AVAILABLE:
return str(args[0]) return str(args[0])
def parse_mvd(description): def parse_mvd(description):
text = ' '.join(description) text = " ".join(description)
parsed_description = DescriptionTransform() parsed_description = DescriptionTransform()
try: try:
if not text: if not text:
@@ -111,21 +112,23 @@ if LARK_AVAILABLE:
return parsed_description return parsed_description
def parse_semicolon_separated_kv(text: str) -> dict[str, str | list[str]] | None: def parse_semicolon_separated_kv(text: str) -> dict[str, str | list[str]] | None:
if not re.search(r'\w+\s*:\s*[^:]+', text): if not re.search(r"\w+\s*:\s*[^:]+", text):
return None return None
result = {} result = {}
try: try:
pairs = text.split(';') pairs = text.split(";")
for pair in pairs: for pair in pairs:
if ':' in pair: if ":" in pair:
key, value = pair.split(':', 1) key, value = pair.split(":", 1)
key = key.strip() key = key.strip()
values = [v.strip() for v in value.split(',')] values = [v.strip() for v in value.split(",")]
result[key] = values[0] if len(values) == 1 else values result[key] = values[0] if len(values) == 1 else values
return result return result
except Exception: except Exception:
return None return None
else: else:
def parse_mvd(description): def parse_mvd(description):
return None return None
@@ -161,7 +164,7 @@ class MvdInfo:
return AutoCommitList( return AutoCommitList(
vd_list, vd_list,
callback=lambda val: (self._update_keyword("ViewDefinition", val), setattr(self, "_parsed", None)), callback=lambda val: (self._update_keyword("ViewDefinition", val), setattr(self, "_parsed", None)),
formatter=lambda lst: ",".join(str(i) for i in lst) formatter=lambda lst: ",".join(str(i) for i in lst),
) )
@view_definitions.setter @view_definitions.setter
@@ -180,7 +183,7 @@ class MvdInfo:
return AutoCommitList( return AutoCommitList(
comment_list, comment_list,
callback=lambda val: self._update_keyword("Comment", val), callback=lambda val: self._update_keyword("Comment", val),
formatter=lambda lst: ", ".join(str(i) for i in lst) formatter=lambda lst: ", ".join(str(i) for i in lst),
) )
@comments.setter @comments.setter
@@ -232,7 +235,7 @@ class MvdInfo:
def __getattr__(self, name): def __getattr__(self, name):
self._ensure_parsed() self._ensure_parsed()
if hasattr(self._parsed, '_dynamic'): if hasattr(self._parsed, "_dynamic"):
name_lc = name.lower() name_lc = name.lower()
if name_lc in self._parsed._dynamic: if name_lc in self._parsed._dynamic:
value, original_keyword = self._parsed._dynamic[name_lc] value, original_keyword = self._parsed._dynamic[name_lc]
@@ -241,7 +244,7 @@ class MvdInfo:
def __dir__(self): def __dir__(self):
base = super().__dir__() base = super().__dir__()
if self._parsed and hasattr(self._parsed, '_dynamic'): if self._parsed and hasattr(self._parsed, "_dynamic"):
return base + [kw for _, kw in self._parsed._dynamic.values()] return base + [kw for _, kw in self._parsed._dynamic.values()]
return base return base
@@ -258,10 +261,7 @@ class DictionaryHandler(dict):
super().__setitem__(k, v) super().__setitem__(k, v)
def _commit(self): def _commit(self):
new_value = "; ".join( new_value = "; ".join(f"{k}: {', '.join(v) if isinstance(v, list) else v}" for k, v in self.items())
f"{k}: {', '.join(v) if isinstance(v, list) else v}"
for k, v in self.items()
)
self._mvdinfo._update_keyword(self._keyword, new_value) self._mvdinfo._update_keyword(self._keyword, new_value)
def __setitem__(self, key, value): def __setitem__(self, key, value):
@@ -277,6 +277,7 @@ class DictionaryHandler(dict):
class AutoCommitList(list): class AutoCommitList(list):
"ensures keyword attributes are written back to ifcopenshell.file.header" "ensures keyword attributes are written back to ifcopenshell.file.header"
def __init__(self, iterable, callback, formatter=None): def __init__(self, iterable, callback, formatter=None):
super().__init__(iterable) super().__init__(iterable)
self._callback = callback self._callback = callback
@@ -597,24 +597,28 @@ def validate_guid(guid: str) -> Union[str, None]:
return "Couldn't decompress guid, it's not base64 encoded." return "Couldn't decompress guid, it's not base64 encoded."
return None return None
def to_string_header_entity(header_entity): def to_string_header_entity(header_entity):
"""Recreate IFC header string representation, like FILE_NAME(...)""" """Recreate IFC header string representation, like FILE_NAME(...)"""
# Prefer native .toString() if available (native IfcOpenShell wrapper) # Prefer native .toString() if available (native IfcOpenShell wrapper)
if isinstance(header_entity, W.HeaderEntity): if isinstance(header_entity, W.HeaderEntity):
return header_entity.toString() return header_entity.toString()
elif hasattr(header_entity, '_fields'): elif hasattr(header_entity, "_fields"):
values = [repr(getattr(header_entity, f)) for f in header_entity._fields] values = [repr(getattr(header_entity, f)) for f in header_entity._fields]
return f"{type(header_entity).__name__.upper()}({','.join(values)})" return f"{type(header_entity).__name__.upper()}({','.join(values)})"
else: else:
raise TypeError(f"Cannot stringify header_entity of type {type(header_entity)}") raise TypeError(f"Cannot stringify header_entity of type {type(header_entity)}")
def validate_ifc_header(f: Union[ifcopenshell.file, ifcopenshell.simple_spf.file], logger: Logger) -> None: def validate_ifc_header(f: Union[ifcopenshell.file, ifcopenshell.simple_spf.file], logger: Logger) -> None:
header: Union[W.IfcSpfHeader, types.SimpleNamespace] = f.header header: Union[W.IfcSpfHeader, types.SimpleNamespace] = f.header
AGGREGATE_TYPE = "LIST [ 1 : ? ] OF STRING (256)" AGGREGATE_TYPE = "LIST [ 1 : ? ] OF STRING (256)"
STRING_TYPE = "STRING (256)" STRING_TYPE = "STRING (256)"
def log_error(header_entity: Union[W.HeaderEntity, tuple], name: str, index: int, expected_type: str, provided_type: str) -> None: def log_error(
header_entity: Union[W.HeaderEntity, tuple], name: str, index: int, expected_type: str, provided_type: str
) -> None:
logger.error( logger.error(
( (
"For instance:\n %s\n %s\n" "For instance:\n %s\n %s\n"
+54 -50
View File
@@ -8,8 +8,10 @@ from ifcopenshell.util.mvd_info import parse_mvd
@pytest.fixture @pytest.fixture
def load_fixture(): def load_fixture():
base_dir = os.path.join(os.path.dirname(__file__), "fixtures", "mvd_parsing") base_dir = os.path.join(os.path.dirname(__file__), "fixtures", "mvd_parsing")
def _load(filename): def _load(filename):
return ifcopenshell.open(os.path.join(base_dir, filename)) return ifcopenshell.open(os.path.join(base_dir, filename))
return _load return _load
@@ -20,53 +22,54 @@ class TestViewDefinition:
def test_multiple_views(self, load_fixture): def test_multiple_views(self, load_fixture):
f = load_fixture("two_views.ifc") f = load_fixture("two_views.ifc")
assert f.mvd.view_definitions == ['CoordinationView_V2.0', 'SpaceBoundaryAddonView'] assert f.mvd.view_definitions == ["CoordinationView_V2.0", "SpaceBoundaryAddonView"]
def test_add_view(self): def test_add_view(self):
header = MockHeader(("ViewDefinition [CoordinationView_V2.0]",)) header = MockHeader(("ViewDefinition [CoordinationView_V2.0]",))
mvd = mvd_info.MvdInfo(header) mvd = mvd_info.MvdInfo(header)
assert mvd.view_definitions == ["CoordinationView_V2.0"] assert mvd.view_definitions == ["CoordinationView_V2.0"]
mvd.view_definitions.append("SpaceBoundaryAddonView") mvd.view_definitions.append("SpaceBoundaryAddonView")
assert mvd.view_definitions == ['CoordinationView_V2.0', 'SpaceBoundaryAddonView'] assert mvd.view_definitions == ["CoordinationView_V2.0", "SpaceBoundaryAddonView"]
class TestExchangeRequirements: class TestExchangeRequirements:
def test_parsing(self, load_fixture): def test_parsing(self, load_fixture):
f = load_fixture("contains_exchange_requirement.ifc") f = load_fixture("contains_exchange_requirement.ifc")
parsed = parse_mvd(f.mvd.description) parsed = parse_mvd(f.mvd.description)
assert parsed.exchange_requirements == 'Any' assert parsed.exchange_requirements == "Any"
def test_access_and_modification(self, load_fixture): def test_access_and_modification(self, load_fixture):
f = load_fixture("contains_exchange_requirement.ifc") f = load_fixture("contains_exchange_requirement.ifc")
f.header.file_description.description = ( f.header.file_description.description = (
'ViewDefinition [Alignment-basedView]', "ViewDefinition [Alignment-basedView]",
'ExchangeRequirement [SomethingElse]' "ExchangeRequirement [SomethingElse]",
) )
assert f.mvd.exchange_requirements == 'SomethingElse' assert f.mvd.exchange_requirements == "SomethingElse"
f.mvd.view_definitions = ['CoordinationView_V2.0'] f.mvd.view_definitions = ["CoordinationView_V2.0"]
assert f.mvd.view_definitions == ['CoordinationView_V2.0'] assert f.mvd.view_definitions == ["CoordinationView_V2.0"]
class TestComments: class TestComments:
def test_read_and_append(self, load_fixture): def test_read_and_append(self, load_fixture):
f = load_fixture("contains_comment.ifc") f = load_fixture("contains_comment.ifc")
assert f.mvd.comments == ['Any'] assert f.mvd.comments == ["Any"]
f.mvd.comments = ['SomethingElse'] f.mvd.comments = ["SomethingElse"]
assert f.mvd.comments == ['SomethingElse'] assert f.mvd.comments == ["SomethingElse"]
f.mvd.comments.append('AnotherComment') f.mvd.comments.append("AnotherComment")
assert f.mvd.comments == ['SomethingElse', ' AnotherComment'] assert f.mvd.comments == ["SomethingElse", " AnotherComment"]
assert f.mvd.description[1] == 'Comment [SomethingElse, AnotherComment]' assert f.mvd.description[1] == "Comment [SomethingElse, AnotherComment]"
def test_comment_list_modifications(self, load_fixture): def test_comment_list_modifications(self, load_fixture):
f = load_fixture("contains_comment.ifc") f = load_fixture("contains_comment.ifc")
f.mvd.comments = '' f.mvd.comments = ""
f.mvd.comments.append('OnlyOne') f.mvd.comments.append("OnlyOne")
assert 'OnlyOne' in f.mvd.comments assert "OnlyOne" in f.mvd.comments
f.mvd.comments.insert(0, 'FirstOne') f.mvd.comments.insert(0, "FirstOne")
f.mvd.comments[0] == 'FirstOne' f.mvd.comments[0] == "FirstOne"
f.mvd.comments.pop() f.mvd.comments.pop()
assert f.mvd.comments[0] == 'FirstOne' assert f.mvd.comments[0] == "FirstOne"
del f.mvd.comments[0] del f.mvd.comments[0]
assert not f.mvd.comments assert not f.mvd.comments
@@ -75,55 +78,58 @@ class TestComments:
class TestOptions: class TestOptions:
def test_string_options(self, load_fixture): def test_string_options(self, load_fixture):
f = load_fixture("contains_options.ifc") f = load_fixture("contains_options.ifc")
assert f.mvd.options == 'Any' assert f.mvd.options == "Any"
assert 'options' in f.mvd.keywords assert "options" in f.mvd.keywords
class TestDynamicFields: class TestDynamicFields:
def test_options_modifications(self, load_fixture): def test_options_modifications(self, load_fixture):
f = load_fixture("dynamic_fields.ifc") f = load_fixture("dynamic_fields.ifc")
f.mvd.options['ExcludedObjects'].append('Chair') f.mvd.options["ExcludedObjects"].append("Chair")
f.mvd.options['SplitLevel'] = 'Off' f.mvd.options["SplitLevel"] = "Off"
f.mvd.options['OtherAttr'] = 'SomeValue' f.mvd.options["OtherAttr"] = "SomeValue"
assert f.mvd.description[2].startswith('Option [') assert f.mvd.description[2].startswith("Option [")
assert 'OtherAttr: SomeValue' in f.mvd.description[2] assert "OtherAttr: SomeValue" in f.mvd.description[2]
assert f.mvd.description == f.header.file_description.description assert f.mvd.description == f.header.file_description.description
def test_remark_editing(self, load_fixture): def test_remark_editing(self, load_fixture):
f = load_fixture("dynamic_fields.ifc") f = load_fixture("dynamic_fields.ifc")
assert f.mvd.remark == {'SomeKey': 'SomeValue', 'AnotherKey': 'AnotherValue'} assert f.mvd.remark == {"SomeKey": "SomeValue", "AnotherKey": "AnotherValue"}
f.mvd.remark['AnotherKey'] = 'SometingElse' f.mvd.remark["AnotherKey"] = "SometingElse"
f.mvd.remark['IncludedObjects'] = ['Floor', 'Roof'] f.mvd.remark["IncludedObjects"] = ["Floor", "Roof"]
assert f.mvd.remark['AnotherKey'] == 'SometingElse' assert f.mvd.remark["AnotherKey"] == "SometingElse"
assert f.mvd.remark['IncludedObjects'] == ['Floor', 'Roof'] assert f.mvd.remark["IncludedObjects"] == ["Floor", "Roof"]
assert 'remark' in f.mvd.keywords assert "remark" in f.mvd.keywords
def test_custom_dict_behavior(self, load_fixture): def test_custom_dict_behavior(self, load_fixture):
f = load_fixture("dynamic_fields.ifc") f = load_fixture("dynamic_fields.ifc")
# delete # delete
del f.mvd.options['SplitLevel'] del f.mvd.options["SplitLevel"]
assert not f.mvd.options.get('SplitLevel') assert not f.mvd.options.get("SplitLevel")
# keys, values, items # keys, values, items
assert set(f.mvd.remark.keys()) == {'SomeKey', 'AnotherKey'} assert set(f.mvd.remark.keys()) == {"SomeKey", "AnotherKey"}
assert 'SomeValue' in f.mvd.remark.values() assert "SomeValue" in f.mvd.remark.values()
assert ('SomeKey', 'SomeValue') in f.mvd.remark.items() assert ("SomeKey", "SomeValue") in f.mvd.remark.items()
# containment # containment
assert 'SomeKey' in f.mvd.remark assert "SomeKey" in f.mvd.remark
assert 'MissingKey' not in f.mvd.remark assert "MissingKey" not in f.mvd.remark
class TestKeywords: class TestKeywords:
@pytest.mark.parametrize("filename, expected_keywords", [ @pytest.mark.parametrize(
("contains_comment.ifc", {"view_definitions", "comments"}), "filename, expected_keywords",
("contains_exchange_requirement.ifc", {"view_definitions", "exchange_requirements"}), [
("contains_options.ifc", {"view_definitions", "options"}), ("contains_comment.ifc", {"view_definitions", "comments"}),
("dynamic_fields.ifc", {"view_definitions", "exchange_requirements", "comments", "remark"}) ("contains_exchange_requirement.ifc", {"view_definitions", "exchange_requirements"}),
]) ("contains_options.ifc", {"view_definitions", "options"}),
("dynamic_fields.ifc", {"view_definitions", "exchange_requirements", "comments", "remark"}),
],
)
def test_keywords_present(self, load_fixture, filename, expected_keywords): def test_keywords_present(self, load_fixture, filename, expected_keywords):
f = load_fixture(filename) f = load_fixture(filename)
assert f.mvd.keywords == expected_keywords assert f.mvd.keywords == expected_keywords
@@ -133,13 +139,11 @@ class TestFallbackBehavior:
def test_parse_mvd_fallback(self, monkeypatch): def test_parse_mvd_fallback(self, monkeypatch):
monkeypatch.setattr(mvd_info, "LARK_AVAILABLE", False) monkeypatch.setattr(mvd_info, "LARK_AVAILABLE", False)
header = MockHeader(("ViewDefinition [ShouldNotParse]",)) header = MockHeader(("ViewDefinition [ShouldNotParse]",))
mvd = mvd_info.MvdInfo( mvd = mvd_info.MvdInfo(header)
header
)
assert mvd.view_definitions is None assert mvd.view_definitions is None
assert mvd.keywords == set() assert mvd.keywords == set()
class MockHeader: class MockHeader:
def __init__(self, description): def __init__(self, description):
self.file_description = type("FileDescription", (), {"description": description}) self.file_description = type("FileDescription", (), {"description": description})