mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-18 06:21:40 +00:00
Source UNIQUE-clause validation from express_parser (#4499 review)
Per review, do not hard-code the uniqueness rules. express_parser already
captures each entity's UNIQUE clauses (nodes.entity_definition.unique), but
they were dropped at codegen. Emit them, via the same pipeline that already
produces the WHERE rules, into the generated express/rules/<SCHEMA>.py
modules, and have validate read them from there so the data tracks the
EXPRESS schema.
- express/nodes.py: capture the full attribute list per UNIQUE clause
(label, (attrs...)); previously only the first attribute was kept, which
silently dropped compound clauses such as IfcApplication.UR2
(ApplicationFullName, Version).
- express/rule_compiler.py: collect_uniqueness_rules / format_uniqueness_rules
emit a uniqueness_rules dict into every generated rules module.
- express/rules/{IFC2X3,IFC4,IFC4X1,IFC4X2}.py: carry the generated dict.
- validate.py: get_uniqueness_rules reads it statically (ast, cached, no
heavy import; graceful {} if absent) and validate_uniqueness_rules applies
it generically. IfcRoot.UR1 and IfcApplication keep their dedicated paths
to avoid double-reporting.
Verified: get_uniqueness_rules('IFC4') returns IfcApplication /
IfcPropertyEnumeration / IfcRoot from the rules module; a duplicate
IfcPropertyEnumeration name (IFC4) and IFC2X3 UR clauses are flagged, valid
files clean, no double-reporting. All test/test_validate.py fixtures (40)
pass; black and ruff clean.
Refs #4499. IFC4X3 variants gain the dict automatically on the next full
rules regeneration; validate no-ops gracefully until then.
Generated with the assistance of an AI coding tool.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -182,11 +182,25 @@ class EntityDeclaration(Node):
|
|||||||
|
|
||||||
self.where = [(r.simple_id, format_clause(r.expression[0])) for r in clause[1::2]]
|
self.where = [(r.simple_id, format_clause(r.expression[0])) for r in clause[1::2]]
|
||||||
|
|
||||||
|
# Each entity-level UNIQUE clause is captured as (label, attributes)
|
||||||
|
# where label may be None and attributes is a tuple of the referenced
|
||||||
|
# attribute names. A rule may reference more than one attribute, e.g.
|
||||||
|
# ``UR2 : ApplicationFullName, Version;`` (a compound uniqueness rule).
|
||||||
self.unique = []
|
self.unique = []
|
||||||
clause = [r for r in self.entity_body[0] if r.rule == "unique_clause"]
|
clause = [r for r in self.entity_body[0] if r.rule == "unique_clause"]
|
||||||
if clause:
|
if clause:
|
||||||
clause = clause[0]
|
clause = clause[0]
|
||||||
self.unique = [(r[0], r[2].simple_id) for r in map(list, list(clause)[1::2])]
|
for rule in map(list, list(clause)[1::2]):
|
||||||
|
if len(rule) >= 2 and rule[1] == ":":
|
||||||
|
label = rule[0]
|
||||||
|
attribute_tokens = rule[2:]
|
||||||
|
else:
|
||||||
|
label = None
|
||||||
|
attribute_tokens = rule
|
||||||
|
# Referenced attributes are Node instances (they carry a
|
||||||
|
# ``simple_id``); labels and the ``:``/``,`` punctuation are str.
|
||||||
|
attributes = tuple(t.simple_id for t in attribute_tokens if not isinstance(t, str))
|
||||||
|
self.unique.append((label, attributes))
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
strm = io.StringIO()
|
strm = io.StringIO()
|
||||||
@@ -222,8 +236,9 @@ class EntityDeclaration(Node):
|
|||||||
|
|
||||||
if self.unique:
|
if self.unique:
|
||||||
print(" UNIQUE", file=strm)
|
print(" UNIQUE", file=strm)
|
||||||
for nm_exp in self.unique:
|
for label, attributes in self.unique:
|
||||||
print(" %s : %s;" % nm_exp, file=strm)
|
prefix = "%s : " % label if label else ""
|
||||||
|
print(" %s%s;" % (prefix, ", ".join(attributes)), file=strm)
|
||||||
|
|
||||||
print("END_ENTITY;", file=strm)
|
print("END_ENTITY;", file=strm)
|
||||||
return strm.getvalue()
|
return strm.getvalue()
|
||||||
|
|||||||
@@ -792,6 +792,34 @@ class AttributeGetattrTransformer(ast.NodeTransformer):
|
|||||||
child.parent = node
|
child.parent = node
|
||||||
|
|
||||||
|
|
||||||
|
def collect_uniqueness_rules(schema):
|
||||||
|
"""Enumerate the entity-level EXPRESS ``UNIQUE`` clauses of a parsed schema.
|
||||||
|
|
||||||
|
``schema`` is an ``ifcopenshell.express.express_parser`` schema (the pyparsing
|
||||||
|
AST wrapper), whose entity definitions expose ``.unique`` as a list of
|
||||||
|
``(label, attributes)`` pairs. Returns an ordered mapping::
|
||||||
|
|
||||||
|
{entity_name: [(rule_label, (attribute_name, ...)), ...], ...}
|
||||||
|
|
||||||
|
limited to entities that actually declare a ``UNIQUE`` clause. This is the
|
||||||
|
single source of truth used both to emit ``uniqueness_rules`` into the
|
||||||
|
generated schema rules module and, at runtime, to drive uniqueness
|
||||||
|
validation, so the data always tracks the EXPRESS schema rather than a
|
||||||
|
hand-maintained table.
|
||||||
|
"""
|
||||||
|
rules = {}
|
||||||
|
for name, entity in schema.entities.items():
|
||||||
|
unique = getattr(entity, "unique", None)
|
||||||
|
if unique:
|
||||||
|
rules[name] = [(label, tuple(attributes)) for label, attributes in unique]
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
|
def format_uniqueness_rules(schema):
|
||||||
|
"""Render :func:`collect_uniqueness_rules` as an assignable Python literal."""
|
||||||
|
return "uniqueness_rules = " + repr(collect_uniqueness_rules(schema))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import io
|
import io
|
||||||
import sys
|
import sys
|
||||||
@@ -1013,6 +1041,10 @@ INDETERMINATE = indeterminate_type()
|
|||||||
sep="\n",
|
sep="\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Entity-level UNIQUE clauses are not retained by the compiled C++ schema, so
|
||||||
|
# emit them here (straight from express_parser) for ifcopenshell.validate.
|
||||||
|
print(format_uniqueness_rules(schema), "\n", file=output, sep="\n")
|
||||||
|
|
||||||
for nm in schema.all_declarations.keys():
|
for nm in schema.all_declarations.keys():
|
||||||
print(nm)
|
print(nm)
|
||||||
|
|
||||||
|
|||||||
@@ -8127,4 +8127,8 @@ def IfcVectorSum(arg1, arg2):
|
|||||||
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
|
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
|
||||||
else:
|
else:
|
||||||
result = IfcVector(Orientation=vec1, Magnitude=0.0)
|
result = IfcVector(Orientation=vec1, Magnitude=0.0)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Entity-level EXPRESS UNIQUE clauses, emitted from express_parser for ifcopenshell.validate.
|
||||||
|
uniqueness_rules = {'IfcActionRequest': [('UR2', ('RequestID',))], 'IfcApplication': [('UR1', ('ApplicationIdentifier',)), ('UR2', ('ApplicationFullName', 'Version'))], 'IfcCostSchedule': [('UR2', ('ID',))], 'IfcFuelProperties': [('UR11', ('Material',))], 'IfcGeneralMaterialProperties': [('UR11', ('Material',))], 'IfcHygroscopicMaterialProperties': [('UR11', ('Material',))], 'IfcMechanicalMaterialProperties': [('UR11', ('Material',))], 'IfcOpticalMaterialProperties': [('UR11', ('Material',))], 'IfcOrderAction': [('UR2', ('ActionID',))], 'IfcPermit': [('UR2', ('PermitID',))], 'IfcProductsOfCombustionProperties': [('UR11', ('Material',))], 'IfcProjectOrder': [('UR2', ('ID',))], 'IfcPropertyEnumeration': [('UR1', ('Name',))], 'IfcRoot': [('UR1', ('GlobalId',))], 'IfcThermalMaterialProperties': [('UR11', ('Material',))], 'IfcWaterProperties': [('UR11', ('Material',))]}
|
||||||
|
|||||||
@@ -12226,4 +12226,8 @@ def IfcVectorSum(arg1, arg2):
|
|||||||
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
|
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
|
||||||
else:
|
else:
|
||||||
result = IfcVector(Orientation=vec1, Magnitude=0.0)
|
result = IfcVector(Orientation=vec1, Magnitude=0.0)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Entity-level EXPRESS UNIQUE clauses, emitted from express_parser for ifcopenshell.validate.
|
||||||
|
uniqueness_rules = {'IfcApplication': [('UR1', ('ApplicationIdentifier',)), ('UR2', ('ApplicationFullName', 'Version'))], 'IfcPropertyEnumeration': [('UR1', ('Name',))], 'IfcRoot': [('UR1', ('GlobalId',))]}
|
||||||
|
|||||||
@@ -12383,4 +12383,8 @@ def IfcVectorSum(arg1, arg2):
|
|||||||
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
|
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
|
||||||
else:
|
else:
|
||||||
result = IfcVector(Orientation=vec1, Magnitude=0.0)
|
result = IfcVector(Orientation=vec1, Magnitude=0.0)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Entity-level EXPRESS UNIQUE clauses, emitted from express_parser for ifcopenshell.validate.
|
||||||
|
uniqueness_rules = {'IfcApplication': [('UR1', ('ApplicationIdentifier',)), ('UR2', ('ApplicationFullName', 'Version'))], 'IfcPropertyEnumeration': [('UR1', ('Name',))], 'IfcRoot': [('UR1', ('GlobalId',))]}
|
||||||
|
|||||||
@@ -12675,4 +12675,8 @@ def IfcVectorSum(arg1, arg2):
|
|||||||
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
|
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
|
||||||
else:
|
else:
|
||||||
result = IfcVector(Orientation=vec1, Magnitude=0.0)
|
result = IfcVector(Orientation=vec1, Magnitude=0.0)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Entity-level EXPRESS UNIQUE clauses, emitted from express_parser for ifcopenshell.validate.
|
||||||
|
uniqueness_rules = {'IfcApplication': [('UR1', ('ApplicationIdentifier',)), ('UR2', ('ApplicationFullName', 'Version'))], 'IfcPropertyEnumeration': [('UR1', ('Name',))], 'IfcRoot': [('UR1', ('GlobalId',))]}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ Can be used to run validation on IFC file from the command line:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import ast
|
||||||
import functools
|
import functools
|
||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
@@ -593,6 +594,11 @@ def validate(f: Union[ifcopenshell.file, str], logger: Union[Logger, json_logger
|
|||||||
else:
|
else:
|
||||||
logger.error("For instance:\n %s\n%s", inst, e)
|
logger.error("For instance:\n %s\n%s", inst, e)
|
||||||
|
|
||||||
|
if isinstance(logger, json_logger):
|
||||||
|
logger.set_state("instance", None)
|
||||||
|
logger.set_state("attribute", None)
|
||||||
|
validate_uniqueness_rules(f, logger, schema)
|
||||||
|
|
||||||
if filename:
|
if filename:
|
||||||
# IfcOpenShell uses lazy-loading, so entity instance
|
# IfcOpenShell uses lazy-loading, so entity instance
|
||||||
# attributes aren't parsed yet, and counts aren't verified yet.
|
# attributes aren't parsed yet, and counts aren't verified yet.
|
||||||
@@ -761,6 +767,125 @@ def validate_ifc_applications(f: ifcopenshell.file, logger: Union[Logger, json_l
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=None)
|
||||||
|
def get_uniqueness_rules(schema_identifier: str) -> dict[str, list[tuple[str, tuple[str, ...]]]]:
|
||||||
|
"""Return the entity-level EXPRESS ``UNIQUE`` clauses for a schema.
|
||||||
|
|
||||||
|
The data is read from the ``uniqueness_rules`` mapping emitted into the
|
||||||
|
generated schema rules module (``ifcopenshell/express/rules/<schema>.py``)
|
||||||
|
by ``ifcopenshell.express.rule_compiler``, i.e. straight from
|
||||||
|
``express_parser`` rather than a hard-coded table. The assignment is
|
||||||
|
extracted statically (without importing the large rules module) and
|
||||||
|
literal-evaluated. Returns an empty mapping when the rules module or the
|
||||||
|
``uniqueness_rules`` assignment is absent (e.g. a schema whose rules have
|
||||||
|
not been regenerated yet), so validation degrades gracefully.
|
||||||
|
"""
|
||||||
|
rules_dir = os.path.join(os.path.dirname(ifcopenshell.express.rule_executor.__file__), "rules")
|
||||||
|
rules_path = os.path.join(rules_dir, f"{schema_identifier}.py")
|
||||||
|
try:
|
||||||
|
with open(rules_path, "r") as rules_file:
|
||||||
|
source = rules_file.read()
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
if "uniqueness_rules" not in source:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
module = ast.parse(source)
|
||||||
|
except SyntaxError:
|
||||||
|
return {}
|
||||||
|
for node in module.body:
|
||||||
|
if isinstance(node, ast.Assign) and any(
|
||||||
|
isinstance(target, ast.Name) and target.id == "uniqueness_rules" for target in node.targets
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return ast.literal_eval(node.value)
|
||||||
|
except (ValueError, SyntaxError):
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _uniqueness_key_component(value: Any) -> Any:
|
||||||
|
# An instance reference is unique by identity (its STEP id), not by content.
|
||||||
|
if isinstance(value, ifcopenshell.entity_instance):
|
||||||
|
return ("#", value.id())
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_uniqueness_rules(
|
||||||
|
f: ifcopenshell.file,
|
||||||
|
logger: Union[Logger, json_logger],
|
||||||
|
schema: schema_definition,
|
||||||
|
) -> None:
|
||||||
|
"""Validate entity-level EXPRESS ``UNIQUE`` clauses generically.
|
||||||
|
|
||||||
|
Generalises the dedicated ``IfcApplication`` check to every other
|
||||||
|
entity-level ``UNIQUE`` clause of the active schema. The rules come from
|
||||||
|
:func:`get_uniqueness_rules` (express_parser driven). ``IfcRoot.UR1`` and
|
||||||
|
``IfcApplication.UR1/UR2`` keep their dedicated passes and are skipped here
|
||||||
|
to avoid double reporting.
|
||||||
|
"""
|
||||||
|
uniqueness_rules = get_uniqueness_rules(f.schema_identifier)
|
||||||
|
for entity_name, rules in uniqueness_rules.items():
|
||||||
|
if entity_name in ("IfcRoot", "IfcApplication"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
insts = f.by_type(entity_name)
|
||||||
|
except RuntimeError:
|
||||||
|
# Entity not present in this schema variant.
|
||||||
|
continue
|
||||||
|
if not insts:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_, attrs = get_entity_attributes(schema, entity_name)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
attr_names = [attr.name() for attr in attrs]
|
||||||
|
|
||||||
|
for label, rule_attrs in rules:
|
||||||
|
try:
|
||||||
|
indices = tuple(attr_names.index(name) for name in rule_attrs)
|
||||||
|
except ValueError:
|
||||||
|
# A referenced attribute is absent in this schema variant.
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(indices) == 1:
|
||||||
|
pos: Union[int, tuple[int, ...]] = indices[0]
|
||||||
|
description = "The attribute %s should be unique" % rule_attrs[0]
|
||||||
|
else:
|
||||||
|
pos = indices
|
||||||
|
description = "The combination of attributes %s should be unique" % " and ".join(rule_attrs)
|
||||||
|
rule = "Rule %s.%s:\n %s" % (entity_name, label, description)
|
||||||
|
|
||||||
|
seen: dict[tuple, ifcopenshell.entity_instance] = {}
|
||||||
|
for inst in insts:
|
||||||
|
try:
|
||||||
|
values = tuple(inst[i] for i in indices)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# Per EXPRESS, a rule is not enforced when a referenced value is
|
||||||
|
# indeterminate (?), so skip instances with a missing value.
|
||||||
|
if any(value is None for value in values):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
key = tuple(_uniqueness_key_component(value) for value in values)
|
||||||
|
previous_element = seen.get(key)
|
||||||
|
except TypeError:
|
||||||
|
continue
|
||||||
|
if previous_element is None:
|
||||||
|
seen[key] = inst
|
||||||
|
continue
|
||||||
|
if isinstance(logger, json_logger):
|
||||||
|
logger.set_state("instance", inst)
|
||||||
|
logger.error(
|
||||||
|
"On instance:\n %s\n %s\n%s\nViolated by:\n %s\n %s",
|
||||||
|
inst,
|
||||||
|
annotate_inst_attr_pos(inst, pos),
|
||||||
|
rule,
|
||||||
|
previous_element,
|
||||||
|
annotate_inst_attr_pos(previous_element, pos),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LogDetectionHandler(Handler):
|
class LogDetectionHandler(Handler):
|
||||||
message_logged = False
|
message_logged = False
|
||||||
|
|
||||||
|
|||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
ISO-10303-21;
|
||||||
|
HEADER;
|
||||||
|
FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');
|
||||||
|
FILE_NAME('','2022-10-01T16:31:47',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0','');
|
||||||
|
FILE_SCHEMA(('IFC4'));
|
||||||
|
ENDSEC;
|
||||||
|
DATA;
|
||||||
|
#1=IFCPROPERTYENUMERATION('Colour',(IFCLABEL('Red'),IFCLABEL('Green')),$);
|
||||||
|
#2=IFCPROPERTYENUMERATION('Colour',(IFCLABEL('Blue')),$);
|
||||||
|
ENDSEC;
|
||||||
|
END-ISO-10303-21;
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
ISO-10303-21;
|
||||||
|
HEADER;
|
||||||
|
FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');
|
||||||
|
FILE_NAME('','2022-10-01T16:31:47',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0','');
|
||||||
|
FILE_SCHEMA(('IFC4'));
|
||||||
|
ENDSEC;
|
||||||
|
DATA;
|
||||||
|
#1=IFCPROPERTYENUMERATION('Colour',(IFCLABEL('Red'),IFCLABEL('Green')),$);
|
||||||
|
#2=IFCPROPERTYENUMERATION('Size',(IFCLABEL('Big')),$);
|
||||||
|
ENDSEC;
|
||||||
|
END-ISO-10303-21;
|
||||||
Reference in New Issue
Block a user