try: from lark import Lark, Transformer from lark.exceptions import UnexpectedCharacters, UnexpectedEOF, UnexpectedToken LARK_AVAILABLE = True except ImportError: LARK_AVAILABLE = False import re from typing import Union if LARK_AVAILABLE: mvd_grammar = r""" start: entry+ entry: "ViewDefinition" "[" simple_value_list "]" -> view_definition | "Comment" "[" simple_value_list "]" -> comment | "ExchangeRequirement" "[" other_keyword "]" -> exchangerequirement | "Option" "[" other_keyword "]" -> option | GENERIC_KEYWORD "[" dynamic_option_word "]" -> dynamic_option GENERIC_KEYWORD: /[A-Za-z0-9_]+/ simple_value_list: value ("," value)* value_list_set: value_set (";" value_set)* value_set: set_name ":" simple_value_list set_name: /[A-Za-z0-9_]+/ value: /[A-Za-z0-9 _\.-]+/ other_keyword: /[^\[\]]+/ dynamic_option_word: /[^\[\]]+/ %import common.WS %ignore WS """ parser = Lark(mvd_grammar, parser="lalr") class DescriptionTransform(Transformer): def __init__(self): self.view_definitions = [] self.keywords = set() self.comments = "" self.exchange_requirements = "" self.options = "" self._dynamic = {} def view_definition(self, args): self.keywords.add("view_definitions") self.view_definitions.extend(args[0]) def store_text_attribute(self, args, keyword): self.keywords.add(keyword) setattr( self, keyword, " ".join(" ".join(str(child) for child in args[0].children).split()), ) def comment(self, args): self.keywords.add("comments") self.comments = args[0] if len(args[0]) > 1 else args[0][0] def exchangerequirement(self, args): self.store_text_attribute(args, "exchange_requirements") def option(self, args): if v := parse_semicolon_separated_kv( " ".join(" ".join(str(child) for child in args[0].children).split()) ): setattr(self, "options", v) else: self.store_text_attribute(args, "options") def dynamic_option(self, args): try: original_keyword = str(args[0]) key = original_keyword.lower() raw_text = args[1].children[0].value parsed_value = parse_semicolon_separated_kv(raw_text) self._dynamic[key] = (parsed_value, original_keyword) self.keywords.add(key) setattr(self, key, parsed_value) except Exception: setattr(self, key, None) def simple_value_list(self, args): return [str(arg) for arg in args] def value_list_set(self, args): return args def value_set(self, args): return [str(args[0])] + args[1] def value(self, args): return str(args[0]) def set_name(self, args): return str(args[0]) def parse_mvd(description): text = " ".join(description) parsed_description = DescriptionTransform() try: if not text: parsed_description.view_definitions = None return parsed_description parse_tree = parser.parse(text) parsed_description.transform(parse_tree) except (UnexpectedCharacters, UnexpectedEOF, UnexpectedToken): parsed_description.view_definitions = None return parsed_description def parse_semicolon_separated_kv( text: str, ) -> Union[dict[str, Union[str, list[str]]], None]: if not re.search(r"\w+\s*:\s*[^:]+", text): return None result = {} try: pairs = text.split(";") for pair in pairs: if ":" in pair: key, value = pair.split(":", 1) key = key.strip() values = [v.strip() for v in value.split(",")] result[key] = values[0] if len(values) == 1 else values return result except Exception: return None else: def parse_mvd(description): return None class MvdInfo: def __init__(self, header): self._header = header self._parsed = None def _ensure_parsed(self): if not LARK_AVAILABLE: return if self._parsed is None: description = self._header.file_description.description if not description: self._parsed = DescriptionTransform() # avoid AttributeError else: self._parsed = parse_mvd(description) @property def description(self) -> list[str]: return self._header.file_description.description @description.setter def description(self, new_description: list[str]): self._header.file_description.description = tuple(new_description) self._parsed = None @property def view_definitions(self): self._ensure_parsed() if not self._parsed or self._parsed.view_definitions is None: return None # vd = self._parsed.view_definitions vd_list = vd if isinstance(vd, list) else [vd] if vd else [] return AutoCommitList( vd_list, callback=lambda val: ( self._update_keyword("ViewDefinition", val), setattr(self, "_parsed", None), ), formatter=lambda lst: ",".join(str(i) for i in lst), ) @view_definitions.setter def view_definitions(self, new_value: Union[str, list[str]]): if isinstance(new_value, list): value = ", ".join(new_value) else: value = str(new_value) self._update_keyword("ViewDefinition", value) @property def comments(self): self._ensure_parsed() comments = self._parsed.comments comment_list = ( comments if isinstance(comments, list) else [comments] if comments else [] ) return AutoCommitList( comment_list, callback=lambda val: self._update_keyword("Comment", val), formatter=lambda lst: ", ".join(str(i) for i in lst), ) @comments.setter def comments(self, new_value: Union[str, list[str]]): if isinstance(new_value, list): value = ", ".join(new_value) else: value = str(new_value) self._update_keyword("Comment", value) @property def exchange_requirements(self): self._ensure_parsed() return self._parsed.exchange_requirements if self._parsed else None @exchange_requirements.setter def exchange_requirements(self, new_value: str): self._update_keyword("ExchangeRequirement", new_value) @property def options(self): self._ensure_parsed() if isinstance(self._parsed.options, dict): return DictionaryHandler(self._parsed.options, self, "Option") return self._parsed.options if self._parsed else None @options.setter def options(self, new_value: str): self._update_keyword("Option", new_value) @property def keywords(self): self._ensure_parsed() return self._parsed.keywords if self._parsed else set() def _update_keyword(self, keyword: str, new_value: str): updated = False new_line = f"{keyword} [{new_value}]" lines = [] for line in self.description: if line.strip().startswith(f"{keyword} ["): lines.append(new_line) updated = True else: lines.append(line) if not updated: lines.append(new_line) self.description = lines def __getattr__(self, name): self._ensure_parsed() if hasattr(self._parsed, "_dynamic"): name_lc = name.lower() if name_lc in self._parsed._dynamic: value, original_keyword = self._parsed._dynamic[name_lc] return DictionaryHandler(value, self, original_keyword) raise AttributeError(f"'MvdInfo' object has no attribute '{name}'") def __dir__(self): base = super().__dir__() if self._parsed and hasattr(self._parsed, "_dynamic"): return base + [kw for _, kw in self._parsed._dynamic.values()] return base class DictionaryHandler(dict): def __init__(self, initial_data, mvdinfo, keyword): super().__init__() self._mvdinfo = mvdinfo self._keyword = keyword for k, v in initial_data.items(): if isinstance(v, list): super().__setitem__(k, AutoCommitList(v, self._commit)) else: super().__setitem__(k, v) def _commit(self): new_value = "; ".join( f"{k}: {', '.join(v) if isinstance(v, list) else v}" for k, v in self.items() ) self._mvdinfo._update_keyword(self._keyword, new_value) def __setitem__(self, key, value): if isinstance(value, list): value = AutoCommitList(value, self._commit) super().__setitem__(key, value) self._commit() def __delitem__(self, key): super().__delitem__(key) self._commit() class AutoCommitList(list): "ensures keyword attributes are written back to ifcopenshell.file.header" def __init__(self, iterable, callback, formatter=None): super().__init__(iterable) self._callback = callback self._formatter = formatter def _commit(self): if self._formatter: self._callback(self._formatter(self)) else: self._callback() def append(self, item): super().append(item) self._commit() def extend(self, iterable): super().extend(iterable) self._commit() def insert(self, index, item): super().insert(index, item) self._commit() def remove(self, item): super().remove(item) self._commit() def pop(self, index=-1): item = super().pop(index) self._commit() return item def clear(self): super().clear() self._commit() def __setitem__(self, index, value): super().__setitem__(index, value) self._commit() def __delitem__(self, index): super().__delitem__(index) self._commit()