First Commit
This commit is contained in:
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,140 @@
|
||||
from lark.exceptions import UnexpectedToken
|
||||
|
||||
|
||||
class _ValidationError(Exception):
|
||||
def __init__(self, *args, **kwargs):
|
||||
if self.__class__ is _ValidationError:
|
||||
raise TypeError("Do not raise _ValidationError directly.")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class ErrorCollector:
|
||||
def __init__(self):
|
||||
self.errors = []
|
||||
|
||||
def add(self, error):
|
||||
self.errors.append(error)
|
||||
|
||||
def raise_if_any(self):
|
||||
if self.errors:
|
||||
raise CollectedValidationErrors(self.errors)
|
||||
|
||||
|
||||
class CollectedValidationErrors(_ValidationError):
|
||||
def __init__(self, errors):
|
||||
self.errors = errors
|
||||
|
||||
def asdict(self, with_message=True):
|
||||
return [e.asdict(with_message=with_message) for e in self.errors]
|
||||
|
||||
def __str__(self):
|
||||
return f"{len(self.errors)} validation error(s) collected:\n" + "\n\n".join(
|
||||
str(e) for e in self.errors
|
||||
)
|
||||
|
||||
|
||||
class SyntaxError(_ValidationError):
|
||||
def __init__(self, filecontent, exception):
|
||||
self.filecontent = filecontent
|
||||
self.exception = exception
|
||||
|
||||
def asdict(self, with_message=True):
|
||||
return {
|
||||
"type": (
|
||||
"unexpected_token"
|
||||
if isinstance(self.exception, UnexpectedToken)
|
||||
else "unexpected_character"
|
||||
),
|
||||
"lineno": self.exception.line,
|
||||
"column": self.exception.column,
|
||||
"found_type": self.exception.token.type.lower(),
|
||||
"found_value": self.exception.token.value,
|
||||
"expected": sorted(x for x in self.exception.accepts if "__ANON" not in x),
|
||||
"line": self.filecontent.split("\n")[self.exception.line - 1],
|
||||
**({"message": str(self)} if with_message else {}),
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
d = self.asdict(with_message=False)
|
||||
if len(d["expected"]) == 1:
|
||||
exp = d["expected"][0]
|
||||
else:
|
||||
exp = f"one of {' '.join(d['expected'])}"
|
||||
|
||||
sth = "character" if d["type"] == "unexpected_character" else ""
|
||||
|
||||
return f"On line {d['lineno']} column {d['column']}:\nUnexpected {sth}{d['found_type']} ('{d['found_value']}')\nExpecting {exp}\n{d['lineno']:05d} | {d['line']}\n {' ' * (self.exception.column - 1)}^"
|
||||
|
||||
|
||||
class DuplicateNameError(_ValidationError):
|
||||
def __init__(self, filecontent, name, linenumbers):
|
||||
self.name = name
|
||||
self.filecontent = filecontent
|
||||
self.linenumbers = linenumbers
|
||||
|
||||
def asdict(self, with_message=True):
|
||||
return {
|
||||
"type": "duplicate_name",
|
||||
"name": self.name,
|
||||
"lineno": self.linenumbers[0],
|
||||
"line": self.filecontent.split("\n")[self.linenumbers[0] - 1],
|
||||
**({"message": str(self)} if with_message else {}),
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
d = self.asdict(with_message=False)
|
||||
|
||||
def build():
|
||||
yield f"On line {d['lineno']}:\nDuplicate instance name #{d['name']}"
|
||||
yield f"{d['lineno']:05d} | {d['line']}"
|
||||
yield " " * 8 + "^" * len(d["line"].rstrip())
|
||||
|
||||
return "\n".join(build())
|
||||
|
||||
|
||||
class HeaderFieldError(_ValidationError):
|
||||
def __init__(self, field, found_len, expected_len):
|
||||
self.field = field
|
||||
self.found_len = found_len
|
||||
self.expected_len = expected_len
|
||||
|
||||
def asdict(self, with_message=True):
|
||||
return {
|
||||
"type": "invalid_header_field",
|
||||
"field": self.field,
|
||||
"expected_field_count": self.expected_len,
|
||||
"actual_field_count": self.found_len,
|
||||
**({"message": str(self)} if with_message else {}),
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"Invalid number of parameters for HEADER field '{self.field}'. "
|
||||
f"Expected {self.expected_len}, found {self.found_len}."
|
||||
)
|
||||
|
||||
|
||||
class InvalidNameError(_ValidationError):
|
||||
def __init__(self, filecontent, name, linenumbers):
|
||||
self.name = name
|
||||
self.filecontent = filecontent
|
||||
self.linenumbers = linenumbers
|
||||
|
||||
def asdict(self, with_message=True):
|
||||
return {
|
||||
"type": "invalid_name",
|
||||
"name": self.name,
|
||||
"lineno": self.linenumbers[0],
|
||||
"line": self.filecontent.split("\n")[self.linenumbers[0] - 1],
|
||||
**({"message": str(self)} if with_message else {}),
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
d = self.asdict(with_message=False)
|
||||
|
||||
def build():
|
||||
yield f"On line {d['lineno']}:\nInvalid instance name #{d['name']}"
|
||||
yield f"{d['lineno']:05d} | {d['line']}"
|
||||
yield " " * 8 + "^" * len(d["line"].rstrip())
|
||||
|
||||
return "\n".join(build())
|
||||
@@ -0,0 +1,108 @@
|
||||
import types
|
||||
import re
|
||||
import numbers
|
||||
import itertools
|
||||
|
||||
from .parse import parse, ParseResult
|
||||
from .grammar import HEADER_FIELDS
|
||||
from .transformer import entity_instance
|
||||
|
||||
try:
|
||||
from .mvd_info import MvdInfo, LARK_AVAILABLE
|
||||
except ImportError: # in case of running module locally (e.g. test_parser.py)
|
||||
from mvd_info import MvdInfo, LARK_AVAILABLE
|
||||
|
||||
|
||||
class file:
|
||||
"""
|
||||
A somewhat compatible interface (but very limited) to ifcopenshell.file
|
||||
"""
|
||||
|
||||
def __init__(self, result: ParseResult):
|
||||
self.header_ = result.header
|
||||
self.data_ = result.entities
|
||||
|
||||
@property
|
||||
def schema_identifier(self) -> str:
|
||||
return self.header_["FILE_SCHEMA"][0][0]
|
||||
|
||||
@property
|
||||
def schema(self) -> str:
|
||||
"""General IFC schema version: IFC2X3, IFC4, IFC4X3."""
|
||||
prefixes = ("IFC", "X", "_ADD", "_TC")
|
||||
reg = "".join(f"(?P<{s}>{s}\\d+)?" for s in prefixes)
|
||||
match = re.match(reg, self.schema_identifier)
|
||||
version_tuple = tuple(
|
||||
map(
|
||||
lambda pp: int(pp[1][len(pp[0]) :]) if pp[1] else None,
|
||||
((p, match.group(p)) for p in prefixes),
|
||||
)
|
||||
)
|
||||
return "".join(
|
||||
"".join(map(str, t)) if t[1] else ""
|
||||
for t in zip(prefixes, version_tuple[0:2])
|
||||
)
|
||||
|
||||
@property
|
||||
def schema_version(self) -> tuple[int, int, int, int]:
|
||||
"""Numeric representation of the full IFC schema version.
|
||||
|
||||
E.g. IFC4X3_ADD2 is represented as (4, 3, 2, 0).
|
||||
"""
|
||||
schema = self.schema
|
||||
version = []
|
||||
for prefix in ("IFC", "X", "_ADD", "_TC"):
|
||||
number = re.search(prefix + r"(\d)", schema)
|
||||
version.append(int(number.group(1)) if number else 0)
|
||||
return tuple(version)
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
header = {}
|
||||
for field_name, namedtuple_class in HEADER_FIELDS.items():
|
||||
field_data = self.header_.get(field_name.upper(), [])
|
||||
header[field_name.lower()] = namedtuple_class(*field_data)
|
||||
|
||||
return types.SimpleNamespace(**header)
|
||||
|
||||
@property
|
||||
def mvd(self):
|
||||
if not LARK_AVAILABLE or MvdInfo is None:
|
||||
return None
|
||||
return MvdInfo(self.header)
|
||||
|
||||
def __getitem__(self, key: numbers.Integral) -> entity_instance:
|
||||
return self.by_id(key)
|
||||
|
||||
def by_id(self, id: int) -> entity_instance:
|
||||
"""Return an IFC entity instance filtered by IFC ID.
|
||||
|
||||
:param id: STEP numerical identifier
|
||||
:type id: int
|
||||
|
||||
:raises RuntimeError: If `id` is not found or multiple definitions exist for `id`.
|
||||
|
||||
:rtype: entity_instance
|
||||
"""
|
||||
ns = self.data_.get(id, [])
|
||||
if len(ns) == 0:
|
||||
raise RuntimeError(f"Instance with id {id} not found")
|
||||
elif len(ns) > 1:
|
||||
raise RuntimeError(f"Duplicate definition for id {id}")
|
||||
return ns[0]
|
||||
|
||||
def by_type(self, type: str) -> list[entity_instance]:
|
||||
"""Return IFC objects filtered by IFC Type and wrapped with the entity_instance class.
|
||||
:rtype: list[entity_instance]
|
||||
"""
|
||||
type_lc = type.lower()
|
||||
return list(
|
||||
filter(
|
||||
lambda ent: ent.type.lower() == type_lc,
|
||||
itertools.chain.from_iterable(self.data_.values()),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def open(fn, only_header=False) -> file:
|
||||
return file(parse(filename=fn, only_header=only_header))
|
||||
@@ -0,0 +1,125 @@
|
||||
from collections import namedtuple
|
||||
|
||||
grammar = r"""
|
||||
file: "ISO-10303-21;" header data_section "END-ISO-10303-21;"
|
||||
header: "HEADER" ";" header_entity_list "ENDSEC" ";"
|
||||
header_line: (SPECIAL|DIGIT|LOWER|UPPER)* "*"
|
||||
data_section: "DATA" ";" (entity_instance)* "ENDSEC" ";"
|
||||
entity_instance: simple_entity_instance|complex_entity_instance
|
||||
simple_entity_instance: id "=" simple_record ";"
|
||||
complex_entity_instance: id "=" subsuper_record ";"
|
||||
subsuper_record : "(" simple_record_list ")"
|
||||
simple_record_list:simple_record simple_record*
|
||||
simple_record: keyword "("parameter_list?")"
|
||||
header_entity_list: file_description file_name file_schema
|
||||
file_description: "FILE_DESCRIPTION" "(" parameter_list ")" ";"
|
||||
file_name: "FILE_NAME" "(" parameter_list ")" ";"
|
||||
file_schema: "FILE_SCHEMA" "(" parameter_list ")" ";"
|
||||
id: /#[0-9]+/
|
||||
keyword: /[A-Z][0-9A-Z_]*/
|
||||
parameter: untyped_parameter|typed_parameter|omitted_parameter
|
||||
parameter_list: parameter ("," parameter)*
|
||||
list: "(" parameter ("," parameter)* ")" |"("")"
|
||||
typed_parameter: keyword "(" parameter ")"|"()"
|
||||
untyped_parameter: string| NONE |INT |REAL |enumeration |id |binary |list
|
||||
omitted_parameter:STAR
|
||||
enumeration: "." keyword "."
|
||||
binary: "\"" ("0"|"1"|"2"|"3") (HEX)* "\""
|
||||
string: "'" (REVERSE_SOLIDUS REVERSE_SOLIDUS|SPECIAL|DIGIT|SPACE|LOWER|UPPER|CONTROL_DIRECTIVE|"\\*\\")* "'"
|
||||
|
||||
STAR: "*"
|
||||
SLASH: "/"
|
||||
NONE: "$"
|
||||
SPECIAL : "!"
|
||||
| "*"
|
||||
| "$"
|
||||
| "%"
|
||||
| "&"
|
||||
| "."
|
||||
| "#"
|
||||
| "+"
|
||||
| ","
|
||||
| "-"
|
||||
| "("
|
||||
| ")"
|
||||
| "?"
|
||||
| "/"
|
||||
| ":"
|
||||
| ";"
|
||||
| "<"
|
||||
| "="
|
||||
| ">"
|
||||
| "@"
|
||||
| "["
|
||||
| "]"
|
||||
| "{"
|
||||
| "|"
|
||||
| "}"
|
||||
| "^"
|
||||
| "`"
|
||||
| "~"
|
||||
| "_"
|
||||
| "\""
|
||||
| "\"\""
|
||||
| "''"
|
||||
REAL: SIGN? DIGIT (DIGIT)* "." (DIGIT)* ("E" SIGN? DIGIT (DIGIT)* )?
|
||||
INT: SIGN? DIGIT (DIGIT)*
|
||||
CONTROL_DIRECTIVE: PAGE | ALPHABET | EXTENDED2 | EXTENDED4 | ARBITRARY
|
||||
PAGE : REVERSE_SOLIDUS "S" REVERSE_SOLIDUS LATIN_CODEPOINT
|
||||
LATIN_CODEPOINT : SPACE | DIGIT | LOWER | UPPER | SPECIAL | REVERSE_SOLIDUS | APOSTROPHE
|
||||
ALPHABET : REVERSE_SOLIDUS "P" UPPER REVERSE_SOLIDUS
|
||||
EXTENDED2: REVERSE_SOLIDUS "X2" REVERSE_SOLIDUS (HEX_TWO)* END_EXTENDED
|
||||
EXTENDED4 :REVERSE_SOLIDUS "X4" REVERSE_SOLIDUS (HEX_FOUR)* END_EXTENDED
|
||||
END_EXTENDED: REVERSE_SOLIDUS "X0" REVERSE_SOLIDUS
|
||||
ARBITRARY: REVERSE_SOLIDUS "X" REVERSE_SOLIDUS HEX_ONE
|
||||
HEX_FOUR: HEX_TWO HEX_TWO
|
||||
HEX_TWO: HEX_ONE HEX_ONE
|
||||
HEX_ONE: HEX HEX
|
||||
HEX: "0"
|
||||
| "1"
|
||||
| "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "A"
|
||||
| "B"
|
||||
| "C"
|
||||
| "D"
|
||||
| "E"
|
||||
| "F"
|
||||
APOSTROPHE: "'"
|
||||
REVERSE_SOLIDUS: "\\"
|
||||
DIGIT: "0".."9"
|
||||
SIGN: "+"|"-"
|
||||
LOWER: "a".."z"
|
||||
UPPER: "A".."Z"
|
||||
ESCAPE : "\\" ( "$" | "\"" | CHAR )
|
||||
CHAR : /[^$"\n]/
|
||||
WORD : CHAR+
|
||||
SPACE.10 : " "
|
||||
|
||||
%ignore /[ \t\f\r\n]/+
|
||||
"""
|
||||
|
||||
HEADER_FIELDS = {
|
||||
"file_description": namedtuple(
|
||||
"file_description", ["description", "implementation_level"]
|
||||
),
|
||||
"file_name": namedtuple(
|
||||
"file_name",
|
||||
[
|
||||
"name",
|
||||
"time_stamp",
|
||||
"author",
|
||||
"organization",
|
||||
"preprocessor_version",
|
||||
"originating_system",
|
||||
"authorization",
|
||||
],
|
||||
),
|
||||
"file_schema": namedtuple("file_schema", ["schema_identifiers"]),
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
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()
|
||||
@@ -0,0 +1,184 @@
|
||||
from dataclasses import dataclass
|
||||
from collections import defaultdict
|
||||
import re
|
||||
import sys
|
||||
import builtins
|
||||
from lark import Lark, UnexpectedCharacters, UnexpectedToken
|
||||
|
||||
# import transformer
|
||||
from .transformer import (
|
||||
Transformer,
|
||||
entity_instance,
|
||||
make_header_ent,
|
||||
create_step_entity,
|
||||
)
|
||||
from .grammar import grammar, HEADER_FIELDS
|
||||
from .errors import (
|
||||
HeaderFieldError,
|
||||
DuplicateNameError,
|
||||
ErrorCollector,
|
||||
SyntaxError,
|
||||
InvalidNameError,
|
||||
)
|
||||
|
||||
|
||||
def validate_header_fields(header, error_collector, only_header=False):
|
||||
for field in HEADER_FIELDS.keys():
|
||||
observed = header.get(field.upper(), [])
|
||||
expected = HEADER_FIELDS.get(field)._fields
|
||||
if len(observed) != len(expected):
|
||||
error_collector.add(
|
||||
HeaderFieldError(field.upper(), len(observed), len(expected))
|
||||
)
|
||||
if only_header:
|
||||
error_collector.raise_if_any()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseResult:
|
||||
header: dict
|
||||
entities: dict[int, list[entity_instance]]
|
||||
|
||||
|
||||
def process_tree(filecontent, file_tree, with_progress, error_collector):
|
||||
ents = defaultdict(list)
|
||||
header, data = file_tree.children
|
||||
|
||||
header = dict(map(make_header_ent, header.children[0].children))
|
||||
validate_header_fields(header, error_collector)
|
||||
|
||||
n = len(data.children)
|
||||
if n:
|
||||
percentages = [i * 100.0 / n for i in range(n + 1)]
|
||||
num_dots = [int(b) - int(a) for a, b in zip(percentages, percentages[1:])]
|
||||
|
||||
for idx, entity_tree in enumerate(data.children):
|
||||
if with_progress:
|
||||
sys.stdout.write(num_dots[idx] * ".")
|
||||
sys.stdout.flush()
|
||||
ent = create_step_entity(entity_tree)
|
||||
id_ = int(ent["id"])
|
||||
if id_ == 0:
|
||||
error_collector.add(InvalidNameError(filecontent, ent["id"], ent["lines"]))
|
||||
if ents[id_]:
|
||||
error_collector.add(
|
||||
DuplicateNameError(filecontent, ent["id"], ent["lines"])
|
||||
)
|
||||
else:
|
||||
ents[id_].append(ent)
|
||||
|
||||
return header, ents
|
||||
|
||||
|
||||
def parse(
|
||||
*,
|
||||
filename=None,
|
||||
filecontent=None,
|
||||
with_progress=False,
|
||||
with_tree=True,
|
||||
only_header=False,
|
||||
) -> ParseResult:
|
||||
error_collector = ErrorCollector()
|
||||
if filename:
|
||||
assert not filecontent
|
||||
filecontent = builtins.open(filename, encoding=None).read()
|
||||
|
||||
# Match and remove the comments
|
||||
p = r"/\*[\s\S]*?\*/"
|
||||
|
||||
def replace_fn(match):
|
||||
return re.sub(r"[^\n]", " ", match.group(), flags=re.M)
|
||||
|
||||
filecontent_wo_comments = re.sub(p, replace_fn, filecontent)
|
||||
|
||||
if only_header:
|
||||
# Extract just the HEADER section using regex
|
||||
header_match = re.search(
|
||||
r"ISO-10303-21;\s*HEADER;(.*?)ENDSEC;",
|
||||
filecontent_wo_comments,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if not header_match:
|
||||
error_collector.add(
|
||||
HeaderFieldError("header", "", "No HEADER section found in file")
|
||||
)
|
||||
error_collector.raise_if_any()
|
||||
|
||||
header_text = f"HEADER;{header_match.group(1)}ENDSEC;"
|
||||
full_header_text = f"ISO-10303-21;{header_text}DATA;ENDSEC;END-ISO-10303-21;"
|
||||
|
||||
parser = Lark(grammar, parser="lalr", start="file")
|
||||
try:
|
||||
ast = parser.parse(full_header_text)
|
||||
except (UnexpectedToken, UnexpectedCharacters) as e:
|
||||
error_collector.add(SyntaxError(filecontent, e))
|
||||
error_collector.raise_if_any() # Immediately abort in case of critical error
|
||||
|
||||
header_tree = ast.children[0] # HEADER section
|
||||
|
||||
header = dict(map(make_header_ent, header_tree.children[0].children))
|
||||
validate_header_fields(header, error_collector, only_header=True)
|
||||
error_collector.raise_if_any()
|
||||
return ParseResult(header=header, entities=defaultdict(list))
|
||||
|
||||
instance_identifiers = []
|
||||
transformer = {}
|
||||
if not with_tree:
|
||||
# If we're not going to return the tree, we also don't need to
|
||||
# keep in memory while parsing. So we build a transformer that
|
||||
# just returns None for every rule. lark creates a dictionary
|
||||
# of callbacks from the transformer type object, so we can't
|
||||
# simply use __getattr__ we need an actual type objects with
|
||||
# callback functions for the rules given in the
|
||||
|
||||
# Create a temporary parser just for analysing the grammar
|
||||
temp = Lark(grammar, parser="lalr", start="file")
|
||||
# Extract the rule names
|
||||
rule_names = filter(
|
||||
lambda s: not s.startswith("_"), set(r.origin.name for r in temp.rules)
|
||||
)
|
||||
null_function = lambda self, *args: None
|
||||
# Create dictionary of methods for type() creation
|
||||
methods = {r: null_function for r in rule_names}
|
||||
|
||||
# Even in this case we do want to report duplicate identifiers
|
||||
# so these need to be captured
|
||||
methods["id"] = lambda self, *args: args
|
||||
methods["simple_entity_instance"] = (
|
||||
lambda self, tree: instance_identifiers.append(
|
||||
(int(tree[0][0][0][1:]), int(tree[0][0][0].line))
|
||||
)
|
||||
)
|
||||
|
||||
NT = type("NullTransformer", (Transformer,), methods)
|
||||
transformer = {"transformer": NT()}
|
||||
|
||||
parser = Lark(grammar, parser="lalr", start="file", **transformer)
|
||||
|
||||
try:
|
||||
ast = parser.parse(filecontent_wo_comments)
|
||||
except (UnexpectedToken, UnexpectedCharacters) as e:
|
||||
error_collector.add(SyntaxError(filecontent, e))
|
||||
error_collector.raise_if_any() # Immediately abort in case of critical error
|
||||
|
||||
if with_tree:
|
||||
header, data = process_tree(filecontent, ast, with_progress, error_collector)
|
||||
error_collector.raise_if_any()
|
||||
return ParseResult(header=header, entities=data)
|
||||
else:
|
||||
# process_tree() would take care of duplicate identifiers,
|
||||
# but we need to do it ourselves now using our rudimentary
|
||||
# transformer
|
||||
seen = set()
|
||||
for iden, lineno in instance_identifiers:
|
||||
if iden == 0:
|
||||
error_collector.add(
|
||||
InvalidNameError(filecontent, iden, [lineno, lineno])
|
||||
)
|
||||
if iden in seen:
|
||||
error_collector.add(
|
||||
DuplicateNameError(filecontent, iden, [lineno, lineno])
|
||||
)
|
||||
else:
|
||||
seen.add(iden)
|
||||
error_collector.raise_if_any()
|
||||
@@ -0,0 +1,107 @@
|
||||
from lark import Transformer
|
||||
from dataclasses import dataclass
|
||||
import numbers
|
||||
from lark import Lark, Transformer, Tree, Token
|
||||
|
||||
|
||||
class IfcType:
|
||||
def __init__(self, ifctype, value):
|
||||
self.ifctype = ifctype
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return self.ifctype + "(" + str(self.value) + ")"
|
||||
|
||||
__repr__ = __str__
|
||||
|
||||
|
||||
@dataclass
|
||||
class entity_instance:
|
||||
id: int
|
||||
type: str
|
||||
attributes: tuple
|
||||
lines: tuple
|
||||
|
||||
def __getitem__(self, k):
|
||||
if isinstance(k, numbers.Integral):
|
||||
return self.attributes[k]
|
||||
else:
|
||||
# compatibility with dict
|
||||
return getattr(self, k)
|
||||
|
||||
def __repr__(self):
|
||||
return f'#{self.id}={self.type}({",".join(map(str, self.attributes))})'
|
||||
|
||||
|
||||
class T(Transformer):
|
||||
def id(self, s):
|
||||
return int(s[0][1:])
|
||||
|
||||
def string(self, s):
|
||||
word = "".join(s).replace("''", "'")
|
||||
return word
|
||||
|
||||
def keyword(self, s):
|
||||
word = "".join(s)
|
||||
return word
|
||||
|
||||
def untyped_parameter(self, s):
|
||||
return s[0]
|
||||
|
||||
def parameter(self, s):
|
||||
return s[0]
|
||||
|
||||
def typed_parameter(self, s):
|
||||
if len(s):
|
||||
return IfcType(s[0], s[1])
|
||||
else:
|
||||
return ()
|
||||
|
||||
def omitted_parameter(self, s):
|
||||
return s[0]
|
||||
|
||||
def enumeration(self, s):
|
||||
return s[0]
|
||||
|
||||
parameter_list = tuple
|
||||
list = tuple
|
||||
subsuper_record = list
|
||||
INT = int
|
||||
REAL = float
|
||||
NONE = lambda *args: None
|
||||
STAR = str
|
||||
|
||||
|
||||
def create_step_entity(entity_tree):
|
||||
t = T(visit_tokens=True).transform(entity_tree)
|
||||
|
||||
def get_line_number(t):
|
||||
if isinstance(t, Token):
|
||||
yield t.line
|
||||
|
||||
def traverse(fn, x):
|
||||
yield from fn(x)
|
||||
if isinstance(x, Tree):
|
||||
for c in x.children:
|
||||
yield from traverse(fn, c)
|
||||
|
||||
lines = list(traverse(get_line_number, entity_tree))
|
||||
|
||||
entity_id = t.children[0].children[0]
|
||||
entity_type = t.children[0].children[1].children[0]
|
||||
|
||||
attributes_tree = t.children[0].children[1].children[1]
|
||||
attributes = list(attributes_tree)
|
||||
|
||||
return entity_instance(
|
||||
entity_id,
|
||||
entity_type,
|
||||
attributes,
|
||||
(min(lines), max(lines)),
|
||||
)
|
||||
|
||||
|
||||
def make_header_ent(ast):
|
||||
rule = ast.data
|
||||
params = T(visit_tokens=True).transform(ast.children[0])
|
||||
return rule.upper(), params
|
||||
Reference in New Issue
Block a user