Skip to content

Commit efdc9d6

Browse files
Ghesselinkaothms
authored andcommitted
file.mvd attr available in spf parser
1 parent 4e255c6 commit efdc9d6

File tree

3 files changed

+372
-1
lines changed

3 files changed

+372
-1
lines changed

__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from lark import Lark, Transformer, Tree, Token
1212
from lark.exceptions import UnexpectedToken, UnexpectedCharacters
13-
13+
from ifcopenshell.util.mvd_info import MvdInfo, LARK_AVAILABLE
1414

1515
class ValidationError(Exception):
1616
pass
@@ -484,6 +484,13 @@ def header(self):
484484
header[field_name.lower()] = namedtuple_class(*field_data)
485485

486486
return types.SimpleNamespace(**header)
487+
488+
489+
@property
490+
def mvd(self):
491+
if not LARK_AVAILABLE or MvdInfo is None:
492+
return None
493+
return MvdInfo(self.header)
487494

488495
def __getitem__(self, key: numbers.Integral) -> entity_instance:
489496
return self.by_id(key)

mvd_info.py

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
try:
2+
from lark import Lark, Transformer
3+
from lark.exceptions import UnexpectedCharacters, UnexpectedEOF, UnexpectedToken
4+
LARK_AVAILABLE = True
5+
except ImportError:
6+
LARK_AVAILABLE = False
7+
8+
import re
9+
10+
if LARK_AVAILABLE:
11+
mvd_grammar = r'''
12+
start: entry+
13+
14+
entry: "ViewDefinition" "[" simple_value_list "]" -> view_definition
15+
| "Comment" "[" simple_value_list "]" -> comment
16+
| "ExchangeRequirement" "[" other_keyword "]" -> exchangerequirement
17+
| "Option" "[" other_keyword "]" -> option
18+
| GENERIC_KEYWORD "[" dynamic_option_word "]" -> dynamic_option
19+
20+
GENERIC_KEYWORD: /[A-Za-z0-9_]+/
21+
22+
simple_value_list: value ("," value)*
23+
24+
value_list_set: value_set (";" value_set)*
25+
26+
value_set: set_name ":" simple_value_list
27+
28+
set_name: /[A-Za-z0-9_]+/
29+
30+
value: /[A-Za-z0-9 _\.-]+/
31+
32+
other_keyword: /[^\[\]]+/
33+
34+
dynamic_option_word: /[^\[\]]+/
35+
36+
%import common.WS
37+
%ignore WS
38+
'''
39+
40+
parser = Lark(mvd_grammar, parser='lalr')
41+
42+
class DescriptionTransform(Transformer):
43+
def __init__(self):
44+
self.view_definitions = []
45+
self.keywords = set()
46+
self.comments = ""
47+
self.exchange_requirements = ""
48+
self.options = ""
49+
self._dynamic = {}
50+
51+
def view_definition(self, args):
52+
self.keywords.add('view_definitions')
53+
self.view_definitions.extend(args[0])
54+
55+
def store_text_attribute(self, args, keyword):
56+
self.keywords.add(keyword)
57+
setattr(self, keyword, " ".join(" ".join(str(child) for child in args[0].children).split()))
58+
59+
def comment(self, args):
60+
self.keywords.add("comments")
61+
self.comments = args[0] if len(args[0]) > 1 else args[0][0]
62+
63+
def exchangerequirement(self, args):
64+
self.store_text_attribute(args, "exchange_requirements")
65+
66+
def option(self, args):
67+
if v := parse_semicolon_separated_kv(" ".join(" ".join(str(child) for child in args[0].children).split())):
68+
setattr(self, 'options', v)
69+
else:
70+
self.store_text_attribute(args, "options")
71+
72+
def dynamic_option(self, args):
73+
try:
74+
original_keyword = str(args[0])
75+
key = original_keyword.lower()
76+
raw_text = args[1].children[0].value
77+
parsed_value = parse_semicolon_separated_kv(raw_text)
78+
self._dynamic[key] = (parsed_value, original_keyword)
79+
self.keywords.add(key)
80+
setattr(self, key, parsed_value)
81+
except Exception:
82+
setattr(self, key, None)
83+
84+
def simple_value_list(self, args):
85+
return [str(arg) for arg in args]
86+
87+
def value_list_set(self, args):
88+
return args
89+
90+
def value_set(self, args):
91+
return [str(args[0])] + args[1]
92+
93+
def value(self, args):
94+
return str(args[0])
95+
96+
def set_name(self, args):
97+
return str(args[0])
98+
99+
def parse_mvd(description):
100+
text = ' '.join(description)
101+
parsed_description = DescriptionTransform()
102+
try:
103+
if not text:
104+
parsed_description.view_definitions = None
105+
return parsed_description
106+
parse_tree = parser.parse(text)
107+
parsed_description.transform(parse_tree)
108+
except (UnexpectedCharacters, UnexpectedEOF, UnexpectedToken):
109+
parsed_description.view_definitions = None
110+
return parsed_description
111+
112+
def parse_semicolon_separated_kv(text: str) -> dict[str, str | list[str]] | None:
113+
if not re.search(r'\w+\s*:\s*[^:]+', text):
114+
return None
115+
result = {}
116+
try:
117+
pairs = text.split(';')
118+
for pair in pairs:
119+
if ':' in pair:
120+
key, value = pair.split(':', 1)
121+
key = key.strip()
122+
values = [v.strip() for v in value.split(',')]
123+
result[key] = values[0] if len(values) == 1 else values
124+
return result
125+
except Exception:
126+
return None
127+
else:
128+
def parse_mvd(description):
129+
return None
130+
131+
132+
class MvdInfo:
133+
def __init__(self, header):
134+
self._header = header
135+
self._parsed = None
136+
137+
def _ensure_parsed(self):
138+
if not LARK_AVAILABLE:
139+
return
140+
if self._parsed is None:
141+
description = self._header.file_description.description
142+
if not description:
143+
self._parsed = DescriptionTransform() # avoid AttributeError
144+
else:
145+
self._parsed = parse_mvd(description)
146+
147+
@property
148+
def description(self) -> list[str]:
149+
return self._header.file_description.description
150+
151+
@description.setter
152+
def description(self, new_description: list[str]):
153+
self._header.file_description.description = tuple(new_description)
154+
self._parsed = None
155+
156+
@property
157+
def view_definitions(self):
158+
self._ensure_parsed()
159+
if not self._parsed or self._parsed.view_definitions is None:
160+
return None #
161+
162+
vd = self._parsed.view_definitions
163+
vd_list = vd if isinstance(vd, list) else [vd] if vd else []
164+
return AutoCommitList(
165+
vd_list,
166+
callback=lambda val: (self._update_keyword("ViewDefinition", val), setattr(self, "_parsed", None)),
167+
formatter=lambda lst: ",".join(str(i) for i in lst)
168+
)
169+
170+
@view_definitions.setter
171+
def view_definitions(self, new_value: str | list[str]):
172+
if isinstance(new_value, list):
173+
value = ", ".join(new_value)
174+
else:
175+
value = str(new_value)
176+
self._update_keyword("ViewDefinition", value)
177+
178+
@property
179+
def comments(self):
180+
self._ensure_parsed()
181+
comments = self._parsed.comments
182+
comment_list = comments if isinstance(comments, list) else [comments] if comments else []
183+
return AutoCommitList(
184+
comment_list,
185+
callback=lambda val: self._update_keyword("Comment", val),
186+
formatter=lambda lst: ", ".join(str(i) for i in lst)
187+
)
188+
189+
@comments.setter
190+
def comments(self, new_value: str | list[str]):
191+
if isinstance(new_value, list):
192+
value = ", ".join(new_value)
193+
else:
194+
value = str(new_value)
195+
self._update_keyword("Comment", value)
196+
197+
@property
198+
def exchange_requirements(self):
199+
self._ensure_parsed()
200+
return self._parsed.exchange_requirements if self._parsed else None
201+
202+
@exchange_requirements.setter
203+
def exchange_requirements(self, new_value: str):
204+
self._update_keyword("ExchangeRequirement", new_value)
205+
206+
@property
207+
def options(self):
208+
self._ensure_parsed()
209+
if isinstance(self._parsed.options, dict):
210+
return DictionaryHandler(self._parsed.options, self, "Option")
211+
return self._parsed.options if self._parsed else None
212+
213+
@options.setter
214+
def options(self, new_value: str):
215+
self._update_keyword("Option", new_value)
216+
217+
@property
218+
def keywords(self):
219+
self._ensure_parsed()
220+
return self._parsed.keywords if self._parsed else set()
221+
222+
def _update_keyword(self, keyword: str, new_value: str):
223+
updated = False
224+
new_line = f"{keyword} [{new_value}]"
225+
lines = []
226+
for line in self.description:
227+
if line.strip().startswith(f"{keyword} ["):
228+
lines.append(new_line)
229+
updated = True
230+
else:
231+
lines.append(line)
232+
if not updated:
233+
lines.append(new_line)
234+
self.description = lines
235+
236+
def __getattr__(self, name):
237+
self._ensure_parsed()
238+
if hasattr(self._parsed, '_dynamic'):
239+
name_lc = name.lower()
240+
if name_lc in self._parsed._dynamic:
241+
value, original_keyword = self._parsed._dynamic[name_lc]
242+
return DictionaryHandler(value, self, original_keyword)
243+
raise AttributeError(f"'MvdInfo' object has no attribute '{name}'")
244+
245+
def __dir__(self):
246+
base = super().__dir__()
247+
if self._parsed and hasattr(self._parsed, '_dynamic'):
248+
return base + [kw for _, kw in self._parsed._dynamic.values()]
249+
return base
250+
251+
252+
class DictionaryHandler(dict):
253+
def __init__(self, initial_data, mvdinfo, keyword):
254+
super().__init__()
255+
self._mvdinfo = mvdinfo
256+
self._keyword = keyword
257+
for k, v in initial_data.items():
258+
if isinstance(v, list):
259+
super().__setitem__(k, AutoCommitList(v, self._commit))
260+
else:
261+
super().__setitem__(k, v)
262+
263+
def _commit(self):
264+
new_value = "; ".join(
265+
f"{k}: {', '.join(v) if isinstance(v, list) else v}"
266+
for k, v in self.items()
267+
)
268+
self._mvdinfo._update_keyword(self._keyword, new_value)
269+
270+
def __setitem__(self, key, value):
271+
if isinstance(value, list):
272+
value = AutoCommitList(value, self._commit)
273+
super().__setitem__(key, value)
274+
self._commit()
275+
276+
def __delitem__(self, key):
277+
super().__delitem__(key)
278+
self._commit()
279+
280+
281+
class AutoCommitList(list):
282+
"ensures keyword attributes are written back to ifcopenshell.file.header"
283+
def __init__(self, iterable, callback, formatter=None):
284+
super().__init__(iterable)
285+
self._callback = callback
286+
self._formatter = formatter
287+
288+
def _commit(self):
289+
if self._formatter:
290+
self._callback(self._formatter(self))
291+
else:
292+
self._callback()
293+
294+
def append(self, item):
295+
super().append(item)
296+
self._commit()
297+
298+
def extend(self, iterable):
299+
super().extend(iterable)
300+
self._commit()
301+
302+
def insert(self, index, item):
303+
super().insert(index, item)
304+
self._commit()
305+
306+
def remove(self, item):
307+
super().remove(item)
308+
self._commit()
309+
310+
def pop(self, index=-1):
311+
item = super().pop(index)
312+
self._commit()
313+
return item
314+
315+
def clear(self):
316+
super().clear()
317+
self._commit()
318+
319+
def __setitem__(self, index, value):
320+
super().__setitem__(index, value)
321+
self._commit()
322+
323+
def __delitem__(self, index):
324+
super().__delitem__(index)
325+
self._commit()

