Skip to content

Commit 299a9fa

Browse files
committed
Use argparse subcommands
1 parent 8140baa commit 299a9fa

6 files changed

Lines changed: 489 additions & 47 deletions

File tree

‎README.md‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,37 @@ initialize a geometry backend or require a spreadsheet dependency.
126126
Inspect a document:
127127

128128
```console
129-
python -m ifcopenshell.mvd mvd_examples/wall_extraction.mvdxml
129+
python -m ifcopenshell.mvd inspect mvd_examples/wall_extraction.mvdxml
130+
```
131+
132+
Validate an IFC file against an mvdXML document:
133+
134+
```console
135+
python -m ifcopenshell.mvd validate model.mvdxml model.ifc
130136
```
131137

132138
Generate and execute SPARQL against an IFC-OWL Turtle file:
133139

134140
```console
135-
python -m ifcopenshell.mvd model.mvdxml model.ttl
141+
python -m ifcopenshell.mvd convert model.mvdxml model.ttl
136142
```
137143

144+
Extract a ConceptTemplate, ConceptRoot, or Concept within a ConceptRoot:
145+
146+
```console
147+
python -m ifcopenshell.mvd extract source.mvdxml "Project Context" project-context.mvdxml
148+
python -m ifcopenshell.mvd extract source.mvdxml IfcZone zone.mvdxml
149+
python -m ifcopenshell.mvd extract source.mvdxml "IfcZone/Property Sets for Objects" zone-psets.mvdxml
150+
```
151+
152+
Combine two or more documents (the final argument is the output path):
153+
154+
```console
155+
python -m ifcopenshell.mvd combine first.mvdxml second.mvdxml combined.mvdxml
156+
```
157+
158+
Both commands operate on the immutable objects returned by `parse()`, then pass
159+
the selected or concatenated objects to `serialize()`. Output contains one
160+
flattened definition per ConceptTemplate UUID.
161+
138162
Use `python -m ifcopenshell.mvd --help` for argument details.

‎__init__.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
from .model import concept_or_applicability, concept_root, rule, template
1+
from .model import concept_or_applicability, concept_root, filter, rule, template
22
from .parser import parse
3+
from .serializer import serialize
34

4-
__all__ = ["concept_or_applicability", "concept_root", "parse", "rule", "template"]
5+
__all__ = [
6+
"concept_or_applicability",
7+
"concept_root",
8+
"filter",
9+
"parse",
10+
"rule",
11+
"serialize",
12+
"template",
13+
]

‎__main__.py‎

Lines changed: 111 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,60 @@
33
import argparse
44
from collections.abc import Sequence
55

6+
from . import filter as filter_mvd
7+
from . import serialize
68
from .model import concept_root, template
79
from .parser import parse
810

911

