diff --git a/model.py b/model.py index 8a5c64b..c08dfe0 100644 --- a/model.py +++ b/model.py @@ -12,17 +12,46 @@ import ifcopenshell +from . import mvdxml_expression + extracted_data = list[dict["rule", Any]] concept_data = dict[str, Any] verification_matrix = dict[str, dict[str, int]] def _parse_mvdxml_token(value: str) -> Any: + value = value.strip() if value.lower() == "true": return True if value.lower() == "false": return False - return ast.literal_eval(value) + try: + return ast.literal_eval(value) + except (ValueError, SyntaxError): + # mvdXML parameters are frequently unquoted identifiers or namespaced + # codes, e.g. ``ContextType=Model`` or ``CRSName[Value]=EPSG:5555``. + return value + + +def _iter_expressions(tree: Iterable[Any]) -> Iterator[tuple[Any, ...]]: + """Yield every expression in a ``TemplateRules`` tree. + + ``parser.parse_template_rules`` produces a nested tuple tree: + TemplateRules group -> TemplateRule (the ``;``-separated expressions of + one ``Parameters`` attribute) -> expression (tokens: ``node`` | ``AND`` | + ``OR``). Operators *between* sibling rules are strings and are skipped: + every expression must hold. Callers may therefore pass either a full + parser tree or a bare ``mvdxml_expression.parse()`` result. + """ + for item in tree: + if isinstance(item, str): + continue + if item and all( + isinstance(token, (mvdxml_expression.node, str)) for token in item + ): + yield tuple(item) + else: + yield from _iter_expressions(item) def _merge_dictionaries(dicts: Iterable[dict[rule, Any]]) -> dict[rule, Any]: @@ -252,7 +281,7 @@ def extract( return extracted_entities_data def validate(self, data: extracted_data) -> tuple[bool, str]: - rules = [value[0] for value in self.rules() if not isinstance(value, str)] + rules = list(_iter_expressions(self.rules())) def transform_data(values: dict[rule, Any]) -> dict[str | None, Any]: return { @@ -283,8 +312,10 @@ def translate(value: Any) -> Any: ) if value.b == "Type": item = values.get(value.a) + # Non-entity values (str, float, ...) have no + # IFC type: the type test simply does not hold. return bool( - item is not None + isinstance(item, ifcopenshell.entity_instance) and item.is_a(_parse_mvdxml_token(value.c)) ) if value.b == "Exists": diff --git a/tests/test_graphviz.py b/tests/test_graphviz.py index 0b844ad..606a14c 100644 --- a/tests/test_graphviz.py +++ b/tests/test_graphviz.py @@ -1,5 +1,7 @@ # This file was generated with the assistance of an AI coding tool. +from unittest import mock + import pytest import ifcopenshell @@ -132,12 +134,13 @@ def test_from_graphviz_entity_leaf_filters_ifc_type() -> None: """ parsed = template.from_graphviz(source) - class entity(ifcopenshell.entity_instance): - def __init__(self, ifc_class: str): - self.ifc_class = ifc_class - - def is_a(self, ifc_class: str) -> bool: - return self.ifc_class == ifc_class + def entity(ifc_class: str) -> ifcopenshell.entity_instance: + # A Mock passes the isinstance() check in rule.extract() without + # subclassing entity_instance, whose __setattr__/__getattr__ require + # wrapped_data and otherwise recurse. + instance = mock.Mock(spec=ifcopenshell.entity_instance) + instance.is_a.side_effect = lambda cls: cls == ifc_class + return instance class relationship: def __init__(self, relating_property_definition: entity): diff --git a/tests/test_mvd.py b/tests/test_mvd.py index 023a7a0..e9d2799 100644 --- a/tests/test_mvd.py +++ b/tests/test_mvd.py @@ -149,3 +149,64 @@ def test_top_level_unbound_rule_has_no_parent_binding() -> None: parsed_template = template("IfcWall", "Status", (top_level,)) assert parsed_template.binding_for(top_level) is None + + +@pytest.mark.parametrize( + "value,expected", + [ + ("Model", "Model"), # ContextType[Value]=Model + ("IfcLocalPlacement", "IfcLocalPlacement"), # entity name as a parameter + ("EPSG:5555", "EPSG:5555"), # not valid Python syntax either + (" QTO_OCCURRENCEDRIVEN ", "QTO_OCCURRENCEDRIVEN"), + ("'IfcLabel'", "IfcLabel"), # quoted literals keep working + ("TRUE", True), + ("42", 42), + ], +) +def test_parse_mvdxml_token_falls_back_to_the_raw_string( + value: str, expected: object +) -> None: + assert model._parse_mvdxml_token(value) == expected + + +def test_empty_template_rules_group_is_skipped() -> None: + status = rule("AttributeRule", "Status", bind="Status") + concept = concept_or_applicability( + "status", + template("IfcWall", "Status", (status,)), + ((), "and", (parse_expression("Status='complete'"),)), + ) + + valid, _ = concept.validate([{status: "complete"}]) + + assert valid + + +def test_every_template_rule_expression_is_evaluated() -> None: + status = rule("AttributeRule", "Status", bind="Status") + concept = concept_or_applicability( + "status", + template("IfcWall", "Status", (status,)), + (parse_expression("Status='complete'; Status='draft'"),), + ) + + valid, _ = concept.validate([{status: "complete"}]) + + assert not valid + + +def test_official_reference_view_expressions_all_parse() -> None: + roots = parse(EXAMPLES / "officials" / "ReferenceView_V1-2.mvdxml") + + tokens = [ + token + for root in roots + for concept in root.concepts() + for expression in model._iter_expressions(concept.rules()) + for token in expression + if not isinstance(token, str) + ] + + assert len(tokens) > 400 + for token in tokens: + model._parse_mvdxml_token(token.c)