From 630ecbf0d8da2f413571243e22bc0be1fb1e5a57 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 29 Apr 2024 15:30:47 -0700 Subject: [PATCH 01/56] rfctr(lint): tune in ruff settings --- pyproject.toml | 8 ++++++-- src/docx/image/tiff.py | 6 +----- tests/unitutil/cxml.py | 6 ++---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d35c790c7..8c0518a96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ Homepage = "https://github.com/python-openxml/python-docx" Repository = "https://github.com/python-openxml/python-docx" [tool.black] +line-length = 100 target-version = ["py37", "py38", "py39", "py310", "py311"] [tool.pytest.ini_options] @@ -69,6 +70,10 @@ python_functions = ["it_", "its_", "they_", "and_", "but_"] [tool.ruff] exclude = [] +line-length = 100 +target-version = "py38" + +[tool.ruff.lint] ignore = [ "COM812", # -- over-aggressively insists on trailing commas where not desired -- "PT001", # -- wants @pytest.fixture() instead of @pytest.fixture -- @@ -88,9 +93,8 @@ select = [ "UP032", # -- Use f-string instead of `.format()` call -- "UP034", # -- Avoid extraneous parentheses -- ] -target-version = "py37" -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["docx"] known-local-folder = ["helpers"] diff --git a/src/docx/image/tiff.py b/src/docx/image/tiff.py index b84d9f10f..1194929af 100644 --- a/src/docx/image/tiff.py +++ b/src/docx/image/tiff.py @@ -98,11 +98,7 @@ def _dpi(self, resolution_tag): return 72 # resolution unit defaults to inches (2) - resolution_unit = ( - ifd_entries[TIFF_TAG.RESOLUTION_UNIT] - if TIFF_TAG.RESOLUTION_UNIT in ifd_entries - else 2 - ) + resolution_unit = ifd_entries.get(TIFF_TAG.RESOLUTION_UNIT, 2) if resolution_unit == 1: # aspect ratio only return 72 diff --git a/tests/unitutil/cxml.py b/tests/unitutil/cxml.py index c7b7d172c..e76cabd74 100644 --- a/tests/unitutil/cxml.py +++ b/tests/unitutil/cxml.py @@ -89,7 +89,7 @@ def from_token(cls, token): Return an ``Element`` object constructed from a parser element token. """ tagname = token.tagname - attrs = [(name, value) for name, value in token.attr_list] + attrs = [tuple(a) for a in token.attr_list] text = token.text return cls(tagname, attrs, text) @@ -263,9 +263,7 @@ def grammar(): child_node_list << (open_paren + delimitedList(node) + close_paren | node) root_node = ( - element("element") - + Group(Optional(slash + child_node_list))("child_node_list") - + stringEnd + element("element") + Group(Optional(slash + child_node_list))("child_node_list") + stringEnd ).setParseAction(connect_root_node_children) return root_node From 5a22c521c5f749847c7b038cef0074b469a7994a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 5 Nov 2023 22:11:55 -0800 Subject: [PATCH 02/56] rfctr: improve typing for tables --- pyrightconfig.json | 2 +- requirements-dev.txt | 2 + requirements-test.txt | 1 + src/docx/opc/oxml.py | 6 +- src/docx/oxml/__init__.py | 2 + src/docx/oxml/document.py | 2 +- src/docx/oxml/parser.py | 4 +- src/docx/oxml/section.py | 66 ++--- src/docx/oxml/shared.py | 6 +- src/docx/oxml/table.py | 491 ++++++++++++++++++-------------- src/docx/oxml/text/hyperlink.py | 14 +- src/docx/oxml/text/paragraph.py | 6 +- src/docx/oxml/text/parfmt.py | 6 +- src/docx/oxml/text/run.py | 15 +- src/docx/oxml/xmlchemy.py | 67 ++--- src/docx/table.py | 121 ++++---- tests/test_table.py | 94 +++--- 17 files changed, 480 insertions(+), 425 deletions(-) diff --git a/pyrightconfig.json b/pyrightconfig.json index 161e49d2b..21afeb97b 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -6,7 +6,7 @@ "ignore": [ ], "include": [ - "src/docx/", + "src/docx", "tests" ], "pythonPlatform": "All", diff --git a/requirements-dev.txt b/requirements-dev.txt index 45e5f78c3..14d8740e3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,7 @@ -r requirements-test.txt build +ruff setuptools>=61.0.0 tox twine +types-lxml diff --git a/requirements-test.txt b/requirements-test.txt index 85d9f6ba3..9ee78b43f 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,4 +2,5 @@ behave>=1.2.3 pyparsing>=2.0.1 pytest>=2.5 +pytest-xdist ruff diff --git a/src/docx/opc/oxml.py b/src/docx/opc/oxml.py index 570dcf413..0249de918 100644 --- a/src/docx/opc/oxml.py +++ b/src/docx/opc/oxml.py @@ -1,3 +1,5 @@ +# pyright: reportPrivateUsage=false + """Temporary stand-in for main oxml module. This module came across with the PackageReader transplant. Probably much will get @@ -27,7 +29,7 @@ # =========================================================================== -def parse_xml(text: str) -> etree._Element: # pyright: ignore[reportPrivateUsage] +def parse_xml(text: str) -> etree._Element: """`etree.fromstring()` replacement that uses oxml parser.""" return etree.fromstring(text, oxml_parser) @@ -44,7 +46,7 @@ def qn(tag): return "{%s}%s" % (uri, tagroot) -def serialize_part_xml(part_elm): +def serialize_part_xml(part_elm: etree._Element): """Serialize `part_elm` etree element to XML suitable for storage as an XML part. That is to say, no insignificant whitespace added for readability, and an diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index 621ef279a..a37ee9b8e 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -149,6 +149,7 @@ CT_TblGridCol, CT_TblLayoutType, CT_TblPr, + CT_TblPrEx, CT_TblWidth, CT_Tc, CT_TcPr, @@ -164,6 +165,7 @@ register_element_cls("w:tblGrid", CT_TblGrid) register_element_cls("w:tblLayout", CT_TblLayoutType) register_element_cls("w:tblPr", CT_TblPr) +register_element_cls("w:tblPrEx", CT_TblPrEx) register_element_cls("w:tblStyle", CT_String) register_element_cls("w:tc", CT_Tc) register_element_cls("w:tcPr", CT_TcPr) diff --git a/src/docx/oxml/document.py b/src/docx/oxml/document.py index cc27f5aa9..ff3736f65 100644 --- a/src/docx/oxml/document.py +++ b/src/docx/oxml/document.py @@ -44,7 +44,7 @@ class CT_Body(BaseOxmlElement): p = ZeroOrMore("w:p", successors=("w:sectPr",)) tbl = ZeroOrMore("w:tbl", successors=("w:sectPr",)) - sectPr: CT_SectPr | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + sectPr: CT_SectPr | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:sectPr", successors=() ) diff --git a/src/docx/oxml/parser.py b/src/docx/oxml/parser.py index 7e6a0fb49..a38362676 100644 --- a/src/docx/oxml/parser.py +++ b/src/docx/oxml/parser.py @@ -1,3 +1,5 @@ +# pyright: reportImportCycles=false + """XML parser for python-docx.""" from __future__ import annotations @@ -43,7 +45,7 @@ def OxmlElement( nsptag_str: str, attrs: Dict[str, str] | None = None, nsdecls: Dict[str, str] | None = None, -) -> BaseOxmlElement: +) -> BaseOxmlElement | etree._Element: # pyright: ignore[reportPrivateUsage] """Return a 'loose' lxml element having the tag specified by `nsptag_str`. The tag in `nsptag_str` must contain the standard namespace prefix, e.g. `a:tbl`. diff --git a/src/docx/oxml/section.py b/src/docx/oxml/section.py index a4090898a..71072e2df 100644 --- a/src/docx/oxml/section.py +++ b/src/docx/oxml/section.py @@ -51,38 +51,34 @@ def inner_content_elements(self) -> List[CT_P | CT_Tbl]: class CT_HdrFtrRef(BaseOxmlElement): """`w:headerReference` and `w:footerReference` elements.""" - type_: WD_HEADER_FOOTER = ( - RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:type", WD_HEADER_FOOTER - ) - ) - rId: str = RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "r:id", XsdString + type_: WD_HEADER_FOOTER = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:type", WD_HEADER_FOOTER ) + rId: str = RequiredAttribute("r:id", XsdString) # pyright: ignore[reportAssignmentType] class CT_PageMar(BaseOxmlElement): """```` element, defining page margins.""" - top: Length | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + top: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:top", ST_SignedTwipsMeasure ) - right: Length | None = OptionalAttribute( # pyright: ignore + right: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:right", ST_TwipsMeasure ) - bottom: Length | None = OptionalAttribute( # pyright: ignore + bottom: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:bottom", ST_SignedTwipsMeasure ) - left: Length | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + left: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:left", ST_TwipsMeasure ) - header: Length | None = OptionalAttribute( # pyright: ignore + header: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:header", ST_TwipsMeasure ) - footer: Length | None = OptionalAttribute( # pyright: ignore + footer: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:footer", ST_TwipsMeasure ) - gutter: Length | None = OptionalAttribute( # pyright: ignore + gutter: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:gutter", ST_TwipsMeasure ) @@ -90,16 +86,14 @@ class CT_PageMar(BaseOxmlElement): class CT_PageSz(BaseOxmlElement): """```` element, defining page dimensions and orientation.""" - w: Length | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + w: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:w", ST_TwipsMeasure ) - h: Length | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + h: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:h", ST_TwipsMeasure ) - orient: WD_ORIENTATION = ( - OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:orient", WD_ORIENTATION, default=WD_ORIENTATION.PORTRAIT - ) + orient: WD_ORIENTATION = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:orient", WD_ORIENTATION, default=WD_ORIENTATION.PORTRAIT ) @@ -139,16 +133,16 @@ class CT_SectPr(BaseOxmlElement): ) headerReference = ZeroOrMore("w:headerReference", successors=_tag_seq) footerReference = ZeroOrMore("w:footerReference", successors=_tag_seq) - type: CT_SectType | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + type: CT_SectType | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:type", successors=_tag_seq[3:] ) - pgSz: CT_PageSz | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + pgSz: CT_PageSz | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:pgSz", successors=_tag_seq[4:] ) - pgMar: CT_PageMar | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + pgMar: CT_PageMar | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:pgMar", successors=_tag_seq[5:] ) - titlePg: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + titlePg: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:titlePg", successors=_tag_seq[14:] ) del _tag_seq @@ -187,9 +181,7 @@ def bottom_margin(self) -> Length | None: @bottom_margin.setter def bottom_margin(self, value: int | Length | None): pgMar = self.get_or_add_pgMar() - pgMar.bottom = ( - value if value is None or isinstance(value, Length) else Length(value) - ) + pgMar.bottom = value if value is None or isinstance(value, Length) else Length(value) def clone(self) -> CT_SectPr: """Return an exact duplicate of this ```` element tree suitable for @@ -217,9 +209,7 @@ def footer(self) -> Length | None: @footer.setter def footer(self, value: int | Length | None): pgMar = self.get_or_add_pgMar() - pgMar.footer = ( - value if value is None or isinstance(value, Length) else Length(value) - ) + pgMar.footer = value if value is None or isinstance(value, Length) else Length(value) def get_footerReference(self, type_: WD_HEADER_FOOTER) -> CT_HdrFtrRef | None: """Return footerReference element of `type_` or None if not present.""" @@ -251,9 +241,7 @@ def gutter(self) -> Length | None: @gutter.setter def gutter(self, value: int | Length | None): pgMar = self.get_or_add_pgMar() - pgMar.gutter = ( - value if value is None or isinstance(value, Length) else Length(value) - ) + pgMar.gutter = value if value is None or isinstance(value, Length) else Length(value) @property def header(self) -> Length | None: @@ -270,9 +258,7 @@ def header(self) -> Length | None: @header.setter def header(self, value: int | Length | None): pgMar = self.get_or_add_pgMar() - pgMar.header = ( - value if value is None or isinstance(value, Length) else Length(value) - ) + pgMar.header = value if value is None or isinstance(value, Length) else Length(value) def iter_inner_content(self) -> Iterator[CT_P | CT_Tbl]: """Generate all `w:p` and `w:tbl` elements in this section. @@ -295,9 +281,7 @@ def left_margin(self) -> Length | None: @left_margin.setter def left_margin(self, value: int | Length | None): pgMar = self.get_or_add_pgMar() - pgMar.left = ( - value if value is None or isinstance(value, Length) else Length(value) - ) + pgMar.left = value if value is None or isinstance(value, Length) else Length(value) @property def orientation(self) -> WD_ORIENTATION: @@ -442,8 +426,8 @@ def top_margin(self, value: Length | None): class CT_SectType(BaseOxmlElement): """```` element, defining the section start type.""" - val: WD_SECTION_START | None = ( # pyright: ignore[reportGeneralTypeIssues] - OptionalAttribute("w:val", WD_SECTION_START) + val: WD_SECTION_START | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_SECTION_START ) diff --git a/src/docx/oxml/shared.py b/src/docx/oxml/shared.py index 1774560ac..a74abc4ac 100644 --- a/src/docx/oxml/shared.py +++ b/src/docx/oxml/shared.py @@ -15,7 +15,7 @@ class CT_DecimalNumber(BaseOxmlElement): containing a text representation of a decimal number (e.g. 42) in its ``val`` attribute.""" - val = RequiredAttribute("w:val", ST_DecimalNumber) + val: int = RequiredAttribute("w:val", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] @classmethod def new(cls, nsptagname, val): @@ -42,9 +42,7 @@ class CT_String(BaseOxmlElement): In those cases, it containing a style name in its `val` attribute. """ - val: str = RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:val", ST_String - ) + val: str = RequiredAttribute("w:val", ST_String) # pyright: ignore[reportGeneralTypeIssues] @classmethod def new(cls, nsptagname: str, val: str): diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index 48a6d8c2f..da3c6b51d 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -2,12 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, List +from typing import TYPE_CHECKING, Callable, cast -from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_ROW_HEIGHT_RULE +from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_ROW_HEIGHT_RULE, WD_TABLE_DIRECTION from docx.exceptions import InvalidSpanError from docx.oxml.ns import nsdecls, qn from docx.oxml.parser import parse_xml +from docx.oxml.shared import CT_DecimalNumber from docx.oxml.simpletypes import ( ST_Merge, ST_TblLayoutType, @@ -15,6 +16,7 @@ ST_TwipsMeasure, XsdInt, ) +from docx.oxml.text.paragraph import CT_P from docx.oxml.xmlchemy import ( BaseOxmlElement, OneAndOnlyOne, @@ -24,31 +26,43 @@ ZeroOrMore, ZeroOrOne, ) -from docx.shared import Emu, Twips +from docx.shared import Emu, Length, Twips if TYPE_CHECKING: - from docx.oxml.text.paragraph import CT_P - from docx.shared import Length + from docx.enum.table import WD_TABLE_ALIGNMENT + from docx.enum.text import WD_ALIGN_PARAGRAPH + from docx.oxml.shared import CT_OnOff, CT_String + from docx.oxml.text.parfmt import CT_Jc class CT_Height(BaseOxmlElement): - """Used for ```` to specify a row height and row height rule.""" + """Used for `w:trHeight` to specify a row height and row height rule.""" - val = OptionalAttribute("w:val", ST_TwipsMeasure) - hRule = OptionalAttribute("w:hRule", WD_ROW_HEIGHT_RULE) + val: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:val", ST_TwipsMeasure + ) + hRule: WD_ROW_HEIGHT_RULE | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:hRule", WD_ROW_HEIGHT_RULE + ) class CT_Row(BaseOxmlElement): """```` element.""" - tblPrEx = ZeroOrOne("w:tblPrEx") # custom inserter below - trPr = ZeroOrOne("w:trPr") # custom inserter below + add_tc: Callable[[], CT_Tc] + get_or_add_trPr: Callable[[], CT_TrPr] + + tc_lst: list[CT_Tc] + # -- custom inserter below -- + tblPrEx: CT_TblPrEx | None = ZeroOrOne("w:tblPrEx") # pyright: ignore[reportAssignmentType] + # -- custom inserter below -- + trPr: CT_TrPr | None = ZeroOrOne("w:trPr") # pyright: ignore[reportAssignmentType] tc = ZeroOrMore("w:tc") - def tc_at_grid_col(self, idx): - """The ```` element appearing at grid column `idx`. + def tc_at_grid_col(self, idx: int) -> CT_Tc: + """`` element appearing at grid column `idx`. - Raises |ValueError| if no ``w:tc`` element begins at that grid column. + Raises |ValueError| if no `w:tc` element begins at that grid column. """ grid_col = 0 for tc in self.tc_lst: @@ -60,21 +74,21 @@ def tc_at_grid_col(self, idx): raise ValueError("index out of bounds") @property - def tr_idx(self): - """The index of this ```` element within its parent ```` - element.""" - return self.getparent().tr_lst.index(self) + def tr_idx(self) -> int: + """Index of this `w:tr` element within its parent `w:tbl` element.""" + tbl = cast(CT_Tbl, self.getparent()) + return tbl.tr_lst.index(self) @property - def trHeight_hRule(self): - """Return the value of `w:trPr/w:trHeight@w:hRule`, or |None| if not present.""" + def trHeight_hRule(self) -> WD_ROW_HEIGHT_RULE | None: + """The value of `./w:trPr/w:trHeight/@w:hRule`, or |None| if not present.""" trPr = self.trPr if trPr is None: return None return trPr.trHeight_hRule @trHeight_hRule.setter - def trHeight_hRule(self, value): + def trHeight_hRule(self, value: WD_ROW_HEIGHT_RULE | None): trPr = self.get_or_add_trPr() trPr.trHeight_hRule = value @@ -87,14 +101,14 @@ def trHeight_val(self): return trPr.trHeight_val @trHeight_val.setter - def trHeight_val(self, value): + def trHeight_val(self, value: Length | None): trPr = self.get_or_add_trPr() trPr.trHeight_val = value - def _insert_tblPrEx(self, tblPrEx): + def _insert_tblPrEx(self, tblPrEx: CT_TblPrEx): self.insert(0, tblPrEx) - def _insert_trPr(self, trPr): + def _insert_trPr(self, trPr: CT_TrPr): tblPrEx = self.tblPrEx if tblPrEx is not None: tblPrEx.addnext(trPr) @@ -108,13 +122,16 @@ def _new_tc(self): class CT_Tbl(BaseOxmlElement): """```` element.""" - tblPr = OneAndOnlyOne("w:tblPr") - tblGrid = OneAndOnlyOne("w:tblGrid") + add_tr: Callable[[], CT_Row] + tr_lst: list[CT_Row] + + tblPr: CT_TblPr = OneAndOnlyOne("w:tblPr") # pyright: ignore[reportAssignmentType] + tblGrid: CT_TblGrid = OneAndOnlyOne("w:tblGrid") # pyright: ignore[reportAssignmentType] tr = ZeroOrMore("w:tr") @property - def bidiVisual_val(self): - """Value of `w:tblPr/w:bidiVisual/@w:val` or |None| if not present. + def bidiVisual_val(self) -> bool | None: + """Value of `./w:tblPr/w:bidiVisual/@w:val` or |None| if not present. Controls whether table cells are displayed right-to-left or left-to-right. """ @@ -124,12 +141,12 @@ def bidiVisual_val(self): return bidiVisual.val @bidiVisual_val.setter - def bidiVisual_val(self, value): + def bidiVisual_val(self, value: WD_TABLE_DIRECTION | None): tblPr = self.tblPr if value is None: - tblPr._remove_bidiVisual() + tblPr._remove_bidiVisual() # pyright: ignore[reportPrivateUsage] else: - tblPr.get_or_add_bidiVisual().val = value + tblPr.get_or_add_bidiVisual().val = bool(value) @property def col_count(self): @@ -153,111 +170,118 @@ def new_tbl(cls, rows: int, cols: int, width: Length) -> CT_Tbl: `width` is distributed evenly between the columns. """ - return parse_xml(cls._tbl_xml(rows, cols, width)) + return cast(CT_Tbl, parse_xml(cls._tbl_xml(rows, cols, width))) @property - def tblStyle_val(self): - """Value of `w:tblPr/w:tblStyle/@w:val` (a table style id) or |None| if not - present.""" + def tblStyle_val(self) -> str | None: + """`w:tblPr/w:tblStyle/@w:val` (a table style id) or |None| if not present.""" tblStyle = self.tblPr.tblStyle if tblStyle is None: return None return tblStyle.val @tblStyle_val.setter - def tblStyle_val(self, styleId): + def tblStyle_val(self, styleId: str | None) -> None: """Set the value of `w:tblPr/w:tblStyle/@w:val` (a table style id) to `styleId`. If `styleId` is None, remove the `w:tblStyle` element. """ tblPr = self.tblPr - tblPr._remove_tblStyle() + tblPr._remove_tblStyle() # pyright: ignore[reportPrivateUsage] if styleId is None: return - tblPr._add_tblStyle().val = styleId + tblPr._add_tblStyle().val = styleId # pyright: ignore[reportPrivateUsage] @classmethod def _tbl_xml(cls, rows: int, cols: int, width: Length) -> str: - col_width = Emu(width / cols) if cols > 0 else Emu(0) + col_width = Emu(width // cols) if cols > 0 else Emu(0) return ( - "\n" - " \n" - ' \n' - ' \n' - " \n" - "%s" # tblGrid - "%s" # trs - "\n" - ) % ( - nsdecls("w"), - cls._tblGrid_xml(cols, col_width), - cls._trs_xml(rows, cols, col_width), + f"\n" + f" \n" + f' \n' + f' \n' + f" \n" + f"{cls._tblGrid_xml(cols, col_width)}" + f"{cls._trs_xml(rows, cols, col_width)}" + f"\n" ) @classmethod - def _tblGrid_xml(cls, col_count, col_width): + def _tblGrid_xml(cls, col_count: int, col_width: Length) -> str: xml = " \n" - for i in range(col_count): + for _ in range(col_count): xml += ' \n' % col_width.twips xml += " \n" return xml @classmethod - def _trs_xml(cls, row_count, col_count, col_width): - xml = "" - for i in range(row_count): - xml += (" \n" "%s" " \n") % cls._tcs_xml( - col_count, col_width - ) - return xml + def _trs_xml(cls, row_count: int, col_count: int, col_width: Length) -> str: + return f" \n{cls._tcs_xml(col_count, col_width)} \n" * row_count @classmethod - def _tcs_xml(cls, col_count, col_width): - xml = "" - for i in range(col_count): - xml += ( - " \n" - " \n" - ' \n' - " \n" - " \n" - " \n" - ) % col_width.twips - return xml + def _tcs_xml(cls, col_count: int, col_width: Length) -> str: + return ( + f" \n" + f" \n" + f' \n' + f" \n" + f" \n" + f" \n" + ) * col_count class CT_TblGrid(BaseOxmlElement): - """```` element, child of ````, holds ```` elements - that define column count, width, etc.""" + """`w:tblGrid` element. + + Child of `w:tbl`, holds `w:gridCol> elements that define column count, width, etc. + """ + + add_gridCol: Callable[[], CT_TblGridCol] + gridCol_lst: list[CT_TblGridCol] gridCol = ZeroOrMore("w:gridCol", successors=("w:tblGridChange",)) class CT_TblGridCol(BaseOxmlElement): - """```` element, child of ````, defines a table column.""" + """`w:gridCol` element, child of `w:tblGrid`, defines a table column.""" - w = OptionalAttribute("w:w", ST_TwipsMeasure) + w: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:w", ST_TwipsMeasure + ) @property - def gridCol_idx(self): - """The index of this ```` element within its parent ```` - element.""" - return self.getparent().gridCol_lst.index(self) + def gridCol_idx(self) -> int: + """Index of this `w:gridCol` element within its parent `w:tblGrid` element.""" + tblGrid = cast(CT_TblGrid, self.getparent()) + return tblGrid.gridCol_lst.index(self) class CT_TblLayoutType(BaseOxmlElement): - """```` element, specifying whether column widths are fixed or can be - automatically adjusted based on content.""" + """`w:tblLayout` element. - type = OptionalAttribute("w:type", ST_TblLayoutType) + Specifies whether column widths are fixed or can be automatically adjusted based on + content. + """ + + type: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:type", ST_TblLayoutType + ) class CT_TblPr(BaseOxmlElement): """```` element, child of ````, holds child elements that define table properties such as style and borders.""" + get_or_add_bidiVisual: Callable[[], CT_OnOff] + get_or_add_jc: Callable[[], CT_Jc] + get_or_add_tblLayout: Callable[[], CT_TblLayoutType] + _add_tblStyle: Callable[[], CT_String] + _remove_bidiVisual: Callable[[], None] + _remove_jc: Callable[[], None] + _remove_tblStyle: Callable[[], None] + _tag_seq = ( "w:tblStyle", "w:tblpPr", @@ -278,31 +302,35 @@ class CT_TblPr(BaseOxmlElement): "w:tblDescription", "w:tblPrChange", ) - tblStyle = ZeroOrOne("w:tblStyle", successors=_tag_seq[1:]) - bidiVisual = ZeroOrOne("w:bidiVisual", successors=_tag_seq[4:]) - jc = ZeroOrOne("w:jc", successors=_tag_seq[8:]) - tblLayout = ZeroOrOne("w:tblLayout", successors=_tag_seq[13:]) + tblStyle: CT_String | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:tblStyle", successors=_tag_seq[1:] + ) + bidiVisual: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:bidiVisual", successors=_tag_seq[4:] + ) + jc: CT_Jc | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:jc", successors=_tag_seq[8:] + ) + tblLayout: CT_TblLayoutType | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:tblLayout", successors=_tag_seq[13:] + ) del _tag_seq @property - def alignment(self): - """Member of :ref:`WdRowAlignment` enumeration or |None|, based on the contents - of the `w:val` attribute of `./w:jc`. - - |None| if no `w:jc` element is present. - """ + def alignment(self) -> WD_TABLE_ALIGNMENT | None: + """Horizontal alignment of table, |None| if `./w:jc` is not present.""" jc = self.jc if jc is None: return None - return jc.val + return cast("WD_TABLE_ALIGNMENT | None", jc.val) @alignment.setter - def alignment(self, value): + def alignment(self, value: WD_TABLE_ALIGNMENT | None): self._remove_jc() if value is None: return jc = self.get_or_add_jc() - jc.val = value + jc.val = cast("WD_ALIGN_PARAGRAPH", value) @property def autofit(self) -> bool: @@ -328,33 +356,40 @@ def style(self): return tblStyle.val @style.setter - def style(self, value): + def style(self, value: str | None): self._remove_tblStyle() if value is None: return - self._add_tblStyle(val=value) + self._add_tblStyle().val = value + + +class CT_TblPrEx(BaseOxmlElement): + """`w:tblPrEx` element, exceptions to table-properties. + + Applied at a lower level, like a `w:tr` to modify the appearance. Possibly used when + two tables are merged. For more see: + http://officeopenxml.com/WPtablePropertyExceptions.php + """ class CT_TblWidth(BaseOxmlElement): - """Used for ```` and ```` elements and many others, to specify a - table-related width.""" + """Used for `w:tblW` and `w:tcW` and others, specifies a table-related width.""" # the type for `w` attr is actually ST_MeasurementOrPercent, but using # XsdInt for now because only dxa (twips) values are being used. It's not # entirely clear what the semantics are for other values like -01.4mm - w = RequiredAttribute("w:w", XsdInt) + w: int = RequiredAttribute("w:w", XsdInt) # pyright: ignore[reportAssignmentType] type = RequiredAttribute("w:type", ST_TblWidth) @property - def width(self): - """Return the EMU length value represented by the combined ``w:w`` and - ``w:type`` attributes.""" + def width(self) -> Length | None: + """EMU length indicated by the combined `w:w` and `w:type` attrs.""" if self.type != "dxa": return None return Twips(self.w) @width.setter - def width(self, value): + def width(self, value: Length): self.type = "dxa" self.w = Emu(value).twips @@ -363,17 +398,19 @@ class CT_Tc(BaseOxmlElement): """`w:tc` table cell element.""" add_p: Callable[[], CT_P] - p_lst: List[CT_P] - tbl_lst: List[CT_Tbl] - + get_or_add_tcPr: Callable[[], CT_TcPr] + p_lst: list[CT_P] + tbl_lst: list[CT_Tbl] _insert_tbl: Callable[[CT_Tbl], CT_Tbl] + _new_p: Callable[[], CT_P] - tcPr = ZeroOrOne("w:tcPr") # bunches of successors, overriding insert + # -- tcPr has many successors, `._insert_tcPr()` is overridden below -- + tcPr: CT_TcPr | None = ZeroOrOne("w:tcPr") # pyright: ignore[reportAssignmentType] p = OneOrMore("w:p") tbl = OneOrMore("w:tbl") @property - def bottom(self): + def bottom(self) -> int: """The row index that marks the bottom extent of the vertical span of this cell. This is one greater than the index of the bottom-most row of the span, similar @@ -386,21 +423,18 @@ def bottom(self): return self._tr_idx + 1 def clear_content(self): - """Remove all content child elements, preserving the ```` element if - present. + """Remove all content elements, preserving `w:tcPr` element if present. - Note that this leaves the ```` element in an invalid state because it - doesn't contain at least one block-level element. It's up to the caller to add a - ````child element as the last content element. + Note that this leaves the `w:tc` element in an invalid state because it doesn't + contain at least one block-level element. It's up to the caller to add a + `w:p`child element as the last content element. """ - new_children = [] - tcPr = self.tcPr - if tcPr is not None: - new_children.append(tcPr) - self[:] = new_children + # -- remove all cell inner-content except a `w:tcPr` when present. -- + for e in self.xpath("./*[not(self::w:tcPr)]"): + self.remove(e) @property - def grid_span(self): + def grid_span(self) -> int: """The integer number of columns this cell spans. Determined by ./w:tcPr/w:gridSpan/@val, it defaults to 1. @@ -411,12 +445,12 @@ def grid_span(self): return tcPr.grid_span @grid_span.setter - def grid_span(self, value): + def grid_span(self, value: int): tcPr = self.get_or_add_tcPr() tcPr.grid_span = value @property - def inner_content_elements(self) -> List[CT_P | CT_Tbl]: + def inner_content_elements(self) -> list[CT_P | CT_Tbl]: """Generate all `w:p` and `w:tbl` elements in this document-body. Elements appear in document order. Elements shaded by nesting in a `w:ins` or @@ -433,27 +467,28 @@ def iter_block_items(self): yield child @property - def left(self): + def left(self) -> int: """The grid column index at which this ```` element appears.""" return self._grid_col - def merge(self, other_tc): - """Return the top-left ```` element of a new span formed by merging the - rectangular region defined by using this tc element and `other_tc` as diagonal - corners.""" + def merge(self, other_tc: CT_Tc) -> CT_Tc: + """Return top-left `w:tc` element of a new span. + + Span is formed by merging the rectangular region defined by using this tc + element and `other_tc` as diagonal corners. + """ top, left, height, width = self._span_dimensions(other_tc) top_tc = self._tbl.tr_lst[top].tc_at_grid_col(left) top_tc._grow_to(width, height) return top_tc @classmethod - def new(cls): - """Return a new ```` element, containing an empty paragraph as the - required EG_BlockLevelElt.""" - return parse_xml("\n" " \n" "" % nsdecls("w")) + def new(cls) -> CT_Tc: + """A new `w:tc` element, containing an empty paragraph as the required EG_BlockLevelElt.""" + return cast(CT_Tc, parse_xml("\n" " \n" "" % nsdecls("w"))) @property - def right(self): + def right(self) -> int: """The grid column index that marks the right-side extent of the horizontal span of this cell. @@ -463,108 +498,118 @@ def right(self): return self._grid_col + self.grid_span @property - def top(self): + def top(self) -> int: """The top-most row index in the vertical span of this cell.""" if self.vMerge is None or self.vMerge == ST_Merge.RESTART: return self._tr_idx return self._tc_above.top @property - def vMerge(self): - """The value of the ./w:tcPr/w:vMerge/@val attribute, or |None| if the w:vMerge - element is not present.""" + def vMerge(self) -> str | None: + """Value of ./w:tcPr/w:vMerge/@val, |None| if w:vMerge is not present.""" tcPr = self.tcPr if tcPr is None: return None return tcPr.vMerge_val @vMerge.setter - def vMerge(self, value): + def vMerge(self, value: str | None): tcPr = self.get_or_add_tcPr() tcPr.vMerge_val = value @property - def width(self): - """Return the EMU length value represented in the ``./w:tcPr/w:tcW`` child - element or |None| if not present.""" + def width(self) -> Length | None: + """EMU length represented in `./w:tcPr/w:tcW` or |None| if not present.""" tcPr = self.tcPr if tcPr is None: return None return tcPr.width @width.setter - def width(self, value): + def width(self, value: Length): tcPr = self.get_or_add_tcPr() tcPr.width = value - def _add_width_of(self, other_tc): + def _add_width_of(self, other_tc: CT_Tc): """Add the width of `other_tc` to this cell. Does nothing if either this tc or `other_tc` does not have a specified width. """ if self.width and other_tc.width: - self.width += other_tc.width + self.width = Length(self.width + other_tc.width) @property - def _grid_col(self): + def _grid_col(self) -> int: """The grid column at which this cell begins.""" tr = self._tr idx = tr.tc_lst.index(self) preceding_tcs = tr.tc_lst[:idx] return sum(tc.grid_span for tc in preceding_tcs) - def _grow_to(self, width, height, top_tc=None): - """Grow this cell to `width` grid columns and `height` rows by expanding - horizontal spans and creating continuation cells to form vertical spans.""" + def _grow_to(self, width: int, height: int, top_tc: CT_Tc | None = None): + """Grow this cell to `width` grid columns and `height` rows. - def vMerge_val(top_tc): - if top_tc is not self: - return ST_Merge.CONTINUE - if height == 1: - return None - return ST_Merge.RESTART + This is accomplished by expanding horizontal spans and creating continuation + cells to form vertical spans. + """ + + def vMerge_val(top_tc: CT_Tc): + return ( + ST_Merge.CONTINUE + if top_tc is not self + else None if height == 1 else ST_Merge.RESTART + ) top_tc = self if top_tc is None else top_tc self._span_to_width(width, top_tc, vMerge_val(top_tc)) if height > 1: - self._tc_below._grow_to(width, height - 1, top_tc) + tc_below = self._tc_below + assert tc_below is not None + tc_below._grow_to(width, height - 1, top_tc) - def _insert_tcPr(self, tcPr): - """``tcPr`` has a bunch of successors, but it comes first if it appears, so just - overriding and using insert(0, ...) rather than spelling out successors.""" + def _insert_tcPr(self, tcPr: CT_TcPr) -> CT_TcPr: + """Override default `._insert_tcPr()`.""" + # -- `tcPr`` has a large number of successors, but always comes first if it appears, + # -- so just using insert(0, ...) rather than spelling out successors. self.insert(0, tcPr) return tcPr @property - def _is_empty(self): - """True if this cell contains only a single empty ```` element.""" + def _is_empty(self) -> bool: + """True if this cell contains only a single empty `w:p` element.""" block_items = list(self.iter_block_items()) if len(block_items) > 1: return False - p = block_items[0] # cell must include at least one element - if len(p.r_lst) == 0: + # -- cell must include at least one block item but can be a `w:tbl`, `w:sdt`, + # -- `w:customXml` or a `w:p` + only_item = block_items[0] + if isinstance(only_item, CT_P) and len(only_item.r_lst) == 0: return True return False - def _move_content_to(self, other_tc): - """Append the content of this cell to `other_tc`, leaving this cell with a - single empty ```` element.""" + def _move_content_to(self, other_tc: CT_Tc): + """Append the content of this cell to `other_tc`. + + Leaves this cell with a single empty ```` element. + """ if other_tc is self: return if self._is_empty: return other_tc._remove_trailing_empty_p() - # appending moves each element from self to other_tc + # -- appending moves each element from self to other_tc -- for block_element in self.iter_block_items(): other_tc.append(block_element) - # add back the required minimum single empty element + # -- add back the required minimum single empty element -- self.append(self._new_p()) - def _new_tbl(self): - return CT_Tbl.new() + def _new_tbl(self) -> None: + raise NotImplementedError( + "use CT_Tbl.new_tbl() to add a new table, specifying rows and columns" + ) @property - def _next_tc(self): + def _next_tc(self) -> CT_Tc | None: """The `w:tc` element immediately following this one in this row, or |None| if this is the last `w:tc` element in the row.""" following_tcs = self.xpath("./following-sibling::w:tc") @@ -572,32 +617,33 @@ def _next_tc(self): def _remove(self): """Remove this `w:tc` element from the XML tree.""" - self.getparent().remove(self) + parent_element = self.getparent() + assert parent_element is not None + parent_element.remove(self) def _remove_trailing_empty_p(self): - """Remove the last content element from this cell if it is an empty ```` - element.""" + """Remove last content element from this cell if it's an empty `w:p` element.""" block_items = list(self.iter_block_items()) last_content_elm = block_items[-1] - if last_content_elm.tag != qn("w:p"): + if not isinstance(last_content_elm, CT_P): return p = last_content_elm if len(p.r_lst) > 0: return self.remove(p) - def _span_dimensions(self, other_tc): + def _span_dimensions(self, other_tc: CT_Tc) -> tuple[int, int, int, int]: """Return a (top, left, height, width) 4-tuple specifying the extents of the merged cell formed by using this tc and `other_tc` as opposite corner extents.""" - def raise_on_inverted_L(a, b): + def raise_on_inverted_L(a: CT_Tc, b: CT_Tc): if a.top == b.top and a.bottom != b.bottom: raise InvalidSpanError("requested span not rectangular") if a.left == b.left and a.right != b.right: raise InvalidSpanError("requested span not rectangular") - def raise_on_tee_shaped(a, b): + def raise_on_tee_shaped(a: CT_Tc, b: CT_Tc): top_most, other = (a, b) if a.top < b.top else (b, a) if top_most.top < other.top and top_most.bottom > other.bottom: raise InvalidSpanError("requested span not rectangular") @@ -616,9 +662,10 @@ def raise_on_tee_shaped(a, b): return top, left, bottom - top, right - left - def _span_to_width(self, grid_width, top_tc, vMerge): - """Incorporate and then remove `w:tc` elements to the right of this one until - this cell spans `grid_width`. + def _span_to_width(self, grid_width: int, top_tc: CT_Tc, vMerge: str | None): + """Incorporate `w:tc` elements to the right until this cell spans `grid_width`. + + Incorporated `w:tc` elements are removed (replaced by gridSpan value). Raises |ValueError| if `grid_width` cannot be exactly achieved, such as when a merged cell would drive the span width greater than `grid_width` or if not @@ -632,7 +679,7 @@ def _span_to_width(self, grid_width, top_tc, vMerge): self._swallow_next_tc(grid_width, top_tc) self.vMerge = vMerge - def _swallow_next_tc(self, grid_width, top_tc): + def _swallow_next_tc(self, grid_width: int, top_tc: CT_Tc): """Extend the horizontal span of this `w:tc` element to incorporate the following `w:tc` element in the row and then delete that following `w:tc` element. @@ -643,7 +690,7 @@ def _swallow_next_tc(self, grid_width, top_tc): than `grid_width` or if there is no next `` element in the row. """ - def raise_on_invalid_swallow(next_tc): + def raise_on_invalid_swallow(next_tc: CT_Tc | None): if next_tc is None: raise InvalidSpanError("not enough grid columns") if self.grid_span + next_tc.grid_span > grid_width: @@ -651,23 +698,24 @@ def raise_on_invalid_swallow(next_tc): next_tc = self._next_tc raise_on_invalid_swallow(next_tc) + assert next_tc is not None next_tc._move_content_to(top_tc) self._add_width_of(next_tc) self.grid_span += next_tc.grid_span next_tc._remove() @property - def _tbl(self): + def _tbl(self) -> CT_Tbl: """The tbl element this tc element appears in.""" - return self.xpath("./ancestor::w:tbl[position()=1]")[0] + return cast(CT_Tbl, self.xpath("./ancestor::w:tbl[position()=1]")[0]) @property - def _tc_above(self): + def _tc_above(self) -> CT_Tc: """The `w:tc` element immediately above this one in its grid column.""" return self._tr_above.tc_at_grid_col(self._grid_col) @property - def _tc_below(self): + def _tc_below(self) -> CT_Tc | None: """The tc element immediately below this one in its grid column.""" tr_below = self._tr_below if tr_below is None: @@ -675,12 +723,12 @@ def _tc_below(self): return tr_below.tc_at_grid_col(self._grid_col) @property - def _tr(self): + def _tr(self) -> CT_Row: """The tr element this tc element appears in.""" - return self.xpath("./ancestor::w:tr[position()=1]")[0] + return cast(CT_Row, self.xpath("./ancestor::w:tr[position()=1]")[0]) @property - def _tr_above(self): + def _tr_above(self) -> CT_Row: """The tr element prior in sequence to the tr this cell appears in. Raises |ValueError| if called on a cell in the top-most row. @@ -692,7 +740,7 @@ def _tr_above(self): return tr_lst[tr_idx - 1] @property - def _tr_below(self): + def _tr_below(self) -> CT_Row | None: """The tr element next in sequence after the tr this cell appears in, or |None| if this cell appears in the last row.""" tr_lst = self._tbl.tr_lst @@ -703,7 +751,7 @@ def _tr_below(self): return None @property - def _tr_idx(self): + def _tr_idx(self) -> int: """The row index of the tr element this tc element appears in.""" return self._tbl.tr_lst.index(self._tr) @@ -711,6 +759,14 @@ def _tr_idx(self): class CT_TcPr(BaseOxmlElement): """```` element, defining table cell properties.""" + get_or_add_gridSpan: Callable[[], CT_DecimalNumber] + get_or_add_tcW: Callable[[], CT_TblWidth] + get_or_add_vAlign: Callable[[], CT_VerticalJc] + _add_vMerge: Callable[[], CT_VMerge] + _remove_gridSpan: Callable[[], None] + _remove_vAlign: Callable[[], None] + _remove_vMerge: Callable[[], None] + _tag_seq = ( "w:cnfStyle", "w:tcW", @@ -731,14 +787,22 @@ class CT_TcPr(BaseOxmlElement): "w:cellMerge", "w:tcPrChange", ) - tcW = ZeroOrOne("w:tcW", successors=_tag_seq[2:]) - gridSpan = ZeroOrOne("w:gridSpan", successors=_tag_seq[3:]) - vMerge = ZeroOrOne("w:vMerge", successors=_tag_seq[5:]) - vAlign = ZeroOrOne("w:vAlign", successors=_tag_seq[12:]) + tcW: CT_TblWidth | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:tcW", successors=_tag_seq[2:] + ) + gridSpan: CT_DecimalNumber | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:gridSpan", successors=_tag_seq[3:] + ) + vMerge: CT_VMerge | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:vMerge", successors=_tag_seq[5:] + ) + vAlign: CT_VerticalJc | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:vAlign", successors=_tag_seq[12:] + ) del _tag_seq @property - def grid_span(self): + def grid_span(self) -> int: """The integer number of columns this cell spans. Determined by ./w:gridSpan/@val, it defaults to 1. @@ -749,7 +813,7 @@ def grid_span(self): return gridSpan.val @grid_span.setter - def grid_span(self, value): + def grid_span(self, value: int): self._remove_gridSpan() if value > 1: self.get_or_add_gridSpan().val = value @@ -767,7 +831,7 @@ def vAlign_val(self): return vAlign.val @vAlign_val.setter - def vAlign_val(self, value): + def vAlign_val(self, value: WD_CELL_VERTICAL_ALIGNMENT | None): if value is None: self._remove_vAlign() return @@ -783,22 +847,21 @@ def vMerge_val(self): return vMerge.val @vMerge_val.setter - def vMerge_val(self, value): + def vMerge_val(self, value: str | None): self._remove_vMerge() if value is not None: self._add_vMerge().val = value @property - def width(self): - """Return the EMU length value represented in the ```` child element or - |None| if not present or its type is not 'dxa'.""" + def width(self) -> Length | None: + """EMU length in `./w:tcW` or |None| if not present or its type is not 'dxa'.""" tcW = self.tcW if tcW is None: return None return tcW.width @width.setter - def width(self, value): + def width(self, value: Length): tcW = self.get_or_add_tcW() tcW.width = value @@ -806,6 +869,8 @@ def width(self, value): class CT_TrPr(BaseOxmlElement): """```` element, defining table row properties.""" + get_or_add_trHeight: Callable[[], CT_Height] + _tag_seq = ( "w:cnfStyle", "w:divId", @@ -823,11 +888,13 @@ class CT_TrPr(BaseOxmlElement): "w:del", "w:trPrChange", ) - trHeight = ZeroOrOne("w:trHeight", successors=_tag_seq[8:]) + trHeight: CT_Height | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:trHeight", successors=_tag_seq[8:] + ) del _tag_seq @property - def trHeight_hRule(self): + def trHeight_hRule(self) -> WD_ROW_HEIGHT_RULE | None: """Return the value of `w:trHeight@w:hRule`, or |None| if not present.""" trHeight = self.trHeight if trHeight is None: @@ -835,7 +902,7 @@ def trHeight_hRule(self): return trHeight.hRule @trHeight_hRule.setter - def trHeight_hRule(self, value): + def trHeight_hRule(self, value: WD_ROW_HEIGHT_RULE | None): if value is None and self.trHeight is None: return trHeight = self.get_or_add_trHeight() @@ -850,7 +917,7 @@ def trHeight_val(self): return trHeight.val @trHeight_val.setter - def trHeight_val(self, value): + def trHeight_val(self, value: Length | None): if value is None and self.trHeight is None: return trHeight = self.get_or_add_trHeight() @@ -860,10 +927,14 @@ def trHeight_val(self, value): class CT_VerticalJc(BaseOxmlElement): """`w:vAlign` element, specifying vertical alignment of cell.""" - val = RequiredAttribute("w:val", WD_CELL_VERTICAL_ALIGNMENT) + val: WD_CELL_VERTICAL_ALIGNMENT = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_CELL_VERTICAL_ALIGNMENT + ) class CT_VMerge(BaseOxmlElement): """```` element, specifying vertical merging behavior of a cell.""" - val = OptionalAttribute("w:val", ST_Merge, default=ST_Merge.CONTINUE) + val: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:val", ST_Merge, default=ST_Merge.CONTINUE # pyright: ignore[reportArgumentType] + ) diff --git a/src/docx/oxml/text/hyperlink.py b/src/docx/oxml/text/hyperlink.py index 77d409f6a..38a33ff15 100644 --- a/src/docx/oxml/text/hyperlink.py +++ b/src/docx/oxml/text/hyperlink.py @@ -21,13 +21,13 @@ class CT_Hyperlink(BaseOxmlElement): r_lst: List[CT_R] - rId: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] - "r:id", XsdString - ) - anchor: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + rId: str | None = OptionalAttribute("r:id", XsdString) # pyright: ignore[reportAssignmentType] + anchor: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:anchor", ST_String ) - history = OptionalAttribute("w:history", ST_OnOff, default=True) + history: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:history", ST_OnOff, default=True + ) r = ZeroOrMore("w:r") @@ -36,8 +36,8 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: """All `w:lastRenderedPageBreak` descendants of this hyperlink.""" return self.xpath("./w:r/w:lastRenderedPageBreak") - @property # pyright: ignore[reportIncompatibleVariableOverride] - def text(self) -> str: + @property + def text(self) -> str: # pyright: ignore[reportIncompatibleMethodOverride] """The textual content of this hyperlink. `CT_Hyperlink` stores the hyperlink-text as one or more `w:r` children. diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index f771dd74f..63e96f312 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -26,7 +26,7 @@ class CT_P(BaseOxmlElement): hyperlink_lst: List[CT_Hyperlink] r_lst: List[CT_R] - pPr: CT_PPr | None = ZeroOrOne("w:pPr") # pyright: ignore[reportGeneralTypeIssues] + pPr: CT_PPr | None = ZeroOrOne("w:pPr") # pyright: ignore[reportAssignmentType] hyperlink = ZeroOrMore("w:hyperlink") r = ZeroOrMore("w:r") @@ -92,8 +92,8 @@ def style(self, style: str | None): pPr = self.get_or_add_pPr() pPr.style = style - @property # pyright: ignore[reportIncompatibleVariableOverride] - def text(self): + @property + def text(self): # pyright: ignore[reportIncompatibleMethodOverride] """The textual content of this paragraph. Inner-content child elements like `w:r` and `w:hyperlink` are translated to diff --git a/src/docx/oxml/text/parfmt.py b/src/docx/oxml/text/parfmt.py index 49ea01003..94e802938 100644 --- a/src/docx/oxml/text/parfmt.py +++ b/src/docx/oxml/text/parfmt.py @@ -37,7 +37,9 @@ class CT_Ind(BaseOxmlElement): class CT_Jc(BaseOxmlElement): """```` element, specifying paragraph justification.""" - val = RequiredAttribute("w:val", WD_ALIGN_PARAGRAPH) + val: WD_ALIGN_PARAGRAPH = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_ALIGN_PARAGRAPH + ) class CT_PPr(BaseOxmlElement): @@ -86,7 +88,7 @@ class CT_PPr(BaseOxmlElement): "w:sectPr", "w:pPrChange", ) - pStyle: CT_String | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + pStyle: CT_String | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:pStyle", successors=_tag_seq[1:] ) keepNext = ZeroOrOne("w:keepNext", successors=_tag_seq[2:]) diff --git a/src/docx/oxml/text/run.py b/src/docx/oxml/text/run.py index f17d33845..88efae83c 100644 --- a/src/docx/oxml/text/run.py +++ b/src/docx/oxml/text/run.py @@ -29,7 +29,7 @@ class CT_R(BaseOxmlElement): _add_drawing: Callable[[], CT_Drawing] _add_t: Callable[..., CT_Text] - rPr: CT_RPr | None = ZeroOrOne("w:rPr") # pyright: ignore[reportGeneralTypeIssues] + rPr: CT_RPr | None = ZeroOrOne("w:rPr") # pyright: ignore[reportAssignmentType] br = ZeroOrMore("w:br") cr = ZeroOrMore("w:cr") drawing = ZeroOrMore("w:drawing") @@ -120,12 +120,11 @@ def text(self) -> str: equivalent. """ return "".join( - str(e) - for e in self.xpath("w:br | w:cr | w:noBreakHyphen | w:ptab | w:t | w:tab") + str(e) for e in self.xpath("w:br | w:cr | w:noBreakHyphen | w:ptab | w:t | w:tab") ) - @text.setter # pyright: ignore[reportIncompatibleVariableOverride] - def text(self, text: str): + @text.setter + def text(self, text: str): # pyright: ignore[reportIncompatibleMethodOverride] self.clear_content() _RunContentAppender.append_to_run_from_text(self, text) @@ -141,12 +140,10 @@ def _insert_rPr(self, rPr: CT_RPr) -> CT_RPr: class CT_Br(BaseOxmlElement): """`` element, indicating a line, page, or column break in a run.""" - type: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + type: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:type", ST_BrType, default="textWrapping" ) - clear: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:clear", ST_BrClear - ) + clear: str | None = OptionalAttribute("w:clear", ST_BrClear) # pyright: ignore def __str__(self) -> str: """Text equivalent of this element. Actual value depends on break type. diff --git a/src/docx/oxml/xmlchemy.py b/src/docx/oxml/xmlchemy.py index d075f88f1..077bcd583 100644 --- a/src/docx/oxml/xmlchemy.py +++ b/src/docx/oxml/xmlchemy.py @@ -126,16 +126,12 @@ class BaseAttribute: Provides common methods. """ - def __init__( - self, attr_name: str, simple_type: Type[BaseXmlEnum] | Type[BaseSimpleType] - ): + def __init__(self, attr_name: str, simple_type: Type[BaseXmlEnum] | Type[BaseSimpleType]): super(BaseAttribute, self).__init__() self._attr_name = attr_name self._simple_type = simple_type - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" self._element_cls = element_cls self._prop_name = prop_name @@ -159,14 +155,12 @@ def _clark_name(self): return self._attr_name @property - def _getter(self) -> Callable[[BaseOxmlElement], Any | None]: - ... + def _getter(self) -> Callable[[BaseOxmlElement], Any | None]: ... @property def _setter( self, - ) -> Callable[[BaseOxmlElement, Any | None], None]: - ... + ) -> Callable[[BaseOxmlElement, Any | None], None]: ... class OptionalAttribute(BaseAttribute): @@ -181,7 +175,7 @@ def __init__( self, attr_name: str, simple_type: Type[BaseXmlEnum] | Type[BaseSimpleType], - default: BaseXmlEnum | BaseSimpleType | None = None, + default: BaseXmlEnum | BaseSimpleType | str | bool | None = None, ): super(OptionalAttribute, self).__init__(attr_name, simple_type) self._default = default @@ -259,8 +253,7 @@ def get_attr_value(obj: BaseOxmlElement) -> Any | None: attr_str_value = obj.get(self._clark_name) if attr_str_value is None: raise InvalidXmlError( - "required '%s' attribute not present on element %s" - % (self._attr_name, obj.tag) + "required '%s' attribute not present on element %s" % (self._attr_name, obj.tag) ) return self._simple_type.from_xml(attr_str_value) @@ -292,9 +285,7 @@ def __init__(self, nsptagname: str, successors: Tuple[str, ...] = ()): self._nsptagname = nsptagname self._successors = successors - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Baseline behavior for adding the appropriate methods to `element_cls`.""" self._element_cls = element_cls self._prop_name = prop_name @@ -508,9 +499,7 @@ class OneAndOnlyOne(_BaseChildElement): def __init__(self, nsptagname: str): super(OneAndOnlyOne, self).__init__(nsptagname, ()) - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" super(OneAndOnlyOne, self).populate_class_members(element_cls, prop_name) self._add_getter() @@ -528,9 +517,7 @@ def get_child_element(obj: BaseOxmlElement): ) return child - get_child_element.__doc__ = ( - "Required ``<%s>`` child element." % self._nsptagname - ) + get_child_element.__doc__ = "Required ``<%s>`` child element." % self._nsptagname return get_child_element @@ -538,9 +525,7 @@ class OneOrMore(_BaseChildElement): """Defines a repeating child element for MetaOxmlElement that must appear at least once.""" - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" super(OneOrMore, self).populate_class_members(element_cls, prop_name) self._add_list_getter() @@ -554,9 +539,7 @@ def populate_class_members( class ZeroOrMore(_BaseChildElement): """Defines an optional repeating child element for MetaOxmlElement.""" - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" super(ZeroOrMore, self).populate_class_members(element_cls, prop_name) self._add_list_getter() @@ -570,9 +553,7 @@ def populate_class_members( class ZeroOrOne(_BaseChildElement): """Defines an optional child element for MetaOxmlElement.""" - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" super(ZeroOrOne, self).populate_class_members(element_cls, prop_name) self._add_getter() @@ -604,9 +585,7 @@ def _add_remover(self): def _remove_child(obj: BaseOxmlElement): obj.remove_all(self._nsptagname) - _remove_child.__doc__ = ( - "Remove all ``<%s>`` child elements." - ) % self._nsptagname + _remove_child.__doc__ = ("Remove all ``<%s>`` child elements.") % self._nsptagname self._add_to_class(self._remove_method_name, _remove_child) @lazyproperty @@ -622,16 +601,12 @@ def __init__(self, choices: Sequence[Choice], successors: Tuple[str, ...] = ()): self._choices = choices self._successors = successors - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" super(ZeroOrOneChoice, self).populate_class_members(element_cls, prop_name) self._add_choice_getter() for choice in self._choices: - choice.populate_class_members( - element_cls, self._prop_name, self._successors - ) + choice.populate_class_members(element_cls, self._prop_name, self._successors) self._add_group_remover() def _add_choice_getter(self): @@ -649,9 +624,7 @@ def _remove_choice_group(obj: BaseOxmlElement): for tagname in self._member_nsptagnames: obj.remove_all(tagname) - _remove_choice_group.__doc__ = ( - "Remove the current choice group child element if present." - ) + _remove_choice_group.__doc__ = "Remove the current choice group child element if present." self._add_to_class(self._remove_choice_group_method_name, _remove_choice_group) @property @@ -680,9 +653,7 @@ def _remove_choice_group_method_name(self): # -- lxml typing isn't quite right here, just ignore this error on _Element -- -class BaseOxmlElement( # pyright: ignore[reportGeneralTypeIssues] - etree.ElementBase, metaclass=MetaOxmlElement -): +class BaseOxmlElement(etree.ElementBase, metaclass=MetaOxmlElement): """Effective base class for all custom element classes. Adds standardized behavior to all classes in one place. @@ -726,9 +697,7 @@ def xml(self) -> str: """ return serialize_for_reading(self) - def xpath( # pyright: ignore[reportIncompatibleMethodOverride] - self, xpath_str: str - ) -> Any: + def xpath(self, xpath_str: str) -> Any: # pyright: ignore[reportIncompatibleMethodOverride] """Override of `lxml` _Element.xpath() method. Provides standard Open XML namespace mapping (`nsmap`) in centralized location. diff --git a/src/docx/table.py b/src/docx/table.py index 31372284c..55cf77f41 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -2,27 +2,37 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Tuple, overload +from typing import TYPE_CHECKING, cast, overload +from typing_extensions import TypeAlias + +from docx import types as t from docx.blkcntnr import BlockItemContainer from docx.enum.style import WD_STYLE_TYPE +from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT from docx.oxml.simpletypes import ST_Merge -from docx.shared import Inches, Parented, lazyproperty +from docx.oxml.table import CT_TblGridCol +from docx.shared import Inches, Parented, StoryChild, lazyproperty if TYPE_CHECKING: - from docx import types as t - from docx.enum.table import WD_TABLE_ALIGNMENT, WD_TABLE_DIRECTION - from docx.oxml.table import CT_Tbl, CT_TblPr + from docx.enum.table import WD_ROW_HEIGHT_RULE, WD_TABLE_ALIGNMENT, WD_TABLE_DIRECTION + from docx.oxml.table import CT_Row, CT_Tbl, CT_TblPr, CT_Tc from docx.shared import Length - from docx.styles.style import _TableStyle # pyright: ignore[reportPrivateUsage] + from docx.styles.style import ( + ParagraphStyle, + _TableStyle, # pyright: ignore[reportPrivateUsage] + ) + +TableParent: TypeAlias = "Table | _Columns | _Rows" -class Table(Parented): +class Table(StoryChild): """Proxy class for a WordprocessingML ```` element.""" - def __init__(self, tbl: CT_Tbl, parent: t.StoryChild): + def __init__(self, tbl: CT_Tbl, parent: t.ProvidesStoryPart): super(Table, self).__init__(parent) - self._element = self._tbl = tbl + self._element = tbl + self._tbl = tbl def add_column(self, width: Length): """Return a |_Column| object of `width`, newly added rightmost to the table.""" @@ -40,7 +50,8 @@ def add_row(self): tr = tbl.add_tr() for gridCol in tbl.tblGrid.gridCol_lst: tc = tr.add_tc() - tc.width = gridCol.w + if gridCol.w is not None: + tc.width = gridCol.w return _Row(tr, self) @property @@ -79,7 +90,7 @@ def cell(self, row_idx: int, col_idx: int) -> _Cell: cell_idx = col_idx + (row_idx * self._column_count) return self._cells[cell_idx] - def column_cells(self, column_idx: int) -> List[_Cell]: + def column_cells(self, column_idx: int) -> list[_Cell]: """Sequence of cells in the column at `column_idx` in this table.""" cells = self._cells idxs = range(column_idx, len(cells), self._column_count) @@ -90,7 +101,7 @@ def columns(self): """|_Columns| instance representing the sequence of columns in this table.""" return _Columns(self._tbl, self) - def row_cells(self, row_idx: int) -> List[_Cell]: + def row_cells(self, row_idx: int) -> list[_Cell]: """Sequence of cells in the row at `row_idx` in this table.""" column_count = self._column_count start = row_idx * column_count @@ -116,7 +127,7 @@ def style(self) -> _TableStyle | None: `Light Shading - Accent 1` becomes `Light Shading Accent 1`. """ style_id = self._tbl.tblStyle_val - return self.part.get_style(style_id, WD_STYLE_TYPE.TABLE) + return cast("_TableStyle | None", self.part.get_style(style_id, WD_STYLE_TYPE.TABLE)) @style.setter def style(self, style_or_name: _TableStyle | None): @@ -140,21 +151,21 @@ def table_direction(self) -> WD_TABLE_DIRECTION | None: For example: `WD_TABLE_DIRECTION.LTR`. |None| indicates the value is inherited from the style hierarchy. """ - return self._element.bidiVisual_val + return cast("WD_TABLE_DIRECTION | None", self._tbl.bidiVisual_val) @table_direction.setter def table_direction(self, value: WD_TABLE_DIRECTION | None): self._element.bidiVisual_val = value @property - def _cells(self) -> List[_Cell]: + def _cells(self) -> list[_Cell]: """A sequence of |_Cell| objects, one for each cell of the layout grid. If the table contains a span, one or more |_Cell| object references are repeated. """ col_count = self._column_count - cells = [] + cells: list[_Cell] = [] for tc in self._tbl.iter_tcs(): for grid_span_idx in range(tc.grid_span): if tc.vMerge == ST_Merge.CONTINUE: @@ -178,11 +189,12 @@ def _tblPr(self) -> CT_TblPr: class _Cell(BlockItemContainer): """Table cell.""" - def __init__(self, tc, parent): - super(_Cell, self).__init__(tc, parent) + def __init__(self, tc: CT_Tc, parent: TableParent): + super(_Cell, self).__init__(tc, cast(t.ProvidesStoryPart, parent)) + self._parent = parent self._tc = self._element = tc - def add_paragraph(self, text="", style=None): + def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None): """Return a paragraph newly added to the end of the content in this cell. If present, `text` is added to the paragraph in a single run. If specified, the @@ -195,9 +207,12 @@ def add_paragraph(self, text="", style=None): """ return super(_Cell, self).add_paragraph(text, style) - def add_table(self, rows, cols): - """Return a table newly added to this cell after any existing cell content, - having `rows` rows and `cols` columns. + def add_table( # pyright: ignore[reportIncompatibleMethodOverride] + self, rows: int, cols: int + ) -> Table: + """Return a table newly added to this cell after any existing cell content. + + The new table will have `rows` rows and `cols` columns. An empty paragraph is added after the table because Word requires a paragraph element as the last element in every cell. @@ -207,7 +222,7 @@ def add_table(self, rows, cols): self.add_paragraph() return table - def merge(self, other_cell): + def merge(self, other_cell: _Cell): """Return a merged cell created by spanning the rectangular region having this cell and `other_cell` as diagonal corners. @@ -244,7 +259,7 @@ def text(self) -> str: return "\n".join(p.text for p in self.paragraphs) @text.setter - def text(self, text): + def text(self, text: str): """Write-only. Set entire contents of cell to the string `text`. Any existing content or @@ -270,7 +285,7 @@ def vertical_alignment(self): return tcPr.vAlign_val @vertical_alignment.setter - def vertical_alignment(self, value): + def vertical_alignment(self, value: WD_CELL_VERTICAL_ALIGNMENT | None): tcPr = self._element.get_or_add_tcPr() tcPr.vAlign_val = value @@ -280,34 +295,35 @@ def width(self): return self._tc.width @width.setter - def width(self, value): + def width(self, value: Length): self._tc.width = value class _Column(Parented): """Table column.""" - def __init__(self, gridCol, parent): + def __init__(self, gridCol: CT_TblGridCol, parent: TableParent): super(_Column, self).__init__(parent) + self._parent = parent self._gridCol = gridCol @property - def cells(self): + def cells(self) -> tuple[_Cell, ...]: """Sequence of |_Cell| instances corresponding to cells in this column.""" return tuple(self.table.column_cells(self._index)) @property - def table(self): + def table(self) -> Table: """Reference to the |Table| object this column belongs to.""" return self._parent.table @property - def width(self): + def width(self) -> Length | None: """The width of this column in EMU, or |None| if no explicit width is set.""" return self._gridCol.w @width.setter - def width(self, value): + def width(self, value: Length | None): self._gridCol.w = value @property @@ -322,11 +338,12 @@ class _Columns(Parented): Supports ``len()``, iteration and indexed access. """ - def __init__(self, tbl, parent): + def __init__(self, tbl: CT_Tbl, parent: TableParent): super(_Columns, self).__init__(parent) + self._parent = parent self._tbl = tbl - def __getitem__(self, idx): + def __getitem__(self, idx: int): """Provide indexed access, e.g. 'columns[0]'.""" try: gridCol = self._gridCol_lst[idx] @@ -343,7 +360,7 @@ def __len__(self): return len(self._gridCol_lst) @property - def table(self): + def table(self) -> Table: """Reference to the |Table| object this column collection belongs to.""" return self._parent.table @@ -358,42 +375,45 @@ def _gridCol_lst(self): class _Row(Parented): """Table row.""" - def __init__(self, tr, parent): + def __init__(self, tr: CT_Row, parent: TableParent): super(_Row, self).__init__(parent) + self._parent = parent self._tr = self._element = tr @property - def cells(self) -> Tuple[_Cell]: + def cells(self) -> tuple[_Cell, ...]: """Sequence of |_Cell| instances corresponding to cells in this row.""" return tuple(self.table.row_cells(self._index)) @property - def height(self): + def height(self) -> Length | None: """Return a |Length| object representing the height of this cell, or |None| if no explicit height is set.""" return self._tr.trHeight_val @height.setter - def height(self, value): + def height(self, value: Length | None): self._tr.trHeight_val = value @property - def height_rule(self): - """Return the height rule of this cell as a member of the :ref:`WdRowHeightRule` - enumeration, or |None| if no explicit height_rule is set.""" + def height_rule(self) -> WD_ROW_HEIGHT_RULE | None: + """Return the height rule of this cell as a member of the :ref:`WdRowHeightRule`. + + This value is |None| if no explicit height_rule is set. + """ return self._tr.trHeight_hRule @height_rule.setter - def height_rule(self, value): + def height_rule(self, value: WD_ROW_HEIGHT_RULE | None): self._tr.trHeight_hRule = value @property - def table(self): + def table(self) -> Table: """Reference to the |Table| object this row belongs to.""" return self._parent.table @property - def _index(self): + def _index(self) -> int: """Index of this row in its table, starting from zero.""" return self._tr.tr_idx @@ -404,19 +424,18 @@ class _Rows(Parented): Supports ``len()``, iteration, indexed access, and slicing. """ - def __init__(self, tbl, parent): + def __init__(self, tbl: CT_Tbl, parent: TableParent): super(_Rows, self).__init__(parent) + self._parent = parent self._tbl = tbl @overload - def __getitem__(self, idx: int) -> _Row: - ... + def __getitem__(self, idx: int) -> _Row: ... @overload - def __getitem__(self, idx: slice) -> List[_Row]: - ... + def __getitem__(self, idx: slice) -> list[_Row]: ... - def __getitem__(self, idx: int | slice) -> _Row | List[_Row]: + def __getitem__(self, idx: int | slice) -> _Row | list[_Row]: """Provide indexed access, (e.g. `rows[0]` or `rows[1:3]`)""" return list(self)[idx] @@ -427,6 +446,6 @@ def __len__(self): return len(self._tbl.tr_lst) @property - def table(self): + def table(self) -> Table: """Reference to the |Table| object this row collection belongs to.""" return self._parent.table diff --git a/tests/test_table.py b/tests/test_table.py index 0ef273e3f..eef4b1df1 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -1,7 +1,14 @@ +# pyright: reportPrivateUsage=false + """Test suite for the docx.table module.""" +from __future__ import annotations + +from typing import cast + import pytest +from docx.document import Document from docx.enum.style import WD_STYLE_TYPE from docx.enum.table import ( WD_ALIGN_VERTICAL, @@ -10,7 +17,7 @@ WD_TABLE_DIRECTION, ) from docx.oxml.parser import parse_xml -from docx.oxml.table import CT_Tc +from docx.oxml.table import CT_Tbl, CT_Tc from docx.parts.document import DocumentPart from docx.shared import Inches from docx.table import Table, _Cell, _Column, _Columns, _Row, _Rows @@ -20,7 +27,7 @@ from .oxml.unitdata.text import a_p from .unitutil.cxml import element, xml from .unitutil.file import snippet_seq -from .unitutil.mock import instance_mock, property_mock +from .unitutil.mock import FixtureRequest, Mock, instance_mock, property_mock class DescribeTable: @@ -89,14 +96,44 @@ def it_knows_it_is_the_table_its_children_belong_to(self, table_fixture): table = table_fixture assert table.table is table - def it_knows_its_direction(self, direction_get_fixture): - table, expected_value = direction_get_fixture - assert table.table_direction == expected_value - - def it_can_change_its_direction(self, direction_set_fixture): - table, new_value, expected_xml = direction_set_fixture + @pytest.mark.parametrize( + ("tbl_cxml", "expected_value"), + [ + # ("w:tbl/w:tblPr", None), + ("w:tbl/w:tblPr/w:bidiVisual", WD_TABLE_DIRECTION.RTL), + ("w:tbl/w:tblPr/w:bidiVisual{w:val=0}", WD_TABLE_DIRECTION.LTR), + ("w:tbl/w:tblPr/w:bidiVisual{w:val=on}", WD_TABLE_DIRECTION.RTL), + ], + ) + def it_knows_its_direction( + self, tbl_cxml: str, expected_value: WD_TABLE_DIRECTION | None, document_: Mock + ): + tbl = cast(CT_Tbl, element(tbl_cxml)) + assert Table(tbl, document_).table_direction == expected_value + + @pytest.mark.parametrize( + ("tbl_cxml", "new_value", "expected_cxml"), + [ + ("w:tbl/w:tblPr", WD_TABLE_DIRECTION.RTL, "w:tbl/w:tblPr/w:bidiVisual"), + ( + "w:tbl/w:tblPr/w:bidiVisual", + WD_TABLE_DIRECTION.LTR, + "w:tbl/w:tblPr/w:bidiVisual{w:val=0}", + ), + ( + "w:tbl/w:tblPr/w:bidiVisual{w:val=0}", + WD_TABLE_DIRECTION.RTL, + "w:tbl/w:tblPr/w:bidiVisual", + ), + ("w:tbl/w:tblPr/w:bidiVisual{w:val=1}", None, "w:tbl/w:tblPr"), + ], + ) + def it_can_change_its_direction( + self, tbl_cxml: str, new_value: WD_TABLE_DIRECTION, expected_cxml: str, document_: Mock + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) table.table_direction = new_value - assert table._element.xml == expected_xml + assert table._element.xml == xml(expected_cxml) def it_knows_its_table_style(self, style_get_fixture): table, style_id_, style_ = style_get_fixture @@ -245,41 +282,6 @@ def column_count_fixture(self): table = Table(element(tbl_cxml), None) return table, expected_value - @pytest.fixture( - params=[ - ("w:tbl/w:tblPr", None), - ("w:tbl/w:tblPr/w:bidiVisual", WD_TABLE_DIRECTION.RTL), - ("w:tbl/w:tblPr/w:bidiVisual{w:val=0}", WD_TABLE_DIRECTION.LTR), - ("w:tbl/w:tblPr/w:bidiVisual{w:val=on}", WD_TABLE_DIRECTION.RTL), - ] - ) - def direction_get_fixture(self, request): - tbl_cxml, expected_value = request.param - table = Table(element(tbl_cxml), None) - return table, expected_value - - @pytest.fixture( - params=[ - ("w:tbl/w:tblPr", WD_TABLE_DIRECTION.RTL, "w:tbl/w:tblPr/w:bidiVisual"), - ( - "w:tbl/w:tblPr/w:bidiVisual", - WD_TABLE_DIRECTION.LTR, - "w:tbl/w:tblPr/w:bidiVisual{w:val=0}", - ), - ( - "w:tbl/w:tblPr/w:bidiVisual{w:val=0}", - WD_TABLE_DIRECTION.RTL, - "w:tbl/w:tblPr/w:bidiVisual", - ), - ("w:tbl/w:tblPr/w:bidiVisual{w:val=1}", None, "w:tbl/w:tblPr"), - ] - ) - def direction_set_fixture(self, request): - tbl_cxml, new_value, expected_cxml = request.param - table = Table(element(tbl_cxml), None) - expected_xml = xml(expected_cxml) - return table, new_value, expected_xml - @pytest.fixture def row_cells_fixture(self, _cells_, _column_count_): table = Table(None, None) @@ -331,6 +333,10 @@ def _cells_(self, request): def _column_count_(self, request): return property_mock(request, Table, "_column_count") + @pytest.fixture + def document_(self, request: FixtureRequest): + return instance_mock(request, Document) + @pytest.fixture def document_part_(self, request): return instance_mock(request, DocumentPart) From cf5286cf8bbd0902a05b6d73d2b9c8ecf2eb697b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 27 Apr 2024 15:24:32 -0700 Subject: [PATCH 03/56] rfctr: modernize table tests --- features/steps/cell.py | 50 - features/steps/table.py | 165 ++- ...ble.feature => tbl-cell-add-table.feature} | 0 ...cel-text.feature => tbl-cell-text.feature} | 0 src/docx/oxml/table.py | 1 + src/docx/table.py | 2 +- tests/oxml/test_table.py | 479 ++++--- tests/oxml/unitdata/table.py | 88 -- tests/test_table.py | 1198 +++++++---------- 9 files changed, 857 insertions(+), 1126 deletions(-) delete mode 100644 features/steps/cell.py rename features/{cel-add-table.feature => tbl-cell-add-table.feature} (100%) rename features/{cel-text.feature => tbl-cell-text.feature} (100%) delete mode 100644 tests/oxml/unitdata/table.py diff --git a/features/steps/cell.py b/features/steps/cell.py deleted file mode 100644 index 10896872b..000000000 --- a/features/steps/cell.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Step implementations for table cell-related features.""" - -from behave import given, then, when - -from docx import Document - -from helpers import test_docx - -# given =================================================== - - -@given("a table cell") -def given_a_table_cell(context): - table = Document(test_docx("tbl-2x2-table")).tables[0] - context.cell = table.cell(0, 0) - - -# when ===================================================== - - -@when("I add a 2 x 2 table into the first cell") -def when_I_add_a_2x2_table_into_the_first_cell(context): - context.table_ = context.cell.add_table(2, 2) - - -@when("I assign a string to the cell text attribute") -def when_assign_string_to_cell_text_attribute(context): - cell = context.cell - text = "foobar" - cell.text = text - context.expected_text = text - - -# then ===================================================== - - -@then("cell.tables[0] is a 2 x 2 table") -def then_cell_tables_0_is_a_2x2_table(context): - cell = context.cell - table = cell.tables[0] - assert len(table.rows) == 2 - assert len(table.columns) == 2 - - -@then("the cell contains the string I assigned") -def then_cell_contains_string_assigned(context): - cell, expected_text = context.cell, context.expected_text - text = cell.paragraphs[0].runs[0].text - msg = "expected '%s', got '%s'" % (expected_text, text) - assert text == expected_text, msg diff --git a/features/steps/table.py b/features/steps/table.py index 95f2fab75..0b08f567c 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -1,6 +1,9 @@ +# pyright: reportPrivateUsage=false + """Step implementations for table-related features.""" from behave import given, then, when +from behave.runner import Context from docx import Document from docx.enum.table import ( @@ -10,7 +13,7 @@ WD_TABLE_DIRECTION, ) from docx.shared import Inches -from docx.table import _Column, _Columns, _Row, _Rows +from docx.table import Table, _Column, _Columns, _Row, _Rows from helpers import test_docx @@ -18,12 +21,12 @@ @given("a 2 x 2 table") -def given_a_2x2_table(context): +def given_a_2x2_table(context: Context): context.table_ = Document().add_table(rows=2, cols=2) @given("a 3x3 table having {span_state}") -def given_a_3x3_table_having_span_state(context, span_state): +def given_a_3x3_table_having_span_state(context: Context, span_state: str): table_idx = { "only uniform cells": 0, "a horizontal span": 1, @@ -35,7 +38,7 @@ def given_a_3x3_table_having_span_state(context, span_state): @given("a _Cell object with {state} vertical alignment as cell") -def given_a_Cell_object_with_vertical_alignment_as_cell(context, state): +def given_a_Cell_object_with_vertical_alignment_as_cell(context: Context, state: str): table_idx = { "inherited": 0, "bottom": 1, @@ -48,26 +51,32 @@ def given_a_Cell_object_with_vertical_alignment_as_cell(context, state): @given("a column collection having two columns") -def given_a_column_collection_having_two_columns(context): +def given_a_column_collection_having_two_columns(context: Context): docx_path = test_docx("blk-containing-table") document = Document(docx_path) context.columns = document.tables[0].columns @given("a row collection having two rows") -def given_a_row_collection_having_two_rows(context): +def given_a_row_collection_having_two_rows(context: Context): docx_path = test_docx("blk-containing-table") document = Document(docx_path) context.rows = document.tables[0].rows @given("a table") -def given_a_table(context): +def given_a_table(context: Context): context.table_ = Document().add_table(rows=2, cols=2) +@given("a table cell") +def given_a_table_cell(context: Context): + table = Document(test_docx("tbl-2x2-table")).tables[0] + context.cell = table.cell(0, 0) + + @given("a table cell having a width of {width}") -def given_a_table_cell_having_a_width_of_width(context, width): +def given_a_table_cell_having_a_width_of_width(context: Context, width: str): table_idx = {"no explicit setting": 0, "1 inch": 1, "2 inches": 2}[width] document = Document(test_docx("tbl-props")) table = document.tables[table_idx] @@ -76,7 +85,7 @@ def given_a_table_cell_having_a_width_of_width(context, width): @given("a table column having a width of {width_desc}") -def given_a_table_having_a_width_of_width_desc(context, width_desc): +def given_a_table_having_a_width_of_width_desc(context: Context, width_desc: str): col_idx = { "no explicit setting": 0, "1440": 1, @@ -87,7 +96,7 @@ def given_a_table_having_a_width_of_width_desc(context, width_desc): @given("a table having {alignment} alignment") -def given_a_table_having_alignment_alignment(context, alignment): +def given_a_table_having_alignment_alignment(context: Context, alignment: str): table_idx = { "inherited": 3, "left": 4, @@ -100,7 +109,7 @@ def given_a_table_having_alignment_alignment(context, alignment): @given("a table having an autofit layout of {autofit}") -def given_a_table_having_an_autofit_layout_of_autofit(context, autofit): +def given_a_table_having_an_autofit_layout_of_autofit(context: Context, autofit: str): tbl_idx = { "no explicit setting": 0, "autofit": 1, @@ -111,7 +120,7 @@ def given_a_table_having_an_autofit_layout_of_autofit(context, autofit): @given("a table having {style} style") -def given_a_table_having_style(context, style): +def given_a_table_having_style(context: Context, style: str): table_idx = { "no explicit": 0, "Table Grid": 1, @@ -123,14 +132,14 @@ def given_a_table_having_style(context, style): @given("a table having table direction set {setting}") -def given_a_table_having_table_direction_setting(context, setting): +def given_a_table_having_table_direction_setting(context: Context, setting: str): table_idx = ["to inherit", "right-to-left", "left-to-right"].index(setting) document = Document(test_docx("tbl-on-off-props")) context.table_ = document.tables[table_idx] @given("a table having two columns") -def given_a_table_having_two_columns(context): +def given_a_table_having_two_columns(context: Context): docx_path = test_docx("blk-containing-table") document = Document(docx_path) # context.table is used internally by behave, underscore added @@ -139,14 +148,14 @@ def given_a_table_having_two_columns(context): @given("a table having two rows") -def given_a_table_having_two_rows(context): +def given_a_table_having_two_rows(context: Context): docx_path = test_docx("blk-containing-table") document = Document(docx_path) context.table_ = document.tables[0] @given("a table row having height of {state}") -def given_a_table_row_having_height_of_state(context, state): +def given_a_table_row_having_height_of_state(context: Context, state: str): table_idx = {"no explicit setting": 0, "2 inches": 2, "3 inches": 3}[state] document = Document(test_docx("tbl-props")) table = document.tables[table_idx] @@ -154,10 +163,8 @@ def given_a_table_row_having_height_of_state(context, state): @given("a table row having height rule {state}") -def given_a_table_row_having_height_rule_state(context, state): - table_idx = {"no explicit setting": 0, "automatic": 1, "at least": 2, "exactly": 3}[ - state - ] +def given_a_table_row_having_height_rule_state(context: Context, state: str): + table_idx = {"no explicit setting": 0, "automatic": 1, "at least": 2, "exactly": 3}[state] document = Document(test_docx("tbl-props")) table = document.tables[table_idx] context.row = table.rows[0] @@ -167,35 +174,48 @@ def given_a_table_row_having_height_rule_state(context, state): @when("I add a 1.0 inch column to the table") -def when_I_add_a_1_inch_column_to_table(context): +def when_I_add_a_1_inch_column_to_table(context: Context): context.column = context.table_.add_column(Inches(1.0)) +@when("I add a 2 x 2 table into the first cell") +def when_I_add_a_2x2_table_into_the_first_cell(context: Context): + context.table_ = context.cell.add_table(2, 2) + + @when("I add a row to the table") -def when_add_row_to_table(context): +def when_add_row_to_table(context: Context): table = context.table_ context.row = table.add_row() +@when("I assign a string to the cell text attribute") +def when_assign_string_to_cell_text_attribute(context: Context): + cell = context.cell + text = "foobar" + cell.text = text + context.expected_text = text + + @when("I assign {value} to cell.vertical_alignment") -def when_I_assign_value_to_cell_vertical_alignment(context, value): +def when_I_assign_value_to_cell_vertical_alignment(context: Context, value: str): context.cell.vertical_alignment = eval(value) @when("I assign {value} to row.height") -def when_I_assign_value_to_row_height(context, value): +def when_I_assign_value_to_row_height(context: Context, value: str): new_value = None if value == "None" else int(value) context.row.height = new_value @when("I assign {value} to row.height_rule") -def when_I_assign_value_to_row_height_rule(context, value): +def when_I_assign_value_to_row_height_rule(context: Context, value: str): new_value = None if value == "None" else getattr(WD_ROW_HEIGHT_RULE, value) context.row.height_rule = new_value @when("I assign {value_str} to table.alignment") -def when_I_assign_value_to_table_alignment(context, value_str): +def when_I_assign_value_to_table_alignment(context: Context, value_str: str): value = { "None": None, "WD_TABLE_ALIGNMENT.LEFT": WD_TABLE_ALIGNMENT.LEFT, @@ -207,7 +227,7 @@ def when_I_assign_value_to_table_alignment(context, value_str): @when("I assign {value} to table.style") -def when_apply_value_to_table_style(context, value): +def when_apply_value_to_table_style(context: Context, value: str): table, styles = context.table_, context.document.styles if value == "None": new_value = None @@ -219,14 +239,14 @@ def when_apply_value_to_table_style(context, value): @when("I assign {value} to table.table_direction") -def when_assign_value_to_table_table_direction(context, value): +def when_assign_value_to_table_table_direction(context: Context, value: str): new_value = None if value == "None" else getattr(WD_TABLE_DIRECTION, value) context.table_.table_direction = new_value @when("I merge from cell {origin} to cell {other}") -def when_I_merge_from_cell_origin_to_cell_other(context, origin, other): - def cell(table, idx): +def when_I_merge_from_cell_origin_to_cell_other(context: Context, origin: str, other: str): + def cell(table: Table, idx: int): row, col = idx // 3, idx % 3 return table.cell(row, col) @@ -237,19 +257,19 @@ def cell(table, idx): @when("I set the cell width to {width}") -def when_I_set_the_cell_width_to_width(context, width): +def when_I_set_the_cell_width_to_width(context: Context, width: str): new_value = {"1 inch": Inches(1)}[width] context.cell.width = new_value @when("I set the column width to {width_emu}") -def when_I_set_the_column_width_to_width_emu(context, width_emu): +def when_I_set_the_column_width_to_width_emu(context: Context, width_emu: str): new_value = None if width_emu == "None" else int(width_emu) context.column.width = new_value @when("I set the table autofit to {setting}") -def when_I_set_the_table_autofit_to_setting(context, setting): +def when_I_set_the_table_autofit_to_setting(context: Context, setting: str): new_value = {"autofit": True, "fixed": False}[setting] table = context.table_ table.autofit = new_value @@ -258,21 +278,27 @@ def when_I_set_the_table_autofit_to_setting(context, setting): # then ===================================================== +@then("cell.tables[0] is a 2 x 2 table") +def then_cell_tables_0_is_a_2x2_table(context: Context): + cell = context.cell + table = cell.tables[0] + assert len(table.rows) == 2 + assert len(table.columns) == 2 + + @then("cell.vertical_alignment is {value}") -def then_cell_vertical_alignment_is_value(context, value): +def then_cell_vertical_alignment_is_value(context: Context, value: str): expected_value = { "None": None, "WD_ALIGN_VERTICAL.BOTTOM": WD_ALIGN_VERTICAL.BOTTOM, "WD_ALIGN_VERTICAL.CENTER": WD_ALIGN_VERTICAL.CENTER, }[value] actual_value = context.cell.vertical_alignment - assert actual_value is expected_value, ( - "cell.vertical_alignment is %s" % actual_value - ) + assert actual_value is expected_value, "cell.vertical_alignment is %s" % actual_value @then("I can access a collection column by index") -def then_can_access_collection_column_by_index(context): +def then_can_access_collection_column_by_index(context: Context): columns = context.columns for idx in range(2): column = columns[idx] @@ -280,7 +306,7 @@ def then_can_access_collection_column_by_index(context): @then("I can access a collection row by index") -def then_can_access_collection_row_by_index(context): +def then_can_access_collection_row_by_index(context: Context): rows = context.rows for idx in range(2): row = rows[idx] @@ -288,21 +314,21 @@ def then_can_access_collection_row_by_index(context): @then("I can access the column collection of the table") -def then_can_access_column_collection_of_table(context): +def then_can_access_column_collection_of_table(context: Context): table = context.table_ columns = table.columns assert isinstance(columns, _Columns) @then("I can access the row collection of the table") -def then_can_access_row_collection_of_table(context): +def then_can_access_row_collection_of_table(context: Context): table = context.table_ rows = table.rows assert isinstance(rows, _Rows) @then("I can iterate over the column collection") -def then_can_iterate_over_column_collection(context): +def then_can_iterate_over_column_collection(context: Context): columns = context.columns actual_count = 0 for column in columns: @@ -312,7 +338,7 @@ def then_can_iterate_over_column_collection(context): @then("I can iterate over the row collection") -def then_can_iterate_over_row_collection(context): +def then_can_iterate_over_row_collection(context: Context): rows = context.rows actual_count = 0 for row in rows: @@ -322,7 +348,7 @@ def then_can_iterate_over_row_collection(context): @then("row.height is {value}") -def then_row_height_is_value(context, value): +def then_row_height_is_value(context: Context, value: str): expected_height = None if value == "None" else int(value) actual_height = context.row.height assert actual_height == expected_height, "expected %s, got %s" % ( @@ -332,7 +358,7 @@ def then_row_height_is_value(context, value): @then("row.height_rule is {value}") -def then_row_height_rule_is_value(context, value): +def then_row_height_rule_is_value(context: Context, value: str): expected_rule = None if value == "None" else getattr(WD_ROW_HEIGHT_RULE, value) actual_rule = context.row.height_rule assert actual_rule == expected_rule, "expected %s, got %s" % ( @@ -342,7 +368,7 @@ def then_row_height_rule_is_value(context, value): @then("table.alignment is {value_str}") -def then_table_alignment_is_value(context, value_str): +def then_table_alignment_is_value(context: Context, value_str: str): value = { "None": None, "WD_TABLE_ALIGNMENT.LEFT": WD_TABLE_ALIGNMENT.LEFT, @@ -354,7 +380,7 @@ def then_table_alignment_is_value(context, value_str): @then("table.cell({row}, {col}).text is {expected_text}") -def then_table_cell_row_col_text_is_text(context, row, col, expected_text): +def then_table_cell_row_col_text_is_text(context: Context, row: str, col: str, expected_text: str): table = context.table_ row_idx, col_idx = int(row), int(col) cell_text = table.cell(row_idx, col_idx).text @@ -362,68 +388,76 @@ def then_table_cell_row_col_text_is_text(context, row, col, expected_text): @then("table.style is styles['{style_name}']") -def then_table_style_is_styles_style_name(context, style_name): +def then_table_style_is_styles_style_name(context: Context, style_name: str): table, styles = context.table_, context.document.styles expected_style = styles[style_name] assert table.style == expected_style, "got '%s'" % table.style @then("table.table_direction is {value}") -def then_table_table_direction_is_value(context, value): +def then_table_table_direction_is_value(context: Context, value: str): expected_value = None if value == "None" else getattr(WD_TABLE_DIRECTION, value) actual_value = context.table_.table_direction assert actual_value == expected_value, "got '%s'" % actual_value +@then("the cell contains the string I assigned") +def then_cell_contains_string_assigned(context: Context): + cell, expected_text = context.cell, context.expected_text + text = cell.paragraphs[0].runs[0].text + msg = "expected '%s', got '%s'" % (expected_text, text) + assert text == expected_text, msg + + @then("the column cells text is {expected_text}") -def then_the_column_cells_text_is_expected_text(context, expected_text): +def then_the_column_cells_text_is_expected_text(context: Context, expected_text: str): table = context.table_ cells_text = " ".join(c.text for col in table.columns for c in col.cells) assert cells_text == expected_text, "got %s" % cells_text @then("the length of the column collection is 2") -def then_len_of_column_collection_is_2(context): +def then_len_of_column_collection_is_2(context: Context): columns = context.table_.columns assert len(columns) == 2 @then("the length of the row collection is 2") -def then_len_of_row_collection_is_2(context): +def then_len_of_row_collection_is_2(context: Context): rows = context.table_.rows assert len(rows) == 2 @then("the new column has 2 cells") -def then_new_column_has_2_cells(context): +def then_new_column_has_2_cells(context: Context): assert len(context.column.cells) == 2 @then("the new column is 1.0 inches wide") -def then_new_column_is_1_inches_wide(context): +def then_new_column_is_1_inches_wide(context: Context): assert context.column.width == Inches(1) @then("the new row has 2 cells") -def then_new_row_has_2_cells(context): +def then_new_row_has_2_cells(context: Context): assert len(context.row.cells) == 2 @then("the reported autofit setting is {autofit}") -def then_the_reported_autofit_setting_is_autofit(context, autofit): +def then_the_reported_autofit_setting_is_autofit(context: Context, autofit: str): expected_value = {"autofit": True, "fixed": False}[autofit] table = context.table_ assert table.autofit is expected_value @then("the reported column width is {width_emu}") -def then_the_reported_column_width_is_width_emu(context, width_emu): +def then_the_reported_column_width_is_width_emu(context: Context, width_emu: str): expected_value = None if width_emu == "None" else int(width_emu) assert context.column.width == expected_value, "got %s" % context.column.width @then("the reported width of the cell is {width}") -def then_the_reported_width_of_the_cell_is_width(context, width): +def then_the_reported_width_of_the_cell_is_width(context: Context, width: str): expected_width = {"None": None, "1 inch": Inches(1)}[width] actual_width = context.cell.width assert actual_width == expected_width, "expected %s, got %s" % ( @@ -433,7 +467,7 @@ def then_the_reported_width_of_the_cell_is_width(context, width): @then("the row cells text is {encoded_text}") -def then_the_row_cells_text_is_expected_text(context, encoded_text): +def then_the_row_cells_text_is_expected_text(context: Context, encoded_text: str): expected_text = encoded_text.replace("\\", "\n") table = context.table_ cells_text = " ".join(c.text for row in table.rows for c in row.cells) @@ -441,32 +475,33 @@ def then_the_row_cells_text_is_expected_text(context, encoded_text): @then("the table has {count} columns") -def then_table_has_count_columns(context, count): +def then_table_has_count_columns(context: Context, count: str): column_count = int(count) columns = context.table_.columns assert len(columns) == column_count @then("the table has {count} rows") -def then_table_has_count_rows(context, count): +def then_table_has_count_rows(context: Context, count: str): row_count = int(count) rows = context.table_.rows assert len(rows) == row_count @then("the width of cell {n_str} is {inches_str} inches") -def then_the_width_of_cell_n_is_x_inches(context, n_str, inches_str): - def _cell(table, idx): +def then_the_width_of_cell_n_is_x_inches(context: Context, n_str: str, inches_str: str): + def _cell(table: Table, idx: int): row, col = idx // 3, idx % 3 return table.cell(row, col) idx, inches = int(n_str) - 1, float(inches_str) cell = _cell(context.table_, idx) + assert cell.width is not None assert cell.width == Inches(inches), "got %s" % cell.width.inches @then("the width of each cell is {inches} inches") -def then_the_width_of_each_cell_is_inches(context, inches): +def then_the_width_of_each_cell_is_inches(context: Context, inches: str): table = context.table_ expected_width = Inches(float(inches)) for cell in table._cells: @@ -474,7 +509,7 @@ def then_the_width_of_each_cell_is_inches(context, inches): @then("the width of each column is {inches} inches") -def then_the_width_of_each_column_is_inches(context, inches): +def then_the_width_of_each_column_is_inches(context: Context, inches: str): table = context.table_ expected_width = Inches(float(inches)) for column in table.columns: diff --git a/features/cel-add-table.feature b/features/tbl-cell-add-table.feature similarity index 100% rename from features/cel-add-table.feature rename to features/tbl-cell-add-table.feature diff --git a/features/cel-text.feature b/features/tbl-cell-text.feature similarity index 100% rename from features/cel-text.feature rename to features/tbl-cell-text.feature diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index da3c6b51d..e0aed09a3 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -51,6 +51,7 @@ class CT_Row(BaseOxmlElement): add_tc: Callable[[], CT_Tc] get_or_add_trPr: Callable[[], CT_TrPr] + _add_trPr: Callable[[], CT_TrPr] tc_lst: list[CT_Tc] # -- custom inserter below -- diff --git a/src/docx/table.py b/src/docx/table.py index 55cf77f41..709bc8dbb 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -130,7 +130,7 @@ def style(self) -> _TableStyle | None: return cast("_TableStyle | None", self.part.get_style(style_id, WD_STYLE_TYPE.TABLE)) @style.setter - def style(self, style_or_name: _TableStyle | None): + def style(self, style_or_name: _TableStyle | str | None): style_id = self.part.get_style_id(style_or_name, WD_STYLE_TYPE.TABLE) self._tbl.tblStyle_val = style_id diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 395c812a6..6a177ab77 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -1,3 +1,5 @@ +# pyright: reportPrivateUsage=false + """Test suite for the docx.oxml.text module.""" from __future__ import annotations @@ -13,50 +15,48 @@ from ..unitutil.cxml import element, xml from ..unitutil.file import snippet_seq -from ..unitutil.mock import call, instance_mock, method_mock, property_mock +from ..unitutil.mock import FixtureRequest, Mock, call, instance_mock, method_mock, property_mock class DescribeCT_Row: - def it_can_add_a_trPr(self, add_trPr_fixture): - tr, expected_xml = add_trPr_fixture - tr._add_trPr() - assert tr.xml == expected_xml - def it_raises_on_tc_at_grid_col(self, tc_raise_fixture): - tr, idx = tc_raise_fixture - with pytest.raises(ValueError): # noqa: PT011 - tr.tc_at_grid_col(idx) - - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tr_cxml", "expected_cxml"), + [ ("w:tr", "w:tr/w:trPr"), ("w:tr/w:tblPrEx", "w:tr/(w:tblPrEx,w:trPr)"), ("w:tr/w:tc", "w:tr/(w:trPr,w:tc)"), ("w:tr/(w:sdt,w:del,w:tc)", "w:tr/(w:trPr,w:sdt,w:del,w:tc)"), - ] + ], ) - def add_trPr_fixture(self, request): - tr_cxml, expected_cxml = request.param - tr = element(tr_cxml) - expected_xml = xml(expected_cxml) - return tr, expected_xml - - @pytest.fixture(params=[(0, 0, 3), (1, 0, 1)]) - def tc_raise_fixture(self, request): - snippet_idx, row_idx, col_idx = request.param - tbl = parse_xml(snippet_seq("tbl-cells")[snippet_idx]) - tr = tbl.tr_lst[row_idx] - return tr, col_idx + def it_can_add_a_trPr(self, tr_cxml: str, expected_cxml: str): + tr = cast(CT_Row, element(tr_cxml)) + tr._add_trPr() + assert tr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("snippet_idx", "row_idx", "col_idx", "err_msg"), + [ + (0, 0, 3, "index out of bounds"), + (1, 0, 1, "no cell on grid column 1"), + ], + ) + def it_raises_on_tc_at_grid_col( + self, snippet_idx: int, row_idx: int, col_idx: int, err_msg: str + ): + tr = cast(CT_Tbl, parse_xml(snippet_seq("tbl-cells")[snippet_idx])).tr_lst[row_idx] + with pytest.raises(ValueError, match=err_msg): + tr.tc_at_grid_col(col_idx) class DescribeCT_Tc: + """Unit-test suite for `docx.oxml.table.CT_Tc` objects.""" + def it_can_merge_to_another_tc( - self, tr_, _span_dimensions_, _tbl_, _grow_to_, top_tc_ + self, tr_: Mock, _span_dimensions_: Mock, _tbl_: Mock, _grow_to_: Mock, top_tc_: Mock ): top_tr_ = tr_ - tc, other_tc = element("w:tc"), element("w:tc") + tc, other_tc = cast(CT_Tc, element("w:tc")), cast(CT_Tc, element("w:tc")) top, left, height, width = 0, 1, 2, 3 _span_dimensions_.return_value = top, left, height, width _tbl_.return_value.tr_lst = [tr_] @@ -69,118 +69,9 @@ def it_can_merge_to_another_tc( top_tc_._grow_to.assert_called_once_with(width, height) assert merged_tc is top_tc_ - def it_knows_its_extents_to_help(self, extents_fixture): - tc, attr_name, expected_value = extents_fixture - extent = getattr(tc, attr_name) - assert extent == expected_value - - def it_calculates_the_dimensions_of_a_span_to_help(self, span_fixture): - tc, other_tc, expected_dimensions = span_fixture - dimensions = tc._span_dimensions(other_tc) - assert dimensions == expected_dimensions - - def it_raises_on_invalid_span(self, span_raise_fixture): - tc, other_tc = span_raise_fixture - with pytest.raises(InvalidSpanError): - tc._span_dimensions(other_tc) - - def it_can_grow_itself_to_help_merge(self, grow_to_fixture): - tc, width, height, top_tc, expected_calls = grow_to_fixture - tc._grow_to(width, height, top_tc) - assert tc._span_to_width.call_args_list == expected_calls - - def it_can_extend_its_horz_span_to_help_merge( - self, top_tc_, grid_span_, _move_content_to_, _swallow_next_tc_ - ): - grid_span_.side_effect = [1, 3, 4] - grid_width, vMerge = 4, "continue" - tc = element("w:tc") - - tc._span_to_width(grid_width, top_tc_, vMerge) - - _move_content_to_.assert_called_once_with(tc, top_tc_) - assert _swallow_next_tc_.call_args_list == [ - call(tc, grid_width, top_tc_), - call(tc, grid_width, top_tc_), - ] - assert tc.vMerge == vMerge - - def it_knows_its_inner_content_block_item_elements(self): - tc = cast(CT_Tc, element("w:tc/(w:p,w:tbl,w:p)")) - assert [type(e) for e in tc.inner_content_elements] == [CT_P, CT_Tbl, CT_P] - - def it_can_swallow_the_next_tc_help_merge(self, swallow_fixture): - tc, grid_width, top_tc, tr, expected_xml = swallow_fixture - tc._swallow_next_tc(grid_width, top_tc) - assert tr.xml == expected_xml - - def it_adds_cell_widths_on_swallow(self, add_width_fixture): - tc, grid_width, top_tc, tr, expected_xml = add_width_fixture - tc._swallow_next_tc(grid_width, top_tc) - assert tr.xml == expected_xml - - def it_raises_on_invalid_swallow(self, swallow_raise_fixture): - tc, grid_width, top_tc, tr = swallow_raise_fixture - with pytest.raises(InvalidSpanError): - tc._swallow_next_tc(grid_width, top_tc) - - def it_can_move_its_content_to_help_merge(self, move_fixture): - tc, tc_2, expected_tc_xml, expected_tc_2_xml = move_fixture - tc._move_content_to(tc_2) - assert tc.xml == expected_tc_xml - assert tc_2.xml == expected_tc_2_xml - - def it_raises_on_tr_above(self, tr_above_raise_fixture): - tc = tr_above_raise_fixture - with pytest.raises(ValueError, match="no tr above topmost tr"): - tc._tr_above - - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ - # both cells have a width - ( - "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p)," - "w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", - 0, - 2, - "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=2880,w:type=dxa}," - "w:gridSpan{w:val=2}),w:p))", - ), - # neither have a width - ( - "w:tr/(w:tc/w:p,w:tc/w:p)", - 0, - 2, - "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", - ), - # only second one has a width - ( - "w:tr/(w:tc/w:p," "w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", - 0, - 2, - "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", - ), - # only first one has a width - ( - "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p)," "w:tc/w:p)", - 0, - 2, - "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=1440,w:type=dxa}," - "w:gridSpan{w:val=2}),w:p))", - ), - ] - ) - def add_width_fixture(self, request): - tr_cxml, tc_idx, grid_width, expected_tr_cxml = request.param - tr = element(tr_cxml) - tc = top_tc = tr[tc_idx] - expected_tr_xml = xml(expected_tr_cxml) - return tc, grid_width, top_tc, tr, expected_tr_xml - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("snippet_idx", "row", "col", "attr_name", "expected_value"), + [ (0, 0, 0, "top", 0), (2, 0, 1, "top", 0), (2, 1, 1, "top", 0), @@ -195,63 +86,22 @@ def add_width_fixture(self, request): (4, 1, 1, "bottom", 3), (0, 0, 0, "right", 1), (1, 0, 0, "right", 2), - (0, 0, 0, "right", 1), (4, 2, 1, "right", 3), - ] + ], ) - def extents_fixture(self, request): - snippet_idx, row, col, attr_name, expected_value = request.param + def it_knows_its_extents_to_help( + self, snippet_idx: int, row: int, col: int, attr_name: str, expected_value: int + ): tbl = self._snippet_tbl(snippet_idx) tc = tbl.tr_lst[row].tc_lst[col] - return tc, attr_name, expected_value - @pytest.fixture( - params=[ - (0, 0, 0, 2, 1), - (0, 0, 1, 1, 2), - (0, 1, 1, 2, 2), - (1, 0, 0, 2, 2), - (2, 0, 0, 2, 2), - (2, 1, 2, 1, 2), - ] - ) - def grow_to_fixture(self, request, _span_to_width_): - snippet_idx, row, col, width, height = request.param - tbl = self._snippet_tbl(snippet_idx) - tc = tbl.tr_lst[row].tc_lst[col] - start = 0 if height == 1 else 1 - end = start + height - expected_calls = [ - call(width, tc, None), - call(width, tc, "restart"), - call(width, tc, "continue"), - call(width, tc, "continue"), - ][start:end] - return tc, width, height, None, expected_calls - - @pytest.fixture( - params=[ - ("w:tc/w:p", "w:tc/w:p", "w:tc/w:p", "w:tc/w:p"), - ("w:tc/w:p", "w:tc/w:p/w:r", "w:tc/w:p", "w:tc/w:p/w:r"), - ("w:tc/w:p/w:r", "w:tc/w:p", "w:tc/w:p", "w:tc/w:p/w:r"), - ("w:tc/(w:p/w:r,w:sdt)", "w:tc/w:p", "w:tc/w:p", "w:tc/(w:p/w:r,w:sdt)"), - ( - "w:tc/(w:p/w:r,w:sdt)", - "w:tc/(w:tbl,w:p)", - "w:tc/w:p", - "w:tc/(w:tbl,w:p/w:r,w:sdt)", - ), - ] - ) - def move_fixture(self, request): - tc_cxml, tc_2_cxml, expected_tc_cxml, expected_tc_2_cxml = request.param - tc, tc_2 = element(tc_cxml), element(tc_2_cxml) - expected_tc_xml = xml(expected_tc_cxml) - expected_tc_2_xml = xml(expected_tc_2_cxml) - return tc, tc_2, expected_tc_xml, expected_tc_2_xml - - @pytest.fixture( - params=[ + extent = getattr(tc, attr_name) + + assert extent == expected_value + + @pytest.mark.parametrize( + ("snippet_idx", "row", "col", "row_2", "col_2", "expected_value"), + [ (0, 0, 0, 0, 1, (0, 0, 1, 2)), (0, 0, 1, 2, 1, (0, 1, 3, 1)), (0, 2, 2, 1, 1, (1, 1, 2, 2)), @@ -262,17 +112,28 @@ def move_fixture(self, request): (2, 0, 1, 1, 0, (0, 0, 2, 2)), (2, 1, 2, 0, 1, (0, 1, 2, 2)), (4, 0, 1, 0, 0, (0, 0, 1, 3)), - ] + ], ) - def span_fixture(self, request): - snippet_idx, row, col, row_2, col_2, expected_value = request.param + def it_calculates_the_dimensions_of_a_span_to_help( + self, + snippet_idx: int, + row: int, + col: int, + row_2: int, + col_2: int, + expected_value: tuple[int, int, int, int], + ): tbl = self._snippet_tbl(snippet_idx) tc = tbl.tr_lst[row].tc_lst[col] - tc_2 = tbl.tr_lst[row_2].tc_lst[col_2] - return tc, tc_2, expected_value + other_tc = tbl.tr_lst[row_2].tc_lst[col_2] + + dimensions = tc._span_dimensions(other_tc) - @pytest.fixture( - params=[ + assert dimensions == expected_value + + @pytest.mark.parametrize( + ("snippet_idx", "row", "col", "row_2", "col_2"), + [ (1, 0, 0, 1, 0), # inverted-L horz (1, 1, 0, 0, 0), # same in opposite order (2, 0, 2, 0, 1), # inverted-L vert @@ -280,17 +141,72 @@ def span_fixture(self, request): (5, 1, 0, 2, 1), # same, opposite side (6, 1, 0, 0, 1), # tee-shape vert bar (6, 0, 1, 1, 2), # same, opposite side - ] + ], ) - def span_raise_fixture(self, request): - snippet_idx, row, col, row_2, col_2 = request.param + def it_raises_on_invalid_span( + self, snippet_idx: int, row: int, col: int, row_2: int, col_2: int + ): tbl = self._snippet_tbl(snippet_idx) tc = tbl.tr_lst[row].tc_lst[col] - tc_2 = tbl.tr_lst[row_2].tc_lst[col_2] - return tc, tc_2 + other_tc = tbl.tr_lst[row_2].tc_lst[col_2] + + with pytest.raises(InvalidSpanError): + tc._span_dimensions(other_tc) + + @pytest.mark.parametrize( + ("snippet_idx", "row", "col", "width", "height"), + [ + (0, 0, 0, 2, 1), + (0, 0, 1, 1, 2), + (0, 1, 1, 2, 2), + (1, 0, 0, 2, 2), + (2, 0, 0, 2, 2), + (2, 1, 2, 1, 2), + ], + ) + def it_can_grow_itself_to_help_merge( + self, snippet_idx: int, row: int, col: int, width: int, height: int, _span_to_width_: Mock + ): + tbl = self._snippet_tbl(snippet_idx) + tc = tbl.tr_lst[row].tc_lst[col] + start = 0 if height == 1 else 1 + end = start + height + + tc._grow_to(width, height, None) - @pytest.fixture( - params=[ + assert ( + _span_to_width_.call_args_list + == [ + call(width, tc, None), + call(width, tc, "restart"), + call(width, tc, "continue"), + call(width, tc, "continue"), + ][start:end] + ) + + def it_can_extend_its_horz_span_to_help_merge( + self, top_tc_: Mock, grid_span_: Mock, _move_content_to_: Mock, _swallow_next_tc_: Mock + ): + grid_span_.side_effect = [1, 3, 4] + grid_width, vMerge = 4, "continue" + tc = cast(CT_Tc, element("w:tc")) + + tc._span_to_width(grid_width, top_tc_, vMerge) + + _move_content_to_.assert_called_once_with(tc, top_tc_) + assert _swallow_next_tc_.call_args_list == [ + call(tc, grid_width, top_tc_), + call(tc, grid_width, top_tc_), + ] + assert tc.vMerge == vMerge + + def it_knows_its_inner_content_block_item_elements(self): + tc = cast(CT_Tc, element("w:tc/(w:p,w:tbl,w:p)")) + assert [type(e) for e in tc.inner_content_elements] == [CT_P, CT_Tbl, CT_P] + + @pytest.mark.parametrize( + ("tr_cxml", "tc_idx", "grid_width", "expected_cxml"), + [ ( "w:tr/(w:tc/w:p,w:tc/w:p)", 0, @@ -307,8 +223,7 @@ def span_raise_fixture(self, request): 'w:tr/(w:tc/w:p/w:r/w:t"a",w:tc/w:p/w:r/w:t"b")', 0, 2, - 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p/w:r/w:t"a",' - 'w:p/w:r/w:t"b"))', + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p/w:r/w:t"a",' 'w:p/w:r/w:t"b"))', ), ( "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p),w:tc/w:p)", @@ -322,75 +237,145 @@ def span_raise_fixture(self, request): 3, "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=3},w:p))", ), - ] + ], + ) + def it_can_swallow_the_next_tc_help_merge( + self, tr_cxml: str, tc_idx: int, grid_width: int, expected_cxml: str + ): + tr = cast(CT_Row, element(tr_cxml)) + tc = top_tc = tr.tc_lst[tc_idx] + + tc._swallow_next_tc(grid_width, top_tc) + + assert tr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tr_cxml", "tc_idx", "grid_width", "expected_cxml"), + [ + # both cells have a width + ( + "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p)," + "w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", + 0, + 2, + "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=2880,w:type=dxa}," "w:gridSpan{w:val=2}),w:p))", + ), + # neither have a width + ( + "w:tr/(w:tc/w:p,w:tc/w:p)", + 0, + 2, + "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", + ), + # only second one has a width + ( + "w:tr/(w:tc/w:p," "w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", + 0, + 2, + "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", + ), + # only first one has a width + ( + "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p)," "w:tc/w:p)", + 0, + 2, + "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=1440,w:type=dxa}," "w:gridSpan{w:val=2}),w:p))", + ), + ], ) - def swallow_fixture(self, request): - tr_cxml, tc_idx, grid_width, expected_tr_cxml = request.param - tr = element(tr_cxml) - tc = top_tc = tr[tc_idx] - expected_tr_xml = xml(expected_tr_cxml) - return tc, grid_width, top_tc, tr, expected_tr_xml - - @pytest.fixture( - params=[ + def it_adds_cell_widths_on_swallow( + self, tr_cxml: str, tc_idx: int, grid_width: int, expected_cxml: str + ): + tr = cast(CT_Row, element(tr_cxml)) + tc = top_tc = tr.tc_lst[tc_idx] + tc._swallow_next_tc(grid_width, top_tc) + assert tr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tr_cxml", "tc_idx", "grid_width"), + [ ("w:tr/w:tc/w:p", 0, 2), ("w:tr/(w:tc/w:p,w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", 0, 2), - ] + ], ) - def swallow_raise_fixture(self, request): - tr_cxml, tc_idx, grid_width = request.param - tr = element(tr_cxml) - tc = top_tc = tr[tc_idx] - return tc, grid_width, top_tc, tr - - @pytest.fixture(params=[(0, 0, 0), (4, 0, 0)]) - def tr_above_raise_fixture(self, request): - snippet_idx, row_idx, col_idx = request.param - tbl = parse_xml(snippet_seq("tbl-cells")[snippet_idx]) + def it_raises_on_invalid_swallow(self, tr_cxml: str, tc_idx: int, grid_width: int): + tr = cast(CT_Row, element(tr_cxml)) + tc = top_tc = tr.tc_lst[tc_idx] + + with pytest.raises(InvalidSpanError): + tc._swallow_next_tc(grid_width, top_tc) + + @pytest.mark.parametrize( + ("tc_cxml", "tc_2_cxml", "expected_tc_cxml", "expected_tc_2_cxml"), + [ + ("w:tc/w:p", "w:tc/w:p", "w:tc/w:p", "w:tc/w:p"), + ("w:tc/w:p", "w:tc/w:p/w:r", "w:tc/w:p", "w:tc/w:p/w:r"), + ("w:tc/w:p/w:r", "w:tc/w:p", "w:tc/w:p", "w:tc/w:p/w:r"), + ("w:tc/(w:p/w:r,w:sdt)", "w:tc/w:p", "w:tc/w:p", "w:tc/(w:p/w:r,w:sdt)"), + ( + "w:tc/(w:p/w:r,w:sdt)", + "w:tc/(w:tbl,w:p)", + "w:tc/w:p", + "w:tc/(w:tbl,w:p/w:r,w:sdt)", + ), + ], + ) + def it_can_move_its_content_to_help_merge( + self, tc_cxml: str, tc_2_cxml: str, expected_tc_cxml: str, expected_tc_2_cxml: str + ): + tc, tc_2 = cast(CT_Tc, element(tc_cxml)), cast(CT_Tc, element(tc_2_cxml)) + + tc._move_content_to(tc_2) + + assert tc.xml == xml(expected_tc_cxml) + assert tc_2.xml == xml(expected_tc_2_cxml) + + @pytest.mark.parametrize(("snippet_idx", "row_idx", "col_idx"), [(0, 0, 0), (4, 0, 0)]) + def it_raises_on_tr_above(self, snippet_idx: int, row_idx: int, col_idx: int): + tbl = cast(CT_Tbl, parse_xml(snippet_seq("tbl-cells")[snippet_idx])) tc = tbl.tr_lst[row_idx].tc_lst[col_idx] - return tc - # fixture components --------------------------------------------- + with pytest.raises(ValueError, match="no tr above topmost tr"): + tc._tr_above + + # fixtures ------------------------------------------------------- @pytest.fixture - def grid_span_(self, request): + def grid_span_(self, request: FixtureRequest): return property_mock(request, CT_Tc, "grid_span") @pytest.fixture - def _grow_to_(self, request): + def _grow_to_(self, request: FixtureRequest): return method_mock(request, CT_Tc, "_grow_to") @pytest.fixture - def _move_content_to_(self, request): + def _move_content_to_(self, request: FixtureRequest): return method_mock(request, CT_Tc, "_move_content_to") @pytest.fixture - def _span_dimensions_(self, request): + def _span_dimensions_(self, request: FixtureRequest): return method_mock(request, CT_Tc, "_span_dimensions") @pytest.fixture - def _span_to_width_(self, request): + def _span_to_width_(self, request: FixtureRequest): return method_mock(request, CT_Tc, "_span_to_width", autospec=False) - def _snippet_tbl(self, idx): - """ - Return a element for snippet at `idx` in 'tbl-cells' snippet - file. - """ - return parse_xml(snippet_seq("tbl-cells")[idx]) + def _snippet_tbl(self, idx: int) -> CT_Tbl: + """A element for snippet at `idx` in 'tbl-cells' snippet file.""" + return cast(CT_Tbl, parse_xml(snippet_seq("tbl-cells")[idx])) @pytest.fixture - def _swallow_next_tc_(self, request): + def _swallow_next_tc_(self, request: FixtureRequest): return method_mock(request, CT_Tc, "_swallow_next_tc") @pytest.fixture - def _tbl_(self, request): + def _tbl_(self, request: FixtureRequest): return property_mock(request, CT_Tc, "_tbl") @pytest.fixture - def top_tc_(self, request): + def top_tc_(self, request: FixtureRequest): return instance_mock(request, CT_Tc) @pytest.fixture - def tr_(self, request): + def tr_(self, request: FixtureRequest): return instance_mock(request, CT_Row) diff --git a/tests/oxml/unitdata/table.py b/tests/oxml/unitdata/table.py deleted file mode 100644 index 4f760c1a8..000000000 --- a/tests/oxml/unitdata/table.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Test data builders for text XML elements.""" - -from ...unitdata import BaseBuilder -from .shared import CT_StringBuilder - - -class CT_RowBuilder(BaseBuilder): - __tag__ = "w:tr" - __nspfxs__ = ("w",) - __attrs__ = ("w:w",) - - -class CT_TblBuilder(BaseBuilder): - __tag__ = "w:tbl" - __nspfxs__ = ("w",) - __attrs__ = () - - -class CT_TblGridBuilder(BaseBuilder): - __tag__ = "w:tblGrid" - __nspfxs__ = ("w",) - __attrs__ = ("w:w",) - - -class CT_TblGridColBuilder(BaseBuilder): - __tag__ = "w:gridCol" - __nspfxs__ = ("w",) - __attrs__ = ("w:w",) - - -class CT_TblPrBuilder(BaseBuilder): - __tag__ = "w:tblPr" - __nspfxs__ = ("w",) - __attrs__ = () - - -class CT_TblWidthBuilder(BaseBuilder): - __tag__ = "w:tblW" - __nspfxs__ = ("w",) - __attrs__ = ("w:w", "w:type") - - -class CT_TcBuilder(BaseBuilder): - __tag__ = "w:tc" - __nspfxs__ = ("w",) - __attrs__ = ("w:id",) - - -class CT_TcPrBuilder(BaseBuilder): - __tag__ = "w:tcPr" - __nspfxs__ = ("w",) - __attrs__ = () - - -def a_gridCol(): - return CT_TblGridColBuilder() - - -def a_tbl(): - return CT_TblBuilder() - - -def a_tblGrid(): - return CT_TblGridBuilder() - - -def a_tblPr(): - return CT_TblPrBuilder() - - -def a_tblStyle(): - return CT_StringBuilder("w:tblStyle") - - -def a_tblW(): - return CT_TblWidthBuilder() - - -def a_tc(): - return CT_TcBuilder() - - -def a_tcPr(): - return CT_TcPrBuilder() - - -def a_tr(): - return CT_RowBuilder() diff --git a/tests/test_table.py b/tests/test_table.py index eef4b1df1..65f7cb423 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -17,37 +17,45 @@ WD_TABLE_DIRECTION, ) from docx.oxml.parser import parse_xml -from docx.oxml.table import CT_Tbl, CT_Tc +from docx.oxml.table import CT_Row, CT_Tbl, CT_TblGridCol, CT_Tc from docx.parts.document import DocumentPart -from docx.shared import Inches +from docx.shared import Emu, Inches, Length from docx.table import Table, _Cell, _Column, _Columns, _Row, _Rows from docx.text.paragraph import Paragraph -from .oxml.unitdata.table import a_gridCol, a_tbl, a_tblGrid, a_tc, a_tr -from .oxml.unitdata.text import a_p from .unitutil.cxml import element, xml from .unitutil.file import snippet_seq from .unitutil.mock import FixtureRequest, Mock, instance_mock, property_mock class DescribeTable: - def it_can_add_a_row(self, add_row_fixture): - table, expected_xml = add_row_fixture + """Unit-test suite for `docx.table._Rows` objects.""" + + def it_can_add_a_row(self, document_: Mock): + snippets = snippet_seq("add-row-col") + tbl = cast(CT_Tbl, parse_xml(snippets[0])) + table = Table(tbl, document_) + row = table.add_row() - assert table._tbl.xml == expected_xml + + assert table._tbl.xml == snippets[1] assert isinstance(row, _Row) assert row._tr is table._tbl.tr_lst[-1] assert row._parent is table - def it_can_add_a_column(self, add_column_fixture): - table, width, expected_xml = add_column_fixture - column = table.add_column(width) - assert table._tbl.xml == expected_xml + def it_can_add_a_column(self, document_: Mock): + snippets = snippet_seq("add-row-col") + tbl = cast(CT_Tbl, parse_xml(snippets[0])) + table = Table(tbl, document_) + + column = table.add_column(Inches(1.5)) + + assert table._tbl.xml == snippets[2] assert isinstance(column, _Column) assert column._gridCol is table._tbl.tblGrid.gridCol_lst[-1] assert column._parent is table - def it_provides_access_to_a_cell_by_row_and_col_indices(self, table): + def it_provides_access_to_a_cell_by_row_and_col_indices(self, table: Table): for row_idx in range(2): for col_idx in range(2): cell = table.cell(row_idx, col_idx) @@ -56,50 +64,121 @@ def it_provides_access_to_a_cell_by_row_and_col_indices(self, table): tc = tr.tc_lst[col_idx] assert tc is cell._tc - def it_provides_access_to_the_table_rows(self, table): + def it_provides_access_to_the_table_rows(self, table: Table): rows = table.rows assert isinstance(rows, _Rows) - def it_provides_access_to_the_table_columns(self, table): + def it_provides_access_to_the_table_columns(self, table: Table): columns = table.columns assert isinstance(columns, _Columns) - def it_provides_access_to_the_cells_in_a_column(self, col_cells_fixture): - table, column_idx, expected_cells = col_cells_fixture + def it_provides_access_to_the_cells_in_a_column( + self, _cells_: Mock, _column_count_: Mock, document_: Mock + ): + table = Table(cast(CT_Tbl, element("w:tbl")), document_) + _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] + _column_count_.return_value = 3 + column_idx = 1 + column_cells = table.column_cells(column_idx) - assert column_cells == expected_cells - def it_provides_access_to_the_cells_in_a_row(self, row_cells_fixture): - table, row_idx, expected_cells = row_cells_fixture - row_cells = table.row_cells(row_idx) - assert row_cells == expected_cells + assert column_cells == [1, 4, 7] + + def it_provides_access_to_the_cells_in_a_row( + self, _cells_: Mock, _column_count_: Mock, document_: Mock + ): + table = Table(cast(CT_Tbl, element("w:tbl")), document_) + _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] + _column_count_.return_value = 3 + + row_cells = table.row_cells(1) + + assert row_cells == [3, 4, 5] - def it_knows_its_alignment_setting(self, alignment_get_fixture): - table, expected_value = alignment_get_fixture + @pytest.mark.parametrize( + ("tbl_cxml", "expected_value"), + [ + ("w:tbl/w:tblPr", None), + ("w:tbl/w:tblPr/w:jc{w:val=center}", WD_TABLE_ALIGNMENT.CENTER), + ("w:tbl/w:tblPr/w:jc{w:val=right}", WD_TABLE_ALIGNMENT.RIGHT), + ("w:tbl/w:tblPr/w:jc{w:val=left}", WD_TABLE_ALIGNMENT.LEFT), + ], + ) + def it_knows_its_alignment_setting( + self, tbl_cxml: str, expected_value: WD_TABLE_ALIGNMENT | None, document_: Mock + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) assert table.alignment == expected_value - def it_can_change_its_alignment_setting(self, alignment_set_fixture): - table, new_value, expected_xml = alignment_set_fixture + @pytest.mark.parametrize( + ("tbl_cxml", "new_value", "expected_cxml"), + [ + ("w:tbl/w:tblPr", WD_TABLE_ALIGNMENT.LEFT, "w:tbl/w:tblPr/w:jc{w:val=left}"), + ( + "w:tbl/w:tblPr/w:jc{w:val=left}", + WD_TABLE_ALIGNMENT.RIGHT, + "w:tbl/w:tblPr/w:jc{w:val=right}", + ), + ("w:tbl/w:tblPr/w:jc{w:val=right}", None, "w:tbl/w:tblPr"), + ], + ) + def it_can_change_its_alignment_setting( + self, + tbl_cxml: str, + new_value: WD_TABLE_ALIGNMENT | None, + expected_cxml: str, + document_: Mock, + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) table.alignment = new_value - assert table._tbl.xml == expected_xml + assert table._tbl.xml == xml(expected_cxml) - def it_knows_whether_it_should_autofit(self, autofit_get_fixture): - table, expected_value = autofit_get_fixture + @pytest.mark.parametrize( + ("tbl_cxml", "expected_value"), + [ + ("w:tbl/w:tblPr", True), + ("w:tbl/w:tblPr/w:tblLayout", True), + ("w:tbl/w:tblPr/w:tblLayout{w:type=autofit}", True), + ("w:tbl/w:tblPr/w:tblLayout{w:type=fixed}", False), + ], + ) + def it_knows_whether_it_should_autofit( + self, tbl_cxml: str, expected_value: bool, document_: Mock + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) assert table.autofit is expected_value - def it_can_change_its_autofit_setting(self, autofit_set_fixture): - table, new_value, expected_xml = autofit_set_fixture + @pytest.mark.parametrize( + ("tbl_cxml", "new_value", "expected_cxml"), + [ + ("w:tbl/w:tblPr", True, "w:tbl/w:tblPr/w:tblLayout{w:type=autofit}"), + ("w:tbl/w:tblPr", False, "w:tbl/w:tblPr/w:tblLayout{w:type=fixed}"), + ( + "w:tbl/w:tblPr/w:tblLayout{w:type=fixed}", + True, + "w:tbl/w:tblPr/w:tblLayout{w:type=autofit}", + ), + ( + "w:tbl/w:tblPr/w:tblLayout{w:type=autofit}", + False, + "w:tbl/w:tblPr/w:tblLayout{w:type=fixed}", + ), + ], + ) + def it_can_change_its_autofit_setting( + self, tbl_cxml: str, new_value: bool, expected_cxml: str, document_: Mock + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) table.autofit = new_value - assert table._tbl.xml == expected_xml + assert table._tbl.xml == xml(expected_cxml) - def it_knows_it_is_the_table_its_children_belong_to(self, table_fixture): - table = table_fixture + def it_knows_it_is_the_table_its_children_belong_to(self, table: Table): assert table.table is table @pytest.mark.parametrize( ("tbl_cxml", "expected_value"), [ - # ("w:tbl/w:tblPr", None), + ("w:tbl/w:tblPr", None), ("w:tbl/w:tblPr/w:bidiVisual", WD_TABLE_DIRECTION.RTL), ("w:tbl/w:tblPr/w:bidiVisual{w:val=0}", WD_TABLE_DIRECTION.LTR), ("w:tbl/w:tblPr/w:bidiVisual{w:val=on}", WD_TABLE_DIRECTION.RTL), @@ -135,202 +214,95 @@ def it_can_change_its_direction( table.table_direction = new_value assert table._element.xml == xml(expected_cxml) - def it_knows_its_table_style(self, style_get_fixture): - table, style_id_, style_ = style_get_fixture - style = table.style - table.part.get_style.assert_called_once_with(style_id_, WD_STYLE_TYPE.TABLE) - assert style is style_ - - def it_can_change_its_table_style(self, style_set_fixture): - table, value, expected_xml = style_set_fixture - table.style = value - table.part.get_style_id.assert_called_once_with(value, WD_STYLE_TYPE.TABLE) - assert table._tbl.xml == expected_xml - - def it_provides_access_to_its_cells_to_help(self, cells_fixture): - table, cell_count, unique_count, matches = cells_fixture - cells = table._cells - assert len(cells) == cell_count - assert len(set(cells)) == unique_count - for matching_idxs in matches: - comparator_idx = matching_idxs[0] - for idx in matching_idxs[1:]: - assert cells[idx] is cells[comparator_idx] + def it_knows_its_table_style(self, part_prop_: Mock, document_part_: Mock, document_: Mock): + part_prop_.return_value = document_part_ + style_ = document_part_.get_style.return_value + table = Table(cast(CT_Tbl, element("w:tbl/w:tblPr/w:tblStyle{w:val=BarBaz}")), document_) - def it_knows_its_column_count_to_help(self, column_count_fixture): - table, expected_value = column_count_fixture - column_count = table._column_count - assert column_count == expected_value - - # fixtures ------------------------------------------------------- - - @pytest.fixture - def add_column_fixture(self): - snippets = snippet_seq("add-row-col") - tbl = parse_xml(snippets[0]) - table = Table(tbl, None) - width = Inches(1.5) - expected_xml = snippets[2] - return table, width, expected_xml - - @pytest.fixture - def add_row_fixture(self): - snippets = snippet_seq("add-row-col") - tbl = parse_xml(snippets[0]) - table = Table(tbl, None) - expected_xml = snippets[1] - return table, expected_xml + style = table.style - @pytest.fixture( - params=[ - ("w:tbl/w:tblPr", None), - ("w:tbl/w:tblPr/w:jc{w:val=center}", WD_TABLE_ALIGNMENT.CENTER), - ("w:tbl/w:tblPr/w:jc{w:val=right}", WD_TABLE_ALIGNMENT.RIGHT), - ("w:tbl/w:tblPr/w:jc{w:val=left}", WD_TABLE_ALIGNMENT.LEFT), - ] - ) - def alignment_get_fixture(self, request): - tbl_cxml, expected_value = request.param - table = Table(element(tbl_cxml), None) - return table, expected_value + document_part_.get_style.assert_called_once_with("BarBaz", WD_STYLE_TYPE.TABLE) + assert style is style_ - @pytest.fixture( - params=[ - ( - "w:tbl/w:tblPr", - WD_TABLE_ALIGNMENT.LEFT, - "w:tbl/w:tblPr/w:jc{w:val=left}", - ), + @pytest.mark.parametrize( + ("tbl_cxml", "new_value", "style_id", "expected_cxml"), + [ + ("w:tbl/w:tblPr", "Tbl A", "TblA", "w:tbl/w:tblPr/w:tblStyle{w:val=TblA}"), ( - "w:tbl/w:tblPr/w:jc{w:val=left}", - WD_TABLE_ALIGNMENT.RIGHT, - "w:tbl/w:tblPr/w:jc{w:val=right}", + "w:tbl/w:tblPr/w:tblStyle{w:val=TblA}", + "Tbl B", + "TblB", + "w:tbl/w:tblPr/w:tblStyle{w:val=TblB}", ), - ("w:tbl/w:tblPr/w:jc{w:val=right}", None, "w:tbl/w:tblPr"), - ] - ) - def alignment_set_fixture(self, request): - tbl_cxml, new_value, expected_tbl_cxml = request.param - table = Table(element(tbl_cxml), None) - expected_xml = xml(expected_tbl_cxml) - return table, new_value, expected_xml - - @pytest.fixture( - params=[ - ("w:tbl/w:tblPr", True), - ("w:tbl/w:tblPr/w:tblLayout", True), - ("w:tbl/w:tblPr/w:tblLayout{w:type=autofit}", True), - ("w:tbl/w:tblPr/w:tblLayout{w:type=fixed}", False), - ] + ("w:tbl/w:tblPr/w:tblStyle{w:val=TblB}", None, None, "w:tbl/w:tblPr"), + ], ) - def autofit_get_fixture(self, request): - tbl_cxml, expected_autofit = request.param - table = Table(element(tbl_cxml), None) - return table, expected_autofit + def it_can_change_its_table_style( + self, + tbl_cxml: str, + new_value: str | None, + style_id: str | None, + expected_cxml: str, + document_: Mock, + part_prop_: Mock, + document_part_: Mock, + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) + part_prop_.return_value = document_part_ + document_part_.get_style_id.return_value = style_id - @pytest.fixture( - params=[ - ("w:tbl/w:tblPr", True, "w:tbl/w:tblPr/w:tblLayout{w:type=autofit}"), - ("w:tbl/w:tblPr", False, "w:tbl/w:tblPr/w:tblLayout{w:type=fixed}"), - ("w:tbl/w:tblPr", None, "w:tbl/w:tblPr/w:tblLayout{w:type=fixed}"), - ( - "w:tbl/w:tblPr/w:tblLayout{w:type=fixed}", - True, - "w:tbl/w:tblPr/w:tblLayout{w:type=autofit}", - ), - ( - "w:tbl/w:tblPr/w:tblLayout{w:type=autofit}", - False, - "w:tbl/w:tblPr/w:tblLayout{w:type=fixed}", - ), - ] - ) - def autofit_set_fixture(self, request): - tbl_cxml, new_value, expected_tbl_cxml = request.param - table = Table(element(tbl_cxml), None) - expected_xml = xml(expected_tbl_cxml) - return table, new_value, expected_xml - - @pytest.fixture( - params=[ + table.style = new_value + + document_part_.get_style_id.assert_called_once_with(new_value, WD_STYLE_TYPE.TABLE) + assert table._tbl.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("snippet_idx", "cell_count", "unique_count", "matches"), + [ (0, 9, 9, ()), (1, 9, 8, ((0, 1),)), (2, 9, 8, ((1, 4),)), (3, 9, 6, ((0, 1, 3, 4),)), (4, 9, 4, ((0, 1), (3, 6), (4, 5, 7, 8))), - ] + ], ) - def cells_fixture(self, request): - snippet_idx, cell_count, unique_count, matches = request.param + def it_provides_access_to_its_cells_to_help( + self, + snippet_idx: int, + cell_count: int, + unique_count: int, + matches: tuple[tuple[int, ...]], + document_: Mock, + ): tbl_xml = snippet_seq("tbl-cells")[snippet_idx] - table = Table(parse_xml(tbl_xml), None) - return table, cell_count, unique_count, matches + table = Table(cast(CT_Tbl, parse_xml(tbl_xml)), document_) - @pytest.fixture - def col_cells_fixture(self, _cells_, _column_count_): - table = Table(None, None) - _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] - _column_count_.return_value = 3 - column_idx = 1 - expected_cells = [1, 4, 7] - return table, column_idx, expected_cells + cells = table._cells - @pytest.fixture - def column_count_fixture(self): + assert len(cells) == cell_count + assert len(set(cells)) == unique_count + for matching_idxs in matches: + comparator_idx = matching_idxs[0] + for idx in matching_idxs[1:]: + assert cells[idx] is cells[comparator_idx] + + def it_knows_its_column_count_to_help(self, document_: Mock): tbl_cxml = "w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)" expected_value = 3 - table = Table(element(tbl_cxml), None) - return table, expected_value - - @pytest.fixture - def row_cells_fixture(self, _cells_, _column_count_): - table = Table(None, None) - _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] - _column_count_.return_value = 3 - row_idx = 1 - expected_cells = [3, 4, 5] - return table, row_idx, expected_cells + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) - @pytest.fixture - def style_get_fixture(self, part_prop_): - style_id = "Barbaz" - tbl_cxml = "w:tbl/w:tblPr/w:tblStyle{w:val=%s}" % style_id - table = Table(element(tbl_cxml), None) - style_ = part_prop_.return_value.get_style.return_value - return table, style_id, style_ - - @pytest.fixture( - params=[ - ("w:tbl/w:tblPr", "Tbl A", "TblA", "w:tbl/w:tblPr/w:tblStyle{w:val=TblA}"), - ( - "w:tbl/w:tblPr/w:tblStyle{w:val=TblA}", - "Tbl B", - "TblB", - "w:tbl/w:tblPr/w:tblStyle{w:val=TblB}", - ), - ("w:tbl/w:tblPr/w:tblStyle{w:val=TblB}", None, None, "w:tbl/w:tblPr"), - ] - ) - def style_set_fixture(self, request, part_prop_): - tbl_cxml, value, style_id, expected_cxml = request.param - table = Table(element(tbl_cxml), None) - part_prop_.return_value.get_style_id.return_value = style_id - expected_xml = xml(expected_cxml) - return table, value, expected_xml + column_count = table._column_count - @pytest.fixture - def table_fixture(self): - table = Table(None, None) - return table + assert column_count == expected_value - # fixture components --------------------------------------------- + # fixtures ------------------------------------------------------- @pytest.fixture - def _cells_(self, request): + def _cells_(self, request: FixtureRequest): return property_mock(request, Table, "_cells") @pytest.fixture - def _column_count_(self, request): + def _column_count_(self, request: FixtureRequest): return property_mock(request, Table, "_column_count") @pytest.fixture @@ -338,130 +310,78 @@ def document_(self, request: FixtureRequest): return instance_mock(request, Document) @pytest.fixture - def document_part_(self, request): + def document_part_(self, request: FixtureRequest): return instance_mock(request, DocumentPart) @pytest.fixture - def part_prop_(self, request, document_part_): - return property_mock(request, Table, "part", return_value=document_part_) + def part_prop_(self, request: FixtureRequest): + return property_mock(request, Table, "part") @pytest.fixture - def table(self): - tbl = _tbl_bldr(rows=2, cols=2).element - table = Table(tbl, None) - return table + def table(self, document_: Mock): + tbl_cxml = "w:tbl/(w:tblGrid/(w:gridCol,w:gridCol),w:tr/(w:tc,w:tc),w:tr/(w:tc,w:tc))" + return Table(cast(CT_Tbl, element(tbl_cxml)), document_) class Describe_Cell: - def it_knows_what_text_it_contains(self, text_get_fixture): - cell, expected_text = text_get_fixture + """Unit-test suite for `docx.table._Cell` objects.""" + + @pytest.mark.parametrize( + ("tc_cxml", "expected_text"), + [ + ("w:tc", ""), + ('w:tc/w:p/w:r/w:t"foobar"', "foobar"), + ('w:tc/(w:p/w:r/w:t"foo",w:p/w:r/w:t"bar")', "foo\nbar"), + ('w:tc/(w:tcPr,w:p/w:r/w:t"foobar")', "foobar"), + ('w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:br,w:t"ar",w:br)', "fo\tob\nar\n"), + ], + ) + def it_knows_what_text_it_contains(self, tc_cxml: str, expected_text: str, parent_: Mock): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) text = cell.text assert text == expected_text - def it_can_replace_its_content_with_a_string_of_text(self, text_set_fixture): - cell, text, expected_xml = text_set_fixture - cell.text = text - assert cell._tc.xml == expected_xml - - def it_knows_its_vertical_alignment(self, alignment_get_fixture): - cell, expected_value = alignment_get_fixture - vertical_alignment = cell.vertical_alignment - assert vertical_alignment == expected_value - - def it_can_change_its_vertical_alignment(self, alignment_set_fixture): - cell, new_value, expected_xml = alignment_set_fixture - cell.vertical_alignment = new_value - assert cell._element.xml == expected_xml - - def it_knows_its_width_in_EMU(self, width_get_fixture): - cell, expected_width = width_get_fixture - assert cell.width == expected_width - - def it_can_change_its_width(self, width_set_fixture): - cell, value, expected_xml = width_set_fixture - cell.width = value - assert cell.width == value - assert cell._tc.xml == expected_xml - - def it_provides_access_to_the_paragraphs_it_contains(self, paragraphs_fixture): - cell = paragraphs_fixture - paragraphs = cell.paragraphs - assert len(paragraphs) == 2 - count = 0 - for idx, paragraph in enumerate(paragraphs): - assert isinstance(paragraph, Paragraph) - assert paragraph is paragraphs[idx] - count += 1 - assert count == 2 - - def it_provides_access_to_the_tables_it_contains(self, tables_fixture): - # test len(), iterable, and indexed access - cell, expected_count = tables_fixture - tables = cell.tables - assert len(tables) == expected_count - count = 0 - for idx, table in enumerate(tables): - assert isinstance(table, Table) - assert tables[idx] is table - count += 1 - assert count == expected_count - - def it_can_add_a_paragraph(self, add_paragraph_fixture): - cell, expected_xml = add_paragraph_fixture - p = cell.add_paragraph() - assert cell._tc.xml == expected_xml - assert isinstance(p, Paragraph) - - def it_can_add_a_table(self, add_table_fixture): - cell, expected_xml = add_table_fixture - table = cell.add_table(rows=2, cols=2) - assert cell._element.xml == expected_xml - assert isinstance(table, Table) - - def it_can_merge_itself_with_other_cells(self, merge_fixture): - cell, other_cell, merged_tc_ = merge_fixture - merged_cell = cell.merge(other_cell) - cell._tc.merge.assert_called_once_with(other_cell._tc) - assert isinstance(merged_cell, _Cell) - assert merged_cell._tc is merged_tc_ - assert merged_cell._parent is cell._parent - - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ - ("w:tc", "w:tc/w:p"), - ("w:tc/w:p", "w:tc/(w:p, w:p)"), - ("w:tc/w:tbl", "w:tc/(w:tbl, w:p)"), - ] + @pytest.mark.parametrize( + ("tc_cxml", "new_text", "expected_cxml"), + [ + ("w:tc/w:p", "foobar", 'w:tc/w:p/w:r/w:t"foobar"'), + ( + "w:tc/w:p", + "fo\tob\rar\n", + 'w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:br,w:t"ar",w:br)', + ), + ( + "w:tc/(w:tcPr, w:p, w:tbl, w:p)", + "foobar", + 'w:tc/(w:tcPr, w:p/w:r/w:t"foobar")', + ), + ], ) - def add_paragraph_fixture(self, request): - tc_cxml, after_tc_cxml = request.param - cell = _Cell(element(tc_cxml), None) - expected_xml = xml(after_tc_cxml) - return cell, expected_xml - - @pytest.fixture - def add_table_fixture(self, request): - cell = _Cell(element("w:tc/w:p"), None) - expected_xml = snippet_seq("new-tbl")[1] - return cell, expected_xml + def it_can_replace_its_content_with_a_string_of_text( + self, tc_cxml: str, new_text: str, expected_cxml: str, parent_: Mock + ): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + cell.text = new_text + assert cell._tc.xml == xml(expected_cxml) - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tc_cxml", "expected_value"), + [ ("w:tc", None), ("w:tc/w:tcPr", None), ("w:tc/w:tcPr/w:vAlign{w:val=bottom}", WD_ALIGN_VERTICAL.BOTTOM), ("w:tc/w:tcPr/w:vAlign{w:val=top}", WD_ALIGN_VERTICAL.TOP), - ] + ], ) - def alignment_get_fixture(self, request): - tc_cxml, expected_value = request.param - cell = _Cell(element(tc_cxml), None) - return cell, expected_value + def it_knows_its_vertical_alignment( + self, tc_cxml: str, expected_value: WD_ALIGN_VERTICAL | None, parent_: Mock + ): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + assert cell.vertical_alignment == expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tc_cxml", "new_value", "expected_cxml"), + [ ("w:tc", WD_ALIGN_VERTICAL.TOP, "w:tc/w:tcPr/w:vAlign{w:val=top}"), ( "w:tc/w:tcPr", @@ -476,330 +396,272 @@ def alignment_get_fixture(self, request): ("w:tc/w:tcPr/w:vAlign{w:val=center}", None, "w:tc/w:tcPr"), ("w:tc", None, "w:tc/w:tcPr"), ("w:tc/w:tcPr", None, "w:tc/w:tcPr"), - ] - ) - def alignment_set_fixture(self, request): - cxml, new_value, expected_cxml = request.param - cell = _Cell(element(cxml), None) - expected_xml = xml(expected_cxml) - return cell, new_value, expected_xml - - @pytest.fixture - def merge_fixture(self, tc_, tc_2_, parent_, merged_tc_): - cell, other_cell = _Cell(tc_, parent_), _Cell(tc_2_, parent_) - tc_.merge.return_value = merged_tc_ - return cell, other_cell, merged_tc_ - - @pytest.fixture - def paragraphs_fixture(self): - return _Cell(element("w:tc/(w:p, w:p)"), None) - - @pytest.fixture( - params=[ - ("w:tc", 0), - ("w:tc/w:tbl", 1), - ("w:tc/(w:tbl,w:tbl)", 2), - ("w:tc/(w:p,w:tbl)", 1), - ("w:tc/(w:tbl,w:tbl,w:p)", 2), - ] - ) - def tables_fixture(self, request): - cell_cxml, expected_count = request.param - cell = _Cell(element(cell_cxml), None) - return cell, expected_count - - @pytest.fixture( - params=[ - ("w:tc", ""), - ('w:tc/w:p/w:r/w:t"foobar"', "foobar"), - ('w:tc/(w:p/w:r/w:t"foo",w:p/w:r/w:t"bar")', "foo\nbar"), - ('w:tc/(w:tcPr,w:p/w:r/w:t"foobar")', "foobar"), - ('w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:br,w:t"ar",w:br)', "fo\tob\nar\n"), - ] + ], ) - def text_get_fixture(self, request): - tc_cxml, expected_text = request.param - cell = _Cell(element(tc_cxml), None) - return cell, expected_text + def it_can_change_its_vertical_alignment( + self, tc_cxml: str, new_value: WD_ALIGN_VERTICAL | None, expected_cxml: str, parent_: Mock + ): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + cell.vertical_alignment = new_value + assert cell._element.xml == xml(expected_cxml) - @pytest.fixture( - params=[ - ("w:tc/w:p", "foobar", 'w:tc/w:p/w:r/w:t"foobar"'), - ( - "w:tc/w:p", - "fo\tob\rar\n", - 'w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:br,w:t"ar",w:br)', - ), - ( - "w:tc/(w:tcPr, w:p, w:tbl, w:p)", - "foobar", - 'w:tc/(w:tcPr, w:p/w:r/w:t"foobar")', - ), - ] - ) - def text_set_fixture(self, request): - tc_cxml, new_text, expected_cxml = request.param - cell = _Cell(element(tc_cxml), None) - expected_xml = xml(expected_cxml) - return cell, new_text, expected_xml - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tc_cxml", "expected_value"), + [ ("w:tc", None), ("w:tc/w:tcPr", None), ("w:tc/w:tcPr/w:tcW{w:w=25%,w:type=pct}", None), ("w:tc/w:tcPr/w:tcW{w:w=1440,w:type=dxa}", 914400), - ] + ], ) - def width_get_fixture(self, request): - tc_cxml, expected_width = request.param - cell = _Cell(element(tc_cxml), None) - return cell, expected_width + def it_knows_its_width_in_EMU(self, tc_cxml: str, expected_value: int | None, parent_: Mock): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + assert cell.width == expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tc_cxml", "new_value", "expected_cxml"), + [ ("w:tc", Inches(1), "w:tc/w:tcPr/w:tcW{w:w=1440,w:type=dxa}"), ( "w:tc/w:tcPr/w:tcW{w:w=25%,w:type=pct}", Inches(2), "w:tc/w:tcPr/w:tcW{w:w=2880,w:type=dxa}", ), - ] + ], + ) + def it_can_change_its_width( + self, tc_cxml: str, new_value: Length, expected_cxml: str, parent_: Mock + ): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + cell.width = new_value + assert cell.width == new_value + assert cell._tc.xml == xml(expected_cxml) + + def it_provides_access_to_the_paragraphs_it_contains(self, parent_: Mock): + cell = _Cell(cast(CT_Tc, element("w:tc/(w:p, w:p)")), parent_) + + paragraphs = cell.paragraphs + + # -- every w:p produces a Paragraph instance -- + assert len(paragraphs) == 2 + assert all(isinstance(p, Paragraph) for p in paragraphs) + # -- the return value is iterable and indexable -- + assert all(p is paragraphs[idx] for idx, p in enumerate(paragraphs)) + + @pytest.mark.parametrize( + ("tc_cxml", "expected_table_count"), + [ + ("w:tc", 0), + ("w:tc/w:tbl", 1), + ("w:tc/(w:tbl,w:tbl)", 2), + ("w:tc/(w:p,w:tbl)", 1), + ("w:tc/(w:tbl,w:tbl,w:p)", 2), + ], ) - def width_set_fixture(self, request): - tc_cxml, new_value, expected_cxml = request.param - cell = _Cell(element(tc_cxml), None) - expected_xml = xml(expected_cxml) - return cell, new_value, expected_xml + def it_provides_access_to_the_tables_it_contains( + self, tc_cxml: str, expected_table_count: int, parent_: Mock + ): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + + tables = cell.tables + + # --- test len(), iterable, and indexed access + assert len(tables) == expected_table_count + assert all(isinstance(t, Table) for t in tables) + assert all(t is tables[idx] for idx, t in enumerate(tables)) + + @pytest.mark.parametrize( + ("tc_cxml", "expected_cxml"), + [ + ("w:tc", "w:tc/w:p"), + ("w:tc/w:p", "w:tc/(w:p, w:p)"), + ("w:tc/w:tbl", "w:tc/(w:tbl, w:p)"), + ], + ) + def it_can_add_a_paragraph(self, tc_cxml: str, expected_cxml: str, parent_: Mock): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) - # fixture components --------------------------------------------- + p = cell.add_paragraph() + + assert isinstance(p, Paragraph) + assert cell._tc.xml == xml(expected_cxml) + + def it_can_add_a_table(self, parent_: Mock): + cell = _Cell(cast(CT_Tc, element("w:tc/w:p")), parent_) + + table = cell.add_table(rows=2, cols=2) + + assert isinstance(table, Table) + assert cell._element.xml == snippet_seq("new-tbl")[1] + + def it_can_merge_itself_with_other_cells( + self, tc_: Mock, tc_2_: Mock, parent_: Mock, merged_tc_: Mock + ): + cell, other_cell = _Cell(tc_, parent_), _Cell(tc_2_, parent_) + tc_.merge.return_value = merged_tc_ + + merged_cell = cell.merge(other_cell) + + assert isinstance(merged_cell, _Cell) + tc_.merge.assert_called_once_with(other_cell._tc) + assert merged_cell._tc is merged_tc_ + assert merged_cell._parent is cell._parent + + # fixtures ------------------------------------------------------- @pytest.fixture - def merged_tc_(self, request): + def merged_tc_(self, request: FixtureRequest): return instance_mock(request, CT_Tc) @pytest.fixture - def parent_(self, request): + def parent_(self, request: FixtureRequest): return instance_mock(request, Table) @pytest.fixture - def tc_(self, request): + def tc_(self, request: FixtureRequest): return instance_mock(request, CT_Tc) @pytest.fixture - def tc_2_(self, request): + def tc_2_(self, request: FixtureRequest): return instance_mock(request, CT_Tc) class Describe_Column: - def it_provides_access_to_its_cells(self, cells_fixture): - column, column_idx, expected_cells = cells_fixture - cells = column.cells - column.table.column_cells.assert_called_once_with(column_idx) - assert cells == expected_cells + """Unit-test suite for `docx.table._Cell` objects.""" - def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): - column, table_ = table_fixture - assert column.table is table_ + def it_provides_access_to_its_cells(self, _index_prop_: Mock, table_prop_: Mock, table_: Mock): + table_prop_.return_value = table_ + _index_prop_.return_value = 4 + column = _Column(cast(CT_TblGridCol, element("w:gridCol{w:w=500}")), table_) + table_.column_cells.return_value = [3, 2, 1] - def it_knows_its_width_in_EMU(self, width_get_fixture): - column, expected_width = width_get_fixture - assert column.width == expected_width - - def it_can_change_its_width(self, width_set_fixture): - column, value, expected_xml = width_set_fixture - column.width = value - assert column.width == value - assert column._gridCol.xml == expected_xml - - def it_knows_its_index_in_table_to_help(self, index_fixture): - column, expected_idx = index_fixture - assert column._index == expected_idx - - # fixtures ------------------------------------------------------- + cells = column.cells - @pytest.fixture - def cells_fixture(self, _index_, table_prop_, table_): - column = _Column(None, None) - _index_.return_value = column_idx = 4 - expected_cells = (3, 2, 1) - table_.column_cells.return_value = list(expected_cells) - return column, column_idx, expected_cells + table_.column_cells.assert_called_once_with(4) + assert cells == (3, 2, 1) - @pytest.fixture - def index_fixture(self): - tbl = element("w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)") - gridCol, expected_idx = tbl.tblGrid[1], 1 - column = _Column(gridCol, None) - return column, expected_idx + def it_provides_access_to_the_table_it_belongs_to(self, table_: Mock): + table_.table = table_ + column = _Column(cast(CT_TblGridCol, element("w:gridCol{w:w=500}")), table_) - @pytest.fixture - def table_fixture(self, parent_, table_): - column = _Column(None, parent_) - parent_.table = table_ - return column, table_ + assert column.table is table_ - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("gridCol_cxml", "expected_width"), + [ ("w:gridCol{w:w=4242}", 2693670), ("w:gridCol{w:w=1440}", 914400), ("w:gridCol{w:w=2.54cm}", 914400), ("w:gridCol{w:w=54mm}", 1944000), ("w:gridCol{w:w=12.5pt}", 158750), ("w:gridCol", None), - ] + ], ) - def width_get_fixture(self, request): - gridCol_cxml, expected_width = request.param - column = _Column(element(gridCol_cxml), None) - return column, expected_width - - @pytest.fixture( - params=[ - ("w:gridCol", 914400, "w:gridCol{w:w=1440}"), - ("w:gridCol{w:w=4242}", 457200, "w:gridCol{w:w=720}"), + def it_knows_its_width_in_EMU( + self, gridCol_cxml: str, expected_width: int | None, table_: Mock + ): + column = _Column(cast(CT_TblGridCol, element(gridCol_cxml)), table_) + assert column.width == expected_width + + @pytest.mark.parametrize( + ("gridCol_cxml", "new_value", "expected_cxml"), + [ + ("w:gridCol", Emu(914400), "w:gridCol{w:w=1440}"), + ("w:gridCol{w:w=4242}", Inches(0.5), "w:gridCol{w:w=720}"), ("w:gridCol{w:w=4242}", None, "w:gridCol"), ("w:gridCol", None, "w:gridCol"), - ] + ], ) - def width_set_fixture(self, request): - gridCol_cxml, new_value, expected_cxml = request.param - column = _Column(element(gridCol_cxml), None) - expected_xml = xml(expected_cxml) - return column, new_value, expected_xml + def it_can_change_its_width( + self, gridCol_cxml: str, new_value: Length | None, expected_cxml: str, table_: Mock + ): + column = _Column(cast(CT_TblGridCol, element(gridCol_cxml)), table_) + + column.width = new_value + + assert column.width == new_value + assert column._gridCol.xml == xml(expected_cxml) - # fixture components --------------------------------------------- + def it_knows_its_index_in_table_to_help(self, table_: Mock): + tbl = cast(CT_Tbl, element("w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)")) + gridCol = tbl.tblGrid.gridCol_lst[1] + column = _Column(gridCol, table_) + assert column._index == 1 + + # fixtures ------------------------------------------------------- @pytest.fixture - def _index_(self, request): + def _index_prop_(self, request: FixtureRequest): return property_mock(request, _Column, "_index") @pytest.fixture - def parent_(self, request): + def parent_(self, request: FixtureRequest): return instance_mock(request, Table) @pytest.fixture - def table_(self, request): + def table_(self, request: FixtureRequest): return instance_mock(request, Table) @pytest.fixture - def table_prop_(self, request, table_): - return property_mock(request, _Column, "table", return_value=table_) + def table_prop_(self, request: FixtureRequest): + return property_mock(request, _Column, "table") class Describe_Columns: - def it_knows_how_many_columns_it_contains(self, columns_fixture): - columns, column_count = columns_fixture - assert len(columns) == column_count - - def it_can_interate_over_its__Column_instances(self, columns_fixture): - columns, column_count = columns_fixture - actual_count = 0 - for column in columns: - assert isinstance(column, _Column) - actual_count += 1 - assert actual_count == column_count - - def it_provides_indexed_access_to_columns(self, columns_fixture): - columns, column_count = columns_fixture - for idx in range(-column_count, column_count): - column = columns[idx] - assert isinstance(column, _Column) - - def it_raises_on_indexed_access_out_of_range(self, columns_fixture): - columns, column_count = columns_fixture - too_low = -1 - column_count - too_high = column_count - with pytest.raises(IndexError): - columns[too_low] - with pytest.raises(IndexError): - columns[too_high] + """Unit-test suite for `docx.table._Columns` objects.""" - def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): - columns, table_ = table_fixture - assert columns.table is table_ + def it_has_sequence_behaviors(self, table_: Mock): + columns = _Columns(cast(CT_Tbl, element("w:tbl/w:tblGrid/(w:gridCol,w:gridCol)")), table_) - # fixtures ------------------------------------------------------- + # -- it supports len() -- + assert len(columns) == 2 + # -- it is iterable -- + assert len(tuple(c for c in columns)) == 2 + assert all(type(c) is _Column for c in columns) + # -- it is indexable -- + assert all(type(columns[i]) is _Column for i in range(2)) - @pytest.fixture - def columns_fixture(self): - column_count = 2 - tbl = _tbl_bldr(rows=2, cols=column_count).element - columns = _Columns(tbl, None) - return columns, column_count + def it_raises_on_indexed_access_out_of_range(self, table_: Mock): + columns = _Columns(cast(CT_Tbl, element("w:tbl/w:tblGrid/(w:gridCol,w:gridCol)")), table_) - @pytest.fixture - def table_fixture(self, table_): - columns = _Columns(None, table_) + with pytest.raises(IndexError): + columns[2] + with pytest.raises(IndexError): + columns[-3] + + def it_provides_access_to_the_table_it_belongs_to(self, table_: Mock): + columns = _Columns(cast(CT_Tbl, element("w:tbl")), table_) table_.table = table_ - return columns, table_ - # fixture components --------------------------------------------- + assert columns.table is table_ + + # fixtures ------------------------------------------------------- @pytest.fixture - def table_(self, request): + def table_(self, request: FixtureRequest): return instance_mock(request, Table) class Describe_Row: - def it_knows_its_height(self, height_get_fixture): - row, expected_height = height_get_fixture - assert row.height == expected_height - - def it_can_change_its_height(self, height_set_fixture): - row, value, expected_xml = height_set_fixture - row.height = value - assert row._tr.xml == expected_xml - - def it_knows_its_height_rule(self, height_rule_get_fixture): - row, expected_rule = height_rule_get_fixture - assert row.height_rule == expected_rule - - def it_can_change_its_height_rule(self, height_rule_set_fixture): - row, rule, expected_xml = height_rule_set_fixture - row.height_rule = rule - assert row._tr.xml == expected_xml - - def it_provides_access_to_its_cells(self, cells_fixture): - row, row_idx, expected_cells = cells_fixture - cells = row.cells - row.table.row_cells.assert_called_once_with(row_idx) - assert cells == expected_cells - - def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): - row, table_ = table_fixture - assert row.table is table_ - - def it_knows_its_index_in_table_to_help(self, idx_fixture): - row, expected_idx = idx_fixture - assert row._index == expected_idx - - # fixtures ------------------------------------------------------- + """Unit-test suite for `docx.table._Row` objects.""" - @pytest.fixture - def cells_fixture(self, _index_, table_prop_, table_): - row = _Row(None, None) - _index_.return_value = row_idx = 6 - expected_cells = (1, 2, 3) - table_.row_cells.return_value = list(expected_cells) - return row, row_idx, expected_cells - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ ("w:tr", None), ("w:tr/w:trPr", None), ("w:tr/w:trPr/w:trHeight", None), ("w:tr/w:trPr/w:trHeight{w:val=0}", 0), ("w:tr/w:trPr/w:trHeight{w:val=1440}", 914400), - ] + ], ) - def height_get_fixture(self, request): - tr_cxml, expected_height = request.param - row = _Row(element(tr_cxml), None) - return row, expected_height + def it_knows_its_height(self, tr_cxml: str, expected_value: int | None, parent_: Mock): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + assert row.height == expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tr_cxml", "new_value", "expected_cxml"), + [ ("w:tr", Inches(1), "w:tr/w:trPr/w:trHeight{w:val=1440}"), ("w:tr/w:trPr", Inches(1), "w:tr/w:trPr/w:trHeight{w:val=1440}"), ("w:tr/w:trPr/w:trHeight", Inches(1), "w:tr/w:trPr/w:trHeight{w:val=1440}"), @@ -812,16 +674,18 @@ def height_get_fixture(self, request): ("w:tr", None, "w:tr/w:trPr"), ("w:tr/w:trPr", None, "w:tr/w:trPr"), ("w:tr/w:trPr/w:trHeight", None, "w:tr/w:trPr/w:trHeight"), - ] + ], ) - def height_set_fixture(self, request): - tr_cxml, new_value, expected_cxml = request.param - row = _Row(element(tr_cxml), None) - expected_xml = xml(expected_cxml) - return row, new_value, expected_xml - - @pytest.fixture( - params=[ + def it_can_change_its_height( + self, tr_cxml: str, new_value: Length | None, expected_cxml: str, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + row.height = new_value + assert row._tr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ ("w:tr", None), ("w:tr/w:trPr", None), ("w:tr/w:trPr/w:trHeight{w:val=0, w:hRule=auto}", WD_ROW_HEIGHT.AUTO), @@ -833,15 +697,17 @@ def height_set_fixture(self, request): "w:tr/w:trPr/w:trHeight{w:val=2880, w:hRule=exact}", WD_ROW_HEIGHT.EXACTLY, ), - ] + ], ) - def height_rule_get_fixture(self, request): - tr_cxml, expected_rule = request.param - row = _Row(element(tr_cxml), None) - return row, expected_rule + def it_knows_its_height_rule( + self, tr_cxml: str, expected_value: WD_ROW_HEIGHT | None, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + assert row.height_rule == expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tr_cxml", "new_value", "expected_cxml"), + [ ("w:tr", WD_ROW_HEIGHT.AUTO, "w:tr/w:trPr/w:trHeight{w:hRule=auto}"), ( "w:tr/w:trPr", @@ -866,143 +732,125 @@ def height_rule_get_fixture(self, request): ("w:tr", None, "w:tr/w:trPr"), ("w:tr/w:trPr", None, "w:tr/w:trPr"), ("w:tr/w:trPr/w:trHeight", None, "w:tr/w:trPr/w:trHeight"), - ] + ], ) - def height_rule_set_fixture(self, request): - tr_cxml, new_rule, expected_cxml = request.param - row = _Row(element(tr_cxml), None) - expected_xml = xml(expected_cxml) - return row, new_rule, expected_xml + def it_can_change_its_height_rule( + self, tr_cxml: str, new_value: WD_ROW_HEIGHT | None, expected_cxml: str, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + row.height_rule = new_value + assert row._tr.xml == xml(expected_cxml) - @pytest.fixture - def idx_fixture(self): - tbl = element("w:tbl/(w:tr,w:tr,w:tr)") - tr, expected_idx = tbl[1], 1 - row = _Row(tr, None) - return row, expected_idx + def it_provides_access_to_its_cells( + self, _index_prop_: Mock, table_prop_: Mock, table_: Mock, parent_: Mock + ): + row = _Row(cast(CT_Row, element("w:tr")), parent_) + _index_prop_.return_value = row_idx = 6 + expected_cells = (1, 2, 3) + table_.row_cells.return_value = list(expected_cells) - @pytest.fixture - def table_fixture(self, parent_, table_): - row = _Row(None, parent_) + cells = row.cells + + table_.row_cells.assert_called_once_with(row_idx) + assert cells == expected_cells + + def it_provides_access_to_the_table_it_belongs_to(self, parent_: Mock, table_: Mock): parent_.table = table_ - return row, table_ + row = _Row(cast(CT_Row, element("w:tr")), parent_) + assert row.table is table_ + + def it_knows_its_index_in_table_to_help(self, parent_: Mock): + tbl = element("w:tbl/(w:tr,w:tr,w:tr)") + row = _Row(cast(CT_Row, tbl[1]), parent_) + assert row._index == 1 - # fixture components --------------------------------------------- + # fixtures ------------------------------------------------------- @pytest.fixture - def _index_(self, request): + def _index_prop_(self, request: FixtureRequest): return property_mock(request, _Row, "_index") @pytest.fixture - def parent_(self, request): + def parent_(self, request: FixtureRequest): return instance_mock(request, Table) @pytest.fixture - def table_(self, request): + def table_(self, request: FixtureRequest): return instance_mock(request, Table) @pytest.fixture - def table_prop_(self, request, table_): + def table_prop_(self, request: FixtureRequest, table_: Mock): return property_mock(request, _Row, "table", return_value=table_) class Describe_Rows: - def it_knows_how_many_rows_it_contains(self, rows_fixture): - rows, row_count = rows_fixture - assert len(rows) == row_count - - def it_can_iterate_over_its__Row_instances(self, rows_fixture): - rows, row_count = rows_fixture - actual_count = 0 - for row in rows: - assert isinstance(row, _Row) - actual_count += 1 - assert actual_count == row_count - - def it_provides_indexed_access_to_rows(self, rows_fixture): - rows, row_count = rows_fixture - for idx in range(-row_count, row_count): - row = rows[idx] - assert isinstance(row, _Row) + """Unit-test suite for `docx.table._Rows` objects.""" - def it_provides_sliced_access_to_rows(self, slice_fixture): - rows, start, end, expected_count = slice_fixture - slice_of_rows = rows[start:end] - assert len(slice_of_rows) == expected_count - tr_lst = rows._tbl.tr_lst - for idx, row in enumerate(slice_of_rows): - assert tr_lst.index(row._tr) == start + idx - assert isinstance(row, _Row) - - def it_raises_on_indexed_access_out_of_range(self, rows_fixture): - rows, row_count = rows_fixture - too_low = -1 - row_count - too_high = row_count - - with pytest.raises(IndexError, match="list index out of range"): - rows[too_low] - with pytest.raises(IndexError, match="list index out of range"): - rows[too_high] - - def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): - rows, table_ = table_fixture - assert rows.table is table_ - - # fixtures ------------------------------------------------------- - - @pytest.fixture - def rows_fixture(self): - row_count = 2 - tbl = _tbl_bldr(rows=row_count, cols=2).element - rows = _Rows(tbl, None) - return rows, row_count - - @pytest.fixture( - params=[ - (3, 1, 3, 2), - (3, 0, -1, 2), - ] + @pytest.mark.parametrize( + ("tbl_cxml", "expected_len"), + [ + ("w:tbl", 0), + ("w:tbl/w:tr", 1), + ("w:tbl/(w:tr,w:tr)", 2), + ("w:tbl/(w:tr,w:tr,w:tr)", 3), + ], ) - def slice_fixture(self, request): - row_count, start, end, expected_count = request.param - tbl = _tbl_bldr(rows=row_count, cols=2).element - rows = _Rows(tbl, None) - return rows, start, end, expected_count - - @pytest.fixture - def table_fixture(self, table_): - rows = _Rows(None, table_) - table_.table = table_ - return rows, table_ + def it_has_sequence_behaviors(self, tbl_cxml: str, expected_len: int, parent_: Mock): + tbl = cast(CT_Tbl, element(tbl_cxml)) + table = Table(tbl, parent_) + rows = _Rows(tbl, table) - # fixture components --------------------------------------------- + # -- it supports len() -- + assert len(rows) == expected_len + # -- it is iterable -- + assert len(tuple(r for r in rows)) == expected_len + assert all(type(r) is _Row for r in rows) + # -- it is indexable -- + assert all(type(rows[i]) is _Row for i in range(expected_len)) - @pytest.fixture - def table_(self, request): - return instance_mock(request, Table) + @pytest.mark.parametrize( + ("tbl_cxml", "out_of_range_idx"), + [ + ("w:tbl", 0), + ("w:tbl", 1), + ("w:tbl", -1), + ("w:tbl/w:tr", 1), + ("w:tbl/w:tr", -2), + ("w:tbl/(w:tr,w:tr,w:tr)", 3), + ("w:tbl/(w:tr,w:tr,w:tr)", -4), + ], + ) + def it_raises_on_indexed_access_out_of_range( + self, tbl_cxml: str, out_of_range_idx: int, parent_: Mock + ): + rows = _Rows(cast(CT_Tbl, element(tbl_cxml)), parent_) + with pytest.raises(IndexError, match="list index out of range"): + rows[out_of_range_idx] -# fixtures ----------------------------------------------------------- + @pytest.mark.parametrize(("start", "end", "expected_len"), [(1, 3, 2), (0, -1, 2)]) + def it_provides_sliced_access_to_rows( + self, start: int, end: int, expected_len: int, parent_: Mock + ): + tbl = cast(CT_Tbl, element("w:tbl/(w:tr,w:tr,w:tr)")) + rows = _Rows(tbl, parent_) + slice_of_rows = rows[start:end] -def _tbl_bldr(rows, cols): - tblGrid_bldr = a_tblGrid() - for i in range(cols): - tblGrid_bldr.with_child(a_gridCol()) - tbl_bldr = a_tbl().with_nsdecls().with_child(tblGrid_bldr) - for i in range(rows): - tr_bldr = _tr_bldr(cols) - tbl_bldr.with_child(tr_bldr) - return tbl_bldr + assert len(slice_of_rows) == expected_len + for idx, row in enumerate(slice_of_rows): + assert tbl.tr_lst.index(row._tr) == start + idx + assert isinstance(row, _Row) + def it_provides_access_to_the_table_it_belongs_to(self, parent_: Mock): + tbl = cast(CT_Tbl, element("w:tbl")) + table = Table(tbl, parent_) + rows = _Rows(tbl, table) -def _tc_bldr(): - return a_tc().with_child(a_p()) + assert rows.table is table + # fixtures ------------------------------------------------------- -def _tr_bldr(cols): - tr_bldr = a_tr() - for i in range(cols): - tc_bldr = _tc_bldr() - tr_bldr.with_child(tc_bldr) - return tr_bldr + @pytest.fixture + def parent_(self, request: FixtureRequest): + return instance_mock(request, Document) From d8a328985810a14308f3c90bd5bd3f6795b3956d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 27 Apr 2024 16:07:13 -0700 Subject: [PATCH 04/56] rfctr: improve expression --- src/docx/oxml/table.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index e0aed09a3..687c6e2e6 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -441,9 +441,7 @@ def grid_span(self) -> int: Determined by ./w:tcPr/w:gridSpan/@val, it defaults to 1. """ tcPr = self.tcPr - if tcPr is None: - return 1 - return tcPr.grid_span + return 1 if tcPr is None else tcPr.grid_span @grid_span.setter def grid_span(self, value: int): @@ -809,9 +807,7 @@ def grid_span(self) -> int: Determined by ./w:gridSpan/@val, it defaults to 1. """ gridSpan = self.gridSpan - if gridSpan is None: - return 1 - return gridSpan.val + return 1 if gridSpan is None else gridSpan.val @grid_span.setter def grid_span(self, value: int): @@ -898,9 +894,7 @@ class CT_TrPr(BaseOxmlElement): def trHeight_hRule(self) -> WD_ROW_HEIGHT_RULE | None: """Return the value of `w:trHeight@w:hRule`, or |None| if not present.""" trHeight = self.trHeight - if trHeight is None: - return None - return trHeight.hRule + return None if trHeight is None else trHeight.hRule @trHeight_hRule.setter def trHeight_hRule(self, value: WD_ROW_HEIGHT_RULE | None): @@ -913,9 +907,7 @@ def trHeight_hRule(self, value: WD_ROW_HEIGHT_RULE | None): def trHeight_val(self): """Return the value of `w:trHeight@w:val`, or |None| if not present.""" trHeight = self.trHeight - if trHeight is None: - return None - return trHeight.val + return None if trHeight is None else trHeight.val @trHeight_val.setter def trHeight_val(self, value: Length | None): From 6c34f128a5b5e331f1bbf88935f7c13396d33fb3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 29 Apr 2024 14:43:04 -0700 Subject: [PATCH 05/56] rfctr: modernize opc.shared.lazyproperty No need for two, use the already modernized `docx.shared.lazyproperty`. --- src/docx/opc/package.py | 44 ++++--- src/docx/opc/part.py | 14 ++- src/docx/opc/pkgwriter.py | 13 +- src/docx/opc/rel.py | 18 +-- src/docx/opc/shared.py | 28 ++--- tests/opc/test_package.py | 170 ++++++++++++--------------- tests/opc/test_part.py | 228 ++++++++++++++++-------------------- tests/opc/test_pkgwriter.py | 70 ++++++----- 8 files changed, 277 insertions(+), 308 deletions(-) diff --git a/src/docx/opc/package.py b/src/docx/opc/package.py index b5bdc0e7c..148cd39b1 100644 --- a/src/docx/opc/package.py +++ b/src/docx/opc/package.py @@ -1,5 +1,9 @@ """Objects that implement reading and writing OPC packages.""" +from __future__ import annotations + +from typing import IO, TYPE_CHECKING, Iterator + from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.packuri import PACKAGE_URI, PackURI from docx.opc.part import PartFactory @@ -7,7 +11,10 @@ from docx.opc.pkgreader import PackageReader from docx.opc.pkgwriter import PackageWriter from docx.opc.rel import Relationships -from docx.opc.shared import lazyproperty +from docx.shared import lazyproperty + +if TYPE_CHECKING: + from docx.opc.part import Part class OpcPackage: @@ -56,7 +63,7 @@ def walk_rels(source, visited=None): for rel in walk_rels(self): yield rel - def iter_parts(self): + def iter_parts(self) -> Iterator[Part]: """Generate exactly one reference to each of the parts in the package by performing a depth-first traversal of the rels graph.""" @@ -76,7 +83,7 @@ def walk_parts(source, visited=[]): for part in walk_parts(self): yield part - def load_rel(self, reltype, target, rId, is_external=False): + def load_rel(self, reltype: str, target: Part | str, rId: str, is_external: bool = False): """Return newly added |_Relationship| instance of `reltype` between this part and `target` with key `rId`. @@ -111,14 +118,14 @@ def next_partname(self, template): return PackURI(candidate_partname) @classmethod - def open(cls, pkg_file): + def open(cls, pkg_file: str | IO[bytes]) -> OpcPackage: """Return an |OpcPackage| instance loaded with the contents of `pkg_file`.""" pkg_reader = PackageReader.from_file(pkg_file) package = cls() Unmarshaller.unmarshal(pkg_reader, package, PartFactory) return package - def part_related_by(self, reltype): + def part_related_by(self, reltype: str) -> Part: """Return part to which this package has a relationship of `reltype`. Raises |KeyError| if no such relationship is found and |ValueError| if more than @@ -127,13 +134,16 @@ def part_related_by(self, reltype): return self.rels.part_with_reltype(reltype) @property - def parts(self): + def parts(self) -> list[Part]: """Return a list containing a reference to each of the parts in this package.""" return list(self.iter_parts()) - def relate_to(self, part, reltype): - """Return rId key of relationship to `part`, from the existing relationship if - there is one, otherwise a newly created one.""" + def relate_to(self, part: Part, reltype: str): + """Return rId key of new or existing relationship to `part`. + + If a relationship of `reltype` to `part` already exists, its rId is returned. Otherwise a + new relationship is created and that rId is returned. + """ rel = self.rels.get_or_add(reltype, part) return rel.rId @@ -143,9 +153,11 @@ def rels(self): relationships for this package.""" return Relationships(PACKAGE_URI.baseURI) - def save(self, pkg_file): - """Save this package to `pkg_file`, where `file` can be either a path to a file - (a string) or a file-like object.""" + def save(self, pkg_file: str | IO[bytes]): + """Save this package to `pkg_file`. + + `pkg_file` can be either a file-path or a file-like object. + """ for part in self.parts: part.before_marshal() PackageWriter.write(pkg_file, self.rels, self.parts) @@ -190,9 +202,7 @@ def _unmarshal_parts(pkg_reader, package, part_factory): """ parts = {} for partname, content_type, reltype, blob in pkg_reader.iter_sparts(): - parts[partname] = part_factory( - partname, content_type, reltype, blob, package - ) + parts[partname] = part_factory(partname, content_type, reltype, blob, package) return parts @staticmethod @@ -202,7 +212,5 @@ def _unmarshal_relationships(pkg_reader, package, parts): in `parts`.""" for source_uri, srel in pkg_reader.iter_srels(): source = package if source_uri == "/" else parts[source_uri] - target = ( - srel.target_ref if srel.is_external else parts[srel.target_partname] - ) + target = srel.target_ref if srel.is_external else parts[srel.target_partname] source.load_rel(srel.reltype, target, srel.rId, srel.is_external) diff --git a/src/docx/opc/part.py b/src/docx/opc/part.py index a4ad3e7b2..142f49dd1 100644 --- a/src/docx/opc/part.py +++ b/src/docx/opc/part.py @@ -7,8 +7,9 @@ from docx.opc.oxml import serialize_part_xml from docx.opc.packuri import PackURI from docx.opc.rel import Relationships -from docx.opc.shared import cls_method_fn, lazyproperty +from docx.opc.shared import cls_method_fn from docx.oxml.parser import parse_xml +from docx.shared import lazyproperty if TYPE_CHECKING: from docx.package import Package @@ -81,9 +82,10 @@ def drop_rel(self, rId: str): def load(cls, partname: str, content_type: str, blob: bytes, package: Package): return cls(partname, content_type, blob, package) - def load_rel(self, reltype, target, rId, is_external=False): - """Return newly added |_Relationship| instance of `reltype` between this part - and `target` with key `rId`. + def load_rel(self, reltype: str, target: Part | str, rId: str, is_external: bool = False): + """Return newly added |_Relationship| instance of `reltype`. + + The new relationship relates the `target` part to this part with key `rId`. Target mode is set to ``RTM.EXTERNAL`` if `is_external` is |True|. Intended for use during load from a serialized package, where the rId is well-known. Other @@ -118,7 +120,7 @@ def part_related_by(self, reltype: str) -> Part: """ return self.rels.part_with_reltype(reltype) - def relate_to(self, target: Part, reltype: str, is_external: bool = False) -> str: + def relate_to(self, target: Part | str, reltype: str, is_external: bool = False) -> str: """Return rId key of relationship of `reltype` to `target`. The returned `rId` is from an existing relationship if there is one, otherwise a @@ -142,7 +144,7 @@ def rels(self): """|Relationships| instance holding the relationships for this part.""" return Relationships(self._partname.baseURI) - def target_ref(self, rId): + def target_ref(self, rId: str) -> str: """Return URL contained in target ref of relationship identified by `rId`.""" rel = self.rels[rId] return rel.target_ref diff --git a/src/docx/opc/pkgwriter.py b/src/docx/opc/pkgwriter.py index 75af6ac75..e63516979 100644 --- a/src/docx/opc/pkgwriter.py +++ b/src/docx/opc/pkgwriter.py @@ -4,6 +4,10 @@ OpcPackage.save(). """ +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterable + from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.oxml import CT_Types, serialize_part_xml from docx.opc.packuri import CONTENT_TYPES_URI, PACKAGE_URI @@ -11,6 +15,9 @@ from docx.opc.shared import CaseInsensitiveDict from docx.opc.spec import default_content_types +if TYPE_CHECKING: + from docx.opc.part import Part + class PackageWriter: """Writes a zip-format OPC package to `pkg_file`, where `pkg_file` can be either a @@ -38,13 +45,13 @@ def _write_content_types_stream(phys_writer, parts): phys_writer.write(CONTENT_TYPES_URI, cti.blob) @staticmethod - def _write_parts(phys_writer, parts): + def _write_parts(phys_writer: PhysPkgWriter, parts: Iterable[Part]): """Write the blob of each part in `parts` to the package, along with a rels item for its relationships if and only if it has any.""" for part in parts: phys_writer.write(part.partname, part.blob) - if len(part._rels): - phys_writer.write(part.partname.rels_uri, part._rels.xml) + if len(part.rels): + phys_writer.write(part.partname.rels_uri, part.rels.xml) @staticmethod def _write_pkg_rels(phys_writer, pkg_rels): diff --git a/src/docx/opc/rel.py b/src/docx/opc/rel.py index efac5e06b..5fae7ad9c 100644 --- a/src/docx/opc/rel.py +++ b/src/docx/opc/rel.py @@ -2,10 +2,13 @@ from __future__ import annotations -from typing import Any, Dict +from typing import TYPE_CHECKING, Any, Dict from docx.opc.oxml import CT_Relationships +if TYPE_CHECKING: + from docx.opc.part import Part + class Relationships(Dict[str, "_Relationship"]): """Collection object for |_Relationship| instances, having list semantics.""" @@ -16,7 +19,7 @@ def __init__(self, baseURI: str): self._target_parts_by_rId: Dict[str, Any] = {} def add_relationship( - self, reltype: str, target: str | Any, rId: str, is_external: bool = False + self, reltype: str, target: Part | str, rId: str, is_external: bool = False ) -> "_Relationship": """Return a newly added |_Relationship| instance.""" rel = _Relationship(rId, reltype, target, self._baseURI, is_external) @@ -25,7 +28,7 @@ def add_relationship( self._target_parts_by_rId[rId] = target return rel - def get_or_add(self, reltype, target_part): + def get_or_add(self, reltype: str, target_part: Part) -> _Relationship: """Return relationship of `reltype` to `target_part`, newly added if not already present in collection.""" rel = self._get_matching(reltype, target_part) @@ -64,7 +67,9 @@ def xml(self): rels_elm.add_rel(rel.rId, rel.reltype, rel.target_ref, rel.is_external) return rels_elm.xml - def _get_matching(self, reltype, target, is_external=False): + def _get_matching( + self, reltype: str, target: Part | str, is_external: bool = False + ) -> _Relationship | None: """Return relationship of matching `reltype`, `target`, and `is_external` from collection, or None if not found.""" @@ -99,7 +104,7 @@ def _get_rel_of_type(self, reltype): return matching[0] @property - def _next_rId(self): + def _next_rId(self) -> str: """Next available rId in collection, starting from 'rId1' and making use of any gaps in numbering, e.g. 'rId2' for rIds ['rId1', 'rId3'].""" for n in range(1, len(self) + 2): @@ -135,8 +140,7 @@ def rId(self): def target_part(self): if self._is_external: raise ValueError( - "target_part property on _Relationship is undef" - "ined when target mode is External" + "target_part property on _Relationship is undef" "ined when target mode is External" ) return self._target diff --git a/src/docx/opc/shared.py b/src/docx/opc/shared.py index 1862f66db..9d4c0a6d3 100644 --- a/src/docx/opc/shared.py +++ b/src/docx/opc/shared.py @@ -1,7 +1,13 @@ """Objects shared by opc modules.""" +from __future__ import annotations -class CaseInsensitiveDict(dict): +from typing import Any, Dict, TypeVar + +_T = TypeVar("_T") + + +class CaseInsensitiveDict(Dict[str, Any]): """Mapping type that behaves like dict except that it matches without respect to the case of the key. @@ -23,23 +29,3 @@ def __setitem__(self, key, value): def cls_method_fn(cls: type, method_name: str): """Return method of `cls` having `method_name`.""" return getattr(cls, method_name) - - -def lazyproperty(f): - """@lazyprop decorator. - - Decorated method will be called only on first access to calculate a cached property - value. After that, the cached value is returned. - """ - cache_attr_name = "_%s" % f.__name__ # like '_foobar' for prop 'foobar' - docstring = f.__doc__ - - def get_prop_value(obj): - try: - return getattr(obj, cache_attr_name) - except AttributeError: - value = f(obj) - setattr(obj, cache_attr_name, value) - return value - - return property(get_prop_value, doc=docstring) diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index 7fdeaa422..d8fcef453 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -1,5 +1,9 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for docx.opc.package module""" +from __future__ import annotations + import pytest from docx.opc.constants import RELATIONSHIP_TYPE as RT @@ -12,8 +16,8 @@ from docx.opc.rel import Relationships, _Relationship from ..unitutil.mock import ( + FixtureRequest, Mock, - PropertyMock, call, class_mock, instance_mock, @@ -25,6 +29,8 @@ class DescribeOpcPackage: + """Unit-test suite for `docx.opc.package.OpcPackage` objects.""" + def it_can_open_a_pkg_file(self, PackageReader_, PartFactory_, Unmarshaller_): # mockery ---------------------- pkg_file = Mock(name="pkg_file") @@ -42,19 +48,26 @@ def it_initializes_its_rels_collection_on_first_reference(self, Relationships_): Relationships_.assert_called_once_with(PACKAGE_URI.baseURI) assert rels == Relationships_.return_value - def it_can_add_a_relationship_to_a_part(self, pkg_with_rels_, rel_attrs_): - reltype, target, rId = rel_attrs_ - pkg = pkg_with_rels_ - # exercise --------------------- - pkg.load_rel(reltype, target, rId) - # verify ----------------------- - pkg._rels.add_relationship.assert_called_once_with(reltype, target, rId, False) + def it_can_add_a_relationship_to_a_part(self, rels_prop_: Mock, rels_: Mock, part_: Mock): + rels_prop_.return_value = rels_ + pkg = OpcPackage() + + pkg.load_rel("http://rel/type", part_, "rId99") - def it_can_establish_a_relationship_to_another_part(self, relate_to_part_fixture_): - pkg, part_, reltype, rId = relate_to_part_fixture_ - _rId = pkg.relate_to(part_, reltype) - pkg.rels.get_or_add.assert_called_once_with(reltype, part_) - assert _rId == rId + rels_.add_relationship.assert_called_once_with("http://rel/type", part_, "rId99", False) + + def it_can_establish_a_relationship_to_another_part( + self, rels_prop_: Mock, rels_: Mock, rel_: Mock, part_: Mock + ): + rel_.rId = "rId99" + rels_.get_or_add.return_value = rel_ + rels_prop_.return_value = rels_ + pkg = OpcPackage() + + rId = pkg.relate_to(part_, "http://rel/type") + + rels_.get_or_add.assert_called_once_with("http://rel/type", part_) + assert rId == "rId99" def it_can_provide_a_list_of_the_parts_it_contains(self): # mockery ---------------------- @@ -64,7 +77,7 @@ def it_can_provide_a_list_of_the_parts_it_contains(self): with patch.object(OpcPackage, "iter_parts", return_value=parts): assert pkg.parts == [parts[0], parts[1]] - def it_can_iterate_over_parts_by_walking_rels_graph(self): + def it_can_iterate_over_parts_by_walking_rels_graph(self, rels_prop_: Mock): # +----------+ +--------+ # | pkg_rels |-----> | part_1 | # +----------+ +--------+ @@ -77,7 +90,7 @@ def it_can_iterate_over_parts_by_walking_rels_graph(self): part1.rels = {1: Mock(name="rel1", is_external=False, target_part=part2)} part2.rels = {1: Mock(name="rel2", is_external=False, target_part=part1)} pkg = OpcPackage() - pkg._rels = { + rels_prop_.return_value = { 1: Mock(name="rel3", is_external=False, target_part=part1), 2: Mock(name="rel4", is_external=True), } @@ -106,21 +119,22 @@ def it_can_find_a_part_related_by_reltype(self, related_part_fixture_): pkg.rels.part_with_reltype.assert_called_once_with(reltype) assert related_part is related_part_ - def it_can_save_to_a_pkg_file(self, pkg_file_, PackageWriter_, parts, parts_): + def it_can_save_to_a_pkg_file( + self, pkg_file_: Mock, PackageWriter_: Mock, parts_prop_: Mock, parts_: list[Mock] + ): + parts_prop_.return_value = parts_ pkg = OpcPackage() pkg.save(pkg_file_) for part in parts_: part.before_marshal.assert_called_once_with() - PackageWriter_.write.assert_called_once_with(pkg_file_, pkg._rels, parts_) + PackageWriter_.write.assert_called_once_with(pkg_file_, pkg.rels, parts_) def it_provides_access_to_the_core_properties(self, core_props_fixture): opc_package, core_properties_ = core_props_fixture core_properties = opc_package.core_properties assert core_properties is core_properties_ - def it_provides_access_to_the_core_properties_part_to_help( - self, core_props_part_fixture - ): + def it_provides_access_to_the_core_properties_part_to_help(self, core_props_part_fixture): opc_package, core_properties_part_ = core_props_part_fixture core_properties_part = opc_package._core_properties_part assert core_properties_part is core_properties_part_ @@ -135,9 +149,7 @@ def it_creates_a_default_core_props_part_if_none_present( core_properties_part = opc_package._core_properties_part CorePropertiesPart_.default.assert_called_once_with(opc_package) - relate_to_.assert_called_once_with( - opc_package, core_properties_part_, RT.CORE_PROPERTIES - ) + relate_to_.assert_called_once_with(opc_package, core_properties_part_, RT.CORE_PROPERTIES) assert core_properties_part is core_properties_part_ # fixtures --------------------------------------------- @@ -161,134 +173,106 @@ def core_props_part_fixture(self, part_related_by_, core_properties_part_): def next_partname_fixture(self, request, iter_parts_): existing_partname_ns, next_partname_n = request.param parts_ = [ - instance_mock( - request, Part, name="part[%d]" % idx, partname="/foo/bar/baz%d.xml" % n - ) + instance_mock(request, Part, name="part[%d]" % idx, partname="/foo/bar/baz%d.xml" % n) for idx, n in enumerate(existing_partname_ns) ] expected_value = "/foo/bar/baz%d.xml" % next_partname_n return parts_, expected_value @pytest.fixture - def relate_to_part_fixture_(self, request, pkg, rels_, reltype): - rId = "rId99" - rel_ = instance_mock(request, _Relationship, name="rel_", rId=rId) - rels_.get_or_add.return_value = rel_ - pkg._rels = rels_ - part_ = instance_mock(request, Part, name="part_") - return pkg, part_, reltype, rId - - @pytest.fixture - def related_part_fixture_(self, request, rels_, reltype): + def related_part_fixture_(self, request: FixtureRequest, rels_prop_: Mock, rels_: Mock): related_part_ = instance_mock(request, Part, name="related_part_") rels_.part_with_reltype.return_value = related_part_ pkg = OpcPackage() - pkg._rels = rels_ - return pkg, reltype, related_part_ + rels_prop_.return_value = rels_ + return pkg, "http://rel/type", related_part_ # fixture components ----------------------------------- @pytest.fixture - def CorePropertiesPart_(self, request): + def CorePropertiesPart_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.CorePropertiesPart") @pytest.fixture - def core_properties_(self, request): + def core_properties_(self, request: FixtureRequest): return instance_mock(request, CoreProperties) @pytest.fixture - def core_properties_part_(self, request): + def core_properties_part_(self, request: FixtureRequest): return instance_mock(request, CorePropertiesPart) @pytest.fixture - def _core_properties_part_prop_(self, request): + def _core_properties_part_prop_(self, request: FixtureRequest): return property_mock(request, OpcPackage, "_core_properties_part") @pytest.fixture - def iter_parts_(self, request): + def iter_parts_(self, request: FixtureRequest): return method_mock(request, OpcPackage, "iter_parts") @pytest.fixture - def PackageReader_(self, request): + def PackageReader_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.PackageReader") @pytest.fixture - def PackURI_(self, request): + def PackURI_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.PackURI") @pytest.fixture - def packuri_(self, request): + def packuri_(self, request: FixtureRequest): return instance_mock(request, PackURI) @pytest.fixture - def PackageWriter_(self, request): + def PackageWriter_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.PackageWriter") @pytest.fixture - def PartFactory_(self, request): + def PartFactory_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.PartFactory") @pytest.fixture - def part_related_by_(self, request): - return method_mock(request, OpcPackage, "part_related_by") + def part_(self, request: FixtureRequest): + return instance_mock(request, Part) @pytest.fixture - def parts(self, parts_): - """ - Return a mock patching property OpcPackage.parts, reversing the - patch after each use. - """ - p = patch.object( - OpcPackage, "parts", new_callable=PropertyMock, return_value=parts_ - ) - yield p.start() - p.stop() + def part_related_by_(self, request: FixtureRequest): + return method_mock(request, OpcPackage, "part_related_by") @pytest.fixture - def parts_(self, request): + def parts_(self, request: FixtureRequest): part_ = instance_mock(request, Part, name="part_") part_2_ = instance_mock(request, Part, name="part_2_") return [part_, part_2_] @pytest.fixture - def pkg(self, request): - return OpcPackage() + def parts_prop_(self, request: FixtureRequest): + return property_mock(request, OpcPackage, "parts") @pytest.fixture - def pkg_file_(self, request): + def pkg_file_(self, request: FixtureRequest): return loose_mock(request) @pytest.fixture - def pkg_with_rels_(self, request, rels_): - pkg = OpcPackage() - pkg._rels = rels_ - return pkg - - @pytest.fixture - def Relationships_(self, request): + def Relationships_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.Relationships") @pytest.fixture - def rel_attrs_(self, request): - reltype = "http://rel/type" - target_ = instance_mock(request, Part, name="target_") - rId = "rId99" - return reltype, target_, rId + def rel_(self, request: FixtureRequest): + return instance_mock(request, _Relationship) @pytest.fixture - def relate_to_(self, request): + def relate_to_(self, request: FixtureRequest): return method_mock(request, OpcPackage, "relate_to") @pytest.fixture - def rels_(self, request): + def rels_(self, request: FixtureRequest): return instance_mock(request, Relationships) @pytest.fixture - def reltype(self, request): - return "http://rel/type" + def rels_prop_(self, request: FixtureRequest): + return property_mock(request, OpcPackage, "rels") @pytest.fixture - def Unmarshaller_(self, request): + def Unmarshaller_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.Unmarshaller") @@ -306,9 +290,7 @@ def it_can_unmarshal_from_a_pkg_reader( Unmarshaller.unmarshal(pkg_reader_, pkg_, part_factory_) _unmarshal_parts_.assert_called_once_with(pkg_reader_, pkg_, part_factory_) - _unmarshal_relationships_.assert_called_once_with( - pkg_reader_, pkg_, parts_dict_ - ) + _unmarshal_relationships_.assert_called_once_with(pkg_reader_, pkg_, parts_dict_) for part in parts_dict_.values(): part.after_unmarshal.assert_called_once_with() pkg_.after_unmarshal.assert_called_once_with() @@ -406,13 +388,13 @@ def it_can_unmarshal_relationships(self): # fixtures --------------------------------------------- @pytest.fixture - def blobs_(self, request): + def blobs_(self, request: FixtureRequest): blob_ = loose_mock(request, spec=str, name="blob_") blob_2_ = loose_mock(request, spec=str, name="blob_2_") return blob_, blob_2_ @pytest.fixture - def content_types_(self, request): + def content_types_(self, request: FixtureRequest): content_type_ = loose_mock(request, spec=str, name="content_type_") content_type_2_ = loose_mock(request, spec=str, name="content_type_2_") return content_type_, content_type_2_ @@ -424,13 +406,13 @@ def part_factory_(self, request, parts_): return part_factory_ @pytest.fixture - def partnames_(self, request): + def partnames_(self, request: FixtureRequest): partname_ = loose_mock(request, spec=str, name="partname_") partname_2_ = loose_mock(request, spec=str, name="partname_2_") return partname_, partname_2_ @pytest.fixture - def parts_(self, request): + def parts_(self, request: FixtureRequest): part_ = instance_mock(request, Part, name="part_") part_2_ = instance_mock(request, Part, name="part_2") return part_, part_2_ @@ -442,7 +424,7 @@ def parts_dict_(self, request, partnames_, parts_): return {partname_: part_, partname_2_: part_2_} @pytest.fixture - def pkg_(self, request): + def pkg_(self, request: FixtureRequest): return instance_mock(request, OpcPackage) @pytest.fixture @@ -460,17 +442,15 @@ def pkg_reader_(self, request, partnames_, content_types_, reltypes_, blobs_): return pkg_reader_ @pytest.fixture - def reltypes_(self, request): + def reltypes_(self, request: FixtureRequest): reltype_ = instance_mock(request, str, name="reltype_") reltype_2_ = instance_mock(request, str, name="reltype_2") return reltype_, reltype_2_ @pytest.fixture - def _unmarshal_parts_(self, request): + def _unmarshal_parts_(self, request: FixtureRequest): return method_mock(request, Unmarshaller, "_unmarshal_parts", autospec=False) @pytest.fixture - def _unmarshal_relationships_(self, request): - return method_mock( - request, Unmarshaller, "_unmarshal_relationships", autospec=False - ) + def _unmarshal_relationships_(self, request: FixtureRequest): + return method_mock(request, Unmarshaller, "_unmarshal_relationships", autospec=False) diff --git a/tests/opc/test_part.py b/tests/opc/test_part.py index 163912154..03eacd361 100644 --- a/tests/opc/test_part.py +++ b/tests/opc/test_part.py @@ -1,5 +1,9 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for docx.opc.part module""" +from __future__ import annotations + import pytest from docx.opc.package import OpcPackage @@ -11,6 +15,7 @@ from ..unitutil.cxml import element from ..unitutil.mock import ( ANY, + FixtureRequest, Mock, class_mock, cls_attr_mock, @@ -18,6 +23,7 @@ initializer_mock, instance_mock, loose_mock, + property_mock, ) @@ -117,150 +123,126 @@ def partname_(self, request): class DescribePartRelationshipManagementInterface: - def it_provides_access_to_its_relationships(self, rels_fixture): - part, Relationships_, partname_, rels_ = rels_fixture + """Unit-test suite for `docx.opc.package.Part` relationship behaviors.""" + + def it_provides_access_to_its_relationships( + self, Relationships_: Mock, partname_: Mock, rels_: Mock + ): + Relationships_.return_value = rels_ + part = Part(partname_, "content_type") + rels = part.rels + Relationships_.assert_called_once_with(partname_.baseURI) assert rels is rels_ - def it_can_load_a_relationship(self, load_rel_fixture): - part, rels_, reltype_, target_, rId_ = load_rel_fixture - part.load_rel(reltype_, target_, rId_) - rels_.add_relationship.assert_called_once_with(reltype_, target_, rId_, False) - - def it_can_establish_a_relationship_to_another_part(self, relate_to_part_fixture): - part, target_, reltype_, rId_ = relate_to_part_fixture - rId = part.relate_to(target_, reltype_) - part.rels.get_or_add.assert_called_once_with(reltype_, target_) - assert rId is rId_ - - def it_can_establish_an_external_relationship(self, relate_to_url_fixture): - part, url_, reltype_, rId_ = relate_to_url_fixture - rId = part.relate_to(url_, reltype_, is_external=True) - part.rels.get_or_add_ext_rel.assert_called_once_with(reltype_, url_) - assert rId is rId_ - - def it_can_drop_a_relationship(self, drop_rel_fixture): - part, rId, rel_should_be_gone = drop_rel_fixture - part.drop_rel(rId) - if rel_should_be_gone: - assert rId not in part.rels - else: - assert rId in part.rels - - def it_can_find_a_related_part_by_reltype(self, related_part_fixture): - part, reltype_, related_part_ = related_part_fixture - related_part = part.part_related_by(reltype_) - part.rels.part_with_reltype.assert_called_once_with(reltype_) - assert related_part is related_part_ - - def it_can_find_a_related_part_by_rId(self, related_parts_fixture): - part, related_parts_ = related_parts_fixture - assert part.related_parts is related_parts_ - - def it_can_find_the_uri_of_an_external_relationship(self, target_ref_fixture): - part, rId_, url_ = target_ref_fixture - url = part.target_ref(rId_) - assert url == url_ + def it_can_load_a_relationship(self, rels_prop_: Mock, rels_: Mock, other_part_: Mock): + rels_prop_.return_value = rels_ + part = Part("partname", "content_type") - # fixtures --------------------------------------------- + part.load_rel("http://rel/type", other_part_, "rId42") + + rels_.add_relationship.assert_called_once_with( + "http://rel/type", other_part_, "rId42", False + ) + + def it_can_establish_a_relationship_to_another_part( + self, rels_prop_: Mock, rels_: Mock, rel_: Mock, other_part_: Mock + ): + rels_prop_.return_value = rels_ + rels_.get_or_add.return_value = rel_ + rel_.rId = "rId18" + part = Part("partname", "content_type") + + rId = part.relate_to(other_part_, "http://rel/type") - @pytest.fixture( - params=[ + rels_.get_or_add.assert_called_once_with("http://rel/type", other_part_) + assert rId == "rId18" + + def it_can_establish_an_external_relationship(self, rels_prop_: Mock, rels_: Mock): + rels_prop_.return_value = rels_ + rels_.get_or_add_ext_rel.return_value = "rId27" + part = Part("partname", "content_type") + + rId = part.relate_to("https://hyper/link", "http://rel/type", is_external=True) + + rels_.get_or_add_ext_rel.assert_called_once_with("http://rel/type", "https://hyper/link") + assert rId == "rId27" + + @pytest.mark.parametrize( + ("part_cxml", "rel_should_be_dropped"), + [ ("w:p", True), ("w:p/r:a{r:id=rId42}", True), ("w:p/r:a{r:id=rId42}/r:b{r:id=rId42}", False), - ] + ], ) - def drop_rel_fixture(self, request, part): - part_cxml, rel_should_be_dropped = request.param - rId = "rId42" - part._element = element(part_cxml) - part._rels = {rId: None} - return part, rId, rel_should_be_dropped + def it_can_drop_a_relationship( + self, part_cxml: str, rel_should_be_dropped: bool, rels_prop_: Mock + ): + rels_prop_.return_value = {"rId42": None} + part = Part("partname", "content_type") + part._element = element(part_cxml) # pyright: ignore[reportAttributeAccessIssue] - @pytest.fixture - def load_rel_fixture(self, part, rels_, reltype_, part_, rId_): - part._rels = rels_ - return part, rels_, reltype_, part_, rId_ + part.drop_rel("rId42") - @pytest.fixture - def relate_to_part_fixture(self, request, part, reltype_, part_, rels_, rId_): - part._rels = rels_ - target_ = part_ - return part, target_, reltype_, rId_ + assert ("rId42" not in part.rels) is rel_should_be_dropped - @pytest.fixture - def relate_to_url_fixture(self, request, part, rels_, url_, reltype_, rId_): - part._rels = rels_ - return part, url_, reltype_, rId_ + def it_can_find_a_related_part_by_reltype( + self, rels_prop_: Mock, rels_: Mock, other_part_: Mock + ): + rels_prop_.return_value = rels_ + rels_.part_with_reltype.return_value = other_part_ + part = Part("partname", "content_type") - @pytest.fixture - def related_part_fixture(self, request, part, rels_, reltype_, part_): - part._rels = rels_ - return part, reltype_, part_ + related_part = part.part_related_by("http://rel/type") - @pytest.fixture - def related_parts_fixture(self, request, part, rels_, related_parts_): - part._rels = rels_ - return part, related_parts_ + rels_.part_with_reltype.assert_called_once_with("http://rel/type") + assert related_part is other_part_ - @pytest.fixture - def rels_fixture(self, Relationships_, partname_, rels_): - part = Part(partname_, None) - return part, Relationships_, partname_, rels_ + def it_can_find_a_related_part_by_rId(self, rels_prop_: Mock, rels_: Mock, other_part_: Mock): + rels_prop_.return_value = rels_ + rels_.related_parts = {"rId24": other_part_} + part = Part("partname", "content_type") - @pytest.fixture - def target_ref_fixture(self, request, part, rId_, rel_, url_): - part._rels = {rId_: rel_} - return part, rId_, url_ + assert part.related_parts["rId24"] is other_part_ - # fixture components --------------------------------------------- + def it_can_find_the_uri_of_an_external_relationship( + self, rels_prop_: Mock, rel_: Mock, other_part_: Mock + ): + rels_prop_.return_value = {"rId7": rel_} + rel_.target_ref = "https://hyper/link" + part = Part("partname", "content_type") - @pytest.fixture - def part(self): - return Part(None, None) + url = part.target_ref("rId7") - @pytest.fixture - def part_(self, request): - return instance_mock(request, Part) + assert url == "https://hyper/link" - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - - @pytest.fixture - def Relationships_(self, request, rels_): - return class_mock(request, "docx.opc.part.Relationships", return_value=rels_) + # fixtures --------------------------------------------- @pytest.fixture - def rel_(self, request, rId_, url_): - return instance_mock(request, _Relationship, rId=rId_, target_ref=url_) + def other_part_(self, request: FixtureRequest): + return instance_mock(request, Part) @pytest.fixture - def rels_(self, request, part_, rel_, rId_, related_parts_): - rels_ = instance_mock(request, Relationships) - rels_.part_with_reltype.return_value = part_ - rels_.get_or_add.return_value = rel_ - rels_.get_or_add_ext_rel.return_value = rId_ - rels_.related_parts = related_parts_ - return rels_ + def partname_(self, request: FixtureRequest): + return instance_mock(request, PackURI) @pytest.fixture - def related_parts_(self, request): - return instance_mock(request, dict) + def Relationships_(self, request: FixtureRequest): + return class_mock(request, "docx.opc.part.Relationships") @pytest.fixture - def reltype_(self, request): - return instance_mock(request, str) + def rel_(self, request: FixtureRequest): + return instance_mock(request, _Relationship) @pytest.fixture - def rId_(self, request): - return instance_mock(request, str) + def rels_(self, request: FixtureRequest): + return instance_mock(request, Relationships) @pytest.fixture - def url_(self, request): - return instance_mock(request, str) + def rels_prop_(self, request: FixtureRequest): + return property_mock(request, Part, "rels") class DescribePartFactory: @@ -278,9 +260,7 @@ def it_constructs_part_from_selector_if_defined(self, cls_selector_fixture): part = PartFactory(partname, content_type, reltype, blob, package) # verify ----------------------- cls_selector_fn_.assert_called_once_with(content_type, reltype) - CustomPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) + CustomPartClass_.load.assert_called_once_with(partname, content_type, blob, package) assert part is part_of_custom_type_ def it_constructs_custom_part_type_for_registered_content_types( @@ -292,9 +272,7 @@ def it_constructs_custom_part_type_for_registered_content_types( PartFactory.part_type_for[content_type] = CustomPartClass_ part = PartFactory(partname, content_type, reltype, blob, package) # verify ----------------------- - CustomPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) + CustomPartClass_.load.assert_called_once_with(partname, content_type, blob, package) assert part is part_of_custom_type_ def it_constructs_part_using_default_class_when_no_custom_registered( @@ -302,9 +280,7 @@ def it_constructs_part_using_default_class_when_no_custom_registered( ): partname, content_type, reltype, blob, package = part_args_2_ part = PartFactory(partname, content_type, reltype, blob, package) - DefaultPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) + DefaultPartClass_.load.assert_called_once_with(partname, content_type, blob, package) assert part is part_of_default_type_ # fixtures --------------------------------------------- @@ -319,9 +295,7 @@ def blob_2_(self, request): @pytest.fixture def cls_method_fn_(self, request, cls_selector_fn_): - return function_mock( - request, "docx.opc.part.cls_method_fn", return_value=cls_selector_fn_ - ) + return function_mock(request, "docx.opc.part.cls_method_fn", return_value=cls_selector_fn_) @pytest.fixture def cls_selector_fixture( @@ -405,9 +379,7 @@ def part_args_(self, request, partname_, content_type_, reltype_, package_, blob return partname_, content_type_, reltype_, blob_, package_ @pytest.fixture - def part_args_2_( - self, request, partname_2_, content_type_2_, reltype_2_, package_2_, blob_2_ - ): + def part_args_2_(self, request, partname_2_, content_type_2_, reltype_2_, package_2_, blob_2_): return partname_2_, content_type_2_, reltype_2_, blob_2_, package_2_ @pytest.fixture @@ -426,9 +398,7 @@ def it_can_be_constructed_by_PartFactory( part = XmlPart.load(partname_, content_type_, blob_, package_) parse_xml_.assert_called_once_with(blob_) - __init_.assert_called_once_with( - ANY, partname_, content_type_, element_, package_ - ) + __init_.assert_called_once_with(ANY, partname_, content_type_, element_, package_) assert isinstance(part, XmlPart) def it_can_serialize_to_xml(self, blob_fixture): diff --git a/tests/opc/test_pkgwriter.py b/tests/opc/test_pkgwriter.py index 747300f82..aff8b22d9 100644 --- a/tests/opc/test_pkgwriter.py +++ b/tests/opc/test_pkgwriter.py @@ -1,5 +1,9 @@ +# pyright: reportPrivateUsage=false + """Test suite for opc.pkgwriter module.""" +from __future__ import annotations + import pytest from docx.opc.constants import CONTENT_TYPE as CT @@ -7,9 +11,10 @@ from docx.opc.part import Part from docx.opc.phys_pkg import _ZipPkgWriter from docx.opc.pkgwriter import PackageWriter, _ContentTypesItem +from docx.opc.rel import Relationships from ..unitutil.mock import ( - MagicMock, + FixtureRequest, Mock, call, class_mock, @@ -54,41 +59,48 @@ def it_can_write_a_pkg_rels_item(self): # verify ----------------------- phys_writer.write.assert_called_once_with("/_rels/.rels", pkg_rels.xml) - def it_can_write_a_list_of_parts(self): - # mockery ---------------------- - phys_writer = Mock(name="phys_writer") - rels = MagicMock(name="rels") - rels.__len__.return_value = 1 - part1 = Mock(name="part1", _rels=rels) - part2 = Mock(name="part2", _rels=[]) - # exercise --------------------- - PackageWriter._write_parts(phys_writer, [part1, part2]) - # verify ----------------------- + def it_can_write_a_list_of_parts( + self, phys_pkg_writer_: Mock, part_: Mock, part_2_: Mock, rels_: Mock + ): + rels_.__len__.return_value = 1 + part_.rels = rels_ + part_2_.rels = [] + + PackageWriter._write_parts(phys_pkg_writer_, [part_, part_2_]) + expected_calls = [ - call(part1.partname, part1.blob), - call(part1.partname.rels_uri, part1._rels.xml), - call(part2.partname, part2.blob), + call(part_.partname, part_.blob), + call(part_.partname.rels_uri, part_.rels.xml), + call(part_2_.partname, part_2_.blob), ] - assert phys_writer.write.mock_calls == expected_calls + assert phys_pkg_writer_.write.mock_calls == expected_calls # fixtures --------------------------------------------- @pytest.fixture - def blob_(self, request): + def blob_(self, request: FixtureRequest): return instance_mock(request, str) @pytest.fixture - def cti_(self, request, blob_): + def cti_(self, request: FixtureRequest, blob_): return instance_mock(request, _ContentTypesItem, blob=blob_) @pytest.fixture - def _ContentTypesItem_(self, request, cti_): + def _ContentTypesItem_(self, request: FixtureRequest, cti_): _ContentTypesItem_ = class_mock(request, "docx.opc.pkgwriter._ContentTypesItem") _ContentTypesItem_.from_parts.return_value = cti_ return _ContentTypesItem_ @pytest.fixture - def parts_(self, request): + def part_(self, request: FixtureRequest): + return instance_mock(request, Part) + + @pytest.fixture + def part_2_(self, request: FixtureRequest): + return instance_mock(request, Part) + + @pytest.fixture + def parts_(self, request: FixtureRequest): return instance_mock(request, list) @pytest.fixture @@ -98,9 +110,13 @@ def PhysPkgWriter_(self): p.stop() @pytest.fixture - def phys_pkg_writer_(self, request): + def phys_pkg_writer_(self, request: FixtureRequest): return instance_mock(request, _ZipPkgWriter) + @pytest.fixture + def rels_(self, request: FixtureRequest): + return instance_mock(request, Relationships) + @pytest.fixture def write_cti_fixture(self, _ContentTypesItem_, parts_, phys_pkg_writer_, blob_): return _ContentTypesItem_, parts_, phys_pkg_writer_, blob_ @@ -123,7 +139,7 @@ def _write_methods(self): patch3.stop() @pytest.fixture - def xml_for_(self, request): + def xml_for_(self, request: FixtureRequest): return method_mock(request, _ContentTypesItem, "xml_for") @@ -135,11 +151,9 @@ def it_can_compose_content_types_element(self, xml_for_fixture): # fixtures --------------------------------------------- - def _mock_part(self, request, name, partname_str, content_type): + def _mock_part(self, request: FixtureRequest, name, partname_str, content_type): partname = PackURI(partname_str) - return instance_mock( - request, Part, name=name, partname=partname, content_type=content_type - ) + return instance_mock(request, Part, name=name, partname=partname, content_type=content_type) @pytest.fixture( params=[ @@ -152,7 +166,7 @@ def _mock_part(self, request, name, partname_str, content_type): ("Override", "/zebra/foo.bar", "app/vnd.foobar"), ] ) - def xml_for_fixture(self, request): + def xml_for_fixture(self, request: FixtureRequest): elm_type, partname_str, content_type = request.param part_ = self._mock_part(request, "part_", partname_str, content_type) cti = _ContentTypesItem.from_parts([part_]) @@ -168,9 +182,7 @@ def xml_for_fixture(self, request): types_bldr.with_child( a_Default().with_Extension("rels").with_ContentType(CT.OPC_RELATIONSHIPS) ) - types_bldr.with_child( - a_Default().with_Extension("xml").with_ContentType(CT.XML) - ) + types_bldr.with_child(a_Default().with_Extension("xml").with_ContentType(CT.XML)) if elm_type == "Override": override_bldr = an_Override() From 4e5dd915f054fa1374769eb3126a88045bd62aa6 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 27 Apr 2024 16:07:59 -0700 Subject: [PATCH 06/56] feat(table): add _Row.grid_cols_before --- features/steps/table.py | 14 ++++++++++++++ features/steps/test_files/tbl-props.docx | Bin 20178 -> 20397 bytes features/tbl-row-props.feature | 11 +++++++++++ src/docx/oxml/__init__.py | 1 + src/docx/oxml/table.py | 17 +++++++++++++++++ src/docx/table.py | 17 +++++++++++++++++ tests/test_table.py | 15 +++++++++++++++ 7 files changed, 75 insertions(+) diff --git a/features/steps/table.py b/features/steps/table.py index 0b08f567c..0b7bab822 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -170,6 +170,13 @@ def given_a_table_row_having_height_rule_state(context: Context, state: str): context.row = table.rows[0] +@given("a table row starting with {count} empty grid columns") +def given_a_table_row_starting_with_count_empty_grid_columns(context: Context, count: str): + document = Document(test_docx("tbl-props")) + table = document.tables[7] + context.row = table.rows[int(count)] + + # when ===================================================== @@ -347,6 +354,13 @@ def then_can_iterate_over_row_collection(context: Context): assert actual_count == 2 +@then("row.grid_cols_before is {value}") +def then_row_grid_cols_before_is_value(context: Context, value: str): + expected = int(value) + actual = context.row.grid_cols_before + assert actual == expected, "expected %s, got %s" % (expected, actual) + + @then("row.height is {value}") def then_row_height_is_value(context: Context, value: str): expected_height = None if value == "None" else int(value) diff --git a/features/steps/test_files/tbl-props.docx b/features/steps/test_files/tbl-props.docx index 9d2db676e37d73bbf58a8e00b9dd080e7b41fc3f..740896ff21833af8769d77998975da21601247be 100644 GIT binary patch delta 1899 zcmZwIYar7L0|xN_<}%lplKU-pbA~t+GD38s4UIHnx!*U$^txum+#U`3ujE(X35(k8u!;HgzCh4Li01{pY0f6uU004M?=nHjL zvV;A0%(uht%a1)wGA!hU5O-YF4%-FEX|@>@XvX-S z?(D@9@5%`G3f7de##!#|q?eP5D>}5oZB|{+L5*{m?M-IaThsdduJ}>u$R!NMcJuv< z#tIo93X+^s_lkUTKyLsYUVL$qsug^)kFZXt;8^e9Ug^(^wN?Yo~wpgIxLd zDY0XP7(M9vHf)xO#@gcB&p=ITN9vuMef){qKq37I>1Oe zl{K4>00$Bn5+~DF5s!aGuxXdsFOJJgupS zgXDEHCLKf6jeUi)H3D`|??+Pi_ZOGx4}AJY!1aY;dc&TcuBw`rENJ2_-yPmb6a zs}$7I-eD}~oPq>dE9Y$+c8v_JDt7MmR0n`un#>__{i?K2j*6LYiZg9;3X8tg*do>l zD?=G4L(YrkdVJQ!E)wPs7V^&L8+Sy6?&`=6pFK zIU;z*tznSpy`W$&>kDY~i=Rc(PVZ3(G_|o;zjjNVu zbv#3drcMn`Z(P^R{>XOzkVB}F2Osnb*G?hxpN+>iT%xzqS>w0dm^%r4h`h)WpLp!C z*1KEMTjAIC8r*iDjhhk)T?$$1StT=MPg)f3!&U77KC?^f_D7Y?KiZT&SwOGrbDT=ey%?GOZXKQ}rW<{Buso?> zwn&KTEdG6DL4PW_x+yGD!_bz{INRtaEFoeeTJ-gt%ziqQ?RrN5LhvSYiE$=s`K~xKB4H4V#J!+T}0ZXHK`HQ7`&-~M$tN;Ewz0rc#&PfLOiyS)=ID334V=D*o`~1jK*vo%mHE|Qcql5n_9rM z6zgI$IFfahk~;Fr*+E7dq^zR6j$lQXkz(y*{u6Hx-{A5x&8n^6oQYY8S(}LU*ZSu9 z9FJ-d9vBb3;N$|Q);2~DMS=J*yqvk(3A6n6uNorz)gy^W3bRs29%Ge!4_|K}G;Oi+snlRXyZLO2WGSnsbxn z0dzMxNgz^y#fOqcRuhf|OgKog7TUE!bJe7=R!KxBfr^sT$aL#{GFk_eFNK;Yw}tO{ zN7v627s{5M$MK`Xu;X4*Dn0tET&13%Ql##0(dJqMo-*=}uJ?ajO@{_hdxNn?jNwb4 z@d-N31NK$W_@10l=VH`V!o#2-RhxwiV3PEUWL--BMFd-gJ2Banm2==t{}Rs+*9!NBM%iI3WNC0LUw^>L%6cC{c`Ii z7UC=q*U!3?Y`#@4Cbneb1yiE)3C$1J@ZQN|4{s zNA~o7~r8Yb6jk1NW#DDZR1Jw<|cILvJZ@qOu zD&U4jpS)c}2>m2T4ETS=lz!|0@B=BJJ|h_l z{F7-S|26G@Jo9h^fe#2#Vog<~{&Vr8NFdlD3I<^ZCYV#I5O6T|FolCS3SPCPoU&D; h96=g^MUPW3PKuNiq!DP3(ty;F2onMTvr|70{s#XahQ$B? delta 1671 zcmV;226*|cp8?XH0kDB7f47NbNh4BLufqiZ0O>UV01*HH0C#V4WG`fIV|8t1Zgehq zZEWpZTXUN@6n^io@c1Q9ttBB4c$#()hHhutnM~8&ecVwnw%Gt;K#r6B^(%3)ah%%t zlGu%t7jty^^qq5bjt-Z6{Cporox32*L!Mk-zz^V}6C^$#hROKyf8x)d-h1<+lM9v% zS;Ujz^5P-LFFt>I_i@%Aa(@~JiReg#B=65s|FSy~BJKBjxjzYFmVbytKjS$ci4Q)H zdwets{h&AF*|3KI1jSXFaX-j&xtzn2JC=7VxR|#?iWtAFf0Qm-VVR9PZ8mJJfR)CD25 zg3z3yFfut*9uvbKa&cf&4OogI#Ql_I?5iY!T#pdq=w+)2?uBY4)nB)o{xfULUpAS) z5UYl!lje<^?O*BeXu6!7E)fPgb_w;|^@Nx8Et>|xe~3*Z(dcP0_`nDYe>IPMk60eYxJ^7;I`js{*71xr)Qsog zp7{rl=IxJt!P6Z=SHan83tj5!ZEole zs&6xUw~Sm3nbgu8ZRf~+dF$#}mZ#s*s{(hdN8CWGQ2iVcaBu_ z7!+y)haUVU?u3e-;$iMv)w6figKXObe-v#9-ZH;d^EXvJ>zs5V1rEW0cour`5I&l| z!1a*Z>dn=w&=;Wrz?%9V>+}m8(;jGm*yk01vZ{j)aL;E$Y7lt_-0;->73pgc0u6h^ zu0Cds1PHQh-Dvan{CBO9R^MN~6-m3jGjkBt32611%+@*j2``;Uk~TC-si!|zf6`hZ ze-0!k5%FW62YxWt6O>@*f}6RsBC&pWgisP)h>@6aWAK2mrT<9I RfKMX|P6Ge{Zc6|F003({4j2Fc diff --git a/features/tbl-row-props.feature b/features/tbl-row-props.feature index 377f2853e..a1c23436b 100644 --- a/features/tbl-row-props.feature +++ b/features/tbl-row-props.feature @@ -4,6 +4,17 @@ Feature: Get and set table row properties I need a way to get and set the properties of a table row + Scenario Outline: Get Row.grid_cols_before + Given a table row starting with empty grid columns + Then row.grid_cols_before is + + Examples: Row.grid_cols_before value cases + | count | + | 0 | + | 1 | + | 3 | + + Scenario Outline: Get Row.height_rule Given a table row having height rule Then row.height_rule is diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index a37ee9b8e..c694eb298 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -159,6 +159,7 @@ ) register_element_cls("w:bidiVisual", CT_OnOff) +register_element_cls("w:gridBefore", CT_DecimalNumber) register_element_cls("w:gridCol", CT_TblGridCol) register_element_cls("w:gridSpan", CT_DecimalNumber) register_element_cls("w:tbl", CT_Tbl) diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index 687c6e2e6..474374aa3 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -60,6 +60,14 @@ class CT_Row(BaseOxmlElement): trPr: CT_TrPr | None = ZeroOrOne("w:trPr") # pyright: ignore[reportAssignmentType] tc = ZeroOrMore("w:tc") + @property + def grid_before(self) -> int: + """The number of unpopulated layout-grid cells at the start of this row.""" + trPr = self.trPr + if trPr is None: + return 0 + return trPr.grid_before + def tc_at_grid_col(self, idx: int) -> CT_Tc: """`` element appearing at grid column `idx`. @@ -885,11 +893,20 @@ class CT_TrPr(BaseOxmlElement): "w:del", "w:trPrChange", ) + gridBefore: CT_DecimalNumber | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:gridBefore", successors=_tag_seq[3:] + ) trHeight: CT_Height | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:trHeight", successors=_tag_seq[8:] ) del _tag_seq + @property + def grid_before(self) -> int: + """The number of unpopulated layout-grid cells at the start of this row.""" + gridBefore = self.gridBefore + return 0 if gridBefore is None else gridBefore.val + @property def trHeight_hRule(self) -> WD_ROW_HEIGHT_RULE | None: """Return the value of `w:trHeight@w:hRule`, or |None| if not present.""" diff --git a/src/docx/table.py b/src/docx/table.py index 709bc8dbb..a80a6e4b9 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -385,6 +385,23 @@ def cells(self) -> tuple[_Cell, ...]: """Sequence of |_Cell| instances corresponding to cells in this row.""" return tuple(self.table.row_cells(self._index)) + @property + def grid_cols_before(self) -> int: + """Count of unpopulated grid-columns before the first cell in this row. + + Word allows a row to "start late", meaning that one or more cells are not present at the + beginning of that row. + + Note these are not simply "empty" cells. The renderer reads this value and skips forward to + the table layout-grid position of the first cell in this row; the renderer "skips" this many + columns before drawing the first cell. + + Note this also implies that not all rows are guaranteed to have the same number of cells, + e.g. `_Row.cells` could have length `n` for one row and `n - m` for the next row in the same + table. + """ + return self._tr.grid_before + @property def height(self) -> Length | None: """Return a |Length| object representing the height of this cell, or |None| if diff --git a/tests/test_table.py b/tests/test_table.py index 65f7cb423..7f164181f 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -645,6 +645,21 @@ def table_(self, request: FixtureRequest): class Describe_Row: """Unit-test suite for `docx.table._Row` objects.""" + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ + ("w:tr", 0), + ("w:tr/w:trPr", 0), + ("w:tr/w:trPr/w:gridBefore{w:val=0}", 0), + ("w:tr/w:trPr/w:gridBefore{w:val=3}", 3), + ], + ) + def it_knows_its_grid_cols_before( + self, tr_cxml: str, expected_value: int | None, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + assert row.grid_cols_before == expected_value + @pytest.mark.parametrize( ("tr_cxml", "expected_value"), [ From 1cfcee71f81c09570160f7c0d89614db506472bf Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 27 Apr 2024 16:20:04 -0700 Subject: [PATCH 07/56] feat(table): add _Row.grid_cols_after --- features/steps/table.py | 14 ++++++++++++++ features/steps/test_files/tbl-props.docx | Bin 20397 -> 20419 bytes features/tbl-row-props.feature | 11 +++++++++++ src/docx/oxml/__init__.py | 1 + src/docx/oxml/table.py | 17 +++++++++++++++++ src/docx/table.py | 17 +++++++++++++++++ tests/test_table.py | 13 +++++++++++++ 7 files changed, 73 insertions(+) diff --git a/features/steps/table.py b/features/steps/table.py index 0b7bab822..7cdb50eab 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -154,6 +154,13 @@ def given_a_table_having_two_rows(context: Context): context.table_ = document.tables[0] +@given("a table row ending with {count} empty grid columns") +def given_a_table_row_ending_with_count_empty_grid_columns(context: Context, count: str): + document = Document(test_docx("tbl-props")) + table = document.tables[8] + context.row = table.rows[int(count)] + + @given("a table row having height of {state}") def given_a_table_row_having_height_of_state(context: Context, state: str): table_idx = {"no explicit setting": 0, "2 inches": 2, "3 inches": 3}[state] @@ -354,6 +361,13 @@ def then_can_iterate_over_row_collection(context: Context): assert actual_count == 2 +@then("row.grid_cols_after is {value}") +def then_row_grid_cols_after_is_value(context: Context, value: str): + expected = int(value) + actual = context.row.grid_cols_after + assert actual == expected, "expected %s, got %s" % (expected, actual) + + @then("row.grid_cols_before is {value}") def then_row_grid_cols_before_is_value(context: Context, value: str): expected = int(value) diff --git a/features/steps/test_files/tbl-props.docx b/features/steps/test_files/tbl-props.docx index 740896ff21833af8769d77998975da21601247be..e5fdd728f713d821b47cdee620b6eb1ea5460ade 100644 GIT binary patch delta 1809 zcmV+s2k!W-p8><40kDB74v~SISh{P3Zh!^=08T!Wk0~4mVumedVuq8kDHDH7bE7yA zzR#~_SCT1&VP>jks#25L-QyOR5opw9(M zcBWOctq@21|A|C57{MPY$_y+j!_ zSP>y&#?b`A;+z$dy7NtUGfmyN!~JagIi=%dE-lzk+SmAgCW|M6ZDf^eR97wA56s=j zOLzc9e&_2jrm5EpIU;{*j+b@dVYQ{YP<`6RZ~&*A;b}R&>>J#~wo>O*<1W zyl(vzGC!w5ztvtI_ctHIodmS_fA*O0nY}TitK+^=dS8hHs+Er)1Zl>8P|hw3GW1Gn z-f7ct9L2T%sv#c|(=V7O;~Di=LCN#kui8uM6<~xa3jl|_fs21Gbb%1ANT_oN87Uvi zw~77_J~^Ry&E8dS5vR=(q?WTPs=_d0?Uym4B_0{byR^Uo@FNC(DNVBF!2% z+rQLdiJdbi3xI*9ItaVYdcceN7EN6;@Wvsl_0(6tUDqY2N*twSXHK~YN7u1s8n)a3 zj;sNs;-}!0J?(#EI=)T9@VmEc`;ZJ+AwZV3xrqLQx3qLzsbDy2Ft#-3%5=Y6_{pjY z_K!cHI5N`R4gRwx{?e6rVd)oAu0>%-A!YPq96q)7?PU?$$8>Ip=UpQoJh8%H7*~jT zlrb9Z5d5-hD6*|~PbByeHf<9_d_Xuy+V#9gI%cy$gW!LGJR$8pMQpo68^!@r1XOfn zAl-h!6p;h1WeN6;a1$LmQB<=uPZU1C(;?{&i9(fK6D#Oc5{0JO&@~*;0FGt=8>>&j zS?sy3H61s1{5tWtxMlC#-z1)AYgXU#U1Ykdsy2ydQU5L8IhC7t)W2ZFjv(fzmHff|U>_S}uXhXVxOZv(VcC;opbO+V9p1m7J zt^y5gDz>t7MPCK$2C|(d=MOIyJ?m>9=wg6O{on|X zUiX0F!#Oc*}Lk2mSq48Hw15yUrYW?RnNLbI*|egsQ|eqeDx4Mn!dnsq0{Ke z)ti6N7s4)374bgS=@&SL)l~qp&MN?URVUlPIUk2qWa#ShhEw-9q_2sPtXmtr`j|Bm zK+v=_y~)$_U$sVBeSY~~B<=Rh%!XJ)pwVkGTj%H}R_R2Nw60)`UG1fk)(ZF@_e=3= z^&Rt4ab_0G+{uf$690Ur*z@mK6-iDl`ze2_g&f&EpA!INsuna}eRtdCA({&?HaES% z`Q}0yX;?F`^XmKY(azw2tr?mN&qZHV#X!f?wB#|g#kCLbwq=aTc{&|e_dr?PVGN6Ac z`9r$Ae7>ApU%+T!ZGCdU?uFu}o)VXRQ8(K zAA;X}_#d+@Fqa(;k%607x@&}PfCc~nPCk>-O)3InhLinGI{`wIIZi(Tl#_W*J^_T2 zxK2C*2|be!Jsgu&PY{z7L=X!A000000001h0UVP(PeK9}OOt_5CI&}L00000rp{>F delta 1787 zcmV6(0kDB74k>S&SevqHRB8qQ0G&FMk0~4mu4XM}u4a?5DHDHLbE7yA zexF~#`X##$8B5|2xROjE3^P+TQ|b9z`Ps#% zn-5o$PLFz{$d8%CCB#{0l6ZgDts!Gcr`^uHp&z-~$0+bpn$bS{=+UT6`~AT4+Y_4h z+6X|9UnME^{4C?sS#CUbvsNiCqRpg5u2)?m0CY|V@l?h#BAWOy@9fhwav3Mn0p~~` zM#&|YnYb*t55j;w30c}y%=o%BO5;u`<8q2a@N_tU&NvFI!F1i&0%(8vQ*oqCKo%_B z(&Rb3cIt;5FpaZekj(71tu)>_tc0vpxTfqRfy!=+-c8*J|CtGF0>4)ee+GWGfmyNgWYWVIi;gyCN0=a+SmADDvJk#m&hvDsIFSJ8<@MH zn{Wq;yw2A_OjGwhL4ls;7AfMVw3`$3wqAC$90L56Ng z%{na_j-t5MUp3@IV)}*fU_7P%DkwOc{i?m7UI9j^G6!(L8@PYyLKlS4l7u>kkdgAC zJWcd|@X3KmWMBx0Q0XLY>VD-QknLcMEwyN6{v#8uxcszA_Md5uf8J!?j4T`Ki!^QA zZ2wY+1$NGy%mD_fVq@ai>jBT}TQqh3zB>w8t*5^F?Yb^GRpKZuJKdD?a5RmWhHk+I zaG(mXLRP^kd)j};-1wFshTq*q+lPLi6#^hsXCiu!?!wY>rGmk*!PwHAOVj;w?gteG z?CyU+wjtJ>4f|(}|D{X*!qP8(xfX>Tg_PlsQMhXD+lwMz?$fy;p5Gez;ISD7gSbL; zPZ^`p7S0bHU51v@JrU=}#IOv4$R6PwDeJjUbi}5E`oVvrze3u3hHLIt52OEx}C{6s?P@&V+UhL0`jOzaEKf2!Dz4_&7 zpV4FsSC)U}F2Y@X!yU@YeWx_-v)+9Gn>5lFb&)0mydm7bC4HeyY_-V^-Cp&rNAHH7 zDVTjP&RfLSl7Caxvu=@2q`<)nz>a}l-Gz^)FR&eCH+pjQ zCiH(rs0$QXypMJI1&(fZWq{4|azI|yK?~UDvms@O91U(bb$>(p8W=;(+_0;USt9|A z3{%ybJU#zaYoz7pm+wW=cF)W#L{tnKy(aVG7`XN`YH+m8bSKeJ+O-5cHEmr@TwMCTaU?(O^{;lMFT!z zG0AV2ml6~8lr#z0P)g|}lZNg%hzG@WCNhnn@@_v$*pnpc2PvUhkpEFe`n{+qh~Iyw zssD#`dHH-{x4x87!P-h?kKJ=+PCYm-?ozP`;wu$j?8eO*Z^U1dwW`nE1c`CqT`>Wp z$t?ZkXGNUUK~5HL$+tVRw-EGOq@^=O#!&Ma{~zZhEY z?NLtzEHP6M`?mq}ICNQ?%N~Vg1TCoI&syTD`d{hIhyMXkO9KQH000080JoDdP7||W zFnJvgDQ}xto3d(DY6buRojQ}}O)3JeW|I?6I{_?{PfkApev^+*J^^l%&Q3f6^E{Ib dPeuU-lT%MZ0{=>rF-sPctxpgJF-!me007H)alilo diff --git a/features/tbl-row-props.feature b/features/tbl-row-props.feature index a1c23436b..1b006f204 100644 --- a/features/tbl-row-props.feature +++ b/features/tbl-row-props.feature @@ -4,6 +4,17 @@ Feature: Get and set table row properties I need a way to get and set the properties of a table row + Scenario Outline: Get Row.grid_cols_after + Given a table row ending with empty grid columns + Then row.grid_cols_after is + + Examples: Row.grid_cols_after value cases + | count | + | 0 | + | 1 | + | 2 | + + Scenario Outline: Get Row.grid_cols_before Given a table row starting with empty grid columns Then row.grid_cols_before is diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index c694eb298..bf32932f9 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -159,6 +159,7 @@ ) register_element_cls("w:bidiVisual", CT_OnOff) +register_element_cls("w:gridAfter", CT_DecimalNumber) register_element_cls("w:gridBefore", CT_DecimalNumber) register_element_cls("w:gridCol", CT_TblGridCol) register_element_cls("w:gridSpan", CT_DecimalNumber) diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index 474374aa3..ddebee71b 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -60,6 +60,14 @@ class CT_Row(BaseOxmlElement): trPr: CT_TrPr | None = ZeroOrOne("w:trPr") # pyright: ignore[reportAssignmentType] tc = ZeroOrMore("w:tc") + @property + def grid_after(self) -> int: + """The number of unpopulated layout-grid cells at the end of this row.""" + trPr = self.trPr + if trPr is None: + return 0 + return trPr.grid_after + @property def grid_before(self) -> int: """The number of unpopulated layout-grid cells at the start of this row.""" @@ -893,6 +901,9 @@ class CT_TrPr(BaseOxmlElement): "w:del", "w:trPrChange", ) + gridAfter: CT_DecimalNumber | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:gridAfter", successors=_tag_seq[4:] + ) gridBefore: CT_DecimalNumber | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:gridBefore", successors=_tag_seq[3:] ) @@ -901,6 +912,12 @@ class CT_TrPr(BaseOxmlElement): ) del _tag_seq + @property + def grid_after(self) -> int: + """The number of unpopulated layout-grid cells at the end of this row.""" + gridAfter = self.gridAfter + return 0 if gridAfter is None else gridAfter.val + @property def grid_before(self) -> int: """The number of unpopulated layout-grid cells at the start of this row.""" diff --git a/src/docx/table.py b/src/docx/table.py index a80a6e4b9..9faf8e672 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -385,6 +385,23 @@ def cells(self) -> tuple[_Cell, ...]: """Sequence of |_Cell| instances corresponding to cells in this row.""" return tuple(self.table.row_cells(self._index)) + @property + def grid_cols_after(self) -> int: + """Count of unpopulated grid-columns after the last cell in this row. + + Word allows a row to "end early", meaning that one or more cells are not present at the + end of that row. + + Note these are not simply "empty" cells. The renderer reads this value and "skips" this + many columns after drawing the last cell. + + Note this also implies that not all rows are guaranteed to have the same number of cells, + e.g. `_Row.cells` could have length `n` for one row and `n - m` for the next row in the same + table. Visually this appears as a column (at the beginning or end, not in the middle) with + one or more cells missing. + """ + return self._tr.grid_after + @property def grid_cols_before(self) -> int: """Count of unpopulated grid-columns before the first cell in this row. diff --git a/tests/test_table.py b/tests/test_table.py index 7f164181f..4eb1c8efb 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -645,6 +645,19 @@ def table_(self, request: FixtureRequest): class Describe_Row: """Unit-test suite for `docx.table._Row` objects.""" + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ + ("w:tr", 0), + ("w:tr/w:trPr", 0), + ("w:tr/w:trPr/w:gridAfter{w:val=0}", 0), + ("w:tr/w:trPr/w:gridAfter{w:val=4}", 4), + ], + ) + def it_knows_its_grid_cols_after(self, tr_cxml: str, expected_value: int | None, parent_: Mock): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + assert row.grid_cols_after == expected_value + @pytest.mark.parametrize( ("tr_cxml", "expected_value"), [ From 5a1d6143f1f30a50d09babf2934a6f0857f62c93 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 27 Apr 2024 20:06:31 -0700 Subject: [PATCH 08/56] docs: update Table docs --- docs/index.rst | 1 + docs/user/tables.rst | 202 +++++++++++++++++++++++++++++++++++++++++++ src/docx/table.py | 13 ++- 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 docs/user/tables.rst diff --git a/docs/index.rst b/docs/index.rst index cdb8b5455..1b1029787 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -74,6 +74,7 @@ User Guide user/install user/quickstart user/documents + user/tables user/text user/sections user/hdrftr diff --git a/docs/user/tables.rst b/docs/user/tables.rst new file mode 100644 index 000000000..40ef20933 --- /dev/null +++ b/docs/user/tables.rst @@ -0,0 +1,202 @@ +.. _tables: + +Working with Tables +=================== + +Word provides sophisticated capabilities to create tables. As usual, this power comes with +additional conceptual complexity. + +This complexity becomes most apparent when *reading* tables, in particular from documents drawn from +the wild where there is limited or no prior knowledge as to what the tables might contain or how +they might be structured. + +These are some of the important concepts you'll need to understand. + + +Concept: Simple (uniform) tables +-------------------------------- + +:: + + +---+---+---+ + | a | b | c | + +---+---+---+ + | d | e | f | + +---+---+---+ + | g | h | i | + +---+---+---+ + +The basic concept of a table is intuitive enough. You have *rows* and *columns*, and at each (row, +column) position is a different *cell*. It can be described as a *grid* or a *matrix*. Let's call +this concept a *uniform table*. A relational database table and a Pandas dataframe are both examples +of a uniform table. + +The following invariants apply to uniform tables: + +* Each row has the same number of cells, one for each column. +* Each column has the same number of cells, one for each row. + + +Complication 1: Merged Cells +---------------------------- + +:: + + +---+---+---+ +---+---+---+ + | a | b | | | b | c | + +---+---+---+ + a +---+---+ + | c | d | e | | | d | e | + +---+---+---+ +---+---+---+ + | f | g | h | | f | g | h | + +---+---+---+ +---+---+---+ + +While very suitable for data processing, a uniform table lacks expressive power desireable for +tables intended for a human reader. + +Perhaps the most important characteristic a uniform table lacks is *merged cells*. It is very common +to want to group multiple cells into one, for example to form a column-group heading or provide the +same value for a sequence of cells rather than repeat it for each cell. These make a rendered table +more *readable* by reducing the cognitive load on the human reader and make certain relationships +explicit that might easily be missed otherwise. + +Unfortunately, accommodating merged cells breaks both the invariants of a uniform table: + +* Each row can have a different number of cells. +* Each column can have a different number of cells. + +This challenges reading table contents programatically. One might naturally want to read the table +into a uniform matrix data structure like a 3 x 3 "2D array" (list of lists perhaps), but this is +not directly possible when the table is not known to be uniform. + + +Concept: The layout grid +------------------------ + +:: + + + - + - + - + + | | | | + + - + - + - + + | | | | + + - + - + - + + | | | | + + - + - + - + + +In Word, each table has a *layout grid*. + +- The layout grid is *uniform*. There is a layout position for every (layout-row, layout-column) + pair. +- The layout grid itself is not visible. However it is represented and referenced by certain + elements and attributes within the table XML +- Each table cell is located at a layout-grid position; i.e. the top-left corner of each cell is the + top-left corner of a layout-grid cell. +- Each table cell occupies one or more whole layout-grid cells. A merged cell will occupy multiple + layout-grid cells. No table cell can occupy a partial layout-grid cell. +- Another way of saying this is that every vertical boundary (left and right) of a cell aligns with + a layout-grid vertical boundary, likewise for horizontal boundaries. But not all layout-grid + boundaries need be occupied by a cell boundary of the table. + + +Complication 2: Omitted Cells +----------------------------- + +:: + + +---+---+ +---+---+---+ + | a | b | | a | b | c | + +---+---+---+ +---+---+---+ + | c | d | | d | + +---+---+ +---+---+---+ + | e | | e | f | g | + +---+ +---+---+---+ + +Word is unusual in that it allows cells to be omitted from the beginning or end (but not the middle) +of a row. A typical practical example is a table with both a row of column headings and a column of +row headings, but no top-left cell (position 0, 0), such as this XOR truth table. + +:: + + +---+---+ + | T | F | + +---+---+---+ + | T | F | T | + +---+---+---+ + | F | T | F | + +---+---+---+ + +In `python-docx`, omitted cells in a |_Row| object are represented by the ``.grid_cols_before`` and +``.grid_cols_after`` properties. In the example above, for the first row, ``.grid_cols_before`` +would equal ``1`` and ``.grid_cols_after`` would equal ``0``. + +Note that omitted cells are not just "empty" cells. They represent layout-grid positions that are +unoccupied by a cell and they cannot be represented by a |_Cell| object. This distinction becomes +important when trying to produce a uniform representation (e.g. a 2D array) for an arbitrary Word +table. + + +Concept: `python-docx` approximates uniform tables by default +------------------------------------------------------------- + +To accurately represent an arbitrary table would require a complex graph data structure. Navigating +this data structure would be at least as complex as navigating the `python-docx` object graph for a +table. When extracting content from a collection of arbitrary Word files, such as for indexing the +document, it is common to choose a simpler data structure and *approximate* the table in that +structure. + +Reflecting on how a relational table or dataframe represents tabular information, a straightforward +approximation would simply repeat merged-cell values for each layout-grid cell occupied by the +merged cell:: + + + +---+---+---+ +---+---+---+ + | a | b | -> | a | a | b | + +---+---+---+ +---+---+---+ + | | d | e | -> | c | d | e | + + c +---+---+ +---+---+---+ + | | f | g | -> | c | f | g | + +---+---+---+ +---+---+---+ + +This is what ``_Row.cells`` does by default. Conceptually:: + + >>> [tuple(c.text for c in r.cells) for r in table.rows] + [ + (a, a, b), + (c, d, e), + (c, f, g), + ] + +Note this only produces a uniform "matrix" of cells when there are no omitted cells. Dealing with +omitted cells requires a more sophisticated approach when maintaining column integrity is required:: + + # +---+---+ + # | a | b | + # +---+---+---+ + # | c | d | + # +---+---+ + # | e | + # +---+ + + def iter_row_cell_texts(row: _Row) -> Iterator[str]: + for _ in range(row.grid_cols_before): + yield "" + for c in row.cells: + yield c.text + for _ in range(row.grid_cols_after): + yield "" + + >>> [tuple(iter_row_cell_texts(r)) for r in table.rows] + [ + ("", "a", "b"), + ("c", "d", ""), + ("", "e", ""), + ] + + +Complication 3: Tables are Recursive +------------------------------------ + +Further complicating table processing is their recursive nature. In Word, as in HTML, a table cell +can itself include one or more tables. + +These can be detected using ``_Cell.tables`` or ``_Cell.iter_inner_content()``. The latter preserves +the document order of the table with respect to paragraphs also in the cell. diff --git a/src/docx/table.py b/src/docx/table.py index 9faf8e672..a272560bc 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -382,7 +382,18 @@ def __init__(self, tr: CT_Row, parent: TableParent): @property def cells(self) -> tuple[_Cell, ...]: - """Sequence of |_Cell| instances corresponding to cells in this row.""" + """Sequence of |_Cell| instances corresponding to cells in this row. + + Note that Word allows table rows to start later than the first column and end before the + last column. + + - Only cells actually present are included in the return value. + - This implies the length of this cell sequence may differ between rows of the same table. + - If you are reading the cells from each row to form a rectangular "matrix" data structure + of the table cell values, you will need to account for empty leading and/or trailing + layout-grid positions using `.grid_cols_before` and `.grid_cols_after`. + + """ return tuple(self.table.row_cells(self._index)) @property From 6d49a690bb22fcec30614ef30156242ba0ececd5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 28 Apr 2024 17:39:35 -0700 Subject: [PATCH 09/56] rfctr(table): reimplement CT_Tc._tr_above Use XPath rather than oxml structures. --- src/docx/oxml/table.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index ddebee71b..963f3ebf7 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -748,11 +748,10 @@ def _tr_above(self) -> CT_Row: Raises |ValueError| if called on a cell in the top-most row. """ - tr_lst = self._tbl.tr_lst - tr_idx = tr_lst.index(self._tr) - if tr_idx == 0: - raise ValueError("no tr above topmost tr") - return tr_lst[tr_idx - 1] + tr_aboves = self.xpath("./ancestor::w:tr[position()=1]/preceding-sibling::w:tr[1]") + if not tr_aboves: + raise ValueError("no tr above topmost tr in w:tbl") + return tr_aboves[0] @property def _tr_below(self) -> CT_Row | None: From 382d43e41964d935e8a30d885592205d3ecf22d2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 27 Apr 2024 22:46:14 -0700 Subject: [PATCH 10/56] feat(table): add _Cell.grid_span --- features/steps/table.py | 16 +++++++++++++++- features/steps/test_files/tbl-cell-props.docx | Bin 0 -> 13773 bytes features/tbl-cell-props.feature | 11 +++++++++++ src/docx/table.py | 9 +++++++++ tests/test_table.py | 13 +++++++++++++ 5 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 features/steps/test_files/tbl-cell-props.docx diff --git a/features/steps/table.py b/features/steps/table.py index 7cdb50eab..38d49ee0a 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -13,7 +13,7 @@ WD_TABLE_DIRECTION, ) from docx.shared import Inches -from docx.table import Table, _Column, _Columns, _Row, _Rows +from docx.table import Table, _Cell, _Column, _Columns, _Row, _Rows from helpers import test_docx @@ -37,6 +37,13 @@ def given_a_3x3_table_having_span_state(context: Context, span_state: str): context.table_ = document.tables[table_idx] +@given("a _Cell object spanning {count} layout-grid cells") +def given_a_Cell_object_spanning_count_layout_grid_cells(context: Context, count: str): + document = Document(test_docx("tbl-cell-props")) + table = document.tables[0] + context.cell = _Cell(table._tbl.tr_lst[int(count)].tc_lst[0], table) + + @given("a _Cell object with {state} vertical alignment as cell") def given_a_Cell_object_with_vertical_alignment_as_cell(context: Context, state: str): table_idx = { @@ -292,6 +299,13 @@ def when_I_set_the_table_autofit_to_setting(context: Context, setting: str): # then ===================================================== +@then("cell.grid_span is {count}") +def then_cell_grid_span_is_count(context: Context, count: str): + expected = int(count) + actual = context.cell.grid_span + assert actual == expected, f"expected {expected}, got {actual}" + + @then("cell.tables[0] is a 2 x 2 table") def then_cell_tables_0_is_a_2x2_table(context: Context): cell = context.cell diff --git a/features/steps/test_files/tbl-cell-props.docx b/features/steps/test_files/tbl-cell-props.docx new file mode 100644 index 0000000000000000000000000000000000000000..627fb66fc388257cd6a4659ddc433d3f88c6b9d9 GIT binary patch literal 13773 zcmbum19Y8R+cvsl+iq;zZfv8m?Z!>gu(55sv27cTZJW(W_kO>3@Ag0c8Q&Nu$yym} zu5sPZ{mgsL=ejlJr9ePY0RR9bpekufgc5ZC+7$=@*arguP~Jzigl%n{jBT8BmEG)& z9Y4{zT3gk{Ny&8!pa$H(e3M}Rl;qMGz!=yqWM52TpdC4*g?Y-ub#}z%}ity%9U1_?|S|6XKl@|dJJ8|#7A|Q$9#f> z1*Fwoeq^jtBJfBa+U2U|8YF4d@u=d4Ix>%h#X(HCQ{&R@JxOJcii-2x`Z?N-oY+^p zdaWoqPIFcGWk!`ZCT+vMZ~+4t+2%nLa8IiT7Jh;#_mSJtu@*>+46&{2z^AA|Cy3%=Bb0|P~Ws8n!2UGX=7&$Lwo z`N3q{ttfvZ?wMfc78CA_0-py1Txz;+6}Pk}LPd+v=Ik30^CtGV-l9;9QgYgv%k?;C zD>U?ak|3X+hkM0)`AH}9Q0Evz{x?IxpOrelz8mNS7yy8I|F7#{Y~@H#_v^hpW=s-< z5z*(|>syqR?5b61@SuqYQZ{?^6TpzE1!wgoO1jR=BR9BNzFaTz`u>uUdCX$}<6Ri# z@P5yMij*n!LJC-27V5Eb_0tS

4i)GBIwBc$IDta(=_Wlq_?0%cqmvzaXjR4 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 layout-grid cells + Then cell.grid_span is + + Examples: Cell.grid_span value cases + | count | + | 1 | + | 2 | + | 4 | + + Scenario Outline: Get _Cell.vertical_alignment Given a _Cell object with vertical alignment as cell Then cell.vertical_alignment is diff --git a/src/docx/table.py b/src/docx/table.py index a272560bc..e88232840 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -222,6 +222,15 @@ def add_table( # pyright: ignore[reportIncompatibleMethodOverride] self.add_paragraph() return table + @property + def grid_span(self) -> int: + """Number of layout-grid cells this cell spans horizontally. + + A "normal" cell has a grid-span of 1. A horizontally merged cell has a grid-span of 2 or + more. + """ + return self._tc.grid_span + def merge(self, other_cell: _Cell): """Return a merged cell created by spanning the rectangular region having this cell and `other_cell` as diagonal corners. diff --git a/tests/test_table.py b/tests/test_table.py index 4eb1c8efb..993fb3f23 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -326,6 +326,19 @@ def table(self, document_: Mock): class Describe_Cell: """Unit-test suite for `docx.table._Cell` objects.""" + @pytest.mark.parametrize( + ("tc_cxml", "expected_value"), + [ + ("w:tc", 1), + ("w:tc/w:tcPr", 1), + ("w:tc/w:tcPr/w:gridSpan{w:val=1}", 1), + ("w:tc/w:tcPr/w:gridSpan{w:val=4}", 4), + ], + ) + def it_knows_its_grid_span(self, tc_cxml: str, expected_value: int, parent_: Mock): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + assert cell.grid_span == expected_value + @pytest.mark.parametrize( ("tc_cxml", "expected_text"), [ From 7508051c7664a8a5ce8828e7fa33e1075578617c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 27 Apr 2024 23:06:00 -0700 Subject: [PATCH 11/56] feat(table): add CT_Tc.grid_offset This property was formerly known as `._grid_col` but that didn't account for `.grid_before` in the computation. --- src/docx/oxml/table.py | 28 ++++++++++++++++------------ tests/oxml/test_table.py | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index 963f3ebf7..4715900e6 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -450,6 +450,18 @@ def clear_content(self): for e in self.xpath("./*[not(self::w:tcPr)]"): self.remove(e) + @property + def grid_offset(self) -> int: + """Starting offset of `tc` in the layout-grid columns of its table. + + A cell in the leftmost grid-column has offset 0. + """ + grid_before = self._tr.grid_before + preceding_tc_grid_spans = sum( + tc.grid_span for tc in self.xpath("./preceding-sibling::w:tc") + ) + return grid_before + preceding_tc_grid_spans + @property def grid_span(self) -> int: """The integer number of columns this cell spans. @@ -484,7 +496,7 @@ def iter_block_items(self): @property def left(self) -> int: """The grid column index at which this ```` element appears.""" - return self._grid_col + return self.grid_offset def merge(self, other_tc: CT_Tc) -> CT_Tc: """Return top-left `w:tc` element of a new span. @@ -510,7 +522,7 @@ def right(self) -> int: This is one greater than the index of the right-most column of the span, similar to how a slice of the cell's columns would be specified. """ - return self._grid_col + self.grid_span + return self.grid_offset + self.grid_span @property def top(self) -> int: @@ -553,14 +565,6 @@ def _add_width_of(self, other_tc: CT_Tc): if self.width and other_tc.width: self.width = Length(self.width + other_tc.width) - @property - def _grid_col(self) -> int: - """The grid column at which this cell begins.""" - tr = self._tr - idx = tr.tc_lst.index(self) - preceding_tcs = tr.tc_lst[:idx] - return sum(tc.grid_span for tc in preceding_tcs) - def _grow_to(self, width: int, height: int, top_tc: CT_Tc | None = None): """Grow this cell to `width` grid columns and `height` rows. @@ -727,7 +731,7 @@ def _tbl(self) -> CT_Tbl: @property def _tc_above(self) -> CT_Tc: """The `w:tc` element immediately above this one in its grid column.""" - return self._tr_above.tc_at_grid_col(self._grid_col) + return self._tr_above.tc_at_grid_col(self.grid_offset) @property def _tc_below(self) -> CT_Tc | None: @@ -735,7 +739,7 @@ def _tc_below(self) -> CT_Tc | None: tr_below = self._tr_below if tr_below is None: return None - return tr_below.tc_at_grid_col(self._grid_col) + return tr_below.tc_at_grid_col(self.grid_offset) @property def _tr(self) -> CT_Row: diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 6a177ab77..937496346 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -52,6 +52,22 @@ def it_raises_on_tc_at_grid_col( class DescribeCT_Tc: """Unit-test suite for `docx.oxml.table.CT_Tc` objects.""" + @pytest.mark.parametrize( + ("tr_cxml", "tc_idx", "expected_value"), + [ + ("w:tr/(w:tc/w:p,w:tc/w:p)", 0, 0), + ("w:tr/(w:tc/w:p,w:tc/w:p)", 1, 1), + ("w:tr/(w:trPr/w:gridBefore{w:val=2},w:tc/w:p,w:tc/w:p)", 0, 2), + ("w:tr/(w:trPr/w:gridBefore{w:val=2},w:tc/w:p,w:tc/w:p)", 1, 3), + ("w:tr/(w:trPr/w:gridBefore{w:val=4},w:tc/w:p,w:tc/w:p,w:tc/w:p,w:tc/w:p)", 2, 6), + ], + ) + def it_knows_its_grid_offset(self, tr_cxml: str, tc_idx: int, expected_value: int): + tr = cast(CT_Row, element(tr_cxml)) + tc = tr.tc_lst[tc_idx] + + assert tc.grid_offset == expected_value + def it_can_merge_to_another_tc( self, tr_: Mock, _span_dimensions_: Mock, _tbl_: Mock, _grow_to_: Mock, top_tc_: Mock ): From 512f269b7560c02f81c16c7858ed67bfdc956dae Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 27 Apr 2024 23:33:38 -0700 Subject: [PATCH 12/56] rfctr(table): reimplement CT_Tc.tc_at_grid_offset This method was formerly named `.tc_at_grid_col()`. New implementation takes `CT_Tr.grid_before` into account. --- src/docx/oxml/table.py | 31 +++++++++++++++++++------------ tests/oxml/test_table.py | 20 ++++++-------------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index 4715900e6..42e8cc95c 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -76,19 +76,26 @@ def grid_before(self) -> int: return 0 return trPr.grid_before - def tc_at_grid_col(self, idx: int) -> CT_Tc: - """`` element appearing at grid column `idx`. + def tc_at_grid_offset(self, grid_offset: int) -> CT_Tc: + """The `tc` element in this tr at exact `grid offset`. - Raises |ValueError| if no `w:tc` element begins at that grid column. + Raises ValueError when this `w:tr` contains no `w:tc` with exact starting `grid_offset`. """ - grid_col = 0 + # -- account for omitted cells at the start of the row -- + remaining_offset = grid_offset - self.grid_before + for tc in self.tc_lst: - if grid_col == idx: + # -- We've gone past grid_offset without finding a tc, no sense searching further. -- + if remaining_offset < 0: + break + # -- We've arrived at grid_offset, this is the `w:tc` we're looking for. -- + if remaining_offset == 0: return tc - grid_col += tc.grid_span - if grid_col > idx: - raise ValueError("no cell on grid column %d" % idx) - raise ValueError("index out of bounds") + # -- We're not there yet, skip forward the number of layout-grid cells this cell + # -- occupies. + remaining_offset -= tc.grid_span + + raise ValueError(f"no `tc` element at grid_offset={grid_offset}") @property def tr_idx(self) -> int: @@ -505,7 +512,7 @@ def merge(self, other_tc: CT_Tc) -> CT_Tc: element and `other_tc` as diagonal corners. """ top, left, height, width = self._span_dimensions(other_tc) - top_tc = self._tbl.tr_lst[top].tc_at_grid_col(left) + top_tc = self._tbl.tr_lst[top].tc_at_grid_offset(left) top_tc._grow_to(width, height) return top_tc @@ -731,7 +738,7 @@ def _tbl(self) -> CT_Tbl: @property def _tc_above(self) -> CT_Tc: """The `w:tc` element immediately above this one in its grid column.""" - return self._tr_above.tc_at_grid_col(self.grid_offset) + return self._tr_above.tc_at_grid_offset(self.grid_offset) @property def _tc_below(self) -> CT_Tc | None: @@ -739,7 +746,7 @@ def _tc_below(self) -> CT_Tc | None: tr_below = self._tr_below if tr_below is None: return None - return tr_below.tc_at_grid_col(self.grid_offset) + return tr_below.tc_at_grid_offset(self.grid_offset) @property def _tr(self) -> CT_Row: diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 937496346..46b2f4ed1 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -34,19 +34,11 @@ def it_can_add_a_trPr(self, tr_cxml: str, expected_cxml: str): tr._add_trPr() assert tr.xml == xml(expected_cxml) - @pytest.mark.parametrize( - ("snippet_idx", "row_idx", "col_idx", "err_msg"), - [ - (0, 0, 3, "index out of bounds"), - (1, 0, 1, "no cell on grid column 1"), - ], - ) - def it_raises_on_tc_at_grid_col( - self, snippet_idx: int, row_idx: int, col_idx: int, err_msg: str - ): + @pytest.mark.parametrize(("snippet_idx", "row_idx", "col_idx"), [(0, 0, 3), (1, 0, 1)]) + def it_raises_on_tc_at_grid_col(self, snippet_idx: int, row_idx: int, col_idx: int): tr = cast(CT_Tbl, parse_xml(snippet_seq("tbl-cells")[snippet_idx])).tr_lst[row_idx] - with pytest.raises(ValueError, match=err_msg): - tr.tc_at_grid_col(col_idx) + with pytest.raises(ValueError, match=f"no `tc` element at grid_offset={col_idx}"): + tr.tc_at_grid_offset(col_idx) class DescribeCT_Tc: @@ -76,12 +68,12 @@ def it_can_merge_to_another_tc( top, left, height, width = 0, 1, 2, 3 _span_dimensions_.return_value = top, left, height, width _tbl_.return_value.tr_lst = [tr_] - tr_.tc_at_grid_col.return_value = top_tc_ + tr_.tc_at_grid_offset.return_value = top_tc_ merged_tc = tc.merge(other_tc) _span_dimensions_.assert_called_once_with(tc, other_tc) - top_tr_.tc_at_grid_col.assert_called_once_with(left) + top_tr_.tc_at_grid_offset.assert_called_once_with(left) top_tc_._grow_to.assert_called_once_with(width, height) assert merged_tc is top_tc_ From f4a48b5565a3a09087f541e3ac36a447693927b4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 28 Apr 2024 12:45:09 -0700 Subject: [PATCH 13/56] fix(table): fix _Row.cells can raise IndexError The original implementation of `_Row.cells` did not take into account the fact that rows could include unoccupied grid cells at the beginning and/or end of the row. This "advanced" feature of tables is sometimes used by the Word table layout algorithm when the user does not carefully align the right boundary of cells during resizing, so while quite unusual to be used on purpose, this arises with some frequency in human-authored documents in the wild. The prior implementation of `_Row.cells` assumed that `Table.cells()` was uniform and the cells for a row could be reliably be computed from the table column-count and row and column offsets. That assumption does not always hold and can raise `IndexError` when omitted cells are present. This reimplementation remedies that situation. As a side-effect it should also perform much better when reading large tables. --- src/docx/table.py | 38 +++++++++++++++++++++++++++++++++++--- tests/test_table.py | 39 +++++++++++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/docx/table.py b/src/docx/table.py index e88232840..556e66be8 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast, overload +from typing import TYPE_CHECKING, Iterator, cast, overload from typing_extensions import TypeAlias @@ -102,7 +102,10 @@ def columns(self): return _Columns(self._tbl, self) def row_cells(self, row_idx: int) -> list[_Cell]: - """Sequence of cells in the row at `row_idx` in this table.""" + """DEPRECATED: Use `table.rows[row_idx].cells` instead. + + Sequence of cells in the row at `row_idx` in this table. + """ column_count = self._column_count start = row_idx * column_count end = start + column_count @@ -403,7 +406,36 @@ def cells(self) -> tuple[_Cell, ...]: layout-grid positions using `.grid_cols_before` and `.grid_cols_after`. """ - return tuple(self.table.row_cells(self._index)) + + def iter_tc_cells(tc: CT_Tc) -> Iterator[_Cell]: + """Generate a cell object for each layout-grid cell in `tc`. + + In particular, a `` element with a horizontal "span" with generate the same cell + multiple times, one for each grid-cell being spanned. This approximates a row in a + "uniform" table, where each row has a cell for each column in the table. + """ + # -- a cell comprising the second or later row of a vertical span is indicated by + # -- tc.vMerge="continue" (the default value of the `w:vMerge` attribute, when it is + # -- present in the XML). The `w:tc` element at the same grid-offset in the prior row + # -- is guaranteed to be the same width (gridSpan). So we can delegate content + # -- discovery to that prior-row `w:tc` element (recursively) until we arrive at the + # -- "root" cell -- for the vertical span. + if tc.vMerge == "continue": + yield from iter_tc_cells(tc._tc_above) # pyright: ignore[reportPrivateUsage] + return + + # -- Otherwise, vMerge is either "restart" or None, meaning this `tc` holds the actual + # -- content of the cell (whether it is vertically merged or not). + cell = _Cell(tc, self.table) + for _ in range(tc.grid_span): + yield cell + + def _iter_row_cells() -> Iterator[_Cell]: + """Generate `_Cell` instance for each populated layout-grid cell in this row.""" + for tc in self._tr.tc_lst: + yield from iter_tc_cells(tc) + + return tuple(_iter_row_cells()) @property def grid_cols_after(self) -> int: diff --git a/tests/test_table.py b/tests/test_table.py index 993fb3f23..479d670c6 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -782,18 +782,41 @@ def it_can_change_its_height_rule( row.height_rule = new_value assert row._tr.xml == xml(expected_cxml) + @pytest.mark.parametrize( + ("tbl_cxml", "row_idx", "expected_len"), + [ + # -- cell corresponds to single layout-grid cell -- + ("w:tbl/w:tr/w:tc/w:p", 0, 1), + # -- cell has a horizontal span -- + ("w:tbl/w:tr/w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p)", 0, 2), + # -- cell is in latter row of vertical span -- + ( + "w:tbl/(w:tr/w:tc/(w:tcPr/w:vMerge{w:val=restart},w:p)," + "w:tr/w:tc/(w:tcPr/w:vMerge,w:p))", + 1, + 1, + ), + # -- cell both has horizontal span and is latter row of vertical span -- + ( + "w:tbl/(w:tr/w:tc/(w:tcPr/(w:gridSpan{w:val=2},w:vMerge{w:val=restart}),w:p)," + "w:tr/w:tc/(w:tcPr/(w:gridSpan{w:val=2},w:vMerge),w:p))", + 1, + 2, + ), + ], + ) def it_provides_access_to_its_cells( - self, _index_prop_: Mock, table_prop_: Mock, table_: Mock, parent_: Mock + self, tbl_cxml: str, row_idx: int, expected_len: int, parent_: Mock ): - row = _Row(cast(CT_Row, element("w:tr")), parent_) - _index_prop_.return_value = row_idx = 6 - expected_cells = (1, 2, 3) - table_.row_cells.return_value = list(expected_cells) + tbl = cast(CT_Tbl, element(tbl_cxml)) + tr = tbl.tr_lst[row_idx] + table = Table(tbl, parent_) + row = _Row(tr, table) cells = row.cells - table_.row_cells.assert_called_once_with(row_idx) - assert cells == expected_cells + assert len(cells) == expected_len + assert all(type(c) is _Cell for c in cells) def it_provides_access_to_the_table_it_belongs_to(self, parent_: Mock, table_: Mock): parent_.table = table_ @@ -821,7 +844,7 @@ def table_(self, request: FixtureRequest): @pytest.fixture def table_prop_(self, request: FixtureRequest, table_: Mock): - return property_mock(request, _Row, "table", return_value=table_) + return property_mock(request, _Row, "table") class Describe_Rows: From 89b399b8c4147c0214d8348469209547d50962c8 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 28 Apr 2024 18:03:06 -0700 Subject: [PATCH 14/56] feat(typing): add py.typed, improve public types --- src/docx/api.py | 10 +++++--- src/docx/oxml/text/parfmt.py | 45 +++++++++++++++++++++++++----------- src/docx/parts/document.py | 3 ++- src/docx/py.typed | 0 4 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 src/docx/py.typed diff --git a/src/docx/api.py b/src/docx/api.py index a17c1dad4..aea876458 100644 --- a/src/docx/api.py +++ b/src/docx/api.py @@ -6,13 +6,17 @@ from __future__ import annotations import os -from typing import IO +from typing import IO, TYPE_CHECKING, cast from docx.opc.constants import CONTENT_TYPE as CT from docx.package import Package +if TYPE_CHECKING: + from docx.document import Document as DocumentObject + from docx.parts.document import DocumentPart -def Document(docx: str | IO[bytes] | None = None): + +def Document(docx: str | IO[bytes] | None = None) -> DocumentObject: """Return a |Document| object loaded from `docx`, where `docx` can be either a path to a ``.docx`` file (a string) or a file-like object. @@ -20,7 +24,7 @@ def Document(docx: str | IO[bytes] | None = None): loaded. """ docx = _default_docx_path() if docx is None else docx - document_part = Package.open(docx).main_document_part + document_part = cast("DocumentPart", Package.open(docx).main_document_part) if document_part.content_type != CT.WML_DOCUMENT_MAIN: tmpl = "file '%s' is not a Word file, content type is '%s'" raise ValueError(tmpl % (docx, document_part.content_type)) diff --git a/src/docx/oxml/text/parfmt.py b/src/docx/oxml/text/parfmt.py index 94e802938..de5609636 100644 --- a/src/docx/oxml/text/parfmt.py +++ b/src/docx/oxml/text/parfmt.py @@ -28,10 +28,18 @@ class CT_Ind(BaseOxmlElement): """```` element, specifying paragraph indentation.""" - left = OptionalAttribute("w:left", ST_SignedTwipsMeasure) - right = OptionalAttribute("w:right", ST_SignedTwipsMeasure) - firstLine = OptionalAttribute("w:firstLine", ST_TwipsMeasure) - hanging = OptionalAttribute("w:hanging", ST_TwipsMeasure) + left: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:left", ST_SignedTwipsMeasure + ) + right: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:right", ST_SignedTwipsMeasure + ) + firstLine: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:firstLine", ST_TwipsMeasure + ) + hanging: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:hanging", ST_TwipsMeasure + ) class CT_Jc(BaseOxmlElement): @@ -45,6 +53,7 @@ class CT_Jc(BaseOxmlElement): class CT_PPr(BaseOxmlElement): """```` element, containing the properties for a paragraph.""" + get_or_add_ind: Callable[[], CT_Ind] get_or_add_pStyle: Callable[[], CT_String] _insert_sectPr: Callable[[CT_SectPr], None] _remove_pStyle: Callable[[], None] @@ -98,13 +107,15 @@ class CT_PPr(BaseOxmlElement): numPr = ZeroOrOne("w:numPr", successors=_tag_seq[7:]) tabs = ZeroOrOne("w:tabs", successors=_tag_seq[11:]) spacing = ZeroOrOne("w:spacing", successors=_tag_seq[22:]) - ind = ZeroOrOne("w:ind", successors=_tag_seq[23:]) + ind: CT_Ind | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:ind", successors=_tag_seq[23:] + ) jc = ZeroOrOne("w:jc", successors=_tag_seq[27:]) sectPr = ZeroOrOne("w:sectPr", successors=_tag_seq[35:]) del _tag_seq @property - def first_line_indent(self): + def first_line_indent(self) -> Length | None: """A |Length| value calculated from the values of `w:ind/@w:firstLine` and `w:ind/@w:hanging`. @@ -122,7 +133,7 @@ def first_line_indent(self): return firstLine @first_line_indent.setter - def first_line_indent(self, value): + def first_line_indent(self, value: Length | None): if self.ind is None and value is None: return ind = self.get_or_add_ind() @@ -135,7 +146,7 @@ def first_line_indent(self, value): ind.firstLine = value @property - def ind_left(self): + def ind_left(self) -> Length | None: """The value of `w:ind/@w:left` or |None| if not present.""" ind = self.ind if ind is None: @@ -143,14 +154,14 @@ def ind_left(self): return ind.left @ind_left.setter - def ind_left(self, value): + def ind_left(self, value: Length | None): if value is None and self.ind is None: return ind = self.get_or_add_ind() ind.left = value @property - def ind_right(self): + def ind_right(self) -> Length | None: """The value of `w:ind/@w:right` or |None| if not present.""" ind = self.ind if ind is None: @@ -158,7 +169,7 @@ def ind_right(self): return ind.right @ind_right.setter - def ind_right(self, value): + def ind_right(self, value: Length | None): if value is None and self.ind is None: return ind = self.get_or_add_ind() @@ -340,9 +351,15 @@ class CT_TabStop(BaseOxmlElement): only needs a __str__ method. """ - val = RequiredAttribute("w:val", WD_TAB_ALIGNMENT) - leader = OptionalAttribute("w:leader", WD_TAB_LEADER, default=WD_TAB_LEADER.SPACES) - pos = RequiredAttribute("w:pos", ST_SignedTwipsMeasure) + val: WD_TAB_ALIGNMENT = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_TAB_ALIGNMENT + ) + leader: WD_TAB_LEADER | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:leader", WD_TAB_LEADER, default=WD_TAB_LEADER.SPACES + ) + pos: Length = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:pos", ST_SignedTwipsMeasure + ) def __str__(self) -> str: """Text equivalent of a `w:tab` element appearing in a run. diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index a157764b9..81e621c1a 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -16,6 +16,7 @@ from docx.shared import lazyproperty if TYPE_CHECKING: + from docx.opc.coreprops import CoreProperties from docx.styles.style import BaseStyle @@ -41,7 +42,7 @@ def add_header_part(self): return header_part, rId @property - def core_properties(self): + def core_properties(self) -> CoreProperties: """A |CoreProperties| object providing read/write access to the core properties of this document.""" return self.package.core_properties diff --git a/src/docx/py.typed b/src/docx/py.typed new file mode 100644 index 000000000..e69de29bb From 94802e4af62cf68469bcc0176789f158b39e3404 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 29 Apr 2024 16:57:06 -0700 Subject: [PATCH 15/56] fix: fix some shortlist issues --- pyproject.toml | 2 +- src/docx/enum/base.py | 11 +++++------ src/docx/image/image.py | 32 ++++++++++++-------------------- src/docx/oxml/ns.py | 6 +++--- src/docx/oxml/simpletypes.py | 18 ++++++------------ src/docx/text/paragraph.py | 12 +++--------- 6 files changed, 30 insertions(+), 51 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8c0518a96..8d483f00b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ "lxml>=3.1.0", - "typing_extensions", + "typing_extensions>=4.9.0", ] description = "Create, read, and update Microsoft Word .docx files." dynamic = ["version"] diff --git a/src/docx/enum/base.py b/src/docx/enum/base.py index e37e74299..bc96ab6a2 100644 --- a/src/docx/enum/base.py +++ b/src/docx/enum/base.py @@ -4,9 +4,10 @@ import enum import textwrap -from typing import Any, Dict, Type, TypeVar +from typing import TYPE_CHECKING, Any, Dict, Type, TypeVar -from typing_extensions import Self +if TYPE_CHECKING: + from typing_extensions import Self _T = TypeVar("_T", bound="BaseXmlEnum") @@ -69,7 +70,7 @@ def to_xml(cls: Type[_T], value: int | _T | None) -> str | None: """XML value of this enum member, generally an XML attribute value.""" # -- presence of multi-arg `__new__()` method fools type-checker, but getting a # -- member by its value using EnumCls(val) works as usual. - return cls(value).xml_value # pyright: ignore[reportGeneralTypeIssues] + return cls(value).xml_value class DocsPageFormatter: @@ -129,9 +130,7 @@ def _member_defs(self): """A single string containing the aggregated member definitions section of the documentation page.""" members = self._clsdict["__members__"] - member_defs = [ - self._member_def(member) for member in members if member.name is not None - ] + member_defs = [self._member_def(member) for member in members if member.name is not None] return "\n".join(member_defs) @property diff --git a/src/docx/image/image.py b/src/docx/image/image.py index 945432872..710546fdb 100644 --- a/src/docx/image/image.py +++ b/src/docx/image/image.py @@ -11,8 +11,6 @@ import os from typing import IO, Tuple -from typing_extensions import Self - from docx.image.exceptions import UnrecognizedImageError from docx.shared import Emu, Inches, Length, lazyproperty @@ -28,14 +26,14 @@ def __init__(self, blob: bytes, filename: str, image_header: BaseImageHeader): self._image_header = image_header @classmethod - def from_blob(cls, blob: bytes) -> Self: + def from_blob(cls, blob: bytes) -> Image: """Return a new |Image| subclass instance parsed from the image binary contained in `blob`.""" stream = io.BytesIO(blob) return cls._from_stream(stream, blob) @classmethod - def from_file(cls, image_descriptor): + def from_file(cls, image_descriptor: str | IO[bytes]): """Return a new |Image| subclass instance loaded from the image file identified by `image_descriptor`, a path or file-like object.""" if isinstance(image_descriptor, str): @@ -57,7 +55,7 @@ def blob(self): return self._blob @property - def content_type(self): + def content_type(self) -> str: """MIME content type for this image, e.g. ``'image/jpeg'`` for a JPEG image.""" return self._image_header.content_type @@ -167,12 +165,11 @@ def _from_stream( return cls(blob, filename, image_header) -def _ImageHeaderFactory(stream): - """Return a |BaseImageHeader| subclass instance that knows how to parse the headers - of the image in `stream`.""" +def _ImageHeaderFactory(stream: IO[bytes]): + """A |BaseImageHeader| subclass instance that can parse headers of image in `stream`.""" from docx.image import SIGNATURES - def read_32(stream): + def read_32(stream: IO[bytes]): stream.seek(0) return stream.read(32) @@ -188,32 +185,27 @@ def read_32(stream): class BaseImageHeader: """Base class for image header subclasses like |Jpeg| and |Tiff|.""" - def __init__(self, px_width, px_height, horz_dpi, vert_dpi): + def __init__(self, px_width: int, px_height: int, horz_dpi: int, vert_dpi: int): self._px_width = px_width self._px_height = px_height self._horz_dpi = horz_dpi self._vert_dpi = vert_dpi @property - def content_type(self): + def content_type(self) -> str: """Abstract property definition, must be implemented by all subclasses.""" - msg = ( - "content_type property must be implemented by all subclasses of " - "BaseImageHeader" - ) + msg = "content_type property must be implemented by all subclasses of " "BaseImageHeader" raise NotImplementedError(msg) @property - def default_ext(self): + def default_ext(self) -> str: """Default filename extension for images of this type. An abstract property definition, must be implemented by all subclasses. """ - msg = ( - "default_ext property must be implemented by all subclasses of " - "BaseImageHeader" + raise NotImplementedError( + "default_ext property must be implemented by all subclasses of " "BaseImageHeader" ) - raise NotImplementedError(msg) @property def px_width(self): diff --git a/src/docx/oxml/ns.py b/src/docx/oxml/ns.py index 3238864e9..5bed1e6a0 100644 --- a/src/docx/oxml/ns.py +++ b/src/docx/oxml/ns.py @@ -1,8 +1,8 @@ """Namespace-related objects.""" -from typing import Any, Dict +from __future__ import annotations -from typing_extensions import Self +from typing import Any, Dict nsmap = { "a": "http://schemas.openxmlformats.org/drawingml/2006/main", @@ -41,7 +41,7 @@ def clark_name(self) -> str: return "{%s}%s" % (self._ns_uri, self._local_part) @classmethod - def from_clark_name(cls, clark_name: str) -> Self: + def from_clark_name(cls, clark_name: str) -> NamespacePrefixedTag: nsuri, local_name = clark_name[1:].split("}") nstag = "%s:%s" % (pfxmap[nsuri], local_name) return cls(nstag) diff --git a/src/docx/oxml/simpletypes.py b/src/docx/oxml/simpletypes.py index debb5dc3c..dd10ab910 100644 --- a/src/docx/oxml/simpletypes.py +++ b/src/docx/oxml/simpletypes.py @@ -36,12 +36,10 @@ def convert_from_xml(cls, str_value: str) -> Any: return int(str_value) @classmethod - def convert_to_xml(cls, value: Any) -> str: - ... + def convert_to_xml(cls, value: Any) -> str: ... @classmethod - def validate(cls, value: Any) -> None: - ... + def validate(cls, value: Any) -> None: ... @classmethod def validate_int(cls, value: object): @@ -49,9 +47,7 @@ def validate_int(cls, value: object): raise TypeError("value must be , got %s" % type(value)) @classmethod - def validate_int_in_range( - cls, value: int, min_inclusive: int, max_inclusive: int - ) -> None: + def validate_int_in_range(cls, value: int, min_inclusive: int, max_inclusive: int) -> None: cls.validate_int(value) if value < min_inclusive or value > max_inclusive: raise ValueError( @@ -129,8 +125,7 @@ def convert_to_xml(cls, value: bool) -> str: def validate(cls, value: Any) -> None: if value not in (True, False): raise TypeError( - "only True or False (and possibly None) may be assigned, got" - " '%s'" % value + "only True or False (and possibly None) may be assigned, got" " '%s'" % value ) @@ -248,8 +243,7 @@ def validate(cls, value: Any) -> None: # must be an RGBColor object --- if not isinstance(value, RGBColor): raise ValueError( - "rgb color value must be RGBColor object, got %s %s" - % (type(value), value) + "rgb color value must be RGBColor object, got %s %s" % (type(value), value) ) @@ -316,7 +310,7 @@ class ST_SignedTwipsMeasure(XsdInt): def convert_from_xml(cls, str_value: str) -> Length: if "i" in str_value or "m" in str_value or "p" in str_value: return ST_UniversalMeasure.convert_from_xml(str_value) - return Twips(int(str_value)) + return Twips(int(round(float(str_value)))) @classmethod def convert_to_xml(cls, value: int | Length) -> str: diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 0a5d67674..89c032586 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -4,8 +4,6 @@ from typing import TYPE_CHECKING, Iterator, List, cast -from typing_extensions import Self - from docx import types as t from docx.enum.style import WD_STYLE_TYPE from docx.oxml.text.run import CT_R @@ -29,9 +27,7 @@ def __init__(self, p: CT_P, parent: t.ProvidesStoryPart): super(Paragraph, self).__init__(parent) self._p = self._element = p - def add_run( - self, text: str | None = None, style: str | CharacterStyle | None = None - ) -> Run: + def add_run(self, text: str | None = None, style: str | CharacterStyle | None = None) -> Run: """Append run containing `text` and having character-style `style`. `text` can contain tab (``\\t``) characters, which are converted to the @@ -82,7 +78,7 @@ def hyperlinks(self) -> List[Hyperlink]: def insert_paragraph_before( self, text: str | None = None, style: str | ParagraphStyle | None = None - ) -> Self: + ) -> Paragraph: """Return a newly created paragraph, inserted directly before this paragraph. If `text` is supplied, the new paragraph contains that text in a single run. If @@ -123,9 +119,7 @@ def rendered_page_breaks(self) -> List[RenderedPageBreak]: Most often an empty list, sometimes contains one page-break, but can contain more than one is rare or contrived cases. """ - return [ - RenderedPageBreak(lrpb, self) for lrpb in self._p.lastRenderedPageBreaks - ] + return [RenderedPageBreak(lrpb, self) for lrpb in self._p.lastRenderedPageBreaks] @property def runs(self) -> List[Run]: From 5a80006553f982ef47ebc9b4eb3652452b3c07e7 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 29 Apr 2024 17:53:03 -0700 Subject: [PATCH 16/56] fix(packaging): small packaging and doc tweaks `lxml` won't install on Apple Silicon after `4.9.2`. Dropping testing for Python 3.7. --- pyproject.toml | 2 +- requirements-docs.txt | 1 + tox.ini | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8d483f00b..ad89abd19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Topic :: Software Development :: Libraries", ] dependencies = [ - "lxml>=3.1.0", + "lxml>=3.1.0,<=4.9.2", "typing_extensions>=4.9.0", ] description = "Create, read, and update Microsoft Word .docx files." diff --git a/requirements-docs.txt b/requirements-docs.txt index 11f9d2cd2..90edd8e31 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,4 +1,5 @@ Sphinx==1.8.6 Jinja2==2.11.3 MarkupSafe==0.23 +alabaster<0.7.14 -e . diff --git a/tox.ini b/tox.ini index 1c4e3aea7..f8595ba45 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37, py38, py39, py310, py311 +envlist = py38, py39, py310, py311 [testenv] deps = -rrequirements-test.txt From 0a09474b4d1def9fef65267ac27c9f5a48346d25 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 29 Apr 2024 18:42:34 -0700 Subject: [PATCH 17/56] rfctr: resolve some import cycles --- src/docx/blkcntnr.py | 12 +++--------- src/docx/document.py | 6 ++---- src/docx/drawing/__init__.py | 6 +++++- src/docx/shared.py | 6 ++---- src/docx/table.py | 4 ++-- src/docx/text/hyperlink.py | 10 ++++++---- src/docx/text/pagebreak.py | 2 +- src/docx/text/paragraph.py | 2 +- src/docx/text/run.py | 10 +++------- 9 files changed, 25 insertions(+), 33 deletions(-) diff --git a/src/docx/blkcntnr.py b/src/docx/blkcntnr.py index 1327e6d08..a9969f6f6 100644 --- a/src/docx/blkcntnr.py +++ b/src/docx/blkcntnr.py @@ -18,7 +18,7 @@ from docx.text.paragraph import Paragraph if TYPE_CHECKING: - from docx import types as t + import docx.types as t from docx.oxml.document import CT_Body from docx.oxml.section import CT_HdrFtr from docx.oxml.table import CT_Tc @@ -41,9 +41,7 @@ def __init__(self, element: BlockItemElement, parent: t.ProvidesStoryPart): super(BlockItemContainer, self).__init__(parent) self._element = element - def add_paragraph( - self, text: str = "", style: str | ParagraphStyle | None = None - ) -> Paragraph: + def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph: """Return paragraph newly added to the end of the content in this container. The paragraph has `text` in a single run if present, and is given paragraph @@ -77,11 +75,7 @@ def iter_inner_content(self) -> Iterator[Paragraph | Table]: from docx.table import Table for element in self._element.inner_content_elements: - yield ( - Paragraph(element, self) - if isinstance(element, CT_P) - else Table(element, self) - ) + yield (Paragraph(element, self) if isinstance(element, CT_P) else Table(element, self)) @property def paragraphs(self): diff --git a/src/docx/document.py b/src/docx/document.py index 4deb8aa8e..8944a0e50 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -14,7 +14,7 @@ from docx.shared import ElementProxy, Emu if TYPE_CHECKING: - from docx import types as t + import docx.types as t from docx.oxml.document import CT_Body, CT_Document from docx.parts.document import DocumentPart from docx.settings import Settings @@ -56,9 +56,7 @@ def add_page_break(self): paragraph.add_run().add_break(WD_BREAK.PAGE) return paragraph - def add_paragraph( - self, text: str = "", style: str | ParagraphStyle | None = None - ) -> Paragraph: + def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph: """Return paragraph newly added to the end of the document. The paragraph is populated with `text` and having paragraph style `style`. diff --git a/src/docx/drawing/__init__.py b/src/docx/drawing/__init__.py index 03c9c5ab8..f40205747 100644 --- a/src/docx/drawing/__init__.py +++ b/src/docx/drawing/__init__.py @@ -2,10 +2,14 @@ from __future__ import annotations -from docx import types as t +from typing import TYPE_CHECKING + from docx.oxml.drawing import CT_Drawing from docx.shared import Parented +if TYPE_CHECKING: + import docx.types as t + class Drawing(Parented): """Container for a DrawingML object.""" diff --git a/src/docx/shared.py b/src/docx/shared.py index 7b696202f..491d42741 100644 --- a/src/docx/shared.py +++ b/src/docx/shared.py @@ -16,7 +16,7 @@ ) if TYPE_CHECKING: - from docx import types as t + import docx.types as t from docx.opc.part import XmlPart from docx.oxml.xmlchemy import BaseOxmlElement from docx.parts.story import StoryPart @@ -284,9 +284,7 @@ class ElementProxy: common type of class in python-docx other than custom element (oxml) classes. """ - def __init__( - self, element: BaseOxmlElement, parent: t.ProvidesXmlPart | None = None - ): + def __init__(self, element: BaseOxmlElement, parent: t.ProvidesXmlPart | None = None): self._element = element self._parent = parent diff --git a/src/docx/table.py b/src/docx/table.py index 556e66be8..545c46884 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -6,7 +6,6 @@ from typing_extensions import TypeAlias -from docx import types as t from docx.blkcntnr import BlockItemContainer from docx.enum.style import WD_STYLE_TYPE from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT @@ -15,6 +14,7 @@ from docx.shared import Inches, Parented, StoryChild, lazyproperty if TYPE_CHECKING: + import docx.types as t from docx.enum.table import WD_ROW_HEIGHT_RULE, WD_TABLE_ALIGNMENT, WD_TABLE_DIRECTION from docx.oxml.table import CT_Row, CT_Tbl, CT_TblPr, CT_Tc from docx.shared import Length @@ -193,7 +193,7 @@ class _Cell(BlockItemContainer): """Table cell.""" def __init__(self, tc: CT_Tc, parent: TableParent): - super(_Cell, self).__init__(tc, cast(t.ProvidesStoryPart, parent)) + super(_Cell, self).__init__(tc, cast("t.ProvidesStoryPart", parent)) self._parent = parent self._tc = self._element = tc diff --git a/src/docx/text/hyperlink.py b/src/docx/text/hyperlink.py index 705a97ee4..a23df1c74 100644 --- a/src/docx/text/hyperlink.py +++ b/src/docx/text/hyperlink.py @@ -7,13 +7,15 @@ from __future__ import annotations -from typing import List +from typing import TYPE_CHECKING -from docx import types as t -from docx.oxml.text.hyperlink import CT_Hyperlink from docx.shared import Parented from docx.text.run import Run +if TYPE_CHECKING: + import docx.types as t + from docx.oxml.text.hyperlink import CT_Hyperlink + class Hyperlink(Parented): """Proxy object wrapping a `` element. @@ -78,7 +80,7 @@ def fragment(self) -> str: return self._hyperlink.anchor or "" @property - def runs(self) -> List[Run]: + def runs(self) -> list[Run]: """List of |Run| instances in this hyperlink. Together these define the visible text of the hyperlink. The text of a hyperlink diff --git a/src/docx/text/pagebreak.py b/src/docx/text/pagebreak.py index a5e68b5aa..0977ccea9 100644 --- a/src/docx/text/pagebreak.py +++ b/src/docx/text/pagebreak.py @@ -4,11 +4,11 @@ from typing import TYPE_CHECKING -from docx import types as t from docx.oxml.text.pagebreak import CT_LastRenderedPageBreak from docx.shared import Parented if TYPE_CHECKING: + import docx.types as t from docx.text.paragraph import Paragraph diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 89c032586..234ea66cb 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Iterator, List, cast -from docx import types as t from docx.enum.style import WD_STYLE_TYPE from docx.oxml.text.run import CT_R from docx.shared import StoryChild @@ -15,6 +14,7 @@ from docx.text.run import Run if TYPE_CHECKING: + import docx.types as t from docx.enum.text import WD_PARAGRAPH_ALIGNMENT from docx.oxml.text.paragraph import CT_P from docx.styles.style import CharacterStyle diff --git a/src/docx/text/run.py b/src/docx/text/run.py index 44c41c0fe..daa604e87 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -4,7 +4,6 @@ from typing import IO, TYPE_CHECKING, Iterator, cast -from docx import types as t from docx.drawing import Drawing from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_BREAK @@ -17,6 +16,7 @@ from docx.text.pagebreak import RenderedPageBreak if TYPE_CHECKING: + import docx.types as t from docx.enum.text import WD_UNDERLINE from docx.oxml.text.run import CT_R, CT_Text from docx.shared import Length @@ -170,9 +170,7 @@ def iter_inner_content(self) -> Iterator[str | Drawing | RenderedPageBreak]: yield item elif isinstance(item, CT_LastRenderedPageBreak): yield RenderedPageBreak(item, self) - elif isinstance( # pyright: ignore[reportUnnecessaryIsInstance] - item, CT_Drawing - ): + elif isinstance(item, CT_Drawing): # pyright: ignore[reportUnnecessaryIsInstance] yield Drawing(item, self) @property @@ -185,9 +183,7 @@ def style(self) -> CharacterStyle: property to |None| removes any directly-applied character style. """ style_id = self._r.style - return cast( - CharacterStyle, self.part.get_style(style_id, WD_STYLE_TYPE.CHARACTER) - ) + return cast(CharacterStyle, self.part.get_style(style_id, WD_STYLE_TYPE.CHARACTER)) @style.setter def style(self, style_or_name: str | CharacterStyle | None): From e531576191d7709e27b77e9f8aecae7fd68e07a0 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 29 Apr 2024 19:47:22 -0700 Subject: [PATCH 18/56] release: prepare v1.1.1 release --- HISTORY.rst | 8 ++++++++ src/docx/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 8e0b1a588..51262c4b3 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,14 @@ Release History --------------- +1.1.1 (2024-04-29) +++++++++++++++++++ + +- Fix #531, #1146 Index error on table with misaligned borders +- Fix #1335 Tolerate invalid float value in bottom-margin +- Fix #1337 Do not require typing-extensions at runtime + + 1.1.0 (2023-11-03) ++++++++++++++++++ diff --git a/src/docx/__init__.py b/src/docx/__init__.py index b214045d1..7a4d0bbe8 100644 --- a/src/docx/__init__.py +++ b/src/docx/__init__.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from docx.opc.part import Part -__version__ = "1.1.0" +__version__ = "1.1.1" __all__ = ["Document"] From 3f56b7d4f045c92a984491ac45cbfed50f15cdc2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 1 May 2024 12:19:31 -0700 Subject: [PATCH 19/56] rfctr(dev): use more performant `fd` for clean --- Makefile | 3 ++- requirements-test.txt | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0478b2bce..da0d7a4ac 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,8 @@ build: $(BUILD) clean: - find . -type f -name \*.pyc -exec rm {} \; + # find . -type f -name \*.pyc -exec rm {} \; + fd -e pyc -I -x rm rm -rf dist *.egg-info .coverage .DS_Store cleandocs: diff --git a/requirements-test.txt b/requirements-test.txt index 9ee78b43f..b542c1af7 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,5 +2,6 @@ behave>=1.2.3 pyparsing>=2.0.1 pytest>=2.5 +pytest-coverage pytest-xdist ruff From e4934749b8c94bec743467f7c0e26384eacbd9a4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 30 Apr 2024 23:13:57 -0700 Subject: [PATCH 20/56] fix: XmlPart._rel_ref_count `.rel_ref_count()` as implemented was only applicable to `XmlPart` where references to a related part could be present in the XML. Longer term it probably makes sense to override `Part.drop_rel()` in `XmlPart` and not have a `_rel_ref_count()` method in `part` at all, but this works and is less potentially disruptive. --- src/docx/opc/part.py | 19 +++++++++++++------ tests/opc/test_part.py | 39 +++++++++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/docx/opc/part.py b/src/docx/opc/part.py index 142f49dd1..1353bb850 100644 --- a/src/docx/opc/part.py +++ b/src/docx/opc/part.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Dict, Type +from typing import TYPE_CHECKING, Callable, Dict, Type, cast from docx.opc.oxml import serialize_part_xml from docx.opc.packuri import PackURI @@ -149,11 +149,12 @@ def target_ref(self, rId: str) -> str: rel = self.rels[rId] return rel.target_ref - def _rel_ref_count(self, rId): - """Return the count of references in this part's XML to the relationship - identified by `rId`.""" - rIds = self._element.xpath("//@r:id") - return len([_rId for _rId in rIds if _rId == rId]) + def _rel_ref_count(self, rId: str) -> int: + """Return the count of references in this part to the relationship identified by `rId`. + + Only an XML part can contain references, so this is 0 for `Part`. + """ + return 0 class PartFactory: @@ -231,3 +232,9 @@ def part(self): That chain of delegation ends here for child objects. """ return self + + def _rel_ref_count(self, rId: str) -> int: + """Return the count of references in this part's XML to the relationship + identified by `rId`.""" + rIds = cast("list[str]", self._element.xpath("//@r:id")) + return len([_rId for _rId in rIds if _rId == rId]) diff --git a/tests/opc/test_part.py b/tests/opc/test_part.py index 03eacd361..b156a63f8 100644 --- a/tests/opc/test_part.py +++ b/tests/opc/test_part.py @@ -169,24 +169,13 @@ def it_can_establish_an_external_relationship(self, rels_prop_: Mock, rels_: Moc rels_.get_or_add_ext_rel.assert_called_once_with("http://rel/type", "https://hyper/link") assert rId == "rId27" - @pytest.mark.parametrize( - ("part_cxml", "rel_should_be_dropped"), - [ - ("w:p", True), - ("w:p/r:a{r:id=rId42}", True), - ("w:p/r:a{r:id=rId42}/r:b{r:id=rId42}", False), - ], - ) - def it_can_drop_a_relationship( - self, part_cxml: str, rel_should_be_dropped: bool, rels_prop_: Mock - ): + def it_can_drop_a_relationship(self, rels_prop_: Mock): rels_prop_.return_value = {"rId42": None} - part = Part("partname", "content_type") - part._element = element(part_cxml) # pyright: ignore[reportAttributeAccessIssue] + part = Part(PackURI("/partname"), "content_type") part.drop_rel("rId42") - assert ("rId42" not in part.rels) is rel_should_be_dropped + assert "rId42" not in part.rels def it_can_find_a_related_part_by_reltype( self, rels_prop_: Mock, rels_: Mock, other_part_: Mock @@ -411,6 +400,24 @@ def it_knows_its_the_part_for_its_child_objects(self, part_fixture): xml_part = part_fixture assert xml_part.part is xml_part + @pytest.mark.parametrize( + ("part_cxml", "rel_should_be_dropped"), + [ + ("w:p", True), + ("w:p/r:a{r:id=rId42}", True), + ("w:p/r:a{r:id=rId42}/r:b{r:id=rId42}", False), + ], + ) + def it_only_drops_a_relationship_with_zero_reference_count( + self, part_cxml: str, rel_should_be_dropped: bool, rels_prop_: Mock, package_: Mock + ): + rels_prop_.return_value = {"rId42": None} + part = XmlPart(PackURI("/partname"), "content_type", element(part_cxml), package_) + + part.drop_rel("rId42") + + assert ("rId42" not in part.rels) is rel_should_be_dropped + # fixtures ------------------------------------------------------- @pytest.fixture @@ -452,6 +459,10 @@ def parse_xml_(self, request, element_): def partname_(self, request): return instance_mock(request, PackURI) + @pytest.fixture + def rels_prop_(self, request: FixtureRequest): + return property_mock(request, XmlPart, "rels") + @pytest.fixture def serialize_part_xml_(self, request): return function_mock(request, "docx.opc.part.serialize_part_xml") From f246fde2534e0e0de3c942164db7251f0693d962 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 30 Apr 2024 11:07:39 -0700 Subject: [PATCH 21/56] rfctr: improve typing --- features/steps/coreprops.py | 17 +- src/docx/enum/__init__.py | 11 -- src/docx/image/image.py | 2 +- src/docx/opc/coreprops.py | 33 ++-- src/docx/opc/oxml.py | 13 +- src/docx/opc/package.py | 18 ++- src/docx/opc/packuri.py | 18 +-- src/docx/opc/part.py | 29 ++-- src/docx/opc/parts/coreprops.py | 21 ++- src/docx/opc/rel.py | 35 ++-- src/docx/oxml/coreprops.py | 71 ++++---- src/docx/oxml/document.py | 2 +- src/docx/oxml/parser.py | 2 +- src/docx/oxml/settings.py | 25 ++- src/docx/oxml/shape.py | 56 ++++--- src/docx/oxml/shared.py | 6 +- src/docx/oxml/styles.py | 8 +- src/docx/oxml/table.py | 2 +- src/docx/oxml/text/font.py | 32 ++-- src/docx/package.py | 21 ++- src/docx/parts/document.py | 11 +- src/docx/parts/hdrftr.py | 18 ++- src/docx/parts/image.py | 15 +- src/docx/parts/settings.py | 31 ++-- src/docx/parts/story.py | 8 +- src/docx/section.py | 12 +- src/docx/settings.py | 21 ++- src/docx/shape.py | 26 ++- src/docx/text/run.py | 4 +- tests/opc/parts/test_coreprops.py | 35 ++-- tests/opc/test_coreprops.py | 258 +++++++++++++++--------------- tests/opc/test_part.py | 101 ++++-------- 32 files changed, 501 insertions(+), 461 deletions(-) diff --git a/features/steps/coreprops.py b/features/steps/coreprops.py index 0f6b6a854..0d4e55eb7 100644 --- a/features/steps/coreprops.py +++ b/features/steps/coreprops.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from behave import given, then, when +from behave.runner import Context from docx import Document from docx.opc.coreprops import CoreProperties @@ -13,12 +14,12 @@ @given("a document having known core properties") -def given_a_document_having_known_core_properties(context): +def given_a_document_having_known_core_properties(context: Context): context.document = Document(test_docx("doc-coreprops")) @given("a document having no core properties part") -def given_a_document_having_no_core_properties_part(context): +def given_a_document_having_no_core_properties_part(context: Context): context.document = Document(test_docx("doc-no-coreprops")) @@ -26,12 +27,12 @@ def given_a_document_having_no_core_properties_part(context): @when("I access the core properties object") -def when_I_access_the_core_properties_object(context): +def when_I_access_the_core_properties_object(context: Context): context.document.core_properties @when("I assign new values to the properties") -def when_I_assign_new_values_to_the_properties(context): +def when_I_assign_new_values_to_the_properties(context: Context): context.propvals = ( ("author", "Creator"), ("category", "Category"), @@ -58,7 +59,7 @@ def when_I_assign_new_values_to_the_properties(context): @then("a core properties part with default values is added") -def then_a_core_properties_part_with_default_values_is_added(context): +def then_a_core_properties_part_with_default_values_is_added(context: Context): core_properties = context.document.core_properties assert core_properties.title == "Word Document" assert core_properties.last_modified_by == "python-docx" @@ -71,14 +72,14 @@ def then_a_core_properties_part_with_default_values_is_added(context): @then("I can access the core properties object") -def then_I_can_access_the_core_properties_object(context): +def then_I_can_access_the_core_properties_object(context: Context): document = context.document core_properties = document.core_properties assert isinstance(core_properties, CoreProperties) @then("the core property values match the known values") -def then_the_core_property_values_match_the_known_values(context): +def then_the_core_property_values_match_the_known_values(context: Context): known_propvals = ( ("author", "Steve Canny"), ("category", "Category"), @@ -106,7 +107,7 @@ def then_the_core_property_values_match_the_known_values(context): @then("the core property values match the new values") -def then_the_core_property_values_match_the_new_values(context): +def then_the_core_property_values_match_the_new_values(context: Context): core_properties = context.document.core_properties for name, expected_value in context.propvals: value = getattr(core_properties, name) diff --git a/src/docx/enum/__init__.py b/src/docx/enum/__init__.py index bfab52d36..e69de29bb 100644 --- a/src/docx/enum/__init__.py +++ b/src/docx/enum/__init__.py @@ -1,11 +0,0 @@ -"""Enumerations used in python-docx.""" - - -class Enumeration: - @classmethod - def from_xml(cls, xml_val): - return cls._xml_to_idx[xml_val] - - @classmethod - def to_xml(cls, enum_val): - return cls._idx_to_xml[enum_val] diff --git a/src/docx/image/image.py b/src/docx/image/image.py index 710546fdb..0022b5b45 100644 --- a/src/docx/image/image.py +++ b/src/docx/image/image.py @@ -114,7 +114,7 @@ def height(self) -> Inches: return Inches(self.px_height / self.vert_dpi) def scaled_dimensions( - self, width: int | None = None, height: int | None = None + self, width: int | Length | None = None, height: int | Length | None = None ) -> Tuple[Length, Length]: """(cx, cy) pair representing scaled dimensions of this image. diff --git a/src/docx/opc/coreprops.py b/src/docx/opc/coreprops.py index 2fd9a75c8..c564550d4 100644 --- a/src/docx/opc/coreprops.py +++ b/src/docx/opc/coreprops.py @@ -3,12 +3,21 @@ These are broadly-standardized attributes like author, last-modified, etc. """ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from docx.oxml.coreprops import CT_CoreProperties + +if TYPE_CHECKING: + from docx.oxml.coreprops import CT_CoreProperties + class CoreProperties: """Corresponds to part named ``/docProps/core.xml``, containing the core document properties for this document package.""" - def __init__(self, element): + def __init__(self, element: CT_CoreProperties): self._element = element @property @@ -16,7 +25,7 @@ def author(self): return self._element.author_text @author.setter - def author(self, value): + def author(self, value: str): self._element.author_text = value @property @@ -24,7 +33,7 @@ def category(self): return self._element.category_text @category.setter - def category(self, value): + def category(self, value: str): self._element.category_text = value @property @@ -32,7 +41,7 @@ def comments(self): return self._element.comments_text @comments.setter - def comments(self, value): + def comments(self, value: str): self._element.comments_text = value @property @@ -40,7 +49,7 @@ def content_status(self): return self._element.contentStatus_text @content_status.setter - def content_status(self, value): + def content_status(self, value: str): self._element.contentStatus_text = value @property @@ -56,7 +65,7 @@ def identifier(self): return self._element.identifier_text @identifier.setter - def identifier(self, value): + def identifier(self, value: str): self._element.identifier_text = value @property @@ -64,7 +73,7 @@ def keywords(self): return self._element.keywords_text @keywords.setter - def keywords(self, value): + def keywords(self, value: str): self._element.keywords_text = value @property @@ -72,7 +81,7 @@ def language(self): return self._element.language_text @language.setter - def language(self, value): + def language(self, value: str): self._element.language_text = value @property @@ -80,7 +89,7 @@ def last_modified_by(self): return self._element.lastModifiedBy_text @last_modified_by.setter - def last_modified_by(self, value): + def last_modified_by(self, value: str): self._element.lastModifiedBy_text = value @property @@ -112,7 +121,7 @@ def subject(self): return self._element.subject_text @subject.setter - def subject(self, value): + def subject(self, value: str): self._element.subject_text = value @property @@ -120,7 +129,7 @@ def title(self): return self._element.title_text @title.setter - def title(self, value): + def title(self, value: str): self._element.title_text = value @property @@ -128,5 +137,5 @@ def version(self): return self._element.version_text @version.setter - def version(self, value): + def version(self, value: str): self._element.version_text = value diff --git a/src/docx/opc/oxml.py b/src/docx/opc/oxml.py index 0249de918..7da72f50d 100644 --- a/src/docx/opc/oxml.py +++ b/src/docx/opc/oxml.py @@ -7,6 +7,10 @@ deleted or only hold the package related custom element classes. """ +from __future__ import annotations + +from typing import cast + from lxml import etree from docx.opc.constants import NAMESPACE as NS @@ -138,7 +142,7 @@ class CT_Relationship(BaseOxmlElement): target part.""" @staticmethod - def new(rId, reltype, target, target_mode=RTM.INTERNAL): + def new(rId: str, reltype: str, target: str, target_mode: str = RTM.INTERNAL): """Return a new ```` element.""" xml = '' % nsmap["pr"] relationship = parse_xml(xml) @@ -178,7 +182,7 @@ def target_mode(self): class CT_Relationships(BaseOxmlElement): """```` element, the root element in a .rels file.""" - def add_rel(self, rId, reltype, target, is_external=False): + def add_rel(self, rId: str, reltype: str, target: str, is_external: bool = False): """Add a child ```` element with attributes set according to parameter values.""" target_mode = RTM.EXTERNAL if is_external else RTM.INTERNAL @@ -186,11 +190,10 @@ def add_rel(self, rId, reltype, target, is_external=False): self.append(relationship) @staticmethod - def new(): + def new() -> CT_Relationships: """Return a new ```` element.""" xml = '' % nsmap["pr"] - relationships = parse_xml(xml) - return relationships + return cast(CT_Relationships, parse_xml(xml)) @property def Relationship_lst(self): diff --git a/src/docx/opc/package.py b/src/docx/opc/package.py index 148cd39b1..3b1eef256 100644 --- a/src/docx/opc/package.py +++ b/src/docx/opc/package.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import IO, TYPE_CHECKING, Iterator +from typing import IO, TYPE_CHECKING, Iterator, cast from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.packuri import PACKAGE_URI, PackURI @@ -14,7 +14,9 @@ from docx.shared import lazyproperty if TYPE_CHECKING: + from docx.opc.coreprops import CoreProperties from docx.opc.part import Part + from docx.opc.rel import _Relationship # pyright: ignore[reportPrivateUsage] class OpcPackage: @@ -37,16 +39,18 @@ def after_unmarshal(self): pass @property - def core_properties(self): + def core_properties(self) -> CoreProperties: """|CoreProperties| object providing read/write access to the Dublin Core properties for this document.""" return self._core_properties_part.core_properties - def iter_rels(self): + def iter_rels(self) -> Iterator[_Relationship]: """Generate exactly one reference to each relationship in the package by performing a depth-first traversal of the rels graph.""" - def walk_rels(source, visited=None): + def walk_rels( + source: OpcPackage | Part, visited: list[Part] | None = None + ) -> Iterator[_Relationship]: visited = [] if visited is None else visited for rel in source.rels.values(): yield rel @@ -103,7 +107,7 @@ def main_document_part(self): """ return self.part_related_by(RT.OFFICE_DOCUMENT) - def next_partname(self, template): + def next_partname(self, template: str) -> PackURI: """Return a |PackURI| instance representing partname matching `template`. The returned part-name has the next available numeric suffix to distinguish it @@ -163,13 +167,13 @@ def save(self, pkg_file: str | IO[bytes]): PackageWriter.write(pkg_file, self.rels, self.parts) @property - def _core_properties_part(self): + def _core_properties_part(self) -> CorePropertiesPart: """|CorePropertiesPart| object related to this package. Creates a default core properties part if one is not present (not common). """ try: - return self.part_related_by(RT.CORE_PROPERTIES) + return cast(CorePropertiesPart, self.part_related_by(RT.CORE_PROPERTIES)) except KeyError: core_properties_part = CorePropertiesPart.default(self) self.relate_to(core_properties_part, RT.CORE_PROPERTIES) diff --git a/src/docx/opc/packuri.py b/src/docx/opc/packuri.py index fe330d89b..fdbb67ed8 100644 --- a/src/docx/opc/packuri.py +++ b/src/docx/opc/packuri.py @@ -3,6 +3,8 @@ Also some useful known pack URI strings such as PACKAGE_URI. """ +from __future__ import annotations + import posixpath import re @@ -16,22 +18,21 @@ class PackURI(str): _filename_re = re.compile("([a-zA-Z]+)([1-9][0-9]*)?") - def __new__(cls, pack_uri_str): + def __new__(cls, pack_uri_str: str): if pack_uri_str[0] != "/": tmpl = "PackURI must begin with slash, got '%s'" raise ValueError(tmpl % pack_uri_str) return str.__new__(cls, pack_uri_str) @staticmethod - def from_rel_ref(baseURI, relative_ref): - """Return a |PackURI| instance containing the absolute pack URI formed by - translating `relative_ref` onto `baseURI`.""" + def from_rel_ref(baseURI: str, relative_ref: str) -> PackURI: + """The absolute PackURI formed by translating `relative_ref` onto `baseURI`.""" joined_uri = posixpath.join(baseURI, relative_ref) abs_uri = posixpath.abspath(joined_uri) return PackURI(abs_uri) @property - def baseURI(self): + def baseURI(self) -> str: """The base URI of this pack URI, the directory portion, roughly speaking. E.g. ``'/ppt/slides'`` for ``'/ppt/slides/slide1.xml'``. For the package pseudo- @@ -40,9 +41,8 @@ def baseURI(self): return posixpath.split(self)[0] @property - def ext(self): - """The extension portion of this pack URI, e.g. ``'xml'`` for - ``'/word/document.xml'``. + def ext(self) -> str: + """The extension portion of this pack URI, e.g. ``'xml'`` for ``'/word/document.xml'``. Note the period is not included. """ @@ -84,7 +84,7 @@ def membername(self): """ return self[1:] - def relative_ref(self, baseURI): + def relative_ref(self, baseURI: str): """Return string containing relative reference to package item from `baseURI`. E.g. PackURI('/ppt/slideLayouts/slideLayout1.xml') would return diff --git a/src/docx/opc/part.py b/src/docx/opc/part.py index 1353bb850..e3887ef41 100644 --- a/src/docx/opc/part.py +++ b/src/docx/opc/part.py @@ -1,8 +1,10 @@ +# pyright: reportImportCycles=false + """Open Packaging Convention (OPC) objects related to package parts.""" from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Dict, Type, cast +from typing import TYPE_CHECKING, Callable, Type, cast from docx.opc.oxml import serialize_part_xml from docx.opc.packuri import PackURI @@ -12,6 +14,7 @@ from docx.shared import lazyproperty if TYPE_CHECKING: + from docx.oxml.xmlchemy import BaseOxmlElement from docx.package import Package @@ -24,7 +27,7 @@ class Part: def __init__( self, - partname: str, + partname: PackURI, content_type: str, blob: bytes | None = None, package: Package | None = None, @@ -56,13 +59,13 @@ def before_marshal(self): pass @property - def blob(self): + def blob(self) -> bytes: """Contents of this package part as a sequence of bytes. May be text or binary. Intended to be overridden by subclasses. Default behavior is to return load blob. """ - return self._blob + return self._blob or b"" @property def content_type(self): @@ -79,7 +82,7 @@ def drop_rel(self, rId: str): del self.rels[rId] @classmethod - def load(cls, partname: str, content_type: str, blob: bytes, package: Package): + def load(cls, partname: PackURI, content_type: str, blob: bytes, package: Package): return cls(partname, content_type, blob, package) def load_rel(self, reltype: str, target: Part | str, rId: str, is_external: bool = False): @@ -105,7 +108,7 @@ def partname(self): return self._partname @partname.setter - def partname(self, partname): + def partname(self, partname: str): if not isinstance(partname, PackURI): tmpl = "partname must be instance of PackURI, got '%s'" raise TypeError(tmpl % type(partname).__name__) @@ -127,9 +130,9 @@ def relate_to(self, target: Part | str, reltype: str, is_external: bool = False) new relationship is created. """ if is_external: - return self.rels.get_or_add_ext_rel(reltype, target) + return self.rels.get_or_add_ext_rel(reltype, cast(str, target)) else: - rel = self.rels.get_or_add(reltype, target) + rel = self.rels.get_or_add(reltype, cast(Part, target)) return rel.rId @property @@ -171,12 +174,12 @@ class PartFactory: """ part_class_selector: Callable[[str, str], Type[Part] | None] | None - part_type_for: Dict[str, Type[Part]] = {} + part_type_for: dict[str, Type[Part]] = {} default_part_type = Part def __new__( cls, - partname: str, + partname: PackURI, content_type: str, reltype: str, blob: bytes, @@ -206,7 +209,9 @@ class XmlPart(Part): reserializing the XML payload and managing relationships to other parts. """ - def __init__(self, partname, content_type, element, package): + def __init__( + self, partname: PackURI, content_type: str, element: BaseOxmlElement, package: Package + ): super(XmlPart, self).__init__(partname, content_type, package=package) self._element = element @@ -220,7 +225,7 @@ def element(self): return self._element @classmethod - def load(cls, partname, content_type, blob, package): + def load(cls, partname: PackURI, content_type: str, blob: bytes, package: Package): element = parse_xml(blob) return cls(partname, content_type, element, package) diff --git a/src/docx/opc/parts/coreprops.py b/src/docx/opc/parts/coreprops.py index 6e26e1d05..0d818f18d 100644 --- a/src/docx/opc/parts/coreprops.py +++ b/src/docx/opc/parts/coreprops.py @@ -1,6 +1,9 @@ """Core properties part, corresponds to ``/docProps/core.xml`` part in package.""" -from datetime import datetime +from __future__ import annotations + +import datetime as dt +from typing import TYPE_CHECKING from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.coreprops import CoreProperties @@ -8,13 +11,19 @@ from docx.opc.part import XmlPart from docx.oxml.coreprops import CT_CoreProperties +if TYPE_CHECKING: + from docx.opc.package import OpcPackage + class CorePropertiesPart(XmlPart): - """Corresponds to part named ``/docProps/core.xml``, containing the core document - properties for this document package.""" + """Corresponds to part named ``/docProps/core.xml``. + + The "core" is short for "Dublin Core" and contains document metadata relatively common across + documents of all types, not just DOCX. + """ @classmethod - def default(cls, package): + def default(cls, package: OpcPackage): """Return a new |CorePropertiesPart| object initialized with default values for its base properties.""" core_properties_part = cls._new(package) @@ -22,7 +31,7 @@ def default(cls, package): core_properties.title = "Word Document" core_properties.last_modified_by = "python-docx" core_properties.revision = 1 - core_properties.modified = datetime.utcnow() + core_properties.modified = dt.datetime.utcnow() return core_properties_part @property @@ -32,7 +41,7 @@ def core_properties(self): return CoreProperties(self.element) @classmethod - def _new(cls, package): + def _new(cls, package: OpcPackage) -> CorePropertiesPart: partname = PackURI("/docProps/core.xml") content_type = CT.OPC_CORE_PROPERTIES coreProperties = CT_CoreProperties.new() diff --git a/src/docx/opc/rel.py b/src/docx/opc/rel.py index 5fae7ad9c..47e8860d8 100644 --- a/src/docx/opc/rel.py +++ b/src/docx/opc/rel.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any, Dict, cast from docx.opc.oxml import CT_Relationships @@ -16,7 +16,7 @@ class Relationships(Dict[str, "_Relationship"]): def __init__(self, baseURI: str): super(Relationships, self).__init__() self._baseURI = baseURI - self._target_parts_by_rId: Dict[str, Any] = {} + self._target_parts_by_rId: dict[str, Any] = {} def add_relationship( self, reltype: str, target: Part | str, rId: str, is_external: bool = False @@ -37,7 +37,7 @@ def get_or_add(self, reltype: str, target_part: Part) -> _Relationship: rel = self.add_relationship(reltype, target_part, rId) return rel - def get_or_add_ext_rel(self, reltype, target_ref): + def get_or_add_ext_rel(self, reltype: str, target_ref: str) -> str: """Return rId of external relationship of `reltype` to `target_ref`, newly added if not already present in collection.""" rel = self._get_matching(reltype, target_ref, is_external=True) @@ -46,7 +46,7 @@ def get_or_add_ext_rel(self, reltype, target_ref): rel = self.add_relationship(reltype, target_ref, rId, is_external=True) return rel.rId - def part_with_reltype(self, reltype): + def part_with_reltype(self, reltype: str) -> Part: """Return target part of rel with matching `reltype`, raising |KeyError| if not found and |ValueError| if more than one matching relationship is found.""" rel = self._get_rel_of_type(reltype) @@ -59,7 +59,7 @@ def related_parts(self): return self._target_parts_by_rId @property - def xml(self): + def xml(self) -> str: """Serialize this relationship collection into XML suitable for storage as a .rels file in an OPC package.""" rels_elm = CT_Relationships.new() @@ -73,7 +73,7 @@ def _get_matching( """Return relationship of matching `reltype`, `target`, and `is_external` from collection, or None if not found.""" - def matches(rel, reltype, target, is_external): + def matches(rel: _Relationship, reltype: str, target: Part | str, is_external: bool): if rel.reltype != reltype: return False if rel.is_external != is_external: @@ -88,7 +88,7 @@ def matches(rel, reltype, target, is_external): return rel return None - def _get_rel_of_type(self, reltype): + def _get_rel_of_type(self, reltype: str): """Return single relationship of type `reltype` from the collection. Raises |KeyError| if no matching relationship is found. Raises |ValueError| if @@ -104,7 +104,7 @@ def _get_rel_of_type(self, reltype): return matching[0] @property - def _next_rId(self) -> str: + def _next_rId(self) -> str: # pyright: ignore[reportReturnType] """Next available rId in collection, starting from 'rId1' and making use of any gaps in numbering, e.g. 'rId2' for rIds ['rId1', 'rId3'].""" for n in range(1, len(self) + 2): @@ -116,7 +116,9 @@ def _next_rId(self) -> str: class _Relationship: """Value object for relationship to part.""" - def __init__(self, rId: str, reltype, target, baseURI, external=False): + def __init__( + self, rId: str, reltype: str, target: Part | str, baseURI: str, external: bool = False + ): super(_Relationship, self).__init__() self._rId = rId self._reltype = reltype @@ -125,28 +127,29 @@ def __init__(self, rId: str, reltype, target, baseURI, external=False): self._is_external = bool(external) @property - def is_external(self): + def is_external(self) -> bool: return self._is_external @property - def reltype(self): + def reltype(self) -> str: return self._reltype @property - def rId(self): + def rId(self) -> str: return self._rId @property - def target_part(self): + def target_part(self) -> Part: if self._is_external: raise ValueError( "target_part property on _Relationship is undef" "ined when target mode is External" ) - return self._target + return cast("Part", self._target) @property def target_ref(self) -> str: if self._is_external: - return self._target + return cast(str, self._target) else: - return self._target.partname.relative_ref(self._baseURI) + target = cast("Part", self._target) + return target.partname.relative_ref(self._baseURI) diff --git a/src/docx/oxml/coreprops.py b/src/docx/oxml/coreprops.py index 2cafcd960..93f8890c7 100644 --- a/src/docx/oxml/coreprops.py +++ b/src/docx/oxml/coreprops.py @@ -1,13 +1,18 @@ """Custom element classes for core properties-related XML elements.""" +from __future__ import annotations + +import datetime as dt import re -from datetime import datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any, Callable from docx.oxml.ns import nsdecls, qn from docx.oxml.parser import parse_xml from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrOne +if TYPE_CHECKING: + from lxml.etree import _Element as etree_Element # pyright: ignore[reportPrivateUsage] + class CT_CoreProperties(BaseOxmlElement): """`` element, the root element of the Core Properties part. @@ -17,6 +22,8 @@ class CT_CoreProperties(BaseOxmlElement): present in the XML. String elements are limited in length to 255 unicode characters. """ + get_or_add_revision: Callable[[], etree_Element] + category = ZeroOrOne("cp:category", successors=()) contentStatus = ZeroOrOne("cp:contentStatus", successors=()) created = ZeroOrOne("dcterms:created", successors=()) @@ -28,7 +35,9 @@ class CT_CoreProperties(BaseOxmlElement): lastModifiedBy = ZeroOrOne("cp:lastModifiedBy", successors=()) lastPrinted = ZeroOrOne("cp:lastPrinted", successors=()) modified = ZeroOrOne("dcterms:modified", successors=()) - revision = ZeroOrOne("cp:revision", successors=()) + revision: etree_Element | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "cp:revision", successors=() + ) subject = ZeroOrOne("dc:subject", successors=()) title = ZeroOrOne("dc:title", successors=()) version = ZeroOrOne("cp:version", successors=()) @@ -80,7 +89,7 @@ def created_datetime(self): return self._datetime_of_element("created") @created_datetime.setter - def created_datetime(self, value): + def created_datetime(self, value: dt.datetime): self._set_element_datetime("created", value) @property @@ -88,7 +97,7 @@ def identifier_text(self): return self._text_of_element("identifier") @identifier_text.setter - def identifier_text(self, value): + def identifier_text(self, value: str): self._set_element_text("identifier", value) @property @@ -96,7 +105,7 @@ def keywords_text(self): return self._text_of_element("keywords") @keywords_text.setter - def keywords_text(self, value): + def keywords_text(self, value: str): self._set_element_text("keywords", value) @property @@ -104,7 +113,7 @@ def language_text(self): return self._text_of_element("language") @language_text.setter - def language_text(self, value): + def language_text(self, value: str): self._set_element_text("language", value) @property @@ -112,7 +121,7 @@ def lastModifiedBy_text(self): return self._text_of_element("lastModifiedBy") @lastModifiedBy_text.setter - def lastModifiedBy_text(self, value): + def lastModifiedBy_text(self, value: str): self._set_element_text("lastModifiedBy", value) @property @@ -120,15 +129,15 @@ def lastPrinted_datetime(self): return self._datetime_of_element("lastPrinted") @lastPrinted_datetime.setter - def lastPrinted_datetime(self, value): + def lastPrinted_datetime(self, value: dt.datetime): self._set_element_datetime("lastPrinted", value) @property - def modified_datetime(self): + def modified_datetime(self) -> dt.datetime | None: return self._datetime_of_element("modified") @modified_datetime.setter - def modified_datetime(self, value): + def modified_datetime(self, value: dt.datetime): self._set_element_datetime("modified", value) @property @@ -137,7 +146,7 @@ def revision_number(self): revision = self.revision if revision is None: return 0 - revision_str = revision.text + revision_str = str(revision.text) try: revision = int(revision_str) except ValueError: @@ -149,9 +158,9 @@ def revision_number(self): return revision @revision_number.setter - def revision_number(self, value): + def revision_number(self, value: int): """Set revision property to string value of integer `value`.""" - if not isinstance(value, int) or value < 1: + if not isinstance(value, int) or value < 1: # pyright: ignore[reportUnnecessaryIsInstance] tmpl = "revision property requires positive int, got '%s'" raise ValueError(tmpl % value) revision = self.get_or_add_revision() @@ -162,7 +171,7 @@ def subject_text(self): return self._text_of_element("subject") @subject_text.setter - def subject_text(self, value): + def subject_text(self, value: str): self._set_element_text("subject", value) @property @@ -170,7 +179,7 @@ def title_text(self): return self._text_of_element("title") @title_text.setter - def title_text(self, value): + def title_text(self, value: str): self._set_element_text("title", value) @property @@ -178,10 +187,10 @@ def version_text(self): return self._text_of_element("version") @version_text.setter - def version_text(self, value): + def version_text(self, value: str): self._set_element_text("version", value) - def _datetime_of_element(self, property_name): + def _datetime_of_element(self, property_name: str) -> dt.datetime | None: element = getattr(self, property_name) if element is None: return None @@ -192,7 +201,7 @@ def _datetime_of_element(self, property_name): # invalid datetime strings are ignored return None - def _get_or_add(self, prop_name): + def _get_or_add(self, prop_name: str) -> BaseOxmlElement: """Return element returned by "get_or_add_" method for `prop_name`.""" get_or_add_method_name = "get_or_add_%s" % prop_name get_or_add_method = getattr(self, get_or_add_method_name) @@ -200,8 +209,8 @@ def _get_or_add(self, prop_name): return element @classmethod - def _offset_dt(cls, dt, offset_str): - """A |datetime| instance offset from `dt` by timezone offset in `offset_str`. + def _offset_dt(cls, dt_: dt.datetime, offset_str: str) -> dt.datetime: + """A |datetime| instance offset from `dt_` by timezone offset in `offset_str`. `offset_str` is like `"-07:00"`. """ @@ -212,13 +221,13 @@ def _offset_dt(cls, dt, offset_str): sign_factor = -1 if sign == "+" else 1 hours = int(hours_str) * sign_factor minutes = int(minutes_str) * sign_factor - td = timedelta(hours=hours, minutes=minutes) - return dt + td + td = dt.timedelta(hours=hours, minutes=minutes) + return dt_ + td _offset_pattern = re.compile(r"([+-])(\d\d):(\d\d)") @classmethod - def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): + def _parse_W3CDTF_to_datetime(cls, w3cdtf_str: str) -> dt.datetime: # valid W3CDTF date cases: # yyyy e.g. "2003" # yyyy-mm e.g. "2003-12" @@ -235,22 +244,22 @@ def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): # "-07:30", so we have to do it ourselves parseable_part = w3cdtf_str[:19] offset_str = w3cdtf_str[19:] - dt = None + dt_ = None for tmpl in templates: try: - dt = datetime.strptime(parseable_part, tmpl) + dt_ = dt.datetime.strptime(parseable_part, tmpl) except ValueError: continue - if dt is None: + if dt_ is None: tmpl = "could not parse W3CDTF datetime string '%s'" raise ValueError(tmpl % w3cdtf_str) if len(offset_str) == 6: - return cls._offset_dt(dt, offset_str) - return dt + return cls._offset_dt(dt_, offset_str) + return dt_ - def _set_element_datetime(self, prop_name, value): + def _set_element_datetime(self, prop_name: str, value: dt.datetime): """Set date/time value of child element having `prop_name` to `value`.""" - if not isinstance(value, datetime): + if not isinstance(value, dt.datetime): # pyright: ignore[reportUnnecessaryIsInstance] tmpl = "property requires object, got %s" raise ValueError(tmpl % type(value)) element = self._get_or_add(prop_name) diff --git a/src/docx/oxml/document.py b/src/docx/oxml/document.py index ff3736f65..36819ef75 100644 --- a/src/docx/oxml/document.py +++ b/src/docx/oxml/document.py @@ -15,7 +15,7 @@ class CT_Document(BaseOxmlElement): """```` element, the root element of a document.xml file.""" - body = ZeroOrOne("w:body") + body: CT_Body = ZeroOrOne("w:body") # pyright: ignore[reportAssignmentType] @property def sectPr_lst(self) -> List[CT_SectPr]: diff --git a/src/docx/oxml/parser.py b/src/docx/oxml/parser.py index a38362676..e16ba30ba 100644 --- a/src/docx/oxml/parser.py +++ b/src/docx/oxml/parser.py @@ -20,7 +20,7 @@ oxml_parser.set_element_class_lookup(element_class_lookup) -def parse_xml(xml: str) -> "BaseOxmlElement": +def parse_xml(xml: str | bytes) -> "BaseOxmlElement": """Root lxml element obtained by parsing XML character string `xml`. The custom parser is used, so custom element classes are produced for elements in diff --git a/src/docx/oxml/settings.py b/src/docx/oxml/settings.py index fd39fbd99..d5bb41a6d 100644 --- a/src/docx/oxml/settings.py +++ b/src/docx/oxml/settings.py @@ -1,11 +1,21 @@ """Custom element classes related to document settings.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable + from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrOne +if TYPE_CHECKING: + from docx.oxml.shared import CT_OnOff + class CT_Settings(BaseOxmlElement): """`w:settings` element, root element for the settings part.""" + get_or_add_evenAndOddHeaders: Callable[[], CT_OnOff] + _remove_evenAndOddHeaders: Callable[[], None] + _tag_seq = ( "w:writeProtection", "w:view", @@ -106,11 +116,13 @@ class CT_Settings(BaseOxmlElement): "w:decimalSymbol", "w:listSeparator", ) - evenAndOddHeaders = ZeroOrOne("w:evenAndOddHeaders", successors=_tag_seq[48:]) + evenAndOddHeaders: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:evenAndOddHeaders", successors=_tag_seq[48:] + ) del _tag_seq @property - def evenAndOddHeaders_val(self): + def evenAndOddHeaders_val(self) -> bool: """Value of `w:evenAndOddHeaders/@w:val` or |None| if not present.""" evenAndOddHeaders = self.evenAndOddHeaders if evenAndOddHeaders is None: @@ -118,8 +130,9 @@ def evenAndOddHeaders_val(self): return evenAndOddHeaders.val @evenAndOddHeaders_val.setter - def evenAndOddHeaders_val(self, value): - if value in [None, False]: + def evenAndOddHeaders_val(self, value: bool | None): + if value is None or value is False: self._remove_evenAndOddHeaders() - else: - self.get_or_add_evenAndOddHeaders().val = value + return + + self.get_or_add_evenAndOddHeaders().val = value diff --git a/src/docx/oxml/shape.py b/src/docx/oxml/shape.py index 05c96697a..289d35579 100644 --- a/src/docx/oxml/shape.py +++ b/src/docx/oxml/shape.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from docx.oxml.ns import nsdecls from docx.oxml.parser import parse_xml @@ -34,48 +34,58 @@ class CT_Blip(BaseOxmlElement): """```` element, specifies image source and adjustments such as alpha and tint.""" - embed = OptionalAttribute("r:embed", ST_RelationshipId) - link = OptionalAttribute("r:link", ST_RelationshipId) + embed: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "r:embed", ST_RelationshipId + ) + link: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "r:link", ST_RelationshipId + ) class CT_BlipFillProperties(BaseOxmlElement): """```` element, specifies picture properties.""" - blip = ZeroOrOne("a:blip", successors=("a:srcRect", "a:tile", "a:stretch")) + blip: CT_Blip = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:blip", successors=("a:srcRect", "a:tile", "a:stretch") + ) class CT_GraphicalObject(BaseOxmlElement): """```` element, container for a DrawingML object.""" - graphicData = OneAndOnlyOne("a:graphicData") + graphicData: CT_GraphicalObjectData = OneAndOnlyOne( # pyright: ignore[reportAssignmentType] + "a:graphicData" + ) class CT_GraphicalObjectData(BaseOxmlElement): """```` element, container for the XML of a DrawingML object.""" - pic = ZeroOrOne("pic:pic") - uri = RequiredAttribute("uri", XsdToken) + pic: CT_Picture = ZeroOrOne("pic:pic") # pyright: ignore[reportAssignmentType] + uri: str = RequiredAttribute("uri", XsdToken) # pyright: ignore[reportAssignmentType] class CT_Inline(BaseOxmlElement): """`` element, container for an inline shape.""" - extent = OneAndOnlyOne("wp:extent") - docPr = OneAndOnlyOne("wp:docPr") - graphic = OneAndOnlyOne("a:graphic") + extent: CT_PositiveSize2D = OneAndOnlyOne("wp:extent") # pyright: ignore[reportAssignmentType] + docPr: CT_NonVisualDrawingProps = OneAndOnlyOne( # pyright: ignore[reportAssignmentType] + "wp:docPr" + ) + graphic: CT_GraphicalObject = OneAndOnlyOne( # pyright: ignore[reportAssignmentType] + "a:graphic" + ) @classmethod def new(cls, cx: Length, cy: Length, shape_id: int, pic: CT_Picture) -> CT_Inline: """Return a new ```` element populated with the values passed as parameters.""" - inline = parse_xml(cls._inline_xml()) + inline = cast(CT_Inline, parse_xml(cls._inline_xml())) inline.extent.cx = cx inline.extent.cy = cy inline.docPr.id = shape_id inline.docPr.name = "Picture %d" % shape_id - inline.graphic.graphicData.uri = ( - "http://schemas.openxmlformats.org/drawingml/2006/picture" - ) + inline.graphic.graphicData.uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" inline.graphic.graphicData._insert_pic(pic) return inline @@ -126,9 +136,13 @@ class CT_NonVisualPictureProperties(BaseOxmlElement): class CT_Picture(BaseOxmlElement): """```` element, a DrawingML picture.""" - nvPicPr = OneAndOnlyOne("pic:nvPicPr") - blipFill = OneAndOnlyOne("pic:blipFill") - spPr = OneAndOnlyOne("pic:spPr") + nvPicPr: CT_PictureNonVisual = OneAndOnlyOne( # pyright: ignore[reportAssignmentType] + "pic:nvPicPr" + ) + blipFill: CT_BlipFillProperties = OneAndOnlyOne( # pyright: ignore[reportAssignmentType] + "pic:blipFill" + ) + spPr: CT_ShapeProperties = OneAndOnlyOne("pic:spPr") # pyright: ignore[reportAssignmentType] @classmethod def new(cls, pic_id, filename, rId, cx, cy): @@ -190,8 +204,12 @@ class CT_PositiveSize2D(BaseOxmlElement): Specifies the size of a DrawingML drawing. """ - cx = RequiredAttribute("cx", ST_PositiveCoordinate) - cy = RequiredAttribute("cy", ST_PositiveCoordinate) + cx: Length = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "cx", ST_PositiveCoordinate + ) + cy: Length = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "cy", ST_PositiveCoordinate + ) class CT_PresetGeometry2D(BaseOxmlElement): diff --git a/src/docx/oxml/shared.py b/src/docx/oxml/shared.py index a74abc4ac..8c2ebc9a9 100644 --- a/src/docx/oxml/shared.py +++ b/src/docx/oxml/shared.py @@ -18,7 +18,7 @@ class CT_DecimalNumber(BaseOxmlElement): val: int = RequiredAttribute("w:val", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] @classmethod - def new(cls, nsptagname, val): + def new(cls, nsptagname: str, val: int): """Return a new ``CT_DecimalNumber`` element having tagname `nsptagname` and ``val`` attribute set to `val`.""" return OxmlElement(nsptagname, attrs={qn("w:val"): str(val)}) @@ -31,7 +31,7 @@ class CT_OnOff(BaseOxmlElement): "off". Defaults to `True`, so `` for example means "bold is turned on". """ - val: bool = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + val: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:val", ST_OnOff, default=True ) @@ -42,7 +42,7 @@ class CT_String(BaseOxmlElement): In those cases, it containing a style name in its `val` attribute. """ - val: str = RequiredAttribute("w:val", ST_String) # pyright: ignore[reportGeneralTypeIssues] + val: str = RequiredAttribute("w:val", ST_String) # pyright: ignore[reportAssignmentType] @classmethod def new(cls, nsptagname: str, val: str): diff --git a/src/docx/oxml/styles.py b/src/docx/oxml/styles.py index e0a3eaeaf..fb0e5d0dd 100644 --- a/src/docx/oxml/styles.py +++ b/src/docx/oxml/styles.py @@ -128,12 +128,10 @@ class CT_Style(BaseOxmlElement): rPr = ZeroOrOne("w:rPr", successors=_tag_seq[18:]) del _tag_seq - type: WD_STYLE_TYPE | None = ( - OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:type", WD_STYLE_TYPE - ) + type: WD_STYLE_TYPE | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:type", WD_STYLE_TYPE ) - styleId: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + styleId: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:styleId", ST_String ) default = OptionalAttribute("w:default", ST_OnOff) diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index 42e8cc95c..e38d58562 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -973,5 +973,5 @@ class CT_VMerge(BaseOxmlElement): """```` element, specifying vertical merging behavior of a cell.""" val: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] - "w:val", ST_Merge, default=ST_Merge.CONTINUE # pyright: ignore[reportArgumentType] + "w:val", ST_Merge, default=ST_Merge.CONTINUE ) diff --git a/src/docx/oxml/text/font.py b/src/docx/oxml/text/font.py index 0e183cf65..140086aab 100644 --- a/src/docx/oxml/text/font.py +++ b/src/docx/oxml/text/font.py @@ -39,10 +39,10 @@ class CT_Fonts(BaseOxmlElement): Specifies typeface name for the various language types. """ - ascii: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + ascii: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:ascii", ST_String ) - hAnsi: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + hAnsi: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:hAnsi", ST_String ) @@ -148,18 +148,14 @@ class CT_RPr(BaseOxmlElement): sz: CT_HpsMeasure | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] "w:sz", successors=_tag_seq[24:] ) - highlight: CT_Highlight | None = ( - ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:highlight", successors=_tag_seq[26:] - ) + highlight: CT_Highlight | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + "w:highlight", successors=_tag_seq[26:] ) u: CT_Underline | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] "w:u", successors=_tag_seq[27:] ) - vertAlign: CT_VerticalAlignRun | None = ( - ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:vertAlign", successors=_tag_seq[32:] - ) + vertAlign: CT_VerticalAlignRun | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + "w:vertAlign", successors=_tag_seq[32:] ) rtl = ZeroOrOne("w:rtl", successors=_tag_seq[33:]) cs = ZeroOrOne("w:cs", successors=_tag_seq[34:]) @@ -268,10 +264,7 @@ def subscript(self, value: bool | None) -> None: elif bool(value) is True: self.get_or_add_vertAlign().val = ST_VerticalAlignRun.SUBSCRIPT # -- assert bool(value) is False -- - elif ( - self.vertAlign is not None - and self.vertAlign.val == ST_VerticalAlignRun.SUBSCRIPT - ): + elif self.vertAlign is not None and self.vertAlign.val == ST_VerticalAlignRun.SUBSCRIPT: self._remove_vertAlign() @property @@ -295,10 +288,7 @@ def superscript(self, value: bool | None): elif bool(value) is True: self.get_or_add_vertAlign().val = ST_VerticalAlignRun.SUPERSCRIPT # -- assert bool(value) is False -- - elif ( - self.vertAlign is not None - and self.vertAlign.val == ST_VerticalAlignRun.SUPERSCRIPT - ): + elif self.vertAlign is not None and self.vertAlign.val == ST_VerticalAlignRun.SUPERSCRIPT: self._remove_vertAlign() @property @@ -353,10 +343,8 @@ def _set_bool_val(self, name: str, value: bool | None): class CT_Underline(BaseOxmlElement): """`` element, specifying the underlining style for a run.""" - val: WD_UNDERLINE | None = ( - OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:val", WD_UNDERLINE - ) + val: WD_UNDERLINE | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_UNDERLINE ) diff --git a/src/docx/package.py b/src/docx/package.py index 12a166bf3..7ea47e6e1 100644 --- a/src/docx/package.py +++ b/src/docx/package.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import IO +from typing import IO, cast from docx.image.image import Image from docx.opc.constants import RELATIONSHIP_TYPE as RT @@ -44,16 +44,16 @@ def _gather_image_parts(self): continue if rel.target_part in self.image_parts: continue - self.image_parts.append(rel.target_part) + self.image_parts.append(cast("ImagePart", rel.target_part)) class ImageParts: """Collection of |ImagePart| objects corresponding to images in the package.""" def __init__(self): - self._image_parts = [] + self._image_parts: list[ImagePart] = [] - def __contains__(self, item): + def __contains__(self, item: object): return self._image_parts.__contains__(item) def __iter__(self): @@ -62,7 +62,7 @@ def __iter__(self): def __len__(self): return self._image_parts.__len__() - def append(self, item): + def append(self, item: ImagePart): self._image_parts.append(item) def get_or_add_image_part(self, image_descriptor: str | IO[bytes]) -> ImagePart: @@ -77,15 +77,14 @@ def get_or_add_image_part(self, image_descriptor: str | IO[bytes]) -> ImagePart: return matching_image_part return self._add_image_part(image) - def _add_image_part(self, image): - """Return an |ImagePart| instance newly created from image and appended to the - collection.""" + def _add_image_part(self, image: Image): + """Return |ImagePart| instance newly created from `image` and appended to the collection.""" partname = self._next_image_partname(image.ext) image_part = ImagePart.from_image(image, partname) self.append(image_part) return image_part - def _get_by_sha1(self, sha1): + def _get_by_sha1(self, sha1: str) -> ImagePart | None: """Return the image part in this collection having a SHA1 hash matching `sha1`, or |None| if not found.""" for image_part in self._image_parts: @@ -93,7 +92,7 @@ def _get_by_sha1(self, sha1): return image_part return None - def _next_image_partname(self, ext): + def _next_image_partname(self, ext: str) -> PackURI: """The next available image partname, starting from ``/word/media/image1.{ext}`` where unused numbers are reused. @@ -101,7 +100,7 @@ def _next_image_partname(self, ext): not include the leading period. """ - def image_partname(n): + def image_partname(n: int) -> PackURI: return PackURI("/word/media/image%d.%s" % (n, ext)) used_numbers = [image_part.partname.idx for image_part in self] diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 81e621c1a..416bb1a27 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast +from typing import IO, TYPE_CHECKING, cast from docx.document import Document from docx.enum.style import WD_STYLE_TYPE @@ -17,6 +17,7 @@ if TYPE_CHECKING: from docx.opc.coreprops import CoreProperties + from docx.settings import Settings from docx.styles.style import BaseStyle @@ -101,13 +102,13 @@ def numbering_part(self): self.relate_to(numbering_part, RT.NUMBERING) return numbering_part - def save(self, path_or_stream): + def save(self, path_or_stream: str | IO[bytes]): """Save this document to `path_or_stream`, which can be either a path to a filesystem location (a string) or a file-like object.""" self.package.save(path_or_stream) @property - def settings(self): + def settings(self) -> Settings: """A |Settings| object providing access to the settings in the settings part of this document.""" return self._settings_part.settings @@ -119,14 +120,14 @@ def styles(self): return self._styles_part.styles @property - def _settings_part(self): + def _settings_part(self) -> SettingsPart: """A |SettingsPart| object providing access to the document-level settings for this document. Creates a default settings part if one is not present. """ try: - return self.part_related_by(RT.SETTINGS) + return cast(SettingsPart, self.part_related_by(RT.SETTINGS)) except KeyError: settings_part = SettingsPart.default(self.package) self.relate_to(settings_part, RT.SETTINGS) diff --git a/src/docx/parts/hdrftr.py b/src/docx/parts/hdrftr.py index 46821d780..35113801c 100644 --- a/src/docx/parts/hdrftr.py +++ b/src/docx/parts/hdrftr.py @@ -1,17 +1,23 @@ """Header and footer part objects.""" +from __future__ import annotations + import os +from typing import TYPE_CHECKING from docx.opc.constants import CONTENT_TYPE as CT from docx.oxml.parser import parse_xml from docx.parts.story import StoryPart +if TYPE_CHECKING: + from docx.package import Package + class FooterPart(StoryPart): """Definition of a section footer.""" @classmethod - def new(cls, package): + def new(cls, package: Package): """Return newly created footer part.""" partname = package.next_partname("/word/footer%d.xml") content_type = CT.WML_FOOTER @@ -21,9 +27,7 @@ def new(cls, package): @classmethod def _default_footer_xml(cls): """Return bytes containing XML for a default footer part.""" - path = os.path.join( - os.path.split(__file__)[0], "..", "templates", "default-footer.xml" - ) + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-footer.xml") with open(path, "rb") as f: xml_bytes = f.read() return xml_bytes @@ -33,7 +37,7 @@ class HeaderPart(StoryPart): """Definition of a section header.""" @classmethod - def new(cls, package): + def new(cls, package: Package): """Return newly created header part.""" partname = package.next_partname("/word/header%d.xml") content_type = CT.WML_HEADER @@ -43,9 +47,7 @@ def new(cls, package): @classmethod def _default_header_xml(cls): """Return bytes containing XML for a default header part.""" - path = os.path.join( - os.path.split(__file__)[0], "..", "templates", "default-header.xml" - ) + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-header.xml") with open(path, "rb") as f: xml_bytes = f.read() return xml_bytes diff --git a/src/docx/parts/image.py b/src/docx/parts/image.py index e4580df74..5aec07077 100644 --- a/src/docx/parts/image.py +++ b/src/docx/parts/image.py @@ -3,11 +3,16 @@ from __future__ import annotations import hashlib +from typing import TYPE_CHECKING from docx.image.image import Image from docx.opc.part import Part from docx.shared import Emu, Inches +if TYPE_CHECKING: + from docx.opc.package import OpcPackage + from docx.opc.packuri import PackURI + class ImagePart(Part): """An image part. @@ -16,7 +21,7 @@ class ImagePart(Part): """ def __init__( - self, partname: str, content_type: str, blob: bytes, image: Image | None = None + self, partname: PackURI, content_type: str, blob: bytes, image: Image | None = None ): super(ImagePart, self).__init__(partname, content_type, blob) self._image = image @@ -36,7 +41,7 @@ def default_cy(self): vertical dots per inch (dpi).""" px_height = self.image.px_height horz_dpi = self.image.horz_dpi - height_in_emu = 914400 * px_height / horz_dpi + height_in_emu = int(round(914400 * px_height / horz_dpi)) return Emu(height_in_emu) @property @@ -52,7 +57,7 @@ def filename(self): return "image.%s" % self.partname.ext @classmethod - def from_image(cls, image, partname): + def from_image(cls, image: Image, partname: PackURI): """Return an |ImagePart| instance newly created from `image` and assigned `partname`.""" return ImagePart(partname, image.content_type, image.blob, image) @@ -64,7 +69,7 @@ def image(self) -> Image: return self._image @classmethod - def load(cls, partname, content_type, blob, package): + def load(cls, partname: PackURI, content_type: str, blob: bytes, package: OpcPackage): """Called by ``docx.opc.package.PartFactory`` to load an image part from a package being opened by ``Document(...)`` call.""" return cls(partname, content_type, blob) @@ -72,4 +77,4 @@ def load(cls, partname, content_type, blob, package): @property def sha1(self): """SHA1 hash digest of the blob of this image part.""" - return hashlib.sha1(self._blob).hexdigest() + return hashlib.sha1(self.blob).hexdigest() diff --git a/src/docx/parts/settings.py b/src/docx/parts/settings.py index d83c9d5ca..116facca2 100644 --- a/src/docx/parts/settings.py +++ b/src/docx/parts/settings.py @@ -1,6 +1,9 @@ """|SettingsPart| and closely related objects.""" +from __future__ import annotations + import os +from typing import TYPE_CHECKING, cast from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.packuri import PackURI @@ -8,31 +11,41 @@ from docx.oxml.parser import parse_xml from docx.settings import Settings +if TYPE_CHECKING: + from docx.oxml.settings import CT_Settings + from docx.package import Package + class SettingsPart(XmlPart): """Document-level settings part of a WordprocessingML (WML) package.""" + def __init__( + self, partname: PackURI, content_type: str, element: CT_Settings, package: Package + ): + super().__init__(partname, content_type, element, package) + self._settings = element + @classmethod - def default(cls, package): + def default(cls, package: Package): """Return a newly created settings part, containing a default `w:settings` element tree.""" partname = PackURI("/word/settings.xml") content_type = CT.WML_SETTINGS - element = parse_xml(cls._default_settings_xml()) + element = cast("CT_Settings", parse_xml(cls._default_settings_xml())) return cls(partname, content_type, element, package) @property - def settings(self): - """A |Settings| proxy object for the `w:settings` element in this part, - containing the document-level settings for this document.""" - return Settings(self.element) + def settings(self) -> Settings: + """A |Settings| proxy object for the `w:settings` element in this part. + + Contains the document-level settings for this document. + """ + return Settings(self._settings) @classmethod def _default_settings_xml(cls): """Return a bytestream containing XML for a default settings part.""" - path = os.path.join( - os.path.split(__file__)[0], "..", "templates", "default-settings.xml" - ) + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-settings.xml") with open(path, "rb") as f: xml_bytes = f.read() return xml_bytes diff --git a/src/docx/parts/story.py b/src/docx/parts/story.py index b5c8ac882..7482c91a8 100644 --- a/src/docx/parts/story.py +++ b/src/docx/parts/story.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import IO, TYPE_CHECKING, Tuple +from typing import IO, TYPE_CHECKING, Tuple, cast from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.part import XmlPart @@ -60,8 +60,8 @@ def get_style_id( def new_pic_inline( self, image_descriptor: str | IO[bytes], - width: Length | None = None, - height: Length | None = None, + width: int | Length | None = None, + height: int | Length | None = None, ) -> CT_Inline: """Return a newly-created `w:inline` element. @@ -92,4 +92,4 @@ def _document_part(self) -> DocumentPart: """|DocumentPart| object for this package.""" package = self.package assert package is not None - return package.main_document_part + return cast("DocumentPart", package.main_document_part) diff --git a/src/docx/section.py b/src/docx/section.py index f72b60867..982a14370 100644 --- a/src/docx/section.py +++ b/src/docx/section.py @@ -160,11 +160,7 @@ def iter_inner_content(self) -> Iterator[Paragraph | Table]: Items appear in document order. """ for element in self._sectPr.iter_inner_content(): - yield ( - Paragraph(element, self) # pyright: ignore[reportGeneralTypeIssues] - if isinstance(element, CT_P) - else Table(element, self) - ) + yield (Paragraph(element, self) if isinstance(element, CT_P) else Table(element, self)) @property def left_margin(self) -> Length | None: @@ -269,12 +265,10 @@ def __init__(self, document_elm: CT_Document, document_part: DocumentPart): self._document_part = document_part @overload - def __getitem__(self, key: int) -> Section: - ... + def __getitem__(self, key: int) -> Section: ... @overload - def __getitem__(self, key: slice) -> List[Section]: - ... + def __getitem__(self, key: slice) -> List[Section]: ... def __getitem__(self, key: int | slice) -> Section | List[Section]: if isinstance(key, slice): diff --git a/src/docx/settings.py b/src/docx/settings.py index 78f816e87..0a5aa2f36 100644 --- a/src/docx/settings.py +++ b/src/docx/settings.py @@ -1,7 +1,16 @@ """Settings object, providing access to document-level settings.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + from docx.shared import ElementProxy +if TYPE_CHECKING: + import docx.types as t + from docx.oxml.settings import CT_Settings + from docx.oxml.xmlchemy import BaseOxmlElement + class Settings(ElementProxy): """Provides access to document-level settings for a document. @@ -9,14 +18,18 @@ class Settings(ElementProxy): Accessed using the :attr:`.Document.settings` property. """ + def __init__(self, element: BaseOxmlElement, parent: t.ProvidesXmlPart | None = None): + super().__init__(element, parent) + self._settings = cast("CT_Settings", element) + @property - def odd_and_even_pages_header_footer(self): + def odd_and_even_pages_header_footer(self) -> bool: """True if this document has distinct odd and even page headers and footers. Read/write. """ - return self._element.evenAndOddHeaders_val + return self._settings.evenAndOddHeaders_val @odd_and_even_pages_header_footer.setter - def odd_and_even_pages_header_footer(self, value): - self._element.evenAndOddHeaders_val = value + def odd_and_even_pages_header_footer(self, value: bool): + self._settings.evenAndOddHeaders_val = value diff --git a/src/docx/shape.py b/src/docx/shape.py index b91ecbf64..cd35deb35 100644 --- a/src/docx/shape.py +++ b/src/docx/shape.py @@ -3,26 +3,36 @@ A shape is a visual object that appears on the drawing layer of a document. """ +from __future__ import annotations + +from typing import TYPE_CHECKING + from docx.enum.shape import WD_INLINE_SHAPE from docx.oxml.ns import nsmap from docx.shared import Parented +if TYPE_CHECKING: + from docx.oxml.document import CT_Body + from docx.oxml.shape import CT_Inline + from docx.parts.story import StoryPart + from docx.shared import Length + class InlineShapes(Parented): - """Sequence of |InlineShape| instances, supporting len(), iteration, and indexed - access.""" + """Sequence of |InlineShape| instances, supporting len(), iteration, and indexed access.""" - def __init__(self, body_elm, parent): + def __init__(self, body_elm: CT_Body, parent: StoryPart): super(InlineShapes, self).__init__(parent) self._body = body_elm - def __getitem__(self, idx): + def __getitem__(self, idx: int): """Provide indexed access, e.g. 'inline_shapes[idx]'.""" try: inline = self._inline_lst[idx] except IndexError: msg = "inline shape index [%d] out of range" % idx raise IndexError(msg) + return InlineShape(inline) def __iter__(self): @@ -42,12 +52,12 @@ class InlineShape: """Proxy for an ```` element, representing the container for an inline graphical object.""" - def __init__(self, inline): + def __init__(self, inline: CT_Inline): super(InlineShape, self).__init__() self._inline = inline @property - def height(self): + def height(self) -> Length: """Read/write. The display height of this inline shape as an |Emu| instance. @@ -55,7 +65,7 @@ def height(self): return self._inline.extent.cy @height.setter - def height(self, cy): + def height(self, cy: Length): self._inline.extent.cy = cy self._inline.graphic.graphicData.pic.spPr.cy = cy @@ -88,6 +98,6 @@ def width(self): return self._inline.extent.cx @width.setter - def width(self, cx): + def width(self, cx: Length): self._inline.extent.cx = cx self._inline.graphic.graphicData.pic.spPr.cx = cx diff --git a/src/docx/text/run.py b/src/docx/text/run.py index daa604e87..0e2f5bc17 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -59,8 +59,8 @@ def add_break(self, break_type: WD_BREAK = WD_BREAK.LINE): def add_picture( self, image_path_or_stream: str | IO[bytes], - width: Length | None = None, - height: Length | None = None, + width: int | Length | None = None, + height: int | Length | None = None, ) -> InlineShape: """Return |InlineShape| containing image identified by `image_path_or_stream`. diff --git a/tests/opc/parts/test_coreprops.py b/tests/opc/parts/test_coreprops.py index 1db650353..5bcf49651 100644 --- a/tests/opc/parts/test_coreprops.py +++ b/tests/opc/parts/test_coreprops.py @@ -5,22 +5,32 @@ import pytest from docx.opc.coreprops import CoreProperties +from docx.opc.package import OpcPackage +from docx.opc.packuri import PackURI from docx.opc.parts.coreprops import CorePropertiesPart -from docx.oxml.coreprops import CT_CoreProperties -from ...unitutil.mock import class_mock, instance_mock +from ...unitutil.cxml import element +from ...unitutil.mock import FixtureRequest, Mock, class_mock, instance_mock class DescribeCorePropertiesPart: - def it_provides_access_to_its_core_props_object(self, coreprops_fixture): - core_properties_part, CoreProperties_ = coreprops_fixture + """Unit-test suite for `docx.opc.parts.coreprops.CorePropertiesPart` objects.""" + + def it_provides_access_to_its_core_props_object(self, CoreProperties_: Mock, package_: Mock): + core_properties_part = CorePropertiesPart( + PackURI("/part/name"), "content/type", element("cp:coreProperties"), package_ + ) + core_properties = core_properties_part.core_properties + CoreProperties_.assert_called_once_with(core_properties_part.element) assert isinstance(core_properties, CoreProperties) - def it_can_create_a_default_core_properties_part(self): - core_properties_part = CorePropertiesPart.default(None) + def it_can_create_a_default_core_properties_part(self, package_: Mock): + core_properties_part = CorePropertiesPart.default(package_) + assert isinstance(core_properties_part, CorePropertiesPart) + # -- core_properties = core_properties_part.core_properties assert core_properties.title == "Word Document" assert core_properties.last_modified_by == "python-docx" @@ -32,16 +42,9 @@ def it_can_create_a_default_core_properties_part(self): # fixtures --------------------------------------------- @pytest.fixture - def coreprops_fixture(self, element_, CoreProperties_): - core_properties_part = CorePropertiesPart(None, None, element_, None) - return core_properties_part, CoreProperties_ - - # fixture components ----------------------------------- - - @pytest.fixture - def CoreProperties_(self, request): + def CoreProperties_(self, request: FixtureRequest): return class_mock(request, "docx.opc.parts.coreprops.CoreProperties") @pytest.fixture - def element_(self, request): - return instance_mock(request, CT_CoreProperties) + def package_(self, request: FixtureRequest): + return instance_mock(request, OpcPackage) diff --git a/tests/opc/test_coreprops.py b/tests/opc/test_coreprops.py index 2978ad5ae..0214cdbdf 100644 --- a/tests/opc/test_coreprops.py +++ b/tests/opc/test_coreprops.py @@ -1,160 +1,153 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for the docx.opc.coreprops module.""" -from datetime import datetime +from __future__ import annotations + +import datetime as dt +from typing import TYPE_CHECKING, cast import pytest from docx.opc.coreprops import CoreProperties from docx.oxml.parser import parse_xml +if TYPE_CHECKING: + from docx.oxml.coreprops import CT_CoreProperties + class DescribeCoreProperties: - def it_knows_the_string_property_values(self, text_prop_get_fixture): - core_properties, prop_name, expected_value = text_prop_get_fixture + """Unit-test suite for `docx.opc.coreprops.CoreProperties` objects.""" + + @pytest.mark.parametrize( + ("prop_name", "expected_value"), + [ + ("author", "python-docx"), + ("category", ""), + ("comments", ""), + ("content_status", "DRAFT"), + ("identifier", "GXS 10.2.1ab"), + ("keywords", "foo bar baz"), + ("language", "US-EN"), + ("last_modified_by", "Steve Canny"), + ("subject", "Spam"), + ("title", "Word Document"), + ("version", "1.2.88"), + ], + ) + def it_knows_the_string_property_values( + self, prop_name: str, expected_value: str, core_properties: CoreProperties + ): actual_value = getattr(core_properties, prop_name) assert actual_value == expected_value - def it_can_change_the_string_property_values(self, text_prop_set_fixture): - core_properties, prop_name, value, expected_xml = text_prop_set_fixture - setattr(core_properties, prop_name, value) - assert core_properties._element.xml == expected_xml - - def it_knows_the_date_property_values(self, date_prop_get_fixture): - core_properties, prop_name, expected_datetime = date_prop_get_fixture - actual_datetime = getattr(core_properties, prop_name) - assert actual_datetime == expected_datetime + @pytest.mark.parametrize( + ("prop_name", "tagname", "value"), + [ + ("author", "dc:creator", "scanny"), + ("category", "cp:category", "silly stories"), + ("comments", "dc:description", "Bar foo to you"), + ("content_status", "cp:contentStatus", "FINAL"), + ("identifier", "dc:identifier", "GT 5.2.xab"), + ("keywords", "cp:keywords", "dog cat moo"), + ("language", "dc:language", "GB-EN"), + ("last_modified_by", "cp:lastModifiedBy", "Billy Bob"), + ("subject", "dc:subject", "Eggs"), + ("title", "dc:title", "Dissertation"), + ("version", "cp:version", "81.2.8"), + ], + ) + def it_can_change_the_string_property_values(self, prop_name: str, tagname: str, value: str): + coreProperties = self.coreProperties(tagname="", str_val="") + core_properties = CoreProperties(cast("CT_CoreProperties", parse_xml(coreProperties))) - def it_can_change_the_date_property_values(self, date_prop_set_fixture): - core_properties, prop_name, value, expected_xml = date_prop_set_fixture setattr(core_properties, prop_name, value) - assert core_properties._element.xml == expected_xml - - def it_knows_the_revision_number(self, revision_get_fixture): - core_properties, expected_revision = revision_get_fixture - assert core_properties.revision == expected_revision - - def it_can_change_the_revision_number(self, revision_set_fixture): - core_properties, revision, expected_xml = revision_set_fixture - core_properties.revision = revision - assert core_properties._element.xml == expected_xml - # fixtures ------------------------------------------------------- + assert core_properties._element.xml == self.coreProperties(tagname, value) - @pytest.fixture( - params=[ - ("created", datetime(2012, 11, 17, 16, 37, 40)), - ("last_printed", datetime(2014, 6, 4, 4, 28)), + @pytest.mark.parametrize( + ("prop_name", "expected_datetime"), + [ + ("created", dt.datetime(2012, 11, 17, 16, 37, 40)), + ("last_printed", dt.datetime(2014, 6, 4, 4, 28)), ("modified", None), - ] + ], ) - def date_prop_get_fixture(self, request, core_properties): - prop_name, expected_datetime = request.param - return core_properties, prop_name, expected_datetime + def it_knows_the_date_property_values( + self, prop_name: str, expected_datetime: dt.datetime, core_properties: CoreProperties + ): + actual_datetime = getattr(core_properties, prop_name) + assert actual_datetime == expected_datetime - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("prop_name", "tagname", "value", "str_val", "attrs"), + [ ( "created", "dcterms:created", - datetime(2001, 2, 3, 4, 5), + dt.datetime(2001, 2, 3, 4, 5), "2001-02-03T04:05:00Z", ' xsi:type="dcterms:W3CDTF"', ), ( "last_printed", "cp:lastPrinted", - datetime(2014, 6, 4, 4), + dt.datetime(2014, 6, 4, 4), "2014-06-04T04:00:00Z", "", ), ( "modified", "dcterms:modified", - datetime(2005, 4, 3, 2, 1), + dt.datetime(2005, 4, 3, 2, 1), "2005-04-03T02:01:00Z", ' xsi:type="dcterms:W3CDTF"', ), - ] + ], ) - def date_prop_set_fixture(self, request): - prop_name, tagname, value, str_val, attrs = request.param - coreProperties = self.coreProperties(None, None) - core_properties = CoreProperties(parse_xml(coreProperties)) + def it_can_change_the_date_property_values( + self, prop_name: str, tagname: str, value: dt.datetime, str_val: str, attrs: str + ): + coreProperties = self.coreProperties(tagname="", str_val="") + core_properties = CoreProperties(cast("CT_CoreProperties", parse_xml(coreProperties))) expected_xml = self.coreProperties(tagname, str_val, attrs) - return core_properties, prop_name, value, expected_xml - @pytest.fixture( - params=[("42", 42), (None, 0), ("foobar", 0), ("-17", 0), ("32.7", 0)] - ) - def revision_get_fixture(self, request): - str_val, expected_revision = request.param - tagname = "" if str_val is None else "cp:revision" - coreProperties = self.coreProperties(tagname, str_val) - core_properties = CoreProperties(parse_xml(coreProperties)) - return core_properties, expected_revision - - @pytest.fixture( - params=[ - (42, "42"), - ] + setattr(core_properties, prop_name, value) + + assert core_properties._element.xml == expected_xml + + @pytest.mark.parametrize( + ("str_val", "expected_value"), + [("42", 42), (None, 0), ("foobar", 0), ("-17", 0), ("32.7", 0)], ) - def revision_set_fixture(self, request): - value, str_val = request.param - coreProperties = self.coreProperties(None, None) - core_properties = CoreProperties(parse_xml(coreProperties)) + def it_knows_the_revision_number(self, str_val: str | None, expected_value: int): + tagname, str_val = ("cp:revision", str_val) if str_val else ("", "") + coreProperties = self.coreProperties(tagname, str_val or "") + core_properties = CoreProperties(cast("CT_CoreProperties", parse_xml(coreProperties))) + + assert core_properties.revision == expected_value + + @pytest.mark.parametrize(("value", "str_val"), [(42, "42")]) + def it_can_change_the_revision_number(self, value: int, str_val: str): + coreProperties = self.coreProperties(tagname="", str_val="") + core_properties = CoreProperties(cast("CT_CoreProperties", parse_xml(coreProperties))) expected_xml = self.coreProperties("cp:revision", str_val) - return core_properties, value, expected_xml - @pytest.fixture( - params=[ - ("author", "python-docx"), - ("category", ""), - ("comments", ""), - ("content_status", "DRAFT"), - ("identifier", "GXS 10.2.1ab"), - ("keywords", "foo bar baz"), - ("language", "US-EN"), - ("last_modified_by", "Steve Canny"), - ("subject", "Spam"), - ("title", "Word Document"), - ("version", "1.2.88"), - ] - ) - def text_prop_get_fixture(self, request, core_properties): - prop_name, expected_value = request.param - return core_properties, prop_name, expected_value + core_properties.revision = value - @pytest.fixture( - params=[ - ("author", "dc:creator", "scanny"), - ("category", "cp:category", "silly stories"), - ("comments", "dc:description", "Bar foo to you"), - ("content_status", "cp:contentStatus", "FINAL"), - ("identifier", "dc:identifier", "GT 5.2.xab"), - ("keywords", "cp:keywords", "dog cat moo"), - ("language", "dc:language", "GB-EN"), - ("last_modified_by", "cp:lastModifiedBy", "Billy Bob"), - ("subject", "dc:subject", "Eggs"), - ("title", "dc:title", "Dissertation"), - ("version", "cp:version", "81.2.8"), - ] - ) - def text_prop_set_fixture(self, request): - prop_name, tagname, value = request.param - coreProperties = self.coreProperties(None, None) - core_properties = CoreProperties(parse_xml(coreProperties)) - expected_xml = self.coreProperties(tagname, value) - return core_properties, prop_name, value, expected_xml + assert core_properties._element.xml == expected_xml - # fixture components --------------------------------------------- + # fixtures ------------------------------------------------------- - def coreProperties(self, tagname, str_val, attrs=""): + def coreProperties(self, tagname: str, str_val: str, attrs: str = "") -> str: tmpl = ( - '%s\n' + "%s\n" ) if not tagname: child_element = "" @@ -166,27 +159,30 @@ def coreProperties(self, tagname, str_val, attrs=""): @pytest.fixture def core_properties(self): - element = parse_xml( - b"" - b'\n\n' - b" DRAFT\n" - b" python-docx\n" - b' 2012-11-17T11:07:' - b"40-05:30\n" - b" \n" - b" GXS 10.2.1ab\n" - b" US-EN\n" - b" 2014-06-04T04:28:00Z\n" - b" foo bar baz\n" - b" Steve Canny\n" - b" 4\n" - b" Spam\n" - b" Word Document\n" - b" 1.2.88\n" - b"\n" + element = cast( + "CT_CoreProperties", + parse_xml( + b"" + b'\n\n' + b" DRAFT\n" + b" python-docx\n" + b' 2012-11-17T11:07:' + b"40-05:30\n" + b" \n" + b" GXS 10.2.1ab\n" + b" US-EN\n" + b" 2014-06-04T04:28:00Z\n" + b" foo bar baz\n" + b" Steve Canny\n" + b" 4\n" + b" Spam\n" + b" Word Document\n" + b" 1.2.88\n" + b"\n" + ), ) return CoreProperties(element) diff --git a/tests/opc/test_part.py b/tests/opc/test_part.py index b156a63f8..dbbcaf262 100644 --- a/tests/opc/test_part.py +++ b/tests/opc/test_part.py @@ -28,99 +28,54 @@ class DescribePart: - def it_can_be_constructed_by_PartFactory( - self, partname_, content_type_, blob_, package_, __init_ - ): - part = Part.load(partname_, content_type_, blob_, package_) + """Unit-test suite for `docx.opc.part.Part` objects.""" - __init_.assert_called_once_with(ANY, partname_, content_type_, blob_, package_) + def it_can_be_constructed_by_PartFactory(self, package_: Mock, init__: Mock): + part = Part.load(PackURI("/part/name"), "content/type", b"1be2", package_) + + init__.assert_called_once_with(ANY, "/part/name", "content/type", b"1be2", package_) assert isinstance(part, Part) - def it_knows_its_partname(self, partname_get_fixture): - part, expected_partname = partname_get_fixture - assert part.partname == expected_partname + def it_knows_its_partname(self): + part = Part(PackURI("/part/name"), "content/type") + assert part.partname == "/part/name" - def it_can_change_its_partname(self, partname_set_fixture): - part, new_partname = partname_set_fixture - part.partname = new_partname - assert part.partname == new_partname + def it_can_change_its_partname(self): + part = Part(PackURI("/old/part/name"), "content/type") + part.partname = PackURI("/new/part/name") + assert part.partname == "/new/part/name" - def it_knows_its_content_type(self, content_type_fixture): - part, expected_content_type = content_type_fixture - assert part.content_type == expected_content_type + def it_knows_its_content_type(self): + part = Part(PackURI("/part/name"), "content/type") + assert part.content_type == "content/type" - def it_knows_the_package_it_belongs_to(self, package_get_fixture): - part, expected_package = package_get_fixture - assert part.package == expected_package + def it_knows_the_package_it_belongs_to(self, package_: Mock): + part = Part(PackURI("/part/name"), "content/type", package=package_) + assert part.package is package_ - def it_can_be_notified_after_unmarshalling_is_complete(self, part): + def it_can_be_notified_after_unmarshalling_is_complete(self): + part = Part(PackURI("/part/name"), "content/type") part.after_unmarshal() - def it_can_be_notified_before_marshalling_is_started(self, part): + def it_can_be_notified_before_marshalling_is_started(self): + part = Part(PackURI("/part/name"), "content/type") part.before_marshal() - def it_uses_the_load_blob_as_its_blob(self, blob_fixture): - part, load_blob = blob_fixture - assert part.blob is load_blob + def it_uses_the_load_blob_as_its_blob(self): + blob = b"abcde" + part = Part(PackURI("/part/name"), "content/type", blob) + assert part.blob is blob # fixtures --------------------------------------------- @pytest.fixture - def blob_fixture(self, blob_): - part = Part(None, None, blob_, None) - return part, blob_ - - @pytest.fixture - def content_type_fixture(self): - content_type = "content/type" - part = Part(None, content_type, None, None) - return part, content_type - - @pytest.fixture - def package_get_fixture(self, package_): - part = Part(None, None, None, package_) - return part, package_ - - @pytest.fixture - def part(self): - part = Part(None, None) - return part - - @pytest.fixture - def partname_get_fixture(self): - partname = PackURI("/part/name") - part = Part(partname, None, None, None) - return part, partname - - @pytest.fixture - def partname_set_fixture(self): - old_partname = PackURI("/old/part/name") - new_partname = PackURI("/new/part/name") - part = Part(old_partname, None, None, None) - return part, new_partname - - # fixture components --------------------------------------------- - - @pytest.fixture - def blob_(self, request): - return instance_mock(request, bytes) - - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def __init_(self, request): + def init__(self, request: FixtureRequest): return initializer_mock(request, Part) @pytest.fixture - def package_(self, request): + def package_(self, request: FixtureRequest): return instance_mock(request, OpcPackage) - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - class DescribePartRelationshipManagementInterface: """Unit-test suite for `docx.opc.package.Part` relationship behaviors.""" From 0ec5dcd1eb1c9483947621040d72573d4d35398a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 30 Apr 2024 11:08:45 -0700 Subject: [PATCH 22/56] fix(pkg): pull lxml pin Looks like this cure is worse than the disease. While it may ease installation on Apple Silicon in some instances, it breaks installation on Python 3.12. Pull this pin and we'll just have to live with troublesome `lxml` install on certain Mac/version combinations. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ad89abd19..8d483f00b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Topic :: Software Development :: Libraries", ] dependencies = [ - "lxml>=3.1.0,<=4.9.2", + "lxml>=3.1.0", "typing_extensions>=4.9.0", ] description = "Create, read, and update Microsoft Word .docx files." From 4cbbdab6cf627309a77ca1b8e201e89c70950340 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 30 Apr 2024 11:28:34 -0700 Subject: [PATCH 23/56] fix: accommodate docxtpl use of Part._rels --- src/docx/opc/part.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/docx/opc/part.py b/src/docx/opc/part.py index e3887ef41..cbb4ab556 100644 --- a/src/docx/opc/part.py +++ b/src/docx/opc/part.py @@ -145,7 +145,9 @@ def related_parts(self): @lazyproperty def rels(self): """|Relationships| instance holding the relationships for this part.""" - return Relationships(self._partname.baseURI) + # -- prevent breakage in `python-docx-template` by retaining legacy `._rels` attribute -- + self._rels = Relationships(self._partname.baseURI) + return self._rels def target_ref(self, rId: str) -> str: """Return URL contained in target ref of relationship identified by `rId`.""" From 0a8e9c40729bc734fa65f354d72d1159c345becc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 1 May 2024 11:10:48 -0700 Subject: [PATCH 24/56] fix: Python 3.12 fixes --- features/steps/coreprops.py | 18 +++++++++--------- pyproject.toml | 11 +++++++++++ pyrightconfig.json | 21 --------------------- src/docx/opc/parts/coreprops.py | 2 +- src/docx/oxml/coreprops.py | 4 ++-- tests/opc/parts/test_coreprops.py | 9 ++++++--- tests/opc/test_coreprops.py | 4 ++-- tox.ini | 2 +- 8 files changed, 32 insertions(+), 39 deletions(-) delete mode 100644 pyrightconfig.json diff --git a/features/steps/coreprops.py b/features/steps/coreprops.py index 0d4e55eb7..90467fb67 100644 --- a/features/steps/coreprops.py +++ b/features/steps/coreprops.py @@ -1,6 +1,6 @@ """Gherkin step implementations for core properties-related features.""" -from datetime import datetime, timedelta +import datetime as dt from behave import given, then, when from behave.runner import Context @@ -38,13 +38,13 @@ def when_I_assign_new_values_to_the_properties(context: Context): ("category", "Category"), ("comments", "Description"), ("content_status", "Content Status"), - ("created", datetime(2013, 6, 15, 12, 34, 56)), + ("created", dt.datetime(2013, 6, 15, 12, 34, 56, tzinfo=dt.timezone.utc)), ("identifier", "Identifier"), ("keywords", "key; word; keyword"), ("language", "Language"), ("last_modified_by", "Last Modified By"), - ("last_printed", datetime(2013, 6, 15, 12, 34, 56)), - ("modified", datetime(2013, 6, 15, 12, 34, 56)), + ("last_printed", dt.datetime(2013, 6, 15, 12, 34, 56, tzinfo=dt.timezone.utc)), + ("modified", dt.datetime(2013, 6, 15, 12, 34, 56, tzinfo=dt.timezone.utc)), ("revision", 9), ("subject", "Subject"), ("title", "Title"), @@ -66,8 +66,8 @@ def then_a_core_properties_part_with_default_values_is_added(context: Context): assert core_properties.revision == 1 # core_properties.modified only stores time with seconds resolution, so # comparison needs to be a little loose (within two seconds) - modified_timedelta = datetime.utcnow() - core_properties.modified - max_expected_timedelta = timedelta(seconds=2) + modified_timedelta = dt.datetime.now(dt.timezone.utc) - core_properties.modified + max_expected_timedelta = dt.timedelta(seconds=2) assert modified_timedelta < max_expected_timedelta @@ -85,13 +85,13 @@ def then_the_core_property_values_match_the_known_values(context: Context): ("category", "Category"), ("comments", "Description"), ("content_status", "Content Status"), - ("created", datetime(2014, 12, 13, 22, 2, 0)), + ("created", dt.datetime(2014, 12, 13, 22, 2, 0, tzinfo=dt.timezone.utc)), ("identifier", "Identifier"), ("keywords", "key; word; keyword"), ("language", "Language"), ("last_modified_by", "Steve Canny"), - ("last_printed", datetime(2014, 12, 13, 22, 2, 42)), - ("modified", datetime(2014, 12, 13, 22, 6, 0)), + ("last_printed", dt.datetime(2014, 12, 13, 22, 2, 42, tzinfo=dt.timezone.utc)), + ("modified", dt.datetime(2014, 12, 13, 22, 6, 0, tzinfo=dt.timezone.utc)), ("revision", 2), ("subject", "Subject"), ("title", "Title"), diff --git a/pyproject.toml b/pyproject.toml index 8d483f00b..91bac83d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,17 @@ Repository = "https://github.com/python-openxml/python-docx" line-length = 100 target-version = ["py37", "py38", "py39", "py310", "py311"] +[tool.pyright] +include = ["src/docx", "tests"] +pythonPlatform = "All" +pythonVersion = "3.8" +reportImportCycles = true +reportUnnecessaryCast = true +reportUnnecessaryTypeIgnoreComment = true +stubPath = "./typings" +typeCheckingMode = "strict" +verboseOutput = true + [tool.pytest.ini_options] filterwarnings = [ # -- exit on any warning not explicitly ignored here -- diff --git a/pyrightconfig.json b/pyrightconfig.json deleted file mode 100644 index 21afeb97b..000000000 --- a/pyrightconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "exclude": [ - "**/__pycache__", - "**/.*" - ], - "ignore": [ - ], - "include": [ - "src/docx", - "tests" - ], - "pythonPlatform": "All", - "pythonVersion": "3.7", - "reportImportCycles": true, - "reportUnnecessaryCast": true, - "reportUnnecessaryTypeIgnoreComment": true, - "stubPath": "./typings", - "typeCheckingMode": "strict", - "useLibraryCodeForTypes": true, - "verboseOutput": true -} diff --git a/src/docx/opc/parts/coreprops.py b/src/docx/opc/parts/coreprops.py index 0d818f18d..fda011218 100644 --- a/src/docx/opc/parts/coreprops.py +++ b/src/docx/opc/parts/coreprops.py @@ -31,7 +31,7 @@ def default(cls, package: OpcPackage): core_properties.title = "Word Document" core_properties.last_modified_by = "python-docx" core_properties.revision = 1 - core_properties.modified = dt.datetime.utcnow() + core_properties.modified = dt.datetime.now(dt.timezone.utc) return core_properties_part @property diff --git a/src/docx/oxml/coreprops.py b/src/docx/oxml/coreprops.py index 93f8890c7..8ba9ff42e 100644 --- a/src/docx/oxml/coreprops.py +++ b/src/docx/oxml/coreprops.py @@ -254,8 +254,8 @@ def _parse_W3CDTF_to_datetime(cls, w3cdtf_str: str) -> dt.datetime: tmpl = "could not parse W3CDTF datetime string '%s'" raise ValueError(tmpl % w3cdtf_str) if len(offset_str) == 6: - return cls._offset_dt(dt_, offset_str) - return dt_ + dt_ = cls._offset_dt(dt_, offset_str) + return dt_.replace(tzinfo=dt.timezone.utc) def _set_element_datetime(self, prop_name: str, value: dt.datetime): """Set date/time value of child element having `prop_name` to `value`.""" diff --git a/tests/opc/parts/test_coreprops.py b/tests/opc/parts/test_coreprops.py index 5bcf49651..b754d2d7e 100644 --- a/tests/opc/parts/test_coreprops.py +++ b/tests/opc/parts/test_coreprops.py @@ -1,6 +1,8 @@ """Unit test suite for the docx.opc.parts.coreprops module.""" -from datetime import datetime, timedelta +from __future__ import annotations + +import datetime as dt import pytest @@ -35,8 +37,9 @@ def it_can_create_a_default_core_properties_part(self, package_: Mock): assert core_properties.title == "Word Document" assert core_properties.last_modified_by == "python-docx" assert core_properties.revision == 1 - delta = datetime.utcnow() - core_properties.modified - max_expected_delta = timedelta(seconds=2) + assert core_properties.modified is not None + delta = dt.datetime.now(dt.timezone.utc) - core_properties.modified + max_expected_delta = dt.timedelta(seconds=2) assert delta < max_expected_delta # fixtures --------------------------------------------- diff --git a/tests/opc/test_coreprops.py b/tests/opc/test_coreprops.py index 0214cdbdf..5d9743397 100644 --- a/tests/opc/test_coreprops.py +++ b/tests/opc/test_coreprops.py @@ -68,8 +68,8 @@ def it_can_change_the_string_property_values(self, prop_name: str, tagname: str, @pytest.mark.parametrize( ("prop_name", "expected_datetime"), [ - ("created", dt.datetime(2012, 11, 17, 16, 37, 40)), - ("last_printed", dt.datetime(2014, 6, 4, 4, 28)), + ("created", dt.datetime(2012, 11, 17, 16, 37, 40, tzinfo=dt.timezone.utc)), + ("last_printed", dt.datetime(2014, 6, 4, 4, 28, tzinfo=dt.timezone.utc)), ("modified", None), ], ) diff --git a/tox.ini b/tox.ini index f8595ba45..37acaa5fa 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38, py39, py310, py311 +envlist = py38, py39, py310, py311, py312 [testenv] deps = -rrequirements-test.txt From 0cf6d71fb47ede07ecd5de2a8655f9f46c5f083d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 1 May 2024 12:31:09 -0700 Subject: [PATCH 25/56] release: prepare v1.1.2 release --- HISTORY.rst | 7 +++++++ src/docx/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 51262c4b3..0dab17d87 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,13 @@ Release History --------------- +1.1.2 (2024-05-01) +++++++++++++++++++ + +- Fix #1383 Revert lxml<=4.9.2 pin that breaks Python 3.12 install +- Fix #1385 Support use of Part._rels by python-docx-template +- Add support and testing for Python 3.12 + 1.1.1 (2024-04-29) ++++++++++++++++++ diff --git a/src/docx/__init__.py b/src/docx/__init__.py index 7a4d0bbe8..205221027 100644 --- a/src/docx/__init__.py +++ b/src/docx/__init__.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from docx.opc.part import Part -__version__ = "1.1.1" +__version__ = "1.1.2" __all__ = ["Document"] From 3228bc581dfc5891dfeb2db338e7c43be015df88 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 6 Jun 2025 11:26:43 -0700 Subject: [PATCH 26/56] proj: modernize project environment Primarily introducing `uv`. Also bump minimum Python to 3.9. - remove reportImportCycles from pyright config because that doesn't respect the `TYPE_CHECKING` flag so doesn't actually report useful cycles in a project like this where some types are recursive. --- .fdignore | 7 + .projections.json | 14 ++ .rgignore | 9 + pyproject.toml | 23 ++- uv.lock | 415 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 460 insertions(+), 8 deletions(-) create mode 100644 .fdignore create mode 100644 .projections.json create mode 100644 .rgignore create mode 100644 uv.lock diff --git a/.fdignore b/.fdignore new file mode 100644 index 000000000..41bdd3828 --- /dev/null +++ b/.fdignore @@ -0,0 +1,7 @@ +.tox +Session.vim +build/ +docs/.build +features/_scratch +__pycache__/ +src/*.egg-info diff --git a/.projections.json b/.projections.json new file mode 100644 index 000000000..7d68dd4c5 --- /dev/null +++ b/.projections.json @@ -0,0 +1,14 @@ +{ + "src/docx/*.py" : { + "alternate" : [ + "tests/{dirname}/test_{basename}.py" + ], + "type" : "source" + }, + "tests/**/test_*.py" : { + "alternate" : [ + "src/docx/{dirname}/{basename}.py" + ], + "type" : "test" + } +} diff --git a/.rgignore b/.rgignore new file mode 100644 index 000000000..12d71b5b4 --- /dev/null +++ b/.rgignore @@ -0,0 +1,9 @@ +.tox +Session.vim +build/ +docs/.build +features/_scratch +__pycache__/ +ref/ +src/*.egg-info +tests/test_files diff --git a/pyproject.toml b/pyproject.toml index 91bac83d5..7c343f2e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dynamic = ["version"] keywords = ["docx", "office", "openxml", "word"] license = { text = "MIT" } readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.9" [project.urls] Changelog = "https://github.com/python-openxml/python-docx/blob/master/HISTORY.rst" @@ -38,20 +38,18 @@ Documentation = "https://python-docx.readthedocs.org/en/latest/" Homepage = "https://github.com/python-openxml/python-docx" Repository = "https://github.com/python-openxml/python-docx" -[tool.black] -line-length = 100 -target-version = ["py37", "py38", "py39", "py310", "py311"] - [tool.pyright] include = ["src/docx", "tests"] pythonPlatform = "All" -pythonVersion = "3.8" -reportImportCycles = true +pythonVersion = "3.9" +reportImportCycles = false reportUnnecessaryCast = true reportUnnecessaryTypeIgnoreComment = true stubPath = "./typings" typeCheckingMode = "strict" verboseOutput = true +venvPath = "." +venv = ".venv" [tool.pytest.ini_options] filterwarnings = [ @@ -88,7 +86,6 @@ target-version = "py38" ignore = [ "COM812", # -- over-aggressively insists on trailing commas where not desired -- "PT001", # -- wants @pytest.fixture() instead of @pytest.fixture -- - "PT005", # -- wants @pytest.fixture() instead of @pytest.fixture -- ] select = [ "C4", # -- flake8-comprehensions -- @@ -111,3 +108,13 @@ known-local-folder = ["helpers"] [tool.setuptools.dynamic] version = {attr = "docx.__version__"} + +[dependency-groups] +dev = [ + "behave>=1.2.6", + "pyparsing>=3.2.3", + "pyright>=1.1.401", + "pytest>=8.4.0", + "ruff>=0.11.13", + "types-lxml-multi-subclass>=2025.3.30", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..bbef867c8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,415 @@ +version = 1 +revision = 1 +requires-python = ">=3.9" + +[[package]] +name = "beautifulsoup4" +version = "4.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285 }, +] + +[[package]] +name = "behave" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "parse-type" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/4b/d0a8c23b6c8985e5544ea96d27105a273ea22051317f850c2cdbf2029fe4/behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", size = 701696 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/6c/ec9169548b6c4cb877aaa6773408ca08ae2a282805b958dbc163cb19822d/behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c", size = 136779 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "cssselect" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/0a/c3ea9573b1dc2e151abfe88c7fe0c26d1892fe6ed02d0cdb30f0d57029d5/cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7", size = 42870 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "lxml" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/1f/a3b6b74a451ceb84b471caa75c934d2430a4d84395d38ef201d539f38cd1/lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c", size = 8076838 }, + { url = "https://files.pythonhosted.org/packages/36/af/a567a55b3e47135b4d1f05a1118c24529104c003f95851374b3748139dc1/lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7", size = 4381827 }, + { url = "https://files.pythonhosted.org/packages/50/ba/4ee47d24c675932b3eb5b6de77d0f623c2db6dc466e7a1f199792c5e3e3a/lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf", size = 5204098 }, + { url = "https://files.pythonhosted.org/packages/f2/0f/b4db6dfebfefe3abafe360f42a3d471881687fd449a0b86b70f1f2683438/lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28", size = 4930261 }, + { url = "https://files.pythonhosted.org/packages/0b/1f/0bb1bae1ce056910f8db81c6aba80fec0e46c98d77c0f59298c70cd362a3/lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609", size = 5529621 }, + { url = "https://files.pythonhosted.org/packages/21/f5/e7b66a533fc4a1e7fa63dd22a1ab2ec4d10319b909211181e1ab3e539295/lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4", size = 4983231 }, + { url = "https://files.pythonhosted.org/packages/11/39/a38244b669c2d95a6a101a84d3c85ba921fea827e9e5483e93168bf1ccb2/lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7", size = 5084279 }, + { url = "https://files.pythonhosted.org/packages/db/64/48cac242347a09a07740d6cee7b7fd4663d5c1abd65f2e3c60420e231b27/lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f", size = 4927405 }, + { url = "https://files.pythonhosted.org/packages/98/89/97442835fbb01d80b72374f9594fe44f01817d203fa056e9906128a5d896/lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997", size = 5550169 }, + { url = "https://files.pythonhosted.org/packages/f1/97/164ca398ee654eb21f29c6b582685c6c6b9d62d5213abc9b8380278e9c0a/lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c", size = 5062691 }, + { url = "https://files.pythonhosted.org/packages/d0/bc/712b96823d7feb53482d2e4f59c090fb18ec7b0d0b476f353b3085893cda/lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b", size = 5133503 }, + { url = "https://files.pythonhosted.org/packages/d4/55/a62a39e8f9da2a8b6002603475e3c57c870cd9c95fd4b94d4d9ac9036055/lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b", size = 4999346 }, + { url = "https://files.pythonhosted.org/packages/ea/47/a393728ae001b92bb1a9e095e570bf71ec7f7fbae7688a4792222e56e5b9/lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563", size = 5627139 }, + { url = "https://files.pythonhosted.org/packages/5e/5f/9dcaaad037c3e642a7ea64b479aa082968de46dd67a8293c541742b6c9db/lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5", size = 5465609 }, + { url = "https://files.pythonhosted.org/packages/a7/0a/ebcae89edf27e61c45023005171d0ba95cb414ee41c045ae4caf1b8487fd/lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776", size = 5192285 }, + { url = "https://files.pythonhosted.org/packages/42/ad/cc8140ca99add7d85c92db8b2354638ed6d5cc0e917b21d36039cb15a238/lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7", size = 3477507 }, + { url = "https://files.pythonhosted.org/packages/e9/39/597ce090da1097d2aabd2f9ef42187a6c9c8546d67c419ce61b88b336c85/lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250", size = 3805104 }, + { url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240 }, + { url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685 }, + { url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164 }, + { url = "https://files.pythonhosted.org/packages/f9/94/bbc66e42559f9d04857071e3b3d0c9abd88579367fd2588a4042f641f57e/lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df", size = 4746206 }, + { url = "https://files.pythonhosted.org/packages/66/95/34b0679bee435da2d7cae895731700e519a8dfcab499c21662ebe671603e/lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e", size = 5342144 }, + { url = "https://files.pythonhosted.org/packages/e0/5d/abfcc6ab2fa0be72b2ba938abdae1f7cad4c632f8d552683ea295d55adfb/lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44", size = 4825124 }, + { url = "https://files.pythonhosted.org/packages/5a/78/6bd33186c8863b36e084f294fc0a5e5eefe77af95f0663ef33809cc1c8aa/lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba", size = 4876520 }, + { url = "https://files.pythonhosted.org/packages/3b/74/4d7ad4839bd0fc64e3d12da74fc9a193febb0fae0ba6ebd5149d4c23176a/lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba", size = 4765016 }, + { url = "https://files.pythonhosted.org/packages/24/0d/0a98ed1f2471911dadfc541003ac6dd6879fc87b15e1143743ca20f3e973/lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c", size = 5362884 }, + { url = "https://files.pythonhosted.org/packages/48/de/d4f7e4c39740a6610f0f6959052b547478107967362e8424e1163ec37ae8/lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8", size = 4902690 }, + { url = "https://files.pythonhosted.org/packages/07/8c/61763abd242af84f355ca4ef1ee096d3c1b7514819564cce70fd18c22e9a/lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86", size = 4944418 }, + { url = "https://files.pythonhosted.org/packages/f9/c5/6d7e3b63e7e282619193961a570c0a4c8a57fe820f07ca3fe2f6bd86608a/lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056", size = 4827092 }, + { url = "https://files.pythonhosted.org/packages/71/4a/e60a306df54680b103348545706a98a7514a42c8b4fbfdcaa608567bb065/lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7", size = 5418231 }, + { url = "https://files.pythonhosted.org/packages/27/f2/9754aacd6016c930875854f08ac4b192a47fe19565f776a64004aa167521/lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd", size = 5261798 }, + { url = "https://files.pythonhosted.org/packages/38/a2/0c49ec6941428b1bd4f280650d7b11a0f91ace9db7de32eb7aa23bcb39ff/lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751", size = 4988195 }, + { url = "https://files.pythonhosted.org/packages/7a/75/87a3963a08eafc46a86c1131c6e28a4de103ba30b5ae903114177352a3d7/lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4", size = 3474243 }, + { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197 }, + { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392 }, + { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103 }, + { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224 }, + { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913 }, + { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441 }, + { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580 }, + { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493 }, + { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679 }, + { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691 }, + { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075 }, + { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680 }, + { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253 }, + { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651 }, + { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315 }, + { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149 }, + { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095 }, + { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086 }, + { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613 }, + { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008 }, + { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915 }, + { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890 }, + { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644 }, + { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817 }, + { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916 }, + { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274 }, + { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757 }, + { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028 }, + { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487 }, + { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688 }, + { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043 }, + { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569 }, + { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270 }, + { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606 }, + { url = "https://files.pythonhosted.org/packages/1e/04/acd238222ea25683e43ac7113facc380b3aaf77c53e7d88c4f544cef02ca/lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e", size = 8082189 }, + { url = "https://files.pythonhosted.org/packages/d6/4e/cc7fe9ccb9999cc648492ce970b63c657606aefc7d0fba46b17aa2ba93fb/lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40", size = 4384950 }, + { url = "https://files.pythonhosted.org/packages/56/bf/acd219c489346d0243a30769b9d446b71e5608581db49a18c8d91a669e19/lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729", size = 5209823 }, + { url = "https://files.pythonhosted.org/packages/57/51/ec31cd33175c09aa7b93d101f56eed43d89e15504455d884d021df7166a7/lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87", size = 4931808 }, + { url = "https://files.pythonhosted.org/packages/e5/68/865d229f191514da1777125598d028dc88a5ea300d68c30e1f120bfd01bd/lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd", size = 5086067 }, + { url = "https://files.pythonhosted.org/packages/82/01/4c958c5848b4e263cd9e83dff6b49f975a5a0854feb1070dfe0bdcdf70a0/lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433", size = 4929026 }, + { url = "https://files.pythonhosted.org/packages/55/31/5327d8af74d7f35e645b40ae6658761e1fee59ebecaa6a8d295e495c2ca9/lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140", size = 5134245 }, + { url = "https://files.pythonhosted.org/packages/6f/c9/204eba2400beb0016dacc2c5335ecb1e37f397796683ffdb7f471e86bddb/lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5", size = 5001020 }, + { url = "https://files.pythonhosted.org/packages/07/53/979165f50a853dab1cf3b9e53105032d55f85c5993f94afc4d9a61a22877/lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142", size = 5192346 }, + { url = "https://files.pythonhosted.org/packages/17/2b/f37b5ae28949143f863ba3066b30eede6107fc9a503bd0d01677d4e2a1e0/lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6", size = 3478275 }, + { url = "https://files.pythonhosted.org/packages/9a/d5/b795a183680126147665a8eeda8e802c180f2f7661aa9a550bba5bcdae63/lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1", size = 3806275 }, + { url = "https://files.pythonhosted.org/packages/c6/b0/e4d1cbb8c078bc4ae44de9c6a79fec4e2b4151b1b4d50af71d799e76b177/lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55", size = 3892319 }, + { url = "https://files.pythonhosted.org/packages/5b/aa/e2bdefba40d815059bcb60b371a36fbfcce970a935370e1b367ba1cc8f74/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740", size = 4211614 }, + { url = "https://files.pythonhosted.org/packages/3c/5f/91ff89d1e092e7cfdd8453a939436ac116db0a665e7f4be0cd8e65c7dc5a/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5", size = 4306273 }, + { url = "https://files.pythonhosted.org/packages/be/7c/8c3f15df2ca534589717bfd19d1e3482167801caedfa4d90a575facf68a6/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37", size = 4208552 }, + { url = "https://files.pythonhosted.org/packages/7d/d8/9567afb1665f64d73fc54eb904e418d1138d7f011ed00647121b4dd60b38/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571", size = 4331091 }, + { url = "https://files.pythonhosted.org/packages/f1/ab/fdbbd91d8d82bf1a723ba88ec3e3d76c022b53c391b0c13cad441cdb8f9e/lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4", size = 3487862 }, + { url = "https://files.pythonhosted.org/packages/ad/fb/d19b67e4bb63adc20574ba3476cf763b3514df1a37551084b890254e4b15/lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530", size = 3891034 }, + { url = "https://files.pythonhosted.org/packages/c9/5d/6e1033ee0cdb2f9bc93164f9df14e42cb5bbf1bbed3bf67f687de2763104/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6", size = 4207420 }, + { url = "https://files.pythonhosted.org/packages/f3/4b/23ac79efc32d913259d66672c5f93daac7750a3d97cdc1c1a9a5d1c1b46c/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877", size = 4305106 }, + { url = "https://files.pythonhosted.org/packages/a4/7a/fe558bee63a62f7a75a52111c0a94556c1c1bdcf558cd7d52861de558759/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8", size = 4205587 }, + { url = "https://files.pythonhosted.org/packages/ed/5b/3207e6bd8d67c952acfec6bac9d1fa0ee353202e7c40b335ebe00879ab7d/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d", size = 4329077 }, + { url = "https://files.pythonhosted.org/packages/a1/25/d381abcfd00102d3304aa191caab62f6e3bcbac93ee248771db6be153dfd/lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987", size = 3486416 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "parse" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126 }, +] + +[[package]] +name = "parse-type" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/e9/a3b2ae5f8a852542788ac1f1865dcea0c549cc40af243f42cabfa0acf24d/parse_type-0.6.4.tar.gz", hash = "sha256:5e1ec10440b000c3f818006033372939e693a9ec0176f446d9303e4db88489a6", size = 96480 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/b3/f6cc950042bfdbe98672e7c834d930f85920fb7d3359f59096e8d2799617/parse_type-0.6.4-py2.py3-none-any.whl", hash = "sha256:83d41144a82d6b8541127bf212dd76c7f01baff680b498ce8a4d052a7a5bce4c", size = 27442 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pyparsing" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 }, +] + +[[package]] +name = "pyright" +version = "1.1.401" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/9a/7ab2b333b921b2d6bfcffe05a0e0a0bbeff884bd6fb5ed50cd68e2898e53/pyright-1.1.401.tar.gz", hash = "sha256:788a82b6611fa5e34a326a921d86d898768cddf59edde8e93e56087d277cc6f1", size = 3894193 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/e6/1f908fce68b0401d41580e0f9acc4c3d1b248adcff00dfaad75cd21a1370/pyright-1.1.401-py3-none-any.whl", hash = "sha256:6fde30492ba5b0d7667c16ecaf6c699fab8d7a1263f6a18549e0b00bf7724c06", size = 5629193 }, +] + +[[package]] +name = "pytest" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797 }, +] + +[[package]] +name = "python-docx" +source = { editable = "." } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +dev = [ + { name = "behave" }, + { name = "pyparsing" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-lxml-multi-subclass" }, +] + +[package.metadata] +requires-dist = [ + { name = "lxml", specifier = ">=3.1.0" }, + { name = "typing-extensions", specifier = ">=4.9.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "behave", specifier = ">=1.2.6" }, + { name = "pyparsing", specifier = ">=3.2.3" }, + { name = "pyright", specifier = ">=1.1.401" }, + { name = "pytest", specifier = ">=8.4.0" }, + { name = "ruff", specifier = ">=0.11.13" }, + { name = "types-lxml-multi-subclass", specifier = ">=2025.3.30" }, +] + +[[package]] +name = "ruff" +version = "0.11.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516 }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083 }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024 }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324 }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416 }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197 }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615 }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080 }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315 }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640 }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364 }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462 }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028 }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992 }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944 }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669 }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "soupsieve" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "types-html5lib" +version = "1.1.11.20250516" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ed/9f092ff479e2b5598941855f314a22953bb04b5fb38bcba3f880feb833ba/types_html5lib-1.1.11.20250516.tar.gz", hash = "sha256:65043a6718c97f7d52567cc0cdf41efbfc33b1f92c6c0c5e19f60a7ec69ae720", size = 16136 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/3b/cb5b23c7b51bf48b8c9f175abb9dce2f1ecd2d2c25f92ea9f4e3720e9398/types_html5lib-1.1.11.20250516-py3-none-any.whl", hash = "sha256:5e407b14b1bd2b9b1107cbd1e2e19d4a0c46d60febd231c7ab7313d7405663c1", size = 21770 }, +] + +[[package]] +name = "types-lxml-multi-subclass" +version = "2025.3.30" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "cssselect" }, + { name = "types-html5lib" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/3a/7f6d1d3b921404efef20ed1ddc2b6f1333e3f0bc5b91da37874e786ff835/types_lxml_multi_subclass-2025.3.30.tar.gz", hash = "sha256:7ac7a78e592fdba16951668968b21511adda49bbefbc0f130e55501b70e068b4", size = 153188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/8e/106b4c5a67e6d52475ef51008e6c27d4ad472690d619dc32e079d28a540b/types_lxml_multi_subclass-2025.3.30-py3-none-any.whl", hash = "sha256:b0563e4e49e66eb8093c44e74b262c59e3be6d3bb3437511e3a4843fd74044d1", size = 93475 }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839 }, +] From 4262f4de7985ab5647b364c397ed038e00560098 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 6 Jun 2025 15:26:58 -0700 Subject: [PATCH 27/56] modn: improve type annotation --- src/docx/opc/coreprops.py | 9 +++++---- src/docx/opc/oxml.py | 25 ++++++++++++------------- src/docx/oxml/__init__.py | 1 + src/docx/oxml/coreprops.py | 32 ++++++++++++++++---------------- src/docx/oxml/ns.py | 4 ++-- src/docx/oxml/shape.py | 6 ++---- src/docx/oxml/text/parfmt.py | 5 +++++ src/docx/oxml/xmlchemy.py | 24 +++++++----------------- src/docx/parts/document.py | 2 +- 9 files changed, 51 insertions(+), 57 deletions(-) diff --git a/src/docx/opc/coreprops.py b/src/docx/opc/coreprops.py index c564550d4..62f0c5ab1 100644 --- a/src/docx/opc/coreprops.py +++ b/src/docx/opc/coreprops.py @@ -5,6 +5,7 @@ from __future__ import annotations +import datetime as dt from typing import TYPE_CHECKING from docx.oxml.coreprops import CT_CoreProperties @@ -57,7 +58,7 @@ def created(self): return self._element.created_datetime @created.setter - def created(self, value): + def created(self, value: dt.datetime): self._element.created_datetime = value @property @@ -97,7 +98,7 @@ def last_printed(self): return self._element.lastPrinted_datetime @last_printed.setter - def last_printed(self, value): + def last_printed(self, value: dt.datetime): self._element.lastPrinted_datetime = value @property @@ -105,7 +106,7 @@ def modified(self): return self._element.modified_datetime @modified.setter - def modified(self, value): + def modified(self, value: dt.datetime): self._element.modified_datetime = value @property @@ -113,7 +114,7 @@ def revision(self): return self._element.revision_number @revision.setter - def revision(self, value): + def revision(self, value: int): self._element.revision_number = value @property diff --git a/src/docx/opc/oxml.py b/src/docx/opc/oxml.py index 7da72f50d..7d3c489d6 100644 --- a/src/docx/opc/oxml.py +++ b/src/docx/opc/oxml.py @@ -38,7 +38,7 @@ def parse_xml(text: str) -> etree._Element: return etree.fromstring(text, oxml_parser) -def qn(tag): +def qn(tag: str) -> str: """Stands for "qualified name", a utility function to turn a namespace prefixed tag name into a Clark-notation qualified tag name for lxml. @@ -50,7 +50,7 @@ def qn(tag): return "{%s}%s" % (uri, tagroot) -def serialize_part_xml(part_elm: etree._Element): +def serialize_part_xml(part_elm: etree._Element) -> bytes: """Serialize `part_elm` etree element to XML suitable for storage as an XML part. That is to say, no insignificant whitespace added for readability, and an @@ -59,7 +59,7 @@ def serialize_part_xml(part_elm: etree._Element): return etree.tostring(part_elm, encoding="UTF-8", standalone=True) -def serialize_for_reading(element): +def serialize_for_reading(element: etree._Element) -> str: """Serialize `element` to human-readable XML suitable for tests. No XML declaration. @@ -77,7 +77,7 @@ class BaseOxmlElement(etree.ElementBase): classes in one place.""" @property - def xml(self): + def xml(self) -> str: """Return XML string for this element, suitable for testing purposes. Pretty printed for readability and without an XML declaration at the top. @@ -86,8 +86,10 @@ def xml(self): class CT_Default(BaseOxmlElement): - """```` element, specifying the default content type to be applied to a - part with the specified extension.""" + """`` element that appears in `[Content_Types].xml` part. + + Used to specify a default content type to be applied to any part with the specified extension. + """ @property def content_type(self): @@ -101,9 +103,8 @@ def extension(self): return self.get("Extension") @staticmethod - def new(ext, content_type): - """Return a new ```` element with attributes set to parameter - values.""" + def new(ext: str, content_type: str): + """Return a new ```` element with attributes set to parameter values.""" xml = '' % nsmap["ct"] default = parse_xml(xml) default.set("Extension", ext) @@ -123,8 +124,7 @@ def content_type(self): @staticmethod def new(partname, content_type): - """Return a new ```` element with attributes set to parameter - values.""" + """Return a new ```` element with attributes set to parameter values.""" xml = '' % nsmap["ct"] override = parse_xml(xml) override.set("PartName", partname) @@ -138,8 +138,7 @@ def partname(self): class CT_Relationship(BaseOxmlElement): - """```` element, representing a single relationship from a source to a - target part.""" + """`` element, representing a single relationship from source to target part.""" @staticmethod def new(rId: str, reltype: str, target: str, target_mode: str = RTM.INTERNAL): diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index bf32932f9..3fbc114ae 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -234,6 +234,7 @@ register_element_cls("w:jc", CT_Jc) register_element_cls("w:keepLines", CT_OnOff) register_element_cls("w:keepNext", CT_OnOff) +register_element_cls("w:outlineLvl", CT_DecimalNumber) register_element_cls("w:pageBreakBefore", CT_OnOff) register_element_cls("w:pPr", CT_PPr) register_element_cls("w:pStyle", CT_String) diff --git a/src/docx/oxml/coreprops.py b/src/docx/oxml/coreprops.py index 8ba9ff42e..fcff0c7ba 100644 --- a/src/docx/oxml/coreprops.py +++ b/src/docx/oxml/coreprops.py @@ -4,7 +4,7 @@ import datetime as dt import re -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, cast from docx.oxml.ns import nsdecls, qn from docx.oxml.parser import parse_xml @@ -45,14 +45,14 @@ class CT_CoreProperties(BaseOxmlElement): _coreProperties_tmpl = "\n" % nsdecls("cp", "dc", "dcterms") @classmethod - def new(cls): + def new(cls) -> CT_CoreProperties: """Return a new `` element.""" xml = cls._coreProperties_tmpl - coreProperties = parse_xml(xml) + coreProperties = cast(CT_CoreProperties, parse_xml(xml)) return coreProperties @property - def author_text(self): + def author_text(self) -> str: """The text in the `dc:creator` child element.""" return self._text_of_element("creator") @@ -77,7 +77,7 @@ def comments_text(self, value: str): self._set_element_text("description", value) @property - def contentStatus_text(self): + def contentStatus_text(self) -> str: return self._text_of_element("contentStatus") @contentStatus_text.setter @@ -85,7 +85,7 @@ def contentStatus_text(self, value: str): self._set_element_text("contentStatus", value) @property - def created_datetime(self): + def created_datetime(self) -> dt.datetime | None: return self._datetime_of_element("created") @created_datetime.setter @@ -93,7 +93,7 @@ def created_datetime(self, value: dt.datetime): self._set_element_datetime("created", value) @property - def identifier_text(self): + def identifier_text(self) -> str: return self._text_of_element("identifier") @identifier_text.setter @@ -101,7 +101,7 @@ def identifier_text(self, value: str): self._set_element_text("identifier", value) @property - def keywords_text(self): + def keywords_text(self) -> str: return self._text_of_element("keywords") @keywords_text.setter @@ -109,7 +109,7 @@ def keywords_text(self, value: str): self._set_element_text("keywords", value) @property - def language_text(self): + def language_text(self) -> str: return self._text_of_element("language") @language_text.setter @@ -117,7 +117,7 @@ def language_text(self, value: str): self._set_element_text("language", value) @property - def lastModifiedBy_text(self): + def lastModifiedBy_text(self) -> str: return self._text_of_element("lastModifiedBy") @lastModifiedBy_text.setter @@ -125,7 +125,7 @@ def lastModifiedBy_text(self, value: str): self._set_element_text("lastModifiedBy", value) @property - def lastPrinted_datetime(self): + def lastPrinted_datetime(self) -> dt.datetime | None: return self._datetime_of_element("lastPrinted") @lastPrinted_datetime.setter @@ -141,7 +141,7 @@ def modified_datetime(self, value: dt.datetime): self._set_element_datetime("modified", value) @property - def revision_number(self): + def revision_number(self) -> int: """Integer value of revision property.""" revision = self.revision if revision is None: @@ -167,7 +167,7 @@ def revision_number(self, value: int): revision.text = str(value) @property - def subject_text(self): + def subject_text(self) -> str: return self._text_of_element("subject") @subject_text.setter @@ -175,7 +175,7 @@ def subject_text(self, value: str): self._set_element_text("subject", value) @property - def title_text(self): + def title_text(self) -> str: return self._text_of_element("title") @title_text.setter @@ -183,7 +183,7 @@ def title_text(self, value: str): self._set_element_text("title", value) @property - def version_text(self): + def version_text(self) -> str: return self._text_of_element("version") @version_text.setter @@ -257,7 +257,7 @@ def _parse_W3CDTF_to_datetime(cls, w3cdtf_str: str) -> dt.datetime: dt_ = cls._offset_dt(dt_, offset_str) return dt_.replace(tzinfo=dt.timezone.utc) - def _set_element_datetime(self, prop_name: str, value: dt.datetime): + def _set_element_datetime(self, prop_name: str, value: dt.datetime) -> None: """Set date/time value of child element having `prop_name` to `value`.""" if not isinstance(value, dt.datetime): # pyright: ignore[reportUnnecessaryIsInstance] tmpl = "property requires object, got %s" diff --git a/src/docx/oxml/ns.py b/src/docx/oxml/ns.py index 5bed1e6a0..ce03940f7 100644 --- a/src/docx/oxml/ns.py +++ b/src/docx/oxml/ns.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict +from typing import Dict nsmap = { "a": "http://schemas.openxmlformats.org/drawingml/2006/main", @@ -29,7 +29,7 @@ class NamespacePrefixedTag(str): """Value object that knows the semantics of an XML tag having a namespace prefix.""" - def __new__(cls, nstag: str, *args: Any): + def __new__(cls, nstag: str): return super(NamespacePrefixedTag, cls).__new__(cls, nstag) def __init__(self, nstag: str): diff --git a/src/docx/oxml/shape.py b/src/docx/oxml/shape.py index 289d35579..00e7593a9 100644 --- a/src/docx/oxml/shape.py +++ b/src/docx/oxml/shape.py @@ -145,10 +145,8 @@ class CT_Picture(BaseOxmlElement): spPr: CT_ShapeProperties = OneAndOnlyOne("pic:spPr") # pyright: ignore[reportAssignmentType] @classmethod - def new(cls, pic_id, filename, rId, cx, cy): - """Return a new ```` element populated with the minimal contents - required to define a viable picture element, based on the values passed as - parameters.""" + def new(cls, pic_id: int, filename: str, rId: str, cx: Length, cy: Length) -> CT_Picture: + """A new minimum viable `` (picture) element.""" pic = parse_xml(cls._pic_xml()) pic.nvPicPr.cNvPr.id = pic_id pic.nvPicPr.cNvPr.name = filename diff --git a/src/docx/oxml/text/parfmt.py b/src/docx/oxml/text/parfmt.py index de5609636..2133686b2 100644 --- a/src/docx/oxml/text/parfmt.py +++ b/src/docx/oxml/text/parfmt.py @@ -10,6 +10,7 @@ WD_TAB_ALIGNMENT, WD_TAB_LEADER, ) +from docx.oxml.shared import CT_DecimalNumber from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure from docx.oxml.xmlchemy import ( BaseOxmlElement, @@ -55,6 +56,7 @@ class CT_PPr(BaseOxmlElement): get_or_add_ind: Callable[[], CT_Ind] get_or_add_pStyle: Callable[[], CT_String] + get_or_add_sectPr: Callable[[], CT_SectPr] _insert_sectPr: Callable[[CT_SectPr], None] _remove_pStyle: Callable[[], None] _remove_sectPr: Callable[[], None] @@ -111,6 +113,9 @@ class CT_PPr(BaseOxmlElement): "w:ind", successors=_tag_seq[23:] ) jc = ZeroOrOne("w:jc", successors=_tag_seq[27:]) + outlineLvl: CT_DecimalNumber = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:outlineLvl", successors=_tag_seq[31:] + ) sectPr = ZeroOrOne("w:sectPr", successors=_tag_seq[35:]) del _tag_seq diff --git a/src/docx/oxml/xmlchemy.py b/src/docx/oxml/xmlchemy.py index 077bcd583..bc33e1f58 100644 --- a/src/docx/oxml/xmlchemy.py +++ b/src/docx/oxml/xmlchemy.py @@ -5,17 +5,7 @@ from __future__ import annotations import re -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Dict, - List, - Sequence, - Tuple, - Type, - TypeVar, -) +from typing import TYPE_CHECKING, Any, Callable, Sequence, Type, TypeVar from lxml import etree from lxml.etree import ElementBase, _Element # pyright: ignore[reportPrivateUsage] @@ -65,7 +55,7 @@ def __eq__(self, other: object) -> bool: def __ne__(self, other: object) -> bool: return not self.__eq__(other) - def _attr_seq(self, attrs: str) -> List[str]: + def _attr_seq(self, attrs: str) -> list[str]: """Return a sequence of attribute strings parsed from `attrs`. Each attribute string is stripped of whitespace on both ends. @@ -90,7 +80,7 @@ def _eq_elm_strs(self, line: str, line_2: str): return True @classmethod - def _parse_line(cls, line: str) -> Tuple[str, str, str, str]: + def _parse_line(cls, line: str) -> tuple[str, str, str, str]: """(front, attrs, close, text) 4-tuple result of parsing XML element `line`.""" match = cls._xml_elm_line_patt.match(line) if match is None: @@ -105,7 +95,7 @@ def _parse_line(cls, line: str) -> Tuple[str, str, str, str]: class MetaOxmlElement(type): """Metaclass for BaseOxmlElement.""" - def __init__(cls, clsname: str, bases: Tuple[type, ...], namespace: Dict[str, Any]): + def __init__(cls, clsname: str, bases: tuple[type, ...], namespace: dict[str, Any]): dispatchable = ( OneAndOnlyOne, OneOrMore, @@ -280,7 +270,7 @@ class _BaseChildElement: and ZeroOrMore. """ - def __init__(self, nsptagname: str, successors: Tuple[str, ...] = ()): + def __init__(self, nsptagname: str, successors: tuple[str, ...] = ()): super(_BaseChildElement, self).__init__() self._nsptagname = nsptagname self._successors = successors @@ -446,7 +436,7 @@ def populate_class_members( # pyright: ignore[reportIncompatibleMethodOverride] self, element_cls: MetaOxmlElement, group_prop_name: str, - successors: Tuple[str, ...], + successors: tuple[str, ...], ) -> None: """Add the appropriate methods to `element_cls`.""" self._element_cls = element_cls @@ -597,7 +587,7 @@ class ZeroOrOneChoice(_BaseChildElement): """Correspondes to an ``EG_*`` element group where at most one of its members may appear as a child.""" - def __init__(self, choices: Sequence[Choice], successors: Tuple[str, ...] = ()): + def __init__(self, choices: Sequence[Choice], successors: tuple[str, ...] = ()): self._choices = choices self._successors = successors diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 416bb1a27..648b58aa2 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -5,7 +5,6 @@ from typing import IO, TYPE_CHECKING, cast from docx.document import Document -from docx.enum.style import WD_STYLE_TYPE from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.numbering import NumberingPart @@ -16,6 +15,7 @@ from docx.shared import lazyproperty if TYPE_CHECKING: + from docx.enum.style import WD_STYLE_TYPE from docx.opc.coreprops import CoreProperties from docx.settings import Settings from docx.styles.style import BaseStyle From afa670a9716b1ac64e6e312ac7ab5efef01ec84a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 6 Jun 2025 11:33:58 -0700 Subject: [PATCH 28/56] modn: modernize tests - inline single-test fixtures - remove obsolete pre-cxml XML builders --- src/docx/dml/color.py | 88 ++++--- src/docx/document.py | 10 +- src/docx/enum/base.py | 10 +- src/docx/opc/package.py | 7 +- src/docx/oxml/text/font.py | 63 ++--- src/docx/parts/document.py | 7 +- src/docx/parts/numbering.py | 5 +- src/docx/shared.py | 8 +- src/docx/text/run.py | 2 +- tests/dml/test_color.py | 149 ++++++------ tests/oxml/unitdata/dml.py | 63 ----- tests/parts/test_document.py | 266 ++++++++++++-------- tests/test_api.py | 67 +++-- tests/test_blkcntnr.py | 172 +++++++------ tests/test_document.py | 410 +++++++++++++++---------------- tests/test_package.py | 120 +++++---- tests/test_section.py | 156 +++++------- tests/test_settings.py | 67 ++--- tests/test_shape.py | 269 ++++++++------------ tests/test_shared.py | 128 +++++----- tests/text/test_run.py | 460 +++++++++++++++++------------------ 21 files changed, 1180 insertions(+), 1347 deletions(-) delete mode 100644 tests/oxml/unitdata/dml.py diff --git a/src/docx/dml/color.py b/src/docx/dml/color.py index d7ee0a21c..a8322d21a 100644 --- a/src/docx/dml/color.py +++ b/src/docx/dml/color.py @@ -1,83 +1,95 @@ """DrawingML objects related to color, ColorFormat being the most prominent.""" -from ..enum.dml import MSO_COLOR_TYPE -from ..oxml.simpletypes import ST_HexColorAuto -from ..shared import ElementProxy +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from typing_extensions import TypeAlias + +from docx.enum.dml import MSO_COLOR_TYPE +from docx.oxml.simpletypes import ST_HexColorAuto +from docx.shared import ElementProxy, RGBColor + +if TYPE_CHECKING: + from docx.enum.dml import MSO_THEME_COLOR + from docx.oxml.text.font import CT_Color + from docx.oxml.text.run import CT_R + +# -- other element types can be a parent of an `w:rPr` element, but for now only `w:r` is -- +RPrParent: TypeAlias = "CT_R" class ColorFormat(ElementProxy): - """Provides access to color settings such as RGB color, theme color, and luminance - adjustments.""" + """Provides access to color settings like RGB color, theme color, and luminance adjustments.""" - def __init__(self, rPr_parent): + def __init__(self, rPr_parent: RPrParent): super(ColorFormat, self).__init__(rPr_parent) + self._element = rPr_parent @property - def rgb(self): + def rgb(self) -> RGBColor | None: """An |RGBColor| value or |None| if no RGB color is specified. - When :attr:`type` is `MSO_COLOR_TYPE.RGB`, the value of this property will - always be an |RGBColor| value. It may also be an |RGBColor| value if - :attr:`type` is `MSO_COLOR_TYPE.THEME`, as Word writes the current value of a - theme color when one is assigned. In that case, the RGB value should be - interpreted as no more than a good guess however, as the theme color takes - precedence at rendering time. Its value is |None| whenever :attr:`type` is - either |None| or `MSO_COLOR_TYPE.AUTO`. - - Assigning an |RGBColor| value causes :attr:`type` to become `MSO_COLOR_TYPE.RGB` - and any theme color is removed. Assigning |None| causes any color to be removed - such that the effective color is inherited from the style hierarchy. + When :attr:`type` is `MSO_COLOR_TYPE.RGB`, the value of this property will always be an + |RGBColor| value. It may also be an |RGBColor| value if :attr:`type` is + `MSO_COLOR_TYPE.THEME`, as Word writes the current value of a theme color when one is + assigned. In that case, the RGB value should be interpreted as no more than a good guess + however, as the theme color takes precedence at rendering time. Its value is |None| + whenever :attr:`type` is either |None| or `MSO_COLOR_TYPE.AUTO`. + + Assigning an |RGBColor| value causes :attr:`type` to become `MSO_COLOR_TYPE.RGB` and any + theme color is removed. Assigning |None| causes any color to be removed such that the + effective color is inherited from the style hierarchy. """ color = self._color if color is None: return None if color.val == ST_HexColorAuto.AUTO: return None - return color.val + return cast(RGBColor, color.val) @rgb.setter - def rgb(self, value): + def rgb(self, value: RGBColor | None): if value is None and self._color is None: return rPr = self._element.get_or_add_rPr() - rPr._remove_color() + rPr._remove_color() # pyright: ignore[reportPrivateUsage] if value is not None: rPr.get_or_add_color().val = value @property - def theme_color(self): + def theme_color(self) -> MSO_THEME_COLOR | None: """Member of :ref:`MsoThemeColorIndex` or |None| if no theme color is specified. - When :attr:`type` is `MSO_COLOR_TYPE.THEME`, the value of this property will - always be a member of :ref:`MsoThemeColorIndex`. When :attr:`type` has any other - value, the value of this property is |None|. + When :attr:`type` is `MSO_COLOR_TYPE.THEME`, the value of this property will always be a + member of :ref:`MsoThemeColorIndex`. When :attr:`type` has any other value, the value of + this property is |None|. Assigning a member of :ref:`MsoThemeColorIndex` causes :attr:`type` to become - `MSO_COLOR_TYPE.THEME`. Any existing RGB value is retained but ignored by Word. - Assigning |None| causes any color specification to be removed such that the - effective color is inherited from the style hierarchy. + `MSO_COLOR_TYPE.THEME`. Any existing RGB value is retained but ignored by Word. Assigning + |None| causes any color specification to be removed such that the effective color is + inherited from the style hierarchy. """ color = self._color - if color is None or color.themeColor is None: + if color is None: return None return color.themeColor @theme_color.setter - def theme_color(self, value): + def theme_color(self, value: MSO_THEME_COLOR | None): if value is None: - if self._color is not None: - self._element.rPr._remove_color() + if self._color is not None and self._element.rPr is not None: + self._element.rPr._remove_color() # pyright: ignore[reportPrivateUsage] return self._element.get_or_add_rPr().get_or_add_color().themeColor = value @property - def type(self) -> MSO_COLOR_TYPE: + def type(self) -> MSO_COLOR_TYPE | None: """Read-only. - A member of :ref:`MsoColorType`, one of RGB, THEME, or AUTO, corresponding to - the way this color is defined. Its value is |None| if no color is applied at - this level, which causes the effective color to be inherited from the style - hierarchy. + A member of :ref:`MsoColorType`, one of RGB, THEME, or AUTO, corresponding to the way this + color is defined. Its value is |None| if no color is applied at this level, which causes + the effective color to be inherited from the style hierarchy. """ color = self._color if color is None: @@ -89,7 +101,7 @@ def type(self) -> MSO_COLOR_TYPE: return MSO_COLOR_TYPE.RGB @property - def _color(self): + def _color(self) -> CT_Color | None: """Return `w:rPr/w:color` or |None| if not present. Helper to factor out repetitive element access. diff --git a/src/docx/document.py b/src/docx/document.py index 8944a0e50..2cf0a1c38 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -11,14 +11,13 @@ from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK from docx.section import Section, Sections -from docx.shared import ElementProxy, Emu +from docx.shared import ElementProxy, Emu, Inches, Length if TYPE_CHECKING: import docx.types as t from docx.oxml.document import CT_Body, CT_Document from docx.parts.document import DocumentPart from docx.settings import Settings - from docx.shared import Length from docx.styles.style import ParagraphStyle, _TableStyle from docx.table import Table from docx.text.paragraph import Paragraph @@ -178,7 +177,10 @@ def tables(self) -> List[Table]: def _block_width(self) -> Length: """A |Length| object specifying the space between margins in last section.""" section = self.sections[-1] - return Emu(section.page_width - section.left_margin - section.right_margin) + page_width = section.page_width or Inches(8.5) + left_margin = section.left_margin or Inches(1) + right_margin = section.right_margin or Inches(1) + return Emu(page_width - left_margin - right_margin) @property def _body(self) -> _Body: @@ -198,7 +200,7 @@ def __init__(self, body_elm: CT_Body, parent: t.ProvidesStoryPart): super(_Body, self).__init__(body_elm, parent) self._body = body_elm - def clear_content(self): + def clear_content(self) -> _Body: """Return this |_Body| instance after clearing it of all content. Section properties for the main document story, if present, are preserved. diff --git a/src/docx/enum/base.py b/src/docx/enum/base.py index bc96ab6a2..66e989757 100644 --- a/src/docx/enum/base.py +++ b/src/docx/enum/base.py @@ -37,9 +37,9 @@ class BaseXmlEnum(int, enum.Enum): corresponding member in the MS API enum of the same name. """ - xml_value: str + xml_value: str | None - def __new__(cls, ms_api_value: int, xml_value: str, docstr: str): + def __new__(cls, ms_api_value: int, xml_value: str | None, docstr: str): self = int.__new__(cls, ms_api_value) self._value_ = ms_api_value self.xml_value = xml_value @@ -70,7 +70,11 @@ def to_xml(cls: Type[_T], value: int | _T | None) -> str | None: """XML value of this enum member, generally an XML attribute value.""" # -- presence of multi-arg `__new__()` method fools type-checker, but getting a # -- member by its value using EnumCls(val) works as usual. - return cls(value).xml_value + member = cls(value) + xml_value = member.xml_value + if not xml_value: + raise ValueError(f"{cls.__name__}.{member.name} has no XML representation") + return xml_value class DocsPageFormatter: diff --git a/src/docx/opc/package.py b/src/docx/opc/package.py index 3b1eef256..3c1cdca22 100644 --- a/src/docx/opc/package.py +++ b/src/docx/opc/package.py @@ -14,6 +14,8 @@ from docx.shared import lazyproperty if TYPE_CHECKING: + from typing_extensions import Self + from docx.opc.coreprops import CoreProperties from docx.opc.part import Part from docx.opc.rel import _Relationship # pyright: ignore[reportPrivateUsage] @@ -26,9 +28,6 @@ class OpcPackage: to a package file or file-like object containing one. """ - def __init__(self): - super(OpcPackage, self).__init__() - def after_unmarshal(self): """Entry point for any post-unmarshaling processing. @@ -122,7 +121,7 @@ def next_partname(self, template: str) -> PackURI: return PackURI(candidate_partname) @classmethod - def open(cls, pkg_file: str | IO[bytes]) -> OpcPackage: + def open(cls, pkg_file: str | IO[bytes]) -> Self: """Return an |OpcPackage| instance loaded with the contents of `pkg_file`.""" pkg_reader = PackageReader.from_file(pkg_file) package = cls() diff --git a/src/docx/oxml/text/font.py b/src/docx/oxml/text/font.py index 140086aab..c5dc9bd2e 100644 --- a/src/docx/oxml/text/font.py +++ b/src/docx/oxml/text/font.py @@ -1,3 +1,5 @@ +# pyright: reportAssignmentType=false + """Custom element classes related to run properties (font).""" from __future__ import annotations @@ -20,6 +22,7 @@ RequiredAttribute, ZeroOrOne, ) +from docx.shared import RGBColor if TYPE_CHECKING: from docx.oxml.shared import CT_OnOff, CT_String @@ -29,8 +32,8 @@ class CT_Color(BaseOxmlElement): """`w:color` element, specifying the color of a font and perhaps other objects.""" - val = RequiredAttribute("w:val", ST_HexColor) - themeColor = OptionalAttribute("w:themeColor", MSO_THEME_COLOR) + val: RGBColor | str = RequiredAttribute("w:val", ST_HexColor) + themeColor: MSO_THEME_COLOR | None = OptionalAttribute("w:themeColor", MSO_THEME_COLOR) class CT_Fonts(BaseOxmlElement): @@ -39,39 +42,33 @@ class CT_Fonts(BaseOxmlElement): Specifies typeface name for the various language types. """ - ascii: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] - "w:ascii", ST_String - ) - hAnsi: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] - "w:hAnsi", ST_String - ) + ascii: str | None = OptionalAttribute("w:ascii", ST_String) + hAnsi: str | None = OptionalAttribute("w:hAnsi", ST_String) class CT_Highlight(BaseOxmlElement): """`w:highlight` element, specifying font highlighting/background color.""" - val: WD_COLOR_INDEX = RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:val", WD_COLOR_INDEX - ) + val: WD_COLOR_INDEX = RequiredAttribute("w:val", WD_COLOR_INDEX) class CT_HpsMeasure(BaseOxmlElement): """Used for `` element and others, specifying font size in half-points.""" - val: Length = RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:val", ST_HpsMeasure - ) + val: Length = RequiredAttribute("w:val", ST_HpsMeasure) class CT_RPr(BaseOxmlElement): """`` element, containing the properties for a run.""" + get_or_add_color: Callable[[], CT_Color] get_or_add_highlight: Callable[[], CT_Highlight] get_or_add_rFonts: Callable[[], CT_Fonts] get_or_add_sz: Callable[[], CT_HpsMeasure] get_or_add_vertAlign: Callable[[], CT_VerticalAlignRun] _add_rStyle: Callable[..., CT_String] _add_u: Callable[[], CT_Underline] + _remove_color: Callable[[], None] _remove_highlight: Callable[[], None] _remove_rFonts: Callable[[], None] _remove_rStyle: Callable[[], None] @@ -120,15 +117,9 @@ class CT_RPr(BaseOxmlElement): "w:specVanish", "w:oMath", ) - rStyle: CT_String | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:rStyle", successors=_tag_seq[1:] - ) - rFonts: CT_Fonts | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:rFonts", successors=_tag_seq[2:] - ) - b: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:b", successors=_tag_seq[3:] - ) + rStyle: CT_String | None = ZeroOrOne("w:rStyle", successors=_tag_seq[1:]) + rFonts: CT_Fonts | None = ZeroOrOne("w:rFonts", successors=_tag_seq[2:]) + b: CT_OnOff | None = ZeroOrOne("w:b", successors=_tag_seq[3:]) bCs = ZeroOrOne("w:bCs", successors=_tag_seq[4:]) i = ZeroOrOne("w:i", successors=_tag_seq[5:]) iCs = ZeroOrOne("w:iCs", successors=_tag_seq[6:]) @@ -144,19 +135,11 @@ class CT_RPr(BaseOxmlElement): snapToGrid = ZeroOrOne("w:snapToGrid", successors=_tag_seq[16:]) vanish = ZeroOrOne("w:vanish", successors=_tag_seq[17:]) webHidden = ZeroOrOne("w:webHidden", successors=_tag_seq[18:]) - color = ZeroOrOne("w:color", successors=_tag_seq[19:]) - sz: CT_HpsMeasure | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:sz", successors=_tag_seq[24:] - ) - highlight: CT_Highlight | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:highlight", successors=_tag_seq[26:] - ) - u: CT_Underline | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:u", successors=_tag_seq[27:] - ) - vertAlign: CT_VerticalAlignRun | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:vertAlign", successors=_tag_seq[32:] - ) + color: CT_Color | None = ZeroOrOne("w:color", successors=_tag_seq[19:]) + sz: CT_HpsMeasure | None = ZeroOrOne("w:sz", successors=_tag_seq[24:]) + highlight: CT_Highlight | None = ZeroOrOne("w:highlight", successors=_tag_seq[26:]) + u: CT_Underline | None = ZeroOrOne("w:u", successors=_tag_seq[27:]) + vertAlign: CT_VerticalAlignRun | None = ZeroOrOne("w:vertAlign", successors=_tag_seq[32:]) rtl = ZeroOrOne("w:rtl", successors=_tag_seq[33:]) cs = ZeroOrOne("w:cs", successors=_tag_seq[34:]) specVanish = ZeroOrOne("w:specVanish", successors=_tag_seq[38:]) @@ -343,14 +326,10 @@ def _set_bool_val(self, name: str, value: bool | None): class CT_Underline(BaseOxmlElement): """`` element, specifying the underlining style for a run.""" - val: WD_UNDERLINE | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] - "w:val", WD_UNDERLINE - ) + val: WD_UNDERLINE | None = OptionalAttribute("w:val", WD_UNDERLINE) class CT_VerticalAlignRun(BaseOxmlElement): """`` element, specifying subscript or superscript.""" - val: str = RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:val", ST_VerticalAlignRun - ) + val: str = RequiredAttribute("w:val", ST_VerticalAlignRun) diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 648b58aa2..dea0845f7 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -89,14 +89,13 @@ def inline_shapes(self): return InlineShapes(self._element.body, self) @lazyproperty - def numbering_part(self): - """A |NumberingPart| object providing access to the numbering definitions for - this document. + def numbering_part(self) -> NumberingPart: + """A |NumberingPart| object providing access to the numbering definitions for this document. Creates an empty numbering part if one is not present. """ try: - return self.part_related_by(RT.NUMBERING) + return cast(NumberingPart, self.part_related_by(RT.NUMBERING)) except KeyError: numbering_part = NumberingPart.new() self.relate_to(numbering_part, RT.NUMBERING) diff --git a/src/docx/parts/numbering.py b/src/docx/parts/numbering.py index 54a430c1b..745c8458a 100644 --- a/src/docx/parts/numbering.py +++ b/src/docx/parts/numbering.py @@ -9,9 +9,8 @@ class NumberingPart(XmlPart): or glossary.""" @classmethod - def new(cls): - """Return newly created empty numbering part, containing only the root - ```` element.""" + def new(cls) -> "NumberingPart": + """Newly created numbering part, containing only the root ```` element.""" raise NotImplementedError @lazyproperty diff --git a/src/docx/shared.py b/src/docx/shared.py index 491d42741..1d561227b 100644 --- a/src/docx/shared.py +++ b/src/docx/shared.py @@ -127,11 +127,9 @@ class RGBColor(Tuple[int, int, int]): def __new__(cls, r: int, g: int, b: int): msg = "RGBColor() takes three integer values 0-255" for val in (r, g, b): - if ( - not isinstance(val, int) # pyright: ignore[reportUnnecessaryIsInstance] - or val < 0 - or val > 255 - ): + if not isinstance(val, int): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError(msg) + if val < 0 or val > 255: raise ValueError(msg) return super(RGBColor, cls).__new__(cls, (r, g, b)) diff --git a/src/docx/text/run.py b/src/docx/text/run.py index 0e2f5bc17..d35988370 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -233,7 +233,7 @@ def underline(self) -> bool | WD_UNDERLINE | None: return self.font.underline @underline.setter - def underline(self, value: bool): + def underline(self, value: bool | WD_UNDERLINE | None): self.font.underline = value diff --git a/tests/dml/test_color.py b/tests/dml/test_color.py index ea848e7d6..f9fcae0c6 100644 --- a/tests/dml/test_color.py +++ b/tests/dml/test_color.py @@ -1,57 +1,59 @@ -"""Test suite for docx.dml.color module.""" +# pyright: reportPrivateUsage=false + +"""Unit-test suite for the `docx.dml.color` module.""" + +from __future__ import annotations + +from typing import cast import pytest from docx.dml.color import ColorFormat from docx.enum.dml import MSO_COLOR_TYPE, MSO_THEME_COLOR +from docx.oxml.text.run import CT_R from docx.shared import RGBColor from ..unitutil.cxml import element, xml class DescribeColorFormat: - def it_knows_its_color_type(self, type_fixture): - color_format, expected_value = type_fixture - assert color_format.type == expected_value - - def it_knows_its_RGB_value(self, rgb_get_fixture): - color_format, expected_value = rgb_get_fixture - assert color_format.rgb == expected_value - - def it_can_change_its_RGB_value(self, rgb_set_fixture): - color_format, new_value, expected_xml = rgb_set_fixture - color_format.rgb = new_value - assert color_format._element.xml == expected_xml - - def it_knows_its_theme_color(self, theme_color_get_fixture): - color_format, expected_value = theme_color_get_fixture - assert color_format.theme_color == expected_value - - def it_can_change_its_theme_color(self, theme_color_set_fixture): - color_format, new_value, expected_xml = theme_color_set_fixture - color_format.theme_color = new_value - assert color_format._element.xml == expected_xml + """Unit-test suite for `docx.dml.color.ColorFormat` objects.""" - # fixtures --------------------------------------------- + @pytest.mark.parametrize( + ("r_cxml", "expected_value"), + [ + ("w:r", None), + ("w:r/w:rPr", None), + ("w:r/w:rPr/w:color{w:val=auto}", MSO_COLOR_TYPE.AUTO), + ("w:r/w:rPr/w:color{w:val=4224FF}", MSO_COLOR_TYPE.RGB), + ("w:r/w:rPr/w:color{w:themeColor=dark1}", MSO_COLOR_TYPE.THEME), + ( + "w:r/w:rPr/w:color{w:val=F00BA9,w:themeColor=accent1}", + MSO_COLOR_TYPE.THEME, + ), + ], + ) + def it_knows_its_color_type(self, r_cxml: str, expected_value: MSO_COLOR_TYPE | None): + assert ColorFormat(cast(CT_R, element(r_cxml))).type == expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("r_cxml", "rgb"), + [ ("w:r", None), ("w:r/w:rPr", None), ("w:r/w:rPr/w:color{w:val=auto}", None), ("w:r/w:rPr/w:color{w:val=4224FF}", "4224ff"), ("w:r/w:rPr/w:color{w:val=auto,w:themeColor=accent1}", None), ("w:r/w:rPr/w:color{w:val=F00BA9,w:themeColor=accent1}", "f00ba9"), - ] + ], ) - def rgb_get_fixture(self, request): - r_cxml, rgb = request.param - color_format = ColorFormat(element(r_cxml)) - expected_value = None if rgb is None else RGBColor.from_string(rgb) - return color_format, expected_value + def it_knows_its_RGB_value(self, r_cxml: str, rgb: str | None): + expected_value = RGBColor.from_string(rgb) if rgb else None + assert ColorFormat(cast(CT_R, element(r_cxml))).rgb == expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("r_cxml", "new_value", "expected_cxml"), + [ ("w:r", RGBColor(10, 20, 30), "w:r/w:rPr/w:color{w:val=0A141E}"), ("w:r/w:rPr", RGBColor(1, 2, 3), "w:r/w:rPr/w:color{w:val=010203}"), ( @@ -71,73 +73,60 @@ def rgb_get_fixture(self, request): ), ("w:r/w:rPr/w:color{w:val=234bcd,w:themeColor=dark1}", None, "w:r/w:rPr"), ("w:r", None, "w:r"), - ] + ], ) - def rgb_set_fixture(self, request): - r_cxml, new_value, expected_cxml = request.param - color_format = ColorFormat(element(r_cxml)) - expected_xml = xml(expected_cxml) - return color_format, new_value, expected_xml + def it_can_change_its_RGB_value( + self, r_cxml: str, new_value: RGBColor | None, expected_cxml: str + ): + color_format = ColorFormat(cast(CT_R, element(r_cxml))) + color_format.rgb = new_value + assert color_format._element.xml == xml(expected_cxml) - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("r_cxml", "expected_value"), + [ ("w:r", None), ("w:r/w:rPr", None), ("w:r/w:rPr/w:color{w:val=auto}", None), ("w:r/w:rPr/w:color{w:val=4224FF}", None), - ("w:r/w:rPr/w:color{w:themeColor=accent1}", "ACCENT_1"), - ("w:r/w:rPr/w:color{w:val=F00BA9,w:themeColor=dark1}", "DARK_1"), - ] + ("w:r/w:rPr/w:color{w:themeColor=accent1}", MSO_THEME_COLOR.ACCENT_1), + ("w:r/w:rPr/w:color{w:val=F00BA9,w:themeColor=dark1}", MSO_THEME_COLOR.DARK_1), + ], ) - def theme_color_get_fixture(self, request): - r_cxml, value = request.param - color_format = ColorFormat(element(r_cxml)) - expected_value = None if value is None else getattr(MSO_THEME_COLOR, value) - return color_format, expected_value + def it_knows_its_theme_color(self, r_cxml: str, expected_value: MSO_THEME_COLOR | None): + color_format = ColorFormat(cast(CT_R, element(r_cxml))) + assert color_format.theme_color == expected_value - @pytest.fixture( - params=[ - ("w:r", "ACCENT_1", "w:r/w:rPr/w:color{w:val=000000,w:themeColor=accent1}"), + @pytest.mark.parametrize( + ("r_cxml", "new_value", "expected_cxml"), + [ + ( + "w:r", + MSO_THEME_COLOR.ACCENT_1, + "w:r/w:rPr/w:color{w:val=000000,w:themeColor=accent1}", + ), ( "w:r/w:rPr", - "ACCENT_2", + MSO_THEME_COLOR.ACCENT_2, "w:r/w:rPr/w:color{w:val=000000,w:themeColor=accent2}", ), ( "w:r/w:rPr/w:color{w:val=101112}", - "ACCENT_3", + MSO_THEME_COLOR.ACCENT_3, "w:r/w:rPr/w:color{w:val=101112,w:themeColor=accent3}", ), ( "w:r/w:rPr/w:color{w:val=234bcd,w:themeColor=dark1}", - "LIGHT_2", + MSO_THEME_COLOR.LIGHT_2, "w:r/w:rPr/w:color{w:val=234bcd,w:themeColor=light2}", ), ("w:r/w:rPr/w:color{w:val=234bcd,w:themeColor=dark1}", None, "w:r/w:rPr"), ("w:r", None, "w:r"), - ] - ) - def theme_color_set_fixture(self, request): - r_cxml, member, expected_cxml = request.param - color_format = ColorFormat(element(r_cxml)) - new_value = None if member is None else getattr(MSO_THEME_COLOR, member) - expected_xml = xml(expected_cxml) - return color_format, new_value, expected_xml - - @pytest.fixture( - params=[ - ("w:r", None), - ("w:r/w:rPr", None), - ("w:r/w:rPr/w:color{w:val=auto}", MSO_COLOR_TYPE.AUTO), - ("w:r/w:rPr/w:color{w:val=4224FF}", MSO_COLOR_TYPE.RGB), - ("w:r/w:rPr/w:color{w:themeColor=dark1}", MSO_COLOR_TYPE.THEME), - ( - "w:r/w:rPr/w:color{w:val=F00BA9,w:themeColor=accent1}", - MSO_COLOR_TYPE.THEME, - ), - ] + ], ) - def type_fixture(self, request): - r_cxml, expected_value = request.param - color_format = ColorFormat(element(r_cxml)) - return color_format, expected_value + def it_can_change_its_theme_color( + self, r_cxml: str, new_value: MSO_THEME_COLOR | None, expected_cxml: str + ): + color_format = ColorFormat(cast(CT_R, element(r_cxml))) + color_format.theme_color = new_value + assert color_format._element.xml == xml(expected_cxml) diff --git a/tests/oxml/unitdata/dml.py b/tests/oxml/unitdata/dml.py deleted file mode 100644 index 325a3f690..000000000 --- a/tests/oxml/unitdata/dml.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Test data builders for DrawingML XML elements.""" - -from ...unitdata import BaseBuilder - - -class CT_BlipBuilder(BaseBuilder): - __tag__ = "a:blip" - __nspfxs__ = ("a",) - __attrs__ = ("r:embed", "r:link", "cstate") - - -class CT_BlipFillPropertiesBuilder(BaseBuilder): - __tag__ = "pic:blipFill" - __nspfxs__ = ("pic",) - __attrs__ = () - - -class CT_GraphicalObjectBuilder(BaseBuilder): - __tag__ = "a:graphic" - __nspfxs__ = ("a",) - __attrs__ = () - - -class CT_GraphicalObjectDataBuilder(BaseBuilder): - __tag__ = "a:graphicData" - __nspfxs__ = ("a",) - __attrs__ = ("uri",) - - -class CT_InlineBuilder(BaseBuilder): - __tag__ = "wp:inline" - __nspfxs__ = ("wp",) - __attrs__ = ("distT", "distB", "distL", "distR") - - -class CT_PictureBuilder(BaseBuilder): - __tag__ = "pic:pic" - __nspfxs__ = ("pic",) - __attrs__ = () - - -def a_blip(): - return CT_BlipBuilder() - - -def a_blipFill(): - return CT_BlipFillPropertiesBuilder() - - -def a_graphic(): - return CT_GraphicalObjectBuilder() - - -def a_graphicData(): - return CT_GraphicalObjectDataBuilder() - - -def a_pic(): - return CT_PictureBuilder() - - -def an_inline(): - return CT_InlineBuilder() diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 3a86b5168..cfe9e870c 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -1,10 +1,14 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for the docx.parts.document module.""" import pytest from docx.enum.style import WD_STYLE_TYPE +from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.coreprops import CoreProperties +from docx.opc.packuri import PackURI from docx.package import Package from docx.parts.document import DocumentPart from docx.parts.hdrftr import FooterPart, HeaderPart @@ -15,15 +19,26 @@ from docx.styles.style import BaseStyle from docx.styles.styles import Styles -from ..oxml.parts.unitdata.document import a_body, a_document -from ..unitutil.mock import class_mock, instance_mock, method_mock, property_mock +from ..unitutil.cxml import element +from ..unitutil.mock import ( + FixtureRequest, + Mock, + class_mock, + instance_mock, + method_mock, + property_mock, +) class DescribeDocumentPart: - def it_can_add_a_footer_part(self, package_, FooterPart_, footer_part_, relate_to_): + def it_can_add_a_footer_part( + self, package_: Mock, FooterPart_: Mock, footer_part_: Mock, relate_to_: Mock + ): FooterPart_.new.return_value = footer_part_ relate_to_.return_value = "rId12" - document_part = DocumentPart(None, None, None, package_) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) footer_part, rId = document_part.add_footer_part() @@ -32,10 +47,14 @@ def it_can_add_a_footer_part(self, package_, FooterPart_, footer_part_, relate_t assert footer_part is footer_part_ assert rId == "rId12" - def it_can_add_a_header_part(self, package_, HeaderPart_, header_part_, relate_to_): + def it_can_add_a_header_part( + self, package_: Mock, HeaderPart_: Mock, header_part_: Mock, relate_to_: Mock + ): HeaderPart_.new.return_value = header_part_ relate_to_.return_value = "rId7" - document_part = DocumentPart(None, None, None, package_) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) header_part, rId = document_part.add_header_part() @@ -44,19 +63,23 @@ def it_can_add_a_header_part(self, package_, HeaderPart_, header_part_, relate_t assert header_part is header_part_ assert rId == "rId7" - def it_can_drop_a_specified_header_part(self, drop_rel_): - document_part = DocumentPart(None, None, None, None) + def it_can_drop_a_specified_header_part(self, drop_rel_: Mock, package_: Mock): + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) document_part.drop_header_part("rId42") drop_rel_.assert_called_once_with(document_part, "rId42") def it_provides_access_to_a_footer_part_by_rId( - self, related_parts_prop_, related_parts_, footer_part_ + self, related_parts_prop_: Mock, related_parts_: Mock, footer_part_: Mock, package_: Mock ): related_parts_prop_.return_value = related_parts_ related_parts_.__getitem__.return_value = footer_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) footer_part = document_part.footer_part("rId9") @@ -64,50 +87,79 @@ def it_provides_access_to_a_footer_part_by_rId( assert footer_part is footer_part_ def it_provides_access_to_a_header_part_by_rId( - self, related_parts_prop_, related_parts_, header_part_ + self, related_parts_prop_: Mock, related_parts_: Mock, header_part_: Mock, package_: Mock ): related_parts_prop_.return_value = related_parts_ related_parts_.__getitem__.return_value = header_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) header_part = document_part.header_part("rId11") related_parts_.__getitem__.assert_called_once_with("rId11") assert header_part is header_part_ - def it_can_save_the_package_to_a_file(self, save_fixture): - document, file_ = save_fixture - document.save(file_) - document._package.save.assert_called_once_with(file_) + def it_can_save_the_package_to_a_file(self, package_: Mock): + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) - def it_provides_access_to_the_document_settings(self, settings_fixture): - document_part, settings_ = settings_fixture - settings = document_part.settings - assert settings is settings_ + document_part.save("foobar.docx") - def it_provides_access_to_the_document_styles(self, styles_fixture): - document_part, styles_ = styles_fixture - styles = document_part.styles - assert styles is styles_ + package_.save.assert_called_once_with("foobar.docx") + + def it_provides_access_to_the_document_settings( + self, _settings_part_prop_: Mock, settings_part_: Mock, settings_: Mock, package_: Mock + ): + settings_part_.settings = settings_ + _settings_part_prop_.return_value = settings_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) - def it_provides_access_to_its_core_properties(self, core_props_fixture): - document_part, core_properties_ = core_props_fixture - core_properties = document_part.core_properties - assert core_properties is core_properties_ + assert document_part.settings is settings_ + + def it_provides_access_to_the_document_styles( + self, _styles_part_prop_: Mock, styles_part_: Mock, styles_: Mock, package_: Mock + ): + styles_part_.styles = styles_ + _styles_part_prop_.return_value = styles_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + assert document_part.styles is styles_ + + def it_provides_access_to_its_core_properties(self, package_: Mock, core_properties_: Mock): + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + package_.core_properties = core_properties_ + + assert document_part.core_properties is core_properties_ def it_provides_access_to_the_inline_shapes_in_the_document( - self, inline_shapes_fixture + self, InlineShapes_: Mock, package_: Mock ): - document, InlineShapes_, body_elm = inline_shapes_fixture - inline_shapes = document.inline_shapes - InlineShapes_.assert_called_once_with(body_elm, document) + document_elm = element("w:document/w:body") + body_elm = document_elm[0] + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, document_elm, package_ + ) + + inline_shapes = document_part.inline_shapes + + InlineShapes_.assert_called_once_with(body_elm, document_part) assert inline_shapes is InlineShapes_.return_value def it_provides_access_to_the_numbering_part( - self, part_related_by_, numbering_part_ + self, part_related_by_: Mock, numbering_part_: Mock, package_: Mock ): part_related_by_.return_value = numbering_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) numbering_part = document_part.numbering_part @@ -115,11 +167,18 @@ def it_provides_access_to_the_numbering_part( assert numbering_part is numbering_part_ def and_it_creates_a_numbering_part_if_not_present( - self, part_related_by_, relate_to_, NumberingPart_, numbering_part_ + self, + part_related_by_: Mock, + relate_to_: Mock, + NumberingPart_: Mock, + numbering_part_: Mock, + package_: Mock, ): part_related_by_.side_effect = KeyError NumberingPart_.new.return_value = numbering_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) numbering_part = document_part.numbering_part @@ -127,20 +186,28 @@ def and_it_creates_a_numbering_part_if_not_present( relate_to_.assert_called_once_with(document_part, numbering_part_, RT.NUMBERING) assert numbering_part is numbering_part_ - def it_can_get_a_style_by_id(self, styles_prop_, styles_, style_): + def it_can_get_a_style_by_id( + self, styles_prop_: Mock, styles_: Mock, style_: Mock, package_: Mock + ): styles_prop_.return_value = styles_ styles_.get_by_id.return_value = style_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) style = document_part.get_style("BodyText", WD_STYLE_TYPE.PARAGRAPH) styles_.get_by_id.assert_called_once_with("BodyText", WD_STYLE_TYPE.PARAGRAPH) assert style is style_ - def it_can_get_the_id_of_a_style(self, style_, styles_prop_, styles_): + def it_can_get_the_id_of_a_style( + self, style_: Mock, styles_prop_: Mock, styles_: Mock, package_: Mock + ): styles_prop_.return_value = styles_ styles_.get_style_id.return_value = "BodyCharacter" - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) style_id = document_part.get_style_id(style_, WD_STYLE_TYPE.CHARACTER) @@ -148,10 +215,12 @@ def it_can_get_the_id_of_a_style(self, style_, styles_prop_, styles_): assert style_id == "BodyCharacter" def it_provides_access_to_its_settings_part_to_help( - self, part_related_by_, settings_part_ + self, part_related_by_: Mock, settings_part_: Mock, package_: Mock ): part_related_by_.return_value = settings_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) settings_part = document_part._settings_part @@ -159,11 +228,18 @@ def it_provides_access_to_its_settings_part_to_help( assert settings_part is settings_part_ def and_it_creates_a_default_settings_part_if_not_present( - self, package_, part_related_by_, SettingsPart_, settings_part_, relate_to_ + self, + package_: Mock, + part_related_by_: Mock, + SettingsPart_: Mock, + settings_part_: Mock, + relate_to_: Mock, ): part_related_by_.side_effect = KeyError SettingsPart_.default.return_value = settings_part_ - document_part = DocumentPart(None, None, None, package_) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) settings_part = document_part._settings_part @@ -172,10 +248,12 @@ def and_it_creates_a_default_settings_part_if_not_present( assert settings_part is settings_part_ def it_provides_access_to_its_styles_part_to_help( - self, part_related_by_, styles_part_ + self, part_related_by_: Mock, styles_part_: Mock, package_: Mock ): part_related_by_.return_value = styles_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) styles_part = document_part._styles_part @@ -183,11 +261,18 @@ def it_provides_access_to_its_styles_part_to_help( assert styles_part is styles_part_ def and_it_creates_a_default_styles_part_if_not_present( - self, package_, part_related_by_, StylesPart_, styles_part_, relate_to_ + self, + package_: Mock, + part_related_by_: Mock, + StylesPart_: Mock, + styles_part_: Mock, + relate_to_: Mock, ): part_related_by_.side_effect = KeyError StylesPart_.default.return_value = styles_part_ - document_part = DocumentPart(None, None, None, package_) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) styles_part = document_part._styles_part @@ -195,135 +280,100 @@ def and_it_creates_a_default_styles_part_if_not_present( relate_to_.assert_called_once_with(document_part, styles_part_, RT.STYLES) assert styles_part is styles_part_ - # fixtures ------------------------------------------------------- - - @pytest.fixture - def core_props_fixture(self, package_, core_properties_): - document_part = DocumentPart(None, None, None, package_) - package_.core_properties = core_properties_ - return document_part, core_properties_ - - @pytest.fixture - def inline_shapes_fixture(self, request, InlineShapes_): - document_elm = (a_document().with_nsdecls().with_child(a_body())).element - body_elm = document_elm[0] - document = DocumentPart(None, None, document_elm, None) - return document, InlineShapes_, body_elm - - @pytest.fixture - def save_fixture(self, package_): - document_part = DocumentPart(None, None, None, package_) - file_ = "foobar.docx" - return document_part, file_ - - @pytest.fixture - def settings_fixture(self, _settings_part_prop_, settings_part_, settings_): - document_part = DocumentPart(None, None, None, None) - _settings_part_prop_.return_value = settings_part_ - settings_part_.settings = settings_ - return document_part, settings_ - - @pytest.fixture - def styles_fixture(self, _styles_part_prop_, styles_part_, styles_): - document_part = DocumentPart(None, None, None, None) - _styles_part_prop_.return_value = styles_part_ - styles_part_.styles = styles_ - return document_part, styles_ - - # fixture components --------------------------------------------- + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def core_properties_(self, request): + def core_properties_(self, request: FixtureRequest): return instance_mock(request, CoreProperties) @pytest.fixture - def drop_rel_(self, request): + def drop_rel_(self, request: FixtureRequest): return method_mock(request, DocumentPart, "drop_rel", autospec=True) @pytest.fixture - def FooterPart_(self, request): + def FooterPart_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.FooterPart") @pytest.fixture - def footer_part_(self, request): + def footer_part_(self, request: FixtureRequest): return instance_mock(request, FooterPart) @pytest.fixture - def HeaderPart_(self, request): + def HeaderPart_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.HeaderPart") @pytest.fixture - def header_part_(self, request): + def header_part_(self, request: FixtureRequest): return instance_mock(request, HeaderPart) @pytest.fixture - def InlineShapes_(self, request): + def InlineShapes_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.InlineShapes") @pytest.fixture - def NumberingPart_(self, request): + def NumberingPart_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.NumberingPart") @pytest.fixture - def numbering_part_(self, request): + def numbering_part_(self, request: FixtureRequest): return instance_mock(request, NumberingPart) @pytest.fixture - def package_(self, request): + def package_(self, request: FixtureRequest): return instance_mock(request, Package) @pytest.fixture - def part_related_by_(self, request): + def part_related_by_(self, request: FixtureRequest): return method_mock(request, DocumentPart, "part_related_by") @pytest.fixture - def relate_to_(self, request): + def relate_to_(self, request: FixtureRequest): return method_mock(request, DocumentPart, "relate_to") @pytest.fixture - def related_parts_(self, request): + def related_parts_(self, request: FixtureRequest): return instance_mock(request, dict) @pytest.fixture - def related_parts_prop_(self, request): + def related_parts_prop_(self, request: FixtureRequest): return property_mock(request, DocumentPart, "related_parts") @pytest.fixture - def SettingsPart_(self, request): + def SettingsPart_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.SettingsPart") @pytest.fixture - def settings_(self, request): + def settings_(self, request: FixtureRequest): return instance_mock(request, Settings) @pytest.fixture - def settings_part_(self, request): + def settings_part_(self, request: FixtureRequest): return instance_mock(request, SettingsPart) @pytest.fixture - def _settings_part_prop_(self, request): + def _settings_part_prop_(self, request: FixtureRequest): return property_mock(request, DocumentPart, "_settings_part") @pytest.fixture - def style_(self, request): + def style_(self, request: FixtureRequest): return instance_mock(request, BaseStyle) @pytest.fixture - def styles_(self, request): + def styles_(self, request: FixtureRequest): return instance_mock(request, Styles) @pytest.fixture - def StylesPart_(self, request): + def StylesPart_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.StylesPart") @pytest.fixture - def styles_part_(self, request): + def styles_part_(self, request: FixtureRequest): return instance_mock(request, StylesPart) @pytest.fixture - def styles_prop_(self, request): + def styles_prop_(self, request: FixtureRequest): return property_mock(request, DocumentPart, "styles") @pytest.fixture - def _styles_part_prop_(self, request): + def _styles_part_prop_(self, request: FixtureRequest): return property_mock(request, DocumentPart, "_styles_part") diff --git a/tests/test_api.py b/tests/test_api.py index b6e6818b5..6b5d3ae07 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,66 +2,55 @@ import pytest -import docx -from docx.api import Document +from docx.api import Document as DocumentFactoryFn +from docx.document import Document as DocumentCls from docx.opc.constants import CONTENT_TYPE as CT -from .unitutil.mock import class_mock, function_mock, instance_mock +from .unitutil.mock import FixtureRequest, Mock, class_mock, function_mock, instance_mock class DescribeDocument: - def it_opens_a_docx_file(self, open_fixture): - docx, Package_, document_ = open_fixture - document = Document(docx) - Package_.open.assert_called_once_with(docx) - assert document is document_ - - def it_opens_the_default_docx_if_none_specified(self, default_fixture): - docx, Package_, document_ = default_fixture - document = Document() - Package_.open.assert_called_once_with(docx) - assert document is document_ - - def it_raises_on_not_a_Word_file(self, raise_fixture): - not_a_docx = raise_fixture - with pytest.raises(ValueError, match="file 'foobar.xlsx' is not a Word file,"): - Document(not_a_docx) + """Unit-test suite for `docx.api.Document` factory function.""" - # fixtures ------------------------------------------------------- - - @pytest.fixture - def default_fixture(self, _default_docx_path_, Package_, document_): - docx = "barfoo.docx" - _default_docx_path_.return_value = docx + def it_opens_a_docx_file(self, Package_: Mock, document_: Mock): document_part = Package_.open.return_value.main_document_part document_part.document = document_ document_part.content_type = CT.WML_DOCUMENT_MAIN - return docx, Package_, document_ - @pytest.fixture - def open_fixture(self, Package_, document_): - docx = "foobar.docx" + document = DocumentFactoryFn("foobar.docx") + + Package_.open.assert_called_once_with("foobar.docx") + assert document is document_ + + def it_opens_the_default_docx_if_none_specified( + self, _default_docx_path_: Mock, Package_: Mock, document_: Mock + ): + _default_docx_path_.return_value = "default-document.docx" document_part = Package_.open.return_value.main_document_part document_part.document = document_ document_part.content_type = CT.WML_DOCUMENT_MAIN - return docx, Package_, document_ - @pytest.fixture - def raise_fixture(self, Package_): - not_a_docx = "foobar.xlsx" + document = DocumentFactoryFn() + + Package_.open.assert_called_once_with("default-document.docx") + assert document is document_ + + def it_raises_on_not_a_Word_file(self, Package_: Mock): Package_.open.return_value.main_document_part.content_type = "BOGUS" - return not_a_docx - # fixture components --------------------------------------------- + with pytest.raises(ValueError, match="file 'foobar.xlsx' is not a Word file,"): + DocumentFactoryFn("foobar.xlsx") + + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def _default_docx_path_(self, request): + def _default_docx_path_(self, request: FixtureRequest): return function_mock(request, "docx.api._default_docx_path") @pytest.fixture - def document_(self, request): - return instance_mock(request, docx.document.Document) + def document_(self, request: FixtureRequest): + return instance_mock(request, DocumentCls) @pytest.fixture - def Package_(self, request): + def Package_(self, request: FixtureRequest): return class_mock(request, "docx.api.Package") diff --git a/tests/test_blkcntnr.py b/tests/test_blkcntnr.py index 1549bd8ea..ab463663f 100644 --- a/tests/test_blkcntnr.py +++ b/tests/test_blkcntnr.py @@ -1,42 +1,61 @@ +# pyright: reportPrivateUsage=false + """Test suite for the docx.blkcntnr (block item container) module.""" +from __future__ import annotations + +from typing import cast + import pytest -from docx import Document +import docx from docx.blkcntnr import BlockItemContainer +from docx.document import Document +from docx.oxml.document import CT_Body from docx.shared import Inches from docx.table import Table from docx.text.paragraph import Paragraph from .unitutil.cxml import element, xml from .unitutil.file import snippet_seq, test_file -from .unitutil.mock import call, instance_mock, method_mock +from .unitutil.mock import FixtureRequest, Mock, call, instance_mock, method_mock class DescribeBlockItemContainer: """Unit-test suite for `docx.blkcntnr.BlockItemContainer`.""" - def it_can_add_a_paragraph(self, add_paragraph_fixture, _add_paragraph_): - text, style, paragraph_, add_run_calls = add_paragraph_fixture + @pytest.mark.parametrize( + ("text", "style"), [("", None), ("Foo", None), ("", "Bar"), ("Foo", "Bar")] + ) + def it_can_add_a_paragraph( + self, + text: str, + style: str | None, + blkcntnr: BlockItemContainer, + _add_paragraph_: Mock, + paragraph_: Mock, + ): + paragraph_.style = None _add_paragraph_.return_value = paragraph_ - blkcntnr = BlockItemContainer(None, None) paragraph = blkcntnr.add_paragraph(text, style) _add_paragraph_.assert_called_once_with(blkcntnr) - assert paragraph.add_run.call_args_list == add_run_calls + assert paragraph_.add_run.call_args_list == ([call(text)] if text else []) assert paragraph.style == style assert paragraph is paragraph_ - def it_can_add_a_table(self, add_table_fixture): - blkcntnr, rows, cols, width, expected_xml = add_table_fixture + def it_can_add_a_table(self, blkcntnr: BlockItemContainer): + rows, cols, width = 2, 2, Inches(2) + table = blkcntnr.add_table(rows, cols, width) + assert isinstance(table, Table) - assert table._element.xml == expected_xml + assert table._element.xml == snippet_seq("new-tbl")[0] assert table._parent is blkcntnr def it_can_iterate_its_inner_content(self): - document = Document(test_file("blk-inner-content.docx")) + document = docx.Document(test_file("blk-inner-content.docx")) inner_content = document.iter_inner_content() @@ -55,101 +74,78 @@ def it_can_iterate_its_inner_content(self): with pytest.raises(StopIteration): next(inner_content) - def it_provides_access_to_the_paragraphs_it_contains(self, paragraphs_fixture): - # test len(), iterable, and indexed access - blkcntnr, expected_count = paragraphs_fixture - paragraphs = blkcntnr.paragraphs - assert len(paragraphs) == expected_count - count = 0 - for idx, paragraph in enumerate(paragraphs): - assert isinstance(paragraph, Paragraph) - assert paragraphs[idx] is paragraph - count += 1 - assert count == expected_count - - def it_provides_access_to_the_tables_it_contains(self, tables_fixture): - # test len(), iterable, and indexed access - blkcntnr, expected_count = tables_fixture - tables = blkcntnr.tables - assert len(tables) == expected_count - count = 0 - for idx, table in enumerate(tables): - assert isinstance(table, Table) - assert tables[idx] is table - count += 1 - assert count == expected_count - - def it_adds_a_paragraph_to_help(self, _add_paragraph_fixture): - blkcntnr, expected_xml = _add_paragraph_fixture - new_paragraph = blkcntnr._add_paragraph() - assert isinstance(new_paragraph, Paragraph) - assert new_paragraph._parent == blkcntnr - assert blkcntnr._element.xml == expected_xml - - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ - ("", None), - ("Foo", None), - ("", "Bar"), - ("Foo", "Bar"), - ] - ) - def add_paragraph_fixture(self, request, paragraph_): - text, style = request.param - paragraph_.style = None - add_run_calls = [call(text)] if text else [] - return text, style, paragraph_, add_run_calls - - @pytest.fixture - def _add_paragraph_fixture(self, request): - blkcntnr_cxml, after_cxml = "w:body", "w:body/w:p" - blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) - expected_xml = xml(after_cxml) - return blkcntnr, expected_xml - - @pytest.fixture - def add_table_fixture(self): - blkcntnr = BlockItemContainer(element("w:body"), None) - rows, cols, width = 2, 2, Inches(2) - expected_xml = snippet_seq("new-tbl")[0] - return blkcntnr, rows, cols, width, expected_xml - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("blkcntnr_cxml", "expected_count"), + [ ("w:body", 0), ("w:body/w:p", 1), ("w:body/(w:p,w:p)", 2), ("w:body/(w:p,w:tbl)", 1), ("w:body/(w:p,w:tbl,w:p)", 2), - ] + ], ) - def paragraphs_fixture(self, request): - blkcntnr_cxml, expected_count = request.param - blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) - return blkcntnr, expected_count + def it_provides_access_to_the_paragraphs_it_contains( + self, blkcntnr_cxml: str, expected_count: int, document_: Mock + ): + blkcntnr = BlockItemContainer(cast(CT_Body, element(blkcntnr_cxml)), document_) + + paragraphs = blkcntnr.paragraphs - @pytest.fixture( - params=[ + # -- supports len() -- + assert len(paragraphs) == expected_count + # -- is iterable -- + assert all(isinstance(p, Paragraph) for p in paragraphs) + # -- is indexable -- + assert all(p is paragraphs[idx] for idx, p in enumerate(paragraphs)) + + @pytest.mark.parametrize( + ("blkcntnr_cxml", "expected_count"), + [ ("w:body", 0), ("w:body/w:tbl", 1), ("w:body/(w:tbl,w:tbl)", 2), ("w:body/(w:p,w:tbl)", 1), ("w:body/(w:tbl,w:tbl,w:p)", 2), - ] + ], ) - def tables_fixture(self, request): - blkcntnr_cxml, expected_count = request.param - blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) - return blkcntnr, expected_count + def it_provides_access_to_the_tables_it_contains( + self, blkcntnr_cxml: str, expected_count: int, document_: Mock + ): + blkcntnr = BlockItemContainer(cast(CT_Body, element(blkcntnr_cxml)), document_) + + tables = blkcntnr.tables + + # -- supports len() -- + assert len(tables) == expected_count + # -- is iterable -- + assert all(isinstance(t, Table) for t in tables) + # -- is indexable -- + assert all(t is tables[idx] for idx, t in enumerate(tables)) - # fixture components --------------------------------------------- + def it_adds_a_paragraph_to_help(self, document_: Mock): + blkcntnr = BlockItemContainer(cast(CT_Body, element("w:body")), document_) + + new_paragraph = blkcntnr._add_paragraph() + + assert isinstance(new_paragraph, Paragraph) + assert new_paragraph._parent == blkcntnr + assert blkcntnr._element.xml == xml("w:body/w:p") + + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def _add_paragraph_(self, request): + def _add_paragraph_(self, request: FixtureRequest): return method_mock(request, BlockItemContainer, "_add_paragraph") @pytest.fixture - def paragraph_(self, request): + def blkcntnr(self, document_: Mock): + blkcntnr_elm = cast(CT_Body, element("w:body")) + return BlockItemContainer(blkcntnr_elm, document_) + + @pytest.fixture + def document_(self, request: FixtureRequest): + return instance_mock(request, Document) + + @pytest.fixture + def paragraph_(self, request: FixtureRequest): return instance_mock(request, Paragraph) diff --git a/tests/test_document.py b/tests/test_document.py index 6a2c5af88..739813321 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -13,7 +13,7 @@ from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK from docx.opc.coreprops import CoreProperties -from docx.oxml.document import CT_Document +from docx.oxml.document import CT_Body, CT_Document from docx.parts.document import DocumentPart from docx.section import Section, Sections from docx.settings import Settings @@ -25,33 +25,43 @@ from docx.text.run import Run from .unitutil.cxml import element, xml -from .unitutil.mock import Mock, class_mock, instance_mock, method_mock, property_mock +from .unitutil.mock import ( + FixtureRequest, + Mock, + class_mock, + instance_mock, + method_mock, + property_mock, +) class DescribeDocument: - """Unit-test suite for `docx.Document`.""" + """Unit-test suite for `docx.document.Document`.""" - def it_can_add_a_heading(self, add_heading_fixture, add_paragraph_, paragraph_): - level, style = add_heading_fixture + @pytest.mark.parametrize( + ("level", "style"), [(0, "Title"), (1, "Heading 1"), (2, "Heading 2"), (9, "Heading 9")] + ) + def it_can_add_a_heading( + self, level: int, style: str, document: Document, add_paragraph_: Mock, paragraph_: Mock + ): add_paragraph_.return_value = paragraph_ - document = Document(None, None) paragraph = document.add_heading("Spam vs. Bacon", level) add_paragraph_.assert_called_once_with(document, "Spam vs. Bacon", style) assert paragraph is paragraph_ - def it_raises_on_heading_level_out_of_range(self): - document = Document(None, None) + def it_raises_on_heading_level_out_of_range(self, document: Document): with pytest.raises(ValueError, match="level must be in range 0-9, got -1"): document.add_heading(level=-1) with pytest.raises(ValueError, match="level must be in range 0-9, got 10"): document.add_heading(level=10) - def it_can_add_a_page_break(self, add_paragraph_, paragraph_, run_): + def it_can_add_a_page_break( + self, document: Document, add_paragraph_: Mock, paragraph_: Mock, run_: Mock + ): add_paragraph_.return_value = paragraph_ paragraph_.add_run.return_value = run_ - document = Document(None, None) paragraph = document.add_page_break() @@ -60,70 +70,137 @@ def it_can_add_a_page_break(self, add_paragraph_, paragraph_, run_): run_.add_break.assert_called_once_with(WD_BREAK.PAGE) assert paragraph is paragraph_ - def it_can_add_a_paragraph(self, add_paragraph_fixture): - document, text, style, paragraph_ = add_paragraph_fixture + @pytest.mark.parametrize( + ("text", "style"), [("", None), ("", "Heading 1"), ("foo\rbar", "Body Text")] + ) + def it_can_add_a_paragraph( + self, + text: str, + style: str | None, + document: Document, + body_: Mock, + body_prop_: Mock, + paragraph_: Mock, + ): + body_prop_.return_value = body_ + body_.add_paragraph.return_value = paragraph_ + paragraph = document.add_paragraph(text, style) - document._body.add_paragraph.assert_called_once_with(text, style) + + body_.add_paragraph.assert_called_once_with(text, style) assert paragraph is paragraph_ - def it_can_add_a_picture(self, add_picture_fixture): - document, path, width, height, run_, picture_ = add_picture_fixture + def it_can_add_a_picture( + self, document: Document, add_paragraph_: Mock, run_: Mock, picture_: Mock + ): + path, width, height = "foobar.png", 100, 200 + add_paragraph_.return_value.add_run.return_value = run_ + run_.add_picture.return_value = picture_ + picture = document.add_picture(path, width, height) + run_.add_picture.assert_called_once_with(path, width, height) assert picture is picture_ + @pytest.mark.parametrize( + ("sentinel_cxml", "start_type", "new_sentinel_cxml"), + [ + ("w:sectPr", WD_SECTION.EVEN_PAGE, "w:sectPr/w:type{w:val=evenPage}"), + ( + "w:sectPr/w:type{w:val=evenPage}", + WD_SECTION.ODD_PAGE, + "w:sectPr/w:type{w:val=oddPage}", + ), + ("w:sectPr/w:type{w:val=oddPage}", WD_SECTION.NEW_PAGE, "w:sectPr"), + ], + ) def it_can_add_a_section( - self, add_section_fixture, Section_, section_, document_part_ + self, + sentinel_cxml: str, + start_type: WD_SECTION, + new_sentinel_cxml: str, + Section_: Mock, + section_: Mock, + document_part_: Mock, ): - document_elm, start_type, expected_xml = add_section_fixture Section_.return_value = section_ - document = Document(document_elm, document_part_) + document = Document( + cast(CT_Document, element("w:document/w:body/(w:p,%s)" % sentinel_cxml)), + document_part_, + ) section = document.add_section(start_type) - assert document.element.xml == expected_xml + assert document.element.xml == xml( + "w:document/w:body/(w:p,w:p/w:pPr/%s,%s)" % (sentinel_cxml, new_sentinel_cxml) + ) sectPr = document.element.xpath("w:body/w:sectPr")[0] Section_.assert_called_once_with(sectPr, document_part_) assert section is section_ - def it_can_add_a_table(self, add_table_fixture): - document, rows, cols, style, width, table_ = add_table_fixture + def it_can_add_a_table( + self, + document: Document, + _block_width_prop_: Mock, + body_prop_: Mock, + body_: Mock, + table_: Mock, + ): + rows, cols, style = 4, 2, "Light Shading Accent 1" + body_prop_.return_value = body_ + body_.add_table.return_value = table_ + _block_width_prop_.return_value = width = 42 + table = document.add_table(rows, cols, style) - document._body.add_table.assert_called_once_with(rows, cols, width) + + body_.add_table.assert_called_once_with(rows, cols, width) assert table == table_ assert table.style == style - def it_can_save_the_document_to_a_file(self, save_fixture): - document, file_ = save_fixture - document.save(file_) - document._part.save.assert_called_once_with(file_) + def it_can_save_the_document_to_a_file(self, document_part_: Mock): + document = Document(cast(CT_Document, element("w:document")), document_part_) + + document.save("foobar.docx") + + document_part_.save.assert_called_once_with("foobar.docx") + + def it_provides_access_to_its_core_properties( + self, document_part_: Mock, core_properties_: Mock + ): + document_part_.core_properties = core_properties_ + document = Document(cast(CT_Document, element("w:document")), document_part_) - def it_provides_access_to_its_core_properties(self, core_props_fixture): - document, core_properties_ = core_props_fixture core_properties = document.core_properties + assert core_properties is core_properties_ - def it_provides_access_to_its_inline_shapes(self, inline_shapes_fixture): - document, inline_shapes_ = inline_shapes_fixture + def it_provides_access_to_its_inline_shapes(self, document_part_: Mock, inline_shapes_: Mock): + document_part_.inline_shapes = inline_shapes_ + document = Document(cast(CT_Document, element("w:document")), document_part_) + assert document.inline_shapes is inline_shapes_ def it_can_iterate_the_inner_content_of_the_document( self, body_prop_: Mock, body_: Mock, document_part_: Mock ): - document_elm = cast(CT_Document, element("w:document")) body_prop_.return_value = body_ body_.iter_inner_content.return_value = iter((1, 2, 3)) - document = Document(document_elm, document_part_) + document = Document(cast(CT_Document, element("w:document")), document_part_) assert list(document.iter_inner_content()) == [1, 2, 3] - def it_provides_access_to_its_paragraphs(self, paragraphs_fixture): - document, paragraphs_ = paragraphs_fixture + def it_provides_access_to_its_paragraphs( + self, document: Document, body_prop_: Mock, body_: Mock, paragraphs_: Mock + ): + body_prop_.return_value = body_ + body_.paragraphs = paragraphs_ paragraphs = document.paragraphs assert paragraphs is paragraphs_ - def it_provides_access_to_its_sections(self, document_part_, Sections_, sections_): - document_elm = element("w:document") + def it_provides_access_to_its_sections( + self, document_part_: Mock, Sections_: Mock, sections_: Mock + ): + document_elm = cast(CT_Document, element("w:document")) Sections_.return_value = sections_ document = Document(document_elm, document_part_) @@ -132,267 +209,172 @@ def it_provides_access_to_its_sections(self, document_part_, Sections_, sections Sections_.assert_called_once_with(document_elm, document_part_) assert sections is sections_ - def it_provides_access_to_its_settings(self, settings_fixture): - document, settings_ = settings_fixture - assert document.settings is settings_ - - def it_provides_access_to_its_styles(self, styles_fixture): - document, styles_ = styles_fixture - assert document.styles is styles_ + def it_provides_access_to_its_settings(self, document_part_: Mock, settings_: Mock): + document_part_.settings = settings_ + document = Document(cast(CT_Document, element("w:document")), document_part_) - def it_provides_access_to_its_tables(self, tables_fixture): - document, tables_ = tables_fixture - tables = document.tables - assert tables is tables_ + assert document.settings is settings_ - def it_provides_access_to_the_document_part(self, part_fixture): - document, part_ = part_fixture - assert document.part is part_ + def it_provides_access_to_its_styles(self, document_part_: Mock, styles_: Mock): + document_part_.styles = styles_ + document = Document(cast(CT_Document, element("w:document")), document_part_) - def it_provides_access_to_the_document_body(self, body_fixture): - document, body_elm, _Body_, body_ = body_fixture - body = document._body - _Body_.assert_called_once_with(body_elm, document) - assert body is body_ + assert document.styles is styles_ - def it_determines_block_width_to_help(self, block_width_fixture): - document, expected_value = block_width_fixture - width = document._block_width - assert isinstance(width, Length) - assert width == expected_value + def it_provides_access_to_its_tables( + self, document: Document, body_prop_: Mock, body_: Mock, tables_: Mock + ): + body_prop_.return_value = body_ + body_.tables = tables_ - # fixtures ------------------------------------------------------- + assert document.tables is tables_ - @pytest.fixture( - params=[ - (0, "Title"), - (1, "Heading 1"), - (2, "Heading 2"), - (9, "Heading 9"), - ] - ) - def add_heading_fixture(self, request): - level, style = request.param - return level, style - - @pytest.fixture( - params=[ - ("", None), - ("", "Heading 1"), - ("foo\rbar", "Body Text"), - ] - ) - def add_paragraph_fixture(self, request, body_prop_, paragraph_): - text, style = request.param - document = Document(None, None) - body_prop_.return_value.add_paragraph.return_value = paragraph_ - return document, text, style, paragraph_ + def it_provides_access_to_the_document_part(self, document_part_: Mock): + document = Document(cast(CT_Document, element("w:document")), document_part_) + assert document.part is document_part_ - @pytest.fixture - def add_picture_fixture(self, request, add_paragraph_, run_, picture_): - document = Document(None, None) - path, width, height = "foobar.png", 100, 200 - add_paragraph_.return_value.add_run.return_value = run_ - run_.add_picture.return_value = picture_ - return document, path, width, height, run_, picture_ + def it_provides_access_to_the_document_body( + self, _Body_: Mock, body_: Mock, document_part_: Mock + ): + _Body_.return_value = body_ + document_elm = cast(CT_Document, element("w:document/w:body")) + body_elm = document_elm[0] + document = Document(document_elm, document_part_) - @pytest.fixture( - params=[ - ("w:sectPr", WD_SECTION.EVEN_PAGE, "w:sectPr/w:type{w:val=evenPage}"), - ( - "w:sectPr/w:type{w:val=evenPage}", - WD_SECTION.ODD_PAGE, - "w:sectPr/w:type{w:val=oddPage}", - ), - ("w:sectPr/w:type{w:val=oddPage}", WD_SECTION.NEW_PAGE, "w:sectPr"), - ] - ) - def add_section_fixture(self, request): - sentinel, start_type, new_sentinel = request.param - document_elm = element("w:document/w:body/(w:p,%s)" % sentinel) - expected_xml = xml( - "w:document/w:body/(w:p,w:p/w:pPr/%s,%s)" % (sentinel, new_sentinel) - ) - return document_elm, start_type, expected_xml + body = document._body - @pytest.fixture - def add_table_fixture(self, _block_width_prop_, body_prop_, table_): - document = Document(None, None) - rows, cols, style = 4, 2, "Light Shading Accent 1" - body_prop_.return_value.add_table.return_value = table_ - _block_width_prop_.return_value = width = 42 - return document, rows, cols, style, width, table_ + _Body_.assert_called_once_with(body_elm, document) + assert body is body_ - @pytest.fixture - def block_width_fixture(self, sections_prop_, section_): - document = Document(None, None) + def it_determines_block_width_to_help( + self, document: Document, sections_prop_: Mock, section_: Mock + ): sections_prop_.return_value = [None, section_] section_.page_width = 6000 section_.left_margin = 1500 section_.right_margin = 1000 - expected_value = 3500 - return document, expected_value - @pytest.fixture - def body_fixture(self, _Body_, body_): - document_elm = element("w:document/w:body") - body_elm = document_elm[0] - document = Document(document_elm, None) - return document, body_elm, _Body_, body_ - - @pytest.fixture - def core_props_fixture(self, document_part_, core_properties_): - document = Document(None, document_part_) - document_part_.core_properties = core_properties_ - return document, core_properties_ - - @pytest.fixture - def inline_shapes_fixture(self, document_part_, inline_shapes_): - document = Document(None, document_part_) - document_part_.inline_shapes = inline_shapes_ - return document, inline_shapes_ - - @pytest.fixture - def paragraphs_fixture(self, body_prop_, paragraphs_): - document = Document(None, None) - body_prop_.return_value.paragraphs = paragraphs_ - return document, paragraphs_ - - @pytest.fixture - def part_fixture(self, document_part_): - document = Document(None, document_part_) - return document, document_part_ - - @pytest.fixture - def save_fixture(self, document_part_): - document = Document(None, document_part_) - file_ = "foobar.docx" - return document, file_ - - @pytest.fixture - def settings_fixture(self, document_part_, settings_): - document = Document(None, document_part_) - document_part_.settings = settings_ - return document, settings_ - - @pytest.fixture - def styles_fixture(self, document_part_, styles_): - document = Document(None, document_part_) - document_part_.styles = styles_ - return document, styles_ + width = document._block_width - @pytest.fixture - def tables_fixture(self, body_prop_, tables_): - document = Document(None, None) - body_prop_.return_value.tables = tables_ - return document, tables_ + assert isinstance(width, Length) + assert width == 3500 - # fixture components --------------------------------------------- + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def add_paragraph_(self, request): + def add_paragraph_(self, request: FixtureRequest): return method_mock(request, Document, "add_paragraph") @pytest.fixture - def _Body_(self, request, body_): - return class_mock(request, "docx.document._Body", return_value=body_) + def _Body_(self, request: FixtureRequest): + return class_mock(request, "docx.document._Body") @pytest.fixture - def body_(self, request): + def body_(self, request: FixtureRequest): return instance_mock(request, _Body) @pytest.fixture - def _block_width_prop_(self, request): + def _block_width_prop_(self, request: FixtureRequest): return property_mock(request, Document, "_block_width") @pytest.fixture - def body_prop_(self, request, body_): - return property_mock(request, Document, "_body", return_value=body_) + def body_prop_(self, request: FixtureRequest): + return property_mock(request, Document, "_body") @pytest.fixture - def core_properties_(self, request): + def core_properties_(self, request: FixtureRequest): return instance_mock(request, CoreProperties) @pytest.fixture - def document_part_(self, request): + def document(self, document_part_: Mock) -> Document: + document_elm = cast(CT_Document, element("w:document")) + return Document(document_elm, document_part_) + + @pytest.fixture + def document_part_(self, request: FixtureRequest): return instance_mock(request, DocumentPart) @pytest.fixture - def inline_shapes_(self, request): + def inline_shapes_(self, request: FixtureRequest): return instance_mock(request, InlineShapes) @pytest.fixture - def paragraph_(self, request): + def paragraph_(self, request: FixtureRequest): return instance_mock(request, Paragraph) @pytest.fixture - def paragraphs_(self, request): + def paragraphs_(self, request: FixtureRequest): return instance_mock(request, list) @pytest.fixture - def picture_(self, request): + def picture_(self, request: FixtureRequest): return instance_mock(request, InlineShape) @pytest.fixture - def run_(self, request): + def run_(self, request: FixtureRequest): return instance_mock(request, Run) @pytest.fixture - def Section_(self, request): + def Section_(self, request: FixtureRequest): return class_mock(request, "docx.document.Section") @pytest.fixture - def section_(self, request): + def section_(self, request: FixtureRequest): return instance_mock(request, Section) @pytest.fixture - def Sections_(self, request): + def Sections_(self, request: FixtureRequest): return class_mock(request, "docx.document.Sections") @pytest.fixture - def sections_(self, request): + def sections_(self, request: FixtureRequest): return instance_mock(request, Sections) @pytest.fixture - def sections_prop_(self, request): + def sections_prop_(self, request: FixtureRequest): return property_mock(request, Document, "sections") @pytest.fixture - def settings_(self, request): + def settings_(self, request: FixtureRequest): return instance_mock(request, Settings) @pytest.fixture - def styles_(self, request): + def styles_(self, request: FixtureRequest): return instance_mock(request, Styles) @pytest.fixture - def table_(self, request): - return instance_mock(request, Table, style="UNASSIGNED") + def table_(self, request: FixtureRequest): + return instance_mock(request, Table) @pytest.fixture - def tables_(self, request): + def tables_(self, request: FixtureRequest): return instance_mock(request, list) class Describe_Body: - def it_can_clear_itself_of_all_content_it_holds(self, clear_fixture): - body, expected_xml = clear_fixture - _body = body.clear_content() - assert body._body.xml == expected_xml - assert _body is body + """Unit-test suite for `docx.document._Body`.""" - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("cxml", "expected_cxml"), + [ ("w:body", "w:body"), ("w:body/w:p", "w:body"), ("w:body/w:sectPr", "w:body/w:sectPr"), ("w:body/(w:p, w:sectPr)", "w:body/w:sectPr"), - ] + ], ) - def clear_fixture(self, request): - before_cxml, after_cxml = request.param - body = _Body(element(before_cxml), None) - expected_xml = xml(after_cxml) - return body, expected_xml + def it_can_clear_itself_of_all_content_it_holds( + self, cxml: str, expected_cxml: str, document_: Mock + ): + body = _Body(cast(CT_Body, element(cxml)), document_) + + _body = body.clear_content() + + assert body._body.xml == xml(expected_cxml) + assert _body is body + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def document_(self, request: FixtureRequest): + return instance_mock(request, Document) diff --git a/tests/test_package.py b/tests/test_package.py index eda5f0132..ac9839828 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -1,5 +1,9 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for docx.package module.""" +from __future__ import annotations + import pytest from docx.image.image import Image @@ -8,12 +12,21 @@ from docx.parts.image import ImagePart from .unitutil.file import docx_path -from .unitutil.mock import class_mock, instance_mock, method_mock, property_mock +from .unitutil.mock import ( + FixtureRequest, + Mock, + class_mock, + instance_mock, + method_mock, + property_mock, +) class DescribePackage: + """Unit-test suite for `docx.package.Package`.""" + def it_can_get_or_add_an_image_part_containing_a_specified_image( - self, image_parts_prop_, image_parts_, image_part_ + self, image_parts_prop_: Mock, image_parts_: Mock, image_part_: Mock ): image_parts_prop_.return_value = image_parts_ image_parts_.get_or_add_image_part.return_value = image_part_ @@ -26,29 +39,36 @@ def it_can_get_or_add_an_image_part_containing_a_specified_image( def it_gathers_package_image_parts_after_unmarshalling(self): package = Package.open(docx_path("having-images")) + image_parts = package.image_parts + assert len(image_parts) == 3 - for image_part in image_parts: - assert isinstance(image_part, ImagePart) + assert all(isinstance(p, ImagePart) for p in image_parts) # fixture components --------------------------------------------- @pytest.fixture - def image_part_(self, request): + def image_part_(self, request: FixtureRequest): return instance_mock(request, ImagePart) @pytest.fixture - def image_parts_(self, request): + def image_parts_(self, request: FixtureRequest): return instance_mock(request, ImageParts) @pytest.fixture - def image_parts_prop_(self, request): + def image_parts_prop_(self, request: FixtureRequest): return property_mock(request, Package, "image_parts") class DescribeImageParts: + """Unit-test suite for `docx.package.Package`.""" + def it_can_get_a_matching_image_part( - self, Image_, image_, _get_by_sha1_, image_part_ + self, + Image_: Mock, + image_: Mock, + _get_by_sha1_: Mock, + image_part_: Mock, ): Image_.from_file.return_value = image_ image_.sha1 = "f005ba11" @@ -62,7 +82,12 @@ def it_can_get_a_matching_image_part( assert image_part is image_part_ def but_it_adds_a_new_image_part_when_match_fails( - self, Image_, image_, _get_by_sha1_, _add_image_part_, image_part_ + self, + Image_: Mock, + image_: Mock, + _get_by_sha1_: Mock, + _add_image_part_: Mock, + image_part_: Mock, ): Image_.from_file.return_value = image_ image_.sha1 = "fa1afe1" @@ -77,73 +102,74 @@ def but_it_adds_a_new_image_part_when_match_fails( _add_image_part_.assert_called_once_with(image_parts, image_) assert image_part is image_part_ - def it_knows_the_next_available_image_partname(self, next_partname_fixture): - image_parts, ext, expected_partname = next_partname_fixture - assert image_parts._next_image_partname(ext) == expected_partname + @pytest.mark.parametrize( + ("existing_partname_numbers", "expected_partname_number"), + [ + ((2, 3), 1), + ((1, 3), 2), + ((1, 2), 3), + ], + ) + def it_knows_the_next_available_image_partname( + self, + request: FixtureRequest, + existing_partname_numbers: tuple[int, int], + expected_partname_number: int, + ): + image_parts = ImageParts() + for n in existing_partname_numbers: + image_parts.append( + instance_mock(request, ImagePart, partname=PackURI(f"/word/media/image{n}.png")) + ) + + next_partname = image_parts._next_image_partname("png") - def it_can_really_add_a_new_image_part( - self, _next_image_partname_, partname_, image_, ImagePart_, image_part_ + assert next_partname == PackURI("/word/media/image%d.png" % expected_partname_number) + + def it_can_add_a_new_image_part( + self, + _next_image_partname_: Mock, + image_: Mock, + ImagePart_: Mock, + image_part_: Mock, ): - _next_image_partname_.return_value = partname_ + partname = PackURI("/word/media/image7.png") + _next_image_partname_.return_value = partname ImagePart_.from_image.return_value = image_part_ image_parts = ImageParts() image_part = image_parts._add_image_part(image_) - ImagePart_.from_image.assert_called_once_with(image_, partname_) + ImagePart_.from_image.assert_called_once_with(image_, partname) assert image_part in image_parts assert image_part is image_part_ # fixtures ------------------------------------------------------- - @pytest.fixture(params=[((2, 3), 1), ((1, 3), 2), ((1, 2), 3)]) - def next_partname_fixture(self, request): - def image_part_with_partname_(n): - partname = image_partname(n) - return instance_mock(request, ImagePart, partname=partname) - - def image_partname(n): - return PackURI("/word/media/image%d.png" % n) - - existing_partname_numbers, expected_partname_number = request.param - image_parts = ImageParts() - for n in existing_partname_numbers: - image_part_ = image_part_with_partname_(n) - image_parts.append(image_part_) - ext = "png" - expected_image_partname = image_partname(expected_partname_number) - return image_parts, ext, expected_image_partname - - # fixture components --------------------------------------------- - @pytest.fixture - def _add_image_part_(self, request): + def _add_image_part_(self, request: FixtureRequest): return method_mock(request, ImageParts, "_add_image_part") @pytest.fixture - def _get_by_sha1_(self, request): + def _get_by_sha1_(self, request: FixtureRequest): return method_mock(request, ImageParts, "_get_by_sha1") @pytest.fixture - def Image_(self, request): + def Image_(self, request: FixtureRequest): return class_mock(request, "docx.package.Image") @pytest.fixture - def image_(self, request): + def image_(self, request: FixtureRequest): return instance_mock(request, Image) @pytest.fixture - def ImagePart_(self, request): + def ImagePart_(self, request: FixtureRequest): return class_mock(request, "docx.package.ImagePart") @pytest.fixture - def image_part_(self, request): + def image_part_(self, request: FixtureRequest): return instance_mock(request, ImagePart) @pytest.fixture - def _next_image_partname_(self, request): + def _next_image_partname_(self, request: FixtureRequest): return method_mock(request, ImageParts, "_next_image_partname") - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) diff --git a/tests/test_section.py b/tests/test_section.py index 333e755b7..54d665768 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -65,9 +65,7 @@ def it_can_access_its_Section_instances_by_index( ): document_elm = cast( CT_Document, - element( - "w:document/w:body/(w:p/w:pPr/w:sectPr,w:p/w:pPr/w:sectPr,w:sectPr)" - ), + element("w:document/w:body/(w:p/w:pPr/w:sectPr,w:p/w:pPr/w:sectPr,w:sectPr)"), ) sectPrs = document_elm.xpath("//w:sectPr") Section_.return_value = section_ @@ -87,9 +85,7 @@ def it_can_access_its_Section_instances_by_slice( ): document_elm = cast( CT_Document, - element( - "w:document/w:body/(w:p/w:pPr/w:sectPr,w:p/w:pPr/w:sectPr,w:sectPr)" - ), + element("w:document/w:body/(w:p/w:pPr/w:sectPr,w:p/w:pPr/w:sectPr,w:sectPr)"), ) sectPrs = document_elm.xpath("//w:sectPr") Section_.return_value = section_ @@ -103,7 +99,7 @@ def it_can_access_its_Section_instances_by_slice( ] assert section_lst == [section_, section_] - # fixture components --------------------------------------------- + # -- fixtures--------------------------------------------------------------------------------- @pytest.fixture def document_part_(self, request: FixtureRequest): @@ -170,9 +166,7 @@ def it_provides_access_to_its_even_page_footer( footer = section.even_page_footer - _Footer_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE - ) + _Footer_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) assert footer is footer_ def it_provides_access_to_its_even_page_header( @@ -184,9 +178,7 @@ def it_provides_access_to_its_even_page_header( header = section.even_page_header - _Header_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE - ) + _Header_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) assert header is header_ def it_provides_access_to_its_first_page_footer( @@ -198,9 +190,7 @@ def it_provides_access_to_its_first_page_footer( footer = section.first_page_footer - _Footer_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE - ) + _Footer_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE) assert footer is footer_ def it_provides_access_to_its_first_page_header( @@ -212,9 +202,7 @@ def it_provides_access_to_its_first_page_header( header = section.first_page_header - _Header_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE - ) + _Header_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE) assert header is header_ def it_provides_access_to_its_default_footer( @@ -226,9 +214,7 @@ def it_provides_access_to_its_default_footer( footer = section.footer - _Footer_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY - ) + _Footer_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) assert footer is footer_ def it_provides_access_to_its_default_header( @@ -240,9 +226,7 @@ def it_provides_access_to_its_default_header( header = section.header - _Header_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY - ) + _Header_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) assert header is header_ def it_can_iterate_its_inner_content(self): @@ -562,20 +546,16 @@ def header_(self, request: FixtureRequest): class Describe_BaseHeaderFooter: """Unit-test suite for `docx.section._BaseHeaderFooter`.""" - @pytest.mark.parametrize( - ("has_definition", "expected_value"), [(False, True), (True, False)] - ) + @pytest.mark.parametrize(("has_definition", "expected_value"), [(False, True), (True, False)]) def it_knows_when_its_linked_to_the_previous_header_or_footer( - self, has_definition: bool, expected_value: bool, _has_definition_prop_: Mock + self, + has_definition: bool, + expected_value: bool, + header: _BaseHeaderFooter, + _has_definition_prop_: Mock, ): _has_definition_prop_.return_value = has_definition - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) - - is_linked = header.is_linked_to_previous - - assert is_linked is expected_value + assert header.is_linked_to_previous is expected_value @pytest.mark.parametrize( ("has_definition", "value", "drop_calls", "add_calls"), @@ -592,14 +572,12 @@ def it_can_change_whether_it_is_linked_to_previous_header_or_footer( value: bool, drop_calls: int, add_calls: int, + header: _BaseHeaderFooter, _has_definition_prop_: Mock, _drop_definition_: Mock, _add_definition_: Mock, ): _has_definition_prop_.return_value = has_definition - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) header.is_linked_to_previous = value @@ -607,13 +585,10 @@ def it_can_change_whether_it_is_linked_to_previous_header_or_footer( assert _add_definition_.call_args_list == [call(header)] * add_calls def it_provides_access_to_the_header_or_footer_part_for_BlockItemContainer( - self, _get_or_add_definition_: Mock, header_part_: Mock + self, header: _BaseHeaderFooter, _get_or_add_definition_: Mock, header_part_: Mock ): # ---this override fulfills part of the BlockItemContainer subclass interface--- _get_or_add_definition_.return_value = header_part_ - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) header_part = header.part @@ -621,14 +596,11 @@ def it_provides_access_to_the_header_or_footer_part_for_BlockItemContainer( assert header_part is header_part_ def it_provides_access_to_the_hdr_or_ftr_element_to_help( - self, _get_or_add_definition_: Mock, header_part_: Mock + self, header: _BaseHeaderFooter, _get_or_add_definition_: Mock, header_part_: Mock ): hdr = element("w:hdr") _get_or_add_definition_.return_value = header_part_ header_part_.element = hdr - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) hdr_elm = header._element @@ -636,13 +608,14 @@ def it_provides_access_to_the_hdr_or_ftr_element_to_help( assert hdr_elm is hdr def it_gets_the_definition_when_it_has_one( - self, _has_definition_prop_: Mock, _definition_prop_: Mock, header_part_: Mock + self, + header: _BaseHeaderFooter, + _has_definition_prop_: Mock, + _definition_prop_: Mock, + header_part_: Mock, ): _has_definition_prop_.return_value = True _definition_prop_.return_value = header_part_ - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) header_part = header._get_or_add_definition() @@ -650,6 +623,7 @@ def it_gets_the_definition_when_it_has_one( def but_it_gets_the_prior_definition_when_it_is_linked( self, + header: _BaseHeaderFooter, _has_definition_prop_: Mock, _prior_headerfooter_prop_: Mock, prior_headerfooter_: Mock, @@ -658,9 +632,6 @@ def but_it_gets_the_prior_definition_when_it_is_linked( _has_definition_prop_.return_value = False _prior_headerfooter_prop_.return_value = prior_headerfooter_ prior_headerfooter_._get_or_add_definition.return_value = header_part_ - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) header_part = header._get_or_add_definition() @@ -669,6 +640,7 @@ def but_it_gets_the_prior_definition_when_it_is_linked( def and_it_adds_a_definition_when_it_is_linked_and_the_first_section( self, + header: _BaseHeaderFooter, _has_definition_prop_: Mock, _prior_headerfooter_prop_: Mock, _add_definition_: Mock, @@ -677,9 +649,6 @@ def and_it_adds_a_definition_when_it_is_linked_and_the_first_section( _has_definition_prop_.return_value = False _prior_headerfooter_prop_.return_value = None _add_definition_.return_value = header_part_ - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) header_part = header._get_or_add_definition() @@ -696,6 +665,10 @@ def _add_definition_(self, request: FixtureRequest): def _definition_prop_(self, request: FixtureRequest): return property_mock(request, _BaseHeaderFooter, "_definition") + @pytest.fixture + def document_part_(self, request: FixtureRequest): + return instance_mock(request, DocumentPart) + @pytest.fixture def _drop_definition_(self, request: FixtureRequest): return method_mock(request, _BaseHeaderFooter, "_drop_definition") @@ -708,6 +681,11 @@ def _get_or_add_definition_(self, request: FixtureRequest): def _has_definition_prop_(self, request: FixtureRequest): return property_mock(request, _BaseHeaderFooter, "_has_definition") + @pytest.fixture + def header(self, document_part_: Mock) -> _BaseHeaderFooter: + sectPr = cast(CT_SectPr, element("w:sectPr")) + return _BaseHeaderFooter(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) + @pytest.fixture def header_part_(self, request: FixtureRequest): return instance_mock(request, HeaderPart) @@ -724,25 +702,21 @@ def _prior_headerfooter_prop_(self, request: FixtureRequest): class Describe_Footer: """Unit-test suite for `docx.section._Footer`.""" - def it_can_add_a_footer_part_to_help( - self, document_part_: Mock, footer_part_: Mock - ): - sectPr = element("w:sectPr{r:a=b}") + def it_can_add_a_footer_part_to_help(self, document_part_: Mock, footer_part_: Mock): + sectPr = cast(CT_SectPr, element("w:sectPr{r:a=b}")) document_part_.add_footer_part.return_value = footer_part_, "rId3" footer = _Footer(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) footer_part = footer._add_definition() document_part_.add_footer_part.assert_called_once_with() - assert sectPr.xml == xml( - "w:sectPr{r:a=b}/w:footerReference{w:type=default,r:id=rId3}" - ) + assert sectPr.xml == xml("w:sectPr{r:a=b}/w:footerReference{w:type=default,r:id=rId3}") assert footer_part is footer_part_ def it_provides_access_to_its_footer_part_to_help( self, document_part_: Mock, footer_part_: Mock ): - sectPr = element("w:sectPr/w:footerReference{w:type=even,r:id=rId3}") + sectPr = cast(CT_SectPr, element("w:sectPr/w:footerReference{w:type=even,r:id=rId3}")) document_part_.footer_part.return_value = footer_part_ footer = _Footer(sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) @@ -752,7 +726,9 @@ def it_provides_access_to_its_footer_part_to_help( assert footer_part is footer_part_ def it_can_drop_the_related_footer_part_to_help(self, document_part_: Mock): - sectPr = element("w:sectPr{r:a=b}/w:footerReference{w:type=first,r:id=rId42}") + sectPr = cast( + CT_SectPr, element("w:sectPr{r:a=b}/w:footerReference{w:type=first,r:id=rId42}") + ) footer = _Footer(sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE) footer._drop_definition() @@ -778,28 +754,26 @@ def it_provides_access_to_the_prior_Footer_to_help( self, request: FixtureRequest, document_part_: Mock, footer_: Mock ): doc_elm = element("w:document/(w:sectPr,w:sectPr)") - prior_sectPr, sectPr = doc_elm[0], doc_elm[1] + prior_sectPr, sectPr = cast(CT_SectPr, doc_elm[0]), cast(CT_SectPr, doc_elm[1]) footer = _Footer(sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) # ---mock must occur after construction of "real" footer--- _Footer_ = class_mock(request, "docx.section._Footer", return_value=footer_) prior_footer = footer._prior_headerfooter - _Footer_.assert_called_once_with( - prior_sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE - ) + _Footer_.assert_called_once_with(prior_sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) assert prior_footer is footer_ - def but_it_returns_None_when_its_the_first_footer(self): + def but_it_returns_None_when_its_the_first_footer(self, document_part_: Mock): doc_elm = cast(CT_Document, element("w:document/w:sectPr")) - sectPr = doc_elm[0] - footer = _Footer(sectPr, None, None) + sectPr = cast(CT_SectPr, doc_elm[0]) + footer = _Footer(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) prior_footer = footer._prior_headerfooter assert prior_footer is None - # -- fixtures ---------------------------------------------------- + # -- fixtures--------------------------------------------------------------------------------- @pytest.fixture def document_part_(self, request: FixtureRequest): @@ -815,25 +789,23 @@ def footer_part_(self, request: FixtureRequest): class Describe_Header: - def it_can_add_a_header_part_to_help( - self, document_part_: Mock, header_part_: Mock - ): - sectPr = element("w:sectPr{r:a=b}") + """Unit-test suite for `docx.section._Header`.""" + + def it_can_add_a_header_part_to_help(self, document_part_: Mock, header_part_: Mock): + sectPr = cast(CT_SectPr, element("w:sectPr{r:a=b}")) document_part_.add_header_part.return_value = header_part_, "rId3" header = _Header(sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE) header_part = header._add_definition() document_part_.add_header_part.assert_called_once_with() - assert sectPr.xml == xml( - "w:sectPr{r:a=b}/w:headerReference{w:type=first,r:id=rId3}" - ) + assert sectPr.xml == xml("w:sectPr{r:a=b}/w:headerReference{w:type=first,r:id=rId3}") assert header_part is header_part_ def it_provides_access_to_its_header_part_to_help( self, document_part_: Mock, header_part_: Mock ): - sectPr = element("w:sectPr/w:headerReference{w:type=default,r:id=rId8}") + sectPr = cast(CT_SectPr, element("w:sectPr/w:headerReference{w:type=default,r:id=rId8}")) document_part_.header_part.return_value = header_part_ header = _Header(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) @@ -843,7 +815,9 @@ def it_provides_access_to_its_header_part_to_help( assert header_part is header_part_ def it_can_drop_the_related_header_part_to_help(self, document_part_: Mock): - sectPr = element("w:sectPr{r:a=b}/w:headerReference{w:type=even,r:id=rId42}") + sectPr = cast( + CT_SectPr, element("w:sectPr{r:a=b}/w:headerReference{w:type=even,r:id=rId42}") + ) header = _Header(sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) header._drop_definition() @@ -866,31 +840,29 @@ def it_knows_when_it_has_a_header_part_to_help( assert has_definition is expected_value def it_provides_access_to_the_prior_Header_to_help( - self, request, document_part_: Mock, header_: Mock + self, request: FixtureRequest, document_part_: Mock, header_: Mock ): doc_elm = element("w:document/(w:sectPr,w:sectPr)") - prior_sectPr, sectPr = doc_elm[0], doc_elm[1] + prior_sectPr, sectPr = cast(CT_SectPr, doc_elm[0]), cast(CT_SectPr, doc_elm[1]) header = _Header(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) # ---mock must occur after construction of "real" header--- _Header_ = class_mock(request, "docx.section._Header", return_value=header_) prior_header = header._prior_headerfooter - _Header_.assert_called_once_with( - prior_sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY - ) + _Header_.assert_called_once_with(prior_sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) assert prior_header is header_ - def but_it_returns_None_when_its_the_first_header(self): + def but_it_returns_None_when_its_the_first_header(self, document_part_: Mock): doc_elm = element("w:document/w:sectPr") - sectPr = doc_elm[0] - header = _Header(sectPr, None, None) + sectPr = cast(CT_SectPr, doc_elm[0]) + header = _Header(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) prior_header = header._prior_headerfooter assert prior_header is None - # -- fixtures----------------------------------------------------- + # -- fixtures--------------------------------------------------------------------------------- @pytest.fixture def document_part_(self, request: FixtureRequest): diff --git a/tests/test_settings.py b/tests/test_settings.py index 9f430822d..ff07eda26 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,5 +1,9 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for the docx.settings module.""" +from __future__ import annotations + import pytest from docx.settings import Settings @@ -8,56 +12,37 @@ class DescribeSettings: - def it_knows_when_the_document_has_distinct_odd_and_even_headers( - self, odd_and_even_get_fixture - ): - settings_elm, expected_value = odd_and_even_get_fixture - settings = Settings(settings_elm) - - odd_and_even_pages_header_footer = settings.odd_and_even_pages_header_footer - - assert odd_and_even_pages_header_footer is expected_value - - def it_can_change_whether_the_document_has_distinct_odd_and_even_headers( - self, odd_and_even_set_fixture - ): - settings_elm, value, expected_xml = odd_and_even_set_fixture - settings = Settings(settings_elm) + """Unit-test suite for the `docx.settings.Settings` objects.""" - settings.odd_and_even_pages_header_footer = value - - assert settings_elm.xml == expected_xml - - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ ("w:settings", False), ("w:settings/w:evenAndOddHeaders", True), ("w:settings/w:evenAndOddHeaders{w:val=0}", False), ("w:settings/w:evenAndOddHeaders{w:val=1}", True), ("w:settings/w:evenAndOddHeaders{w:val=true}", True), - ] + ], ) - def odd_and_even_get_fixture(self, request): - settings_cxml, expected_value = request.param - settings_elm = element(settings_cxml) - return settings_elm, expected_value + def it_knows_when_the_document_has_distinct_odd_and_even_headers( + self, cxml: str, expected_value: bool + ): + assert Settings(element(cxml)).odd_and_even_pages_header_footer is expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("cxml", "new_value", "expected_cxml"), + [ ("w:settings", True, "w:settings/w:evenAndOddHeaders"), ("w:settings/w:evenAndOddHeaders", False, "w:settings"), - ( - "w:settings/w:evenAndOddHeaders{w:val=1}", - True, - "w:settings/w:evenAndOddHeaders", - ), + ("w:settings/w:evenAndOddHeaders{w:val=1}", True, "w:settings/w:evenAndOddHeaders"), ("w:settings/w:evenAndOddHeaders{w:val=off}", False, "w:settings"), - ] + ], ) - def odd_and_even_set_fixture(self, request): - settings_cxml, value, expected_cxml = request.param - settings_elm = element(settings_cxml) - expected_xml = xml(expected_cxml) - return settings_elm, value, expected_xml + def it_can_change_whether_the_document_has_distinct_odd_and_even_headers( + self, cxml: str, new_value: bool, expected_cxml: str + ): + settings = Settings(element(cxml)) + + settings.odd_and_even_pages_header_footer = new_value + + assert settings._settings.xml == xml(expected_cxml) diff --git a/tests/test_shape.py b/tests/test_shape.py index da307e48f..68998b90e 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -1,194 +1,129 @@ +# pyright: reportPrivateUsage=false + """Test suite for the docx.shape module.""" +from __future__ import annotations + +from typing import cast + import pytest +from docx.document import Document from docx.enum.shape import WD_INLINE_SHAPE +from docx.oxml.document import CT_Body from docx.oxml.ns import nsmap +from docx.oxml.shape import CT_Inline from docx.shape import InlineShape, InlineShapes -from docx.shared import Length - -from .oxml.unitdata.dml import ( - a_blip, - a_blipFill, - a_graphic, - a_graphicData, - a_pic, - an_inline, -) +from docx.shared import Emu, Length + from .unitutil.cxml import element, xml -from .unitutil.mock import loose_mock +from .unitutil.mock import FixtureRequest, Mock, instance_mock class DescribeInlineShapes: - def it_knows_how_many_inline_shapes_it_contains(self, inline_shapes_fixture): - inline_shapes, expected_count = inline_shapes_fixture - assert len(inline_shapes) == expected_count - - def it_can_iterate_over_its_InlineShape_instances(self, inline_shapes_fixture): - inline_shapes, inline_shape_count = inline_shapes_fixture - actual_count = 0 - for inline_shape in inline_shapes: - assert isinstance(inline_shape, InlineShape) - actual_count += 1 - assert actual_count == inline_shape_count - - def it_provides_indexed_access_to_inline_shapes(self, inline_shapes_fixture): - inline_shapes, inline_shape_count = inline_shapes_fixture - for idx in range(-inline_shape_count, inline_shape_count): - inline_shape = inline_shapes[idx] - assert isinstance(inline_shape, InlineShape) - - def it_raises_on_indexed_access_out_of_range(self, inline_shapes_fixture): - inline_shapes, inline_shape_count = inline_shapes_fixture - too_low = -1 - inline_shape_count - with pytest.raises(IndexError, match=r"inline shape index \[-3\] out of rang"): - inline_shapes[too_low] - too_high = inline_shape_count + """Unit-test suite for `docx.shape.InlineShapes` objects.""" + + def it_knows_how_many_inline_shapes_it_contains(self, body: CT_Body, document_: Mock): + inline_shapes = InlineShapes(body, document_) + assert len(inline_shapes) == 2 + + def it_can_iterate_over_its_InlineShape_instances(self, body: CT_Body, document_: Mock): + inline_shapes = InlineShapes(body, document_) + assert all(isinstance(s, InlineShape) for s in inline_shapes) + assert len(list(inline_shapes)) == 2 + + def it_provides_indexed_access_to_inline_shapes(self, body: CT_Body, document_: Mock): + inline_shapes = InlineShapes(body, document_) + for idx in range(-2, 2): + assert isinstance(inline_shapes[idx], InlineShape) + + def it_raises_on_indexed_access_out_of_range(self, body: CT_Body, document_: Mock): + inline_shapes = InlineShapes(body, document_) + + with pytest.raises(IndexError, match=r"inline shape index \[-3\] out of range"): + inline_shapes[-3] with pytest.raises(IndexError, match=r"inline shape index \[2\] out of range"): - inline_shapes[too_high] + inline_shapes[2] - def it_knows_the_part_it_belongs_to(self, inline_shapes_with_parent_): - inline_shapes, parent_ = inline_shapes_with_parent_ - part = inline_shapes.part - assert part is parent_.part + def it_knows_the_part_it_belongs_to(self, body: CT_Body, document_: Mock): + inline_shapes = InlineShapes(body, document_) + assert inline_shapes.part is document_.part - # fixtures ------------------------------------------------------- + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def inline_shapes_fixture(self): - body = element("w:body/w:p/(w:r/w:drawing/wp:inline, w:r/w:drawing/wp:inline)") - inline_shapes = InlineShapes(body, None) - expected_count = 2 - return inline_shapes, expected_count - - # fixture components --------------------------------------------- + def body(self) -> CT_Body: + return cast( + CT_Body, element("w:body/w:p/(w:r/w:drawing/wp:inline, w:r/w:drawing/wp:inline)") + ) @pytest.fixture - def inline_shapes_with_parent_(self, request): - parent_ = loose_mock(request, name="parent_") - inline_shapes = InlineShapes(None, parent_) - return inline_shapes, parent_ + def document_(self, request: FixtureRequest): + return instance_mock(request, Document) class DescribeInlineShape: - def it_knows_what_type_of_shape_it_is(self, shape_type_fixture): - inline_shape, inline_shape_type = shape_type_fixture - assert inline_shape.type == inline_shape_type - - def it_knows_its_display_dimensions(self, dimensions_get_fixture): - inline_shape, cx, cy = dimensions_get_fixture - width = inline_shape.width - height = inline_shape.height - assert isinstance(width, Length) - assert width == cx - assert isinstance(height, Length) - assert height == cy + """Unit-test suite for `docx.shape.InlineShape` objects.""" + + @pytest.mark.parametrize( + ("uri", "content_cxml", "expected_value"), + [ + # -- embedded picture -- + (nsmap["pic"], "/pic:pic/pic:blipFill/a:blip{r:embed=rId1}", WD_INLINE_SHAPE.PICTURE), + # -- linked picture -- + ( + nsmap["pic"], + "/pic:pic/pic:blipFill/a:blip{r:link=rId2}", + WD_INLINE_SHAPE.LINKED_PICTURE, + ), + # -- linked and embedded picture (not expected) -- + ( + nsmap["pic"], + "/pic:pic/pic:blipFill/a:blip{r:embed=rId1,r:link=rId2}", + WD_INLINE_SHAPE.LINKED_PICTURE, + ), + # -- chart -- + (nsmap["c"], "", WD_INLINE_SHAPE.CHART), + # -- SmartArt -- + (nsmap["dgm"], "", WD_INLINE_SHAPE.SMART_ART), + # -- something else we don't know about -- + ("foobar", "", WD_INLINE_SHAPE.NOT_IMPLEMENTED), + ], + ) + def it_knows_what_type_of_shape_it_is( + self, uri: str, content_cxml: str, expected_value: WD_INLINE_SHAPE + ): + cxml = "wp:inline/a:graphic/a:graphicData{uri=%s}%s" % (uri, content_cxml) + inline = cast(CT_Inline, element(cxml)) + inline_shape = InlineShape(inline) + assert inline_shape.type == expected_value - def it_can_change_its_display_dimensions(self, dimensions_set_fixture): - inline_shape, cx, cy, expected_xml = dimensions_set_fixture - inline_shape.width = cx - inline_shape.height = cy - assert inline_shape._inline.xml == expected_xml + def it_knows_its_display_dimensions(self): + inline = cast(CT_Inline, element("wp:inline/wp:extent{cx=333, cy=666}")) + inline_shape = InlineShape(inline) - # fixtures ------------------------------------------------------- + width, height = inline_shape.width, inline_shape.height - @pytest.fixture - def dimensions_get_fixture(self): - inline_cxml, expected_cx, expected_cy = ( - "wp:inline/wp:extent{cx=333, cy=666}", - 333, - 666, + assert isinstance(width, Length) + assert width == 333 + assert isinstance(height, Length) + assert height == 666 + + def it_can_change_its_display_dimensions(self): + inline_shape = InlineShape( + cast( + CT_Inline, + element( + "wp:inline/(wp:extent{cx=333,cy=666},a:graphic/a:graphicData/pic:pic/" + "pic:spPr/a:xfrm/a:ext{cx=333,cy=666})" + ), + ) ) - inline_shape = InlineShape(element(inline_cxml)) - return inline_shape, expected_cx, expected_cy - @pytest.fixture - def dimensions_set_fixture(self): - inline_cxml, new_cx, new_cy, expected_cxml = ( - "wp:inline/(wp:extent{cx=333,cy=666},a:graphic/a:graphicData/" - "pic:pic/pic:spPr/a:xfrm/a:ext{cx=333,cy=666})", - 444, - 888, - "wp:inline/(wp:extent{cx=444,cy=888},a:graphic/a:graphicData/" - "pic:pic/pic:spPr/a:xfrm/a:ext{cx=444,cy=888})", + inline_shape.width = Emu(444) + inline_shape.height = Emu(888) + + assert inline_shape._inline.xml == xml( + "wp:inline/(wp:extent{cx=444,cy=888},a:graphic/a:graphicData/pic:pic/pic:spPr/" + "a:xfrm/a:ext{cx=444,cy=888})" ) - inline_shape = InlineShape(element(inline_cxml)) - expected_xml = xml(expected_cxml) - return inline_shape, new_cx, new_cy, expected_xml - - @pytest.fixture( - params=[ - "embed pic", - "link pic", - "link+embed pic", - "chart", - "smart art", - "not implemented", - ] - ) - def shape_type_fixture(self, request): - if request.param == "embed pic": - inline = self._inline_with_picture(embed=True) - shape_type = WD_INLINE_SHAPE.PICTURE - - elif request.param == "link pic": - inline = self._inline_with_picture(link=True) - shape_type = WD_INLINE_SHAPE.LINKED_PICTURE - - elif request.param == "link+embed pic": - inline = self._inline_with_picture(embed=True, link=True) - shape_type = WD_INLINE_SHAPE.LINKED_PICTURE - - elif request.param == "chart": - inline = self._inline_with_uri(nsmap["c"]) - shape_type = WD_INLINE_SHAPE.CHART - - elif request.param == "smart art": - inline = self._inline_with_uri(nsmap["dgm"]) - shape_type = WD_INLINE_SHAPE.SMART_ART - - elif request.param == "not implemented": - inline = self._inline_with_uri("foobar") - shape_type = WD_INLINE_SHAPE.NOT_IMPLEMENTED - - return InlineShape(inline), shape_type - - # fixture components --------------------------------------------- - - def _inline_with_picture(self, embed=False, link=False): - picture_ns = nsmap["pic"] - - blip_bldr = a_blip() - if embed: - blip_bldr.with_embed("rId1") - if link: - blip_bldr.with_link("rId2") - - inline = ( - an_inline() - .with_nsdecls("wp", "r") - .with_child( - a_graphic() - .with_nsdecls() - .with_child( - a_graphicData() - .with_uri(picture_ns) - .with_child( - a_pic() - .with_nsdecls() - .with_child(a_blipFill().with_child(blip_bldr)) - ) - ) - ) - ).element - return inline - - def _inline_with_uri(self, uri): - inline = ( - an_inline() - .with_nsdecls("wp") - .with_child( - a_graphic().with_nsdecls().with_child(a_graphicData().with_uri(uri)) - ) - ).element - return inline diff --git a/tests/test_shared.py b/tests/test_shared.py index 3fbe54b07..fb6c273cb 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -1,17 +1,25 @@ """Test suite for the docx.shared module.""" +from __future__ import annotations + import pytest from docx.opc.part import XmlPart from docx.shared import Cm, ElementProxy, Emu, Inches, Length, Mm, Pt, RGBColor, Twips from .unitutil.cxml import element -from .unitutil.mock import instance_mock +from .unitutil.mock import FixtureRequest, Mock, instance_mock class DescribeElementProxy: - def it_knows_when_its_equal_to_another_proxy_object(self, eq_fixture): - proxy, proxy_2, proxy_3, not_a_proxy = eq_fixture + """Unit-test suite for `docx.shared.ElementProxy` objects.""" + + def it_knows_when_its_equal_to_another_proxy_object(self): + p, q = element("w:p"), element("w:p") + proxy = ElementProxy(p) + proxy_2 = ElementProxy(p) + proxy_3 = ElementProxy(q) + not_a_proxy = "Foobar" assert (proxy == proxy_2) is True assert (proxy == proxy_3) is False @@ -21,66 +29,33 @@ def it_knows_when_its_equal_to_another_proxy_object(self, eq_fixture): assert (proxy != proxy_3) is True assert (proxy != not_a_proxy) is True - def it_knows_its_element(self, element_fixture): - proxy, element = element_fixture - assert proxy.element is element - - def it_knows_its_part(self, part_fixture): - proxy, part_ = part_fixture - assert proxy.part is part_ - - # fixture -------------------------------------------------------- - - @pytest.fixture - def element_fixture(self): + def it_knows_its_element(self): p = element("w:p") proxy = ElementProxy(p) - return proxy, p - - @pytest.fixture - def eq_fixture(self): - p, q = element("w:p"), element("w:p") - proxy = ElementProxy(p) - proxy_2 = ElementProxy(p) - proxy_3 = ElementProxy(q) - not_a_proxy = "Foobar" - return proxy, proxy_2, proxy_3, not_a_proxy + assert proxy.element is p - @pytest.fixture - def part_fixture(self, other_proxy_, part_): + def it_knows_its_part(self, other_proxy_: Mock, part_: Mock): other_proxy_.part = part_ - proxy = ElementProxy(None, other_proxy_) - return proxy, part_ + proxy = ElementProxy(element("w:p"), other_proxy_) + assert proxy.part is part_ - # fixture components --------------------------------------------- + # -- fixture --------------------------------------------------------------------------------- @pytest.fixture - def other_proxy_(self, request): + def other_proxy_(self, request: FixtureRequest): return instance_mock(request, ElementProxy) @pytest.fixture - def part_(self, request): + def part_(self, request: FixtureRequest): return instance_mock(request, XmlPart) class DescribeLength: - def it_can_construct_from_convenient_units(self, construct_fixture): - UnitCls, units_val, emu = construct_fixture - length = UnitCls(units_val) - assert isinstance(length, Length) - assert length == emu - - def it_can_self_convert_to_convenient_units(self, units_fixture): - emu, units_prop_name, expected_length_in_units, type_ = units_fixture - length = Length(emu) - length_in_units = getattr(length, units_prop_name) - assert length_in_units == expected_length_in_units - assert isinstance(length_in_units, type_) - - # fixtures ------------------------------------------------------- + """Unit-test suite for `docx.shared.Length` objects.""" - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("UnitCls", "units_val", "emu"), + [ (Length, 914400, 914400), (Inches, 1.1, 1005840), (Cm, 2.53, 910799), @@ -88,32 +63,47 @@ def it_can_self_convert_to_convenient_units(self, units_fixture): (Mm, 13.8, 496800), (Pt, 24.5, 311150), (Twips, 360, 228600), - ] + ], ) - def construct_fixture(self, request): - UnitCls, units_val, emu = request.param - return UnitCls, units_val, emu - - @pytest.fixture( - params=[ - (914400, "inches", 1.0, float), - (914400, "cm", 2.54, float), - (914400, "emu", 914400, int), - (914400, "mm", 25.4, float), - (914400, "pt", 72.0, float), - (914400, "twips", 1440, int), - ] + def it_can_construct_from_convenient_units(self, UnitCls: type, units_val: float, emu: int): + length = UnitCls(units_val) + assert isinstance(length, Length) + assert length == emu + + @pytest.mark.parametrize( + ("prop_name", "expected_value", "expected_type"), + [ + ("inches", 1.0, float), + ("cm", 2.54, float), + ("emu", 914400, int), + ("mm", 25.4, float), + ("pt", 72.0, float), + ("twips", 1440, int), + ], ) - def units_fixture(self, request): - emu, units_prop_name, expected_length_in_units, type_ = request.param - return emu, units_prop_name, expected_length_in_units, type_ + def it_can_self_convert_to_convenient_units( + self, prop_name: str, expected_value: float, expected_type: type + ): + # -- use an inch for the initial value -- + length = Length(914400) + length_in_units = getattr(length, prop_name) + assert length_in_units == expected_value + assert isinstance(length_in_units, expected_type) class DescribeRGBColor: + """Unit-test suite for `docx.shared.RGBColor` objects.""" + def it_is_natively_constructed_using_three_ints_0_to_255(self): - RGBColor(0x12, 0x34, 0x56) - with pytest.raises(ValueError, match=r"RGBColor\(\) takes three integer valu"): - RGBColor("12", "34", "56") + rgb_color = RGBColor(0x12, 0x34, 0x56) + + assert isinstance(rgb_color, RGBColor) + # -- it is comparable to a tuple[int, int, int] -- + assert rgb_color == (18, 52, 86) + + def it_raises_with_helpful_error_message_on_wrong_types(self): + with pytest.raises(TypeError, match=r"RGBColor\(\) takes three integer valu"): + RGBColor("12", "34", "56") # pyright: ignore with pytest.raises(ValueError, match=r"\(\) takes three integer values 0-255"): RGBColor(-1, 34, 56) with pytest.raises(ValueError, match=r"RGBColor\(\) takes three integer valu"): @@ -124,7 +114,7 @@ def it_can_construct_from_a_hex_string_rgb_value(self): assert rgb == RGBColor(0x12, 0x34, 0x56) def it_can_provide_a_hex_string_rgb_value(self): - assert str(RGBColor(0x12, 0x34, 0x56)) == "123456" + assert str(RGBColor(0xF3, 0x8A, 0x56)) == "F38A56" def it_has_a_custom_repr(self): rgb_color = RGBColor(0x42, 0xF0, 0xBA) diff --git a/tests/text/test_run.py b/tests/text/test_run.py index 772c5ad82..a54120fdd 100644 --- a/tests/text/test_run.py +++ b/tests/text/test_run.py @@ -15,23 +15,67 @@ from docx.parts.document import DocumentPart from docx.shape import InlineShape from docx.text.font import Font +from docx.text.paragraph import Paragraph from docx.text.run import Run from ..unitutil.cxml import element, xml -from ..unitutil.mock import class_mock, instance_mock, property_mock +from ..unitutil.mock import FixtureRequest, Mock, class_mock, instance_mock, property_mock class DescribeRun: """Unit-test suite for `docx.text.run.Run`.""" - def it_knows_its_bool_prop_states(self, bool_prop_get_fixture): - run, prop_name, expected_state = bool_prop_get_fixture - assert getattr(run, prop_name) == expected_state + @pytest.mark.parametrize( + ("r_cxml", "bool_prop_name", "expected_value"), + [ + ("w:r/w:rPr", "bold", None), + ("w:r/w:rPr/w:b", "bold", True), + ("w:r/w:rPr/w:b{w:val=on}", "bold", True), + ("w:r/w:rPr/w:b{w:val=off}", "bold", False), + ("w:r/w:rPr/w:b{w:val=1}", "bold", True), + ("w:r/w:rPr/w:i{w:val=0}", "italic", False), + ], + ) + def it_knows_its_bool_prop_states( + self, r_cxml: str, bool_prop_name: str, expected_value: bool | None, paragraph_: Mock + ): + run = Run(cast(CT_R, element(r_cxml)), paragraph_) + assert getattr(run, bool_prop_name) == expected_value - def it_can_change_its_bool_prop_settings(self, bool_prop_set_fixture): - run, prop_name, value, expected_xml = bool_prop_set_fixture - setattr(run, prop_name, value) - assert run._r.xml == expected_xml + @pytest.mark.parametrize( + ("initial_r_cxml", "bool_prop_name", "value", "expected_cxml"), + [ + # -- nothing to True, False, and None --------------------------- + ("w:r", "bold", True, "w:r/w:rPr/w:b"), + ("w:r", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), + ("w:r", "italic", None, "w:r/w:rPr"), + # -- default to True, False, and None --------------------------- + ("w:r/w:rPr/w:b", "bold", True, "w:r/w:rPr/w:b"), + ("w:r/w:rPr/w:b", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), + ("w:r/w:rPr/w:i", "italic", None, "w:r/w:rPr"), + # -- True to True, False, and None ------------------------------ + ("w:r/w:rPr/w:b{w:val=on}", "bold", True, "w:r/w:rPr/w:b"), + ("w:r/w:rPr/w:b{w:val=1}", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), + ("w:r/w:rPr/w:b{w:val=1}", "bold", None, "w:r/w:rPr"), + # -- False to True, False, and None ----------------------------- + ("w:r/w:rPr/w:i{w:val=false}", "italic", True, "w:r/w:rPr/w:i"), + ("w:r/w:rPr/w:i{w:val=0}", "italic", False, "w:r/w:rPr/w:i{w:val=0}"), + ("w:r/w:rPr/w:i{w:val=off}", "italic", None, "w:r/w:rPr"), + ], + ) + def it_can_change_its_bool_prop_settings( + self, + initial_r_cxml: str, + bool_prop_name: str, + value: bool | None, + expected_cxml: str, + paragraph_: Mock, + ): + run = Run(cast(CT_R, element(initial_r_cxml)), paragraph_) + + setattr(run, bool_prop_name, value) + + assert run._r.xml == xml(expected_cxml) @pytest.mark.parametrize( ("r_cxml", "expected_value"), @@ -43,11 +87,9 @@ def it_can_change_its_bool_prop_settings(self, bool_prop_set_fixture): ], ) def it_knows_whether_it_contains_a_page_break( - self, r_cxml: str, expected_value: bool + self, r_cxml: str, expected_value: bool, paragraph_: Mock ): - r = cast(CT_R, element(r_cxml)) - run = Run(r, None) # pyright: ignore[reportGeneralTypeIssues] - + run = Run(cast(CT_R, element(r_cxml)), paragraph_) assert run.contains_page_break == expected_value @pytest.mark.parametrize( @@ -80,48 +122,138 @@ def it_can_iterate_its_inner_content_items( actual = [type(item).__name__ for item in inner_content] assert actual == expected, f"expected: {expected}, got: {actual}" - def it_knows_its_character_style(self, style_get_fixture): - run, style_id_, style_ = style_get_fixture + def it_knows_its_character_style( + self, part_prop_: Mock, document_part_: Mock, paragraph_: Mock + ): + style_ = document_part_.get_style.return_value + part_prop_.return_value = document_part_ + style_id = "Barfoo" + run = Run(cast(CT_R, element(f"w:r/w:rPr/w:rStyle{{w:val={style_id}}}")), paragraph_) + style = run.style - run.part.get_style.assert_called_once_with(style_id_, WD_STYLE_TYPE.CHARACTER) + + document_part_.get_style.assert_called_once_with(style_id, WD_STYLE_TYPE.CHARACTER) assert style is style_ - def it_can_change_its_character_style(self, style_set_fixture): - run, value, expected_xml = style_set_fixture + @pytest.mark.parametrize( + ("r_cxml", "value", "style_id", "expected_cxml"), + [ + ("w:r", "Foo Font", "FooFont", "w:r/w:rPr/w:rStyle{w:val=FooFont}"), + ("w:r/w:rPr", "Foo Font", "FooFont", "w:r/w:rPr/w:rStyle{w:val=FooFont}"), + ( + "w:r/w:rPr/w:rStyle{w:val=FooFont}", + "Bar Font", + "BarFont", + "w:r/w:rPr/w:rStyle{w:val=BarFont}", + ), + ("w:r/w:rPr/w:rStyle{w:val=FooFont}", None, None, "w:r/w:rPr"), + ("w:r", None, None, "w:r/w:rPr"), + ], + ) + def it_can_change_its_character_style( + self, + r_cxml: str, + value: str | None, + style_id: str | None, + expected_cxml: str, + part_prop_: Mock, + paragraph_: Mock, + ): + part_ = part_prop_.return_value + part_.get_style_id.return_value = style_id + run = Run(cast(CT_R, element(r_cxml)), paragraph_) + run.style = value - run.part.get_style_id.assert_called_once_with(value, WD_STYLE_TYPE.CHARACTER) - assert run._r.xml == expected_xml - def it_knows_its_underline_type(self, underline_get_fixture): - run, expected_value = underline_get_fixture + part_.get_style_id.assert_called_once_with(value, WD_STYLE_TYPE.CHARACTER) + assert run._r.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("r_cxml", "expected_value"), + [ + ("w:r", None), + ("w:r/w:rPr/w:u", None), + ("w:r/w:rPr/w:u{w:val=single}", True), + ("w:r/w:rPr/w:u{w:val=none}", False), + ("w:r/w:rPr/w:u{w:val=double}", WD_UNDERLINE.DOUBLE), + ("w:r/w:rPr/w:u{w:val=wave}", WD_UNDERLINE.WAVY), + ], + ) + def it_knows_its_underline_type( + self, r_cxml: str, expected_value: bool | WD_UNDERLINE | None, paragraph_: Mock + ): + run = Run(cast(CT_R, element(r_cxml)), paragraph_) assert run.underline is expected_value - def it_can_change_its_underline_type(self, underline_set_fixture): - run, underline, expected_xml = underline_set_fixture - run.underline = underline - assert run._r.xml == expected_xml + @pytest.mark.parametrize( + ("initial_r_cxml", "new_underline", "expected_cxml"), + [ + ("w:r", True, "w:r/w:rPr/w:u{w:val=single}"), + ("w:r", False, "w:r/w:rPr/w:u{w:val=none}"), + ("w:r", None, "w:r/w:rPr"), + ("w:r", WD_UNDERLINE.SINGLE, "w:r/w:rPr/w:u{w:val=single}"), + ("w:r", WD_UNDERLINE.THICK, "w:r/w:rPr/w:u{w:val=thick}"), + ("w:r/w:rPr/w:u{w:val=single}", True, "w:r/w:rPr/w:u{w:val=single}"), + ("w:r/w:rPr/w:u{w:val=single}", False, "w:r/w:rPr/w:u{w:val=none}"), + ("w:r/w:rPr/w:u{w:val=single}", None, "w:r/w:rPr"), + ( + "w:r/w:rPr/w:u{w:val=single}", + WD_UNDERLINE.SINGLE, + "w:r/w:rPr/w:u{w:val=single}", + ), + ( + "w:r/w:rPr/w:u{w:val=single}", + WD_UNDERLINE.DOTTED, + "w:r/w:rPr/w:u{w:val=dotted}", + ), + ], + ) + def it_can_change_its_underline_type( + self, + initial_r_cxml: str, + new_underline: bool | WD_UNDERLINE | None, + expected_cxml: str, + paragraph_: Mock, + ): + run = Run(cast(CT_R, element(initial_r_cxml)), paragraph_) + + run.underline = new_underline + + assert run._r.xml == xml(expected_cxml) @pytest.mark.parametrize("invalid_value", ["foobar", 42, "single"]) - def it_raises_on_assign_invalid_underline_value(self, invalid_value: Any): - r = cast(CT_R, element("w:r/w:rPr")) - run = Run(r, None) + def it_raises_on_assign_invalid_underline_value(self, invalid_value: Any, paragraph_: Mock): + run = Run(cast(CT_R, element("w:r/w:rPr")), paragraph_) with pytest.raises(ValueError, match=" is not a valid WD_UNDERLINE"): run.underline = invalid_value - def it_provides_access_to_its_font(self, font_fixture): - run, Font_, font_ = font_fixture + def it_provides_access_to_its_font(self, Font_: Mock, font_: Mock, paragraph_: Mock): + Font_.return_value = font_ + run = Run(cast(CT_R, element("w:r")), paragraph_) + font = run.font + Font_.assert_called_once_with(run._element) assert font is font_ - def it_can_add_text(self, add_text_fixture, Text_): - r, text_str, expected_xml = add_text_fixture - run = Run(r, None) + @pytest.mark.parametrize( + ("r_cxml", "new_text", "expected_cxml"), + [ + ("w:r", "foo", 'w:r/w:t"foo"'), + ('w:r/w:t"foo"', "bar", 'w:r/(w:t"foo", w:t"bar")'), + ("w:r", "fo ", 'w:r/w:t{xml:space=preserve}"fo "'), + ("w:r", "f o", 'w:r/w:t"f o"'), + ], + ) + def it_can_add_text( + self, r_cxml: str, new_text: str, expected_cxml: str, Text_: Mock, paragraph_: Mock + ): + run = Run(cast(CT_R, element(r_cxml)), paragraph_) - _text = run.add_text(text_str) + text = run.add_text(new_text) - assert run._r.xml == expected_xml - assert _text is Text_.return_value + assert run._r.xml == xml(expected_cxml) + assert text is Text_.return_value @pytest.mark.parametrize( ("break_type", "expected_cxml"), @@ -134,28 +266,42 @@ def it_can_add_text(self, add_text_fixture, Text_): (WD_BREAK.LINE_CLEAR_ALL, "w:r/w:br{w:clear=all}"), ], ) - def it_can_add_a_break(self, break_type: WD_BREAK, expected_cxml: str): - r = cast(CT_R, element("w:r")) - run = Run(r, None) # pyright:ignore[reportGeneralTypeIssues] - expected_xml = xml(expected_cxml) + def it_can_add_a_break(self, break_type: WD_BREAK, expected_cxml: str, paragraph_: Mock): + run = Run(cast(CT_R, element("w:r")), paragraph_) run.add_break(break_type) - assert run._r.xml == expected_xml + assert run._r.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("r_cxml", "expected_cxml"), [('w:r/w:t"foo"', 'w:r/(w:t"foo", w:tab)')] + ) + def it_can_add_a_tab(self, r_cxml: str, expected_cxml: str, paragraph_: Mock): + run = Run(cast(CT_R, element(r_cxml)), paragraph_) - def it_can_add_a_tab(self, add_tab_fixture): - run, expected_xml = add_tab_fixture run.add_tab() - assert run._r.xml == expected_xml - def it_can_add_a_picture(self, add_picture_fixture): - run, image, width, height, inline = add_picture_fixture[:5] - expected_xml, InlineShape_, picture_ = add_picture_fixture[5:] + assert run._r.xml == xml(expected_cxml) + + def it_can_add_a_picture( + self, + part_prop_: Mock, + document_part_: Mock, + InlineShape_: Mock, + picture_: Mock, + paragraph_: Mock, + ): + part_prop_.return_value = document_part_ + run = Run(cast(CT_R, element("w:r/wp:x")), paragraph_) + image = "foobar.png" + width, height, inline = 1111, 2222, element("wp:inline{id=42}") + document_part_.new_pic_inline.return_value = inline + InlineShape_.return_value = picture_ picture = run.add_picture(image, width, height) - run.part.new_pic_inline.assert_called_once_with(image, width, height) - assert run._r.xml == expected_xml + document_part_.new_pic_inline.assert_called_once_with(image, width, height) + assert run._r.xml == xml("w:r/(wp:x,w:drawing/wp:inline{id=42})") InlineShape_.assert_called_once_with(inline) assert picture is picture_ @@ -174,15 +320,13 @@ def it_can_add_a_picture(self, add_picture_fixture): ], ) def it_can_remove_its_content_but_keep_formatting( - self, initial_r_cxml: str, expected_cxml: str + self, initial_r_cxml: str, expected_cxml: str, paragraph_: Mock ): - r = cast(CT_R, element(initial_r_cxml)) - run = Run(r, None) # pyright: ignore[reportGeneralTypeIssues] - expected_xml = xml(expected_cxml) + run = Run(cast(CT_R, element(initial_r_cxml)), paragraph_) cleared_run = run.clear() - assert run._r.xml == expected_xml + assert run._r.xml == xml(expected_cxml) assert cleared_run is run @pytest.mark.parametrize( @@ -194,212 +338,58 @@ def it_can_remove_its_content_but_keep_formatting( ('w:r/(w:br{w:type=page}, w:t"abc", w:t"def", w:tab)', "abcdef\t"), ], ) - def it_knows_the_text_it_contains(self, r_cxml: str, expected_text: str): - r = cast(CT_R, element(r_cxml)) - run = Run(r, None) # pyright: ignore[reportGeneralTypeIssues] + def it_knows_the_text_it_contains(self, r_cxml: str, expected_text: str, paragraph_: Mock): + run = Run(cast(CT_R, element(r_cxml)), paragraph_) assert run.text == expected_text - def it_can_replace_the_text_it_contains(self, text_set_fixture): - run, text, expected_xml = text_set_fixture - run.text = text - assert run._r.xml == expected_xml - - # fixtures ------------------------------------------------------- - - @pytest.fixture - def add_picture_fixture(self, part_prop_, document_part_, InlineShape_, picture_): - run = Run(element("w:r/wp:x"), None) - image = "foobar.png" - width, height, inline = 1111, 2222, element("wp:inline{id=42}") - expected_xml = xml("w:r/(wp:x,w:drawing/wp:inline{id=42})") - document_part_.new_pic_inline.return_value = inline - InlineShape_.return_value = picture_ - return (run, image, width, height, inline, expected_xml, InlineShape_, picture_) - - @pytest.fixture( - params=[ - ('w:r/w:t"foo"', 'w:r/(w:t"foo", w:tab)'), - ] - ) - def add_tab_fixture(self, request): - r_cxml, expected_cxml = request.param - run = Run(element(r_cxml), None) - expected_xml = xml(expected_cxml) - return run, expected_xml - - @pytest.fixture( - params=[ - ("w:r", "foo", 'w:r/w:t"foo"'), - ('w:r/w:t"foo"', "bar", 'w:r/(w:t"foo", w:t"bar")'), - ("w:r", "fo ", 'w:r/w:t{xml:space=preserve}"fo "'), - ("w:r", "f o", 'w:r/w:t"f o"'), - ] - ) - def add_text_fixture(self, request): - r_cxml, text, expected_cxml = request.param - r = element(r_cxml) - expected_xml = xml(expected_cxml) - return r, text, expected_xml - - @pytest.fixture( - params=[ - ("w:r/w:rPr", "bold", None), - ("w:r/w:rPr/w:b", "bold", True), - ("w:r/w:rPr/w:b{w:val=on}", "bold", True), - ("w:r/w:rPr/w:b{w:val=off}", "bold", False), - ("w:r/w:rPr/w:b{w:val=1}", "bold", True), - ("w:r/w:rPr/w:i{w:val=0}", "italic", False), - ] - ) - def bool_prop_get_fixture(self, request): - r_cxml, bool_prop_name, expected_value = request.param - run = Run(element(r_cxml), None) - return run, bool_prop_name, expected_value - - @pytest.fixture( - params=[ - # nothing to True, False, and None --------------------------- - ("w:r", "bold", True, "w:r/w:rPr/w:b"), - ("w:r", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), - ("w:r", "italic", None, "w:r/w:rPr"), - # default to True, False, and None --------------------------- - ("w:r/w:rPr/w:b", "bold", True, "w:r/w:rPr/w:b"), - ("w:r/w:rPr/w:b", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), - ("w:r/w:rPr/w:i", "italic", None, "w:r/w:rPr"), - # True to True, False, and None ------------------------------ - ("w:r/w:rPr/w:b{w:val=on}", "bold", True, "w:r/w:rPr/w:b"), - ("w:r/w:rPr/w:b{w:val=1}", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), - ("w:r/w:rPr/w:b{w:val=1}", "bold", None, "w:r/w:rPr"), - # False to True, False, and None ----------------------------- - ("w:r/w:rPr/w:i{w:val=false}", "italic", True, "w:r/w:rPr/w:i"), - ("w:r/w:rPr/w:i{w:val=0}", "italic", False, "w:r/w:rPr/w:i{w:val=0}"), - ("w:r/w:rPr/w:i{w:val=off}", "italic", None, "w:r/w:rPr"), - ] - ) - def bool_prop_set_fixture(self, request): - initial_r_cxml, bool_prop_name, value, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, bool_prop_name, value, expected_xml - - @pytest.fixture - def font_fixture(self, Font_, font_): - run = Run(element("w:r"), None) - return run, Font_, font_ - - @pytest.fixture - def style_get_fixture(self, part_prop_): - style_id = "Barfoo" - r_cxml = "w:r/w:rPr/w:rStyle{w:val=%s}" % style_id - run = Run(element(r_cxml), None) - style_ = part_prop_.return_value.get_style.return_value - return run, style_id, style_ - - @pytest.fixture( - params=[ - ("w:r", "Foo Font", "FooFont", "w:r/w:rPr/w:rStyle{w:val=FooFont}"), - ("w:r/w:rPr", "Foo Font", "FooFont", "w:r/w:rPr/w:rStyle{w:val=FooFont}"), - ( - "w:r/w:rPr/w:rStyle{w:val=FooFont}", - "Bar Font", - "BarFont", - "w:r/w:rPr/w:rStyle{w:val=BarFont}", - ), - ("w:r/w:rPr/w:rStyle{w:val=FooFont}", None, None, "w:r/w:rPr"), - ("w:r", None, None, "w:r/w:rPr"), - ] - ) - def style_set_fixture(self, request, part_prop_): - r_cxml, value, style_id, expected_cxml = request.param - run = Run(element(r_cxml), None) - part_prop_.return_value.get_style_id.return_value = style_id - expected_xml = xml(expected_cxml) - return run, value, expected_xml - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("new_text", "expected_cxml"), + [ ("abc def", 'w:r/w:t"abc def"'), ("abc\tdef", 'w:r/(w:t"abc", w:tab, w:t"def")'), ("abc\ndef", 'w:r/(w:t"abc", w:br, w:t"def")'), ("abc\rdef", 'w:r/(w:t"abc", w:br, w:t"def")'), - ] - ) - def text_set_fixture(self, request): - new_text, expected_cxml = request.param - initial_r_cxml = 'w:r/w:t"should get deleted"' - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, new_text, expected_xml - - @pytest.fixture( - params=[ - ("w:r", None), - ("w:r/w:rPr/w:u", None), - ("w:r/w:rPr/w:u{w:val=single}", True), - ("w:r/w:rPr/w:u{w:val=none}", False), - ("w:r/w:rPr/w:u{w:val=double}", WD_UNDERLINE.DOUBLE), - ("w:r/w:rPr/w:u{w:val=wave}", WD_UNDERLINE.WAVY), - ] + ], ) - def underline_get_fixture(self, request): - r_cxml, expected_underline = request.param - run = Run(element(r_cxml), None) - return run, expected_underline + def it_can_replace_the_text_it_contains( + self, new_text: str, expected_cxml: str, paragraph_: Mock + ): + run = Run(cast(CT_R, element('w:r/w:t"should get deleted"')), paragraph_) - @pytest.fixture( - params=[ - ("w:r", True, "w:r/w:rPr/w:u{w:val=single}"), - ("w:r", False, "w:r/w:rPr/w:u{w:val=none}"), - ("w:r", None, "w:r/w:rPr"), - ("w:r", WD_UNDERLINE.SINGLE, "w:r/w:rPr/w:u{w:val=single}"), - ("w:r", WD_UNDERLINE.THICK, "w:r/w:rPr/w:u{w:val=thick}"), - ("w:r/w:rPr/w:u{w:val=single}", True, "w:r/w:rPr/w:u{w:val=single}"), - ("w:r/w:rPr/w:u{w:val=single}", False, "w:r/w:rPr/w:u{w:val=none}"), - ("w:r/w:rPr/w:u{w:val=single}", None, "w:r/w:rPr"), - ( - "w:r/w:rPr/w:u{w:val=single}", - WD_UNDERLINE.SINGLE, - "w:r/w:rPr/w:u{w:val=single}", - ), - ( - "w:r/w:rPr/w:u{w:val=single}", - WD_UNDERLINE.DOTTED, - "w:r/w:rPr/w:u{w:val=dotted}", - ), - ] - ) - def underline_set_fixture(self, request): - initial_r_cxml, new_underline, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, new_underline, expected_xml + run.text = new_text - # fixture components --------------------------------------------- + assert run._r.xml == xml(expected_cxml) + + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def document_part_(self, request): + def document_part_(self, request: FixtureRequest): return instance_mock(request, DocumentPart) @pytest.fixture - def Font_(self, request, font_): - return class_mock(request, "docx.text.run.Font", return_value=font_) + def Font_(self, request: FixtureRequest): + return class_mock(request, "docx.text.run.Font") @pytest.fixture - def font_(self, request): + def font_(self, request: FixtureRequest): return instance_mock(request, Font) @pytest.fixture - def InlineShape_(self, request): + def InlineShape_(self, request: FixtureRequest): return class_mock(request, "docx.text.run.InlineShape") @pytest.fixture - def part_prop_(self, request, document_part_): - return property_mock(request, Run, "part", return_value=document_part_) + def paragraph_(self, request: FixtureRequest): + return instance_mock(request, Paragraph) + + @pytest.fixture + def part_prop_(self, request: FixtureRequest): + return property_mock(request, Run, "part") @pytest.fixture - def picture_(self, request): + def picture_(self, request: FixtureRequest): return instance_mock(request, InlineShape) @pytest.fixture - def Text_(self, request): + def Text_(self, request: FixtureRequest): return class_mock(request, "docx.text.run._Text") From 592fa8f332819a7a8175dd023d3e04597ebba524 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 6 Jun 2025 10:58:26 -0700 Subject: [PATCH 29/56] modn: improve ruff compliance Run `ruff format` on code base. --- docs/conf.py | 4 +- features/steps/block.py | 4 +- features/steps/document.py | 8 +- features/steps/hyperlink.py | 32 +- features/steps/pagebreak.py | 72 ++--- features/steps/paragraph.py | 16 +- features/steps/parfmt.py | 4 +- features/steps/section.py | 16 +- features/steps/settings.py | 8 +- features/steps/shape.py | 7 +- features/steps/text.py | 18 +- src/docx/blkcntnr.py | 2 +- src/docx/image/__init__.py | 2 +- src/docx/image/constants.py | 128 ++++---- src/docx/image/image.py | 4 +- src/docx/image/jpeg.py | 20 +- src/docx/opc/constants.py | 472 ++++++++---------------------- src/docx/opc/packuri.py | 3 +- src/docx/opc/pkgreader.py | 8 +- src/docx/opc/rel.py | 6 +- src/docx/oxml/simpletypes.py | 2 +- src/docx/oxml/table.py | 10 +- src/docx/oxml/text/font.py | 8 +- src/docx/oxml/text/pagebreak.py | 12 +- src/docx/oxml/xmlchemy.py | 6 +- src/docx/parts/settings.py | 3 +- src/docx/parts/styles.py | 4 +- src/docx/styles/styles.py | 17 +- src/docx/text/font.py | 6 +- src/docx/text/tabstops.py | 4 +- src/docx/types.py | 6 +- tests/image/test_bmp.py | 4 +- tests/image/test_gif.py | 2 +- tests/image/test_helpers.py | 4 +- tests/image/test_image.py | 8 +- tests/image/test_jpeg.py | 20 +- tests/image/test_png.py | 24 +- tests/image/test_tiff.py | 30 +- tests/opc/test_pkgreader.py | 32 +- tests/opc/test_rel.py | 20 +- tests/oxml/parts/test_document.py | 5 +- tests/oxml/test__init__.py | 9 +- tests/oxml/test_styles.py | 3 +- tests/oxml/test_table.py | 11 +- tests/oxml/test_xmlchemy.py | 66 ++--- tests/oxml/text/test_hyperlink.py | 4 +- tests/parts/test_hdrftr.py | 8 +- tests/parts/test_image.py | 8 +- tests/parts/test_numbering.py | 4 +- tests/parts/test_settings.py | 8 +- tests/parts/test_story.py | 4 +- tests/styles/test_style.py | 14 +- tests/styles/test_styles.py | 23 +- tests/test_enum.py | 4 +- tests/text/test_font.py | 24 +- tests/text/test_pagebreak.py | 8 +- tests/text/test_paragraph.py | 18 +- tests/unitutil/file.py | 4 +- tests/unitutil/mock.py | 8 +- 59 files changed, 383 insertions(+), 906 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 06b428064..e37e9be7e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -270,9 +270,7 @@ # Custom sidebar templates, maps document names to template names. # html_sidebars = {} -html_sidebars = { - "**": ["localtoc.html", "relations.html", "sidebarlinks.html", "searchbox.html"] -} +html_sidebars = {"**": ["localtoc.html", "relations.html", "sidebarlinks.html", "searchbox.html"]} # Additional templates that should be rendered to pages, maps page names to # template names. diff --git a/features/steps/block.py b/features/steps/block.py index c365c9510..e3d5c6154 100644 --- a/features/steps/block.py +++ b/features/steps/block.py @@ -13,9 +13,7 @@ @given("a _Cell object with paragraphs and tables") def given_a_cell_with_paragraphs_and_tables(context: Context): - context.cell = ( - Document(test_docx("blk-paras-and-tables")).tables[1].rows[0].cells[0] - ) + context.cell = Document(test_docx("blk-paras-and-tables")).tables[1].rows[0].cells[0] @given("a Document object with paragraphs and tables") diff --git a/features/steps/document.py b/features/steps/document.py index 49165efc3..1c12ac106 100644 --- a/features/steps/document.py +++ b/features/steps/document.py @@ -126,17 +126,13 @@ def when_add_picture_specifying_width_and_height(context): @when("I add a picture specifying a height of 1.5 inches") def when_add_picture_specifying_height(context): document = context.document - context.picture = document.add_picture( - test_file("monty-truth.png"), height=Inches(1.5) - ) + context.picture = document.add_picture(test_file("monty-truth.png"), height=Inches(1.5)) @when("I add a picture specifying a width of 1.5 inches") def when_add_picture_specifying_width(context): document = context.document - context.picture = document.add_picture( - test_file("monty-truth.png"), width=Inches(1.5) - ) + context.picture = document.add_picture(test_file("monty-truth.png"), width=Inches(1.5)) @when("I add a picture specifying only the image file") diff --git a/features/steps/hyperlink.py b/features/steps/hyperlink.py index 2bba31ed8..14fa9f7be 100644 --- a/features/steps/hyperlink.py +++ b/features/steps/hyperlink.py @@ -27,9 +27,7 @@ def given_a_hyperlink_having_a_uri_fragment(context: Context): @given("a hyperlink having address {address} and fragment {fragment}") -def given_a_hyperlink_having_address_and_fragment( - context: Context, address: str, fragment: str -): +def given_a_hyperlink_having_address_and_fragment(context: Context, address: str, fragment: str): paragraph_idxs: Dict[Tuple[str, str], int] = { ("''", "linkedBookmark"): 1, ("https://foo.com", "''"): 2, @@ -73,60 +71,46 @@ def given_a_hyperlink_having_one_or_more_runs(context: Context, one_or_more: str def then_hyperlink_address_is_the_URL_of_the_hyperlink(context: Context): actual_value = context.hyperlink.address expected_value = "http://yahoo.com/" - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.contains_page_break is {value}") def then_hyperlink_contains_page_break_is_value(context: Context, value: str): actual_value = context.hyperlink.contains_page_break expected_value = {"True": True, "False": False}[value] - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.fragment is the URI fragment of the hyperlink") def then_hyperlink_fragment_is_the_URI_fragment_of_the_hyperlink(context: Context): actual_value = context.hyperlink.fragment expected_value = "linkedBookmark" - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.runs contains only Run instances") def then_hyperlink_runs_contains_only_Run_instances(context: Context): actual_value = [type(item).__name__ for item in context.hyperlink.runs] expected_value = ["Run" for _ in context.hyperlink.runs] - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.runs has length {value}") def then_hyperlink_runs_has_length(context: Context, value: str): actual_value = len(context.hyperlink.runs) expected_value = int(value) - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.text is the visible text of the hyperlink") def then_hyperlink_text_is_the_visible_text_of_the_hyperlink(context: Context): actual_value = context.hyperlink.text expected_value = "awesome hyperlink" - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.url is {value}") def then_hyperlink_url_is_value(context: Context, value: str): actual_value = context.hyperlink.url expected_value = "" if value == "''" else value - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" diff --git a/features/steps/pagebreak.py b/features/steps/pagebreak.py index 7d443da46..870428127 100644 --- a/features/steps/pagebreak.py +++ b/features/steps/pagebreak.py @@ -38,33 +38,23 @@ def then_rendered_page_break_preceding_paragraph_fragment_includes_the_hyperlink actual_value = type(para_frag).__name__ expected_value = "Paragraph" - assert ( - actual_value == expected_value - ), f"expected: '{expected_value}', got: '{actual_value}'" + assert actual_value == expected_value, f"expected: '{expected_value}', got: '{actual_value}'" actual_value = para_frag.text expected_value = "Page break in>><pqr stu - """ % nsdecls( - "w" - ) + """ % nsdecls("w") r = parse_xml(r_xml) context.run = Run(r, None) @@ -235,9 +233,7 @@ def then_run_font_is_the_Font_object_for_the_run(context): def then_run_iter_inner_content_generates_text_and_page_breaks(context: Context): actual_value = [type(item).__name__ for item in context.run.iter_inner_content()] expected_value = ["str", "RenderedPageBreak", "str", "RenderedPageBreak", "str"] - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("run.style is styles['{style_name}']") @@ -267,15 +263,15 @@ def then_the_picture_appears_at_the_end_of_the_run(context): run = context.run r = run._r blip_rId = r.xpath( - "./w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic/pic:blipFill/" - "a:blip/@r:embed" + "./w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic/pic:blipFill/a:blip/@r:embed" )[0] image_part = run.part.related_parts[blip_rId] image_sha1 = hashlib.sha1(image_part.blob).hexdigest() expected_sha1 = "79769f1e202add2e963158b532e36c2c0f76a70c" - assert ( - image_sha1 == expected_sha1 - ), "image SHA1 doesn't match, expected %s, got %s" % (expected_sha1, image_sha1) + assert image_sha1 == expected_sha1, "image SHA1 doesn't match, expected %s, got %s" % ( + expected_sha1, + image_sha1, + ) @then("the run appears in {boolean_prop_name} unconditionally") diff --git a/src/docx/blkcntnr.py b/src/docx/blkcntnr.py index a9969f6f6..951e03427 100644 --- a/src/docx/blkcntnr.py +++ b/src/docx/blkcntnr.py @@ -67,7 +67,7 @@ def add_table(self, rows: int, cols: int, width: Length) -> Table: from docx.table import Table tbl = CT_Tbl.new_tbl(rows, cols, width) - self._element._insert_tbl(tbl) # # pyright: ignore[reportPrivateUsage] + self._element._insert_tbl(tbl) # pyright: ignore[reportPrivateUsage] return Table(tbl, self) def iter_inner_content(self) -> Iterator[Paragraph | Table]: diff --git a/src/docx/image/__init__.py b/src/docx/image/__init__.py index d28033ef1..9d5e4b05b 100644 --- a/src/docx/image/__init__.py +++ b/src/docx/image/__init__.py @@ -12,7 +12,7 @@ SIGNATURES = ( # class, offset, signature_bytes - (Png, 0, b"\x89PNG\x0D\x0A\x1A\x0A"), + (Png, 0, b"\x89PNG\x0d\x0a\x1a\x0a"), (Jfif, 6, b"JFIF"), (Exif, 6, b"Exif"), (Gif, 0, b"GIF87a"), diff --git a/src/docx/image/constants.py b/src/docx/image/constants.py index 729a828b2..03fae5855 100644 --- a/src/docx/image/constants.py +++ b/src/docx/image/constants.py @@ -5,58 +5,58 @@ class JPEG_MARKER_CODE: """JPEG marker codes.""" TEM = b"\x01" - DHT = b"\xC4" - DAC = b"\xCC" - JPG = b"\xC8" - - SOF0 = b"\xC0" - SOF1 = b"\xC1" - SOF2 = b"\xC2" - SOF3 = b"\xC3" - SOF5 = b"\xC5" - SOF6 = b"\xC6" - SOF7 = b"\xC7" - SOF9 = b"\xC9" - SOFA = b"\xCA" - SOFB = b"\xCB" - SOFD = b"\xCD" - SOFE = b"\xCE" - SOFF = b"\xCF" - - RST0 = b"\xD0" - RST1 = b"\xD1" - RST2 = b"\xD2" - RST3 = b"\xD3" - RST4 = b"\xD4" - RST5 = b"\xD5" - RST6 = b"\xD6" - RST7 = b"\xD7" - - SOI = b"\xD8" - EOI = b"\xD9" - SOS = b"\xDA" - DQT = b"\xDB" # Define Quantization Table(s) - DNL = b"\xDC" - DRI = b"\xDD" - DHP = b"\xDE" - EXP = b"\xDF" - - APP0 = b"\xE0" - APP1 = b"\xE1" - APP2 = b"\xE2" - APP3 = b"\xE3" - APP4 = b"\xE4" - APP5 = b"\xE5" - APP6 = b"\xE6" - APP7 = b"\xE7" - APP8 = b"\xE8" - APP9 = b"\xE9" - APPA = b"\xEA" - APPB = b"\xEB" - APPC = b"\xEC" - APPD = b"\xED" - APPE = b"\xEE" - APPF = b"\xEF" + DHT = b"\xc4" + DAC = b"\xcc" + JPG = b"\xc8" + + SOF0 = b"\xc0" + SOF1 = b"\xc1" + SOF2 = b"\xc2" + SOF3 = b"\xc3" + SOF5 = b"\xc5" + SOF6 = b"\xc6" + SOF7 = b"\xc7" + SOF9 = b"\xc9" + SOFA = b"\xca" + SOFB = b"\xcb" + SOFD = b"\xcd" + SOFE = b"\xce" + SOFF = b"\xcf" + + RST0 = b"\xd0" + RST1 = b"\xd1" + RST2 = b"\xd2" + RST3 = b"\xd3" + RST4 = b"\xd4" + RST5 = b"\xd5" + RST6 = b"\xd6" + RST7 = b"\xd7" + + SOI = b"\xd8" + EOI = b"\xd9" + SOS = b"\xda" + DQT = b"\xdb" # Define Quantization Table(s) + DNL = b"\xdc" + DRI = b"\xdd" + DHP = b"\xde" + EXP = b"\xdf" + + APP0 = b"\xe0" + APP1 = b"\xe1" + APP2 = b"\xe2" + APP3 = b"\xe3" + APP4 = b"\xe4" + APP5 = b"\xe5" + APP6 = b"\xe6" + APP7 = b"\xe7" + APP8 = b"\xe8" + APP9 = b"\xe9" + APPA = b"\xea" + APPB = b"\xeb" + APPC = b"\xec" + APPD = b"\xed" + APPE = b"\xee" + APPF = b"\xef" STANDALONE_MARKERS = (TEM, SOI, EOI, RST0, RST1, RST2, RST3, RST4, RST5, RST6, RST7) @@ -78,18 +78,18 @@ class JPEG_MARKER_CODE: marker_names = { b"\x00": "UNKNOWN", - b"\xC0": "SOF0", - b"\xC2": "SOF2", - b"\xC4": "DHT", - b"\xDA": "SOS", # start of scan - b"\xD8": "SOI", # start of image - b"\xD9": "EOI", # end of image - b"\xDB": "DQT", - b"\xE0": "APP0", - b"\xE1": "APP1", - b"\xE2": "APP2", - b"\xED": "APP13", - b"\xEE": "APP14", + b"\xc0": "SOF0", + b"\xc2": "SOF2", + b"\xc4": "DHT", + b"\xda": "SOS", # start of scan + b"\xd8": "SOI", # start of image + b"\xd9": "EOI", # end of image + b"\xdb": "DQT", + b"\xe0": "APP0", + b"\xe1": "APP1", + b"\xe2": "APP2", + b"\xed": "APP13", + b"\xee": "APP14", } @classmethod diff --git a/src/docx/image/image.py b/src/docx/image/image.py index 0022b5b45..e5e7f8a13 100644 --- a/src/docx/image/image.py +++ b/src/docx/image/image.py @@ -194,7 +194,7 @@ def __init__(self, px_width: int, px_height: int, horz_dpi: int, vert_dpi: int): @property def content_type(self) -> str: """Abstract property definition, must be implemented by all subclasses.""" - msg = "content_type property must be implemented by all subclasses of " "BaseImageHeader" + msg = "content_type property must be implemented by all subclasses of BaseImageHeader" raise NotImplementedError(msg) @property @@ -204,7 +204,7 @@ def default_ext(self) -> str: An abstract property definition, must be implemented by all subclasses. """ raise NotImplementedError( - "default_ext property must be implemented by all subclasses of " "BaseImageHeader" + "default_ext property must be implemented by all subclasses of BaseImageHeader" ) @property diff --git a/src/docx/image/jpeg.py b/src/docx/image/jpeg.py index b0114a998..74da51871 100644 --- a/src/docx/image/jpeg.py +++ b/src/docx/image/jpeg.py @@ -188,20 +188,20 @@ def next(self, start): def _next_non_ff_byte(self, start): """Return an offset, byte 2-tuple for the next byte in `stream` that is not - '\xFF', starting with the byte at offset `start`. + '\xff', starting with the byte at offset `start`. - If the byte at offset `start` is not '\xFF', `start` and the returned `offset` + If the byte at offset `start` is not '\xff', `start` and the returned `offset` will be the same. """ self._stream.seek(start) byte_ = self._read_byte() - while byte_ == b"\xFF": + while byte_ == b"\xff": byte_ = self._read_byte() offset_of_non_ff_byte = self._stream.tell() - 1 return offset_of_non_ff_byte, byte_ def _offset_of_next_ff_byte(self, start): - """Return the offset of the next '\xFF' byte in `stream` starting with the byte + """Return the offset of the next '\xff' byte in `stream` starting with the byte at offset `start`. Returns `start` if the byte at that offset is a hex 255; it does not necessarily @@ -209,7 +209,7 @@ def _offset_of_next_ff_byte(self, start): """ self._stream.seek(start) byte_ = self._read_byte() - while byte_ != b"\xFF": + while byte_ != b"\xff": byte_ = self._read_byte() offset_of_ff_byte = self._stream.tell() - 1 return offset_of_ff_byte @@ -263,7 +263,7 @@ def from_stream(cls, stream, marker_code, offset): @property def marker_code(self): - """The single-byte code that identifies the type of this marker, e.g. ``'\xE0'`` + """The single-byte code that identifies the type of this marker, e.g. ``'\xe0'`` for start of image (SOI).""" return self._marker_code @@ -284,9 +284,7 @@ def segment_length(self): class _App0Marker(_Marker): """Represents a JFIF APP0 marker segment.""" - def __init__( - self, marker_code, offset, length, density_units, x_density, y_density - ): + def __init__(self, marker_code, offset, length, density_units, x_density, y_density): super(_App0Marker, self).__init__(marker_code, offset, length) self._density_units = density_units self._x_density = x_density @@ -332,9 +330,7 @@ def from_stream(cls, stream, marker_code, offset): density_units = stream.read_byte(offset, 9) x_density = stream.read_short(offset, 10) y_density = stream.read_short(offset, 12) - return cls( - marker_code, offset, segment_length, density_units, x_density, y_density - ) + return cls(marker_code, offset, segment_length, density_units, x_density, y_density) class _App1Marker(_Marker): diff --git a/src/docx/opc/constants.py b/src/docx/opc/constants.py index 89d3c16cc..a3d0e0812 100644 --- a/src/docx/opc/constants.py +++ b/src/docx/opc/constants.py @@ -9,27 +9,15 @@ class CONTENT_TYPE: BMP = "image/bmp" DML_CHART = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml" - DML_CHARTSHAPES = ( - "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml" - ) - DML_DIAGRAM_COLORS = ( - "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml" - ) - DML_DIAGRAM_DATA = ( - "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml" - ) - DML_DIAGRAM_LAYOUT = ( - "application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml" - ) - DML_DIAGRAM_STYLE = ( - "application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml" - ) + DML_CHARTSHAPES = "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml" + DML_DIAGRAM_COLORS = "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml" + DML_DIAGRAM_DATA = "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml" + DML_DIAGRAM_LAYOUT = "application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml" + DML_DIAGRAM_STYLE = "application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml" GIF = "image/gif" JPEG = "image/jpeg" MS_PHOTO = "image/vnd.ms-photo" - OFC_CUSTOM_PROPERTIES = ( - "application/vnd.openxmlformats-officedocument.custom-properties+xml" - ) + OFC_CUSTOM_PROPERTIES = "application/vnd.openxmlformats-officedocument.custom-properties+xml" OFC_CUSTOM_XML_PROPERTIES = ( "application/vnd.openxmlformats-officedocument.customXmlProperties+xml" ) @@ -40,209 +28,126 @@ class CONTENT_TYPE: OFC_OLE_OBJECT = "application/vnd.openxmlformats-officedocument.oleObject" OFC_PACKAGE = "application/vnd.openxmlformats-officedocument.package" OFC_THEME = "application/vnd.openxmlformats-officedocument.theme+xml" - OFC_THEME_OVERRIDE = ( - "application/vnd.openxmlformats-officedocument.themeOverride+xml" - ) + OFC_THEME_OVERRIDE = "application/vnd.openxmlformats-officedocument.themeOverride+xml" OFC_VML_DRAWING = "application/vnd.openxmlformats-officedocument.vmlDrawing" OPC_CORE_PROPERTIES = "application/vnd.openxmlformats-package.core-properties+xml" OPC_DIGITAL_SIGNATURE_CERTIFICATE = ( "application/vnd.openxmlformats-package.digital-signature-certificate" ) - OPC_DIGITAL_SIGNATURE_ORIGIN = ( - "application/vnd.openxmlformats-package.digital-signature-origin" - ) + OPC_DIGITAL_SIGNATURE_ORIGIN = "application/vnd.openxmlformats-package.digital-signature-origin" OPC_DIGITAL_SIGNATURE_XMLSIGNATURE = ( "application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml" ) OPC_RELATIONSHIPS = "application/vnd.openxmlformats-package.relationships+xml" - PML_COMMENTS = ( - "application/vnd.openxmlformats-officedocument.presentationml.comments+xml" - ) + PML_COMMENTS = "application/vnd.openxmlformats-officedocument.presentationml.comments+xml" PML_COMMENT_AUTHORS = ( - "application/vnd.openxmlformats-officedocument.presentationml.commen" - "tAuthors+xml" + "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml" ) PML_HANDOUT_MASTER = ( - "application/vnd.openxmlformats-officedocument.presentationml.handou" - "tMaster+xml" + "application/vnd.openxmlformats-officedocument.presentationml.handoutMaster+xml" ) PML_NOTES_MASTER = ( - "application/vnd.openxmlformats-officedocument.presentationml.notesM" - "aster+xml" - ) - PML_NOTES_SLIDE = ( - "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml" + "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml" ) + PML_NOTES_SLIDE = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml" PML_PRESENTATION_MAIN = ( - "application/vnd.openxmlformats-officedocument.presentationml.presen" - "tation.main+xml" - ) - PML_PRES_PROPS = ( - "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml" + "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml" ) + PML_PRES_PROPS = "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml" PML_PRINTER_SETTINGS = ( - "application/vnd.openxmlformats-officedocument.presentationml.printe" - "rSettings" + "application/vnd.openxmlformats-officedocument.presentationml.printerSettings" ) PML_SLIDE = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml" PML_SLIDESHOW_MAIN = ( - "application/vnd.openxmlformats-officedocument.presentationml.slides" - "how.main+xml" + "application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml" ) PML_SLIDE_LAYOUT = ( - "application/vnd.openxmlformats-officedocument.presentationml.slideL" - "ayout+xml" + "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml" ) PML_SLIDE_MASTER = ( - "application/vnd.openxmlformats-officedocument.presentationml.slideM" - "aster+xml" + "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml" ) PML_SLIDE_UPDATE_INFO = ( - "application/vnd.openxmlformats-officedocument.presentationml.slideU" - "pdateInfo+xml" + "application/vnd.openxmlformats-officedocument.presentationml.slideUpdateInfo+xml" ) PML_TABLE_STYLES = ( - "application/vnd.openxmlformats-officedocument.presentationml.tableS" - "tyles+xml" + "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml" ) PML_TAGS = "application/vnd.openxmlformats-officedocument.presentationml.tags+xml" PML_TEMPLATE_MAIN = ( - "application/vnd.openxmlformats-officedocument.presentationml.templa" - "te.main+xml" - ) - PML_VIEW_PROPS = ( - "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml" + "application/vnd.openxmlformats-officedocument.presentationml.template.main+xml" ) + PML_VIEW_PROPS = "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml" PNG = "image/png" - SML_CALC_CHAIN = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml" - ) - SML_CHARTSHEET = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml" - ) - SML_COMMENTS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml" - ) - SML_CONNECTIONS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml" - ) + SML_CALC_CHAIN = "application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml" + SML_CHARTSHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml" + SML_COMMENTS = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml" + SML_CONNECTIONS = "application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml" SML_CUSTOM_PROPERTY = ( "application/vnd.openxmlformats-officedocument.spreadsheetml.customProperty" ) - SML_DIALOGSHEET = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml" - ) + SML_DIALOGSHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml" SML_EXTERNAL_LINK = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.externa" - "lLink+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml" ) SML_PIVOT_CACHE_DEFINITION = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCa" - "cheDefinition+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml" ) SML_PIVOT_CACHE_RECORDS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCa" - "cheRecords+xml" - ) - SML_PIVOT_TABLE = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml" ) + SML_PIVOT_TABLE = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml" SML_PRINTER_SETTINGS = ( "application/vnd.openxmlformats-officedocument.spreadsheetml.printerSettings" ) - SML_QUERY_TABLE = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.queryTable+xml" - ) + SML_QUERY_TABLE = "application/vnd.openxmlformats-officedocument.spreadsheetml.queryTable+xml" SML_REVISION_HEADERS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.revisio" - "nHeaders+xml" - ) - SML_REVISION_LOG = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionLog+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionHeaders+xml" ) + SML_REVISION_LOG = "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionLog+xml" SML_SHARED_STRINGS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedS" - "trings+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml" ) SML_SHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - SML_SHEET_MAIN = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" - ) + SML_SHEET_MAIN = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" SML_SHEET_METADATA = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMe" - "tadata+xml" - ) - SML_STYLES = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml" ) + SML_STYLES = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" SML_TABLE = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml" SML_TABLE_SINGLE_CELLS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.tableSi" - "ngleCells+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.tableSingleCells+xml" ) SML_TEMPLATE_MAIN = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.templat" - "e.main+xml" - ) - SML_USER_NAMES = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.userNames+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml" ) + SML_USER_NAMES = "application/vnd.openxmlformats-officedocument.spreadsheetml.userNames+xml" SML_VOLATILE_DEPENDENCIES = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.volatil" - "eDependencies+xml" - ) - SML_WORKSHEET = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.volatileDependencies+xml" ) + SML_WORKSHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" TIFF = "image/tiff" - WML_COMMENTS = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml" - ) - WML_DOCUMENT = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ) + WML_COMMENTS = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml" + WML_DOCUMENT = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" WML_DOCUMENT_GLOSSARY = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.docu" - "ment.glossary+xml" + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml" ) WML_DOCUMENT_MAIN = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.docu" - "ment.main+xml" - ) - WML_ENDNOTES = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml" - ) - WML_FONT_TABLE = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.font" - "Table+xml" - ) - WML_FOOTER = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml" - ) - WML_FOOTNOTES = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.foot" - "notes+xml" - ) - WML_HEADER = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml" - ) - WML_NUMBERING = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.numb" - "ering+xml" - ) + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml" + ) + WML_ENDNOTES = "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml" + WML_FONT_TABLE = "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml" + WML_FOOTER = "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml" + WML_FOOTNOTES = "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml" + WML_HEADER = "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml" + WML_NUMBERING = "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml" WML_PRINTER_SETTINGS = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.prin" - "terSettings" - ) - WML_SETTINGS = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml" - ) - WML_STYLES = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml" + "application/vnd.openxmlformats-officedocument.wordprocessingml.printerSettings" ) + WML_SETTINGS = "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml" + WML_STYLES = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml" WML_WEB_SETTINGS = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.webS" - "ettings+xml" + "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml" ) XML = "application/xml" X_EMF = "image/x-emf" @@ -257,9 +162,7 @@ class NAMESPACE: DML_WORDPROCESSING_DRAWING = ( "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" ) - OFC_RELATIONSHIPS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - ) + OFC_RELATIONSHIPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" OPC_RELATIONSHIPS = "http://schemas.openxmlformats.org/package/2006/relationships" OPC_CONTENT_TYPES = "http://schemas.openxmlformats.org/package/2006/content-types" WML_MAIN = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" @@ -274,259 +177,130 @@ class RELATIONSHIP_TARGET_MODE: class RELATIONSHIP_TYPE: AUDIO = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio" - A_F_CHUNK = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" - ) - CALC_CHAIN = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/calcChain" - ) + A_F_CHUNK = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" + CALC_CHAIN = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/calcChain" CERTIFICATE = ( - "http://schemas.openxmlformats.org/package/2006/relationships/digita" - "l-signature/certificate" + "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/certificate" ) CHART = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" - CHARTSHEET = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/chartsheet" - ) + CHARTSHEET = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet" CHART_USER_SHAPES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/chartUserShapes" - ) - COMMENTS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/comments" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartUserShapes" ) + COMMENTS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" COMMENT_AUTHORS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/commentAuthors" - ) - CONNECTIONS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/connections" - ) - CONTROL = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/control" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/commentAuthors" ) + CONNECTIONS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/connections" + CONTROL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/control" CORE_PROPERTIES = ( - "http://schemas.openxmlformats.org/package/2006/relationships/metada" - "ta/core-properties" + "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" ) CUSTOM_PROPERTIES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/custom-properties" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" ) CUSTOM_PROPERTY = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/customProperty" - ) - CUSTOM_XML = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/customXml" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customProperty" ) + CUSTOM_XML = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" CUSTOM_XML_PROPS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/customXmlProps" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps" ) DIAGRAM_COLORS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/diagramColors" - ) - DIAGRAM_DATA = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/diagramData" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors" ) + DIAGRAM_DATA = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData" DIAGRAM_LAYOUT = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/diagramLayout" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramLayout" ) DIAGRAM_QUICK_STYLE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/diagramQuickStyle" - ) - DIALOGSHEET = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/dialogsheet" - ) - DRAWING = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" - ) - ENDNOTES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/endnotes" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramQuickStyle" ) + DIALOGSHEET = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/dialogsheet" + DRAWING = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" + ENDNOTES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" EXTENDED_PROPERTIES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/extended-properties" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" ) EXTERNAL_LINK = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/externalLink" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/externalLink" ) FONT = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font" - FONT_TABLE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/fontTable" - ) - FOOTER = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" - ) - FOOTNOTES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/footnotes" - ) + FONT_TABLE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" + FOOTER = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" + FOOTNOTES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" GLOSSARY_DOCUMENT = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/glossaryDocument" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument" ) HANDOUT_MASTER = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/handoutMaster" - ) - HEADER = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" - ) - HYPERLINK = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/hyperlink" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/handoutMaster" ) + HEADER = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" + HYPERLINK = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" IMAGE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" - NOTES_MASTER = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/notesMaster" - ) - NOTES_SLIDE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/notesSlide" - ) - NUMBERING = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/numbering" - ) + NOTES_MASTER = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" + NOTES_SLIDE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" + NUMBERING = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" OFFICE_DOCUMENT = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/officeDocument" - ) - OLE_OBJECT = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/oleObject" - ) - ORIGIN = ( - "http://schemas.openxmlformats.org/package/2006/relationships/digita" - "l-signature/origin" - ) - PACKAGE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ) + OLE_OBJECT = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" + ORIGIN = "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/origin" + PACKAGE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" PIVOT_CACHE_DEFINITION = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/pivotCacheDefinition" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition" ) PIVOT_CACHE_RECORDS = ( "http://schemas.openxmlformats.org/officeDocument/2006/relationships" "/spreadsheetml/pivotCacheRecords" ) - PIVOT_TABLE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/pivotTable" - ) - PRES_PROPS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/presProps" - ) + PIVOT_TABLE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable" + PRES_PROPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps" PRINTER_SETTINGS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/printerSettings" - ) - QUERY_TABLE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/queryTable" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/printerSettings" ) + QUERY_TABLE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/queryTable" REVISION_HEADERS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/revisionHeaders" - ) - REVISION_LOG = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/revisionLog" - ) - SETTINGS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/settings" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/revisionHeaders" ) + REVISION_LOG = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/revisionLog" + SETTINGS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" SHARED_STRINGS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/sharedStrings" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" ) SHEET_METADATA = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/sheetMetadata" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sheetMetadata" ) SIGNATURE = ( - "http://schemas.openxmlformats.org/package/2006/relationships/digita" - "l-signature/signature" + "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/signature" ) SLIDE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" - SLIDE_LAYOUT = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/slideLayout" - ) - SLIDE_MASTER = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/slideMaster" - ) + SLIDE_LAYOUT = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" + SLIDE_MASTER = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" SLIDE_UPDATE_INFO = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/slideUpdateInfo" - ) - STYLES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideUpdateInfo" ) + STYLES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" TABLE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" TABLE_SINGLE_CELLS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/tableSingleCells" - ) - TABLE_STYLES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/tableStyles" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableSingleCells" ) + TABLE_STYLES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles" TAGS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags" THEME = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" THEME_OVERRIDE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/themeOverride" - ) - THUMBNAIL = ( - "http://schemas.openxmlformats.org/package/2006/relationships/metada" - "ta/thumbnail" - ) - USERNAMES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/usernames" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/themeOverride" ) + THUMBNAIL = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail" + USERNAMES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/usernames" VIDEO = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/video" - VIEW_PROPS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/viewProps" - ) - VML_DRAWING = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/vmlDrawing" - ) + VIEW_PROPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/viewProps" + VML_DRAWING = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" VOLATILE_DEPENDENCIES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/volatileDependencies" - ) - WEB_SETTINGS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/webSettings" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/volatileDependencies" ) + WEB_SETTINGS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings" WORKSHEET_SOURCE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/worksheetSource" - ) - XML_MAPS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/xmlMaps" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheetSource" ) + XML_MAPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/xmlMaps" diff --git a/src/docx/opc/packuri.py b/src/docx/opc/packuri.py index fdbb67ed8..89437b164 100644 --- a/src/docx/opc/packuri.py +++ b/src/docx/opc/packuri.py @@ -10,8 +10,7 @@ class PackURI(str): - """Provides access to pack URI components such as the baseURI and the filename - slice. + """Provides access to pack URI components such as the baseURI and the filename slice. Behaves as |str| otherwise. """ diff --git a/src/docx/opc/pkgreader.py b/src/docx/opc/pkgreader.py index f00e7b5f0..15207e517 100644 --- a/src/docx/opc/pkgreader.py +++ b/src/docx/opc/pkgreader.py @@ -22,9 +22,7 @@ def from_file(pkg_file): phys_reader = PhysPkgReader(pkg_file) content_types = _ContentTypeMap.from_xml(phys_reader.content_types_xml) pkg_srels = PackageReader._srels_for(phys_reader, PACKAGE_URI) - sparts = PackageReader._load_serialized_parts( - phys_reader, pkg_srels, content_types - ) + sparts = PackageReader._load_serialized_parts(phys_reader, pkg_srels, content_types) phys_reader.close() return PackageReader(content_types, pkg_srels, sparts) @@ -80,9 +78,7 @@ def _walk_phys_parts(phys_reader, srels, visited_partnames=None): part_srels = PackageReader._srels_for(phys_reader, partname) blob = phys_reader.blob_for(partname) yield (partname, blob, reltype, part_srels) - next_walker = PackageReader._walk_phys_parts( - phys_reader, part_srels, visited_partnames - ) + next_walker = PackageReader._walk_phys_parts(phys_reader, part_srels, visited_partnames) for partname, blob, reltype, srels in next_walker: yield (partname, blob, reltype, srels) diff --git a/src/docx/opc/rel.py b/src/docx/opc/rel.py index 47e8860d8..153b308d0 100644 --- a/src/docx/opc/rel.py +++ b/src/docx/opc/rel.py @@ -79,9 +79,7 @@ def matches(rel: _Relationship, reltype: str, target: Part | str, is_external: b if rel.is_external != is_external: return False rel_target = rel.target_ref if rel.is_external else rel.target_part - if rel_target != target: - return False - return True + return rel_target == target for rel in self.values(): if matches(rel, reltype, target, is_external): @@ -142,7 +140,7 @@ def rId(self) -> str: def target_part(self) -> Part: if self._is_external: raise ValueError( - "target_part property on _Relationship is undef" "ined when target mode is External" + "target_part property on _Relationship is undefined when target mode is External" ) return cast("Part", self._target) diff --git a/src/docx/oxml/simpletypes.py b/src/docx/oxml/simpletypes.py index dd10ab910..69d4b65d4 100644 --- a/src/docx/oxml/simpletypes.py +++ b/src/docx/oxml/simpletypes.py @@ -125,7 +125,7 @@ def convert_to_xml(cls, value: bool) -> str: def validate(cls, value: Any) -> None: if value not in (True, False): raise TypeError( - "only True or False (and possibly None) may be assigned, got" " '%s'" % value + "only True or False (and possibly None) may be assigned, got '%s'" % value ) diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index e38d58562..9457da207 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -519,7 +519,7 @@ def merge(self, other_tc: CT_Tc) -> CT_Tc: @classmethod def new(cls) -> CT_Tc: """A new `w:tc` element, containing an empty paragraph as the required EG_BlockLevelElt.""" - return cast(CT_Tc, parse_xml("\n" " \n" "" % nsdecls("w"))) + return cast(CT_Tc, parse_xml("" % nsdecls("w"))) @property def right(self) -> int: @@ -583,7 +583,9 @@ def vMerge_val(top_tc: CT_Tc): return ( ST_Merge.CONTINUE if top_tc is not self - else None if height == 1 else ST_Merge.RESTART + else None + if height == 1 + else ST_Merge.RESTART ) top_tc = self if top_tc is None else top_tc @@ -609,9 +611,7 @@ def _is_empty(self) -> bool: # -- cell must include at least one block item but can be a `w:tbl`, `w:sdt`, # -- `w:customXml` or a `w:p` only_item = block_items[0] - if isinstance(only_item, CT_P) and len(only_item.r_lst) == 0: - return True - return False + return isinstance(only_item, CT_P) and len(only_item.r_lst) == 0 def _move_content_to(self, other_tc: CT_Tc): """Append the content of this cell to `other_tc`. diff --git a/src/docx/oxml/text/font.py b/src/docx/oxml/text/font.py index c5dc9bd2e..32eb567ba 100644 --- a/src/docx/oxml/text/font.py +++ b/src/docx/oxml/text/font.py @@ -236,9 +236,7 @@ def subscript(self) -> bool | None: vertAlign = self.vertAlign if vertAlign is None: return None - if vertAlign.val == ST_VerticalAlignRun.SUBSCRIPT: - return True - return False + return vertAlign.val == ST_VerticalAlignRun.SUBSCRIPT @subscript.setter def subscript(self, value: bool | None) -> None: @@ -260,9 +258,7 @@ def superscript(self) -> bool | None: vertAlign = self.vertAlign if vertAlign is None: return None - if vertAlign.val == ST_VerticalAlignRun.SUPERSCRIPT: - return True - return False + return vertAlign.val == ST_VerticalAlignRun.SUPERSCRIPT @superscript.setter def superscript(self, value: bool | None): diff --git a/src/docx/oxml/text/pagebreak.py b/src/docx/oxml/text/pagebreak.py index 943f9b6c2..45a6f51a7 100644 --- a/src/docx/oxml/text/pagebreak.py +++ b/src/docx/oxml/text/pagebreak.py @@ -46,9 +46,7 @@ def following_fragment_p(self) -> CT_P: # -- splitting approach is different when break is inside a hyperlink -- return ( - self._following_frag_in_hlink - if self._is_in_hyperlink - else self._following_frag_in_run + self._following_frag_in_hlink if self._is_in_hyperlink else self._following_frag_in_run ) @property @@ -116,9 +114,7 @@ def preceding_fragment_p(self) -> CT_P: # -- splitting approach is different when break is inside a hyperlink -- return ( - self._preceding_frag_in_hlink - if self._is_in_hyperlink - else self._preceding_frag_in_run + self._preceding_frag_in_hlink if self._is_in_hyperlink else self._preceding_frag_in_run ) def _enclosing_hyperlink(self, lrpb: CT_LastRenderedPageBreak) -> CT_Hyperlink: @@ -139,9 +135,7 @@ def _first_lrpb_in_p(self, p: CT_P) -> CT_LastRenderedPageBreak: Raises `ValueError` if there are no rendered page-breaks in `p`. """ - lrpbs = p.xpath( - "./w:r/w:lastRenderedPageBreak | ./w:hyperlink/w:r/w:lastRenderedPageBreak" - ) + lrpbs = p.xpath("./w:r/w:lastRenderedPageBreak | ./w:hyperlink/w:r/w:lastRenderedPageBreak") if not lrpbs: raise ValueError("no rendered page-breaks in paragraph element") return lrpbs[0] diff --git a/src/docx/oxml/xmlchemy.py b/src/docx/oxml/xmlchemy.py index bc33e1f58..df75ee18c 100644 --- a/src/docx/oxml/xmlchemy.py +++ b/src/docx/oxml/xmlchemy.py @@ -75,9 +75,7 @@ def _eq_elm_strs(self, line: str, line_2: str): return False if close != close_2: return False - if text != text_2: - return False - return True + return text == text_2 @classmethod def _parse_line(cls, line: str) -> tuple[str, str, str, str]: @@ -464,7 +462,7 @@ def get_or_change_to_child(obj: BaseOxmlElement): return child get_or_change_to_child.__doc__ = ( - "Return the ``<%s>`` child, replacing any other group element if" " found." + "Return the ``<%s>`` child, replacing any other group element if found." ) % self._nsptagname self._add_to_class(self._get_or_change_to_method_name, get_or_change_to_child) diff --git a/src/docx/parts/settings.py b/src/docx/parts/settings.py index 116facca2..7fe371f09 100644 --- a/src/docx/parts/settings.py +++ b/src/docx/parts/settings.py @@ -27,8 +27,7 @@ def __init__( @classmethod def default(cls, package: Package): - """Return a newly created settings part, containing a default `w:settings` - element tree.""" + """Return a newly created settings part, containing a default `w:settings` element tree.""" partname = PackURI("/word/settings.xml") content_type = CT.WML_SETTINGS element = cast("CT_Settings", parse_xml(cls._default_settings_xml())) diff --git a/src/docx/parts/styles.py b/src/docx/parts/styles.py index dffa762ef..6e065beee 100644 --- a/src/docx/parts/styles.py +++ b/src/docx/parts/styles.py @@ -36,9 +36,7 @@ def styles(self): @classmethod def _default_styles_xml(cls): """Return a bytestream containing XML for a default styles part.""" - path = os.path.join( - os.path.split(__file__)[0], "..", "templates", "default-styles.xml" - ) + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-styles.xml") with open(path, "rb") as f: xml_bytes = f.read() return xml_bytes diff --git a/src/docx/styles/styles.py b/src/docx/styles/styles.py index 98a56e520..b05b3ebb1 100644 --- a/src/docx/styles/styles.py +++ b/src/docx/styles/styles.py @@ -40,10 +40,7 @@ def __getitem__(self, key: str): style_elm = self._element.get_by_id(key) if style_elm is not None: - msg = ( - "style lookup by style_id is deprecated. Use style name as " - "key instead." - ) + msg = "style lookup by style_id is deprecated. Use style name as key instead." warn(msg, UserWarning, stacklevel=2) return StyleFactory(style_elm) @@ -118,9 +115,7 @@ def _get_by_id(self, style_id: str | None, style_type: WD_STYLE_TYPE): return self.default(style_type) return StyleFactory(style) - def _get_style_id_from_name( - self, style_name: str, style_type: WD_STYLE_TYPE - ) -> str | None: + def _get_style_id_from_name(self, style_name: str, style_type: WD_STYLE_TYPE) -> str | None: """Return the id of the style of `style_type` corresponding to `style_name`. Returns |None| if that style is the default style for `style_type`. Raises @@ -129,17 +124,13 @@ def _get_style_id_from_name( """ return self._get_style_id_from_style(self[style_name], style_type) - def _get_style_id_from_style( - self, style: BaseStyle, style_type: WD_STYLE_TYPE - ) -> str | None: + def _get_style_id_from_style(self, style: BaseStyle, style_type: WD_STYLE_TYPE) -> str | None: """Id of `style`, or |None| if it is the default style of `style_type`. Raises |ValueError| if style is not of `style_type`. """ if style.type != style_type: - raise ValueError( - "assigned style is type %s, need type %s" % (style.type, style_type) - ) + raise ValueError("assigned style is type %s, need type %s" % (style.type, style_type)) if style == self.default(style_type): return None return style.style_id diff --git a/src/docx/text/font.py b/src/docx/text/font.py index acd60795b..0439f4547 100644 --- a/src/docx/text/font.py +++ b/src/docx/text/font.py @@ -398,11 +398,7 @@ def underline(self, value: bool | WD_UNDERLINE | None) -> None: # -- False == 0, which happen to match the mapping for WD_UNDERLINE.SINGLE # -- and .NONE respectively. val = ( - WD_UNDERLINE.SINGLE - if value is True - else WD_UNDERLINE.NONE - if value is False - else value + WD_UNDERLINE.SINGLE if value is True else WD_UNDERLINE.NONE if value is False else value ) rPr.u_val = val diff --git a/src/docx/text/tabstops.py b/src/docx/text/tabstops.py index 824085d2b..0f8c22c9c 100644 --- a/src/docx/text/tabstops.py +++ b/src/docx/text/tabstops.py @@ -50,9 +50,7 @@ def __len__(self): return 0 return len(tabs.tab_lst) - def add_tab_stop( - self, position, alignment=WD_TAB_ALIGNMENT.LEFT, leader=WD_TAB_LEADER.SPACES - ): + def add_tab_stop(self, position, alignment=WD_TAB_ALIGNMENT.LEFT, leader=WD_TAB_LEADER.SPACES): """Add a new tab stop at `position`, a |Length| object specifying the location of the tab stop relative to the paragraph edge. diff --git a/src/docx/types.py b/src/docx/types.py index 00bc100a1..06d1a571a 100644 --- a/src/docx/types.py +++ b/src/docx/types.py @@ -19,8 +19,7 @@ class ProvidesStoryPart(Protocol): """ @property - def part(self) -> StoryPart: - ... + def part(self) -> StoryPart: ... class ProvidesXmlPart(Protocol): @@ -32,5 +31,4 @@ class ProvidesXmlPart(Protocol): """ @property - def part(self) -> XmlPart: - ... + def part(self) -> XmlPart: ... diff --git a/tests/image/test_bmp.py b/tests/image/test_bmp.py index 15b322b66..27c0e8f5c 100644 --- a/tests/image/test_bmp.py +++ b/tests/image/test_bmp.py @@ -14,8 +14,8 @@ class DescribeBmp: def it_can_construct_from_a_bmp_stream(self, Bmp__init__): cx, cy, horz_dpi, vert_dpi = 26, 43, 200, 96 bytes_ = ( - b"fillerfillerfiller\x1A\x00\x00\x00\x2B\x00\x00\x00" - b"fillerfiller\xB8\x1E\x00\x00\x00\x00\x00\x00" + b"fillerfillerfiller\x1a\x00\x00\x00\x2b\x00\x00\x00" + b"fillerfiller\xb8\x1e\x00\x00\x00\x00\x00\x00" ) stream = io.BytesIO(bytes_) diff --git a/tests/image/test_gif.py b/tests/image/test_gif.py index a533da04d..4aa6581ba 100644 --- a/tests/image/test_gif.py +++ b/tests/image/test_gif.py @@ -13,7 +13,7 @@ class DescribeGif: def it_can_construct_from_a_gif_stream(self, Gif__init__): cx, cy = 42, 24 - bytes_ = b"filler\x2A\x00\x18\x00" + bytes_ = b"filler\x2a\x00\x18\x00" stream = io.BytesIO(bytes_) gif = Gif.from_stream(stream) diff --git a/tests/image/test_helpers.py b/tests/image/test_helpers.py index 9192564dc..03421ff5f 100644 --- a/tests/image/test_helpers.py +++ b/tests/image/test_helpers.py @@ -28,8 +28,8 @@ def it_can_read_a_long(self, read_long_fixture): @pytest.fixture( params=[ - (BIG_ENDIAN, b"\xBE\x00\x00\x00\x2A\xEF", 1, 42), - (LITTLE_ENDIAN, b"\xBE\xEF\x2A\x00\x00\x00", 2, 42), + (BIG_ENDIAN, b"\xbe\x00\x00\x00\x2a\xef", 1, 42), + (LITTLE_ENDIAN, b"\xbe\xef\x2a\x00\x00\x00", 2, 42), ] ) def read_long_fixture(self, request): diff --git a/tests/image/test_image.py b/tests/image/test_image.py index bd5ed0903..c13e87305 100644 --- a/tests/image/test_image.py +++ b/tests/image/test_image.py @@ -27,9 +27,7 @@ class DescribeImage: - def it_can_construct_from_an_image_blob( - self, blob_, BytesIO_, _from_stream_, stream_, image_ - ): + def it_can_construct_from_an_image_blob(self, blob_, BytesIO_, _from_stream_, stream_, image_): image = Image.from_blob(blob_) BytesIO_.assert_called_once_with(blob_) @@ -231,9 +229,7 @@ def filename_(self, request): @pytest.fixture def _from_stream_(self, request, image_): - return method_mock( - request, Image, "_from_stream", autospec=False, return_value=image_ - ) + return method_mock(request, Image, "_from_stream", autospec=False, return_value=image_) @pytest.fixture def height_prop_(self, request): diff --git a/tests/image/test_jpeg.py b/tests/image/test_jpeg.py index a558e1d4e..129a07d80 100644 --- a/tests/image/test_jpeg.py +++ b/tests/image/test_jpeg.py @@ -247,7 +247,7 @@ def it_can_construct_from_a_stream_and_offset(self, from_stream_fixture): ) def from_stream_fixture(self, request, _Marker__init_): marker_code, offset, length = request.param - bytes_ = b"\xFF\xD8\xFF\xE0\x00\x10" + bytes_ = b"\xff\xd8\xff\xe0\x00\x10" stream_reader = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) return stream_reader, marker_code, offset, _Marker__init_, length @@ -258,7 +258,7 @@ def _Marker__init_(self, request): class Describe_App0Marker: def it_can_construct_from_a_stream_and_offset(self, _App0Marker__init_): - bytes_ = b"\x00\x10JFIF\x00\x01\x01\x01\x00\x2A\x00\x18" + bytes_ = b"\x00\x10JFIF\x00\x01\x01\x01\x00\x2a\x00\x18" marker_code, offset, length = JPEG_MARKER_CODE.APP0, 0, 16 density_units, x_density, y_density = 1, 42, 24 stream = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) @@ -318,9 +318,7 @@ def it_can_construct_from_non_Exif_APP1_segment(self, _App1Marker__init_): app1_marker = _App1Marker.from_stream(stream, marker_code, offset) - _App1Marker__init_.assert_called_once_with( - ANY, marker_code, offset, length, 72, 72 - ) + _App1Marker__init_.assert_called_once_with(ANY, marker_code, offset, length, 72, 72) assert isinstance(app1_marker, _App1Marker) def it_gets_a_tiff_from_its_Exif_segment_to_help_construct(self, get_tiff_fixture): @@ -348,9 +346,7 @@ def _App1Marker__init_(self, request): def get_tiff_fixture(self, request, substream_, Tiff_, tiff_): bytes_ = b"xfillerxMM\x00*\x00\x00\x00\x42" stream_reader = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) - BytesIO_ = class_mock( - request, "docx.image.jpeg.io.BytesIO", return_value=substream_ - ) + BytesIO_ = class_mock(request, "docx.image.jpeg.io.BytesIO", return_value=substream_) offset, segment_length, segment_bytes = 0, 16, bytes_[8:] return ( stream_reader, @@ -390,7 +386,7 @@ def _tiff_from_exif_segment_(self, request, tiff_): class Describe_SofMarker: def it_can_construct_from_a_stream_and_offset(self, request, _SofMarker__init_): - bytes_ = b"\x00\x11\x00\x00\x2A\x00\x18" + bytes_ = b"\x00\x11\x00\x00\x2a\x00\x18" marker_code, offset, length = JPEG_MARKER_CODE.SOF0, 0, 17 px_width, px_height = 24, 42 stream = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) @@ -509,7 +505,7 @@ def _MarkerFinder__init_(self, request): ) def next_fixture(self, request): start, marker_code, segment_offset = request.param - bytes_ = b"\xFF\xD8\xFF\xE0\x00\x01\xFF\x00\xFF\xFF\xFF\xD9" + bytes_ = b"\xff\xd8\xff\xe0\x00\x01\xff\x00\xff\xff\xff\xd9" stream_reader = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) marker_finder = _MarkerFinder(stream_reader) expected_code_and_offset = (marker_code, segment_offset) @@ -626,9 +622,7 @@ def stream_(self, request): @pytest.fixture def StreamReader_(self, request, stream_reader_): - return class_mock( - request, "docx.image.jpeg.StreamReader", return_value=stream_reader_ - ) + return class_mock(request, "docx.image.jpeg.StreamReader", return_value=stream_reader_) @pytest.fixture def stream_reader_(self, request): diff --git a/tests/image/test_png.py b/tests/image/test_png.py index 61e7fdbed..5379b403b 100644 --- a/tests/image/test_png.py +++ b/tests/image/test_png.py @@ -30,9 +30,7 @@ class DescribePng: - def it_can_construct_from_a_png_stream( - self, stream_, _PngParser_, png_parser_, Png__init__ - ): + def it_can_construct_from_a_png_stream(self, stream_, _PngParser_, png_parser_, Png__init__): px_width, px_height, horz_dpi, vert_dpi = 42, 24, 36, 63 png_parser_.px_width = px_width png_parser_.px_height = px_height @@ -42,9 +40,7 @@ def it_can_construct_from_a_png_stream( png = Png.from_stream(stream_) _PngParser_.parse.assert_called_once_with(stream_) - Png__init__.assert_called_once_with( - ANY, px_width, px_height, horz_dpi, vert_dpi - ) + Png__init__.assert_called_once_with(ANY, px_width, px_height, horz_dpi, vert_dpi) assert isinstance(png, Png) def it_knows_its_content_type(self): @@ -157,9 +153,7 @@ def stream_(self, request): class Describe_Chunks: - def it_can_construct_from_a_stream( - self, stream_, _ChunkParser_, chunk_parser_, _Chunks__init_ - ): + def it_can_construct_from_a_stream(self, stream_, _ChunkParser_, chunk_parser_, _Chunks__init_): chunk_lst = [1, 2] chunk_parser_.iter_chunks.return_value = iter(chunk_lst) @@ -277,9 +271,7 @@ def chunk_2_(self, request): @pytest.fixture def _ChunkFactory_(self, request, chunk_lst_): - return function_mock( - request, "docx.image.png._ChunkFactory", side_effect=chunk_lst_ - ) + return function_mock(request, "docx.image.png._ChunkFactory", side_effect=chunk_lst_) @pytest.fixture def chunk_lst_(self, chunk_, chunk_2_): @@ -315,9 +307,7 @@ def iter_offsets_fixture(self): @pytest.fixture def StreamReader_(self, request, stream_rdr_): - return class_mock( - request, "docx.image.png.StreamReader", return_value=stream_rdr_ - ) + return class_mock(request, "docx.image.png.StreamReader", return_value=stream_rdr_) @pytest.fixture def stream_(self, request): @@ -409,7 +399,7 @@ def it_can_construct_from_a_stream_and_offset(self, from_offset_fixture): @pytest.fixture def from_offset_fixture(self): - bytes_ = b"\x00\x00\x00\x2A\x00\x00\x00\x18" + bytes_ = b"\x00\x00\x00\x2a\x00\x00\x00\x18" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) offset, px_width, px_height = 0, 42, 24 return stream_rdr, offset, px_width, px_height @@ -430,7 +420,7 @@ def it_can_construct_from_a_stream_and_offset(self, from_offset_fixture): @pytest.fixture def from_offset_fixture(self): - bytes_ = b"\x00\x00\x00\x2A\x00\x00\x00\x18\x01" + bytes_ = b"\x00\x00\x00\x2a\x00\x00\x00\x18\x01" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) offset, horz_px_per_unit, vert_px_per_unit, units_specifier = (0, 42, 24, 1) return (stream_rdr, offset, horz_px_per_unit, vert_px_per_unit, units_specifier) diff --git a/tests/image/test_tiff.py b/tests/image/test_tiff.py index b7f37afe5..35344eede 100644 --- a/tests/image/test_tiff.py +++ b/tests/image/test_tiff.py @@ -32,9 +32,7 @@ class DescribeTiff: - def it_can_construct_from_a_tiff_stream( - self, stream_, _TiffParser_, tiff_parser_, Tiff__init_ - ): + def it_can_construct_from_a_tiff_stream(self, stream_, _TiffParser_, tiff_parser_, Tiff__init_): px_width, px_height = 111, 222 horz_dpi, vert_dpi = 333, 444 tiff_parser_.px_width = px_width @@ -45,9 +43,7 @@ def it_can_construct_from_a_tiff_stream( tiff = Tiff.from_stream(stream_) _TiffParser_.parse.assert_called_once_with(stream_) - Tiff__init_.assert_called_once_with( - ANY, px_width, px_height, horz_dpi, vert_dpi - ) + Tiff__init_.assert_called_once_with(ANY, px_width, px_height, horz_dpi, vert_dpi) assert isinstance(tiff, Tiff) def it_knows_its_content_type(self): @@ -186,9 +182,7 @@ def stream_(self, request): @pytest.fixture def StreamReader_(self, request, stream_rdr_): - return class_mock( - request, "docx.image.tiff.StreamReader", return_value=stream_rdr_ - ) + return class_mock(request, "docx.image.tiff.StreamReader", return_value=stream_rdr_) @pytest.fixture def stream_rdr_(self, request, ifd0_offset_): @@ -244,9 +238,7 @@ def _IfdEntries__init_(self, request): @pytest.fixture def _IfdParser_(self, request, ifd_parser_): - return class_mock( - request, "docx.image.tiff._IfdParser", return_value=ifd_parser_ - ) + return class_mock(request, "docx.image.tiff._IfdParser", return_value=ifd_parser_) @pytest.fixture def ifd_parser_(self, request): @@ -386,9 +378,7 @@ def offset_(self, request): class Describe_IfdEntry: - def it_can_construct_from_a_stream_and_offset( - self, _parse_value_, _IfdEntry__init_, value_ - ): + def it_can_construct_from_a_stream_and_offset(self, _parse_value_, _IfdEntry__init_, value_): bytes_ = b"\x00\x01\x66\x66\x00\x00\x00\x02\x00\x00\x00\x03" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) offset, tag_code, value_count, value_offset = 0, 1, 2, 3 @@ -396,9 +386,7 @@ def it_can_construct_from_a_stream_and_offset( ifd_entry = _IfdEntry.from_stream(stream_rdr, offset) - _parse_value_.assert_called_once_with( - stream_rdr, offset, value_count, value_offset - ) + _parse_value_.assert_called_once_with(stream_rdr, offset, value_count, value_offset) _IfdEntry__init_.assert_called_once_with(ANY, tag_code, value_) assert isinstance(ifd_entry, _IfdEntry) @@ -432,7 +420,7 @@ def it_can_parse_an_ascii_string_IFD_entry(self): class Describe_ShortIfdEntry: def it_can_parse_a_short_int_IFD_entry(self): - bytes_ = b"foobaroo\x00\x2A" + bytes_ = b"foobaroo\x00\x2a" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) val = _ShortIfdEntry._parse_value(stream_rdr, 0, 1, None) assert val == 42 @@ -440,7 +428,7 @@ def it_can_parse_a_short_int_IFD_entry(self): class Describe_LongIfdEntry: def it_can_parse_a_long_int_IFD_entry(self): - bytes_ = b"foobaroo\x00\x00\x00\x2A" + bytes_ = b"foobaroo\x00\x00\x00\x2a" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) val = _LongIfdEntry._parse_value(stream_rdr, 0, 1, None) assert val == 42 @@ -448,7 +436,7 @@ def it_can_parse_a_long_int_IFD_entry(self): class Describe_RationalIfdEntry: def it_can_parse_a_rational_IFD_entry(self): - bytes_ = b"\x00\x00\x00\x2A\x00\x00\x00\x54" + bytes_ = b"\x00\x00\x00\x2a\x00\x00\x00\x54" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) val = _RationalIfdEntry._parse_value(stream_rdr, None, 1, 0) assert val == 0.5 diff --git a/tests/opc/test_pkgreader.py b/tests/opc/test_pkgreader.py index 8e14f0e01..0aed52c8d 100644 --- a/tests/opc/test_pkgreader.py +++ b/tests/opc/test_pkgreader.py @@ -44,9 +44,7 @@ def it_can_construct_from_pkg_file( PhysPkgReader_.assert_called_once_with(pkg_file) from_xml.assert_called_once_with(phys_reader.content_types_xml) _srels_for.assert_called_once_with(phys_reader, "/") - _load_serialized_parts.assert_called_once_with( - phys_reader, pkg_srels, content_types - ) + _load_serialized_parts.assert_called_once_with(phys_reader, pkg_srels, content_types) phys_reader.close.assert_called_once_with() _init_.assert_called_once_with(ANY, content_types, pkg_srels, sparts) assert isinstance(pkg_reader, PackageReader) @@ -94,17 +92,11 @@ def it_can_load_serialized_parts(self, _SerializedPart_, _walk_phys_parts): Mock(name="spart_2"), ) # exercise --------------------- - retval = PackageReader._load_serialized_parts( - phys_reader, pkg_srels, content_types - ) + retval = PackageReader._load_serialized_parts(phys_reader, pkg_srels, content_types) # verify ----------------------- expected_calls = [ - call( - "/part/name1.xml", "app/vnd.type_1", "", "reltype1", "srels_1" - ), - call( - "/part/name2.xml", "app/vnd.type_2", "", "reltype2", "srels_2" - ), + call("/part/name1.xml", "app/vnd.type_1", "", "reltype1", "srels_1"), + call("/part/name2.xml", "app/vnd.type_2", "", "reltype2", "srels_2"), ] assert _SerializedPart_.call_args_list == expected_calls assert retval == expected_sparts @@ -208,9 +200,7 @@ def _init_(self, request): return initializer_mock(request, PackageReader) @pytest.fixture - def iter_sparts_fixture( - self, sparts_, partnames_, content_types_, reltypes_, blobs_ - ): + def iter_sparts_fixture(self, sparts_, partnames_, content_types_, reltypes_, blobs_): pkg_reader = PackageReader(None, None, sparts_) expected_iter_spart_items = [ (partnames_[0], content_types_[0], reltypes_[0], blobs_[0]), @@ -220,9 +210,7 @@ def iter_sparts_fixture( @pytest.fixture def _load_serialized_parts(self, request): - return method_mock( - request, PackageReader, "_load_serialized_parts", autospec=False - ) + return method_mock(request, PackageReader, "_load_serialized_parts", autospec=False) @pytest.fixture def partnames_(self, request): @@ -283,15 +271,11 @@ def it_can_construct_from_ct_item_xml(self, from_xml_fixture): assert ct_map._defaults == expected_defaults assert ct_map._overrides == expected_overrides - def it_matches_an_override_on_case_insensitive_partname( - self, match_override_fixture - ): + def it_matches_an_override_on_case_insensitive_partname(self, match_override_fixture): ct_map, partname, content_type = match_override_fixture assert ct_map[partname] == content_type - def it_falls_back_to_case_insensitive_extension_default_match( - self, match_default_fixture - ): + def it_falls_back_to_case_insensitive_extension_default_match(self, match_default_fixture): ct_map, partname, content_type = match_default_fixture assert ct_map[partname] == content_type diff --git a/tests/opc/test_rel.py b/tests/opc/test_rel.py index 7b7a98dfe..f56fecd22 100644 --- a/tests/opc/test_rel.py +++ b/tests/opc/test_rel.py @@ -77,18 +77,14 @@ def it_can_find_a_relationship_by_rId(self): rels["foobar"] = rel assert rels["foobar"] == rel - def it_can_find_or_add_a_relationship( - self, rels_with_matching_rel_, rels_with_missing_rel_ - ): + def it_can_find_or_add_a_relationship(self, rels_with_matching_rel_, rels_with_missing_rel_): rels, reltype, part, matching_rel = rels_with_matching_rel_ assert rels.get_or_add(reltype, part) == matching_rel rels, reltype, part, new_rel = rels_with_missing_rel_ assert rels.get_or_add(reltype, part) == new_rel - def it_can_find_or_add_an_external_relationship( - self, add_matching_ext_rel_fixture_ - ): + def it_can_find_or_add_an_external_relationship(self, add_matching_ext_rel_fixture_): rels, reltype, url, rId = add_matching_ext_rel_fixture_ _rId = rels.get_or_add_ext_rel(reltype, url) assert _rId == rId @@ -235,20 +231,14 @@ def rels_with_missing_rel_(self, request, rels, _Relationship_): @pytest.fixture def rels_with_rId_gap(self, request): rels = Relationships(None) - rel_with_rId1 = instance_mock( - request, _Relationship, name="rel_with_rId1", rId="rId1" - ) - rel_with_rId3 = instance_mock( - request, _Relationship, name="rel_with_rId3", rId="rId3" - ) + rel_with_rId1 = instance_mock(request, _Relationship, name="rel_with_rId1", rId="rId1") + rel_with_rId3 = instance_mock(request, _Relationship, name="rel_with_rId3", rId="rId3") rels["rId1"] = rel_with_rId1 rels["rId3"] = rel_with_rId3 return rels, "rId2" @pytest.fixture - def rels_with_target_known_by_reltype( - self, rels, _rel_with_target_known_by_reltype - ): + def rels_with_target_known_by_reltype(self, rels, _rel_with_target_known_by_reltype): rel, reltype, target_part = _rel_with_target_known_by_reltype rels[1] = rel return rels, reltype, target_part diff --git a/tests/oxml/parts/test_document.py b/tests/oxml/parts/test_document.py index 90b587674..149a65790 100644 --- a/tests/oxml/parts/test_document.py +++ b/tests/oxml/parts/test_document.py @@ -38,9 +38,6 @@ def clear_fixture(self, request): def section_break_fixture(self): body = element("w:body/w:sectPr/w:type{w:val=foobar}") expected_xml = xml( - "w:body/(" - " w:p/w:pPr/w:sectPr/w:type{w:val=foobar}," - " w:sectPr/w:type{w:val=foobar}" - ")" + "w:body/(w:p/w:pPr/w:sectPr/w:type{w:val=foobar},w:sectPr/w:type{w:val=foobar})" ) return body, expected_xml diff --git a/tests/oxml/test__init__.py b/tests/oxml/test__init__.py index 5f392df38..9f19094b4 100644 --- a/tests/oxml/test__init__.py +++ b/tests/oxml/test__init__.py @@ -12,15 +12,12 @@ class DescribeOxmlElement: def it_returns_an_lxml_element_with_matching_tag_name(self): element = OxmlElement("a:foo") assert isinstance(element, etree._Element) - assert element.tag == ( - "{http://schemas.openxmlformats.org/drawingml/2006/main}foo" - ) + assert element.tag == ("{http://schemas.openxmlformats.org/drawingml/2006/main}foo") def it_adds_supplied_attributes(self): element = OxmlElement("a:foo", {"a": "b", "c": "d"}) assert etree.tostring(element) == ( - '' + '' ).encode("utf-8") def it_adds_additional_namespace_declarations_when_supplied(self): @@ -43,7 +40,7 @@ def it_strips_whitespace_between_elements(self, whitespace_fixture): @pytest.fixture def whitespace_fixture(self): - pretty_xml_text = "\n" " text\n" "\n" + pretty_xml_text = "\n text\n\n" stripped_xml_text = "text" return pretty_xml_text, stripped_xml_text diff --git a/tests/oxml/test_styles.py b/tests/oxml/test_styles.py index 7677a8a9e..8814dd6aa 100644 --- a/tests/oxml/test_styles.py +++ b/tests/oxml/test_styles.py @@ -31,8 +31,7 @@ def it_can_add_a_style_of_type(self, add_fixture): "heading 1", WD_STYLE_TYPE.PARAGRAPH, True, - "w:styles/w:style{w:type=paragraph,w:styleId=Heading1}/w:name{w:val" - "=heading 1}", + "w:styles/w:style{w:type=paragraph,w:styleId=Heading1}/w:name{w:val=heading 1}", ), ] ) diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 46b2f4ed1..2c9e05344 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -19,7 +19,6 @@ class DescribeCT_Row: - @pytest.mark.parametrize( ("tr_cxml", "expected_cxml"), [ @@ -231,7 +230,7 @@ def it_knows_its_inner_content_block_item_elements(self): 'w:tr/(w:tc/w:p/w:r/w:t"a",w:tc/w:p/w:r/w:t"b")', 0, 2, - 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p/w:r/w:t"a",' 'w:p/w:r/w:t"b"))', + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p/w:r/w:t"a",w:p/w:r/w:t"b"))', ), ( "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p),w:tc/w:p)", @@ -266,7 +265,7 @@ def it_can_swallow_the_next_tc_help_merge( "w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", 0, 2, - "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=2880,w:type=dxa}," "w:gridSpan{w:val=2}),w:p))", + "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=2880,w:type=dxa},w:gridSpan{w:val=2}),w:p))", ), # neither have a width ( @@ -277,17 +276,17 @@ def it_can_swallow_the_next_tc_help_merge( ), # only second one has a width ( - "w:tr/(w:tc/w:p," "w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", + "w:tr/(w:tc/w:p,w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", 0, 2, "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", ), # only first one has a width ( - "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p)," "w:tc/w:p)", + "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p),w:tc/w:p)", 0, 2, - "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=1440,w:type=dxa}," "w:gridSpan{w:val=2}),w:p))", + "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=1440,w:type=dxa},w:gridSpan{w:val=2}),w:p))", ), ], ) diff --git a/tests/oxml/test_xmlchemy.py b/tests/oxml/test_xmlchemy.py index fca309851..76b53c957 100644 --- a/tests/oxml/test_xmlchemy.py +++ b/tests/oxml/test_xmlchemy.py @@ -131,7 +131,7 @@ def it_returns_unicode_text(self, type_fixture): @pytest.fixture def pretty_fixture(self, element): - expected_xml_text = "\n" " text\n" "\n" + expected_xml_text = "\n text\n\n" return element, expected_xml_text @pytest.fixture @@ -176,8 +176,7 @@ def it_knows_if_two_xml_lines_are_equivalent(self, xml_line_case): ('', "", None), ("t", "", "t"), ( - '2013-12-23T23:15:00Z", + '2013-12-23T23:15:00Z', "", @@ -250,22 +249,16 @@ def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): parent, choice, expected_xml = insert_fixture parent._insert_choice(choice) assert parent.xml == expected_xml - assert parent._insert_choice.__doc__.startswith( - "Return the passed ```` " - ) + assert parent._insert_choice.__doc__.startswith("Return the passed ```` ") def it_adds_an_add_method_for_the_child_element(self, add_fixture): parent, expected_xml = add_fixture choice = parent._add_choice() assert parent.xml == expected_xml assert isinstance(choice, CT_Choice) - assert parent._add_choice.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_choice.__doc__.startswith("Add a new ```` child element ") - def it_adds_a_get_or_change_to_method_for_the_child_element( - self, get_or_change_to_fixture - ): + def it_adds_a_get_or_change_to_method_for_the_child_element(self, get_or_change_to_fixture): parent, expected_xml = get_or_change_to_fixture choice = parent.get_or_change_to_choice() assert isinstance(choice, CT_Choice) @@ -302,10 +295,7 @@ def getter_fixture(self, request): @pytest.fixture def insert_fixture(self): parent = ( - a_parent() - .with_nsdecls() - .with_child(an_oomChild()) - .with_child(an_oooChild()) + a_parent().with_nsdecls().with_child(an_oomChild()).with_child(an_oooChild()) ).element choice = a_choice().with_nsdecls().element expected_xml = ( @@ -362,27 +352,21 @@ def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): parent, oomChild, expected_xml = insert_fixture parent._insert_oomChild(oomChild) assert parent.xml == expected_xml - assert parent._insert_oomChild.__doc__.startswith( - "Return the passed ```` " - ) + assert parent._insert_oomChild.__doc__.startswith("Return the passed ```` ") def it_adds_a_private_add_method_for_the_child_element(self, add_fixture): parent, expected_xml = add_fixture oomChild = parent._add_oomChild() assert parent.xml == expected_xml assert isinstance(oomChild, CT_OomChild) - assert parent._add_oomChild.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_oomChild.__doc__.startswith("Add a new ```` child element ") def it_adds_a_public_add_method_for_the_child_element(self, add_fixture): parent, expected_xml = add_fixture oomChild = parent.add_oomChild() assert parent.xml == expected_xml assert isinstance(oomChild, CT_OomChild) - assert parent._add_oomChild.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_oomChild.__doc__.startswith("Add a new ```` child element ") # fixtures ------------------------------------------------------- @@ -444,9 +428,7 @@ def it_adds_a_setter_property_for_the_attr(self, setter_fixture): assert parent.xml == expected_xml def it_adds_a_docstring_for_the_property(self): - assert CT_Parent.optAttr.__doc__.startswith( - "ST_IntegerType type-converted value of " - ) + assert CT_Parent.optAttr.__doc__.startswith("ST_IntegerType type-converted value of ") # fixtures ------------------------------------------------------- @@ -477,9 +459,7 @@ def it_adds_a_setter_property_for_the_attr(self, setter_fixture): assert parent.xml == expected_xml def it_adds_a_docstring_for_the_property(self): - assert CT_Parent.reqAttr.__doc__.startswith( - "ST_IntegerType type-converted value of " - ) + assert CT_Parent.reqAttr.__doc__.startswith("ST_IntegerType type-converted value of ") def it_raises_on_get_when_attribute_not_present(self): parent = a_parent().with_nsdecls().element @@ -532,27 +512,21 @@ def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): parent, zomChild, expected_xml = insert_fixture parent._insert_zomChild(zomChild) assert parent.xml == expected_xml - assert parent._insert_zomChild.__doc__.startswith( - "Return the passed ```` " - ) + assert parent._insert_zomChild.__doc__.startswith("Return the passed ```` ") def it_adds_an_add_method_for_the_child_element(self, add_fixture): parent, expected_xml = add_fixture zomChild = parent._add_zomChild() assert parent.xml == expected_xml assert isinstance(zomChild, CT_ZomChild) - assert parent._add_zomChild.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_zomChild.__doc__.startswith("Add a new ```` child element ") def it_adds_a_public_add_method_for_the_child_element(self, add_fixture): parent, expected_xml = add_fixture zomChild = parent.add_zomChild() assert parent.xml == expected_xml assert isinstance(zomChild, CT_ZomChild) - assert parent._add_zomChild.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_zomChild.__doc__.startswith("Add a new ```` child element ") def it_removes_the_property_root_name_used_for_declaration(self): assert not hasattr(CT_Parent, "zomChild") @@ -614,17 +588,13 @@ def it_adds_an_add_method_for_the_child_element(self, add_fixture): zooChild = parent._add_zooChild() assert parent.xml == expected_xml assert isinstance(zooChild, CT_ZooChild) - assert parent._add_zooChild.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_zooChild.__doc__.startswith("Add a new ```` child element ") def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): parent, zooChild, expected_xml = insert_fixture parent._insert_zooChild(zooChild) assert parent.xml == expected_xml - assert parent._insert_zooChild.__doc__.startswith( - "Return the passed ```` " - ) + assert parent._insert_zooChild.__doc__.startswith("Return the passed ```` ") def it_adds_a_get_or_add_method_for_the_child_element(self, get_or_add_fixture): parent, expected_xml = get_or_add_fixture @@ -743,9 +713,7 @@ class CT_Parent(BaseOxmlElement): (Choice("w:choice"), Choice("w:choice2")), successors=("w:oomChild", "w:oooChild"), ) - oomChild = OneOrMore( - "w:oomChild", successors=("w:oooChild", "w:zomChild", "w:zooChild") - ) + oomChild = OneOrMore("w:oomChild", successors=("w:oooChild", "w:zomChild", "w:zooChild")) oooChild = OneAndOnlyOne("w:oooChild") zomChild = ZeroOrMore("w:zomChild", successors=("w:zooChild",)) zooChild = ZeroOrOne("w:zooChild", successors=()) diff --git a/tests/oxml/text/test_hyperlink.py b/tests/oxml/text/test_hyperlink.py index f55ab9c22..f5cec4761 100644 --- a/tests/oxml/text/test_hyperlink.py +++ b/tests/oxml/text/test_hyperlink.py @@ -30,9 +30,7 @@ def it_has_a_relationship_that_contains_the_hyperlink_address(self): ("w:hyperlink{r:id=rId6,w:history=1}", True), ], ) - def it_knows_whether_it_has_been_clicked_on_aka_visited( - self, cxml: str, expected_value: bool - ): + def it_knows_whether_it_has_been_clicked_on_aka_visited(self, cxml: str, expected_value: bool): hyperlink = cast(CT_Hyperlink, element(cxml)) assert hyperlink.history is expected_value diff --git a/tests/parts/test_hdrftr.py b/tests/parts/test_hdrftr.py index ee0cc7134..bb98acead 100644 --- a/tests/parts/test_hdrftr.py +++ b/tests/parts/test_hdrftr.py @@ -27,9 +27,7 @@ def it_is_used_by_loader_to_construct_footer_part( FooterPart_load_.assert_called_once_with(partname, content_type, blob, package_) assert part is footer_part_ - def it_can_create_a_new_footer_part( - self, package_, _default_footer_xml_, parse_xml_, _init_ - ): + def it_can_create_a_new_footer_part(self, package_, _default_footer_xml_, parse_xml_, _init_): ftr = element("w:ftr") package_.next_partname.return_value = "/word/footer24.xml" _default_footer_xml_.return_value = "" @@ -95,9 +93,7 @@ def it_is_used_by_loader_to_construct_header_part( HeaderPart_load_.assert_called_once_with(partname, content_type, blob, package_) assert part is header_part_ - def it_can_create_a_new_header_part( - self, package_, _default_header_xml_, parse_xml_, _init_ - ): + def it_can_create_a_new_header_part(self, package_, _default_header_xml_, parse_xml_, _init_): hdr = element("w:hdr") package_.next_partname.return_value = "/word/header42.xml" _default_header_xml_.return_value = "" diff --git a/tests/parts/test_image.py b/tests/parts/test_image.py index acf0b0727..395f57726 100644 --- a/tests/parts/test_image.py +++ b/tests/parts/test_image.py @@ -24,17 +24,13 @@ def it_is_used_by_PartFactory_to_construct_image_part( part = PartFactory(partname_, content_type, reltype, blob_, package_) - image_part_load_.assert_called_once_with( - partname_, content_type, blob_, package_ - ) + image_part_load_.assert_called_once_with(partname_, content_type, blob_, package_) assert part is image_part_ def it_can_construct_from_an_Image_instance(self, image_, partname_, _init_): image_part = ImagePart.from_image(image_, partname_) - _init_.assert_called_once_with( - ANY, partname_, image_.content_type, image_.blob, image_ - ) + _init_.assert_called_once_with(ANY, partname_, image_.content_type, image_.blob, image_) assert isinstance(image_part, ImagePart) def it_knows_its_default_dimensions_in_EMU(self, dimensions_fixture): diff --git a/tests/parts/test_numbering.py b/tests/parts/test_numbering.py index 7655206ec..1ed0f2a05 100644 --- a/tests/parts/test_numbering.py +++ b/tests/parts/test_numbering.py @@ -24,9 +24,7 @@ def it_provides_access_to_the_numbering_definitions(self, num_defs_fixture): # fixtures ------------------------------------------------------- @pytest.fixture - def num_defs_fixture( - self, _NumberingDefinitions_, numbering_elm_, numbering_definitions_ - ): + def num_defs_fixture(self, _NumberingDefinitions_, numbering_elm_, numbering_definitions_): numbering_part = NumberingPart(None, None, numbering_elm_, None) return ( numbering_part, diff --git a/tests/parts/test_settings.py b/tests/parts/test_settings.py index 581cc6173..73b8a5e9a 100644 --- a/tests/parts/test_settings.py +++ b/tests/parts/test_settings.py @@ -14,9 +14,7 @@ class DescribeSettingsPart: - def it_is_used_by_loader_to_construct_settings_part( - self, load_, package_, settings_part_ - ): + def it_is_used_by_loader_to_construct_settings_part(self, load_, package_, settings_part_): partname, blob = "partname", "blob" content_type = CT.WML_SETTINGS load_.return_value = settings_part_ @@ -61,9 +59,7 @@ def package_(self, request): @pytest.fixture def Settings_(self, request, settings_): - return class_mock( - request, "docx.parts.settings.Settings", return_value=settings_ - ) + return class_mock(request, "docx.parts.settings.Settings", return_value=settings_) @pytest.fixture def settings_(self, request): diff --git a/tests/parts/test_story.py b/tests/parts/test_story.py index b65abe8b7..9a1dc7fab 100644 --- a/tests/parts/test_story.py +++ b/tests/parts/test_story.py @@ -30,9 +30,7 @@ def it_can_get_or_add_an_image(self, package_, image_part_, image_, relate_to_): assert rId == "rId42" assert image is image_ - def it_can_get_a_style_by_id_and_type( - self, _document_part_prop_, document_part_, style_ - ): + def it_can_get_a_style_by_id_and_type(self, _document_part_prop_, document_part_, style_): style_id = "BodyText" style_type = WD_STYLE_TYPE.PARAGRAPH _document_part_prop_.return_value = document_part_ diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index b24e02733..6201f9927 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -75,9 +75,7 @@ def character_style_(self, request): @pytest.fixture def _TableStyle_(self, request, table_style_): - return class_mock( - request, "docx.styles.style._TableStyle", return_value=table_style_ - ) + return class_mock(request, "docx.styles.style._TableStyle", return_value=table_style_) @pytest.fixture def table_style_(self, request): @@ -529,17 +527,11 @@ def next_get_fixture(self, request): def next_set_fixture(self, request): style_name, next_style_name, style_cxml = request.param styles = element( - "w:styles/(" - "w:style{w:type=paragraph,w:styleId=H}," - "w:style{w:type=paragraph,w:styleId=B})" + "w:styles/(w:style{w:type=paragraph,w:styleId=H},w:style{w:type=paragraph,w:styleId=B})" ) style_elms = {"H": styles[0], "B": styles[1]} style = ParagraphStyle(style_elms[style_name]) - next_style = ( - None - if next_style_name is None - else ParagraphStyle(style_elms[next_style_name]) - ) + next_style = ParagraphStyle(style_elms[next_style_name]) if next_style_name else None expected_xml = xml(style_cxml) return style, next_style, expected_xml diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py index ea9346bdc..7493388d0 100644 --- a/tests/styles/test_styles.py +++ b/tests/styles/test_styles.py @@ -52,9 +52,7 @@ def it_can_add_a_new_style(self, add_fixture): style = styles.add_style(name, style_type, builtin) - styles._element.add_style_of_type.assert_called_once_with( - name_, style_type, builtin - ) + styles._element.add_style_of_type.assert_called_once_with(name_, style_type, builtin) StyleFactory_.assert_called_once_with(style_elm_) assert style is style_ @@ -110,9 +108,7 @@ def and_it_can_get_a_style_id_from_a_style_name(self, _get_style_id_from_name_): style_id = styles.get_style_id("Style Name", style_type) - _get_style_id_from_name_.assert_called_once_with( - styles, "Style Name", style_type - ) + _get_style_id_from_name_.assert_called_once_with(styles, "Style Name", style_type) assert style_id == "StyleId" def but_it_returns_None_for_a_style_or_name_of_None(self): @@ -132,9 +128,7 @@ def it_gets_a_style_by_id_to_help(self, _get_by_id_fixture): assert StyleFactory_.call_args_list == StyleFactory_calls assert style is style_ - def it_gets_a_style_id_from_a_name_to_help( - self, _getitem_, _get_style_id_from_style_, style_ - ): + def it_gets_a_style_id_from_a_name_to_help(self, _getitem_, _get_style_id_from_style_, style_): style_name, style_type, style_id_ = "Foo Bar", 1, "FooBar" _getitem_.return_value = style_ _get_style_id_from_style_.return_value = style_id_ @@ -173,9 +167,7 @@ def it_provides_access_to_the_latent_styles(self, latent_styles_fixture): ("Heading 1", "heading 1", WD_STYLE_TYPE.PARAGRAPH, True), ] ) - def add_fixture( - self, request, styles_elm_, _getitem_, style_elm_, StyleFactory_, style_ - ): + def add_fixture(self, request, styles_elm_, _getitem_, style_elm_, StyleFactory_, style_): name, name_, style_type, builtin = request.param styles = Styles(styles_elm_) _getitem_.return_value = None @@ -207,8 +199,7 @@ def add_raises_fixture(self, _getitem_): WD_STYLE_TYPE.PARAGRAPH, ), ( - "w:styles/(w:style{w:type=table,w:default=1},w:style{w:type=table,w" - ":default=1})", + "w:styles/(w:style{w:type=table,w:default=1},w:style{w:type=table,w:default=1})", True, WD_STYLE_TYPE.TABLE, ), @@ -387,9 +378,7 @@ def _get_style_id_from_style_(self, request): @pytest.fixture def LatentStyles_(self, request, latent_styles_): - return class_mock( - request, "docx.styles.styles.LatentStyles", return_value=latent_styles_ - ) + return class_mock(request, "docx.styles.styles.LatentStyles", return_value=latent_styles_) @pytest.fixture def latent_styles_(self, request): diff --git a/tests/test_enum.py b/tests/test_enum.py index 1b8a14f5b..79607a7e0 100644 --- a/tests/test_enum.py +++ b/tests/test_enum.py @@ -60,9 +60,7 @@ def and_it_can_find_the_member_from_None_when_a_member_maps_that(self): assert SomeXmlAttr.from_xml(None) == SomeXmlAttr.BAZ def but_it_raises_when_there_is_no_such_mapped_XML_value(self): - with pytest.raises( - ValueError, match="SomeXmlAttr has no XML mapping for 'baz'" - ): + with pytest.raises(ValueError, match="SomeXmlAttr has no XML mapping for 'baz'"): SomeXmlAttr.from_xml("baz") diff --git a/tests/text/test_font.py b/tests/text/test_font.py index 6a9da0223..471c5451b 100644 --- a/tests/text/test_font.py +++ b/tests/text/test_font.py @@ -62,9 +62,7 @@ def it_knows_its_typeface_name(self, r_cxml: str, expected_value: str | None): ), ], ) - def it_can_change_its_typeface_name( - self, r_cxml: str, value: str, expected_r_cxml: str - ): + def it_can_change_its_typeface_name(self, r_cxml: str, value: str, expected_r_cxml: str): r = cast(CT_R, element(r_cxml)) font = Font(r) expected_xml = xml(expected_r_cxml) @@ -95,9 +93,7 @@ def it_knows_its_size(self, r_cxml: str, expected_value: Length | None): ("w:r/w:rPr/w:sz{w:val=36}", None, "w:r/w:rPr"), ], ) - def it_can_change_its_size( - self, r_cxml: str, value: Length | None, expected_r_cxml: str - ): + def it_can_change_its_size(self, r_cxml: str, value: Length | None, expected_r_cxml: str): r = cast(CT_R, element(r_cxml)) font = Font(r) expected_xml = xml(expected_r_cxml) @@ -224,9 +220,7 @@ def it_can_change_its_bool_prop_settings( ("w:r/w:rPr/w:vertAlign{w:val=superscript}", False), ], ) - def it_knows_whether_it_is_subscript( - self, r_cxml: str, expected_value: bool | None - ): + def it_knows_whether_it_is_subscript(self, r_cxml: str, expected_value: bool | None): r = cast(CT_R, element(r_cxml)) font = Font(r) assert font.subscript == expected_value @@ -283,9 +277,7 @@ def it_can_change_whether_it_is_subscript( ("w:r/w:rPr/w:vertAlign{w:val=superscript}", True), ], ) - def it_knows_whether_it_is_superscript( - self, r_cxml: str, expected_value: bool | None - ): + def it_knows_whether_it_is_superscript(self, r_cxml: str, expected_value: bool | None): r = cast(CT_R, element(r_cxml)) font = Font(r) assert font.superscript == expected_value @@ -343,9 +335,7 @@ def it_can_change_whether_it_is_superscript( ("w:r/w:rPr/w:u{w:val=wave}", WD_UNDERLINE.WAVY), ], ) - def it_knows_its_underline_type( - self, r_cxml: str, expected_value: WD_UNDERLINE | bool | None - ): + def it_knows_its_underline_type(self, r_cxml: str, expected_value: WD_UNDERLINE | bool | None): r = cast(CT_R, element(r_cxml)) font = Font(r) assert font.underline is expected_value @@ -393,9 +383,7 @@ def it_can_change_its_underline_type( ("w:r/w:rPr/w:highlight{w:val=blue}", WD_COLOR.BLUE), ], ) - def it_knows_its_highlight_color( - self, r_cxml: str, expected_value: WD_COLOR | None - ): + def it_knows_its_highlight_color(self, r_cxml: str, expected_value: WD_COLOR | None): r = cast(CT_R, element(r_cxml)) font = Font(r) assert font.highlight_color is expected_value diff --git a/tests/text/test_pagebreak.py b/tests/text/test_pagebreak.py index c7494dca2..bc7848797 100644 --- a/tests/text/test_pagebreak.py +++ b/tests/text/test_pagebreak.py @@ -107,13 +107,7 @@ def it_produces_None_for_following_fragment_when_page_break_is_trailing( def it_can_split_off_the_following_paragraph_content_when_in_a_run( self, fake_parent: t.ProvidesStoryPart ): - p_cxml = ( - "w:p/(" - " w:pPr/w:ind" - ' ,w:r/(w:t"foo",w:lastRenderedPageBreak,w:t"bar")' - ' ,w:r/w:t"foo"' - ")" - ) + p_cxml = 'w:p/(w:pPr/w:ind,w:r/(w:t"foo",w:lastRenderedPageBreak,w:t"bar"),w:r/w:t"foo")' p = cast(CT_P, element(p_cxml)) lrpb = p.lastRenderedPageBreaks[0] page_break = RenderedPageBreak(lrpb, fake_parent) diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index c1451c3c1..0329b1dd3 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -85,9 +85,7 @@ def it_can_iterate_its_inner_content_items( def it_knows_its_paragraph_style(self, style_get_fixture): paragraph, style_id_, style_ = style_get_fixture style = paragraph.style - paragraph.part.get_style.assert_called_once_with( - style_id_, WD_STYLE_TYPE.PARAGRAPH - ) + paragraph.part.get_style.assert_called_once_with(style_id_, WD_STYLE_TYPE.PARAGRAPH) assert style is style_ def it_can_change_its_paragraph_style(self, style_set_fixture): @@ -95,9 +93,7 @@ def it_can_change_its_paragraph_style(self, style_set_fixture): paragraph.style = value - paragraph.part.get_style_id.assert_called_once_with( - value, WD_STYLE_TYPE.PARAGRAPH - ) + paragraph.part.get_style_id.assert_called_once_with(value, WD_STYLE_TYPE.PARAGRAPH) assert paragraph._p.xml == expected_xml @pytest.mark.parametrize( @@ -108,8 +104,7 @@ def it_can_change_its_paragraph_style(self, style_set_fixture): ("w:p/w:r/w:lastRenderedPageBreak", 1), ("w:p/w:hyperlink/w:r/w:lastRenderedPageBreak", 1), ( - "w:p/(w:r/w:lastRenderedPageBreak," - "w:hyperlink/w:r/w:lastRenderedPageBreak)", + "w:p/(w:r/w:lastRenderedPageBreak,w:hyperlink/w:r/w:lastRenderedPageBreak)", 2, ), ( @@ -144,8 +139,7 @@ def it_provides_access_to_the_rendered_page_breaks_it_contains( ('w:p/w:r/(w:t"foo", w:br, w:t"bar")', "foo\nbar"), ('w:p/w:r/(w:t"foo", w:cr, w:t"bar")', "foo\nbar"), ( - 'w:p/(w:r/w:t"click ",w:hyperlink{r:id=rId6}/w:r/w:t"here",' - 'w:r/w:t" for more")', + 'w:p/(w:r/w:t"click ",w:hyperlink{r:id=rId6}/w:r/w:t"here",w:r/w:t" for more")', "click here for more", ), ], @@ -385,9 +379,7 @@ def part_prop_(self, request, document_part_): @pytest.fixture def Run_(self, request, runs_): run_, run_2_ = runs_ - return class_mock( - request, "docx.text.paragraph.Run", side_effect=[run_, run_2_] - ) + return class_mock(request, "docx.text.paragraph.Run", side_effect=[run_, run_2_]) @pytest.fixture def r_(self, request): diff --git a/tests/unitutil/file.py b/tests/unitutil/file.py index 795052c8e..226585bc7 100644 --- a/tests/unitutil/file.py +++ b/tests/unitutil/file.py @@ -43,9 +43,7 @@ def snippet_text(snippet_file_name: str): Return the unicode text read from the test snippet file having `snippet_file_name`. """ - snippet_file_path = os.path.join( - test_file_dir, "snippets", "%s.txt" % snippet_file_name - ) + snippet_file_path = os.path.join(test_file_dir, "snippets", "%s.txt" % snippet_file_name) with open(snippet_file_path, "rb") as f: snippet_bytes = f.read() return snippet_bytes.decode("utf-8") diff --git a/tests/unitutil/mock.py b/tests/unitutil/mock.py index d0e41ce93..de05cc206 100644 --- a/tests/unitutil/mock.py +++ b/tests/unitutil/mock.py @@ -75,16 +75,12 @@ def function_mock( return _patch.start() -def initializer_mock( - request: FixtureRequest, cls: type, autospec: bool = True, **kwargs: Any -): +def initializer_mock(request: FixtureRequest, cls: type, autospec: bool = True, **kwargs: Any): """Return mock for __init__() method on `cls`. The patch is reversed after pytest uses it. """ - _patch = patch.object( - cls, "__init__", autospec=autospec, return_value=None, **kwargs - ) + _patch = patch.object(cls, "__init__", autospec=autospec, return_value=None, **kwargs) request.addfinalizer(_patch.stop) return _patch.start() From d9da49bdfd87908e86dbae3516050419181cf63c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 6 Jun 2025 11:21:05 -0700 Subject: [PATCH 30/56] fix: remove redundant w:pic "insertion" The removed `._insert_pic(pic)` call is redundant because the `pic` element is already inserted by the prior `CT_Inline.new()` call. The reason the second `._insert_pic()` call did not add a second `pic` element is that the `pic` element is the same instance in both cases. So that `pic` element is "moved" from where it was, back to the same place. --- src/docx/oxml/shape.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/docx/oxml/shape.py b/src/docx/oxml/shape.py index 00e7593a9..c6df8e7b8 100644 --- a/src/docx/oxml/shape.py +++ b/src/docx/oxml/shape.py @@ -100,7 +100,6 @@ def new_pic_inline( pic_id = 0 # Word doesn't seem to use this, but does not omit it pic = CT_Picture.new(pic_id, filename, rId, cx, cy) inline = cls.new(cx, cy, shape_id, pic) - inline.graphic.graphicData._insert_pic(pic) return inline @classmethod From 5cb32d7787a79ad255ad6f11ad735d2513dd08c1 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:26:57 -0700 Subject: [PATCH 31/56] xfail: acceptance test for Document.comments --- features/doc-comments.feature | 40 ++++++++++ features/steps/comments.py | 69 ++++++++++++++++++ .../steps/test_files/comments-rich-para.docx | Bin 0 -> 19974 bytes src/docx/comments.py | 24 ++++++ 4 files changed, 133 insertions(+) create mode 100644 features/doc-comments.feature create mode 100644 features/steps/comments.py create mode 100644 features/steps/test_files/comments-rich-para.docx create mode 100644 src/docx/comments.py diff --git a/features/doc-comments.feature b/features/doc-comments.feature new file mode 100644 index 000000000..c49edaa77 --- /dev/null +++ b/features/doc-comments.feature @@ -0,0 +1,40 @@ +Feature: Document.comments + In order to operate on comments added to a document + As a developer using python-docx + I need access to the comments collection for the document + And I need methods allowing access to the comments in the collection + + + @wip + Scenario Outline: Access document comments + Given a document having comments part + Then document.comments is a Comments object + + Examples: having a comments part or not + | a-or-no | + | a | + | no | + + + @wip + Scenario Outline: Comments.__len__() + Given a Comments object with comments + Then len(comments) == + + Examples: len(comments) values + | count | + | 0 | + | 4 | + + + @wip + Scenario: Comments.__iter__() + Given a Comments object with 4 comments + Then iterating comments yields 4 Comment objects + + + @wip + Scenario: Comments.get() + Given a Comments object with 4 comments + When I call comments.get(2) + Then the result is a Comment object with id 2 diff --git a/features/steps/comments.py b/features/steps/comments.py new file mode 100644 index 000000000..81993aeda --- /dev/null +++ b/features/steps/comments.py @@ -0,0 +1,69 @@ +"""Step implementations for document comments-related features.""" + +from behave import given, then, when +from behave.runner import Context + +from docx import Document +from docx.comments import Comment, Comments + +from helpers import test_docx + +# given ==================================================== + + +@given("a Comments object with {count} comments") +def given_a_comments_object_with_count_comments(context: Context, count: str): + testfile_name = {"0": "doc-default", "4": "comments-rich-para"}[count] + context.comments = Document(test_docx(testfile_name)).comments + + +@given("a document having a comments part") +def given_a_document_having_a_comments_part(context: Context): + context.document = Document(test_docx("comments-rich-para")) + + +@given("a document having no comments part") +def given_a_document_having_no_comments_part(context: Context): + context.document = Document(test_docx("doc-default")) + + +# when ===================================================== + + +@when("I call comments.get(2)") +def when_I_call_comments_get_2(context: Context): + context.comment = context.comments.get(2) + + +# then ===================================================== + + +@then("document.comments is a Comments object") +def then_document_comments_is_a_Comments_object(context: Context): + document = context.document + assert type(document.comments) is Comments + + +@then("iterating comments yields {count} Comment objects") +def then_iterating_comments_yields_count_comments(context: Context, count: str): + comment_iter = iter(context.comments) + + comment = next(comment_iter) + assert type(comment) is Comment, f"expected a Comment object, got {type(comment)}" + + remaining = list(comment_iter) + assert len(remaining) == int(count) - 1, "iterating comments did not yield the expected count" + + +@then("len(comments) == {count}") +def then_len_comments_eq_count(context: Context, count: str): + actual = len(context.comments) + expected = int(count) + assert actual == expected, f"expected len(comments) of {expected}, got {actual}" + + +@then("the result is a Comment object with id 2") +def then_the_result_is_a_comment_object_with_id_2(context: Context): + comment = context.comment + assert type(comment) is Comment, f"expected a Comment object, got {type(comment)}" + assert comment.comment_id == 2, f"expected comment_id `2`, got '{comment.comment_id}'" diff --git a/features/steps/test_files/comments-rich-para.docx b/features/steps/test_files/comments-rich-para.docx new file mode 100644 index 0000000000000000000000000000000000000000..e63db413e871466da97e906aef754bc06b2f9601 GIT binary patch literal 19974 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 Date: Mon, 9 Jun 2025 22:31:23 -0700 Subject: [PATCH 32/56] comments: add Document.comments Provides access to the comments collection from the document object. --- docs/conf.py | 2 ++ src/docx/document.py | 6 ++++++ src/docx/parts/document.py | 6 ++++++ tests/test_document.py | 11 +++++++++++ 4 files changed, 25 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index e37e9be7e..60e28fa4c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -83,6 +83,8 @@ .. |CharacterStyle| replace:: :class:`.CharacterStyle` +.. |Comments| replace:: :class:`.Comments` + .. |Cm| replace:: :class:`.Cm` .. |ColorFormat| replace:: :class:`.ColorFormat` diff --git a/src/docx/document.py b/src/docx/document.py index 2cf0a1c38..5de03bf9d 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: import docx.types as t + from docx.comments import Comments from docx.oxml.document import CT_Body, CT_Document from docx.parts.document import DocumentPart from docx.settings import Settings @@ -106,6 +107,11 @@ def add_table(self, rows: int, cols: int, style: str | _TableStyle | None = None table.style = style return table + @property + def comments(self) -> Comments: + """A |Comments| object providing access to comments added to the document.""" + return self._part.comments + @property def core_properties(self): """A |CoreProperties| object providing Dublin Core properties of document.""" diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index dea0845f7..78841f47a 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -15,6 +15,7 @@ from docx.shared import lazyproperty if TYPE_CHECKING: + from docx.comments import Comments from docx.enum.style import WD_STYLE_TYPE from docx.opc.coreprops import CoreProperties from docx.settings import Settings @@ -42,6 +43,11 @@ def add_header_part(self): rId = self.relate_to(header_part, RT.HEADER) return header_part, rId + @property + def comments(self) -> Comments: + """|Comments| object providing access to the comments added to this document.""" + raise NotImplementedError + @property def core_properties(self) -> CoreProperties: """A |CoreProperties| object providing read/write access to the core properties diff --git a/tests/test_document.py b/tests/test_document.py index 739813321..0b36017a5 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -9,6 +9,7 @@ import pytest +from docx.comments import Comments from docx.document import Document, _Body from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK @@ -164,6 +165,12 @@ def it_can_save_the_document_to_a_file(self, document_part_: Mock): document_part_.save.assert_called_once_with("foobar.docx") + def it_provides_access_to_the_comments(self, document_part_: Mock, comments_: Mock): + document_part_.comments = comments_ + document = Document(cast(CT_Document, element("w:document")), document_part_) + + assert document.comments is comments_ + def it_provides_access_to_its_core_properties( self, document_part_: Mock, core_properties_: Mock ): @@ -281,6 +288,10 @@ def _block_width_prop_(self, request: FixtureRequest): def body_prop_(self, request: FixtureRequest): return property_mock(request, Document, "_body") + @pytest.fixture + def comments_(self, request: FixtureRequest): + return instance_mock(request, Comments) + @pytest.fixture def core_properties_(self, request: FixtureRequest): return instance_mock(request, CoreProperties) From 8f184cc41811995916fa0d0c02d9dab761092a67 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:32:47 -0700 Subject: [PATCH 33/56] comments: add DocumentPart.comments Provide a way to get a `Comments` object from the `DocumentPart`. --- src/docx/parts/comments.py | 15 +++++++++++++++ src/docx/parts/document.py | 11 ++++++++++- tests/parts/test_document.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/docx/parts/comments.py diff --git a/src/docx/parts/comments.py b/src/docx/parts/comments.py new file mode 100644 index 000000000..6258ceed2 --- /dev/null +++ b/src/docx/parts/comments.py @@ -0,0 +1,15 @@ +"""Contains comments added to the document.""" + +from __future__ import annotations + +from docx.comments import Comments +from docx.parts.story import StoryPart + + +class CommentsPart(StoryPart): + """Container part for comments added to the document.""" + + @property + def comments(self) -> Comments: + """A |Comments| proxy object for the `w:comments` root element of this part.""" + raise NotImplementedError diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 78841f47a..e804647f6 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -6,6 +6,7 @@ from docx.document import Document from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.parts.comments import CommentsPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.numbering import NumberingPart from docx.parts.settings import SettingsPart @@ -46,7 +47,7 @@ def add_header_part(self): @property def comments(self) -> Comments: """|Comments| object providing access to the comments added to this document.""" - raise NotImplementedError + return self._comments_part.comments @property def core_properties(self) -> CoreProperties: @@ -124,6 +125,14 @@ def styles(self): document.""" return self._styles_part.styles + @property + def _comments_part(self) -> CommentsPart: + """A |CommentsPart| object providing access to the comments added to this document. + + Creates a default comments part if one is not present. + """ + raise NotImplementedError + @property def _settings_part(self) -> SettingsPart: """A |SettingsPart| object providing access to the document-level settings for diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index cfe9e870c..c8b7793f9 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -4,12 +4,14 @@ import pytest +from docx.comments import Comments from docx.enum.style import WD_STYLE_TYPE from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.coreprops import CoreProperties from docx.opc.packuri import PackURI from docx.package import Package +from docx.parts.comments import CommentsPart from docx.parts.document import DocumentPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.numbering import NumberingPart @@ -109,6 +111,17 @@ def it_can_save_the_package_to_a_file(self, package_: Mock): package_.save.assert_called_once_with("foobar.docx") + def it_provides_access_to_the_comments_added_to_the_document( + self, _comments_part_prop_: Mock, comments_part_: Mock, comments_: Mock, package_: Mock + ): + comments_part_.comments = comments_ + _comments_part_prop_.return_value = comments_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + assert document_part.comments is comments_ + def it_provides_access_to_the_document_settings( self, _settings_part_prop_: Mock, settings_part_: Mock, settings_: Mock, package_: Mock ): @@ -282,6 +295,22 @@ def and_it_creates_a_default_styles_part_if_not_present( # -- fixtures -------------------------------------------------------------------------------- + @pytest.fixture + def comments_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, Comments) + + @pytest.fixture + def CommentsPart_(self, request: FixtureRequest) -> Mock: + return class_mock(request, "docx.parts.document.CommentsPart") + + @pytest.fixture + def comments_part_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, CommentsPart) + + @pytest.fixture + def _comments_part_prop_(self, request: FixtureRequest) -> Mock: + return property_mock(request, DocumentPart, "_comments_part") + @pytest.fixture def core_properties_(self, request: FixtureRequest): return instance_mock(request, CoreProperties) From ae0e82d979a972d758918072c7088fa49fa5eec3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:34:02 -0700 Subject: [PATCH 34/56] comments: add DocumentPart._comments_part Also involves adding `CommentsPart.default`. Because the comments part is optional, we need a mechanism to add a default (empty) comments part when one is not present. This is what `CommentsPart.default()` is for. --- src/docx/oxml/comments.py | 15 +++++++++++ src/docx/parts/comments.py | 26 +++++++++++++++++++ src/docx/parts/document.py | 8 +++++- src/docx/templates/default-comments.xml | 5 ++++ tests/parts/test_comments.py | 25 +++++++++++++++++++ tests/parts/test_document.py | 33 +++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/docx/oxml/comments.py create mode 100644 src/docx/templates/default-comments.xml create mode 100644 tests/parts/test_comments.py diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py new file mode 100644 index 000000000..65624b738 --- /dev/null +++ b/src/docx/oxml/comments.py @@ -0,0 +1,15 @@ +"""Custom element classes related to document comments.""" + +from __future__ import annotations + +from docx.oxml.xmlchemy import BaseOxmlElement + + +class CT_Comments(BaseOxmlElement): + """`w:comments` element, the root element for the comments part. + + Simply contains a collection of `w:comment` elements, each representing a single comment. Each + contained comment is identified by a unique `w:id` attribute, used to reference the comment + from the document text. The offset of the comment in this collection is arbitrary; it is + essentially a _set_ implemented as a list. + """ diff --git a/src/docx/parts/comments.py b/src/docx/parts/comments.py index 6258ceed2..e43f24a8e 100644 --- a/src/docx/parts/comments.py +++ b/src/docx/parts/comments.py @@ -2,7 +2,17 @@ from __future__ import annotations +import os +from typing import cast + +from typing_extensions import Self + from docx.comments import Comments +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.packuri import PackURI +from docx.oxml.comments import CT_Comments +from docx.oxml.parser import parse_xml +from docx.package import Package from docx.parts.story import StoryPart @@ -13,3 +23,19 @@ class CommentsPart(StoryPart): def comments(self) -> Comments: """A |Comments| proxy object for the `w:comments` root element of this part.""" raise NotImplementedError + + @classmethod + def default(cls, package: Package) -> Self: + """A newly created comments part, containing a default empty `w:comments` element.""" + partname = PackURI("/word/comments.xml") + content_type = CT.WML_COMMENTS + element = cast("CT_Comments", parse_xml(cls._default_comments_xml())) + return cls(partname, content_type, element, package) + + @classmethod + def _default_comments_xml(cls) -> bytes: + """A byte-string containing XML for a default comments part.""" + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-comments.xml") + with open(path, "rb") as f: + xml_bytes = f.read() + return xml_bytes diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index e804647f6..4960264b1 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -131,7 +131,13 @@ def _comments_part(self) -> CommentsPart: Creates a default comments part if one is not present. """ - raise NotImplementedError + try: + return cast(CommentsPart, self.part_related_by(RT.COMMENTS)) + except KeyError: + assert self.package is not None + comments_part = CommentsPart.default(self.package) + self.relate_to(comments_part, RT.COMMENTS) + return comments_part @property def _settings_part(self) -> SettingsPart: diff --git a/src/docx/templates/default-comments.xml b/src/docx/templates/default-comments.xml new file mode 100644 index 000000000..2afdda20b --- /dev/null +++ b/src/docx/templates/default-comments.xml @@ -0,0 +1,5 @@ + + diff --git a/tests/parts/test_comments.py b/tests/parts/test_comments.py new file mode 100644 index 000000000..5e6ef988c --- /dev/null +++ b/tests/parts/test_comments.py @@ -0,0 +1,25 @@ +"""Unit test suite for the docx.parts.hdrftr module.""" + +from __future__ import annotations + +from docx.opc.constants import CONTENT_TYPE as CT +from docx.package import Package +from docx.parts.comments import CommentsPart + + +class DescribeCommentsPart: + """Unit test suite for `docx.parts.comments.CommentsPart` objects.""" + + def it_constructs_a_default_comments_part_to_help(self): + package = Package() + + comments_part = CommentsPart.default(package) + + assert isinstance(comments_part, CommentsPart) + assert comments_part.partname == "/word/comments.xml" + assert comments_part.content_type == CT.WML_COMMENTS + assert comments_part.package is package + assert comments_part.element.tag == ( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}comments" + ) + assert len(comments_part.element) == 0 diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index c8b7793f9..c27990baf 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -227,6 +227,39 @@ def it_can_get_the_id_of_a_style( styles_.get_style_id.assert_called_once_with(style_, WD_STYLE_TYPE.CHARACTER) assert style_id == "BodyCharacter" + def it_provides_access_to_its_comments_part_to_help( + self, package_: Mock, part_related_by_: Mock, comments_part_: Mock + ): + part_related_by_.return_value = comments_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + comments_part = document_part._comments_part + + part_related_by_.assert_called_once_with(document_part, RT.COMMENTS) + assert comments_part is comments_part_ + + def and_it_creates_a_default_comments_part_if_not_present( + self, + package_: Mock, + part_related_by_: Mock, + CommentsPart_: Mock, + comments_part_: Mock, + relate_to_: Mock, + ): + part_related_by_.side_effect = KeyError + CommentsPart_.default.return_value = comments_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + comments_part = document_part._comments_part + + CommentsPart_.default.assert_called_once_with(package_) + relate_to_.assert_called_once_with(document_part, comments_part_, RT.COMMENTS) + assert comments_part is comments_part_ + def it_provides_access_to_its_settings_part_to_help( self, part_related_by_: Mock, settings_part_: Mock, package_: Mock ): From 9c8a2e91fa743bf8f19226eb3353c9fa1a6973b3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:36:02 -0700 Subject: [PATCH 35/56] comments: add CommentsPart.comments --- src/docx/comments.py | 10 ++++++++++ src/docx/parts/comments.py | 8 +++++++- tests/parts/test_comments.py | 38 ++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/docx/comments.py b/src/docx/comments.py index 9165e884d..587837baa 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -2,12 +2,22 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from docx.blkcntnr import BlockItemContainer +if TYPE_CHECKING: + from docx.oxml.comments import CT_Comments + from docx.parts.comments import CommentsPart + class Comments: """Collection containing the comments added to this document.""" + def __init__(self, comments_elm: CT_Comments, comments_part: CommentsPart): + self._comments_elm = comments_elm + self._comments_part = comments_part + class Comment(BlockItemContainer): """Proxy for a single comment in the document. diff --git a/src/docx/parts/comments.py b/src/docx/parts/comments.py index e43f24a8e..111bfb878 100644 --- a/src/docx/parts/comments.py +++ b/src/docx/parts/comments.py @@ -19,10 +19,16 @@ class CommentsPart(StoryPart): """Container part for comments added to the document.""" + def __init__( + self, partname: PackURI, content_type: str, element: CT_Comments, package: Package + ): + super().__init__(partname, content_type, element, package) + self._comments = element + @property def comments(self) -> Comments: """A |Comments| proxy object for the `w:comments` root element of this part.""" - raise NotImplementedError + return Comments(self._comments, self) @classmethod def default(cls, package: Package) -> Self: diff --git a/tests/parts/test_comments.py b/tests/parts/test_comments.py index 5e6ef988c..4cab7783b 100644 --- a/tests/parts/test_comments.py +++ b/tests/parts/test_comments.py @@ -2,14 +2,38 @@ from __future__ import annotations +from typing import cast + +import pytest + +from docx.comments import Comments from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.packuri import PackURI +from docx.oxml.comments import CT_Comments from docx.package import Package from docx.parts.comments import CommentsPart +from ..unitutil.cxml import element +from ..unitutil.mock import FixtureRequest, Mock, class_mock, instance_mock + class DescribeCommentsPart: """Unit test suite for `docx.parts.comments.CommentsPart` objects.""" + def it_provides_access_to_its_comments_collection( + self, Comments_: Mock, comments_: Mock, package_: Mock + ): + Comments_.return_value = comments_ + comments_elm = cast(CT_Comments, element("w:comments")) + comments_part = CommentsPart( + PackURI("/word/comments.xml"), CT.WML_COMMENTS, comments_elm, package_ + ) + + comments = comments_part.comments + + Comments_.assert_called_once_with(comments_part.element, comments_part) + assert comments is comments_ + def it_constructs_a_default_comments_part_to_help(self): package = Package() @@ -23,3 +47,17 @@ def it_constructs_a_default_comments_part_to_help(self): "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}comments" ) assert len(comments_part.element) == 0 + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def Comments_(self, request: FixtureRequest) -> Mock: + return class_mock(request, "docx.parts.comments.Comments") + + @pytest.fixture + def comments_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, Comments) + + @pytest.fixture + def package_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, Package) From 595deccd0d4700cc993332f0cde61a98ca9a443b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:36:50 -0700 Subject: [PATCH 36/56] comments: package-loader loads CommentsPart CommentsPart is loaded as XML-part on document deserialization. --- features/doc-comments.feature | 1 - src/docx/__init__.py | 3 +++ src/docx/parts/comments.py | 6 +++++- tests/parts/test_comments.py | 26 +++++++++++++++++++++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/features/doc-comments.feature b/features/doc-comments.feature index c49edaa77..d23a763a5 100644 --- a/features/doc-comments.feature +++ b/features/doc-comments.feature @@ -5,7 +5,6 @@ Feature: Document.comments And I need methods allowing access to the comments in the collection - @wip Scenario Outline: Access document comments Given a document having comments part Then document.comments is a Comments object diff --git a/src/docx/__init__.py b/src/docx/__init__.py index 205221027..987e8a267 100644 --- a/src/docx/__init__.py +++ b/src/docx/__init__.py @@ -25,6 +25,7 @@ from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.part import PartFactory from docx.opc.parts.coreprops import CorePropertiesPart +from docx.parts.comments import CommentsPart from docx.parts.document import DocumentPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.image import ImagePart @@ -41,6 +42,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: PartFactory.part_class_selector = part_class_selector PartFactory.part_type_for[CT.OPC_CORE_PROPERTIES] = CorePropertiesPart +PartFactory.part_type_for[CT.WML_COMMENTS] = CommentsPart PartFactory.part_type_for[CT.WML_DOCUMENT_MAIN] = DocumentPart PartFactory.part_type_for[CT.WML_FOOTER] = FooterPart PartFactory.part_type_for[CT.WML_HEADER] = HeaderPart @@ -51,6 +53,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: del ( CT, CorePropertiesPart, + CommentsPart, DocumentPart, FooterPart, HeaderPart, diff --git a/src/docx/parts/comments.py b/src/docx/parts/comments.py index 111bfb878..0e4cc7438 100644 --- a/src/docx/parts/comments.py +++ b/src/docx/parts/comments.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import cast +from typing import TYPE_CHECKING, cast from typing_extensions import Self @@ -15,6 +15,10 @@ from docx.package import Package from docx.parts.story import StoryPart +if TYPE_CHECKING: + from docx.oxml.comments import CT_Comments + from docx.package import Package + class CommentsPart(StoryPart): """Container part for comments added to the document.""" diff --git a/tests/parts/test_comments.py b/tests/parts/test_comments.py index 4cab7783b..049c9e737 100644 --- a/tests/parts/test_comments.py +++ b/tests/parts/test_comments.py @@ -8,18 +8,34 @@ from docx.comments import Comments from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.packuri import PackURI +from docx.opc.part import PartFactory from docx.oxml.comments import CT_Comments from docx.package import Package from docx.parts.comments import CommentsPart from ..unitutil.cxml import element -from ..unitutil.mock import FixtureRequest, Mock, class_mock, instance_mock +from ..unitutil.mock import FixtureRequest, Mock, class_mock, instance_mock, method_mock class DescribeCommentsPart: """Unit test suite for `docx.parts.comments.CommentsPart` objects.""" + def it_is_used_by_the_part_loader_to_construct_a_comments_part( + self, package_: Mock, CommentsPart_load_: Mock, comments_part_: Mock + ): + partname = PackURI("/word/comments.xml") + content_type = CT.WML_COMMENTS + reltype = RT.COMMENTS + blob = b"" + CommentsPart_load_.return_value = comments_part_ + + part = PartFactory(partname, content_type, reltype, blob, package_) + + CommentsPart_load_.assert_called_once_with(partname, content_type, blob, package_) + assert part is comments_part_ + def it_provides_access_to_its_comments_collection( self, Comments_: Mock, comments_: Mock, package_: Mock ): @@ -58,6 +74,14 @@ def Comments_(self, request: FixtureRequest) -> Mock: def comments_(self, request: FixtureRequest) -> Mock: return instance_mock(request, Comments) + @pytest.fixture + def comments_part_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, CommentsPart) + + @pytest.fixture + def CommentsPart_load_(self, request: FixtureRequest) -> Mock: + return method_mock(request, CommentsPart, "load", autospec=False) + @pytest.fixture def package_(self, request: FixtureRequest) -> Mock: return instance_mock(request, Package) From 6c0024c52e477707685ba5c373abb2336b9fef36 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:41:05 -0700 Subject: [PATCH 37/56] comments: add Comments.__len__() --- features/doc-comments.feature | 1 - src/docx/comments.py | 4 +++ src/docx/oxml/__init__.py | 27 ++++++++++++------- src/docx/oxml/comments.py | 16 +++++++++++- tests/test_comments.py | 49 +++++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 tests/test_comments.py diff --git a/features/doc-comments.feature b/features/doc-comments.feature index d23a763a5..6aaffee68 100644 --- a/features/doc-comments.feature +++ b/features/doc-comments.feature @@ -15,7 +15,6 @@ Feature: Document.comments | no | - @wip Scenario Outline: Comments.__len__() Given a Comments object with comments Then len(comments) == diff --git a/src/docx/comments.py b/src/docx/comments.py index 587837baa..736cbb7ab 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -18,6 +18,10 @@ def __init__(self, comments_elm: CT_Comments, comments_part: CommentsPart): self._comments_elm = comments_elm self._comments_part = comments_part + def __len__(self) -> int: + """The number of comments in this collection.""" + return len(self._comments_elm.comment_lst) + class Comment(BlockItemContainer): """Proxy for a single comment in the document. diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index 3fbc114ae..37f608cef 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -1,3 +1,5 @@ +# ruff: noqa: E402, I001 + """Initializes oxml sub-package. This including registering custom element classes corresponding to Open XML elements. @@ -84,16 +86,21 @@ # --------------------------------------------------------------------------- # other custom element class mappings -from .coreprops import CT_CoreProperties # noqa +from .comments import CT_Comments, CT_Comment + +register_element_cls("w:comments", CT_Comments) +register_element_cls("w:comment", CT_Comment) + +from .coreprops import CT_CoreProperties register_element_cls("cp:coreProperties", CT_CoreProperties) -from .document import CT_Body, CT_Document # noqa +from .document import CT_Body, CT_Document register_element_cls("w:body", CT_Body) register_element_cls("w:document", CT_Document) -from .numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr # noqa +from .numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr register_element_cls("w:abstractNumId", CT_DecimalNumber) register_element_cls("w:ilvl", CT_DecimalNumber) @@ -104,7 +111,7 @@ register_element_cls("w:numbering", CT_Numbering) register_element_cls("w:startOverride", CT_DecimalNumber) -from .section import ( # noqa +from .section import ( CT_HdrFtr, CT_HdrFtrRef, CT_PageMar, @@ -122,11 +129,11 @@ register_element_cls("w:sectPr", CT_SectPr) register_element_cls("w:type", CT_SectType) -from .settings import CT_Settings # noqa +from .settings import CT_Settings register_element_cls("w:settings", CT_Settings) -from .styles import CT_LatentStyles, CT_LsdException, CT_Style, CT_Styles # noqa +from .styles import CT_LatentStyles, CT_LsdException, CT_Style, CT_Styles register_element_cls("w:basedOn", CT_String) register_element_cls("w:latentStyles", CT_LatentStyles) @@ -141,7 +148,7 @@ register_element_cls("w:uiPriority", CT_DecimalNumber) register_element_cls("w:unhideWhenUsed", CT_OnOff) -from .table import ( # noqa +from .table import ( CT_Height, CT_Row, CT_Tbl, @@ -178,7 +185,7 @@ register_element_cls("w:vAlign", CT_VerticalJc) register_element_cls("w:vMerge", CT_VMerge) -from .text.font import ( # noqa +from .text.font import ( CT_Color, CT_Fonts, CT_Highlight, @@ -217,11 +224,11 @@ register_element_cls("w:vertAlign", CT_VerticalAlignRun) register_element_cls("w:webHidden", CT_OnOff) -from .text.paragraph import CT_P # noqa +from .text.paragraph import CT_P register_element_cls("w:p", CT_P) -from .text.parfmt import ( # noqa +from .text.parfmt import ( CT_Ind, CT_Jc, CT_PPr, diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 65624b738..1e818ebfb 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -2,7 +2,7 @@ from __future__ import annotations -from docx.oxml.xmlchemy import BaseOxmlElement +from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrMore class CT_Comments(BaseOxmlElement): @@ -13,3 +13,17 @@ class CT_Comments(BaseOxmlElement): from the document text. The offset of the comment in this collection is arbitrary; it is essentially a _set_ implemented as a list. """ + + # -- type-declarations to fill in the gaps for metaclass-added methods -- + comment_lst: list[CT_Comment] + + comment = ZeroOrMore("w:comment") + + +class CT_Comment(BaseOxmlElement): + """`w:comment` element, representing a single comment. + + A comment is a so-called "story" and can contain paragraphs and tables much like a table-cell. + While probably most often used for a single sentence or phrase, a comment can contain rich + content, including multiple rich-text paragraphs, hyperlinks, images, and tables. + """ diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 000000000..2bde587c6 --- /dev/null +++ b/tests/test_comments.py @@ -0,0 +1,49 @@ +"""Unit test suite for the docx.comments module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.comments import Comments +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.packuri import PackURI +from docx.oxml.comments import CT_Comments +from docx.package import Package +from docx.parts.comments import CommentsPart + +from .unitutil.cxml import element +from .unitutil.mock import FixtureRequest, Mock, instance_mock + + +class DescribeComments: + """Unit-test suite for `docx.comments.Comments`.""" + + @pytest.mark.parametrize( + ("cxml", "count"), + [ + ("w:comments", 0), + ("w:comments/w:comment", 1), + ("w:comments/(w:comment,w:comment,w:comment)", 3), + ], + ) + def it_knows_how_many_comments_it_contains(self, cxml: str, count: int, package_: Mock): + comments_elm = cast(CT_Comments, element(cxml)) + comments = Comments( + comments_elm, + CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ), + ) + + assert len(comments) == count + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def package_(self, request: FixtureRequest): + return instance_mock(request, Package) From 88ff3cab593bd68440e0d552631f2f80c476447d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:43:23 -0700 Subject: [PATCH 38/56] comments: add Comments.__iter__() --- features/doc-comments.feature | 1 - src/docx/blkcntnr.py | 3 ++- src/docx/comments.py | 15 +++++++++++++-- tests/test_comments.py | 23 ++++++++++++++++++++++- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/features/doc-comments.feature b/features/doc-comments.feature index 6aaffee68..fbe2fd278 100644 --- a/features/doc-comments.feature +++ b/features/doc-comments.feature @@ -25,7 +25,6 @@ Feature: Document.comments | 4 | - @wip Scenario: Comments.__iter__() Given a Comments object with 4 comments Then iterating comments yields 4 Comment objects diff --git a/src/docx/blkcntnr.py b/src/docx/blkcntnr.py index 951e03427..82c7ef727 100644 --- a/src/docx/blkcntnr.py +++ b/src/docx/blkcntnr.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: import docx.types as t + from docx.oxml.comments import CT_Comment from docx.oxml.document import CT_Body from docx.oxml.section import CT_HdrFtr from docx.oxml.table import CT_Tc @@ -26,7 +27,7 @@ from docx.styles.style import ParagraphStyle from docx.table import Table -BlockItemElement: TypeAlias = "CT_Body | CT_HdrFtr | CT_Tc" +BlockItemElement: TypeAlias = "CT_Body | CT_Comment | CT_HdrFtr | CT_Tc" class BlockItemContainer(StoryChild): diff --git a/src/docx/comments.py b/src/docx/comments.py index 736cbb7ab..6ccdec83b 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -2,12 +2,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Iterator from docx.blkcntnr import BlockItemContainer if TYPE_CHECKING: - from docx.oxml.comments import CT_Comments + from docx.oxml.comments import CT_Comment, CT_Comments from docx.parts.comments import CommentsPart @@ -18,6 +18,13 @@ def __init__(self, comments_elm: CT_Comments, comments_part: CommentsPart): self._comments_elm = comments_elm self._comments_part = comments_part + def __iter__(self) -> Iterator[Comment]: + """Iterator over the comments in this collection.""" + return ( + Comment(comment_elm, self._comments_part) + for comment_elm in self._comments_elm.comment_lst + ) + def __len__(self) -> int: """The number of comments in this collection.""" return len(self._comments_elm.comment_lst) @@ -36,3 +43,7 @@ class Comment(BlockItemContainer): Note that certain content like tables may not be displayed in the Word comment sidebar due to space limitations. Such "over-sized" content can still be viewed in the review pane. """ + + def __init__(self, comment_elm: CT_Comment, comments_part: CommentsPart): + super().__init__(comment_elm, comments_part) + self._comment_elm = comment_elm diff --git a/tests/test_comments.py b/tests/test_comments.py index 2bde587c6..b38e429f9 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -6,7 +6,7 @@ import pytest -from docx.comments import Comments +from docx.comments import Comment, Comments from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.packuri import PackURI from docx.oxml.comments import CT_Comments @@ -42,6 +42,27 @@ def it_knows_how_many_comments_it_contains(self, cxml: str, count: int, package_ assert len(comments) == count + def it_is_iterable_over_the_comments_it_contains(self, package_: Mock): + comments_elm = cast(CT_Comments, element("w:comments/(w:comment,w:comment)")) + comments = Comments( + comments_elm, + CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ), + ) + + comment_iter = iter(comments) + + comment1 = next(comment_iter) + assert type(comment1) is Comment, "expected a `Comment` object" + comment2 = next(comment_iter) + assert type(comment2) is Comment, "expected a `Comment` object" + with pytest.raises(StopIteration): + next(comment_iter) + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From e2aec420ba2d43991e4fa8acb04f200c65229ad0 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:47:47 -0700 Subject: [PATCH 39/56] comments: add Comments.get() To get a comment by id, None when not found. --- src/docx/comments.py | 5 +++++ src/docx/oxml/comments.py | 5 +++++ tests/test_comments.py | 22 ++++++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/src/docx/comments.py b/src/docx/comments.py index 6ccdec83b..4a3da9dae 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -29,6 +29,11 @@ def __len__(self) -> int: """The number of comments in this collection.""" return len(self._comments_elm.comment_lst) + def get(self, comment_id: int) -> Comment | None: + """Return the comment identified by `comment_id`, or |None| if not found.""" + comment_elm = self._comments_elm.get_comment_by_id(comment_id) + return Comment(comment_elm, self._comments_part) if comment_elm is not None else None + class Comment(BlockItemContainer): """Proxy for a single comment in the document. diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 1e818ebfb..c5d84bc31 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -19,6 +19,11 @@ class CT_Comments(BaseOxmlElement): comment = ZeroOrMore("w:comment") + def get_comment_by_id(self, comment_id: int) -> CT_Comment | None: + """Return the `w:comment` element identified by `comment_id`, or |None| if not found.""" + comment_elms = self.xpath(f"(./w:comment[@w:id='{comment_id}'])[1]") + return comment_elms[0] if comment_elms else None + class CT_Comment(BaseOxmlElement): """`w:comment` element, representing a single comment. diff --git a/tests/test_comments.py b/tests/test_comments.py index b38e429f9..a32f7acbf 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -1,3 +1,5 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for the docx.comments module.""" from __future__ import annotations @@ -63,6 +65,26 @@ def it_is_iterable_over_the_comments_it_contains(self, package_: Mock): with pytest.raises(StopIteration): next(comment_iter) + def it_can_get_a_comment_by_id(self, package_: Mock): + comments_elm = cast( + CT_Comments, + element("w:comments/(w:comment{w:id=1},w:comment{w:id=2},w:comment{w:id=3})"), + ) + comments = Comments( + comments_elm, + CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ), + ) + + comment = comments.get(2) + + assert type(comment) is Comment, "expected a `Comment` object" + assert comment._comment_elm is comments_elm.comment_lst[1] + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From 0eeaa2f0760b61374fef5f2912f63f7ded4bcaeb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:49:31 -0700 Subject: [PATCH 40/56] comments: add Comment.comment_id --- features/doc-comments.feature | 1 - src/docx/comments.py | 5 +++++ src/docx/oxml/comments.py | 5 ++++- tests/test_comments.py | 18 +++++++++++++++++- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/features/doc-comments.feature b/features/doc-comments.feature index fbe2fd278..944146e5e 100644 --- a/features/doc-comments.feature +++ b/features/doc-comments.feature @@ -30,7 +30,6 @@ Feature: Document.comments Then iterating comments yields 4 Comment objects - @wip Scenario: Comments.get() Given a Comments object with 4 comments When I call comments.get(2) diff --git a/src/docx/comments.py b/src/docx/comments.py index 4a3da9dae..d3f58343f 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -52,3 +52,8 @@ class Comment(BlockItemContainer): def __init__(self, comment_elm: CT_Comment, comments_part: CommentsPart): super().__init__(comment_elm, comments_part) self._comment_elm = comment_elm + + @property + def comment_id(self) -> int: + """The unique identifier of this comment.""" + return self._comment_elm.id diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index c5d84bc31..a24e1dba2 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -2,7 +2,8 @@ from __future__ import annotations -from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrMore +from docx.oxml.simpletypes import ST_DecimalNumber +from docx.oxml.xmlchemy import BaseOxmlElement, RequiredAttribute, ZeroOrMore class CT_Comments(BaseOxmlElement): @@ -32,3 +33,5 @@ class CT_Comment(BaseOxmlElement): While probably most often used for a single sentence or phrase, a comment can contain rich content, including multiple rich-text paragraphs, hyperlinks, images, and tables. """ + + id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] diff --git a/tests/test_comments.py b/tests/test_comments.py index a32f7acbf..8f9fd473f 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -11,7 +11,7 @@ from docx.comments import Comment, Comments from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.packuri import PackURI -from docx.oxml.comments import CT_Comments +from docx.oxml.comments import CT_Comment, CT_Comments from docx.package import Package from docx.parts.comments import CommentsPart @@ -90,3 +90,19 @@ def it_can_get_a_comment_by_id(self, package_: Mock): @pytest.fixture def package_(self, request: FixtureRequest): return instance_mock(request, Package) + + +class DescribeComment: + """Unit-test suite for `docx.comments.Comment`.""" + + def it_knows_its_comment_id(self, comments_part_: Mock): + comment_elm = cast(CT_Comment, element("w:comment{w:id=42}")) + comment = Comment(comment_elm, comments_part_) + + assert comment.comment_id == 42 + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def comments_part_(self, request: FixtureRequest): + return instance_mock(request, CommentsPart) From 7cf36d648fb18b13979ea7df631724d0dda1bc2c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:57:11 -0700 Subject: [PATCH 41/56] xfail: acceptance test for Comment properties --- features/cmt-props.feature | 40 +++++++++++++++++++++++++ features/steps/comments.py | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 features/cmt-props.feature diff --git a/features/cmt-props.feature b/features/cmt-props.feature new file mode 100644 index 000000000..6eead5aa7 --- /dev/null +++ b/features/cmt-props.feature @@ -0,0 +1,40 @@ +Feature: Get comment properties + In order to characterize comments by their metadata + As a developer using python-docx + I need methods to access comment metadata properties + + + Scenario: Comment.id + Given a Comment object + Then comment.comment_id is the comment identifier + + + @wip + Scenario: Comment.author + Given a Comment object + Then comment.author is the author of the comment + + + @wip + Scenario: Comment.initials + Given a Comment object + Then comment.initials is the initials of the comment author + + + @wip + Scenario: Comment.timestamp + Given a Comment object + Then comment.timestamp is the date and time the comment was authored + + + @wip + Scenario: Comment.paragraphs[0].text + Given a Comment object + When I assign para_text = comment.paragraphs[0].text + Then para_text is the text of the first paragraph in the comment + + + @wip + Scenario: Retrieve embedded image from a comment + Given a Comment object containing an embedded image + Then I can extract the image from the comment diff --git a/features/steps/comments.py b/features/steps/comments.py index 81993aeda..14c7d3359 100644 --- a/features/steps/comments.py +++ b/features/steps/comments.py @@ -1,16 +1,29 @@ """Step implementations for document comments-related features.""" +import datetime as dt + from behave import given, then, when from behave.runner import Context from docx import Document from docx.comments import Comment, Comments +from docx.drawing import Drawing from helpers import test_docx # given ==================================================== +@given("a Comment object") +def given_a_comment_object(context: Context): + context.comment = Document(test_docx("comments-rich-para")).comments.get(0) + + +@given("a Comment object containing an embedded image") +def given_a_comment_object_containing_an_embedded_image(context: Context): + context.comment = Document(test_docx("comments-rich-para")).comments.get(1) + + @given("a Comments object with {count} comments") def given_a_comments_object_with_count_comments(context: Context, count: str): testfile_name = {"0": "doc-default", "4": "comments-rich-para"}[count] @@ -30,6 +43,11 @@ def given_a_document_having_no_comments_part(context: Context): # when ===================================================== +@when("I assign para_text = comment.paragraphs[0].text") +def when_I_assign_para_text(context: Context): + context.para_text = context.comment.paragraphs[0].text + + @when("I call comments.get(2)") def when_I_call_comments_get_2(context: Context): context.comment = context.comments.get(2) @@ -38,12 +56,48 @@ def when_I_call_comments_get_2(context: Context): # then ===================================================== +@then("comment.author is the author of the comment") +def then_comment_author_is_the_author_of_the_comment(context: Context): + actual = context.comment.author + assert actual == "Steve Canny", f"expected author 'Steve Canny', got '{actual}'" + + +@then("comment.comment_id is the comment identifier") +def then_comment_comment_id_is_the_comment_identifier(context: Context): + assert context.comment.comment_id == 0 + + +@then("comment.initials is the initials of the comment author") +def then_comment_initials_is_the_initials_of_the_comment_author(context: Context): + initials = context.comment.initials + assert initials == "SJC", f"expected initials 'SJC', got '{initials}'" + + +@then("comment.timestamp is the date and time the comment was authored") +def then_comment_timestamp_is_the_date_and_time_the_comment_was_authored(context: Context): + assert context.comment.timestamp == dt.datetime(2025, 6, 7, 11, 20, 0, tzinfo=dt.timezone.utc) + + @then("document.comments is a Comments object") def then_document_comments_is_a_Comments_object(context: Context): document = context.document assert type(document.comments) is Comments +@then("I can extract the image from the comment") +def then_I_can_extract_the_image_from_the_comment(context: Context): + paragraph = context.comment.paragraphs[0] + run = paragraph.runs[2] + drawing = next(d for d in run.iter_inner_content() if isinstance(d, Drawing)) + assert drawing.has_picture + + image = drawing.image + + assert image.content_type == "image/jpeg", f"got {image.content_type}" + assert image.filename == "image.jpg", f"got {image.filename}" + assert image.sha1 == "1be010ea47803b00e140b852765cdf84f491da47", f"got {image.sha1}" + + @then("iterating comments yields {count} Comment objects") def then_iterating_comments_yields_count_comments(context: Context, count: str): comment_iter = iter(context.comments) @@ -62,6 +116,13 @@ def then_len_comments_eq_count(context: Context, count: str): assert actual == expected, f"expected len(comments) of {expected}, got {actual}" +@then("para_text is the text of the first paragraph in the comment") +def then_para_text_is_the_text_of_the_first_paragraph_in_the_comment(context: Context): + actual = context.para_text + expected = "Text with hyperlink https://google.com embedded." + assert actual == expected, f"expected para_text '{expected}', got '{actual}'" + + @then("the result is a Comment object with id 2") def then_the_result_is_a_comment_object_with_id_2(context: Context): comment = context.comment From 8af46fe57a84299da4ffc3e1528eecc35accca92 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:05:07 -0700 Subject: [PATCH 42/56] comments: add Comment.author --- features/cmt-props.feature | 1 - src/docx/comments.py | 5 +++++ src/docx/oxml/comments.py | 3 ++- tests/test_comments.py | 6 ++++++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/features/cmt-props.feature b/features/cmt-props.feature index 6eead5aa7..95fe17746 100644 --- a/features/cmt-props.feature +++ b/features/cmt-props.feature @@ -9,7 +9,6 @@ Feature: Get comment properties Then comment.comment_id is the comment identifier - @wip Scenario: Comment.author Given a Comment object Then comment.author is the author of the comment diff --git a/src/docx/comments.py b/src/docx/comments.py index d3f58343f..a107f7b0b 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -53,6 +53,11 @@ def __init__(self, comment_elm: CT_Comment, comments_part: CommentsPart): super().__init__(comment_elm, comments_part) self._comment_elm = comment_elm + @property + def author(self) -> str: + """The recorded author of this comment.""" + return self._comment_elm.author + @property def comment_id(self) -> int: """The unique identifier of this comment.""" diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index a24e1dba2..1aa71add5 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -2,7 +2,7 @@ from __future__ import annotations -from docx.oxml.simpletypes import ST_DecimalNumber +from docx.oxml.simpletypes import ST_DecimalNumber, ST_String from docx.oxml.xmlchemy import BaseOxmlElement, RequiredAttribute, ZeroOrMore @@ -35,3 +35,4 @@ class CT_Comment(BaseOxmlElement): """ id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] + author: str = RequiredAttribute("w:author", ST_String) # pyright: ignore[reportAssignmentType] diff --git a/tests/test_comments.py b/tests/test_comments.py index 8f9fd473f..7b0e3588c 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -101,6 +101,12 @@ def it_knows_its_comment_id(self, comments_part_: Mock): assert comment.comment_id == 42 + def it_knows_its_author(self, comments_part_: Mock): + comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:author=Steve Canny}")) + comment = Comment(comment_elm, comments_part_) + + assert comment.author == "Steve Canny" + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From cab50c5e65da92e31f64401f535efa0d9d0d8e84 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:06:01 -0700 Subject: [PATCH 43/56] comments: add Comment.initials --- features/cmt-props.feature | 1 - src/docx/comments.py | 9 +++++++++ src/docx/oxml/comments.py | 5 ++++- tests/test_comments.py | 6 ++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/features/cmt-props.feature b/features/cmt-props.feature index 95fe17746..f1a7fbc4c 100644 --- a/features/cmt-props.feature +++ b/features/cmt-props.feature @@ -14,7 +14,6 @@ Feature: Get comment properties Then comment.author is the author of the comment - @wip Scenario: Comment.initials Given a Comment object Then comment.initials is the initials of the comment author diff --git a/src/docx/comments.py b/src/docx/comments.py index a107f7b0b..cc1a86161 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -62,3 +62,12 @@ def author(self) -> str: def comment_id(self) -> int: """The unique identifier of this comment.""" return self._comment_elm.id + + @property + def initials(self) -> str | None: + """Read/write. The recorded initials of the comment author. + + This attribute is optional in the XML, returns |None| if not set. Assigning |None| removes + any existing initials from the XML. + """ + return self._comment_elm.initials diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 1aa71add5..b841cdfe9 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -3,7 +3,7 @@ from __future__ import annotations from docx.oxml.simpletypes import ST_DecimalNumber, ST_String -from docx.oxml.xmlchemy import BaseOxmlElement, RequiredAttribute, ZeroOrMore +from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore class CT_Comments(BaseOxmlElement): @@ -36,3 +36,6 @@ class CT_Comment(BaseOxmlElement): id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] author: str = RequiredAttribute("w:author", ST_String) # pyright: ignore[reportAssignmentType] + initials: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:initials", ST_String + ) diff --git a/tests/test_comments.py b/tests/test_comments.py index 7b0e3588c..9e4f64d68 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -107,6 +107,12 @@ def it_knows_its_author(self, comments_part_: Mock): assert comment.author == "Steve Canny" + def it_knows_the_initials_of_its_author(self, comments_part_: Mock): + comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:initials=SJC}")) + comment = Comment(comment_elm, comments_part_) + + assert comment.initials == "SJC" + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From cfb87e7708f561f09e6d4f3e7289c659c226eafb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:06:54 -0700 Subject: [PATCH 44/56] comments: add Comment.timestamp --- features/cmt-props.feature | 1 - src/docx/comments.py | 9 ++++++ src/docx/oxml/comments.py | 7 ++++- src/docx/oxml/simpletypes.py | 53 ++++++++++++++++++++++++++++++++++++ tests/test_comments.py | 10 +++++++ 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/features/cmt-props.feature b/features/cmt-props.feature index f1a7fbc4c..ab5450dfa 100644 --- a/features/cmt-props.feature +++ b/features/cmt-props.feature @@ -19,7 +19,6 @@ Feature: Get comment properties Then comment.initials is the initials of the comment author - @wip Scenario: Comment.timestamp Given a Comment object Then comment.timestamp is the date and time the comment was authored diff --git a/src/docx/comments.py b/src/docx/comments.py index cc1a86161..e5d25fd79 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime as dt from typing import TYPE_CHECKING, Iterator from docx.blkcntnr import BlockItemContainer @@ -71,3 +72,11 @@ def initials(self) -> str | None: any existing initials from the XML. """ return self._comment_elm.initials + + @property + def timestamp(self) -> dt.datetime | None: + """The date and time this comment was authored. + + This attribute is optional in the XML, returns |None| if not set. + """ + return self._comment_elm.date diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index b841cdfe9..612a51f8a 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -2,7 +2,9 @@ from __future__ import annotations -from docx.oxml.simpletypes import ST_DecimalNumber, ST_String +import datetime as dt + +from docx.oxml.simpletypes import ST_DateTime, ST_DecimalNumber, ST_String from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore @@ -39,3 +41,6 @@ class CT_Comment(BaseOxmlElement): initials: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:initials", ST_String ) + date: dt.datetime | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:date", ST_DateTime + ) diff --git a/src/docx/oxml/simpletypes.py b/src/docx/oxml/simpletypes.py index 69d4b65d4..a0fc87d3f 100644 --- a/src/docx/oxml/simpletypes.py +++ b/src/docx/oxml/simpletypes.py @@ -9,6 +9,7 @@ from __future__ import annotations +import datetime as dt from typing import TYPE_CHECKING, Any, Tuple from docx.exceptions import InvalidXmlError @@ -213,6 +214,58 @@ def validate(cls, value: Any) -> None: cls.validate_int_in_range(value, -27273042329600, 27273042316900) +class ST_DateTime(BaseSimpleType): + @classmethod + def convert_from_xml(cls, str_value: str) -> dt.datetime: + """Convert an xsd:dateTime string to a datetime object.""" + + def parse_xsd_datetime(dt_str: str) -> dt.datetime: + # -- handle trailing 'Z' (Zulu/UTC), common in Word files -- + if dt_str.endswith("Z"): + try: + # -- optional fractional seconds case -- + return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S.%fZ").replace( + tzinfo=dt.timezone.utc + ) + except ValueError: + return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=dt.timezone.utc + ) + + # -- handles explicit offsets like +00:00, -05:00, or naive datetimes -- + try: + return dt.datetime.fromisoformat(dt_str) + except ValueError: + # -- fall-back to parsing as naive datetime (with or without fractional seconds) -- + try: + return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S.%f") + except ValueError: + return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S") + + try: + # -- parse anything reasonable, but never raise, just use default epoch time -- + return parse_xsd_datetime(str_value) + except Exception: + return dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) + + @classmethod + def convert_to_xml(cls, value: dt.datetime) -> str: + # -- convert naive datetime to timezon-aware assuming local timezone -- + if value.tzinfo is None: + value = value.astimezone() + + # -- convert to UTC if not already -- + value = value.astimezone(dt.timezone.utc) + + # -- format with 'Z' suffix for UTC -- + return value.strftime("%Y-%m-%dT%H:%M:%SZ") + + @classmethod + def validate(cls, value: Any) -> None: + if not isinstance(value, dt.datetime): + raise TypeError("only a datetime.datetime object may be assigned, got '%s'" % value) + + class ST_DecimalNumber(XsdInt): pass diff --git a/tests/test_comments.py b/tests/test_comments.py index 9e4f64d68..ea9e97c96 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -4,6 +4,7 @@ from __future__ import annotations +import datetime as dt from typing import cast import pytest @@ -113,6 +114,15 @@ def it_knows_the_initials_of_its_author(self, comments_part_: Mock): assert comment.initials == "SJC" + def it_knows_the_date_and_time_it_was_authored(self, comments_part_: Mock): + comment_elm = cast( + CT_Comment, + element("w:comment{w:id=42,w:date=2023-10-01T12:34:56Z}"), + ) + comment = Comment(comment_elm, comments_part_) + + assert comment.timestamp == dt.datetime(2023, 10, 1, 12, 34, 56, tzinfo=dt.timezone.utc) + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From 19175adf57d91f314e95fa72972c6065f72b4dff Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:09:32 -0700 Subject: [PATCH 45/56] comments: add Comment.paragraphs Actual implementation is primarily inherited from `BlockItemContainer`, but support for those operations must be present in `CT_Comment` and it's worth testing explicitly. --- features/cmt-props.feature | 1 - src/docx/oxml/comments.py | 23 +++++++++++++++++++++++ tests/test_comments.py | 12 ++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/features/cmt-props.feature b/features/cmt-props.feature index ab5450dfa..f5c636196 100644 --- a/features/cmt-props.feature +++ b/features/cmt-props.feature @@ -24,7 +24,6 @@ Feature: Get comment properties Then comment.timestamp is the date and time the comment was authored - @wip Scenario: Comment.paragraphs[0].text Given a Comment object When I assign para_text = comment.paragraphs[0].text diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 612a51f8a..0ebd7e200 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -3,10 +3,15 @@ from __future__ import annotations import datetime as dt +from typing import TYPE_CHECKING, Callable from docx.oxml.simpletypes import ST_DateTime, ST_DecimalNumber, ST_String from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore +if TYPE_CHECKING: + from docx.oxml.table import CT_Tbl + from docx.oxml.text.paragraph import CT_P + class CT_Comments(BaseOxmlElement): """`w:comments` element, the root element for the comments part. @@ -36,6 +41,7 @@ class CT_Comment(BaseOxmlElement): content, including multiple rich-text paragraphs, hyperlinks, images, and tables. """ + # -- attributes on `w:comment` -- id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] author: str = RequiredAttribute("w:author", ST_String) # pyright: ignore[reportAssignmentType] initials: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] @@ -44,3 +50,20 @@ class CT_Comment(BaseOxmlElement): date: dt.datetime | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:date", ST_DateTime ) + + # -- children -- + + p = ZeroOrMore("w:p", successors=()) + tbl = ZeroOrMore("w:tbl", successors=()) + + # -- type-declarations for methods added by metaclass -- + + add_p: Callable[[], CT_P] + p_lst: list[CT_P] + tbl_lst: list[CT_Tbl] + _insert_tbl: Callable[[CT_Tbl], CT_Tbl] + + @property + def inner_content_elements(self) -> list[CT_P | CT_Tbl]: + """Generate all `w:p` and `w:tbl` elements in this comment.""" + return self.xpath("./w:p | ./w:tbl") diff --git a/tests/test_comments.py b/tests/test_comments.py index ea9e97c96..2a0615a79 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -123,6 +123,18 @@ def it_knows_the_date_and_time_it_was_authored(self, comments_part_: Mock): assert comment.timestamp == dt.datetime(2023, 10, 1, 12, 34, 56, tzinfo=dt.timezone.utc) + def it_provides_access_to_the_paragraphs_it_contains(self, comments_part_: Mock): + comment_elm = cast( + CT_Comment, + element('w:comment{w:id=42}/(w:p/w:r/w:t"First para",w:p/w:r/w:t"Second para")'), + ) + comment = Comment(comment_elm, comments_part_) + + paragraphs = comment.paragraphs + + assert len(paragraphs) == 2 + assert [para.text for para in paragraphs] == ["First para", "Second para"] + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From 432dd15eb343476024167a3ffec39e2b86d5585c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:11:42 -0700 Subject: [PATCH 46/56] drawing: add image extraction from Drawing --- features/cmt-props.feature | 1 - src/docx/drawing/__init__.py | 39 +++++++++++++++++++ tests/test_comments.py | 4 +- tests/test_drawing.py | 74 ++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 tests/test_drawing.py diff --git a/features/cmt-props.feature b/features/cmt-props.feature index f5c636196..e4e620828 100644 --- a/features/cmt-props.feature +++ b/features/cmt-props.feature @@ -30,7 +30,6 @@ Feature: Get comment properties Then para_text is the text of the first paragraph in the comment - @wip Scenario: Retrieve embedded image from a comment Given a Comment object containing an embedded image Then I can extract the image from the comment diff --git a/src/docx/drawing/__init__.py b/src/docx/drawing/__init__.py index f40205747..00d1f51bb 100644 --- a/src/docx/drawing/__init__.py +++ b/src/docx/drawing/__init__.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: import docx.types as t + from docx.image.image import Image class Drawing(Parented): @@ -18,3 +19,41 @@ def __init__(self, drawing: CT_Drawing, parent: t.ProvidesStoryPart): super().__init__(parent) self._parent = parent self._drawing = self._element = drawing + + @property + def has_picture(self) -> bool: + """True when `drawing` contains an embedded picture. + + A drawing can contain a picture, but it can also contain a chart, SmartArt, or a + drawing canvas. Methods related to a picture, like `.image`, will raise when the drawing + does not contain a picture. Use this value to determine whether image methods will succeed. + + This value is `False` when a linked picture is present. This should be relatively rare and + the image would only be retrievable from the filesystem. + + Note this does not distinguish between inline and floating images. The presence of either + one will cause this value to be `True`. + """ + xpath_expr = ( + # -- an inline picture -- + "./wp:inline/a:graphic/a:graphicData/pic:pic" + # -- a floating picture -- + " | ./wp:anchor/a:graphic/a:graphicData/pic:pic" + ) + # -- xpath() will return a list, empty if there are no matches -- + return bool(self._drawing.xpath(xpath_expr)) + + @property + def image(self) -> Image: + """An `Image` proxy object for the image in this (picture) drawing. + + Raises `ValueError` when this drawing does contains something other than a picture. Use + `.has_picture` to qualify drawing objects before using this property. + """ + picture_rIds = self._drawing.xpath(".//pic:blipFill/a:blip/@r:embed") + if not picture_rIds: + raise ValueError("drawing does not contain a picture") + rId = picture_rIds[0] + doc_part = self.part + image_part = doc_part.related_parts[rId] + return image_part.image diff --git a/tests/test_comments.py b/tests/test_comments.py index 2a0615a79..a4be3dbb4 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -1,6 +1,6 @@ # pyright: reportPrivateUsage=false -"""Unit test suite for the docx.comments module.""" +"""Unit test suite for the `docx.comments` module.""" from __future__ import annotations @@ -21,7 +21,7 @@ class DescribeComments: - """Unit-test suite for `docx.comments.Comments`.""" + """Unit-test suite for `docx.comments.Comments` objects.""" @pytest.mark.parametrize( ("cxml", "count"), diff --git a/tests/test_drawing.py b/tests/test_drawing.py new file mode 100644 index 000000000..c8fedb1a4 --- /dev/null +++ b/tests/test_drawing.py @@ -0,0 +1,74 @@ +# pyright: reportPrivateUsage=false + +"""Unit test suite for the `docx.drawing` module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.drawing import Drawing +from docx.image.image import Image +from docx.oxml.drawing import CT_Drawing +from docx.parts.document import DocumentPart +from docx.parts.image import ImagePart + +from .unitutil.cxml import element +from .unitutil.mock import FixtureRequest, Mock, instance_mock + + +class DescribeDrawing: + """Unit-test suite for `docx.drawing.Drawing` objects.""" + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic", True), + ("w:drawing/wp:anchor/a:graphic/a:graphicData/pic:pic", True), + ("w:drawing/wp:inline/a:graphic/a:graphicData/a:grpSp", False), + ("w:drawing/wp:anchor/a:graphic/a:graphicData/a:chart", False), + ], + ) + def it_knows_when_it_contains_a_Picture( + self, cxml: str, expected_value: bool, document_part_: Mock + ): + drawing = Drawing(cast(CT_Drawing, element(cxml)), document_part_) + assert drawing.has_picture == expected_value + + def it_provides_access_to_the_image_in_a_Picture_drawing( + self, document_part_: Mock, image_part_: Mock, image_: Mock + ): + image_part_.image = image_ + document_part_.part.related_parts = {"rId1": image_part_} + cxml = ( + "w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic/pic:blipFill/a:blip{r:embed=rId1}" + ) + drawing = Drawing(cast(CT_Drawing, element(cxml)), document_part_) + + image = drawing.image + + assert image is image_ + + def but_it_raises_when_the_drawing_does_not_contain_a_Picture(self, document_part_: Mock): + drawing = Drawing( + cast(CT_Drawing, element("w:drawing/wp:inline/a:graphic/a:graphicData/a:grpSp")), + document_part_, + ) + + with pytest.raises(ValueError, match="drawing does not contain a picture"): + drawing.image + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def document_part_(self, request: FixtureRequest): + return instance_mock(request, DocumentPart) + + @pytest.fixture + def image_(self, request: FixtureRequest): + return instance_mock(request, Image) + + @pytest.fixture + def image_part_(self, request: FixtureRequest): + return instance_mock(request, ImagePart) From d360409273a9fdfd2d6a26a7f35b8f3bfc781f04 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:20:17 -0700 Subject: [PATCH 47/56] xfail: acceptance test for Comment mutations --- features/cmt-mutations.feature | 66 +++++++++ features/steps/comments.py | 131 ++++++++++++++++++ .../steps/test_files/comments-rich-para.docx | Bin 19974 -> 20023 bytes 3 files changed, 197 insertions(+) create mode 100644 features/cmt-mutations.feature diff --git a/features/cmt-mutations.feature b/features/cmt-mutations.feature new file mode 100644 index 000000000..634e7c1bc --- /dev/null +++ b/features/cmt-mutations.feature @@ -0,0 +1,66 @@ +Feature: Comment mutations + In order to add and modify the content of a comment + As a developer using python-docx + I need mutation methods on Comment objects + + + @wip + Scenario: Comments.add_comment() + Given a Comments object with 0 comments + When I assign comment = comments.add_comment() + Then comment.comment_id == 0 + And len(comment.paragraphs) == 1 + And comment.paragraphs[0].style.name == "CommentText" + And len(comments) == 1 + And comments.get(0) == comment + + + @wip + Scenario: Comments.add_comment() specifying author and initials + Given a Comments object with 0 comments + When I assign comment = comments.add_comment(author="John Doe", initials="JD") + Then comment.author == "John Doe" + And comment.initials == "JD" + + + @wip + Scenario: Comment.add_paragraph() specifying text and style + Given a default Comment object + When I assign paragraph = comment.add_paragraph(text, style) + Then len(comment.paragraphs) == 2 + And paragraph.text == text + And paragraph.style == style + And comment.paragraphs[-1] == paragraph + + + @wip + Scenario: Comment.add_paragraph() not specifying text or style + Given a default Comment object + When I assign paragraph = comment.add_paragraph() + Then len(comment.paragraphs) == 2 + And paragraph.text == "" + And paragraph.style == "CommentText" + And comment.paragraphs[-1] == paragraph + + + @wip + Scenario: Add image to comment + Given a default Comment object + When I assign paragraph = comment.add_paragraph() + And I assign run = paragraph.add_run() + And I call run.add_picture() + Then run.iter_inner_content() yields a single Picture drawing + + + @wip + Scenario: update Comment.author + Given a Comment object + When I assign "Jane Smith" to comment.author + Then comment.author == "Jane Smith" + + + @wip + Scenario: update Comment.initials + Given a Comment object + When I assign "JS" to comment.initials + Then comment.initials == "JS" diff --git a/features/steps/comments.py b/features/steps/comments.py index 14c7d3359..2bca6d5a6 100644 --- a/features/steps/comments.py +++ b/features/steps/comments.py @@ -30,6 +30,11 @@ def given_a_comments_object_with_count_comments(context: Context, count: str): context.comments = Document(test_docx(testfile_name)).comments +@given("a default Comment object") +def given_a_default_comment_object(context: Context): + context.comment = Document(test_docx("comments-rich-para")).comments.add_comment() + + @given("a document having a comments part") def given_a_document_having_a_comments_part(context: Context): context.document = Document(test_docx("comments-rich-para")) @@ -43,11 +48,48 @@ def given_a_document_having_no_comments_part(context: Context): # when ===================================================== +@when('I assign "{author}" to comment.author') +def when_I_assign_author_to_comment_author(context: Context, author: str): + context.comment.author = author + + +@when("I assign comment = comments.add_comment()") +def when_I_assign_comment_eq_add_comment(context: Context): + context.comment = context.comments.add_comment() + + +@when('I assign comment = comments.add_comment(author="John Doe", initials="JD")') +def when_I_assign_comment_eq_comments_add_comment_with_author_and_initials(context: Context): + context.comment = context.comments.add_comment(author="John Doe", initials="JD") + + +@when('I assign "{initials}" to comment.initials') +def when_I_assign_initials(context: Context, initials: str): + context.comment.initials = initials + + @when("I assign para_text = comment.paragraphs[0].text") def when_I_assign_para_text(context: Context): context.para_text = context.comment.paragraphs[0].text +@when("I assign paragraph = comment.add_paragraph()") +def when_I_assign_default_add_paragraph(context: Context): + context.paragraph = context.comment.add_paragraph() + + +@when("I assign paragraph = comment.add_paragraph(text, style)") +def when_I_assign_add_paragraph_with_text_and_style(context: Context): + context.para_text = text = "Comment text" + context.para_style = style = "Normal" + context.paragraph = context.comment.add_paragraph(text, style) + + +@when("I assign run = paragraph.add_run()") +def when_I_assign_paragraph_add_run(context: Context): + context.run = context.paragraph.add_run() + + @when("I call comments.get(2)") def when_I_call_comments_get_2(context: Context): context.comment = context.comments.get(2) @@ -62,6 +104,17 @@ def then_comment_author_is_the_author_of_the_comment(context: Context): assert actual == "Steve Canny", f"expected author 'Steve Canny', got '{actual}'" +@then('comment.author == "{author}"') +def then_comment_author_eq_author(context: Context, author: str): + actual = context.comment.author + assert actual == author, f"expected author '{author}', got '{actual}'" + + +@then("comment.comment_id == 0") +def then_comment_id_is_0(context: Context): + assert context.comment.comment_id == 0 + + @then("comment.comment_id is the comment identifier") def then_comment_comment_id_is_the_comment_identifier(context: Context): assert context.comment.comment_id == 0 @@ -73,11 +126,42 @@ def then_comment_initials_is_the_initials_of_the_comment_author(context: Context assert initials == "SJC", f"expected initials 'SJC', got '{initials}'" +@then('comment.initials == "{initials}"') +def then_comment_initials_eq_initials(context: Context, initials: str): + actual = context.comment.initials + assert actual == initials, f"expected initials '{initials}', got '{actual}'" + + +@then("comment.paragraphs[{idx}] == paragraph") +def then_comment_paragraphs_idx_eq_paragraph(context: Context, idx: str): + actual = context.comment.paragraphs[int(idx)]._p + expected = context.paragraph._p + assert actual == expected, "paragraphs do not compare equal" + + +@then('comment.paragraphs[{idx}].style.name == "{style}"') +def then_comment_paragraphs_idx_style_name_eq_style(context: Context, idx: str, style: str): + actual = context.comment.paragraphs[int(idx)]._p.style + expected = style + assert actual == expected, f"expected style name '{expected}', got '{actual}'" + + @then("comment.timestamp is the date and time the comment was authored") def then_comment_timestamp_is_the_date_and_time_the_comment_was_authored(context: Context): assert context.comment.timestamp == dt.datetime(2025, 6, 7, 11, 20, 0, tzinfo=dt.timezone.utc) +@then("comments.get({id}) == comment") +def then_comments_get_comment_id_eq_comment(context: Context, id: str): + comment_id = int(id) + comment = context.comments.get(comment_id) + + assert type(comment) is Comment, f"expected a Comment object, got {type(comment)}" + assert comment.comment_id == comment_id, ( + f"expected comment_id '{comment_id}', got '{comment.comment_id}'" + ) + + @then("document.comments is a Comments object") def then_document_comments_is_a_Comments_object(context: Context): document = context.document @@ -109,6 +193,13 @@ def then_iterating_comments_yields_count_comments(context: Context, count: str): assert len(remaining) == int(count) - 1, "iterating comments did not yield the expected count" +@then("len(comment.paragraphs) == {count}") +def then_len_comment_paragraphs_eq_count(context: Context, count: str): + actual = len(context.comment.paragraphs) + expected = int(count) + assert actual == expected, f"expected len(comment.paragraphs) of {expected}, got {actual}" + + @then("len(comments) == {count}") def then_len_comments_eq_count(context: Context, count: str): actual = len(context.comments) @@ -123,6 +214,46 @@ def then_para_text_is_the_text_of_the_first_paragraph_in_the_comment(context: Co assert actual == expected, f"expected para_text '{expected}', got '{actual}'" +@then("paragraph.style == style") +def then_paragraph_style_eq_known_style(context: Context): + actual = context.paragraph.style.name + expected = context.para_style + assert actual == expected, f"expected paragraph.style '{expected}', got '{actual}'" + + +@then('paragraph.style == "{style}"') +def then_paragraph_style_eq_style(context: Context, style: str): + actual = context.paragraph._p.style + expected = style + assert actual == expected, f"expected paragraph.style '{expected}', got '{actual}'" + + +@then("paragraph.text == text") +def then_paragraph_text_eq_known_text(context: Context): + actual = context.paragraph.text + expected = context.para_text + assert actual == expected, f"expected paragraph.text '{expected}', got '{actual}'" + + +@then('paragraph.text == ""') +def then_paragraph_text_eq_text(context: Context): + actual = context.paragraph.text + expected = "" + assert actual == expected, f"expected paragraph.text '{expected}', got '{actual}'" + + +@then("run.iter_inner_content() yields a single Picture drawing") +def then_run_iter_inner_content_yields_a_single_picture_drawing(context: Context): + inner_content = list(context.run.iter_inner_content()) + + assert len(inner_content) == 1, ( + f"expected a single inner content element, got {len(inner_content)}" + ) + inner_content_item = inner_content[0] + assert isinstance(inner_content_item, Drawing) + assert inner_content_item.has_picture + + @then("the result is a Comment object with id 2") def then_the_result_is_a_comment_object_with_id_2(context: Context): comment = context.comment diff --git a/features/steps/test_files/comments-rich-para.docx b/features/steps/test_files/comments-rich-para.docx index e63db413e871466da97e906aef754bc06b2f9601..245c17224c22d0a6d72958be917d9ef1a899bad6 100644 GIT binary patch delta 5354 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 From 8ac9fc4f6b50b9b7f208974e853f1995d63a834a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:24:17 -0700 Subject: [PATCH 48/56] comments: add Comments.add_comment() Only with `text` parameter so far. Author and initials parameters to follow. --- features/cmt-mutations.feature | 5 --- src/docx/comments.py | 60 ++++++++++++++++++++++++++ src/docx/oxml/comments.py | 57 ++++++++++++++++++++++++- tests/oxml/test_comments.py | 31 ++++++++++++++ tests/test_comments.py | 78 ++++++++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 tests/oxml/test_comments.py diff --git a/features/cmt-mutations.feature b/features/cmt-mutations.feature index 634e7c1bc..6fda8810b 100644 --- a/features/cmt-mutations.feature +++ b/features/cmt-mutations.feature @@ -4,7 +4,6 @@ Feature: Comment mutations I need mutation methods on Comment objects - @wip Scenario: Comments.add_comment() Given a Comments object with 0 comments When I assign comment = comments.add_comment() @@ -15,7 +14,6 @@ Feature: Comment mutations And comments.get(0) == comment - @wip Scenario: Comments.add_comment() specifying author and initials Given a Comments object with 0 comments When I assign comment = comments.add_comment(author="John Doe", initials="JD") @@ -23,7 +21,6 @@ Feature: Comment mutations And comment.initials == "JD" - @wip Scenario: Comment.add_paragraph() specifying text and style Given a default Comment object When I assign paragraph = comment.add_paragraph(text, style) @@ -33,7 +30,6 @@ Feature: Comment mutations And comment.paragraphs[-1] == paragraph - @wip Scenario: Comment.add_paragraph() not specifying text or style Given a default Comment object When I assign paragraph = comment.add_paragraph() @@ -43,7 +39,6 @@ Feature: Comment mutations And comment.paragraphs[-1] == paragraph - @wip Scenario: Add image to comment Given a default Comment object When I assign paragraph = comment.add_paragraph() diff --git a/src/docx/comments.py b/src/docx/comments.py index e5d25fd79..7fd39d54a 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -10,6 +10,8 @@ if TYPE_CHECKING: from docx.oxml.comments import CT_Comment, CT_Comments from docx.parts.comments import CommentsPart + from docx.styles.style import ParagraphStyle + from docx.text.paragraph import Paragraph class Comments: @@ -30,6 +32,48 @@ def __len__(self) -> int: """The number of comments in this collection.""" return len(self._comments_elm.comment_lst) + def add_comment(self, text: str = "", author: str = "", initials: str | None = "") -> Comment: + """Add a new comment to the document and return it. + + The comment is added to the end of the comments collection and is assigned a unique + comment-id. + + If `text` is provided, it is added to the comment. This option provides for the common + case where a comment contains a modest passage of plain text. Multiple paragraphs can be + added using the `text` argument by separating their text with newlines (`"\\\\n"`). + Between newlines, text is interpreted as it is in `Document.add_paragraph(text=...)`. + + The default is to place a single empty paragraph in the comment, which is the same + behavior as the Word UI when you add a comment. New runs can be added to the first + paragraph in the empty comment with `comments.paragraphs[0].add_run()` to adding more + complex text with emphasis or images. Additional paragraphs can be added using + `.add_paragraph()`. + + `author` is a required attribute, set to the empty string by default. + + `initials` is an optional attribute, set to the empty string by default. Passing |None| + for the `initials` parameter causes that attribute to be omitted from the XML. + """ + comment_elm = self._comments_elm.add_comment() + comment_elm.author = author + comment_elm.initials = initials + comment_elm.date = dt.datetime.now(dt.timezone.utc) + comment = Comment(comment_elm, self._comments_part) + + if text == "": + return comment + + para_text_iter = iter(text.split("\n")) + + first_para_text = next(para_text_iter) + first_para = comment.paragraphs[0] + first_para.add_run(first_para_text) + + for s in para_text_iter: + comment.add_paragraph(text=s) + + return comment + def get(self, comment_id: int) -> Comment | None: """Return the comment identified by `comment_id`, or |None| if not found.""" comment_elm = self._comments_elm.get_comment_by_id(comment_id) @@ -54,6 +98,22 @@ def __init__(self, comment_elm: CT_Comment, comments_part: CommentsPart): super().__init__(comment_elm, comments_part) self._comment_elm = comment_elm + def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph: + """Return paragraph newly added to the end of the content in this container. + + The paragraph has `text` in a single run if present, and is given paragraph style `style`. + When `style` is |None| or ommitted, the "CommentText" paragraph style is applied, which is + the default style for comments. + """ + paragraph = super().add_paragraph(text, style) + + # -- have to assign style directly to element because `paragraph.style` raises when + # -- a style is not present in the styles part + if style is None: + paragraph._p.style = "CommentText" # pyright: ignore[reportPrivateUsage] + + return paragraph + @property def author(self) -> str: """The recorded author of this comment.""" diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 0ebd7e200..ad9821759 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -3,8 +3,10 @@ from __future__ import annotations import datetime as dt -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Callable, cast +from docx.oxml.ns import nsdecls +from docx.oxml.parser import parse_xml from docx.oxml.simpletypes import ST_DateTime, ST_DecimalNumber, ST_String from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore @@ -27,11 +29,64 @@ class CT_Comments(BaseOxmlElement): comment = ZeroOrMore("w:comment") + def add_comment(self) -> CT_Comment: + """Return newly added `w:comment` child of this `w:comments`. + + The returned `w:comment` element is the minimum valid value, having a `w:id` value unique + within the existing comments and the required `w:author` attribute present but set to the + empty string. It's content is limited to a single run containing the necessary annotation + reference but no text. Content is added by adding runs to this first paragraph and by + adding additional paragraphs as needed. + """ + next_id = self._next_available_comment_id() + comment = cast( + CT_Comment, + parse_xml( + f'' + f" " + f" " + f' ' + f" " + f" " + f" " + f' ' + f" " + f" " + f" " + f" " + f"" + ), + ) + self.append(comment) + return comment + def get_comment_by_id(self, comment_id: int) -> CT_Comment | None: """Return the `w:comment` element identified by `comment_id`, or |None| if not found.""" comment_elms = self.xpath(f"(./w:comment[@w:id='{comment_id}'])[1]") return comment_elms[0] if comment_elms else None + def _next_available_comment_id(self) -> int: + """The next available comment id. + + According to the schema, this can be any positive integer, as big as you like, and the + default mechanism is to use `max() + 1`. However, if that yields a value larger than will + fit in a 32-bit signed integer, we take a more deliberate approach to use the first + ununsed integer starting from 0. + """ + used_ids = [int(x) for x in self.xpath("./w:comment/@w:id")] + + next_id = max(used_ids, default=-1) + 1 + + if next_id <= 2**31 - 1: + return next_id + + # -- fall-back to enumerating all used ids to find the first unused one -- + for expected, actual in enumerate(sorted(used_ids)): + if expected != actual: + return expected + + return len(used_ids) + class CT_Comment(BaseOxmlElement): """`w:comment` element, representing a single comment. diff --git a/tests/oxml/test_comments.py b/tests/oxml/test_comments.py new file mode 100644 index 000000000..8fc116144 --- /dev/null +++ b/tests/oxml/test_comments.py @@ -0,0 +1,31 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for `docx.oxml.comments` module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.oxml.comments import CT_Comments + +from ..unitutil.cxml import element + + +class DescribeCT_Comments: + """Unit-test suite for `docx.oxml.comments.CT_Comments`.""" + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:comments", 0), + ("w:comments/(w:comment{w:id=1})", 2), + ("w:comments/(w:comment{w:id=4},w:comment{w:id=2147483646})", 2147483647), + ("w:comments/(w:comment{w:id=1},w:comment{w:id=2147483647})", 0), + ("w:comments/(w:comment{w:id=1},w:comment{w:id=2},w:comment{w:id=3})", 4), + ], + ) + def it_finds_the_next_available_comment_id_to_help(self, cxml: str, expected_value: int): + comments_elm = cast(CT_Comments, element(cxml)) + assert comments_elm._next_available_comment_id() == expected_value diff --git a/tests/test_comments.py b/tests/test_comments.py index a4be3dbb4..8f5be2d1e 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -13,6 +13,7 @@ from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.packuri import PackURI from docx.oxml.comments import CT_Comment, CT_Comments +from docx.oxml.ns import qn from docx.package import Package from docx.parts.comments import CommentsPart @@ -86,8 +87,85 @@ def it_can_get_a_comment_by_id(self, package_: Mock): assert type(comment) is Comment, "expected a `Comment` object" assert comment._comment_elm is comments_elm.comment_lst[1] + def but_it_returns_None_when_no_comment_with_that_id_exists(self, package_: Mock): + comments_elm = cast( + CT_Comments, + element("w:comments/(w:comment{w:id=1},w:comment{w:id=2},w:comment{w:id=3})"), + ) + comments = Comments( + comments_elm, + CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ), + ) + + comment = comments.get(4) + + assert comment is None, "expected None when no comment with that id exists" + + def it_can_add_a_new_comment(self, package_: Mock): + comments_elm = cast(CT_Comments, element("w:comments")) + comments_part = CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ) + now_before = dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + comments = Comments(comments_elm, comments_part) + + comment = comments.add_comment() + + now_after = dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + # -- a comment is unconditionally added, and returned for any further adjustment -- + assert isinstance(comment, Comment) + # -- it is "linked" to the comments part so it can add images and hyperlinks, etc. -- + assert comment.part is comments_part + # -- comment numbering starts at 0, and is incremented for each new comment -- + assert comment.comment_id == 0 + # -- author is a required attribut, but is the empty string by default -- + assert comment.author == "" + # -- initials is an optional attribute, but defaults to the empty string, same as Word -- + assert comment.initials == "" + # -- timestamp is also optional, but defaults to now-UTC -- + assert comment.timestamp is not None + assert now_before <= comment.timestamp <= now_after + # -- by default, a new comment contains a single empty paragraph -- + assert [p.text for p in comment.paragraphs] == [""] + # -- that paragraph has the "CommentText" style, same as Word applies -- + comment_elm = comment._comment_elm + assert len(comment_elm.p_lst) == 1 + p = comment_elm.p_lst[0] + assert p.style == "CommentText" + # -- and that paragraph contains a single run with the necessary annotation reference -- + assert len(p.r_lst) == 1 + r = comment_elm.p_lst[0].r_lst[0] + assert r.style == "CommentReference" + assert r[-1].tag == qn("w:annotationRef") + + def and_it_can_add_text_to_the_comment_when_adding_it(self, comments: Comments, package_: Mock): + comment = comments.add_comment(text="para 1\n\npara 2") + + assert len(comment.paragraphs) == 3 + assert [p.text for p in comment.paragraphs] == ["para 1", "", "para 2"] + assert all(p._p.style == "CommentText" for p in comment.paragraphs) + # -- fixtures -------------------------------------------------------------------------------- + @pytest.fixture + def comments(self, package_: Mock) -> Comments: + comments_elm = cast(CT_Comments, element("w:comments")) + comments_part = CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ) + return Comments(comments_elm, comments_part) + @pytest.fixture def package_(self, request: FixtureRequest): return instance_mock(request, Package) From 761f4ccd7751afeeaa5fff5c6f47325c3e0970fa Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:26:14 -0700 Subject: [PATCH 49/56] comments: add Comment.author, .initials setters - allow setting on construction - allow update with property setters --- features/cmt-mutations.feature | 2 -- src/docx/comments.py | 13 ++++++++++++- src/docx/shared.py | 2 +- tests/test_comments.py | 35 ++++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/features/cmt-mutations.feature b/features/cmt-mutations.feature index 6fda8810b..1ef9ad2db 100644 --- a/features/cmt-mutations.feature +++ b/features/cmt-mutations.feature @@ -47,14 +47,12 @@ Feature: Comment mutations Then run.iter_inner_content() yields a single Picture drawing - @wip Scenario: update Comment.author Given a Comment object When I assign "Jane Smith" to comment.author Then comment.author == "Jane Smith" - @wip Scenario: update Comment.initials Given a Comment object When I assign "JS" to comment.initials diff --git a/src/docx/comments.py b/src/docx/comments.py index 7fd39d54a..f0b359ee7 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -116,9 +116,16 @@ def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = Non @property def author(self) -> str: - """The recorded author of this comment.""" + """Read/write. The recorded author of this comment. + + This field is required but can be set to the empty string. + """ return self._comment_elm.author + @author.setter + def author(self, value: str): + self._comment_elm.author = value + @property def comment_id(self) -> int: """The unique identifier of this comment.""" @@ -133,6 +140,10 @@ def initials(self) -> str | None: """ return self._comment_elm.initials + @initials.setter + def initials(self, value: str | None): + self._comment_elm.initials = value + @property def timestamp(self) -> dt.datetime | None: """The date and time this comment was authored. diff --git a/src/docx/shared.py b/src/docx/shared.py index 1d561227b..6c12dc91e 100644 --- a/src/docx/shared.py +++ b/src/docx/shared.py @@ -328,7 +328,7 @@ def __init__(self, parent: t.ProvidesXmlPart): self._parent = parent @property - def part(self): + def part(self) -> XmlPart: """The package part containing this object.""" return self._parent.part diff --git a/tests/test_comments.py b/tests/test_comments.py index 8f5be2d1e..bdc38af9a 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -153,6 +153,14 @@ def and_it_can_add_text_to_the_comment_when_adding_it(self, comments: Comments, assert [p.text for p in comment.paragraphs] == ["para 1", "", "para 2"] assert all(p._p.style == "CommentText" for p in comment.paragraphs) + def and_it_sets_the_author_and_their_initials_when_adding_a_comment_when_provided( + self, comments: Comments, package_: Mock + ): + comment = comments.add_comment(author="Steve Canny", initials="SJC") + + assert comment.author == "Steve Canny" + assert comment.initials == "SJC" + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture @@ -213,6 +221,33 @@ def it_provides_access_to_the_paragraphs_it_contains(self, comments_part_: Mock) assert len(paragraphs) == 2 assert [para.text for para in paragraphs] == ["First para", "Second para"] + def it_can_update_the_comment_author(self, comments_part_: Mock): + comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:author=Old Author}")) + comment = Comment(comment_elm, comments_part_) + + comment.author = "New Author" + + assert comment.author == "New Author" + + @pytest.mark.parametrize( + "initials", + [ + # -- valid initials -- + "XYZ", + # -- empty string is valid + "", + # -- None is valid, removes existing initials + None, + ], + ) + def it_can_update_the_comment_initials(self, initials: str | None, comments_part_: Mock): + comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:initials=ABC}")) + comment = Comment(comment_elm, comments_part_) + + comment.initials = initials + + assert comment.initials == initials + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From 66da52204db395466cc7ea033af0f5bffd228953 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 20:46:35 -0700 Subject: [PATCH 50/56] xfail: acceptance test for Document.add_comment() --- features/doc-add-comment.feature | 14 ++++++++++++++ features/steps/comments.py | 31 +++++++++++++++++++++++++++---- features/steps/settings.py | 17 +++++++++++------ 3 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 features/doc-add-comment.feature diff --git a/features/doc-add-comment.feature b/features/doc-add-comment.feature new file mode 100644 index 000000000..73560044a --- /dev/null +++ b/features/doc-add-comment.feature @@ -0,0 +1,14 @@ +Feature: Add a comment to a document + In order add a comment to a document + As a developer using python-docx + I need a way to add a comment specifying both its content and its reference + + + @wip + Scenario: Document.add_comment(runs, text, author, initials) + Given a document having a comments part + When I assign comment = document.add_comment(runs, "A comment", "John Doe", "JD") + Then comment is a Comment object + And comment.text == "A comment" + And comment.author == "John Doe" + And comment.initials == "JD" diff --git a/features/steps/comments.py b/features/steps/comments.py index 2bca6d5a6..39680f257 100644 --- a/features/steps/comments.py +++ b/features/steps/comments.py @@ -63,6 +63,17 @@ def when_I_assign_comment_eq_comments_add_comment_with_author_and_initials(conte context.comment = context.comments.add_comment(author="John Doe", initials="JD") +@when('I assign comment = document.add_comment(runs, "A comment", "John Doe", "JD")') +def when_I_assign_comment_eq_document_add_comment(context: Context): + runs = list(context.document.paragraphs[0].runs) + context.comment = context.document.add_comment( + runs=runs, + text="A comment", + author="John Doe", + initials="JD", + ) + + @when('I assign "{initials}" to comment.initials') def when_I_assign_initials(context: Context, initials: str): context.comment.initials = initials @@ -98,10 +109,9 @@ def when_I_call_comments_get_2(context: Context): # then ===================================================== -@then("comment.author is the author of the comment") -def then_comment_author_is_the_author_of_the_comment(context: Context): - actual = context.comment.author - assert actual == "Steve Canny", f"expected author 'Steve Canny', got '{actual}'" +@then("comment is a Comment object") +def then_comment_is_a_Comment_object(context: Context): + assert type(context.comment) is Comment @then('comment.author == "{author}"') @@ -110,6 +120,12 @@ def then_comment_author_eq_author(context: Context, author: str): assert actual == author, f"expected author '{author}', got '{actual}'" +@then("comment.author is the author of the comment") +def then_comment_author_is_the_author_of_the_comment(context: Context): + actual = context.comment.author + assert actual == "Steve Canny", f"expected author 'Steve Canny', got '{actual}'" + + @then("comment.comment_id == 0") def then_comment_id_is_0(context: Context): assert context.comment.comment_id == 0 @@ -146,6 +162,13 @@ def then_comment_paragraphs_idx_style_name_eq_style(context: Context, idx: str, assert actual == expected, f"expected style name '{expected}', got '{actual}'" +@then('comment.text == "{text}"') +def then_comment_text_eq_text(context: Context, text: str): + actual = context.comment.text + expected = text + assert actual == expected, f"expected text '{expected}', got '{actual}'" + + @then("comment.timestamp is the date and time the comment was authored") def then_comment_timestamp_is_the_date_and_time_the_comment_was_authored(context: Context): assert context.comment.timestamp == dt.datetime(2025, 6, 7, 11, 20, 0, tzinfo=dt.timezone.utc) diff --git a/features/steps/settings.py b/features/steps/settings.py index 1b03661eb..882f5ded3 100644 --- a/features/steps/settings.py +++ b/features/steps/settings.py @@ -1,6 +1,7 @@ """Step implementations for document settings-related features.""" from behave import given, then, when +from behave.runner import Context from docx import Document from docx.settings import Settings @@ -11,17 +12,19 @@ @given("a document having a settings part") -def given_a_document_having_a_settings_part(context): +def given_a_document_having_a_settings_part(context: Context): context.document = Document(test_docx("doc-word-default-blank")) @given("a document having no settings part") -def given_a_document_having_no_settings_part(context): +def given_a_document_having_no_settings_part(context: Context): context.document = Document(test_docx("set-no-settings-part")) @given("a Settings object {with_or_without} odd and even page headers as settings") -def given_a_Settings_object_with_or_without_odd_and_even_hdrs(context, with_or_without): +def given_a_Settings_object_with_or_without_odd_and_even_hdrs( + context: Context, with_or_without: str +): testfile_name = {"with": "doc-odd-even-hdrs", "without": "sct-section-props"}[with_or_without] context.settings = Document(test_docx(testfile_name)).settings @@ -30,7 +33,9 @@ def given_a_Settings_object_with_or_without_odd_and_even_hdrs(context, with_or_w @when("I assign {bool_val} to settings.odd_and_even_pages_header_footer") -def when_I_assign_value_to_settings_odd_and_even_pages_header_footer(context, bool_val): +def when_I_assign_value_to_settings_odd_and_even_pages_header_footer( + context: Context, bool_val: str +): context.settings.odd_and_even_pages_header_footer = eval(bool_val) @@ -38,13 +43,13 @@ def when_I_assign_value_to_settings_odd_and_even_pages_header_footer(context, bo @then("document.settings is a Settings object") -def then_document_settings_is_a_Settings_object(context): +def then_document_settings_is_a_Settings_object(context: Context): document = context.document assert type(document.settings) is Settings @then("settings.odd_and_even_pages_header_footer is {bool_val}") -def then_settings_odd_and_even_pages_header_footer_is(context, bool_val): +def then_settings_odd_and_even_pages_header_footer_is(context: Context, bool_val: str): actual = context.settings.odd_and_even_pages_header_footer expected = eval(bool_val) assert actual == expected, "settings.odd_and_even_pages_header_footer is %s" % actual From af3b973dd2c938f6851537978fe76f4f5e91dcc9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 16:31:54 -0700 Subject: [PATCH 51/56] comments: add Document.add_comment() --- src/docx/document.py | 50 +++++++++++++++++++++++++++++++++++++-- src/docx/oxml/shared.py | 3 +-- src/docx/oxml/xmlchemy.py | 3 +-- src/docx/text/run.py | 7 ++++++ tests/test_document.py | 34 +++++++++++++++++++++++++- 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/docx/document.py b/src/docx/document.py index 5de03bf9d..1168c4ae8 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -5,17 +5,18 @@ from __future__ import annotations -from typing import IO, TYPE_CHECKING, Iterator, List +from typing import IO, TYPE_CHECKING, Iterator, List, Sequence from docx.blkcntnr import BlockItemContainer from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK from docx.section import Section, Sections from docx.shared import ElementProxy, Emu, Inches, Length +from docx.text.run import Run if TYPE_CHECKING: import docx.types as t - from docx.comments import Comments + from docx.comments import Comment, Comments from docx.oxml.document import CT_Body, CT_Document from docx.parts.document import DocumentPart from docx.settings import Settings @@ -37,6 +38,51 @@ def __init__(self, element: CT_Document, part: DocumentPart): self._part = part self.__body = None + def add_comment( + self, runs: Run | Sequence[Run], text: str, author: str = "", initials: str | None = "" + ) -> Comment: + """Add a comment to the document, anchored to the specified runs. + + `runs` can be a single `Run` object or a non-empty sequence of `Run` objects. Only the + first and last run of a sequence are used, it's just more convenient to pass a whole + sequence when that's what you have handy, like `paragraph.runs` for example. When `runs` + contains a single `Run` object, that run serves as both the first and last run. + + A comment can be anchored only on an even run boundary, meaning the text the comment + "references" must be a non-zero integer number of consecutive runs. The runs need not be + _contiguous_ per se, like the first can be in one paragraph and the last in the next + paragraph, but all runs between the first and the last will be included in the reference. + + The comment reference range is delimited by placing a `w:commentRangeStart` element before + the first run and a `w:commentRangeEnd` element after the last run. This is why only the + first and last run are required and why a single run can serve as both first and last. + Word works out which text to highlight in the UI based on these range markers. + + `text` allows the contents of a simple comment to be provided in the call, providing for + the common case where a comment is a single phrase or sentence without special formatting + such as bold or italics. More complex comments can be added using the returned `Comment` + object in much the same way as a `Document` or (table) `Cell` object, using methods like + `.add_paragraph()`, .add_run()`, etc. + + The `author` and `initials` parameters allow that metadata to be set for the comment. + `author` is a required attribute on a comment and is the empty string by default. + `initials` is optional on a comment and may be omitted by passing |None|, but Word adds an + `initials` attribute by default and we follow that convention by using the empty string + when no `initials` argument is provided. + """ + # -- normalize `runs` to a sequence of runs -- + runs = [runs] if isinstance(runs, Run) else runs + first_run = runs[0] + last_run = runs[-1] + + # -- Note that comments can only appear in the document part -- + comment = self.comments.add_comment(text=text, author=author, initials=initials) + + # -- let the first run orchestrate placement of the comment range start and end -- + first_run.mark_comment_range(last_run, comment.comment_id) + + return comment + def add_heading(self, text: str = "", level: int = 1): """Return a heading paragraph newly added to the end of the document. diff --git a/src/docx/oxml/shared.py b/src/docx/oxml/shared.py index 8c2ebc9a9..8cfcd2be1 100644 --- a/src/docx/oxml/shared.py +++ b/src/docx/oxml/shared.py @@ -46,8 +46,7 @@ class CT_String(BaseOxmlElement): @classmethod def new(cls, nsptagname: str, val: str): - """Return a new ``CT_String`` element with tagname `nsptagname` and ``val`` - attribute set to `val`.""" + """A new `CT_String`` element with tagname `nsptagname` and `val` attribute set to `val`.""" elm = cast(CT_String, OxmlElement(nsptagname)) elm.val = val return elm diff --git a/src/docx/oxml/xmlchemy.py b/src/docx/oxml/xmlchemy.py index df75ee18c..e2c54b392 100644 --- a/src/docx/oxml/xmlchemy.py +++ b/src/docx/oxml/xmlchemy.py @@ -423,8 +423,7 @@ def _new_method_name(self): class Choice(_BaseChildElement): - """Defines a child element belonging to a group, only one of which may appear as a - child.""" + """Defines a child element belonging to a group, only one of which may appear as a child.""" @property def nsptagname(self): diff --git a/src/docx/text/run.py b/src/docx/text/run.py index d35988370..d49876eaf 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -173,6 +173,13 @@ def iter_inner_content(self) -> Iterator[str | Drawing | RenderedPageBreak]: elif isinstance(item, CT_Drawing): # pyright: ignore[reportUnnecessaryIsInstance] yield Drawing(item, self) + def mark_comment_range(self, last_run: Run, comment_id: int) -> None: + """Mark the range of runs from this run to `last_run` (inclusive) as belonging to a comment. + + `comment_id` identfies the comment that references this range. + """ + raise NotImplementedError + @property def style(self) -> CharacterStyle: """Read/write. diff --git a/tests/test_document.py b/tests/test_document.py index 0b36017a5..53efacf8d 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -9,7 +9,7 @@ import pytest -from docx.comments import Comments +from docx.comments import Comment, Comments from docx.document import Document, _Body from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK @@ -39,6 +39,26 @@ class DescribeDocument: """Unit-test suite for `docx.document.Document`.""" + def it_can_add_a_comment( + self, + document_part_: Mock, + comments_prop_: Mock, + comments_: Mock, + comment_: Mock, + run_mark_comment_range_: Mock, + ): + comment_.comment_id = 42 + comments_.add_comment.return_value = comment_ + comments_prop_.return_value = comments_ + document = Document(cast(CT_Document, element("w:document/w:body/w:p/w:r")), document_part_) + run = document.paragraphs[0].runs[0] + + comment = document.add_comment(run, "Comment text.") + + comments_.add_comment.assert_called_once_with("Comment text.", "", "") + run_mark_comment_range_.assert_called_once_with(run, run, 42) + assert comment is comment_ + @pytest.mark.parametrize( ("level", "style"), [(0, "Title"), (1, "Heading 1"), (2, "Heading 2"), (9, "Heading 9")] ) @@ -288,10 +308,18 @@ def _block_width_prop_(self, request: FixtureRequest): def body_prop_(self, request: FixtureRequest): return property_mock(request, Document, "_body") + @pytest.fixture + def comment_(self, request: FixtureRequest): + return instance_mock(request, Comment) + @pytest.fixture def comments_(self, request: FixtureRequest): return instance_mock(request, Comments) + @pytest.fixture + def comments_prop_(self, request: FixtureRequest): + return property_mock(request, Document, "comments") + @pytest.fixture def core_properties_(self, request: FixtureRequest): return instance_mock(request, CoreProperties) @@ -325,6 +353,10 @@ def picture_(self, request: FixtureRequest): def run_(self, request: FixtureRequest): return instance_mock(request, Run) + @pytest.fixture + def run_mark_comment_range_(self, request: FixtureRequest): + return method_mock(request, Run, "mark_comment_range") + @pytest.fixture def Section_(self, request: FixtureRequest): return class_mock(request, "docx.document.Section") From e3a321d26195fdd6e368f59b63be06b1277dac14 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 17:51:50 -0700 Subject: [PATCH 52/56] comments: add Run.mark_comment_range() --- src/docx/oxml/text/run.py | 33 ++++++++++++++++++++++++++++++++- src/docx/text/run.py | 7 ++++++- tests/text/test_run.py | 13 +++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/docx/oxml/text/run.py b/src/docx/oxml/text/run.py index 88efae83c..7496aa616 100644 --- a/src/docx/oxml/text/run.py +++ b/src/docx/oxml/text/run.py @@ -2,10 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Iterator, List +from typing import TYPE_CHECKING, Callable, Iterator, List, cast from docx.oxml.drawing import CT_Drawing from docx.oxml.ns import qn +from docx.oxml.parser import OxmlElement from docx.oxml.simpletypes import ST_BrClear, ST_BrType from docx.oxml.text.font import CT_RPr from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne @@ -87,6 +88,19 @@ def iter_items() -> Iterator[str | CT_Drawing | CT_LastRenderedPageBreak]: return list(iter_items()) + def insert_comment_range_end_and_reference_below(self, comment_id: int) -> None: + """Insert a `w:commentRangeEnd` and `w:commentReference` element after this run. + + The `w:commentRangeEnd` element is the immediate sibling of this `w:r` and is followed by + a `w:r` containing the `w:commentReference` element. + """ + self.addnext(self._new_comment_reference_run(comment_id)) + self.addnext(OxmlElement("w:commentRangeEnd", attrs={qn("w:id"): str(comment_id)})) + + def insert_comment_range_start_above(self, comment_id: int) -> None: + """Insert a `w:commentRangeStart` element with `comment_id` before this run.""" + self.addprevious(OxmlElement("w:commentRangeStart", attrs={qn("w:id"): str(comment_id)})) + @property def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: """All `w:lastRenderedPageBreaks` descendants of this run.""" @@ -132,6 +146,23 @@ def _insert_rPr(self, rPr: CT_RPr) -> CT_RPr: self.insert(0, rPr) return rPr + def _new_comment_reference_run(self, comment_id: int) -> CT_R: + """Return a new `w:r` element with `w:commentReference` referencing `comment_id`. + + Should look like this: + + + + + + + """ + r = cast(CT_R, OxmlElement("w:r")) + rPr = r.get_or_add_rPr() + rPr.style = "CommentReference" + r.append(OxmlElement("w:commentReference", attrs={qn("w:id"): str(comment_id)})) + return r + # ------------------------------------------------------------------------------------ # Run inner-content elements diff --git a/src/docx/text/run.py b/src/docx/text/run.py index d49876eaf..57ea31fa4 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -178,7 +178,12 @@ def mark_comment_range(self, last_run: Run, comment_id: int) -> None: `comment_id` identfies the comment that references this range. """ - raise NotImplementedError + # -- insert `w:commentRangeStart` with `comment_id` before this (first) run -- + self._r.insert_comment_range_start_above(comment_id) + + # -- insert `w:commentRangeEnd` and `w:commentReference` run with `comment_id` after + # -- `last_run` + last_run._r.insert_comment_range_end_and_reference_below(comment_id) @property def style(self) -> CharacterStyle: diff --git a/tests/text/test_run.py b/tests/text/test_run.py index a54120fdd..910f445d1 100644 --- a/tests/text/test_run.py +++ b/tests/text/test_run.py @@ -11,6 +11,7 @@ from docx import types as t from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_BREAK, WD_UNDERLINE +from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R from docx.parts.document import DocumentPart from docx.shape import InlineShape @@ -122,6 +123,18 @@ def it_can_iterate_its_inner_content_items( actual = [type(item).__name__ for item in inner_content] assert actual == expected, f"expected: {expected}, got: {actual}" + def it_can_mark_a_comment_reference_range(self, paragraph_: Mock): + p = cast(CT_P, element('w:p/w:r/w:t"referenced text"')) + run = last_run = Run(p.r_lst[0], paragraph_) + + run.mark_comment_range(last_run, comment_id=42) + + assert p.xml == xml( + 'w:p/(w:commentRangeStart{w:id=42},w:r/w:t"referenced text"' + ",w:commentRangeEnd{w:id=42}" + ",w:r/(w:rPr/w:rStyle{w:val=CommentReference},w:commentReference{w:id=42}))" + ) + def it_knows_its_character_style( self, part_prop_: Mock, document_part_: Mock, paragraph_: Mock ): From a809d6cc8aec18648850d8b94d554f05621e433a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 18:05:15 -0700 Subject: [PATCH 53/56] comments: add Comment.text --- features/doc-add-comment.feature | 1 - src/docx/comments.py | 10 ++++++++++ tests/test_comments.py | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/features/doc-add-comment.feature b/features/doc-add-comment.feature index 73560044a..36f46244a 100644 --- a/features/doc-add-comment.feature +++ b/features/doc-add-comment.feature @@ -4,7 +4,6 @@ Feature: Add a comment to a document I need a way to add a comment specifying both its content and its reference - @wip Scenario: Document.add_comment(runs, text, author, initials) Given a document having a comments part When I assign comment = document.add_comment(runs, "A comment", "John Doe", "JD") diff --git a/src/docx/comments.py b/src/docx/comments.py index f0b359ee7..9b69cbcec 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -144,6 +144,16 @@ def initials(self) -> str | None: def initials(self, value: str | None): self._comment_elm.initials = value + @property + def text(self) -> str: + """The text content of this comment as a string. + + Only content in paragraphs is included and of course all emphasis and styling is stripped. + + Paragraph boundaries are indicated with a newline ("\n") + """ + return "\n".join(p.text for p in self.paragraphs) + @property def timestamp(self) -> dt.datetime | None: """The date and time this comment was authored. diff --git a/tests/test_comments.py b/tests/test_comments.py index bdc38af9a..0f292ec8a 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -209,6 +209,26 @@ def it_knows_the_date_and_time_it_was_authored(self, comments_part_: Mock): assert comment.timestamp == dt.datetime(2023, 10, 1, 12, 34, 56, tzinfo=dt.timezone.utc) + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:comment{w:id=42}", ""), + ('w:comment{w:id=42}/w:p/w:r/w:t"Comment text."', "Comment text."), + ( + 'w:comment{w:id=42}/(w:p/w:r/w:t"First para",w:p/w:r/w:t"Second para")', + "First para\nSecond para", + ), + ( + 'w:comment{w:id=42}/(w:p/w:r/w:t"First para",w:p,w:p/w:r/w:t"Second para")', + "First para\n\nSecond para", + ), + ], + ) + def it_can_summarize_its_content_as_text( + self, cxml: str, expected_value: str, comments_part_: Mock + ): + assert Comment(cast(CT_Comment, element(cxml)), comments_part_).text == expected_value + def it_provides_access_to_the_paragraphs_it_contains(self, comments_part_: Mock): comment_elm = cast( CT_Comment, From 4fbe1f684e08aa7eebb0ce6bfedfce512b5c95a2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 11 Jun 2025 21:07:55 -0700 Subject: [PATCH 54/56] docs: add Comments docs - developer/analysis docs - user docs - API docs --- docs/_static/img/comment-parts.png | Bin 0 -> 30058 bytes docs/api/comments.rst | 27 ++ docs/conf.py | 6 +- docs/dev/analysis/features/comments.rst | 419 ++++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + docs/index.rst | 2 + docs/user/comments.rst | 168 ++++++++++ pyproject.toml | 26 +- src/docx/comments.py | 2 +- src/docx/document.py | 6 +- src/docx/templates/default-comments.xml | 11 +- uv.lock | 303 +++++++++++------ 12 files changed, 846 insertions(+), 125 deletions(-) create mode 100644 docs/_static/img/comment-parts.png create mode 100644 docs/api/comments.rst create mode 100644 docs/dev/analysis/features/comments.rst create mode 100644 docs/user/comments.rst diff --git a/docs/_static/img/comment-parts.png b/docs/_static/img/comment-parts.png new file mode 100644 index 0000000000000000000000000000000000000000..c7db1be54bbbb04abf5286053f9aaea94c26eab9 GIT binary patch literal 30058 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 PY)xeab@|fA#sU8aWL~ea literal 0 HcmV?d00001 diff --git a/docs/api/comments.rst b/docs/api/comments.rst new file mode 100644 index 000000000..a54ecc9ce --- /dev/null +++ b/docs/api/comments.rst @@ -0,0 +1,27 @@ + +.. _comments_api: + +Comment-related objects +======================= + +.. currentmodule:: docx.comments + + +|Comments| objects +------------------ + +.. autoclass:: Comments() + :members: + :inherited-members: + :exclude-members: + part + + +|Comment| objects +------------------ + +.. autoclass:: Comment() + :members: + :inherited-members: + :exclude-members: + part diff --git a/docs/conf.py b/docs/conf.py index 60e28fa4c..883ecb81d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -83,8 +83,6 @@ .. |CharacterStyle| replace:: :class:`.CharacterStyle` -.. |Comments| replace:: :class:`.Comments` - .. |Cm| replace:: :class:`.Cm` .. |ColorFormat| replace:: :class:`.ColorFormat` @@ -93,6 +91,10 @@ .. |_Columns| replace:: :class:`._Columns` +.. |Comment| replace:: :class:`.Comment` + +.. |Comments| replace:: :class:`.Comments` + .. |CoreProperties| replace:: :class:`.CoreProperties` .. |datetime| replace:: :class:`.datetime.datetime` diff --git a/docs/dev/analysis/features/comments.rst b/docs/dev/analysis/features/comments.rst new file mode 100644 index 000000000..153079caf --- /dev/null +++ b/docs/dev/analysis/features/comments.rst @@ -0,0 +1,419 @@ + +Comments +======== + +Word allows *comments* to be added to a document. This is an aspect of the *reviewing* +feature-set and is typically used by a second party to provide feedback to the author +without changing the document itself. + +The procedure is simple: + +- You select some range of text with the mouse or Shift+Arrow keys +- You press the *New Comment* button (Review toolbar) +- You type or paste in your comment + +.. image:: /_static/img/comment-parts.png + +**Comment Anatomy.** Each comment has two parts, the *comment-reference* and the +*comment-content*: + +The *comment-refererence*, sometimes *comment-anchor*, is the text you selected before +pressing the *New Comment* button. It is a *range* in the document content delimited by +a start marker and an end marker, and containing the *id* of the comment that refers to +it. + +The *comment-content* is whatever content you typed or pasted in. The content for each +comment is stored in the separate *comments-part* (part-name ``word/comments.xml``) as a +distinct comment object. Each comment has a unique id, allowing a comment reference to +be associated with its content and vice versa. + +**Comment Reference.** The comment-reference is a *range*. A range must both start and +end at an even *run* boundary. Intuitively, a range corresponds to a *selection* of text +in the Word UI, one formed by dragging with the mouse or using the *Shift-Arrow* keys. + +In general a range can span "run containers", such as paragraphs, such that the range +begins in one paragraph and ends in a later paragraph. However, a range must enclose +*contiguous* runs, such that a range that contains only two vertically adjacent cells in +a multi-column table is not possible (even though such a selection with the mouse is +possible). + +**Comment Content.** Interestingly, although commonly used to contain a single line of +plain text, the comment-content can contain essentially any content that can appear in +the document body. This includes rich text with emphasis, runs with a different typeface +and size, both paragraph and character styles, hyperlinks, images, and tables. Note that +tables do not appear in the comment as displayed in the *comment-sidebar* although they +do apper in the *reviewing-pane*. + +**Comment Metadata.** Each comment can be assigned *author*, *initals*, and *date* +metadata. In Word, these fields are assigned automatically based on values in ``Settings +> User`` of the installed Word application. These may be configured automatically in an +enterprise installation, based on the user account, but by default they are empty. + +*author* metadata is required, although silently assigned the empty string by Word if +the user name is not configured. *initials* is optional, but always set by Word, to the +empty string if not configured. *date* is also optional, but always set by Word to the +date and time the comment was added (seconds resolution, UTC). + +**Additional Features.** Later versions of Word allow a comment to be *resolved*. A +comment in this state will appear grayed-out in the Word UI. Later versions of Word also +allow a comment to be *replied to*, forming a *comment thread*. Neither of these +features is supported by the initial implementation of comments in *python-docx*. + +The resolved-status and replies features are implemented as *extensions* and involve two +additional comment-related parts: + +- `commentsExtended.xml` - contains completion (resolved) status and parent-id for + threading comment responses; keys to `w15:paraId` of comment paragraph in + `comments.xml` +- `commentsIds.xml` - maps `w16cid:paraId` to `w16cid:durableId`, not sure what that is + exactly. + +**Applicability.** Note that comments cannot be added to a header or footer and cannot +be nested inside a comment itself. In general the *python-docx* API will not allow these +operations but if you outsmart it then the resulting comment will either be silently +removed or trigger a repair error when the document is loaded by Word. + + +Word Behavior +------------- + +- A DOCX package does not contain a ``comments.xml`` part by default. It is added to the + package when the first comment is added to the document. + +- A newly-created comment contains a single paragraph + +- Word starts `w:id` at 0 and increments from there. It appears to use a + `max(comment_ids) + 1` algorithm rather than aggressively filling in id numbering + gaps. + +- Word-behavior: looks like Word doesn't allow a "zero-length" comment reference; if you + insert a comment when no text is selected, the word prior to the insertion-point is + selected. + +- Word allows a comment to be applied to a range that starts before any character and + ends after any later character. However, the XML range-markers can only be placed + between runs. Word accommodates this be breaking runs as necessary to start and stop + at the desired character positions. + + +MS API +------ + +.. highlight:: python + +**Document**:: + + Document.Comments + +**Comments** + +https://learn.microsoft.com/en-us/office/vba/api/word.comments:: + + Comments.Add(Range, Text) -> Comment + + # -- retrieve comment by array idx, not comment_id key -- + Comments.Item(idx: Long) -> Comment + + Comments.Count() -> Long + + # -- restrict visible comments to those by a particular reviewer + Comments.ShowBy = "Travis McGuillicuddy" + +**Comment** + +https://learn.microsoft.com/en-us/office/vba/api/word.comment:: + + # -- delete comment and all replies to it -- + Comment.DeleteRecursively() -> void + + # -- open OLE object embedded in comment for editing -- + Comment.Edit() -> void + + # -- get the "parent" comment when this comment is a reply -- + Comment.Ancestor() -> Comment | Nothing + + # -- author of this comment, with email and name fields -- + Comment.Contact -> CoAuthor + + Comment.Date -> Date + Comment.Done -> bool + Comment.IsInk -> bool + + # -- content of the comment, contrast with `Reference` below -- + Comment.Range -> Range + + # -- content within document this comment refers to -- + Comment.Reference -> Range + + Comment.Replies -> Comments + + # -- described in API docs like the same thing as `Reference` -- + Comment.Scope -> Range + + +Candidate Protocol +------------------ + +.. highlight:: python + +The critical required reference for adding a comment is the *range* referred to by the +comment; i.e. the "selection" of text that is being commented on. Because this range +must start and end at an even run boundary, it is enough to specify the first and last +run in the range, where a single run can be both the start and end run:: + + >>> paragraph = document.add_paragraph("Hello, world!") + >>> document.add_comment( + ... runs=paragraph.runs, + ... text="I have this to say about that" + ... author="Steve Canny", + ... initials="SC", + ... ) + + +A single run can be provided when that is more convenient:: + + >>> paragraph = document.add_paragraph("Summary: ") + >>> run = paragraph.add_run("{{place-summary-here}} + >>> document.add_comment( + ... run, text="The AI model will replace this placeholder with a summary" + ... ) + + +Note that `author` and `initials` are optional parameters; both default to the empty +string. + +`text` is also an optional parameter and also defaults to the empty string. Omitting a +`text` argument (or passing `text=""`) produces a comment containing a single paragraph +you can immediately add runs to and add additional paragraphs after: + + >>> paragraph = document.add_paragraph("Summary: ") + >>> run = paragraph.add_run("{{place-summary-here}}") + >>> comment = document.add_comment(run) + >>> paragraph = comment.paragraphs[0] + >>> paragraph.add_run("The ") + >>> paragraph.add_run("AI model").bold = True + >>> paragraph.add_run(" will replace this placeholder with a ") + >>> paragraph.add_run("summary").bold = True + + +A method directly on |Run| may also be convenient, since you will always have the first +run of the range in hand when adding a comment but may not have ready access to the +``document`` object:: + + >>> runs = find_sequence_of_one_or_more_runs_to_comment_on() + >>> runs[0].add_comment( + ... last_run=runs[-1], + ... text="The AI model will replace this placeholder with a summary", + ... ) + + +However, in this situation we would need to qualify the runs as being inside the +document part and not in a header or footer or comment, and perhaps other invalid +comment locations. I believe comments can be applied to footnotes and endnotes though. + + +Specimen XML +------------ + +.. highlight:: xml + +``comments.xml`` (namespace declarations may vary):: + + + + > + + + + + + + + + + I have this to say about that + + + + + + +Comment reference in document body:: + + + + + Hello, world! + + + + + + + + + + + +**Notes** + +- `w:comment` is a *block-item* container, and can contain any content that can appear + in a document body or table cell, including both paragraphs and tables (and whatever + can go inside those, like images, hyperlinks, etc. + +- Word places the `w:annotationRef`-containing run as the first run in the first + paragraph of the comment. I haven't been able to detect any behavior change caused by + leaving this out or placing it elsewhere in the comment content. + +- Relationships referenced from within `w:comment` content are relationships *from the + comments part* to the image part, hyperlink, etc. + +- `w:commentRangeStart` and `w:commentRangeEnd` elements are *optional*. The + authoritative position of the comment is the required `w:commentReference` element. + This means the *ending* location of a comment anchor can be efficiently found using + XPath. + + +Schema Excerpt +-------------- + +**Notes:** + +- `commentRangeStart` and `commentRangeEnd` are both type `CT_MarkupRange` and both + belong to `EG_RunLevelElts` (peers of `w:r`) which gives them their positioning in the + document structure. + +- These two markers can occur at the *block* level, at the *run* level, or at the *table + row* or *cell* level. However Word only seems to use them as peers of `w:r`. These can + occur as a sibling to: + + - a *paragraph* (`w:p`) + - a *table* (`w:tbl`) + - a *run* (`w:r`) + - a *table row* (`w:tr`) + - a *table cell* (`w:tc`) + +.. code-block:: xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index b32bf5cc1..25bf5fb4e 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :titlesonly: + features/comments features/header features/settings features/text/index diff --git a/docs/index.rst b/docs/index.rst index 1b1029787..aee0acfbf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -81,6 +81,7 @@ User Guide user/api-concepts user/styles-understanding user/styles-using + user/comments user/shapes @@ -96,6 +97,7 @@ API Documentation api/text api/table api/section + api/comments api/shape api/dml api/shared diff --git a/docs/user/comments.rst b/docs/user/comments.rst new file mode 100644 index 000000000..869d6f5f1 --- /dev/null +++ b/docs/user/comments.rst @@ -0,0 +1,168 @@ +.. _comments: + +Working with Comments +===================== + +Word allows *comments* to be added to a document. This is an aspect of the *reviewing* +feature-set and is typically used by a second party to provide feedback to the author +without changing the document itself. + +The procedure is simple: + +- You select some range of text with the mouse or Shift+Arrow keys +- You press the *New Comment* button (Review toolbar) +- You type or paste in your comment + +.. image:: /_static/img/comment-parts.png + +A comment can only be added to the main document. A comment cannot be added in a header, +a footer, or within a comment. A comment _can_ be added to a footnote or endnote, but +those are not yet supported by *python-docx*. + +**Comment Anatomy.** Each comment has two parts, the *comment-reference* and the +*comment-content*: + +The **comment-refererence**, sometimes *comment-anchor*, is the text in the main +document you selected before pressing the *New Comment* button. It is a so-called +*range* in the main document that starts at the first selected character and ends after +the last one. + +The **comment-content**, sometimes just *comment*, is whatever content you typed or +pasted in. The content for each comment is stored in a separate comment object, and +these comment objects are stored in a separate *comments-part* (part-name +``word/comments.xml``), not in the main document. Each comment is assigned a unique id +when it is created, allowing the comment reference to be associated with its content and +vice versa. + +**Comment Reference.** The comment-reference is a *range*. A range must both start and +end at an even *run* boundary. Intuitively, a range corresponds to a *selection* of text +in the Word UI, one formed by dragging with the mouse or using the *Shift-Arrow* keys. + +In the XML, this range is delimited by a start marker `` and an +end marker ``, both of which contain the *id* of the comment they +delimit. The start marker appears before the run starting with the first character of +the range and the end marker appears immediately after the run ending with the last +character of the range. Adding a comment that references an arbitrary range of text in +an existing document may require splitting runs on the desired character boundaries. + +In general a range can span paragraphs, such that the range begins in one paragraph and +ends in a later paragraph. However, a range must enclose *contiguous* runs, such that a +range that contains only two vertically adjacent cells in a multi-column table is not +possible (even though Word allows such a selection with the mouse). + +**Comment Content.** Interestingly, although commonly used to contain a single line of +plain text, the comment-content can contain essentially any content that can appear in +the document body. This includes rich text with emphasis, runs with a different typeface +and size, both paragraph and character styles, hyperlinks, images, and tables. Note that +tables do not appear in the comment as displayed in the *comment-sidebar* although they +do apper in the *reviewing-pane*. + +**Comment Metadata.** Each comment can be assigned *author*, *initals*, and *date* +metadata. In Word, these fields are assigned automatically based on values in ``Settings +> User`` of the installed Word application. These might be configured automatically in +an enterprise installation, based on the user account, but by default they are empty. + +*author* metadata is required, although silently assigned the empty string by Word if +the user name is not configured. *initials* is optional, but always set by Word, to the +empty string if not configured. *date* is also optional, but always set by Word to the +UTC date and time the comment was added, with seconds resolution (no milliseconds or +microseconds). + +**Additional Features.** Later versions of Word allow a comment to be *resolved*. A +comment in this state will appear grayed-out in the Word UI. Later versions of Word also +allow a comment to be *replied to*, forming a *comment thread*. Neither of these +features is supported by the initial implementation of comments in *python-docx*. + +**Applicability.** Note that comments cannot be added to a header or footer and cannot +be nested inside a comment itself. In general the *python-docx* API will not allow these +operations but if you outsmart it then the resulting comment will either be silently +removed or trigger a repair error when the document is loaded by Word. + + +Adding a Comment +---------------- + +A simple example is adding a comment to a paragraph:: + + >>> from docx import Document + >>> document = Document() + >>> paragraph = document.add_paragraph("Hello, world!") + + >>> comment = document.add_comment( + ... runs=paragraph.runs, + ... text="I have this to say about that" + ... author="Steve Canny", + ... initials="SC", + ... ) + >>> comment + + >>> comment.id + 0 + >>> comment.author + 'Steve Canny' + >>> comment.initials + 'SC' + >>> comment.date + datetime.datetime(2025, 6, 11, 20, 42, 30, 0, tzinfo=datetime.timezone.utc) + >>> comment.text + 'I have this to say about that' + +The API documentation for :meth:`.Document.add_comment` provides further details. + + +Accessing and using the Comments collection +------------------------------------------- + +The comments collection is accessed via the :attr:`.Document.comments` property:: + + >>> comments = document.comments + >>> comments + + >>> len(comments) + 1 + +The comments collection supports random access to a comment by its id:: + + >>> comment = comments.get(0) + >>> comment + + + +Adding rich content to a comment +-------------------------------- + +A comment is a _block-item container_, just like the document body or a table cell, so +it can contain any content that can appear in those places. It does not contain +page-layout sections and cannot contain a comment reference, but it can contain multiple +paragraphs and/or tables, and runs within paragraphs can have emphasis such as bold or +italic, and have images or hyperlinks. + +A comment created with `text=""` will contain a single paragraph with a single empty run +containing the so-called *annotation reference* but no text. It's probably best to leave +this run as it is but you can freely add additional runs to the paragraph that contain +whatever content you like. + +The methods for adding this content are the same as those used for the document and +table cells:: + + >>> paragraph = document.add_paragraph("The rain in Spain.") + >>> comment = document.add_comment( + ... runs=paragraph.runs, + ... text="", + ... ) + >>> cmt_para = comment.paragraphs[0] + >>> cmt_para.add_run("Please finish this thought. I believe it should be ") + >>> cmt_para.add_run("falls mainly in the plain.").bold = True + + +Updating comment metadata +------------------------- + +The author and initials metadata can be updated as desired:: + + >>> comment.author = "John Smith" + >>> comment.initials = "JS" + >>> comment.author + 'John Smith' + >>> comment.initials + 'JS' diff --git a/pyproject.toml b/pyproject.toml index 7c343f2e2..bb347f8d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,22 @@ dynamic = ["version"] keywords = ["docx", "office", "openxml", "word"] license = { text = "MIT" } readme = "README.md" -requires-python = ">=3.9" +# requires-python = ">=3.9" +requires-python = ">=3.9,<3.10" + +[dependency-groups] +dev = [ + "Jinja2==2.11.3", + "MarkupSafe==0.23", + "Sphinx==1.8.6", + "alabaster<0.7.14", + "behave>=1.2.6", + "pyparsing>=3.2.3", + "pyright>=1.1.401", + "pytest>=8.4.0", + "ruff>=0.11.13", + "types-lxml-multi-subclass>=2025.3.30", +] [project.urls] Changelog = "https://github.com/python-openxml/python-docx/blob/master/HISTORY.rst" @@ -109,12 +124,3 @@ known-local-folder = ["helpers"] [tool.setuptools.dynamic] version = {attr = "docx.__version__"} -[dependency-groups] -dev = [ - "behave>=1.2.6", - "pyparsing>=3.2.3", - "pyright>=1.1.401", - "pytest>=8.4.0", - "ruff>=0.11.13", - "types-lxml-multi-subclass>=2025.3.30", -] diff --git a/src/docx/comments.py b/src/docx/comments.py index 9b69cbcec..8ea195224 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -150,7 +150,7 @@ def text(self) -> str: Only content in paragraphs is included and of course all emphasis and styling is stripped. - Paragraph boundaries are indicated with a newline ("\n") + Paragraph boundaries are indicated with a newline (`"\\\\n"`) """ return "\n".join(p.text for p in self.paragraphs) diff --git a/src/docx/document.py b/src/docx/document.py index 1168c4ae8..73757b46d 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -39,7 +39,11 @@ def __init__(self, element: CT_Document, part: DocumentPart): self.__body = None def add_comment( - self, runs: Run | Sequence[Run], text: str, author: str = "", initials: str | None = "" + self, + runs: Run | Sequence[Run], + text: str | None = "", + author: str = "", + initials: str | None = "", ) -> Comment: """Add a comment to the document, anchored to the specified runs. diff --git a/src/docx/templates/default-comments.xml b/src/docx/templates/default-comments.xml index 2afdda20b..2a36ca987 100644 --- a/src/docx/templates/default-comments.xml +++ b/src/docx/templates/default-comments.xml @@ -1,5 +1,12 @@ + xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" + xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" + xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" +/> diff --git a/uv.lock b/uv.lock index bbef867c8..da04bfabf 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,24 @@ version = 1 revision = 1 -requires-python = ">=3.9" +requires-python = "==3.9.*" + +[[package]] +name = "alabaster" +version = "0.7.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/71/a8ee96d1fd95ca04a0d2e2d9c4081dac4c2d2b12f7ddb899c8cb9bfd1532/alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2", size = 11454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/88/c7083fc61120ab661c5d0b82cb77079fc1429d3f913a456c1c82cf4658f7/alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3", size = 13857 }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, +] [[package]] name = "beautifulsoup4" @@ -29,6 +47,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/6c/ec9169548b6c4cb877aaa6773408ca08ae2a282805b958dbc163cb19822d/behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c", size = 136779 }, ] +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671 }, + { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744 }, + { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993 }, + { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382 }, + { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536 }, + { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349 }, + { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365 }, + { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499 }, + { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735 }, + { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786 }, + { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203 }, + { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436 }, + { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772 }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -47,18 +96,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786 }, ] +[[package]] +name = "docutils" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/17/559b4d020f4b46e0287a2eddf2d8ebf76318fd3bd495f1625414b052fdc9/docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", size = 2016138 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/5e/6003a0d1f37725ec2ebd4046b657abb9372202655f96e76795dca8c0063c/docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61", size = 575533 }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, ] +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -68,80 +144,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, ] +[[package]] +name = "jinja2" +version = "2.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/e7/65300e6b32e69768ded990494809106f87da1d436418d5f1367ed3966fd7/Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6", size = 257589 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/c2/1eece8c95ddbc9b1aeb64f5783a9e07a286de42191b7204d67b7496ddf35/Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", size = 125699 }, +] + [[package]] name = "lxml" version = "5.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/1f/a3b6b74a451ceb84b471caa75c934d2430a4d84395d38ef201d539f38cd1/lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c", size = 8076838 }, - { url = "https://files.pythonhosted.org/packages/36/af/a567a55b3e47135b4d1f05a1118c24529104c003f95851374b3748139dc1/lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7", size = 4381827 }, - { url = "https://files.pythonhosted.org/packages/50/ba/4ee47d24c675932b3eb5b6de77d0f623c2db6dc466e7a1f199792c5e3e3a/lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf", size = 5204098 }, - { url = "https://files.pythonhosted.org/packages/f2/0f/b4db6dfebfefe3abafe360f42a3d471881687fd449a0b86b70f1f2683438/lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28", size = 4930261 }, - { url = "https://files.pythonhosted.org/packages/0b/1f/0bb1bae1ce056910f8db81c6aba80fec0e46c98d77c0f59298c70cd362a3/lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609", size = 5529621 }, - { url = "https://files.pythonhosted.org/packages/21/f5/e7b66a533fc4a1e7fa63dd22a1ab2ec4d10319b909211181e1ab3e539295/lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4", size = 4983231 }, - { url = "https://files.pythonhosted.org/packages/11/39/a38244b669c2d95a6a101a84d3c85ba921fea827e9e5483e93168bf1ccb2/lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7", size = 5084279 }, - { url = "https://files.pythonhosted.org/packages/db/64/48cac242347a09a07740d6cee7b7fd4663d5c1abd65f2e3c60420e231b27/lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f", size = 4927405 }, - { url = "https://files.pythonhosted.org/packages/98/89/97442835fbb01d80b72374f9594fe44f01817d203fa056e9906128a5d896/lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997", size = 5550169 }, - { url = "https://files.pythonhosted.org/packages/f1/97/164ca398ee654eb21f29c6b582685c6c6b9d62d5213abc9b8380278e9c0a/lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c", size = 5062691 }, - { url = "https://files.pythonhosted.org/packages/d0/bc/712b96823d7feb53482d2e4f59c090fb18ec7b0d0b476f353b3085893cda/lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b", size = 5133503 }, - { url = "https://files.pythonhosted.org/packages/d4/55/a62a39e8f9da2a8b6002603475e3c57c870cd9c95fd4b94d4d9ac9036055/lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b", size = 4999346 }, - { url = "https://files.pythonhosted.org/packages/ea/47/a393728ae001b92bb1a9e095e570bf71ec7f7fbae7688a4792222e56e5b9/lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563", size = 5627139 }, - { url = "https://files.pythonhosted.org/packages/5e/5f/9dcaaad037c3e642a7ea64b479aa082968de46dd67a8293c541742b6c9db/lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5", size = 5465609 }, - { url = "https://files.pythonhosted.org/packages/a7/0a/ebcae89edf27e61c45023005171d0ba95cb414ee41c045ae4caf1b8487fd/lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776", size = 5192285 }, - { url = "https://files.pythonhosted.org/packages/42/ad/cc8140ca99add7d85c92db8b2354638ed6d5cc0e917b21d36039cb15a238/lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7", size = 3477507 }, - { url = "https://files.pythonhosted.org/packages/e9/39/597ce090da1097d2aabd2f9ef42187a6c9c8546d67c419ce61b88b336c85/lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250", size = 3805104 }, - { url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240 }, - { url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685 }, - { url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164 }, - { url = "https://files.pythonhosted.org/packages/f9/94/bbc66e42559f9d04857071e3b3d0c9abd88579367fd2588a4042f641f57e/lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df", size = 4746206 }, - { url = "https://files.pythonhosted.org/packages/66/95/34b0679bee435da2d7cae895731700e519a8dfcab499c21662ebe671603e/lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e", size = 5342144 }, - { url = "https://files.pythonhosted.org/packages/e0/5d/abfcc6ab2fa0be72b2ba938abdae1f7cad4c632f8d552683ea295d55adfb/lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44", size = 4825124 }, - { url = "https://files.pythonhosted.org/packages/5a/78/6bd33186c8863b36e084f294fc0a5e5eefe77af95f0663ef33809cc1c8aa/lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba", size = 4876520 }, - { url = "https://files.pythonhosted.org/packages/3b/74/4d7ad4839bd0fc64e3d12da74fc9a193febb0fae0ba6ebd5149d4c23176a/lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba", size = 4765016 }, - { url = "https://files.pythonhosted.org/packages/24/0d/0a98ed1f2471911dadfc541003ac6dd6879fc87b15e1143743ca20f3e973/lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c", size = 5362884 }, - { url = "https://files.pythonhosted.org/packages/48/de/d4f7e4c39740a6610f0f6959052b547478107967362e8424e1163ec37ae8/lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8", size = 4902690 }, - { url = "https://files.pythonhosted.org/packages/07/8c/61763abd242af84f355ca4ef1ee096d3c1b7514819564cce70fd18c22e9a/lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86", size = 4944418 }, - { url = "https://files.pythonhosted.org/packages/f9/c5/6d7e3b63e7e282619193961a570c0a4c8a57fe820f07ca3fe2f6bd86608a/lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056", size = 4827092 }, - { url = "https://files.pythonhosted.org/packages/71/4a/e60a306df54680b103348545706a98a7514a42c8b4fbfdcaa608567bb065/lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7", size = 5418231 }, - { url = "https://files.pythonhosted.org/packages/27/f2/9754aacd6016c930875854f08ac4b192a47fe19565f776a64004aa167521/lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd", size = 5261798 }, - { url = "https://files.pythonhosted.org/packages/38/a2/0c49ec6941428b1bd4f280650d7b11a0f91ace9db7de32eb7aa23bcb39ff/lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751", size = 4988195 }, - { url = "https://files.pythonhosted.org/packages/7a/75/87a3963a08eafc46a86c1131c6e28a4de103ba30b5ae903114177352a3d7/lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4", size = 3474243 }, - { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197 }, - { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392 }, - { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103 }, - { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224 }, - { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913 }, - { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441 }, - { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580 }, - { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493 }, - { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679 }, - { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691 }, - { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075 }, - { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680 }, - { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253 }, - { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651 }, - { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315 }, - { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149 }, - { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095 }, - { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086 }, - { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613 }, - { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008 }, - { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915 }, - { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890 }, - { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644 }, - { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817 }, - { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916 }, - { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274 }, - { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757 }, - { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028 }, - { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487 }, - { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688 }, - { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043 }, - { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569 }, - { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270 }, - { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606 }, { url = "https://files.pythonhosted.org/packages/1e/04/acd238222ea25683e43ac7113facc380b3aaf77c53e7d88c4f544cef02ca/lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e", size = 8082189 }, { url = "https://files.pythonhosted.org/packages/d6/4e/cc7fe9ccb9999cc648492ce970b63c657606aefc7d0fba46b17aa2ba93fb/lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40", size = 4384950 }, { url = "https://files.pythonhosted.org/packages/56/bf/acd219c489346d0243a30769b9d446b71e5608581db49a18c8d91a669e19/lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729", size = 5209823 }, @@ -153,12 +173,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/53/979165f50a853dab1cf3b9e53105032d55f85c5993f94afc4d9a61a22877/lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142", size = 5192346 }, { url = "https://files.pythonhosted.org/packages/17/2b/f37b5ae28949143f863ba3066b30eede6107fc9a503bd0d01677d4e2a1e0/lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6", size = 3478275 }, { url = "https://files.pythonhosted.org/packages/9a/d5/b795a183680126147665a8eeda8e802c180f2f7661aa9a550bba5bcdae63/lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1", size = 3806275 }, - { url = "https://files.pythonhosted.org/packages/c6/b0/e4d1cbb8c078bc4ae44de9c6a79fec4e2b4151b1b4d50af71d799e76b177/lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55", size = 3892319 }, - { url = "https://files.pythonhosted.org/packages/5b/aa/e2bdefba40d815059bcb60b371a36fbfcce970a935370e1b367ba1cc8f74/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740", size = 4211614 }, - { url = "https://files.pythonhosted.org/packages/3c/5f/91ff89d1e092e7cfdd8453a939436ac116db0a665e7f4be0cd8e65c7dc5a/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5", size = 4306273 }, - { url = "https://files.pythonhosted.org/packages/be/7c/8c3f15df2ca534589717bfd19d1e3482167801caedfa4d90a575facf68a6/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37", size = 4208552 }, - { url = "https://files.pythonhosted.org/packages/7d/d8/9567afb1665f64d73fc54eb904e418d1138d7f011ed00647121b4dd60b38/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571", size = 4331091 }, - { url = "https://files.pythonhosted.org/packages/f1/ab/fdbbd91d8d82bf1a723ba88ec3e3d76c022b53c391b0c13cad441cdb8f9e/lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4", size = 3487862 }, { url = "https://files.pythonhosted.org/packages/ad/fb/d19b67e4bb63adc20574ba3476cf763b3514df1a37551084b890254e4b15/lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530", size = 3891034 }, { url = "https://files.pythonhosted.org/packages/c9/5d/6e1033ee0cdb2f9bc93164f9df14e42cb5bbf1bbed3bf67f687de2763104/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6", size = 4207420 }, { url = "https://files.pythonhosted.org/packages/f3/4b/23ac79efc32d913259d66672c5f93daac7750a3d97cdc1c1a9a5d1c1b46c/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877", size = 4305106 }, @@ -167,6 +181,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/25/d381abcfd00102d3304aa191caab62f6e3bcbac93ee248771db6be153dfd/lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987", size = 3486416 }, ] +[[package]] +name = "markupsafe" +version = "0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/41/bae1254e0396c0cc8cf1751cb7d9afc90a602353695af5952530482c963f/MarkupSafe-0.23.tar.gz", hash = "sha256:a4ec1aff59b95a14b45eb2e23761a0179e98319da5a7eb76b56ea8cdc7b871c3", size = 13416 } + [[package]] name = "nodeenv" version = "1.9.1" @@ -253,12 +273,12 @@ version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232 } wheels = [ @@ -275,11 +295,15 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "alabaster" }, { name = "behave" }, + { name = "jinja2" }, + { name = "markupsafe" }, { name = "pyparsing" }, { name = "pyright" }, { name = "pytest" }, { name = "ruff" }, + { name = "sphinx" }, { name = "types-lxml-multi-subclass" }, ] @@ -291,14 +315,33 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "alabaster", specifier = "<0.7.14" }, { name = "behave", specifier = ">=1.2.6" }, + { name = "jinja2", specifier = "==2.11.3" }, + { name = "markupsafe", specifier = "==0.23" }, { name = "pyparsing", specifier = ">=3.2.3" }, { name = "pyright", specifier = ">=1.1.401" }, { name = "pytest", specifier = ">=8.4.0" }, { name = "ruff", specifier = ">=0.11.13" }, + { name = "sphinx", specifier = "==1.8.6" }, { name = "types-lxml-multi-subclass", specifier = ">=2025.3.30" }, ] +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, +] + [[package]] name = "ruff" version = "0.11.13" @@ -324,6 +367,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928 }, ] +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 }, +] + [[package]] name = "six" version = "1.17.0" @@ -333,6 +385,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274 }, +] + [[package]] name = "soupsieve" version = "2.7" @@ -342,42 +403,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677 }, ] +[[package]] +name = "sphinx" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "six" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-websupport" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/74/5cef400220b2f22a4c85540b9ba20234525571b8b851be8a9ac219326a11/Sphinx-1.8.6.tar.gz", hash = "sha256:e096b1b369dbb0fcb95a31ba8c9e1ae98c588e601f08eada032248e1696de4b1", size = 5816141 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/da/e1b65da61267aeb92a76b6b6752430bcc076d98b723687929eb3d2e0d128/Sphinx-1.8.6-py2.py3-none-any.whl", hash = "sha256:5973adbb19a5de30e15ab394ec8bc05700317fa83f122c349dd01804d983720f", size = 3110177 }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072 }, +] + +[[package]] +name = "sphinxcontrib-websupport" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/aa/b03a3f569a52b6f21a579d168083a27036c1f606269e34abdf5b70fe3a2c/sphinxcontrib-websupport-1.2.4.tar.gz", hash = "sha256:4edf0223a0685a7c485ae5a156b6f529ba1ee481a1417817935b20bde1956232", size = 602360 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/e5/2a547830845e6e6e5d97b3246fc1e3ec74cba879c9adc5a8e27f1291bca3/sphinxcontrib_websupport-1.2.4-py2.py3-none-any.whl", hash = "sha256:6fc9287dfc823fe9aa432463edd6cea47fa9ebbf488d7f289b322ffcfca075c7", size = 39924 }, +] + [[package]] name = "tomli" version = "2.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] @@ -398,7 +474,7 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "cssselect" }, { name = "types-html5lib" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b7/3a/7f6d1d3b921404efef20ed1ddc2b6f1333e3f0bc5b91da37874e786ff835/types_lxml_multi_subclass-2025.3.30.tar.gz", hash = "sha256:7ac7a78e592fdba16951668968b21511adda49bbefbc0f130e55501b70e068b4", size = 153188 } wheels = [ @@ -413,3 +489,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d0 wheels = [ { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839 }, ] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, +] From 1fe660198aab18a421c95d019f53b2aa22d2fe2f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 12 Jun 2025 20:57:40 -0700 Subject: [PATCH 55/56] build: small adjustments for tox --- pyproject.toml | 4 +- tox.ini | 2 +- uv.lock | 262 ++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 260 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bb347f8d3..3650ce4d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,8 +30,7 @@ dynamic = ["version"] keywords = ["docx", "office", "openxml", "word"] license = { text = "MIT" } readme = "README.md" -# requires-python = ">=3.9" -requires-python = ">=3.9,<3.10" +requires-python = ">=3.9" [dependency-groups] dev = [ @@ -44,6 +43,7 @@ dev = [ "pyright>=1.1.401", "pytest>=8.4.0", "ruff>=0.11.13", + "tox>=4.26.0", "types-lxml-multi-subclass>=2025.3.30", ] diff --git a/tox.ini b/tox.ini index 37acaa5fa..1f4741b6f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38, py39, py310, py311, py312 +envlist = py39, py310, py311, py312, py313 [testenv] deps = -rrequirements-test.txt diff --git a/uv.lock b/uv.lock index da04bfabf..675fe6777 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 1 -requires-python = "==3.9.*" +requires-python = ">=3.9" [[package]] name = "alabaster" @@ -47,6 +47,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/6c/ec9169548b6c4cb877aaa6773408ca08ae2a282805b958dbc163cb19822d/behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c", size = 136779 }, ] +[[package]] +name = "cachetools" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/b0/f539a1ddff36644c28a61490056e5bae43bd7386d9f9c69beae2d7e7d6d1/cachetools-6.0.0.tar.gz", hash = "sha256:f225782b84438f828328fc2ad74346522f27e5b1440f4e9fd18b20ebfd1aa2cf", size = 30160 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c3/8bb087c903c95a570015ce84e0c23ae1d79f528c349cbc141b5c4e250293/cachetools-6.0.0-py3-none-any.whl", hash = "sha256:82e73ba88f7b30228b5507dce1a1f878498fc669d972aef2dde4f3a3c24f103e", size = 10964 }, +] + [[package]] name = "certifi" version = "2025.4.26" @@ -56,12 +65,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, ] +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 }, +] + [[package]] name = "charset-normalizer" version = "3.4.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } wheels = [ + { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818 }, + { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649 }, + { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045 }, + { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356 }, + { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471 }, + { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317 }, + { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368 }, + { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491 }, + { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695 }, + { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849 }, + { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091 }, + { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445 }, + { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782 }, + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794 }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846 }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350 }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657 }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260 }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164 }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571 }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952 }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959 }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030 }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015 }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106 }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402 }, + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 }, { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671 }, { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744 }, { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993 }, @@ -96,6 +166,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786 }, ] +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + [[package]] name = "docutils" version = "0.17.1" @@ -110,13 +189,22 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, ] +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, +] + [[package]] name = "idna" version = "3.10" @@ -162,6 +250,74 @@ version = "5.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479 } wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/1f/a3b6b74a451ceb84b471caa75c934d2430a4d84395d38ef201d539f38cd1/lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c", size = 8076838 }, + { url = "https://files.pythonhosted.org/packages/36/af/a567a55b3e47135b4d1f05a1118c24529104c003f95851374b3748139dc1/lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7", size = 4381827 }, + { url = "https://files.pythonhosted.org/packages/50/ba/4ee47d24c675932b3eb5b6de77d0f623c2db6dc466e7a1f199792c5e3e3a/lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf", size = 5204098 }, + { url = "https://files.pythonhosted.org/packages/f2/0f/b4db6dfebfefe3abafe360f42a3d471881687fd449a0b86b70f1f2683438/lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28", size = 4930261 }, + { url = "https://files.pythonhosted.org/packages/0b/1f/0bb1bae1ce056910f8db81c6aba80fec0e46c98d77c0f59298c70cd362a3/lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609", size = 5529621 }, + { url = "https://files.pythonhosted.org/packages/21/f5/e7b66a533fc4a1e7fa63dd22a1ab2ec4d10319b909211181e1ab3e539295/lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4", size = 4983231 }, + { url = "https://files.pythonhosted.org/packages/11/39/a38244b669c2d95a6a101a84d3c85ba921fea827e9e5483e93168bf1ccb2/lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7", size = 5084279 }, + { url = "https://files.pythonhosted.org/packages/db/64/48cac242347a09a07740d6cee7b7fd4663d5c1abd65f2e3c60420e231b27/lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f", size = 4927405 }, + { url = "https://files.pythonhosted.org/packages/98/89/97442835fbb01d80b72374f9594fe44f01817d203fa056e9906128a5d896/lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997", size = 5550169 }, + { url = "https://files.pythonhosted.org/packages/f1/97/164ca398ee654eb21f29c6b582685c6c6b9d62d5213abc9b8380278e9c0a/lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c", size = 5062691 }, + { url = "https://files.pythonhosted.org/packages/d0/bc/712b96823d7feb53482d2e4f59c090fb18ec7b0d0b476f353b3085893cda/lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b", size = 5133503 }, + { url = "https://files.pythonhosted.org/packages/d4/55/a62a39e8f9da2a8b6002603475e3c57c870cd9c95fd4b94d4d9ac9036055/lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b", size = 4999346 }, + { url = "https://files.pythonhosted.org/packages/ea/47/a393728ae001b92bb1a9e095e570bf71ec7f7fbae7688a4792222e56e5b9/lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563", size = 5627139 }, + { url = "https://files.pythonhosted.org/packages/5e/5f/9dcaaad037c3e642a7ea64b479aa082968de46dd67a8293c541742b6c9db/lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5", size = 5465609 }, + { url = "https://files.pythonhosted.org/packages/a7/0a/ebcae89edf27e61c45023005171d0ba95cb414ee41c045ae4caf1b8487fd/lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776", size = 5192285 }, + { url = "https://files.pythonhosted.org/packages/42/ad/cc8140ca99add7d85c92db8b2354638ed6d5cc0e917b21d36039cb15a238/lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7", size = 3477507 }, + { url = "https://files.pythonhosted.org/packages/e9/39/597ce090da1097d2aabd2f9ef42187a6c9c8546d67c419ce61b88b336c85/lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250", size = 3805104 }, + { url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240 }, + { url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685 }, + { url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164 }, + { url = "https://files.pythonhosted.org/packages/f9/94/bbc66e42559f9d04857071e3b3d0c9abd88579367fd2588a4042f641f57e/lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df", size = 4746206 }, + { url = "https://files.pythonhosted.org/packages/66/95/34b0679bee435da2d7cae895731700e519a8dfcab499c21662ebe671603e/lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e", size = 5342144 }, + { url = "https://files.pythonhosted.org/packages/e0/5d/abfcc6ab2fa0be72b2ba938abdae1f7cad4c632f8d552683ea295d55adfb/lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44", size = 4825124 }, + { url = "https://files.pythonhosted.org/packages/5a/78/6bd33186c8863b36e084f294fc0a5e5eefe77af95f0663ef33809cc1c8aa/lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba", size = 4876520 }, + { url = "https://files.pythonhosted.org/packages/3b/74/4d7ad4839bd0fc64e3d12da74fc9a193febb0fae0ba6ebd5149d4c23176a/lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba", size = 4765016 }, + { url = "https://files.pythonhosted.org/packages/24/0d/0a98ed1f2471911dadfc541003ac6dd6879fc87b15e1143743ca20f3e973/lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c", size = 5362884 }, + { url = "https://files.pythonhosted.org/packages/48/de/d4f7e4c39740a6610f0f6959052b547478107967362e8424e1163ec37ae8/lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8", size = 4902690 }, + { url = "https://files.pythonhosted.org/packages/07/8c/61763abd242af84f355ca4ef1ee096d3c1b7514819564cce70fd18c22e9a/lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86", size = 4944418 }, + { url = "https://files.pythonhosted.org/packages/f9/c5/6d7e3b63e7e282619193961a570c0a4c8a57fe820f07ca3fe2f6bd86608a/lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056", size = 4827092 }, + { url = "https://files.pythonhosted.org/packages/71/4a/e60a306df54680b103348545706a98a7514a42c8b4fbfdcaa608567bb065/lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7", size = 5418231 }, + { url = "https://files.pythonhosted.org/packages/27/f2/9754aacd6016c930875854f08ac4b192a47fe19565f776a64004aa167521/lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd", size = 5261798 }, + { url = "https://files.pythonhosted.org/packages/38/a2/0c49ec6941428b1bd4f280650d7b11a0f91ace9db7de32eb7aa23bcb39ff/lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751", size = 4988195 }, + { url = "https://files.pythonhosted.org/packages/7a/75/87a3963a08eafc46a86c1131c6e28a4de103ba30b5ae903114177352a3d7/lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4", size = 3474243 }, + { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197 }, + { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392 }, + { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103 }, + { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224 }, + { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913 }, + { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441 }, + { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580 }, + { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493 }, + { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679 }, + { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691 }, + { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075 }, + { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680 }, + { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253 }, + { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651 }, + { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315 }, + { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149 }, + { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095 }, + { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086 }, + { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613 }, + { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008 }, + { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915 }, + { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890 }, + { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644 }, + { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817 }, + { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916 }, + { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274 }, + { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757 }, + { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028 }, + { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487 }, + { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688 }, + { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043 }, + { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569 }, + { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270 }, + { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606 }, { url = "https://files.pythonhosted.org/packages/1e/04/acd238222ea25683e43ac7113facc380b3aaf77c53e7d88c4f544cef02ca/lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e", size = 8082189 }, { url = "https://files.pythonhosted.org/packages/d6/4e/cc7fe9ccb9999cc648492ce970b63c657606aefc7d0fba46b17aa2ba93fb/lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40", size = 4384950 }, { url = "https://files.pythonhosted.org/packages/56/bf/acd219c489346d0243a30769b9d446b71e5608581db49a18c8d91a669e19/lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729", size = 5209823 }, @@ -173,6 +329,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/53/979165f50a853dab1cf3b9e53105032d55f85c5993f94afc4d9a61a22877/lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142", size = 5192346 }, { url = "https://files.pythonhosted.org/packages/17/2b/f37b5ae28949143f863ba3066b30eede6107fc9a503bd0d01677d4e2a1e0/lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6", size = 3478275 }, { url = "https://files.pythonhosted.org/packages/9a/d5/b795a183680126147665a8eeda8e802c180f2f7661aa9a550bba5bcdae63/lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1", size = 3806275 }, + { url = "https://files.pythonhosted.org/packages/c6/b0/e4d1cbb8c078bc4ae44de9c6a79fec4e2b4151b1b4d50af71d799e76b177/lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55", size = 3892319 }, + { url = "https://files.pythonhosted.org/packages/5b/aa/e2bdefba40d815059bcb60b371a36fbfcce970a935370e1b367ba1cc8f74/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740", size = 4211614 }, + { url = "https://files.pythonhosted.org/packages/3c/5f/91ff89d1e092e7cfdd8453a939436ac116db0a665e7f4be0cd8e65c7dc5a/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5", size = 4306273 }, + { url = "https://files.pythonhosted.org/packages/be/7c/8c3f15df2ca534589717bfd19d1e3482167801caedfa4d90a575facf68a6/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37", size = 4208552 }, + { url = "https://files.pythonhosted.org/packages/7d/d8/9567afb1665f64d73fc54eb904e418d1138d7f011ed00647121b4dd60b38/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571", size = 4331091 }, + { url = "https://files.pythonhosted.org/packages/f1/ab/fdbbd91d8d82bf1a723ba88ec3e3d76c022b53c391b0c13cad441cdb8f9e/lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4", size = 3487862 }, { url = "https://files.pythonhosted.org/packages/ad/fb/d19b67e4bb63adc20574ba3476cf763b3514df1a37551084b890254e4b15/lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530", size = 3891034 }, { url = "https://files.pythonhosted.org/packages/c9/5d/6e1033ee0cdb2f9bc93164f9df14e42cb5bbf1bbed3bf67f687de2763104/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6", size = 4207420 }, { url = "https://files.pythonhosted.org/packages/f3/4b/23ac79efc32d913259d66672c5f93daac7750a3d97cdc1c1a9a5d1c1b46c/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877", size = 4305106 }, @@ -227,6 +389,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/b3/f6cc950042bfdbe98672e7c834d930f85920fb7d3359f59096e8d2799617/parse_type-0.6.4-py2.py3-none-any.whl", hash = "sha256:83d41144a82d6b8541127bf212dd76c7f01baff680b498ce8a4d052a7a5bce4c", size = 27442 }, ] +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -254,6 +425,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 }, ] +[[package]] +name = "pyproject-api" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/fd/437901c891f58a7b9096511750247535e891d2d5a5a6eefbc9386a2b41d5/pyproject_api-1.9.1.tar.gz", hash = "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335", size = 22710 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e6/c293c06695d4a3ab0260ef124a74ebadba5f4c511ce3a4259e976902c00b/pyproject_api-1.9.1-py3-none-any.whl", hash = "sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948", size = 13158 }, +] + [[package]] name = "pyright" version = "1.1.401" @@ -273,12 +457,12 @@ version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232 } wheels = [ @@ -304,6 +488,7 @@ dev = [ { name = "pytest" }, { name = "ruff" }, { name = "sphinx" }, + { name = "tox" }, { name = "types-lxml-multi-subclass" }, ] @@ -324,6 +509,7 @@ dev = [ { name = "pytest", specifier = ">=8.4.0" }, { name = "ruff", specifier = ">=0.11.13" }, { name = "sphinx", specifier = "==1.8.6" }, + { name = "tox", specifier = ">=4.26.0" }, { name = "types-lxml-multi-subclass", specifier = ">=2025.3.30" }, ] @@ -454,9 +640,61 @@ version = "2.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] +[[package]] +name = "tox" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "chardet" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/dcec0c00321a107f7f697fd00754c5112572ea6dcacb40b16d8c3eea7c37/tox-4.26.0.tar.gz", hash = "sha256:a83b3b67b0159fa58e44e646505079e35a43317a62d2ae94725e0586266faeca", size = 197260 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/14/f58b4087cf248b18c795b5c838c7a8d1428dfb07cb468dad3ec7f54041ab/tox-4.26.0-py3-none-any.whl", hash = "sha256:75f17aaf09face9b97bd41645028d9f722301e912be8b4c65a3f938024560224", size = 172761 }, +] + [[package]] name = "types-html5lib" version = "1.1.11.20250516" @@ -474,7 +712,7 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "cssselect" }, { name = "types-html5lib" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b7/3a/7f6d1d3b921404efef20ed1ddc2b6f1333e3f0bc5b91da37874e786ff835/types_lxml_multi_subclass-2025.3.30.tar.gz", hash = "sha256:7ac7a78e592fdba16951668968b21511adda49bbefbc0f130e55501b70e068b4", size = 153188 } wheels = [ @@ -498,3 +736,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e wheels = [ { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, ] + +[[package]] +name = "virtualenv" +version = "20.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982 }, +] From e45454602b53e8e572b179ccf1c91093ec9f4ed7 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2025 13:45:03 -0700 Subject: [PATCH 56/56] release: prepare v1.2.0 release --- HISTORY.rst | 8 + Makefile | 17 +-- pyproject.toml | 1 + src/docx/__init__.py | 2 +- uv.lock | 355 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 373 insertions(+), 10 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 0dab17d87..69bba4161 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,13 @@ Release History --------------- +1.2.0 (2025-06-16) +++++++++++++++++++ + +- Add support for comments +- Drop support for Python 3.8, add testing for Python 3.13 + + 1.1.2 (2024-05-01) ++++++++++++++++++ @@ -10,6 +17,7 @@ Release History - Fix #1385 Support use of Part._rels by python-docx-template - Add support and testing for Python 3.12 + 1.1.1 (2024-04-29) ++++++++++++++++++ diff --git a/Makefile b/Makefile index da0d7a4ac..2b2fb4121 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,6 @@ BEHAVE = behave MAKE = make PYTHON = python -BUILD = $(PYTHON) -m build TWINE = $(PYTHON) -m twine .PHONY: accept build clean cleandocs coverage docs install opendocs sdist test @@ -24,10 +23,10 @@ help: @echo " wheel generate a binary distribution into dist/" accept: - $(BEHAVE) --stop + uv run $(BEHAVE) --stop build: - $(BUILD) + uv build clean: # find . -type f -name \*.pyc -exec rm {} \; @@ -38,7 +37,7 @@ cleandocs: $(MAKE) -C docs clean coverage: - py.test --cov-report term-missing --cov=docx tests/ + uv run pytest --cov-report term-missing --cov=docx tests/ docs: $(MAKE) -C docs html @@ -50,16 +49,16 @@ opendocs: open docs/.build/html/index.html sdist: - $(BUILD) --sdist . + uv build --sdist test: - pytest -x + uv run pytest -x test-upload: sdist wheel - $(TWINE) upload --repository testpypi dist/* + uv run $(TWINE) upload --repository testpypi dist/* upload: clean sdist wheel - $(TWINE) upload dist/* + uv run $(TWINE) upload dist/* wheel: - $(BUILD) --wheel . + uv build --wheel diff --git a/pyproject.toml b/pyproject.toml index 3650ce4d1..b3dc0be02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dev = [ "pytest>=8.4.0", "ruff>=0.11.13", "tox>=4.26.0", + "twine>=6.1.0", "types-lxml-multi-subclass>=2025.3.30", ] diff --git a/src/docx/__init__.py b/src/docx/__init__.py index 987e8a267..fd06c84d2 100644 --- a/src/docx/__init__.py +++ b/src/docx/__init__.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from docx.opc.part import Part -__version__ = "1.1.2" +__version__ = "1.2.0" __all__ = ["Document"] diff --git a/uv.lock b/uv.lock index 675fe6777..7888c5298 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181 }, +] + [[package]] name = "beautifulsoup4" version = "4.13.4" @@ -65,6 +74,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, ] +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, +] + [[package]] name = "chardet" version = "5.2.0" @@ -157,6 +215,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "cryptography" +version = "45.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335 }, + { url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487 }, + { url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922 }, + { url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433 }, + { url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163 }, + { url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687 }, + { url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623 }, + { url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447 }, + { url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830 }, + { url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746 }, + { url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456 }, + { url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495 }, + { url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540 }, + { url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052 }, + { url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024 }, + { url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442 }, + { url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038 }, + { url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964 }, + { url = "https://files.pythonhosted.org/packages/c4/b9/357f18064ec09d4807800d05a48f92f3b369056a12f995ff79549fbb31f1/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507", size = 4143732 }, + { url = "https://files.pythonhosted.org/packages/c4/9c/7f7263b03d5db329093617648b9bd55c953de0b245e64e866e560f9aac07/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0", size = 4385424 }, + { url = "https://files.pythonhosted.org/packages/a6/5a/6aa9d8d5073d5acc0e04e95b2860ef2684b2bd2899d8795fc443013e263b/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b", size = 4142438 }, + { url = "https://files.pythonhosted.org/packages/42/1c/71c638420f2cdd96d9c2b287fec515faf48679b33a2b583d0f1eda3a3375/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58", size = 4384622 }, + { url = "https://files.pythonhosted.org/packages/28/9a/a7d5bb87d149eb99a5abdc69a41e4e47b8001d767e5f403f78bfaafc7aa7/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", size = 4146899 }, + { url = "https://files.pythonhosted.org/packages/17/11/9361c2c71c42cc5c465cf294c8030e72fb0c87752bacbd7a3675245e3db3/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", size = 4388900 }, + { url = "https://files.pythonhosted.org/packages/c0/76/f95b83359012ee0e670da3e41c164a0c256aeedd81886f878911581d852f/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", size = 4146422 }, + { url = "https://files.pythonhosted.org/packages/09/ad/5429fcc4def93e577a5407988f89cf15305e64920203d4ac14601a9dc876/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", size = 4388475 }, +] + [[package]] name = "cssselect" version = "1.3.0" @@ -205,6 +300,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, ] +[[package]] +name = "id" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611 }, +] + [[package]] name = "idna" version = "3.10" @@ -223,6 +330,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656 }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -232,6 +351,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825 }, +] + +[[package]] +name = "jaraco-functools" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187 }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, +] + [[package]] name = "jinja2" version = "2.11.3" @@ -244,6 +408,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/c2/1eece8c95ddbc9b1aeb64f5783a9e07a286de42191b7204d67b7496ddf35/Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", size = 125699 }, ] +[[package]] +name = "keyring" +version = "25.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085 }, +] + [[package]] name = "lxml" version = "5.4.0" @@ -343,12 +525,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/25/d381abcfd00102d3304aa191caab62f6e3bcbac93ee248771db6be153dfd/lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987", size = 3486416 }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + [[package]] name = "markupsafe" version = "0.23" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c0/41/bae1254e0396c0cc8cf1751cb7d9afc90a602353695af5952530482c963f/MarkupSafe-0.23.tar.gz", hash = "sha256:a4ec1aff59b95a14b45eb2e23761a0179e98319da5a7eb76b56ea8cdc7b871c3", size = 13416 } +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "more-itertools" +version = "10.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278 }, +] + +[[package]] +name = "nh3" +version = "0.2.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/30/2f81466f250eb7f591d4d193930df661c8c23e9056bdc78e365b646054d8/nh3-0.2.21.tar.gz", hash = "sha256:4990e7ee6a55490dbf00d61a6f476c9a3258e31e711e13713b2ea7d6616f670e", size = 16581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/81/b83775687fcf00e08ade6d4605f0be9c4584cb44c4973d9f27b7456a31c9/nh3-0.2.21-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fcff321bd60c6c5c9cb4ddf2554e22772bb41ebd93ad88171bbbb6f271255286", size = 1297678 }, + { url = "https://files.pythonhosted.org/packages/22/ee/d0ad8fb4b5769f073b2df6807f69a5e57ca9cea504b78809921aef460d20/nh3-0.2.21-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eedcd7d08b0eae28ba47f43fd33a653b4cdb271d64f1aeda47001618348fde", size = 733774 }, + { url = "https://files.pythonhosted.org/packages/ea/76/b450141e2d384ede43fe53953552f1c6741a499a8c20955ad049555cabc8/nh3-0.2.21-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d426d7be1a2f3d896950fe263332ed1662f6c78525b4520c8e9861f8d7f0d243", size = 760012 }, + { url = "https://files.pythonhosted.org/packages/97/90/1182275db76cd8fbb1f6bf84c770107fafee0cb7da3e66e416bcb9633da2/nh3-0.2.21-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d67709bc0d7d1f5797b21db26e7a8b3d15d21c9c5f58ccfe48b5328483b685b", size = 923619 }, + { url = "https://files.pythonhosted.org/packages/29/c7/269a7cfbec9693fad8d767c34a755c25ccb8d048fc1dfc7a7d86bc99375c/nh3-0.2.21-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55823c5ea1f6b267a4fad5de39bc0524d49a47783e1fe094bcf9c537a37df251", size = 1000384 }, + { url = "https://files.pythonhosted.org/packages/68/a9/48479dbf5f49ad93f0badd73fbb48b3d769189f04c6c69b0df261978b009/nh3-0.2.21-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:818f2b6df3763e058efa9e69677b5a92f9bc0acff3295af5ed013da544250d5b", size = 918908 }, + { url = "https://files.pythonhosted.org/packages/d7/da/0279c118f8be2dc306e56819880b19a1cf2379472e3b79fc8eab44e267e3/nh3-0.2.21-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3b5c58161e08549904ac4abd450dacd94ff648916f7c376ae4b2c0652b98ff9", size = 909180 }, + { url = "https://files.pythonhosted.org/packages/26/16/93309693f8abcb1088ae143a9c8dbcece9c8f7fb297d492d3918340c41f1/nh3-0.2.21-cp313-cp313t-win32.whl", hash = "sha256:637d4a10c834e1b7d9548592c7aad760611415fcd5bd346f77fd8a064309ae6d", size = 532747 }, + { url = "https://files.pythonhosted.org/packages/a2/3a/96eb26c56cbb733c0b4a6a907fab8408ddf3ead5d1b065830a8f6a9c3557/nh3-0.2.21-cp313-cp313t-win_amd64.whl", hash = "sha256:713d16686596e556b65e7f8c58328c2df63f1a7abe1277d87625dcbbc012ef82", size = 528908 }, + { url = "https://files.pythonhosted.org/packages/ba/1d/b1ef74121fe325a69601270f276021908392081f4953d50b03cbb38b395f/nh3-0.2.21-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a772dec5b7b7325780922dd904709f0f5f3a79fbf756de5291c01370f6df0967", size = 1316133 }, + { url = "https://files.pythonhosted.org/packages/b8/f2/2c7f79ce6de55b41e7715f7f59b159fd59f6cdb66223c05b42adaee2b645/nh3-0.2.21-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d002b648592bf3033adfd875a48f09b8ecc000abd7f6a8769ed86b6ccc70c759", size = 758328 }, + { url = "https://files.pythonhosted.org/packages/6d/ad/07bd706fcf2b7979c51b83d8b8def28f413b090cf0cb0035ee6b425e9de5/nh3-0.2.21-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a5174551f95f2836f2ad6a8074560f261cf9740a48437d6151fd2d4d7d617ab", size = 747020 }, + { url = "https://files.pythonhosted.org/packages/75/99/06a6ba0b8a0d79c3d35496f19accc58199a1fb2dce5e711a31be7e2c1426/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b8d55ea1fc7ae3633d758a92aafa3505cd3cc5a6e40470c9164d54dff6f96d42", size = 944878 }, + { url = "https://files.pythonhosted.org/packages/79/d4/dc76f5dc50018cdaf161d436449181557373869aacf38a826885192fc587/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae319f17cd8960d0612f0f0ddff5a90700fa71926ca800e9028e7851ce44a6f", size = 903460 }, + { url = "https://files.pythonhosted.org/packages/cd/c3/d4f8037b2ab02ebf5a2e8637bd54736ed3d0e6a2869e10341f8d9085f00e/nh3-0.2.21-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ca02ac6f27fc80f9894409eb61de2cb20ef0a23740c7e29f9ec827139fa578", size = 839369 }, + { url = "https://files.pythonhosted.org/packages/11/a9/1cd3c6964ec51daed7b01ca4686a5c793581bf4492cbd7274b3f544c9abe/nh3-0.2.21-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f77e62aed5c4acad635239ac1290404c7e940c81abe561fd2af011ff59f585", size = 739036 }, + { url = "https://files.pythonhosted.org/packages/fd/04/bfb3ff08d17a8a96325010ae6c53ba41de6248e63cdb1b88ef6369a6cdfc/nh3-0.2.21-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:087ffadfdcd497658c3adc797258ce0f06be8a537786a7217649fc1c0c60c293", size = 768712 }, + { url = "https://files.pythonhosted.org/packages/9e/aa/cfc0bf545d668b97d9adea4f8b4598667d2b21b725d83396c343ad12bba7/nh3-0.2.21-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ac7006c3abd097790e611fe4646ecb19a8d7f2184b882f6093293b8d9b887431", size = 930559 }, + { url = "https://files.pythonhosted.org/packages/78/9d/6f5369a801d3a1b02e6a9a097d56bcc2f6ef98cffebf03c4bb3850d8e0f0/nh3-0.2.21-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6141caabe00bbddc869665b35fc56a478eb774a8c1dfd6fba9fe1dfdf29e6efa", size = 1008591 }, + { url = "https://files.pythonhosted.org/packages/a6/df/01b05299f68c69e480edff608248313cbb5dbd7595c5e048abe8972a57f9/nh3-0.2.21-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:20979783526641c81d2f5bfa6ca5ccca3d1e4472474b162c6256745fbfe31cd1", size = 925670 }, + { url = "https://files.pythonhosted.org/packages/3d/79/bdba276f58d15386a3387fe8d54e980fb47557c915f5448d8c6ac6f7ea9b/nh3-0.2.21-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a7ea28cd49293749d67e4fcf326c554c83ec912cd09cd94aa7ec3ab1921c8283", size = 917093 }, + { url = "https://files.pythonhosted.org/packages/e7/d8/c6f977a5cd4011c914fb58f5ae573b071d736187ccab31bfb1d539f4af9f/nh3-0.2.21-cp38-abi3-win32.whl", hash = "sha256:6c9c30b8b0d291a7c5ab0967ab200598ba33208f754f2f4920e9343bdd88f79a", size = 537623 }, + { url = "https://files.pythonhosted.org/packages/23/fc/8ce756c032c70ae3dd1d48a3552577a325475af2a2f629604b44f571165c/nh3-0.2.21-cp38-abi3-win_amd64.whl", hash = "sha256:bb0014948f04d7976aabae43fcd4cb7f551f9f8ce785a4c9ef66e6c2590f8629", size = 535283 }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -407,6 +650,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + [[package]] name = "pygments" version = "2.19.1" @@ -489,6 +741,7 @@ dev = [ { name = "ruff" }, { name = "sphinx" }, { name = "tox" }, + { name = "twine" }, { name = "types-lxml-multi-subclass" }, ] @@ -510,9 +763,33 @@ dev = [ { name = "ruff", specifier = ">=0.11.13" }, { name = "sphinx", specifier = "==1.8.6" }, { name = "tox", specifier = ">=4.26.0" }, + { name = "twine", specifier = ">=6.1.0" }, { name = "types-lxml-multi-subclass", specifier = ">=2025.3.30" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, +] + +[[package]] +name = "readme-renderer" +version = "43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/b5/536c775084d239df6345dccf9b043419c7e3308bc31be4c7882196abc62e/readme_renderer-43.0.tar.gz", hash = "sha256:1818dd28140813509eeed8d62687f7cd4f7bad90d4db586001c5dc09d4fde311", size = 31768 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/be/3ea20dc38b9db08387cf97997a85a7d51527ea2057d71118feb0aa8afa55/readme_renderer-43.0-py3-none-any.whl", hash = "sha256:19db308d86ecd60e5affa3b2a98f017af384678c63c88e5d4556a380e674f3f9", size = 13301 }, +] + [[package]] name = "requests" version = "2.32.4" @@ -528,6 +805,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326 }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, +] + [[package]] name = "ruff" version = "0.11.13" @@ -553,6 +865,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928 }, ] +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221 }, +] + [[package]] name = "setuptools" version = "80.9.0" @@ -695,6 +1020,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/14/f58b4087cf248b18c795b5c838c7a8d1428dfb07cb468dad3ec7f54041ab/tox-4.26.0-py3-none-any.whl", hash = "sha256:75f17aaf09face9b97bd41645028d9f722301e912be8b4c65a3f938024560224", size = 172761 }, ] +[[package]] +name = "twine" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/a2/6df94fc5c8e2170d21d7134a565c3a8fb84f9797c1dd65a5976aaf714418/twine-6.1.0.tar.gz", hash = "sha256:be324f6272eff91d07ee93f251edf232fc647935dd585ac003539b42404a8dbd", size = 168404 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791 }, +] + [[package]] name = "types-html5lib" version = "1.1.11.20250516" @@ -750,3 +1096,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c wheels = [ { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982 }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, +]