Files
IfcOpenShell/src/bcf/bcf/v2/bcfxml.py
T
2025-12-19 18:53:14 +05:00

321 lines
12 KiB
Python

"""BCF XML V2 handler."""
from __future__ import annotations
import uuid
import warnings
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
T = TypeVar("T")
class BcfXml:
"""BCF XML handler."""
def __init__(
self, filename: Optional[Path] = None, xml_handler: Optional[AbstractXmlParserSerializer] = None
) -> None:
self._filename = filename
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()
def __enter__(self) -> BcfXml:
return self
def __exit__(self, *args: Any) -> None:
self.close()
def __del__(self) -> None:
self.close()
def close(self) -> None:
if self._zip_file:
self._zip_file.close()
def _load_zip_file(self) -> Optional[zipfile.ZipFile]:
return zipfile.ZipFile(self._filename) if self._filename else None
@property
def version(self) -> mdl.Version:
"""Bcf Version."""
if not self._version:
self._version = (
self._xml_handler.parse(self._zip_file.read("bcf.version"), mdl.Version)
if self._zip_file
else mdl.Version(version_id="2.1")
)
return self._version
@version.setter
def version(self, value: mdl.Version) -> None:
self._version = value
@property
def project_info(self) -> Optional[mdl.ProjectExtension]:
"""BCF project information."""
if not self._project_info and self._zip_file and zipfile.Path(self._zip_file, "project.bcfp").exists():
self._project_info = self._xml_handler.parse(self._zip_file.read("project.bcfp"), mdl.ProjectExtension)
return self._project_info
@project_info.setter
def project_info(self, value: Optional[mdl.ProjectExtension]) -> None:
self._project_info = value
@property
def project(self) -> Optional[mdl.Project]:
"""BCF project."""
return self.project_info.project if self.project_info else None
@property
def extension_schema(self) -> Optional[bytes]:
if not self._extension_schema and self._zip_file and self.project_info and self.project_info.extension_schema:
self._extension_schema = self._zip_file.read(self.project_info.extension_schema)
return self._extension_schema
@extension_schema.setter
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."""
if self._topics is None:
self._topics = self._load_topics()
return self._topics
def _load_topics(self) -> dict[str, TopicHandler]:
topics = {}
if self._zip_file is None:
return topics
for topic_dir in zipfile.Path(self._zip_file).iterdir():
if not topic_dir.is_dir():
continue
markup_path = topic_dir.joinpath("markup.bcf")
if not markup_path.exists():
continue
topics[topic_dir.name] = TopicHandler(topic_dir, self._xml_handler)
return topics
@classmethod
def load(cls, filename: Path, xml_handler: Optional[AbstractXmlParserSerializer] = None) -> Optional[BcfXml]:
"""
Create a BcfXml object from a file.
Args:
filename: Path to the file.
xml_handler: XML parser and serializer.
Returns:
A BcfXml object with the file contents.
Raises:
ValueError: If the file name is null or empty
"""
if not filename:
raise ValueError("filename is required")
xml_handler = xml_handler or XmlParserSerializer()
return cls(xml_handler=xml_handler, filename=filename)
@classmethod
def create_new(
cls,
project_name: Optional[str] = None,
xml_handler: Optional[AbstractXmlParserSerializer] = None,
) -> BcfXml:
"""
Create a new BcfXml object.
Args:
project_name: The name of the project.
xml_handler: XML parser and serializer.
Returns:
A new BcfXml object.
"""
instance = cls(xml_handler=xml_handler or XmlParserSerializer())
instance.project_info = mdl.ProjectExtension(
project=mdl.Project(name=project_name, project_id=str(uuid.uuid4())), extension_schema=""
)
return instance
def save(self, filename: Optional[Path] = None, keep_open: bool = False) -> None:
"""Save the BCF file to the given filename."""
if not filename and not self._filename:
raise ValueError("No file name specified, cannot save BCF file.")
if filename:
self._filename = filename
with InMemoryZipFile(self._filename) as bcf_zip:
self._save_project(bcf_zip)
self._save_version(bcf_zip)
self._save_topics(bcf_zip)
if keep_open:
self._zip_file = self._load_zip_file()
def _save_project(self, destination_zip: ZipFileInterface) -> None:
self._smart_save_xml(destination_zip, self._project_info, "project.bcfp")
if self.extension_schema and self.project_info:
destination_zip.writestr(self.project_info.extension_schema, self.extension_schema)
def _save_version(self, destination_zip: ZipFileInterface) -> None:
if not self._version and self._zip_file:
destination_zip.writestr("bcf.version", self._zip_file.read("bcf.version"))
else:
self._save_xml(destination_zip, "bcf.version", self.version)
def _smart_save_xml(self, destination_zip: ZipFileInterface, item: Any, target: str) -> None:
if item:
self._save_xml(destination_zip, target, item)
elif self._zip_file and zipfile.Path(self._zip_file, target).exists():
destination_zip.writestr(target, self._zip_file.read(target))
def _save_xml(self, destination_zip: ZipFileInterface, inner_file: str, xml_obj: Any) -> None:
destination_zip.writestr(inner_file, self._xml_handler.serialize(xml_obj))
def _save_topics(self, destination_zip: ZipFileInterface) -> None:
for topic_handler in self.topics.values():
topic_handler.save(destination_zip)
def add_topic(
self, title: str, description: str, author: str, topic_type: str = "", topic_status: str = ""
) -> TopicHandler:
"""
Add a new topic to the BCF.
Args:
title: The title of the topic.
description: The description of the topic.
author: The author of the topic.
topic_type: The type of the topic.
topic_status: The status of the topic.
Returns:
The newly created topic wrapped inside a TopicHandler object.
"""
topic_handler = TopicHandler.create_new(
title,
description,
author,
topic_type=topic_type,
topic_status=topic_status,
xml_handler=self._xml_handler,
)
self.topics[topic_handler.guid] = topic_handler
return topic_handler
def __eq__(self, other: object) -> bool | NoReturn:
return (
self.version == other.version and self.project_info == other.project_info
if isinstance(other, BcfXml)
else NotImplemented
)
# region Deprecated methods
def new_project(self) -> BcfXml:
"""Deprecated method."""
warnings.warn("new_project is deprecated, use create_new instead.", DeprecationWarning)
return self.create_new()
def get_project(self, _filepath: Optional[str] = None) -> Optional[mdl.Project]:
"""Deprecated method."""
warnings.warn("get_project is deprecated, use project_info.project instead.", DeprecationWarning)
return self.project
def edit_project(self) -> None:
"""Deprecated method."""
warnings.warn("edit_project is deprecated, there's no need to use it.", DeprecationWarning)
def save_project(self, filepath: Path) -> None:
"""Deprecated method."""
warnings.warn("save_project is deprecated, use save instead.", DeprecationWarning)
self.save(filepath)
def get_version(self) -> Optional[str]:
warnings.warn("get_version is deprecated, use version.version_id instead.", DeprecationWarning)
return self.version.version_id
def edit_version(self) -> None:
"""Deprecated method."""
warnings.warn("edit_version is deprecated, there's no need to use it.", DeprecationWarning)
def get_topics(self) -> dict[str, TopicHandler]:
"""Deprecated method."""
warnings.warn("get_topics is deprecated, use topics instead.", DeprecationWarning)
return self.topics
def get_topic(self, guid: str) -> TopicHandler:
"""Return a topic by its GUID."""
warnings.warn("get_topic is deprecated, use topics[guid] instead", DeprecationWarning)
return self.topics[guid]
def get_header(self, guid: str) -> Optional[mdl.Header]:
"""Return the header of a Topic by its GUID."""
return self.topics[guid].header
def edit_topic(self) -> None:
"""Deprecated method."""
warnings.warn("edit_topic is deprecated, there's no need to use it.", DeprecationWarning)
def add_comment(self, _topic: mdl.Topic, _comment: Optional[mdl.Comment] = None) -> None:
"""Deprecated method."""
warnings.warn("add_comment is deprecated, use topics methods instead.", DeprecationWarning)
def edit_comment(self) -> None:
"""Deprecated method."""
warnings.warn("edit_comment is deprecated, there's no need to use it.", DeprecationWarning)
# TODO: deprecate other methods
# endregion