diff --git a/src/bcf/bcf/agnostic/reporter.py b/src/bcf/bcf/agnostic/reporter.py new file mode 100644 index 0000000000..986bf0327a --- /dev/null +++ b/src/bcf/bcf/agnostic/reporter.py @@ -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 . + +# 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 diff --git a/src/bcf/tests/test_reporter.py b/src/bcf/tests/test_reporter.py new file mode 100644 index 0000000000..5fe8ae73cd --- /dev/null +++ b/src/bcf/tests/test_reporter.py @@ -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