/** * Python bindings for docx_comment_parser using pybind11. * * Build command (example): * c++ -O2 -std=c++17 -fPIC -shared \ * -I../include $(python3-config --includes) \ * -I$(python3 -c "import pybind11; print(pybind11.get_include())") \ * python_bindings.cpp ../src/docx_parser.cpp ../src/batch_parser.cpp \ * ../src/zip_reader.cpp \ * -lz \ * -o docx_comment_parser$(python3-config --extension-suffix) * * Module name * ----------- * This extension is the compiled *core* of the ``docx_comment_parser`` package * and is therefore named ``_core``. End users never import it directly — the * pure-Python package ``src/docx_comment_parser/__init__.py`` re-exports every * public name from here, so the historical import path is unchanged: * * import docx_comment_parser as dcp * parser = dcp.DocxParser() * parser.parse("my_file.docx") * for c in parser.comments(): * print(c.author, c.text) */ #include #include #include #include "docx_comment_parser.h" namespace py = pybind11; using namespace docx; namespace { // Python exception types, created in the module initialiser below. The module // dict holds the owning references for the life of the interpreter, so these // are borrowed handles. // // They live at file scope because pybind11's ExceptionTranslator is a plain // function pointer: the translator must be capture-less. PyObject* g_exc_parser = nullptr; PyObject* g_exc_file = nullptr; PyObject* g_exc_format = nullptr; void translate_docx_exception(std::exception_ptr p) { try { if (p) std::rethrow_exception(p); } // Most-derived first: the first matching clause wins. Registering three // separate translators would not work, because pybind11 tries them in // reverse registration order and the base-class handler would swallow every // derived type before the specific ones were reached. catch (const DocxFileError& e) { PyErr_SetString(g_exc_file, e.what()); } catch (const DocxFormatError& e) { PyErr_SetString(g_exc_format, e.what()); } catch (const DocxParserError& e) { PyErr_SetString(g_exc_parser, e.what()); } } } // anonymous namespace PYBIND11_MODULE(_core, m) { m.doc() = R"doc( docx_comment_parser._core ========================= Compiled C++ core for extracting all comment metadata from .docx files. Import the parent package instead of this module: import docx_comment_parser as dcp Classes ------- CommentRef – lightweight reference to a related comment CommentMetadata – all data extracted for one comment DocumentCommentStats– document-level aggregate statistics DocxParser – single-file parser BatchParser – multi-file parallel parser )doc"; // ── CommentRef ──────────────────────────────────────────────────────────── py::class_(m, "CommentRef", "Lightweight reference to a related (replied-to) comment.") .def_readonly("id", &CommentRef::id, "Comment id (w:id attribute).") .def_readonly("author", &CommentRef::author, "Author name of the referenced comment.") .def_readonly("date", &CommentRef::date, "ISO-8601 date string of the referenced comment.") .def_readonly("text_snippet", &CommentRef::text_snippet, "First 120 characters of the referenced comment's text.") .def("__repr__", [](const CommentRef& r){ return ""; }); // ── CommentMetadata ─────────────────────────────────────────────────────── py::class_(m, "CommentMetadata", R"doc( All metadata extracted for a single comment (w:comment element). Attributes ---------- id : int – w:id author : str – w:author date : str – ISO-8601 date (as stored in XML) initials : str – w:initials text : str – full plain-text of comment body paragraph_style : str – style of first paragraph inside comment range_start_para_id : str – paraId of commentRangeStart (OOXML 2016+) range_end_para_id : str – paraId of commentRangeEnd (OOXML 2016+) referenced_text : str – document text anchored by this comment is_reply : bool – True if this comment is a reply parent_id : int – id of parent comment (-1 if root) replies : list[CommentRef] – direct replies (on parent) para_id : str – unique per-comment paragraph id para_id_parent : str – parent paragraph id string done : bool – resolved/done flag (OOXML 2016+) paragraph_index : int – 0-based paragraph in document body run_index : int – 0-based run within paragraph thread_ids : list[int] – ordered ids of entire thread (root only) )doc") .def_readonly("id", &CommentMetadata::id) .def_readonly("author", &CommentMetadata::author) .def_readonly("date", &CommentMetadata::date) .def_readonly("initials", &CommentMetadata::initials) .def_readonly("text", &CommentMetadata::text) .def_readonly("paragraph_style", &CommentMetadata::paragraph_style) .def_readonly("range_start_para_id", &CommentMetadata::range_start_para_id) .def_readonly("range_end_para_id", &CommentMetadata::range_end_para_id) .def_readonly("referenced_text", &CommentMetadata::referenced_text) .def_readonly("is_reply", &CommentMetadata::is_reply) .def_readonly("parent_id", &CommentMetadata::parent_id) .def_readonly("replies", &CommentMetadata::replies) .def_readonly("para_id", &CommentMetadata::para_id) .def_readonly("para_id_parent", &CommentMetadata::para_id_parent) .def_readonly("done", &CommentMetadata::done) .def_readonly("paragraph_index", &CommentMetadata::paragraph_index) .def_readonly("run_index", &CommentMetadata::run_index) .def_readonly("thread_ids", &CommentMetadata::thread_ids) .def("to_dict", [](const CommentMetadata& m) { py::dict d; d["id"] = m.id; d["author"] = m.author; d["date"] = m.date; d["initials"] = m.initials; d["text"] = m.text; d["paragraph_style"] = m.paragraph_style; d["range_start_para_id"] = m.range_start_para_id; d["range_end_para_id"] = m.range_end_para_id; d["referenced_text"] = m.referenced_text; d["is_reply"] = m.is_reply; d["parent_id"] = m.parent_id; d["para_id"] = m.para_id; d["para_id_parent"] = m.para_id_parent; d["done"] = m.done; d["paragraph_index"] = m.paragraph_index; d["run_index"] = m.run_index; d["thread_ids"] = m.thread_ids; py::list replies; for (const auto& r : m.replies) { py::dict rd; rd["id"] = r.id; rd["author"] = r.author; rd["date"] = r.date; rd["text_snippet"] = r.text_snippet; replies.append(rd); } d["replies"] = replies; return d; }, "Return all metadata as a Python dict.") .def("__repr__", [](const CommentMetadata& m){ return ""; }); // ── DocumentCommentStats ───────────────────────────────────────────────── py::class_(m, "DocumentCommentStats", R"doc( Document-level comment statistics. Attributes ---------- file_path : str total_comments : int total_resolved : int – comments with done=True total_replies : int total_root_comments : int unique_authors : list[str] earliest_date : str – ISO-8601 latest_date : str – ISO-8601 )doc") .def_readonly("file_path", &DocumentCommentStats::file_path) .def_readonly("total_comments", &DocumentCommentStats::total_comments) .def_readonly("total_resolved", &DocumentCommentStats::total_resolved) .def_readonly("total_replies", &DocumentCommentStats::total_replies) .def_readonly("total_root_comments",&DocumentCommentStats::total_root_comments) .def_readonly("unique_authors", &DocumentCommentStats::unique_authors) .def_readonly("earliest_date", &DocumentCommentStats::earliest_date) .def_readonly("latest_date", &DocumentCommentStats::latest_date) .def("to_dict", [](const DocumentCommentStats& s){ py::dict d; d["file_path"] = s.file_path; d["total_comments"] = s.total_comments; d["total_resolved"] = s.total_resolved; d["total_replies"] = s.total_replies; d["total_root_comments"] = s.total_root_comments; d["unique_authors"] = s.unique_authors; d["earliest_date"] = s.earliest_date; d["latest_date"] = s.latest_date; return d; }, "Return stats as a Python dict.") .def("__repr__", [](const DocumentCommentStats& s){ return ""; }); // ── DocxParser ──────────────────────────────────────────────────────────── py::class_(m, "DocxParser", R"doc( Single-file .docx comment parser. Example ------- >>> import docx_comment_parser as dcp >>> p = dcp.DocxParser() >>> p.parse("report.docx") >>> for c in p.comments(): ... print(c.id, c.author, c.text[:60]) )doc") .def(py::init<>()) .def("parse", &DocxParser::parse, py::arg("file_path"), R"doc( Parse a .docx file and extract all comment metadata. Parameters ---------- file_path : str Absolute or relative path to the .docx file. Raises ------ DocxFileError if the file cannot be opened. DocxFormatError if required OOXML parts are missing or malformed. )doc") .def("comments", &DocxParser::comments, py::return_value_policy::reference_internal, "Return list of all CommentMetadata objects (sorted by id).") .def("stats", &DocxParser::stats, py::return_value_policy::reference_internal, "Return DocumentCommentStats for the parsed file.") .def("find_by_id", [](const DocxParser& self, int id) -> py::object { const CommentMetadata* m = self.find_by_id(id); if (!m) return py::none(); return py::cast(*m); }, py::arg("id"), "Return CommentMetadata for the given id, or None if not found.") .def("by_author", [](const DocxParser& self, const std::string& author){ auto ptrs = self.by_author(author); py::list result; for (auto* p : ptrs) result.append(*p); return result; }, py::arg("author"), "Return list of CommentMetadata authored by the given person.") .def("root_comments", [](const DocxParser& self){ auto ptrs = self.root_comments(); py::list result; for (auto* p : ptrs) result.append(*p); return result; }, "Return non-reply root comments in document order.") .def("thread", [](const DocxParser& self, int root_id){ auto ptrs = self.thread(root_id); py::list result; for (auto* p : ptrs) result.append(*p); return result; }, py::arg("root_id"), "Return ordered list of CommentMetadata forming the thread for root_id."); // ── BatchParser ─────────────────────────────────────────────────────────── py::class_(m, "BatchParser", R"doc( Multi-file parallel .docx comment parser. Example ------- >>> import docx_comment_parser as dcp, glob >>> bp = dcp.BatchParser(max_threads=4) >>> bp.parse_all(glob.glob("/docs/*.docx")) >>> for path in glob.glob("/docs/*.docx"): ... print(path, bp.stats(path).total_comments) >>> bp.release_all() )doc") .def(py::init(), py::arg("max_threads") = 0u, "Create a BatchParser. max_threads=0 uses all CPU cores.") .def("parse_all", &BatchParser::parse_all, py::arg("file_paths"), py::call_guard(), R"doc( Parse a list of .docx files in parallel. Files that fail are recorded in errors() rather than raising. Parameters ---------- file_paths : list[str] )doc") .def("comments", [](const BatchParser& self, const std::string& fp) { return self.comments(fp); }, py::arg("file_path"), "Return list of CommentMetadata for a previously parsed file.") .def("stats", [](const BatchParser& self, const std::string& fp){ return self.stats(fp); }, py::arg("file_path"), "Return DocumentCommentStats for a previously parsed file.") .def("errors", [](const BatchParser& self){ py::dict d; for (const auto& kv : self.errors()) d[py::str(kv.first)] = kv.second; return d; }, "Return dict of {file_path: error_message} for files that failed.") .def("parsed_files", &BatchParser::parsed_files, "Return sorted list of file paths that parsed successfully and " "still hold results (released files are excluded).") .def("release", &BatchParser::release, py::arg("file_path"), "Free memory for a specific parsed file.") .def("release_all", &BatchParser::release_all, "Free memory for all parsed files."); // ── Exception types ─────────────────────────────────────────────────────── // // These are built by hand rather than with py::register_exception() for two // reasons: // // 1. Hierarchy. register_exception() accepts a single base, so the C++ // relationship (DocxFileError : DocxParserError) could not be expressed // alongside the historical builtin bases. PyErr_NewException accepts a // *tuple* of bases, so DocxFileError can be both a DocxParserError and // an OSError. Existing `except OSError` / `except ValueError` / // `except DocxParserError` code keeps working, and `except // DocxFileError` starts working. // // 2. Dispatch order. pybind11 tries exception translators in reverse // registration order, so the previously-last-registered DocxParserError // handler caught every derived type first — DocxFileError and // DocxFormatError could never actually be raised. The single // translate_docx_exception() above, with most-derived-first catch // clauses, fixes that. py::object exc_parser = py::reinterpret_steal( PyErr_NewException("docx_comment_parser._core.DocxParserError", PyExc_RuntimeError, nullptr)); py::object file_bases = py::make_tuple( exc_parser, py::reinterpret_borrow(py::handle(PyExc_OSError))); py::object exc_file = py::reinterpret_steal( PyErr_NewException("docx_comment_parser._core.DocxFileError", file_bases.ptr(), nullptr)); py::object format_bases = py::make_tuple( exc_parser, py::reinterpret_borrow(py::handle(PyExc_ValueError))); py::object exc_format = py::reinterpret_steal( PyErr_NewException("docx_comment_parser._core.DocxFormatError", format_bases.ptr(), nullptr)); m.add_object("DocxParserError", exc_parser); m.add_object("DocxFileError", exc_file); m.add_object("DocxFormatError", exc_format); // The module dict now owns a reference to each type for the life of the // interpreter, so the translator holds plain borrowed handles. g_exc_parser = exc_parser.ptr(); g_exc_file = exc_file.ptr(); g_exc_format = exc_format.ptr(); py::register_exception_translator(&translate_docx_exception); }