From a8448d349929e18af2e5289082e939b5fd4b6f3d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:26:57 -0700 Subject: [PATCH 01/26] 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 02/26] 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 ba99fd102e617b2841bea921a09b82b5fb3d18ea Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:32:47 -0700 Subject: [PATCH 03/26] 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 c845531fc39373e67c73c644b8694e28c058a285 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:34:02 -0700 Subject: [PATCH 04/26] 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 dbc75271c92e58a9db7d5b190415b5adb65ef321 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:36:02 -0700 Subject: [PATCH 05/26] 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 4bc6f65c96b77eaf14816f9bff2a19bba45aa676 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:36:50 -0700 Subject: [PATCH 06/26] 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 c3fa43f9af5aa5a5bd7d08cabb0b9361ceb64e10 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:41:05 -0700 Subject: [PATCH 07/26] 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 4a611e7f56deeb15c7669da55d4c3f4565f64c56 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:43:23 -0700 Subject: [PATCH 08/26] 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 0cdfa4999ed7e1f31fc1c430cdf2fdf28e18488d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:47:47 -0700 Subject: [PATCH 09/26] 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 168ae803efc0cc0f967ac42cdf10bb58f6ce27e4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:49:31 -0700 Subject: [PATCH 10/26] 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 5ff4fae4376b4a403a211f2e0d3f1da063ba68ba Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:57:11 -0700 Subject: [PATCH 11/26] 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 8cbd08c5c151b0fd58e5fa2c9ecd85207e128ec5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:05:07 -0700 Subject: [PATCH 12/26] 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 07133dd4e82288e32e3aa4e114319f0501c33a9c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:06:01 -0700 Subject: [PATCH 13/26] 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 870302b28aa937275f62358be9db50423edcef9b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:06:54 -0700 Subject: [PATCH 14/26] 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 e21a27d37419946a22f88ec213c59ba3124ad8ff Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:09:32 -0700 Subject: [PATCH 15/26] 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 4ff09c1328bad2084fadea26b6ad27f09bfef833 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:11:42 -0700 Subject: [PATCH 16/26] 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 1c1351ea246b9b1598938b1e8cb466c63321e780 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:20:17 -0700 Subject: [PATCH 17/26] 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 9fc77af3d38bbc9ed9ad7f8aff48b30bc3d67d83 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:24:17 -0700 Subject: [PATCH 18/26] 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 fdf9f4e322a76867df61b979abab121388ad5313 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:26:14 -0700 Subject: [PATCH 19/26] 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 4677ad1cef44769259d2895fcb488de921b876a4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 20:46:35 -0700 Subject: [PATCH 20/26] 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 ff57c6db2b3003690684863bc75ab279f58b8476 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 16:31:54 -0700 Subject: [PATCH 21/26] 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 a34b3391b52ddefe7819a9d47823baca3a8f07d5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 17:51:50 -0700 Subject: [PATCH 22/26] 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 e0564546b29f1e66639b4443198f2206e61509c2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 18:05:15 -0700 Subject: [PATCH 23/26] 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 545b7397e80264bd82c8f177fa0f607362b410fb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 11 Jun 2025 21:07:55 -0700 Subject: [PATCH 24/26] 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 7114e986c16c1a499100ac8a6f81f09c6b3c2ecf Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 12 Jun 2025 20:57:40 -0700 Subject: [PATCH 25/26] 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 b136d15beb3eaa6499ff1c70b559bf2e1fc29850 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2025 13:45:03 -0700 Subject: [PATCH 26/26] 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 }, +]