1012
def build_parser() -> argparse.ArgumentParser:
1113
parser = argparse.ArgumentParser(
1214
prog="python -m ifcopenshell.mvd",
13-
description="Inspect mvdXML or execute its generated SPARQL against an IFC-OWL file.",
15+
description="Inspect, validate, extract, combine, or convert mvdXML documents.",
1416
)
15-
parser.add_argument("mvdxml", help="Path to an mvdXML document")
16-
parser.add_argument(
17+
subparsers = parser.add_subparsers(dest="command", required=True)
18+
19+
inspect_parser = subparsers.add_parser("inspect", help="Inspect an mvdXML document")
20+
inspect_parser.add_argument("mvdxml", help="Path to an mvdXML document")
21+
22+
validate_parser = subparsers.add_parser(
23+
"validate",
24+
help="Validate an IFC file against an mvdXML document",
25+
)
26+
validate_parser.add_argument("mvdxml", help="Path to an mvdXML document")
27+
validate_parser.add_argument("ifc", help="Path to an IFC file")
28+
29+
convert_parser = subparsers.add_parser(
30+
"convert",
31+
help="Generate and execute SPARQL against an IFC-OWL Turtle file",
32+
)
33+
convert_parser.add_argument("mvdxml", help="Path to an mvdXML document")
34+
convert_parser.add_argument(
1735
"ifcowl",
18-
nargs="?",
19-
help="Optional path to an IFC-OWL Turtle file; omit it to inspect the mvdXML",
36+
help="Path to an IFC-OWL Turtle file",
2037
)
38+
39+
extract_parser = subparsers.add_parser(
40+
"extract",
41+
help="Extract a ConceptTemplate, ConceptRoot, or Concept into a new mvdXML document",
42+
)
43+
extract_parser.add_argument("mvdxml", help="Path to the source mvdXML document")
44+
extract_parser.add_argument(
45+
"selection",
46+
help="ConceptTemplate, ConceptRoot, or ConceptRoot/Concept name",
47+
)
48+
extract_parser.add_argument("output", help="Path to the output mvdXML document")
49+
50+
combine_parser = subparsers.add_parser(
51+
"combine",
52+
help="Combine mvdXML documents",
53+
)
54+
combine_parser.add_argument(
55+
"mvdxml",
56+
nargs="+",
57+
help="Paths to the source mvdXML documents",
58+
)
59+
combine_parser.add_argument("output", help="Path to the output mvdXML document")
2160
return parser
2261

2362

@@ -52,16 +91,78 @@ def execute_sparql(parsed_root: concept_root, mvdxml: str, ifcowl: str) -> None:
5291
print(sparql.executor.run(parsed_root, mvdxml, inferred_ifcowl), end="")
5392

5493

94+
def _parse_roots(mvdxml: str) -> list[concept_root]:
95+
roots = [item for item in parse(mvdxml) if isinstance(item, concept_root)]
96+
if not roots:
97+
raise ValueError(f"mvdXML document {mvdxml!r} contains no ConceptRoot")
98+
return roots
99+
100+
101+
def validate_mvd(mvdxml: str, ifc: str) -> bool:
102+
import ifcopenshell
103+
104+
ifc_file = ifcopenshell.open(ifc)
105+
all_valid = True
106+
107+
for root in _parse_roots(mvdxml):
108+
entities = list(ifc_file.by_type(root.entity))
109+
applicability = root.parsed_applicability
110+
if applicability is not None:
111+
entities = [
112+
entity
113+
for entity in entities
114+
if applicability.validate(applicability.template().extract(entity))[0]
115+
]
116+
117+
verification = {entity.GlobalId: {} for entity in entities}
118+
for entity in entities:
119+
for concept in root.concepts():
120+
valid, _ = concept.validate(concept.template().extract(entity))
121+
verification[entity.GlobalId][concept.name] = int(not valid)
122+
123+
non_respecting = root.get_non_respecting_entities(ifc_file, verification)
124+
all_valid = all_valid and not non_respecting
125+
print(
126+
f"{root.name}: {len(non_respecting)} of "
127+
f"{len(entities)} applicable entities failed"
128+
)
129+
for entity in non_respecting:
130+
failed_concepts = [
131+
name for name, failed in verification[entity.GlobalId].items() if failed
132+
]
133+
print(f" {entity.GlobalId}: {', '.join(failed_concepts)}")
134+
135+
return all_valid
136+
137+
55138
def main(argv: Sequence[str] | None = None) -> int:
56139
args = build_parser().parse_args(argv)
57-
if args.ifcowl is None:
140+
if args.command == "inspect":
58141
inspect_mvd(args.mvdxml)
59142
return 0
143+
if args.command == "validate":
144+
return int(not validate_mvd(args.mvdxml, args.ifc))
145+
if args.command == "extract":
146+
serialize(
147+
filter_mvd(
148+
parse(args.mvdxml, include_templates=True),
149+
args.selection,
150+
),
151+
args.output,
152+
)
153+
return 0
154+
if args.command == "combine":
155+
serialize(
156+
(
157+
item
158+
for filename in args.mvdxml
159+
for item in parse(filename, include_templates=True)
160+
),
161+
args.output,
162+
)
163+
return 0
60164

61-
roots = [item for item in parse(args.mvdxml) if isinstance(item, concept_root)]
62-
if not roots:
63-
raise ValueError(f"mvdXML document {args.mvdxml!r} contains no ConceptRoot")
64-
for root in roots:
165+
for root in _parse_roots(args.mvdxml):
65166
execute_sparql(root, args.mvdxml, args.ifcowl)
66167
return 0
67168

‎model.py‎

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import io
77
import itertools
88
import operator
9-
from dataclasses import dataclass
9+
from dataclasses import dataclass, replace
1010
from functools import partial, reduce
1111
from typing import Any, Callable, Iterable, Iterator, Mapping
1212

@@ -132,15 +132,22 @@ def extract(self, ifc_data: ifcopenshell.entity_instance | Any) -> extracted_dat
132132
try:
133133
values_from_attribute = getattr(ifc_data, self.attribute)
134134
except (AttributeError, TypeError):
135-
return [{self: "Invalid attribute rule"}]
135+
return []
136136

137-
if values_from_attribute is None:
138-
return [{self: "Nonexistent value"}]
137+
if values_from_attribute is None or (
138+
isinstance(values_from_attribute, (list, tuple))
139+
and len(values_from_attribute) == 0
140+
):
141+
return []
139142

140143
if isinstance(values_from_attribute, (list, tuple)):
141-
if not values_from_attribute:
142-
return [{self: "empty data structure"}]
143-
values = values_from_attribute
144+
if any(isinstance(v, (list, tuple)) for v in values_from_attribute):
145+
# currently untested: nested lists (bspline surface points)
146+
values = type(values_from_attribute)(
147+
itertools.chain.from_iterable(values_from_attribute)
148+
)
149+
else:
150+
values = values_from_attribute
144151
else:
145152
values = (values_from_attribute,)
146153

@@ -186,6 +193,7 @@ class template:
186193
name: str | None
187194
rules: tuple[rule, ...] = ()
188195
constraints: tuple[Any, ...] = ()
196+
uuid: str | None = None
189197

190198
def __post_init__(self) -> None:
191199
object.__setattr__(self, "rules", tuple(self.rules))
@@ -230,7 +238,7 @@ def visit(
230238

231239
def root_rule(self) -> rule:
232240
if not self.rules:
233-
raise ValueError(f"Template {self.name or self.entity!r} contains no rules")
241+
return rule("EntityRule", self.entity)
234242
if len(self.rules) == 1:
235243
return self.rules[0]
236244
return rule("EntityRule", self.entity, self.rules)
@@ -405,3 +413,54 @@ def get_respecting_entities(
405413
for global_id, values in verification.items()
406414
if sum(values.values()) == 0
407415
]
416+
417+
418+
mvd_item = concept_root | template
419+
420+
421+
def _without_empty_applicability(root: concept_root) -> concept_root:
422+
applicability = root.parsed_applicability
423+
if applicability is not None and not applicability.template().rules:
424+
return replace(root, parsed_applicability=None)
425+
return root
426+
427+
428+
def filter(items: Iterable[mvd_item], selector: str) -> tuple[mvd_item, ...]:
429+
"""Select a template, root, or ``root/concept`` from parsed mvdXML items."""
430+
431+
parsed = tuple(items)
432+
if "/" in selector:
433+
root_name, concept_name = selector.split("/", 1)
434+
roots = [
435+
item
436+
for item in parsed
437+
if isinstance(item, concept_root) and item.name == root_name
438+
]
439+
matches = [
440+
(root, concept)
441+
for root in roots
442+
for concept in root.concepts()
443+
if concept.name == concept_name
444+
]
445+
if len(matches) != 1:
446+
raise ValueError(
447+
f"Expected one Concept named {selector!r}, found {len(matches)}"
448+
)
449+
root, concept = matches[0]
450+
return (
451+
replace(_without_empty_applicability(root), parsed_concepts=(concept,)),
452+
)
453+
454+
templates: dict[str | int, template] = {}
455+
for item in parsed:
456+
if isinstance(item, template) and item.name == selector:
457+
templates.setdefault(item.uuid.casefold() if item.uuid else id(item), item)
458+
roots = [
459+
_without_empty_applicability(item)
460+
for item in parsed
461+
if isinstance(item, concept_root) and item.name == selector
462+
]
463+
matches: list[mvd_item] = [*templates.values(), *roots]
464+
if len(matches) != 1:
465+
raise ValueError(f"Expected one item named {selector!r}, found {len(matches)}")
466+
return (matches[0],)

0 commit comments

Comments
 (0)