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:
Petru Conduraru
2026-07-21 21:18:51 +03:00
parent 89523999b3
commit be35a62dc7
2 changed files with 348 additions and 0 deletions
+192
View File
@@ -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
+156
View File
@@ -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