From 1e71708aba81f5802df7df016b46d71bfeb8577b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 13 Aug 2024 16:03:31 +0500 Subject: [PATCH] bcf v2 - extensions support #3220 The api is the same as for bcf v3 though for bcf v2 they are currently available only in read-only mode. --- src/bcf/bcf/agnostic/extensions.py | 28 ++++ src/bcf/bcf/extensions.py | 0 src/bcf/bcf/v2/bcfxml.py | 42 ++++++ src/bcf/bcf/v2/model/extensions.py | 197 +++++++++++++++++++++++++ src/bcf/tests/v2/test_example_files.py | 23 +++ 5 files changed, 290 insertions(+) create mode 100644 src/bcf/bcf/agnostic/extensions.py create mode 100644 src/bcf/bcf/extensions.py create mode 100644 src/bcf/bcf/v2/model/extensions.py diff --git a/src/bcf/bcf/agnostic/extensions.py b/src/bcf/bcf/agnostic/extensions.py new file mode 100644 index 0000000000..c5d4a7f556 --- /dev/null +++ b/src/bcf/bcf/agnostic/extensions.py @@ -0,0 +1,28 @@ +import bcf.v2.model.extensions +import bcf.v3.model.extensions +from typing import NamedTuple, Union +from dataclasses import fields + + +class AttributeData(NamedTuple): + attr_type: type + subattr_name: str + subattr_xsd_name: str + + +Extensions = Union[bcf.v2.model.extensions.Extensions, bcf.v3.model.extensions.Extensions] + + +def get_extensions_attributes(extensions: Extensions) -> dict[str, AttributeData]: + """Return mapping of xsd attribute name to a tuple that consists of: + - Extensions attribute name + - Extensions attribute type type + - subattribute name""" + possible_attributes = {} + for field in fields(type(extensions)): + field_type = field.type.__args__[0] # type: ignore [reportAttributeAccessIssue] + subfield = next(iter(fields(field_type))) + xsd_name = subfield.metadata["name"] + possible_attributes[field.name] = AttributeData(field_type, subfield.name, xsd_name) + + return possible_attributes diff --git a/src/bcf/bcf/extensions.py b/src/bcf/bcf/extensions.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bcf/bcf/v2/bcfxml.py b/src/bcf/bcf/v2/bcfxml.py index c8782ac9ba..b15199f4dc 100644 --- a/src/bcf/bcf/v2/bcfxml.py +++ b/src/bcf/bcf/v2/bcfxml.py @@ -6,7 +6,9 @@ import zipfile from pathlib import Path from typing import Any, NoReturn, Optional, TypeVar +import bcf.agnostic.extensions import bcf.v2.model as mdl +import bcf.v2.model.extensions as mdl_extensions from bcf.inmemory_zipfile import InMemoryZipFile, ZipFileInterface from bcf.v2.topic import TopicHandler from bcf.xml_parser import AbstractXmlParserSerializer, XmlParserSerializer @@ -24,6 +26,7 @@ class BcfXml: self._xml_handler = xml_handler or XmlParserSerializer() self._version: Optional[mdl.Version] = None self._project_info: Optional[mdl.ProjectExtension] = None + self._extensions: Optional[mdl_extensions.Extensions] = None self._topics: Optional[dict[str, TopicHandler]] = None self._extension_schema: Optional[bytes] = None self._zip_file = self._load_zip_file() @@ -85,6 +88,45 @@ class BcfXml: def extension_schema(self, value: bytes) -> None: self._extension_schema = value + @property + def extensions(self) -> Optional[mdl_extensions.Extensions]: + """BCF extensions.""" + + if not self._extensions and self.extension_schema: + import io + from xml.etree import ElementTree as etree + + extensions = mdl_extensions.Extensions() + + xs = "{http://www.w3.org/2001/XMLSchema}" + root = etree.parse(io.BytesIO((self.extension_schema))) + + attrs = bcf.agnostic.extensions.get_extensions_attributes(extensions) + xsd_to_attrs = {v.subattr_xsd_name: k for k, v in attrs.items()} + for node in root.findall(f".//{xs}restriction"): + attr_type = node.attrib.get("base") + if not attr_type: + continue + attr_name = xsd_to_attrs.get(attr_type) + if attr_name is None: + continue + values = [] + for enum in node.findall(f".//{xs}enumeration"): + values.append(enum.attrib["value"]) + if not values: + continue + attr_data = attrs[attr_name] + attr = attr_data.attr_type() + setattr(attr, attr_data.subattr_name, values) + setattr(extensions, attr_name, attr) + + self._extensions = extensions + return self._extensions + + @extensions.setter + def extensions(self, value: Optional[mdl_extensions.Extensions]) -> None: + self._extensions = value + @property def topics(self) -> dict[str, TopicHandler]: """BCF topics.""" diff --git a/src/bcf/bcf/v2/model/extensions.py b/src/bcf/bcf/v2/model/extensions.py new file mode 100644 index 0000000000..fb25a47c64 --- /dev/null +++ b/src/bcf/bcf/v2/model/extensions.py @@ -0,0 +1,197 @@ +# NOTE: This file is not generated from +# https://github.com/buildingSMART/BCF-XML/blob/release_2_1/Extension%20Schemas/extensions.xsd +# because in bcf 2.1 there is no extensions.xml - I guess, the schema assumes that each .bcf +# will use their own schema patched by it's own extensions.xsd. +# +# To make things simpler we just mimic extensions structures from bcf 3, so they'll have common API, +# and parse extensions.xsd inside .bcf as .xml and fill our structures. +# +# Preferably if we could generate some kind of extensions.xml from extensions.xsd +# and leave all the handling to xsdata. +# +# We also don't add it to __init__.py not to mess with generator. +# +# Currently extensions support for v2 is only read-only. + + +from dataclasses import dataclass, field, fields +from typing import List, NamedTuple, Optional + + +@dataclass(slots=True, kw_only=True) +class ExtensionsPriorities: + class Meta: + global_type = False + + priority: List[str] = field( + default_factory=list, + metadata={ + "name": "Priority", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + }, + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsSnippetTypes: + class Meta: + global_type = False + + snippet_type: List[str] = field( + default_factory=list, + metadata={ + "name": "SnippetType", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + }, + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsStages: + class Meta: + global_type = False + + stage: List[str] = field( + default_factory=list, + metadata={ + "name": "Stage", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + }, + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsTopicLabels: + class Meta: + global_type = False + + topic_label: List[str] = field( + default_factory=list, + metadata={ + "name": "TopicLabel", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + }, + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsTopicStatuses: + class Meta: + global_type = False + + topic_status: List[str] = field( + default_factory=list, + metadata={ + "name": "TopicStatus", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + }, + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsTopicTypes: + class Meta: + global_type = False + + topic_type: List[str] = field( + default_factory=list, + metadata={ + "name": "TopicType", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + }, + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsUsers: + class Meta: + global_type = False + + user: List[str] = field( + default_factory=list, + metadata={ + "name": "UserIdType", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + }, + ) + + +@dataclass(slots=True, kw_only=True) +class Extensions: + topic_types: Optional[ExtensionsTopicTypes] = field( + default=None, + metadata={ + "name": "TopicTypes", + "type": "Element", + "namespace": "", + }, + ) + topic_statuses: Optional[ExtensionsTopicStatuses] = field( + default=None, + metadata={ + "name": "TopicStatuses", + "type": "Element", + "namespace": "", + }, + ) + priorities: Optional[ExtensionsPriorities] = field( + default=None, + metadata={ + "name": "Priorities", + "type": "Element", + "namespace": "", + }, + ) + topic_labels: Optional[ExtensionsTopicLabels] = field( + default=None, + metadata={ + "name": "TopicLabels", + "type": "Element", + "namespace": "", + }, + ) + users: Optional[ExtensionsUsers] = field( + default=None, + metadata={ + "name": "Users", + "type": "Element", + "namespace": "", + }, + ) + snippet_types: Optional[ExtensionsSnippetTypes] = field( + default=None, + metadata={ + "name": "SnippetTypes", + "type": "Element", + "namespace": "", + }, + ) + stages: Optional[ExtensionsStages] = field( + default=None, + metadata={ + "name": "Stages", + "type": "Element", + "namespace": "", + }, + ) diff --git a/src/bcf/tests/v2/test_example_files.py b/src/bcf/tests/v2/test_example_files.py index 09aa9e4115..3821569c28 100644 --- a/src/bcf/tests/v2/test_example_files.py +++ b/src/bcf/tests/v2/test_example_files.py @@ -55,9 +55,32 @@ def test_save_maximum_information() -> None: def assert_everything_in_place(bcf: BcfXml): assert bcf.version.version_id == "2.1" + assert bcf.project assert bcf.project.name == "BCF API Implementation" + assert bcf.project_info assert bcf.project_info.extension_schema == "extensions.xsd" + assert bcf.extensions + assert bcf.extensions.topic_types + assert bcf.extensions.topic_types.topic_type == ["Architecture", "Hidden Type", "Structural"] + assert bcf.extensions.topic_statuses + assert bcf.extensions.topic_statuses.topic_status == ["Finished status", "Open", "Closed"] + assert bcf.extensions.priorities + assert bcf.extensions.priorities.priority == ["Low", "High", "Medium"] + assert bcf.extensions.topic_labels + assert bcf.extensions.topic_labels.topic_label == [ + "Architecture", + "IT Development", + "Management", + "Mechanical", + "Structural", + ] + assert bcf.extensions.users + assert bcf.extensions.users.user == ["dangl@iabi.eu", "linhard@iabi.eu"] + assert bcf.extensions.snippet_types + assert bcf.extensions.snippet_types.snippet_type == ["IFC2X3", "PDF", "XLSX"] + assert bcf.extensions.stages is None + assert len(bcf.topics) == 2 assert_first_topic_handler(bcf.topics["7ddc3ef0-0ab7-43f1-918a-45e38b42369c"]) second_th = bcf.topics["d1068c81-af04-4546-b63c-348810f6c716"]