test_parser.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,43 @@ def test_parse_valid_header():
6464

6565
for key, val in expected_schema.items():
6666
assert getattr(f.header.file_schema, key) == val, f"{key} mismatch"
67+
6768

69+
def test_header_only_api():
70+
f = open('fixtures/passing_header.ifc', only_header=True)
71+
expected_description = {
72+
"description": ('ViewDefinition [Alignment-basedView]',),
73+
"implementation_level": '2;1',
74+
}
75+
76+
expected_name = {
77+
"name": 'Header example2.ifc',
78+
"time_stamp": '2022-09-16T10:35:07',
79+
"author": ('Evandro Alfieri',),
80+
"organization": ('buildingSMART Int.',),
81+
"preprocessor_version": 'IFC Motor 1.0',
82+
"originating_system": 'Company - Application - 26.0.0.0',
83+
"authorization": 'none',
84+
}
85+
86+
expected_schema = {
87+
"schema_identifiers": ('IFC4X3_ADD2',),
88+
}
89+
90+
for key, val in expected_description.items():
91+
assert getattr(f.header.file_description, key) == val, f"{key} mismatch"
92+
93+
for key, val in expected_name.items():
94+
assert getattr(f.header.file_name, key) == val, f"{key} mismatch"
95+
96+
for key, val in expected_schema.items():
97+
assert getattr(f.header.file_schema, key) == val, f"{key} mismatch"
98+
99+
def test_file_mvd_attr():
100+
f = open('fixtures/extended_mvd.ifc', only_header=True)
101+
102+
assert 'ReferenceView_V1.2' in f.mvd.view_definitions
103+
assert all(i in f.mvd.keywords for i in ['exchange_requirements', 'view_definitions', 'remark', 'comments'])
104+
assert 'Ramp' in f.mvd.options['ExcludedObjects']
105+
assert f.mvd.Remark['SomeKey'] == 'SomeValue'
106+
assert len(f.mvd.comments) == 2

0 commit comments

Comments
 (0)