mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
bcf: add spreadsheet report exporter for topics
Closes the long-standing wishlist in IfcOpenShell/IfcOpenShell#950: export BCF topics to CSV/XLSX/ODS for reporting, similar to BIMcollab or Solibri issue reports. Works across both BCF v2 and v3 files by going through the existing version-agnostic TopicHandler API. AI-generated: this commit's changes were produced with the assistance of an AI coding tool.
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
# BCF - BCF Python library
|
||||
# Copyright (C) 2026 IfcOpenShell contributors
|
||||
#
|
||||
# This file is part of BCF.
|
||||
#
|
||||
# BCF is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# BCF is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with BCF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Export BCF topics to a spreadsheet (CSV, XLSX, or ODS) for reporting.
|
||||
|
||||
This mirrors the format handling conventions of the IfcCSV module, but is
|
||||
kept dependency-free for CSV (the ``xlsx``/``ods`` formats require the
|
||||
optional ``openpyxl``/``odfpy`` packages, same as IfcCSV).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Optional, Union
|
||||
|
||||
import bcf.v2.bcfxml
|
||||
import bcf.v2.topic
|
||||
import bcf.v3.bcfxml
|
||||
import bcf.v3.topic
|
||||
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
openpyxl = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from odf.opendocument import OpenDocumentSpreadsheet
|
||||
from odf.table import Table, TableCell, TableRow
|
||||
from odf.text import P
|
||||
except ImportError:
|
||||
OpenDocumentSpreadsheet = None # type: ignore[assignment, misc]
|
||||
|
||||
|
||||
BcfXml = Union[bcf.v2.bcfxml.BcfXml, bcf.v3.bcfxml.BcfXml]
|
||||
TopicHandler = Union[bcf.v2.topic.TopicHandler, bcf.v3.topic.TopicHandler]
|
||||
|
||||
FILE_FORMAT = Literal["csv", "xlsx", "ods"]
|
||||
|
||||
HEADERS = [
|
||||
"Index",
|
||||
"GUID",
|
||||
"Title",
|
||||
"Type",
|
||||
"Status",
|
||||
"Priority",
|
||||
"Stage",
|
||||
"Assigned To",
|
||||
"Author",
|
||||
"Creation Date",
|
||||
"Modified Author",
|
||||
"Modified Date",
|
||||
"Due Date",
|
||||
"Labels",
|
||||
"Description",
|
||||
"Comments",
|
||||
]
|
||||
|
||||
|
||||
class Reporter:
|
||||
"""Export the topics of a BCF file to a spreadsheet.
|
||||
|
||||
Each row represents one topic (BCF "issue"), similar to the reports
|
||||
produced by tools such as BIMcollab or Solibri. Comments are
|
||||
concatenated into a single cell per topic.
|
||||
"""
|
||||
|
||||
headers: list[str]
|
||||
rows: list[list[Any]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.headers = list(HEADERS)
|
||||
self.rows = []
|
||||
|
||||
def export(
|
||||
self,
|
||||
bcfxml: BcfXml,
|
||||
output: Optional[Union[str, Path]] = None,
|
||||
format: Optional[FILE_FORMAT] = None,
|
||||
delimiter: str = ",",
|
||||
) -> list[list[Any]]:
|
||||
"""Build the report rows, optionally writing them to ``output``.
|
||||
|
||||
:param bcfxml: A loaded BCF v2 or v3 file.
|
||||
:param output: If provided, the report is written to this path.
|
||||
:param format: One of "csv", "xlsx", or "ods". If not provided, it
|
||||
is guessed from the ``output`` file extension, defaulting to
|
||||
"csv".
|
||||
:param delimiter: The field delimiter to use for CSV output.
|
||||
:return: The report rows (excluding the header row).
|
||||
"""
|
||||
self.rows = [self.get_topic_row(topic_handler) for topic_handler in bcfxml.topics.values()]
|
||||
|
||||
if output:
|
||||
format = format or (Path(output).suffix.lstrip(".").lower() or "csv")
|
||||
if format == "csv":
|
||||
self.export_csv(output, delimiter=delimiter)
|
||||
elif format == "xlsx":
|
||||
self.export_xlsx(output)
|
||||
elif format == "ods":
|
||||
self.export_ods(output)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: '{format}'. Use 'csv', 'xlsx', or 'ods'.")
|
||||
|
||||
return self.rows
|
||||
|
||||
def get_topic_row(self, topic_handler: TopicHandler) -> list[Any]:
|
||||
"""Build a single report row from a topic."""
|
||||
topic = topic_handler.topic
|
||||
|
||||
labels = topic.labels
|
||||
if labels is not None and not isinstance(labels, list):
|
||||
# BCF v3 wraps labels in a TopicLabels element, unlike v2's plain list.
|
||||
labels = labels.label
|
||||
|
||||
comments = "; ".join(comment.comment for comment in topic_handler.comments if comment.comment)
|
||||
|
||||
return [
|
||||
topic.index,
|
||||
topic.guid,
|
||||
topic.title,
|
||||
topic.topic_type,
|
||||
topic.topic_status,
|
||||
topic.priority,
|
||||
topic.stage,
|
||||
topic.assigned_to,
|
||||
topic.creation_author,
|
||||
self.format_date(topic.creation_date),
|
||||
topic.modified_author,
|
||||
self.format_date(topic.modified_date),
|
||||
self.format_date(topic.due_date),
|
||||
", ".join(labels) if labels else None,
|
||||
topic.description,
|
||||
comments,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def format_date(value: Any) -> Optional[str]:
|
||||
return str(value) if value is not None else None
|
||||
|
||||
def export_csv(self, output: Union[str, Path], delimiter: str = ",") -> None:
|
||||
with open(output, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f, delimiter=delimiter)
|
||||
writer.writerow(self.headers)
|
||||
writer.writerows(self.rows)
|
||||
|
||||
def export_xlsx(self, output: Union[str, Path]) -> None:
|
||||
if not openpyxl:
|
||||
raise ImportError("openpyxl is required to export to XLSX. Install it with `pip install openpyxl`.")
|
||||
workbook = openpyxl.Workbook()
|
||||
worksheet = workbook.active
|
||||
worksheet.append(self.headers)
|
||||
for row in self.rows:
|
||||
worksheet.append(row)
|
||||
workbook.save(output)
|
||||
|
||||
def export_ods(self, output: Union[str, Path]) -> None:
|
||||
if not OpenDocumentSpreadsheet:
|
||||
raise ImportError("odfpy is required to export to ODS. Install it with `pip install odfpy`.")
|
||||
document = OpenDocumentSpreadsheet()
|
||||
table = Table(name="BCF Topics")
|
||||
table.addElement(self._build_ods_row(self.headers))
|
||||
for row in self.rows:
|
||||
table.addElement(self._build_ods_row(row))
|
||||
document.spreadsheet.addElement(table)
|
||||
document.save(output)
|
||||
|
||||
@staticmethod
|
||||
def _build_ods_row(values: list[Any]) -> "TableRow":
|
||||
row = TableRow()
|
||||
for value in values:
|
||||
cell = TableCell()
|
||||
cell.addElement(P(text="" if value is None else str(value)))
|
||||
row.addElement(cell)
|
||||
return row
|
||||
@@ -0,0 +1,156 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Tests for bcf.agnostic.reporter, exercising both BCF v2 and v3 files."""
|
||||
|
||||
import csv
|
||||
import datetime
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
from xsdata.models.datatype import XmlDateTime
|
||||
|
||||
import bcf.v2.model as mdl2
|
||||
import bcf.v3.model as mdl3
|
||||
from bcf.agnostic.reporter import HEADERS, Reporter
|
||||
from bcf.v2.bcfxml import BcfXml as BcfXmlV2
|
||||
from bcf.v3.bcfxml import BcfXml as BcfXmlV3
|
||||
from bcf.xml_parser import XmlParserSerializer
|
||||
|
||||
|
||||
def build_bcf_v2(xml_handler: XmlParserSerializer) -> BcfXmlV2:
|
||||
bcf = BcfXmlV2.create_new("Test project", xml_handler=xml_handler)
|
||||
topic_handler = bcf.add_topic("Leaking pipe", "The pipe is leaking.", "alice@example.com", "Clash")
|
||||
topic = topic_handler.topic
|
||||
topic.topic_status = "Open"
|
||||
topic.priority = "High"
|
||||
topic.index = 3
|
||||
topic.labels = ["plumbing", "urgent"]
|
||||
topic.assigned_to = "bob@example.com"
|
||||
|
||||
topic_handler.comments = [
|
||||
mdl2.Comment(
|
||||
date=XmlDateTime.from_datetime(datetime.datetime(2026, 1, 1)),
|
||||
author="alice@example.com",
|
||||
comment="Please fix ASAP.",
|
||||
guid=str(uuid.uuid4()),
|
||||
),
|
||||
mdl2.Comment(
|
||||
date=XmlDateTime.from_datetime(datetime.datetime(2026, 1, 2)),
|
||||
author="bob@example.com",
|
||||
comment="Scheduled for tomorrow.",
|
||||
guid=str(uuid.uuid4()),
|
||||
),
|
||||
]
|
||||
return bcf
|
||||
|
||||
|
||||
def build_bcf_v3(xml_handler: XmlParserSerializer) -> BcfXmlV3:
|
||||
bcf = BcfXmlV3.create_new("Test project", xml_handler=xml_handler)
|
||||
topic_handler = bcf.add_topic("Leaking pipe", "The pipe is leaking.", "alice@example.com", "Clash")
|
||||
topic = topic_handler.topic
|
||||
topic.topic_status = "Open"
|
||||
topic.priority = "High"
|
||||
topic.index = 3
|
||||
topic.labels = mdl3.TopicLabels(label=["plumbing", "urgent"])
|
||||
topic.assigned_to = "bob@example.com"
|
||||
|
||||
topic_handler.comments = [
|
||||
mdl3.Comment(
|
||||
date=XmlDateTime.from_datetime(datetime.datetime(2026, 1, 1)),
|
||||
author="alice@example.com",
|
||||
comment="Please fix ASAP.",
|
||||
guid=str(uuid.uuid4()),
|
||||
),
|
||||
mdl3.Comment(
|
||||
date=XmlDateTime.from_datetime(datetime.datetime(2026, 1, 2)),
|
||||
author="bob@example.com",
|
||||
comment="Scheduled for tomorrow.",
|
||||
guid=str(uuid.uuid4()),
|
||||
),
|
||||
]
|
||||
return bcf
|
||||
|
||||
|
||||
BUILDERS = [build_bcf_v2, build_bcf_v3]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("build_bcf", BUILDERS)
|
||||
def test_export_builds_one_row_per_topic(xml_handler, build_bcf):
|
||||
bcf = build_bcf(xml_handler)
|
||||
reporter = Reporter()
|
||||
rows = reporter.export(bcf)
|
||||
|
||||
assert len(rows) == 1
|
||||
row = dict(zip(HEADERS, rows[0]))
|
||||
assert row["Title"] == "Leaking pipe"
|
||||
assert row["Type"] == "Clash"
|
||||
assert row["Status"] == "Open"
|
||||
assert row["Priority"] == "High"
|
||||
assert row["Index"] == 3
|
||||
assert row["Assigned To"] == "bob@example.com"
|
||||
assert row["Author"] == "alice@example.com"
|
||||
assert row["Description"] == "The pipe is leaking."
|
||||
assert row["Labels"] == "plumbing, urgent"
|
||||
assert row["Comments"] == "Please fix ASAP.; Scheduled for tomorrow."
|
||||
|
||||
|
||||
@pytest.mark.parametrize("build_bcf", BUILDERS)
|
||||
def test_export_writes_csv(xml_handler, build_bcf):
|
||||
bcf = build_bcf(xml_handler)
|
||||
reporter = Reporter()
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
output = Path(tmp_dir) / "report.csv"
|
||||
reporter.export(bcf, output=output)
|
||||
|
||||
with open(output, newline="", encoding="utf-8") as f:
|
||||
rows = list(csv.reader(f))
|
||||
|
||||
assert rows[0] == HEADERS
|
||||
assert rows[1][HEADERS.index("Title")] == "Leaking pipe"
|
||||
assert rows[1][HEADERS.index("Comments")] == "Please fix ASAP.; Scheduled for tomorrow."
|
||||
|
||||
|
||||
def test_export_format_is_inferred_from_output_suffix(xml_handler):
|
||||
bcf = build_bcf_v2(xml_handler)
|
||||
reporter = Reporter()
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
output = Path(tmp_dir) / "report.csv"
|
||||
reporter.export(bcf, output=output)
|
||||
assert output.exists()
|
||||
|
||||
|
||||
def test_export_rejects_unsupported_format(xml_handler):
|
||||
bcf = build_bcf_v2(xml_handler)
|
||||
reporter = Reporter()
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
output = Path(tmp_dir) / "report.pdf"
|
||||
with pytest.raises(ValueError):
|
||||
reporter.export(bcf, output=output)
|
||||
|
||||
|
||||
def test_export_xlsx(xml_handler):
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
bcf = build_bcf_v2(xml_handler)
|
||||
reporter = Reporter()
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
output = Path(tmp_dir) / "report.xlsx"
|
||||
reporter.export(bcf, output=output)
|
||||
|
||||
workbook = openpyxl.load_workbook(output)
|
||||
worksheet = workbook.active
|
||||
rows = list(worksheet.iter_rows(values_only=True))
|
||||
assert rows[0] == tuple(HEADERS)
|
||||
assert rows[1][HEADERS.index("Title")] == "Leaking pipe"
|
||||
|
||||
|
||||
def test_export_ods(xml_handler):
|
||||
pytest.importorskip("odf")
|
||||
bcf = build_bcf_v2(xml_handler)
|
||||
reporter = Reporter()
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
output = Path(tmp_dir) / "report.ods"
|
||||
reporter.export(bcf, output=output)
|
||||
assert output.exists()
|
||||
assert output.stat().st_size > 0
|
||||
Reference in New Issue
Block a user