diff --git a/.fdignore b/.fdignore new file mode 100644 index 000000000..41bdd3828 --- /dev/null +++ b/.fdignore @@ -0,0 +1,7 @@ +.tox +Session.vim +build/ +docs/.build +features/_scratch +__pycache__/ +src/*.egg-info diff --git a/.projections.json b/.projections.json new file mode 100644 index 000000000..7d68dd4c5 --- /dev/null +++ b/.projections.json @@ -0,0 +1,14 @@ +{ + "src/docx/*.py" : { + "alternate" : [ + "tests/{dirname}/test_{basename}.py" + ], + "type" : "source" + }, + "tests/**/test_*.py" : { + "alternate" : [ + "src/docx/{dirname}/{basename}.py" + ], + "type" : "test" + } +} diff --git a/.rgignore b/.rgignore new file mode 100644 index 000000000..12d71b5b4 --- /dev/null +++ b/.rgignore @@ -0,0 +1,9 @@ +.tox +Session.vim +build/ +docs/.build +features/_scratch +__pycache__/ +ref/ +src/*.egg-info +tests/test_files diff --git a/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/docs/_static/img/comment-parts.png b/docs/_static/img/comment-parts.png new file mode 100644 index 000000000..c7db1be54 Binary files /dev/null and b/docs/_static/img/comment-parts.png differ 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 06b428064..883ecb81d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -91,6 +91,10 @@ .. |_Columns| replace:: :class:`._Columns` +.. |Comment| replace:: :class:`.Comment` + +.. |Comments| replace:: :class:`.Comments` + .. |CoreProperties| replace:: :class:`.CoreProperties` .. |datetime| replace:: :class:`.datetime.datetime` @@ -270,9 +274,7 @@ # Custom sidebar templates, maps document names to template names. # html_sidebars = {} -html_sidebars = { - "**": ["localtoc.html", "relations.html", "sidebarlinks.html", "searchbox.html"] -} +html_sidebars = {"**": ["localtoc.html", "relations.html", "sidebarlinks.html", "searchbox.html"]} # Additional templates that should be rendered to pages, maps page names to # template names. diff --git a/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/features/cmt-mutations.feature b/features/cmt-mutations.feature new file mode 100644 index 000000000..1ef9ad2db --- /dev/null +++ b/features/cmt-mutations.feature @@ -0,0 +1,59 @@ +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 + + + 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 + + + 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" + + + 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 + + + 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 + + + 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 + + + Scenario: update Comment.author + Given a Comment object + When I assign "Jane Smith" to comment.author + Then comment.author == "Jane Smith" + + + Scenario: update Comment.initials + Given a Comment object + When I assign "JS" to comment.initials + Then comment.initials == "JS" diff --git a/features/cmt-props.feature b/features/cmt-props.feature new file mode 100644 index 000000000..e4e620828 --- /dev/null +++ b/features/cmt-props.feature @@ -0,0 +1,35 @@ +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 + + + Scenario: Comment.author + Given a Comment object + Then comment.author is the author of the comment + + + Scenario: Comment.initials + Given a Comment object + Then comment.initials is the initials of the comment author + + + Scenario: Comment.timestamp + Given a Comment object + Then comment.timestamp is the date and time the comment was authored + + + 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 + + + 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/doc-add-comment.feature b/features/doc-add-comment.feature new file mode 100644 index 000000000..36f46244a --- /dev/null +++ b/features/doc-add-comment.feature @@ -0,0 +1,13 @@ +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 + + + 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/doc-comments.feature b/features/doc-comments.feature new file mode 100644 index 000000000..944146e5e --- /dev/null +++ b/features/doc-comments.feature @@ -0,0 +1,36 @@ +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 + + + 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 | + + + Scenario Outline: Comments.__len__() + Given a Comments object with comments + Then len(comments) == + + Examples: len(comments) values + | count | + | 0 | + | 4 | + + + Scenario: Comments.__iter__() + Given a Comments object with 4 comments + Then iterating comments yields 4 Comment objects + + + 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/block.py b/features/steps/block.py index c365c9510..e3d5c6154 100644 --- a/features/steps/block.py +++ b/features/steps/block.py @@ -13,9 +13,7 @@ @given("a _Cell object with paragraphs and tables") def given_a_cell_with_paragraphs_and_tables(context: Context): - context.cell = ( - Document(test_docx("blk-paras-and-tables")).tables[1].rows[0].cells[0] - ) + context.cell = Document(test_docx("blk-paras-and-tables")).tables[1].rows[0].cells[0] @given("a Document object with paragraphs and tables") diff --git a/features/steps/comments.py b/features/steps/comments.py new file mode 100644 index 000000000..39680f257 --- /dev/null +++ b/features/steps/comments.py @@ -0,0 +1,284 @@ +"""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] + 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")) + + +@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 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 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 + + +@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) + + +# then ===================================================== + + +@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}"') +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.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 + + +@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.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.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) + + +@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 + 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) + + 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(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) + expected = int(count) + 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("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 + 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/document.py b/features/steps/document.py index 49165efc3..1c12ac106 100644 --- a/features/steps/document.py +++ b/features/steps/document.py @@ -126,17 +126,13 @@ def when_add_picture_specifying_width_and_height(context): @when("I add a picture specifying a height of 1.5 inches") def when_add_picture_specifying_height(context): document = context.document - context.picture = document.add_picture( - test_file("monty-truth.png"), height=Inches(1.5) - ) + context.picture = document.add_picture(test_file("monty-truth.png"), height=Inches(1.5)) @when("I add a picture specifying a width of 1.5 inches") def when_add_picture_specifying_width(context): document = context.document - context.picture = document.add_picture( - test_file("monty-truth.png"), width=Inches(1.5) - ) + context.picture = document.add_picture(test_file("monty-truth.png"), width=Inches(1.5)) @when("I add a picture specifying only the image file") diff --git a/features/steps/hyperlink.py b/features/steps/hyperlink.py index 2bba31ed8..14fa9f7be 100644 --- a/features/steps/hyperlink.py +++ b/features/steps/hyperlink.py @@ -27,9 +27,7 @@ def given_a_hyperlink_having_a_uri_fragment(context: Context): @given("a hyperlink having address {address} and fragment {fragment}") -def given_a_hyperlink_having_address_and_fragment( - context: Context, address: str, fragment: str -): +def given_a_hyperlink_having_address_and_fragment(context: Context, address: str, fragment: str): paragraph_idxs: Dict[Tuple[str, str], int] = { ("''", "linkedBookmark"): 1, ("https://foo.com", "''"): 2, @@ -73,60 +71,46 @@ def given_a_hyperlink_having_one_or_more_runs(context: Context, one_or_more: str def then_hyperlink_address_is_the_URL_of_the_hyperlink(context: Context): actual_value = context.hyperlink.address expected_value = "http://yahoo.com/" - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.contains_page_break is {value}") def then_hyperlink_contains_page_break_is_value(context: Context, value: str): actual_value = context.hyperlink.contains_page_break expected_value = {"True": True, "False": False}[value] - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.fragment is the URI fragment of the hyperlink") def then_hyperlink_fragment_is_the_URI_fragment_of_the_hyperlink(context: Context): actual_value = context.hyperlink.fragment expected_value = "linkedBookmark" - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.runs contains only Run instances") def then_hyperlink_runs_contains_only_Run_instances(context: Context): actual_value = [type(item).__name__ for item in context.hyperlink.runs] expected_value = ["Run" for _ in context.hyperlink.runs] - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.runs has length {value}") def then_hyperlink_runs_has_length(context: Context, value: str): actual_value = len(context.hyperlink.runs) expected_value = int(value) - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.text is the visible text of the hyperlink") def then_hyperlink_text_is_the_visible_text_of_the_hyperlink(context: Context): actual_value = context.hyperlink.text expected_value = "awesome hyperlink" - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("hyperlink.url is {value}") def then_hyperlink_url_is_value(context: Context, value: str): actual_value = context.hyperlink.url expected_value = "" if value == "''" else value - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" diff --git a/features/steps/pagebreak.py b/features/steps/pagebreak.py index 7d443da46..870428127 100644 --- a/features/steps/pagebreak.py +++ b/features/steps/pagebreak.py @@ -38,33 +38,23 @@ def then_rendered_page_break_preceding_paragraph_fragment_includes_the_hyperlink actual_value = type(para_frag).__name__ expected_value = "Paragraph" - assert ( - actual_value == expected_value - ), f"expected: '{expected_value}', got: '{actual_value}'" + assert actual_value == expected_value, f"expected: '{expected_value}', got: '{actual_value}'" actual_value = para_frag.text expected_value = "Page break in>><pqr stu - """ % nsdecls( - "w" - ) + """ % nsdecls("w") r = parse_xml(r_xml) context.run = Run(r, None) @@ -235,9 +233,7 @@ def then_run_font_is_the_Font_object_for_the_run(context): def then_run_iter_inner_content_generates_text_and_page_breaks(context: Context): actual_value = [type(item).__name__ for item in context.run.iter_inner_content()] expected_value = ["str", "RenderedPageBreak", "str", "RenderedPageBreak", "str"] - assert ( - actual_value == expected_value - ), f"expected: {expected_value}, got: {actual_value}" + assert actual_value == expected_value, f"expected: {expected_value}, got: {actual_value}" @then("run.style is styles['{style_name}']") @@ -267,15 +263,15 @@ def then_the_picture_appears_at_the_end_of_the_run(context): run = context.run r = run._r blip_rId = r.xpath( - "./w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic/pic:blipFill/" - "a:blip/@r:embed" + "./w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic/pic:blipFill/a:blip/@r:embed" )[0] image_part = run.part.related_parts[blip_rId] image_sha1 = hashlib.sha1(image_part.blob).hexdigest() expected_sha1 = "79769f1e202add2e963158b532e36c2c0f76a70c" - assert ( - image_sha1 == expected_sha1 - ), "image SHA1 doesn't match, expected %s, got %s" % (expected_sha1, image_sha1) + assert image_sha1 == expected_sha1, "image SHA1 doesn't match, expected %s, got %s" % ( + expected_sha1, + image_sha1, + ) @then("the run appears in {boolean_prop_name} unconditionally") diff --git a/pyproject.toml b/pyproject.toml index 91bac83d5..b3dc0be02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,23 @@ dynamic = ["version"] keywords = ["docx", "office", "openxml", "word"] license = { text = "MIT" } readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.9" + +[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", + "tox>=4.26.0", + "twine>=6.1.0", + "types-lxml-multi-subclass>=2025.3.30", +] [project.urls] Changelog = "https://github.com/python-openxml/python-docx/blob/master/HISTORY.rst" @@ -38,20 +54,18 @@ Documentation = "https://python-docx.readthedocs.org/en/latest/" Homepage = "https://github.com/python-openxml/python-docx" Repository = "https://github.com/python-openxml/python-docx" -[tool.black] -line-length = 100 -target-version = ["py37", "py38", "py39", "py310", "py311"] - [tool.pyright] include = ["src/docx", "tests"] pythonPlatform = "All" -pythonVersion = "3.8" -reportImportCycles = true +pythonVersion = "3.9" +reportImportCycles = false reportUnnecessaryCast = true reportUnnecessaryTypeIgnoreComment = true stubPath = "./typings" typeCheckingMode = "strict" verboseOutput = true +venvPath = "." +venv = ".venv" [tool.pytest.ini_options] filterwarnings = [ @@ -88,7 +102,6 @@ target-version = "py38" ignore = [ "COM812", # -- over-aggressively insists on trailing commas where not desired -- "PT001", # -- wants @pytest.fixture() instead of @pytest.fixture -- - "PT005", # -- wants @pytest.fixture() instead of @pytest.fixture -- ] select = [ "C4", # -- flake8-comprehensions -- @@ -111,3 +124,4 @@ known-local-folder = ["helpers"] [tool.setuptools.dynamic] version = {attr = "docx.__version__"} + diff --git a/src/docx/__init__.py b/src/docx/__init__.py index 205221027..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"] @@ -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/blkcntnr.py b/src/docx/blkcntnr.py index a9969f6f6..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): @@ -67,7 +68,7 @@ def add_table(self, rows: int, cols: int, width: Length) -> Table: from docx.table import Table tbl = CT_Tbl.new_tbl(rows, cols, width) - self._element._insert_tbl(tbl) # # pyright: ignore[reportPrivateUsage] + self._element._insert_tbl(tbl) # pyright: ignore[reportPrivateUsage] return Table(tbl, self) def iter_inner_content(self) -> Iterator[Paragraph | Table]: diff --git a/src/docx/comments.py b/src/docx/comments.py new file mode 100644 index 000000000..8ea195224 --- /dev/null +++ b/src/docx/comments.py @@ -0,0 +1,163 @@ +"""Collection providing access to comments added to this document.""" + +from __future__ import annotations + +import datetime as dt +from typing import TYPE_CHECKING, Iterator + +from docx.blkcntnr import BlockItemContainer + +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: + """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 + + 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) + + 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) + 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. + + Provides methods to access comment metadata such as author, initials, and date. + + A comment is also a block-item container, similar to a table cell, so it can contain both + paragraphs and tables and its paragraphs can contain rich text, hyperlinks and images, + although the common case is that a comment contains a single paragraph of plain text like a + sentence or phrase. + + 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 + + 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: + """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.""" + 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 + + @initials.setter + 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. + + This attribute is optional in the XML, returns |None| if not set. + """ + return self._comment_elm.date diff --git a/src/docx/dml/color.py b/src/docx/dml/color.py index d7ee0a21c..a8322d21a 100644 --- a/src/docx/dml/color.py +++ b/src/docx/dml/color.py @@ -1,83 +1,95 @@ """DrawingML objects related to color, ColorFormat being the most prominent.""" -from ..enum.dml import MSO_COLOR_TYPE -from ..oxml.simpletypes import ST_HexColorAuto -from ..shared import ElementProxy +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from typing_extensions import TypeAlias + +from docx.enum.dml import MSO_COLOR_TYPE +from docx.oxml.simpletypes import ST_HexColorAuto +from docx.shared import ElementProxy, RGBColor + +if TYPE_CHECKING: + from docx.enum.dml import MSO_THEME_COLOR + from docx.oxml.text.font import CT_Color + from docx.oxml.text.run import CT_R + +# -- other element types can be a parent of an `w:rPr` element, but for now only `w:r` is -- +RPrParent: TypeAlias = "CT_R" class ColorFormat(ElementProxy): - """Provides access to color settings such as RGB color, theme color, and luminance - adjustments.""" + """Provides access to color settings like RGB color, theme color, and luminance adjustments.""" - def __init__(self, rPr_parent): + def __init__(self, rPr_parent: RPrParent): super(ColorFormat, self).__init__(rPr_parent) + self._element = rPr_parent @property - def rgb(self): + def rgb(self) -> RGBColor | None: """An |RGBColor| value or |None| if no RGB color is specified. - When :attr:`type` is `MSO_COLOR_TYPE.RGB`, the value of this property will - always be an |RGBColor| value. It may also be an |RGBColor| value if - :attr:`type` is `MSO_COLOR_TYPE.THEME`, as Word writes the current value of a - theme color when one is assigned. In that case, the RGB value should be - interpreted as no more than a good guess however, as the theme color takes - precedence at rendering time. Its value is |None| whenever :attr:`type` is - either |None| or `MSO_COLOR_TYPE.AUTO`. - - Assigning an |RGBColor| value causes :attr:`type` to become `MSO_COLOR_TYPE.RGB` - and any theme color is removed. Assigning |None| causes any color to be removed - such that the effective color is inherited from the style hierarchy. + When :attr:`type` is `MSO_COLOR_TYPE.RGB`, the value of this property will always be an + |RGBColor| value. It may also be an |RGBColor| value if :attr:`type` is + `MSO_COLOR_TYPE.THEME`, as Word writes the current value of a theme color when one is + assigned. In that case, the RGB value should be interpreted as no more than a good guess + however, as the theme color takes precedence at rendering time. Its value is |None| + whenever :attr:`type` is either |None| or `MSO_COLOR_TYPE.AUTO`. + + Assigning an |RGBColor| value causes :attr:`type` to become `MSO_COLOR_TYPE.RGB` and any + theme color is removed. Assigning |None| causes any color to be removed such that the + effective color is inherited from the style hierarchy. """ color = self._color if color is None: return None if color.val == ST_HexColorAuto.AUTO: return None - return color.val + return cast(RGBColor, color.val) @rgb.setter - def rgb(self, value): + def rgb(self, value: RGBColor | None): if value is None and self._color is None: return rPr = self._element.get_or_add_rPr() - rPr._remove_color() + rPr._remove_color() # pyright: ignore[reportPrivateUsage] if value is not None: rPr.get_or_add_color().val = value @property - def theme_color(self): + def theme_color(self) -> MSO_THEME_COLOR | None: """Member of :ref:`MsoThemeColorIndex` or |None| if no theme color is specified. - When :attr:`type` is `MSO_COLOR_TYPE.THEME`, the value of this property will - always be a member of :ref:`MsoThemeColorIndex`. When :attr:`type` has any other - value, the value of this property is |None|. + When :attr:`type` is `MSO_COLOR_TYPE.THEME`, the value of this property will always be a + member of :ref:`MsoThemeColorIndex`. When :attr:`type` has any other value, the value of + this property is |None|. Assigning a member of :ref:`MsoThemeColorIndex` causes :attr:`type` to become - `MSO_COLOR_TYPE.THEME`. Any existing RGB value is retained but ignored by Word. - Assigning |None| causes any color specification to be removed such that the - effective color is inherited from the style hierarchy. + `MSO_COLOR_TYPE.THEME`. Any existing RGB value is retained but ignored by Word. Assigning + |None| causes any color specification to be removed such that the effective color is + inherited from the style hierarchy. """ color = self._color - if color is None or color.themeColor is None: + if color is None: return None return color.themeColor @theme_color.setter - def theme_color(self, value): + def theme_color(self, value: MSO_THEME_COLOR | None): if value is None: - if self._color is not None: - self._element.rPr._remove_color() + if self._color is not None and self._element.rPr is not None: + self._element.rPr._remove_color() # pyright: ignore[reportPrivateUsage] return self._element.get_or_add_rPr().get_or_add_color().themeColor = value @property - def type(self) -> MSO_COLOR_TYPE: + def type(self) -> MSO_COLOR_TYPE | None: """Read-only. - A member of :ref:`MsoColorType`, one of RGB, THEME, or AUTO, corresponding to - the way this color is defined. Its value is |None| if no color is applied at - this level, which causes the effective color to be inherited from the style - hierarchy. + A member of :ref:`MsoColorType`, one of RGB, THEME, or AUTO, corresponding to the way this + color is defined. Its value is |None| if no color is applied at this level, which causes + the effective color to be inherited from the style hierarchy. """ color = self._color if color is None: @@ -89,7 +101,7 @@ def type(self) -> MSO_COLOR_TYPE: return MSO_COLOR_TYPE.RGB @property - def _color(self): + def _color(self) -> CT_Color | None: """Return `w:rPr/w:color` or |None| if not present. Helper to factor out repetitive element access. diff --git a/src/docx/document.py b/src/docx/document.py index 8944a0e50..73757b46d 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -5,20 +5,21 @@ 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 +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 Comment, Comments from docx.oxml.document import CT_Body, CT_Document from docx.parts.document import DocumentPart from docx.settings import Settings - from docx.shared import Length from docx.styles.style import ParagraphStyle, _TableStyle from docx.table import Table from docx.text.paragraph import Paragraph @@ -37,6 +38,55 @@ def __init__(self, element: CT_Document, part: DocumentPart): self._part = part self.__body = None + def add_comment( + 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. + + `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. @@ -107,6 +157,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.""" @@ -178,7 +233,10 @@ def tables(self) -> List[Table]: def _block_width(self) -> Length: """A |Length| object specifying the space between margins in last section.""" section = self.sections[-1] - return Emu(section.page_width - section.left_margin - section.right_margin) + page_width = section.page_width or Inches(8.5) + left_margin = section.left_margin or Inches(1) + right_margin = section.right_margin or Inches(1) + return Emu(page_width - left_margin - right_margin) @property def _body(self) -> _Body: @@ -198,7 +256,7 @@ def __init__(self, body_elm: CT_Body, parent: t.ProvidesStoryPart): super(_Body, self).__init__(body_elm, parent) self._body = body_elm - def clear_content(self): + def clear_content(self) -> _Body: """Return this |_Body| instance after clearing it of all content. Section properties for the main document story, if present, are preserved. diff --git a/src/docx/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/src/docx/enum/base.py b/src/docx/enum/base.py index bc96ab6a2..66e989757 100644 --- a/src/docx/enum/base.py +++ b/src/docx/enum/base.py @@ -37,9 +37,9 @@ class BaseXmlEnum(int, enum.Enum): corresponding member in the MS API enum of the same name. """ - xml_value: str + xml_value: str | None - def __new__(cls, ms_api_value: int, xml_value: str, docstr: str): + def __new__(cls, ms_api_value: int, xml_value: str | None, docstr: str): self = int.__new__(cls, ms_api_value) self._value_ = ms_api_value self.xml_value = xml_value @@ -70,7 +70,11 @@ def to_xml(cls: Type[_T], value: int | _T | None) -> str | None: """XML value of this enum member, generally an XML attribute value.""" # -- presence of multi-arg `__new__()` method fools type-checker, but getting a # -- member by its value using EnumCls(val) works as usual. - return cls(value).xml_value + member = cls(value) + xml_value = member.xml_value + if not xml_value: + raise ValueError(f"{cls.__name__}.{member.name} has no XML representation") + return xml_value class DocsPageFormatter: diff --git a/src/docx/image/__init__.py b/src/docx/image/__init__.py index d28033ef1..9d5e4b05b 100644 --- a/src/docx/image/__init__.py +++ b/src/docx/image/__init__.py @@ -12,7 +12,7 @@ SIGNATURES = ( # class, offset, signature_bytes - (Png, 0, b"\x89PNG\x0D\x0A\x1A\x0A"), + (Png, 0, b"\x89PNG\x0d\x0a\x1a\x0a"), (Jfif, 6, b"JFIF"), (Exif, 6, b"Exif"), (Gif, 0, b"GIF87a"), diff --git a/src/docx/image/constants.py b/src/docx/image/constants.py index 729a828b2..03fae5855 100644 --- a/src/docx/image/constants.py +++ b/src/docx/image/constants.py @@ -5,58 +5,58 @@ class JPEG_MARKER_CODE: """JPEG marker codes.""" TEM = b"\x01" - DHT = b"\xC4" - DAC = b"\xCC" - JPG = b"\xC8" - - SOF0 = b"\xC0" - SOF1 = b"\xC1" - SOF2 = b"\xC2" - SOF3 = b"\xC3" - SOF5 = b"\xC5" - SOF6 = b"\xC6" - SOF7 = b"\xC7" - SOF9 = b"\xC9" - SOFA = b"\xCA" - SOFB = b"\xCB" - SOFD = b"\xCD" - SOFE = b"\xCE" - SOFF = b"\xCF" - - RST0 = b"\xD0" - RST1 = b"\xD1" - RST2 = b"\xD2" - RST3 = b"\xD3" - RST4 = b"\xD4" - RST5 = b"\xD5" - RST6 = b"\xD6" - RST7 = b"\xD7" - - SOI = b"\xD8" - EOI = b"\xD9" - SOS = b"\xDA" - DQT = b"\xDB" # Define Quantization Table(s) - DNL = b"\xDC" - DRI = b"\xDD" - DHP = b"\xDE" - EXP = b"\xDF" - - APP0 = b"\xE0" - APP1 = b"\xE1" - APP2 = b"\xE2" - APP3 = b"\xE3" - APP4 = b"\xE4" - APP5 = b"\xE5" - APP6 = b"\xE6" - APP7 = b"\xE7" - APP8 = b"\xE8" - APP9 = b"\xE9" - APPA = b"\xEA" - APPB = b"\xEB" - APPC = b"\xEC" - APPD = b"\xED" - APPE = b"\xEE" - APPF = b"\xEF" + DHT = b"\xc4" + DAC = b"\xcc" + JPG = b"\xc8" + + SOF0 = b"\xc0" + SOF1 = b"\xc1" + SOF2 = b"\xc2" + SOF3 = b"\xc3" + SOF5 = b"\xc5" + SOF6 = b"\xc6" + SOF7 = b"\xc7" + SOF9 = b"\xc9" + SOFA = b"\xca" + SOFB = b"\xcb" + SOFD = b"\xcd" + SOFE = b"\xce" + SOFF = b"\xcf" + + RST0 = b"\xd0" + RST1 = b"\xd1" + RST2 = b"\xd2" + RST3 = b"\xd3" + RST4 = b"\xd4" + RST5 = b"\xd5" + RST6 = b"\xd6" + RST7 = b"\xd7" + + SOI = b"\xd8" + EOI = b"\xd9" + SOS = b"\xda" + DQT = b"\xdb" # Define Quantization Table(s) + DNL = b"\xdc" + DRI = b"\xdd" + DHP = b"\xde" + EXP = b"\xdf" + + APP0 = b"\xe0" + APP1 = b"\xe1" + APP2 = b"\xe2" + APP3 = b"\xe3" + APP4 = b"\xe4" + APP5 = b"\xe5" + APP6 = b"\xe6" + APP7 = b"\xe7" + APP8 = b"\xe8" + APP9 = b"\xe9" + APPA = b"\xea" + APPB = b"\xeb" + APPC = b"\xec" + APPD = b"\xed" + APPE = b"\xee" + APPF = b"\xef" STANDALONE_MARKERS = (TEM, SOI, EOI, RST0, RST1, RST2, RST3, RST4, RST5, RST6, RST7) @@ -78,18 +78,18 @@ class JPEG_MARKER_CODE: marker_names = { b"\x00": "UNKNOWN", - b"\xC0": "SOF0", - b"\xC2": "SOF2", - b"\xC4": "DHT", - b"\xDA": "SOS", # start of scan - b"\xD8": "SOI", # start of image - b"\xD9": "EOI", # end of image - b"\xDB": "DQT", - b"\xE0": "APP0", - b"\xE1": "APP1", - b"\xE2": "APP2", - b"\xED": "APP13", - b"\xEE": "APP14", + b"\xc0": "SOF0", + b"\xc2": "SOF2", + b"\xc4": "DHT", + b"\xda": "SOS", # start of scan + b"\xd8": "SOI", # start of image + b"\xd9": "EOI", # end of image + b"\xdb": "DQT", + b"\xe0": "APP0", + b"\xe1": "APP1", + b"\xe2": "APP2", + b"\xed": "APP13", + b"\xee": "APP14", } @classmethod diff --git a/src/docx/image/image.py b/src/docx/image/image.py index 0022b5b45..e5e7f8a13 100644 --- a/src/docx/image/image.py +++ b/src/docx/image/image.py @@ -194,7 +194,7 @@ def __init__(self, px_width: int, px_height: int, horz_dpi: int, vert_dpi: int): @property def content_type(self) -> str: """Abstract property definition, must be implemented by all subclasses.""" - msg = "content_type property must be implemented by all subclasses of " "BaseImageHeader" + msg = "content_type property must be implemented by all subclasses of BaseImageHeader" raise NotImplementedError(msg) @property @@ -204,7 +204,7 @@ def default_ext(self) -> str: An abstract property definition, must be implemented by all subclasses. """ raise NotImplementedError( - "default_ext property must be implemented by all subclasses of " "BaseImageHeader" + "default_ext property must be implemented by all subclasses of BaseImageHeader" ) @property diff --git a/src/docx/image/jpeg.py b/src/docx/image/jpeg.py index b0114a998..74da51871 100644 --- a/src/docx/image/jpeg.py +++ b/src/docx/image/jpeg.py @@ -188,20 +188,20 @@ def next(self, start): def _next_non_ff_byte(self, start): """Return an offset, byte 2-tuple for the next byte in `stream` that is not - '\xFF', starting with the byte at offset `start`. + '\xff', starting with the byte at offset `start`. - If the byte at offset `start` is not '\xFF', `start` and the returned `offset` + If the byte at offset `start` is not '\xff', `start` and the returned `offset` will be the same. """ self._stream.seek(start) byte_ = self._read_byte() - while byte_ == b"\xFF": + while byte_ == b"\xff": byte_ = self._read_byte() offset_of_non_ff_byte = self._stream.tell() - 1 return offset_of_non_ff_byte, byte_ def _offset_of_next_ff_byte(self, start): - """Return the offset of the next '\xFF' byte in `stream` starting with the byte + """Return the offset of the next '\xff' byte in `stream` starting with the byte at offset `start`. Returns `start` if the byte at that offset is a hex 255; it does not necessarily @@ -209,7 +209,7 @@ def _offset_of_next_ff_byte(self, start): """ self._stream.seek(start) byte_ = self._read_byte() - while byte_ != b"\xFF": + while byte_ != b"\xff": byte_ = self._read_byte() offset_of_ff_byte = self._stream.tell() - 1 return offset_of_ff_byte @@ -263,7 +263,7 @@ def from_stream(cls, stream, marker_code, offset): @property def marker_code(self): - """The single-byte code that identifies the type of this marker, e.g. ``'\xE0'`` + """The single-byte code that identifies the type of this marker, e.g. ``'\xe0'`` for start of image (SOI).""" return self._marker_code @@ -284,9 +284,7 @@ def segment_length(self): class _App0Marker(_Marker): """Represents a JFIF APP0 marker segment.""" - def __init__( - self, marker_code, offset, length, density_units, x_density, y_density - ): + def __init__(self, marker_code, offset, length, density_units, x_density, y_density): super(_App0Marker, self).__init__(marker_code, offset, length) self._density_units = density_units self._x_density = x_density @@ -332,9 +330,7 @@ def from_stream(cls, stream, marker_code, offset): density_units = stream.read_byte(offset, 9) x_density = stream.read_short(offset, 10) y_density = stream.read_short(offset, 12) - return cls( - marker_code, offset, segment_length, density_units, x_density, y_density - ) + return cls(marker_code, offset, segment_length, density_units, x_density, y_density) class _App1Marker(_Marker): diff --git a/src/docx/opc/constants.py b/src/docx/opc/constants.py index 89d3c16cc..a3d0e0812 100644 --- a/src/docx/opc/constants.py +++ b/src/docx/opc/constants.py @@ -9,27 +9,15 @@ class CONTENT_TYPE: BMP = "image/bmp" DML_CHART = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml" - DML_CHARTSHAPES = ( - "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml" - ) - DML_DIAGRAM_COLORS = ( - "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml" - ) - DML_DIAGRAM_DATA = ( - "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml" - ) - DML_DIAGRAM_LAYOUT = ( - "application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml" - ) - DML_DIAGRAM_STYLE = ( - "application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml" - ) + DML_CHARTSHAPES = "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml" + DML_DIAGRAM_COLORS = "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml" + DML_DIAGRAM_DATA = "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml" + DML_DIAGRAM_LAYOUT = "application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml" + DML_DIAGRAM_STYLE = "application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml" GIF = "image/gif" JPEG = "image/jpeg" MS_PHOTO = "image/vnd.ms-photo" - OFC_CUSTOM_PROPERTIES = ( - "application/vnd.openxmlformats-officedocument.custom-properties+xml" - ) + OFC_CUSTOM_PROPERTIES = "application/vnd.openxmlformats-officedocument.custom-properties+xml" OFC_CUSTOM_XML_PROPERTIES = ( "application/vnd.openxmlformats-officedocument.customXmlProperties+xml" ) @@ -40,209 +28,126 @@ class CONTENT_TYPE: OFC_OLE_OBJECT = "application/vnd.openxmlformats-officedocument.oleObject" OFC_PACKAGE = "application/vnd.openxmlformats-officedocument.package" OFC_THEME = "application/vnd.openxmlformats-officedocument.theme+xml" - OFC_THEME_OVERRIDE = ( - "application/vnd.openxmlformats-officedocument.themeOverride+xml" - ) + OFC_THEME_OVERRIDE = "application/vnd.openxmlformats-officedocument.themeOverride+xml" OFC_VML_DRAWING = "application/vnd.openxmlformats-officedocument.vmlDrawing" OPC_CORE_PROPERTIES = "application/vnd.openxmlformats-package.core-properties+xml" OPC_DIGITAL_SIGNATURE_CERTIFICATE = ( "application/vnd.openxmlformats-package.digital-signature-certificate" ) - OPC_DIGITAL_SIGNATURE_ORIGIN = ( - "application/vnd.openxmlformats-package.digital-signature-origin" - ) + OPC_DIGITAL_SIGNATURE_ORIGIN = "application/vnd.openxmlformats-package.digital-signature-origin" OPC_DIGITAL_SIGNATURE_XMLSIGNATURE = ( "application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml" ) OPC_RELATIONSHIPS = "application/vnd.openxmlformats-package.relationships+xml" - PML_COMMENTS = ( - "application/vnd.openxmlformats-officedocument.presentationml.comments+xml" - ) + PML_COMMENTS = "application/vnd.openxmlformats-officedocument.presentationml.comments+xml" PML_COMMENT_AUTHORS = ( - "application/vnd.openxmlformats-officedocument.presentationml.commen" - "tAuthors+xml" + "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml" ) PML_HANDOUT_MASTER = ( - "application/vnd.openxmlformats-officedocument.presentationml.handou" - "tMaster+xml" + "application/vnd.openxmlformats-officedocument.presentationml.handoutMaster+xml" ) PML_NOTES_MASTER = ( - "application/vnd.openxmlformats-officedocument.presentationml.notesM" - "aster+xml" - ) - PML_NOTES_SLIDE = ( - "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml" + "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml" ) + PML_NOTES_SLIDE = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml" PML_PRESENTATION_MAIN = ( - "application/vnd.openxmlformats-officedocument.presentationml.presen" - "tation.main+xml" - ) - PML_PRES_PROPS = ( - "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml" + "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml" ) + PML_PRES_PROPS = "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml" PML_PRINTER_SETTINGS = ( - "application/vnd.openxmlformats-officedocument.presentationml.printe" - "rSettings" + "application/vnd.openxmlformats-officedocument.presentationml.printerSettings" ) PML_SLIDE = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml" PML_SLIDESHOW_MAIN = ( - "application/vnd.openxmlformats-officedocument.presentationml.slides" - "how.main+xml" + "application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml" ) PML_SLIDE_LAYOUT = ( - "application/vnd.openxmlformats-officedocument.presentationml.slideL" - "ayout+xml" + "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml" ) PML_SLIDE_MASTER = ( - "application/vnd.openxmlformats-officedocument.presentationml.slideM" - "aster+xml" + "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml" ) PML_SLIDE_UPDATE_INFO = ( - "application/vnd.openxmlformats-officedocument.presentationml.slideU" - "pdateInfo+xml" + "application/vnd.openxmlformats-officedocument.presentationml.slideUpdateInfo+xml" ) PML_TABLE_STYLES = ( - "application/vnd.openxmlformats-officedocument.presentationml.tableS" - "tyles+xml" + "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml" ) PML_TAGS = "application/vnd.openxmlformats-officedocument.presentationml.tags+xml" PML_TEMPLATE_MAIN = ( - "application/vnd.openxmlformats-officedocument.presentationml.templa" - "te.main+xml" - ) - PML_VIEW_PROPS = ( - "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml" + "application/vnd.openxmlformats-officedocument.presentationml.template.main+xml" ) + PML_VIEW_PROPS = "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml" PNG = "image/png" - SML_CALC_CHAIN = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml" - ) - SML_CHARTSHEET = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml" - ) - SML_COMMENTS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml" - ) - SML_CONNECTIONS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml" - ) + SML_CALC_CHAIN = "application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml" + SML_CHARTSHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml" + SML_COMMENTS = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml" + SML_CONNECTIONS = "application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml" SML_CUSTOM_PROPERTY = ( "application/vnd.openxmlformats-officedocument.spreadsheetml.customProperty" ) - SML_DIALOGSHEET = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml" - ) + SML_DIALOGSHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml" SML_EXTERNAL_LINK = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.externa" - "lLink+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml" ) SML_PIVOT_CACHE_DEFINITION = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCa" - "cheDefinition+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml" ) SML_PIVOT_CACHE_RECORDS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCa" - "cheRecords+xml" - ) - SML_PIVOT_TABLE = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml" ) + SML_PIVOT_TABLE = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml" SML_PRINTER_SETTINGS = ( "application/vnd.openxmlformats-officedocument.spreadsheetml.printerSettings" ) - SML_QUERY_TABLE = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.queryTable+xml" - ) + SML_QUERY_TABLE = "application/vnd.openxmlformats-officedocument.spreadsheetml.queryTable+xml" SML_REVISION_HEADERS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.revisio" - "nHeaders+xml" - ) - SML_REVISION_LOG = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionLog+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionHeaders+xml" ) + SML_REVISION_LOG = "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionLog+xml" SML_SHARED_STRINGS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedS" - "trings+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml" ) SML_SHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - SML_SHEET_MAIN = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" - ) + SML_SHEET_MAIN = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" SML_SHEET_METADATA = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMe" - "tadata+xml" - ) - SML_STYLES = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml" ) + SML_STYLES = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" SML_TABLE = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml" SML_TABLE_SINGLE_CELLS = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.tableSi" - "ngleCells+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.tableSingleCells+xml" ) SML_TEMPLATE_MAIN = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.templat" - "e.main+xml" - ) - SML_USER_NAMES = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.userNames+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml" ) + SML_USER_NAMES = "application/vnd.openxmlformats-officedocument.spreadsheetml.userNames+xml" SML_VOLATILE_DEPENDENCIES = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.volatil" - "eDependencies+xml" - ) - SML_WORKSHEET = ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" + "application/vnd.openxmlformats-officedocument.spreadsheetml.volatileDependencies+xml" ) + SML_WORKSHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" TIFF = "image/tiff" - WML_COMMENTS = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml" - ) - WML_DOCUMENT = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ) + WML_COMMENTS = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml" + WML_DOCUMENT = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" WML_DOCUMENT_GLOSSARY = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.docu" - "ment.glossary+xml" + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml" ) WML_DOCUMENT_MAIN = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.docu" - "ment.main+xml" - ) - WML_ENDNOTES = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml" - ) - WML_FONT_TABLE = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.font" - "Table+xml" - ) - WML_FOOTER = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml" - ) - WML_FOOTNOTES = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.foot" - "notes+xml" - ) - WML_HEADER = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml" - ) - WML_NUMBERING = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.numb" - "ering+xml" - ) + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml" + ) + WML_ENDNOTES = "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml" + WML_FONT_TABLE = "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml" + WML_FOOTER = "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml" + WML_FOOTNOTES = "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml" + WML_HEADER = "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml" + WML_NUMBERING = "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml" WML_PRINTER_SETTINGS = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.prin" - "terSettings" - ) - WML_SETTINGS = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml" - ) - WML_STYLES = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml" + "application/vnd.openxmlformats-officedocument.wordprocessingml.printerSettings" ) + WML_SETTINGS = "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml" + WML_STYLES = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml" WML_WEB_SETTINGS = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.webS" - "ettings+xml" + "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml" ) XML = "application/xml" X_EMF = "image/x-emf" @@ -257,9 +162,7 @@ class NAMESPACE: DML_WORDPROCESSING_DRAWING = ( "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" ) - OFC_RELATIONSHIPS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - ) + OFC_RELATIONSHIPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" OPC_RELATIONSHIPS = "http://schemas.openxmlformats.org/package/2006/relationships" OPC_CONTENT_TYPES = "http://schemas.openxmlformats.org/package/2006/content-types" WML_MAIN = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" @@ -274,259 +177,130 @@ class RELATIONSHIP_TARGET_MODE: class RELATIONSHIP_TYPE: AUDIO = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio" - A_F_CHUNK = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" - ) - CALC_CHAIN = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/calcChain" - ) + A_F_CHUNK = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" + CALC_CHAIN = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/calcChain" CERTIFICATE = ( - "http://schemas.openxmlformats.org/package/2006/relationships/digita" - "l-signature/certificate" + "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/certificate" ) CHART = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" - CHARTSHEET = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/chartsheet" - ) + CHARTSHEET = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet" CHART_USER_SHAPES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/chartUserShapes" - ) - COMMENTS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/comments" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartUserShapes" ) + COMMENTS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" COMMENT_AUTHORS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/commentAuthors" - ) - CONNECTIONS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/connections" - ) - CONTROL = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/control" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/commentAuthors" ) + CONNECTIONS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/connections" + CONTROL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/control" CORE_PROPERTIES = ( - "http://schemas.openxmlformats.org/package/2006/relationships/metada" - "ta/core-properties" + "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" ) CUSTOM_PROPERTIES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/custom-properties" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" ) CUSTOM_PROPERTY = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/customProperty" - ) - CUSTOM_XML = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/customXml" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customProperty" ) + CUSTOM_XML = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" CUSTOM_XML_PROPS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/customXmlProps" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps" ) DIAGRAM_COLORS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/diagramColors" - ) - DIAGRAM_DATA = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/diagramData" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors" ) + DIAGRAM_DATA = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData" DIAGRAM_LAYOUT = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/diagramLayout" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramLayout" ) DIAGRAM_QUICK_STYLE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/diagramQuickStyle" - ) - DIALOGSHEET = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/dialogsheet" - ) - DRAWING = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" - ) - ENDNOTES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/endnotes" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramQuickStyle" ) + DIALOGSHEET = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/dialogsheet" + DRAWING = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" + ENDNOTES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" EXTENDED_PROPERTIES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/extended-properties" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" ) EXTERNAL_LINK = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/externalLink" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/externalLink" ) FONT = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font" - FONT_TABLE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/fontTable" - ) - FOOTER = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" - ) - FOOTNOTES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/footnotes" - ) + FONT_TABLE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" + FOOTER = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" + FOOTNOTES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" GLOSSARY_DOCUMENT = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/glossaryDocument" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument" ) HANDOUT_MASTER = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/handoutMaster" - ) - HEADER = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" - ) - HYPERLINK = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/hyperlink" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/handoutMaster" ) + HEADER = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" + HYPERLINK = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" IMAGE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" - NOTES_MASTER = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/notesMaster" - ) - NOTES_SLIDE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/notesSlide" - ) - NUMBERING = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/numbering" - ) + NOTES_MASTER = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" + NOTES_SLIDE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" + NUMBERING = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" OFFICE_DOCUMENT = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/officeDocument" - ) - OLE_OBJECT = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/oleObject" - ) - ORIGIN = ( - "http://schemas.openxmlformats.org/package/2006/relationships/digita" - "l-signature/origin" - ) - PACKAGE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ) + OLE_OBJECT = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" + ORIGIN = "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/origin" + PACKAGE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" PIVOT_CACHE_DEFINITION = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/pivotCacheDefinition" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition" ) PIVOT_CACHE_RECORDS = ( "http://schemas.openxmlformats.org/officeDocument/2006/relationships" "/spreadsheetml/pivotCacheRecords" ) - PIVOT_TABLE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/pivotTable" - ) - PRES_PROPS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/presProps" - ) + PIVOT_TABLE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable" + PRES_PROPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps" PRINTER_SETTINGS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/printerSettings" - ) - QUERY_TABLE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/queryTable" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/printerSettings" ) + QUERY_TABLE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/queryTable" REVISION_HEADERS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/revisionHeaders" - ) - REVISION_LOG = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/revisionLog" - ) - SETTINGS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/settings" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/revisionHeaders" ) + REVISION_LOG = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/revisionLog" + SETTINGS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" SHARED_STRINGS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/sharedStrings" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" ) SHEET_METADATA = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/sheetMetadata" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sheetMetadata" ) SIGNATURE = ( - "http://schemas.openxmlformats.org/package/2006/relationships/digita" - "l-signature/signature" + "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/signature" ) SLIDE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" - SLIDE_LAYOUT = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/slideLayout" - ) - SLIDE_MASTER = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/slideMaster" - ) + SLIDE_LAYOUT = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" + SLIDE_MASTER = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" SLIDE_UPDATE_INFO = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/slideUpdateInfo" - ) - STYLES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideUpdateInfo" ) + STYLES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" TABLE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" TABLE_SINGLE_CELLS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/tableSingleCells" - ) - TABLE_STYLES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/tableStyles" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableSingleCells" ) + TABLE_STYLES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles" TAGS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags" THEME = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" THEME_OVERRIDE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/themeOverride" - ) - THUMBNAIL = ( - "http://schemas.openxmlformats.org/package/2006/relationships/metada" - "ta/thumbnail" - ) - USERNAMES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/usernames" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/themeOverride" ) + THUMBNAIL = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail" + USERNAMES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/usernames" VIDEO = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/video" - VIEW_PROPS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/viewProps" - ) - VML_DRAWING = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/vmlDrawing" - ) + VIEW_PROPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/viewProps" + VML_DRAWING = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" VOLATILE_DEPENDENCIES = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/volatileDependencies" - ) - WEB_SETTINGS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/webSettings" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/volatileDependencies" ) + WEB_SETTINGS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings" WORKSHEET_SOURCE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - "/worksheetSource" - ) - XML_MAPS = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/xmlMaps" + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheetSource" ) + XML_MAPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/xmlMaps" diff --git a/src/docx/opc/coreprops.py b/src/docx/opc/coreprops.py index c564550d4..62f0c5ab1 100644 --- a/src/docx/opc/coreprops.py +++ b/src/docx/opc/coreprops.py @@ -5,6 +5,7 @@ from __future__ import annotations +import datetime as dt from typing import TYPE_CHECKING from docx.oxml.coreprops import CT_CoreProperties @@ -57,7 +58,7 @@ def created(self): return self._element.created_datetime @created.setter - def created(self, value): + def created(self, value: dt.datetime): self._element.created_datetime = value @property @@ -97,7 +98,7 @@ def last_printed(self): return self._element.lastPrinted_datetime @last_printed.setter - def last_printed(self, value): + def last_printed(self, value: dt.datetime): self._element.lastPrinted_datetime = value @property @@ -105,7 +106,7 @@ def modified(self): return self._element.modified_datetime @modified.setter - def modified(self, value): + def modified(self, value: dt.datetime): self._element.modified_datetime = value @property @@ -113,7 +114,7 @@ def revision(self): return self._element.revision_number @revision.setter - def revision(self, value): + def revision(self, value: int): self._element.revision_number = value @property diff --git a/src/docx/opc/oxml.py b/src/docx/opc/oxml.py index 7da72f50d..7d3c489d6 100644 --- a/src/docx/opc/oxml.py +++ b/src/docx/opc/oxml.py @@ -38,7 +38,7 @@ def parse_xml(text: str) -> etree._Element: return etree.fromstring(text, oxml_parser) -def qn(tag): +def qn(tag: str) -> str: """Stands for "qualified name", a utility function to turn a namespace prefixed tag name into a Clark-notation qualified tag name for lxml. @@ -50,7 +50,7 @@ def qn(tag): return "{%s}%s" % (uri, tagroot) -def serialize_part_xml(part_elm: etree._Element): +def serialize_part_xml(part_elm: etree._Element) -> bytes: """Serialize `part_elm` etree element to XML suitable for storage as an XML part. That is to say, no insignificant whitespace added for readability, and an @@ -59,7 +59,7 @@ def serialize_part_xml(part_elm: etree._Element): return etree.tostring(part_elm, encoding="UTF-8", standalone=True) -def serialize_for_reading(element): +def serialize_for_reading(element: etree._Element) -> str: """Serialize `element` to human-readable XML suitable for tests. No XML declaration. @@ -77,7 +77,7 @@ class BaseOxmlElement(etree.ElementBase): classes in one place.""" @property - def xml(self): + def xml(self) -> str: """Return XML string for this element, suitable for testing purposes. Pretty printed for readability and without an XML declaration at the top. @@ -86,8 +86,10 @@ def xml(self): class CT_Default(BaseOxmlElement): - """```` element, specifying the default content type to be applied to a - part with the specified extension.""" + """`` element that appears in `[Content_Types].xml` part. + + Used to specify a default content type to be applied to any part with the specified extension. + """ @property def content_type(self): @@ -101,9 +103,8 @@ def extension(self): return self.get("Extension") @staticmethod - def new(ext, content_type): - """Return a new ```` element with attributes set to parameter - values.""" + def new(ext: str, content_type: str): + """Return a new ```` element with attributes set to parameter values.""" xml = '' % nsmap["ct"] default = parse_xml(xml) default.set("Extension", ext) @@ -123,8 +124,7 @@ def content_type(self): @staticmethod def new(partname, content_type): - """Return a new ```` element with attributes set to parameter - values.""" + """Return a new ```` element with attributes set to parameter values.""" xml = '' % nsmap["ct"] override = parse_xml(xml) override.set("PartName", partname) @@ -138,8 +138,7 @@ def partname(self): class CT_Relationship(BaseOxmlElement): - """```` element, representing a single relationship from a source to a - target part.""" + """`` element, representing a single relationship from source to target part.""" @staticmethod def new(rId: str, reltype: str, target: str, target_mode: str = RTM.INTERNAL): diff --git a/src/docx/opc/package.py b/src/docx/opc/package.py index 3b1eef256..3c1cdca22 100644 --- a/src/docx/opc/package.py +++ b/src/docx/opc/package.py @@ -14,6 +14,8 @@ from docx.shared import lazyproperty if TYPE_CHECKING: + from typing_extensions import Self + from docx.opc.coreprops import CoreProperties from docx.opc.part import Part from docx.opc.rel import _Relationship # pyright: ignore[reportPrivateUsage] @@ -26,9 +28,6 @@ class OpcPackage: to a package file or file-like object containing one. """ - def __init__(self): - super(OpcPackage, self).__init__() - def after_unmarshal(self): """Entry point for any post-unmarshaling processing. @@ -122,7 +121,7 @@ def next_partname(self, template: str) -> PackURI: return PackURI(candidate_partname) @classmethod - def open(cls, pkg_file: str | IO[bytes]) -> OpcPackage: + def open(cls, pkg_file: str | IO[bytes]) -> Self: """Return an |OpcPackage| instance loaded with the contents of `pkg_file`.""" pkg_reader = PackageReader.from_file(pkg_file) package = cls() diff --git a/src/docx/opc/packuri.py b/src/docx/opc/packuri.py index fdbb67ed8..89437b164 100644 --- a/src/docx/opc/packuri.py +++ b/src/docx/opc/packuri.py @@ -10,8 +10,7 @@ class PackURI(str): - """Provides access to pack URI components such as the baseURI and the filename - slice. + """Provides access to pack URI components such as the baseURI and the filename slice. Behaves as |str| otherwise. """ diff --git a/src/docx/opc/pkgreader.py b/src/docx/opc/pkgreader.py index f00e7b5f0..15207e517 100644 --- a/src/docx/opc/pkgreader.py +++ b/src/docx/opc/pkgreader.py @@ -22,9 +22,7 @@ def from_file(pkg_file): phys_reader = PhysPkgReader(pkg_file) content_types = _ContentTypeMap.from_xml(phys_reader.content_types_xml) pkg_srels = PackageReader._srels_for(phys_reader, PACKAGE_URI) - sparts = PackageReader._load_serialized_parts( - phys_reader, pkg_srels, content_types - ) + sparts = PackageReader._load_serialized_parts(phys_reader, pkg_srels, content_types) phys_reader.close() return PackageReader(content_types, pkg_srels, sparts) @@ -80,9 +78,7 @@ def _walk_phys_parts(phys_reader, srels, visited_partnames=None): part_srels = PackageReader._srels_for(phys_reader, partname) blob = phys_reader.blob_for(partname) yield (partname, blob, reltype, part_srels) - next_walker = PackageReader._walk_phys_parts( - phys_reader, part_srels, visited_partnames - ) + next_walker = PackageReader._walk_phys_parts(phys_reader, part_srels, visited_partnames) for partname, blob, reltype, srels in next_walker: yield (partname, blob, reltype, srels) diff --git a/src/docx/opc/rel.py b/src/docx/opc/rel.py index 47e8860d8..153b308d0 100644 --- a/src/docx/opc/rel.py +++ b/src/docx/opc/rel.py @@ -79,9 +79,7 @@ def matches(rel: _Relationship, reltype: str, target: Part | str, is_external: b if rel.is_external != is_external: return False rel_target = rel.target_ref if rel.is_external else rel.target_part - if rel_target != target: - return False - return True + return rel_target == target for rel in self.values(): if matches(rel, reltype, target, is_external): @@ -142,7 +140,7 @@ def rId(self) -> str: def target_part(self) -> Part: if self._is_external: raise ValueError( - "target_part property on _Relationship is undef" "ined when target mode is External" + "target_part property on _Relationship is undefined when target mode is External" ) return cast("Part", self._target) diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index bf32932f9..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, @@ -234,6 +241,7 @@ register_element_cls("w:jc", CT_Jc) register_element_cls("w:keepLines", CT_OnOff) register_element_cls("w:keepNext", CT_OnOff) +register_element_cls("w:outlineLvl", CT_DecimalNumber) register_element_cls("w:pageBreakBefore", CT_OnOff) register_element_cls("w:pPr", CT_PPr) register_element_cls("w:pStyle", CT_String) diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py new file mode 100644 index 000000000..ad9821759 --- /dev/null +++ b/src/docx/oxml/comments.py @@ -0,0 +1,124 @@ +"""Custom element classes related to document comments.""" + +from __future__ import annotations + +import datetime as dt +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 + +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. + + 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. + """ + + # -- type-declarations to fill in the gaps for metaclass-added methods -- + comment_lst: list[CT_Comment] + + 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. + + 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. + """ + + # -- 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] + "w:initials", ST_String + ) + 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/src/docx/oxml/coreprops.py b/src/docx/oxml/coreprops.py index 8ba9ff42e..fcff0c7ba 100644 --- a/src/docx/oxml/coreprops.py +++ b/src/docx/oxml/coreprops.py @@ -4,7 +4,7 @@ import datetime as dt import re -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, cast from docx.oxml.ns import nsdecls, qn from docx.oxml.parser import parse_xml @@ -45,14 +45,14 @@ class CT_CoreProperties(BaseOxmlElement): _coreProperties_tmpl = "\n" % nsdecls("cp", "dc", "dcterms") @classmethod - def new(cls): + def new(cls) -> CT_CoreProperties: """Return a new `` element.""" xml = cls._coreProperties_tmpl - coreProperties = parse_xml(xml) + coreProperties = cast(CT_CoreProperties, parse_xml(xml)) return coreProperties @property - def author_text(self): + def author_text(self) -> str: """The text in the `dc:creator` child element.""" return self._text_of_element("creator") @@ -77,7 +77,7 @@ def comments_text(self, value: str): self._set_element_text("description", value) @property - def contentStatus_text(self): + def contentStatus_text(self) -> str: return self._text_of_element("contentStatus") @contentStatus_text.setter @@ -85,7 +85,7 @@ def contentStatus_text(self, value: str): self._set_element_text("contentStatus", value) @property - def created_datetime(self): + def created_datetime(self) -> dt.datetime | None: return self._datetime_of_element("created") @created_datetime.setter @@ -93,7 +93,7 @@ def created_datetime(self, value: dt.datetime): self._set_element_datetime("created", value) @property - def identifier_text(self): + def identifier_text(self) -> str: return self._text_of_element("identifier") @identifier_text.setter @@ -101,7 +101,7 @@ def identifier_text(self, value: str): self._set_element_text("identifier", value) @property - def keywords_text(self): + def keywords_text(self) -> str: return self._text_of_element("keywords") @keywords_text.setter @@ -109,7 +109,7 @@ def keywords_text(self, value: str): self._set_element_text("keywords", value) @property - def language_text(self): + def language_text(self) -> str: return self._text_of_element("language") @language_text.setter @@ -117,7 +117,7 @@ def language_text(self, value: str): self._set_element_text("language", value) @property - def lastModifiedBy_text(self): + def lastModifiedBy_text(self) -> str: return self._text_of_element("lastModifiedBy") @lastModifiedBy_text.setter @@ -125,7 +125,7 @@ def lastModifiedBy_text(self, value: str): self._set_element_text("lastModifiedBy", value) @property - def lastPrinted_datetime(self): + def lastPrinted_datetime(self) -> dt.datetime | None: return self._datetime_of_element("lastPrinted") @lastPrinted_datetime.setter @@ -141,7 +141,7 @@ def modified_datetime(self, value: dt.datetime): self._set_element_datetime("modified", value) @property - def revision_number(self): + def revision_number(self) -> int: """Integer value of revision property.""" revision = self.revision if revision is None: @@ -167,7 +167,7 @@ def revision_number(self, value: int): revision.text = str(value) @property - def subject_text(self): + def subject_text(self) -> str: return self._text_of_element("subject") @subject_text.setter @@ -175,7 +175,7 @@ def subject_text(self, value: str): self._set_element_text("subject", value) @property - def title_text(self): + def title_text(self) -> str: return self._text_of_element("title") @title_text.setter @@ -183,7 +183,7 @@ def title_text(self, value: str): self._set_element_text("title", value) @property - def version_text(self): + def version_text(self) -> str: return self._text_of_element("version") @version_text.setter @@ -257,7 +257,7 @@ def _parse_W3CDTF_to_datetime(cls, w3cdtf_str: str) -> dt.datetime: dt_ = cls._offset_dt(dt_, offset_str) return dt_.replace(tzinfo=dt.timezone.utc) - def _set_element_datetime(self, prop_name: str, value: dt.datetime): + def _set_element_datetime(self, prop_name: str, value: dt.datetime) -> None: """Set date/time value of child element having `prop_name` to `value`.""" if not isinstance(value, dt.datetime): # pyright: ignore[reportUnnecessaryIsInstance] tmpl = "property requires object, got %s" diff --git a/src/docx/oxml/ns.py b/src/docx/oxml/ns.py index 5bed1e6a0..ce03940f7 100644 --- a/src/docx/oxml/ns.py +++ b/src/docx/oxml/ns.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict +from typing import Dict nsmap = { "a": "http://schemas.openxmlformats.org/drawingml/2006/main", @@ -29,7 +29,7 @@ class NamespacePrefixedTag(str): """Value object that knows the semantics of an XML tag having a namespace prefix.""" - def __new__(cls, nstag: str, *args: Any): + def __new__(cls, nstag: str): return super(NamespacePrefixedTag, cls).__new__(cls, nstag) def __init__(self, nstag: str): diff --git a/src/docx/oxml/shape.py b/src/docx/oxml/shape.py index 289d35579..c6df8e7b8 100644 --- a/src/docx/oxml/shape.py +++ b/src/docx/oxml/shape.py @@ -100,7 +100,6 @@ def new_pic_inline( pic_id = 0 # Word doesn't seem to use this, but does not omit it pic = CT_Picture.new(pic_id, filename, rId, cx, cy) inline = cls.new(cx, cy, shape_id, pic) - inline.graphic.graphicData._insert_pic(pic) return inline @classmethod @@ -145,10 +144,8 @@ class CT_Picture(BaseOxmlElement): spPr: CT_ShapeProperties = OneAndOnlyOne("pic:spPr") # pyright: ignore[reportAssignmentType] @classmethod - def new(cls, pic_id, filename, rId, cx, cy): - """Return a new ```` element populated with the minimal contents - required to define a viable picture element, based on the values passed as - parameters.""" + def new(cls, pic_id: int, filename: str, rId: str, cx: Length, cy: Length) -> CT_Picture: + """A new minimum viable `` (picture) element.""" pic = parse_xml(cls._pic_xml()) pic.nvPicPr.cNvPr.id = pic_id pic.nvPicPr.cNvPr.name = filename diff --git a/src/docx/oxml/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/simpletypes.py b/src/docx/oxml/simpletypes.py index dd10ab910..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 @@ -125,7 +126,7 @@ def convert_to_xml(cls, value: bool) -> str: def validate(cls, value: Any) -> None: if value not in (True, False): raise TypeError( - "only True or False (and possibly None) may be assigned, got" " '%s'" % value + "only True or False (and possibly None) may be assigned, got '%s'" % value ) @@ -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/src/docx/oxml/table.py b/src/docx/oxml/table.py index e38d58562..9457da207 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -519,7 +519,7 @@ def merge(self, other_tc: CT_Tc) -> CT_Tc: @classmethod def new(cls) -> CT_Tc: """A new `w:tc` element, containing an empty paragraph as the required EG_BlockLevelElt.""" - return cast(CT_Tc, parse_xml("\n" " \n" "" % nsdecls("w"))) + return cast(CT_Tc, parse_xml("" % nsdecls("w"))) @property def right(self) -> int: @@ -583,7 +583,9 @@ def vMerge_val(top_tc: CT_Tc): return ( ST_Merge.CONTINUE if top_tc is not self - else None if height == 1 else ST_Merge.RESTART + else None + if height == 1 + else ST_Merge.RESTART ) top_tc = self if top_tc is None else top_tc @@ -609,9 +611,7 @@ def _is_empty(self) -> bool: # -- cell must include at least one block item but can be a `w:tbl`, `w:sdt`, # -- `w:customXml` or a `w:p` only_item = block_items[0] - if isinstance(only_item, CT_P) and len(only_item.r_lst) == 0: - return True - return False + return isinstance(only_item, CT_P) and len(only_item.r_lst) == 0 def _move_content_to(self, other_tc: CT_Tc): """Append the content of this cell to `other_tc`. diff --git a/src/docx/oxml/text/font.py b/src/docx/oxml/text/font.py index 140086aab..32eb567ba 100644 --- a/src/docx/oxml/text/font.py +++ b/src/docx/oxml/text/font.py @@ -1,3 +1,5 @@ +# pyright: reportAssignmentType=false + """Custom element classes related to run properties (font).""" from __future__ import annotations @@ -20,6 +22,7 @@ RequiredAttribute, ZeroOrOne, ) +from docx.shared import RGBColor if TYPE_CHECKING: from docx.oxml.shared import CT_OnOff, CT_String @@ -29,8 +32,8 @@ class CT_Color(BaseOxmlElement): """`w:color` element, specifying the color of a font and perhaps other objects.""" - val = RequiredAttribute("w:val", ST_HexColor) - themeColor = OptionalAttribute("w:themeColor", MSO_THEME_COLOR) + val: RGBColor | str = RequiredAttribute("w:val", ST_HexColor) + themeColor: MSO_THEME_COLOR | None = OptionalAttribute("w:themeColor", MSO_THEME_COLOR) class CT_Fonts(BaseOxmlElement): @@ -39,39 +42,33 @@ class CT_Fonts(BaseOxmlElement): Specifies typeface name for the various language types. """ - ascii: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] - "w:ascii", ST_String - ) - hAnsi: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] - "w:hAnsi", ST_String - ) + ascii: str | None = OptionalAttribute("w:ascii", ST_String) + hAnsi: str | None = OptionalAttribute("w:hAnsi", ST_String) class CT_Highlight(BaseOxmlElement): """`w:highlight` element, specifying font highlighting/background color.""" - val: WD_COLOR_INDEX = RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:val", WD_COLOR_INDEX - ) + val: WD_COLOR_INDEX = RequiredAttribute("w:val", WD_COLOR_INDEX) class CT_HpsMeasure(BaseOxmlElement): """Used for `` element and others, specifying font size in half-points.""" - val: Length = RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:val", ST_HpsMeasure - ) + val: Length = RequiredAttribute("w:val", ST_HpsMeasure) class CT_RPr(BaseOxmlElement): """`` element, containing the properties for a run.""" + get_or_add_color: Callable[[], CT_Color] get_or_add_highlight: Callable[[], CT_Highlight] get_or_add_rFonts: Callable[[], CT_Fonts] get_or_add_sz: Callable[[], CT_HpsMeasure] get_or_add_vertAlign: Callable[[], CT_VerticalAlignRun] _add_rStyle: Callable[..., CT_String] _add_u: Callable[[], CT_Underline] + _remove_color: Callable[[], None] _remove_highlight: Callable[[], None] _remove_rFonts: Callable[[], None] _remove_rStyle: Callable[[], None] @@ -120,15 +117,9 @@ class CT_RPr(BaseOxmlElement): "w:specVanish", "w:oMath", ) - rStyle: CT_String | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:rStyle", successors=_tag_seq[1:] - ) - rFonts: CT_Fonts | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:rFonts", successors=_tag_seq[2:] - ) - b: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:b", successors=_tag_seq[3:] - ) + rStyle: CT_String | None = ZeroOrOne("w:rStyle", successors=_tag_seq[1:]) + rFonts: CT_Fonts | None = ZeroOrOne("w:rFonts", successors=_tag_seq[2:]) + b: CT_OnOff | None = ZeroOrOne("w:b", successors=_tag_seq[3:]) bCs = ZeroOrOne("w:bCs", successors=_tag_seq[4:]) i = ZeroOrOne("w:i", successors=_tag_seq[5:]) iCs = ZeroOrOne("w:iCs", successors=_tag_seq[6:]) @@ -144,19 +135,11 @@ class CT_RPr(BaseOxmlElement): snapToGrid = ZeroOrOne("w:snapToGrid", successors=_tag_seq[16:]) vanish = ZeroOrOne("w:vanish", successors=_tag_seq[17:]) webHidden = ZeroOrOne("w:webHidden", successors=_tag_seq[18:]) - color = ZeroOrOne("w:color", successors=_tag_seq[19:]) - sz: CT_HpsMeasure | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:sz", successors=_tag_seq[24:] - ) - highlight: CT_Highlight | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:highlight", successors=_tag_seq[26:] - ) - u: CT_Underline | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:u", successors=_tag_seq[27:] - ) - vertAlign: CT_VerticalAlignRun | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:vertAlign", successors=_tag_seq[32:] - ) + color: CT_Color | None = ZeroOrOne("w:color", successors=_tag_seq[19:]) + sz: CT_HpsMeasure | None = ZeroOrOne("w:sz", successors=_tag_seq[24:]) + highlight: CT_Highlight | None = ZeroOrOne("w:highlight", successors=_tag_seq[26:]) + u: CT_Underline | None = ZeroOrOne("w:u", successors=_tag_seq[27:]) + vertAlign: CT_VerticalAlignRun | None = ZeroOrOne("w:vertAlign", successors=_tag_seq[32:]) rtl = ZeroOrOne("w:rtl", successors=_tag_seq[33:]) cs = ZeroOrOne("w:cs", successors=_tag_seq[34:]) specVanish = ZeroOrOne("w:specVanish", successors=_tag_seq[38:]) @@ -253,9 +236,7 @@ def subscript(self) -> bool | None: vertAlign = self.vertAlign if vertAlign is None: return None - if vertAlign.val == ST_VerticalAlignRun.SUBSCRIPT: - return True - return False + return vertAlign.val == ST_VerticalAlignRun.SUBSCRIPT @subscript.setter def subscript(self, value: bool | None) -> None: @@ -277,9 +258,7 @@ def superscript(self) -> bool | None: vertAlign = self.vertAlign if vertAlign is None: return None - if vertAlign.val == ST_VerticalAlignRun.SUPERSCRIPT: - return True - return False + return vertAlign.val == ST_VerticalAlignRun.SUPERSCRIPT @superscript.setter def superscript(self, value: bool | None): @@ -343,14 +322,10 @@ def _set_bool_val(self, name: str, value: bool | None): class CT_Underline(BaseOxmlElement): """`` element, specifying the underlining style for a run.""" - val: WD_UNDERLINE | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] - "w:val", WD_UNDERLINE - ) + val: WD_UNDERLINE | None = OptionalAttribute("w:val", WD_UNDERLINE) class CT_VerticalAlignRun(BaseOxmlElement): """`` element, specifying subscript or superscript.""" - val: str = RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:val", ST_VerticalAlignRun - ) + val: str = RequiredAttribute("w:val", ST_VerticalAlignRun) diff --git a/src/docx/oxml/text/pagebreak.py b/src/docx/oxml/text/pagebreak.py index 943f9b6c2..45a6f51a7 100644 --- a/src/docx/oxml/text/pagebreak.py +++ b/src/docx/oxml/text/pagebreak.py @@ -46,9 +46,7 @@ def following_fragment_p(self) -> CT_P: # -- splitting approach is different when break is inside a hyperlink -- return ( - self._following_frag_in_hlink - if self._is_in_hyperlink - else self._following_frag_in_run + self._following_frag_in_hlink if self._is_in_hyperlink else self._following_frag_in_run ) @property @@ -116,9 +114,7 @@ def preceding_fragment_p(self) -> CT_P: # -- splitting approach is different when break is inside a hyperlink -- return ( - self._preceding_frag_in_hlink - if self._is_in_hyperlink - else self._preceding_frag_in_run + self._preceding_frag_in_hlink if self._is_in_hyperlink else self._preceding_frag_in_run ) def _enclosing_hyperlink(self, lrpb: CT_LastRenderedPageBreak) -> CT_Hyperlink: @@ -139,9 +135,7 @@ def _first_lrpb_in_p(self, p: CT_P) -> CT_LastRenderedPageBreak: Raises `ValueError` if there are no rendered page-breaks in `p`. """ - lrpbs = p.xpath( - "./w:r/w:lastRenderedPageBreak | ./w:hyperlink/w:r/w:lastRenderedPageBreak" - ) + lrpbs = p.xpath("./w:r/w:lastRenderedPageBreak | ./w:hyperlink/w:r/w:lastRenderedPageBreak") if not lrpbs: raise ValueError("no rendered page-breaks in paragraph element") return lrpbs[0] diff --git a/src/docx/oxml/text/parfmt.py b/src/docx/oxml/text/parfmt.py index de5609636..2133686b2 100644 --- a/src/docx/oxml/text/parfmt.py +++ b/src/docx/oxml/text/parfmt.py @@ -10,6 +10,7 @@ WD_TAB_ALIGNMENT, WD_TAB_LEADER, ) +from docx.oxml.shared import CT_DecimalNumber from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure from docx.oxml.xmlchemy import ( BaseOxmlElement, @@ -55,6 +56,7 @@ class CT_PPr(BaseOxmlElement): get_or_add_ind: Callable[[], CT_Ind] get_or_add_pStyle: Callable[[], CT_String] + get_or_add_sectPr: Callable[[], CT_SectPr] _insert_sectPr: Callable[[CT_SectPr], None] _remove_pStyle: Callable[[], None] _remove_sectPr: Callable[[], None] @@ -111,6 +113,9 @@ class CT_PPr(BaseOxmlElement): "w:ind", successors=_tag_seq[23:] ) jc = ZeroOrOne("w:jc", successors=_tag_seq[27:]) + outlineLvl: CT_DecimalNumber = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:outlineLvl", successors=_tag_seq[31:] + ) sectPr = ZeroOrOne("w:sectPr", successors=_tag_seq[35:]) del _tag_seq diff --git a/src/docx/oxml/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/oxml/xmlchemy.py b/src/docx/oxml/xmlchemy.py index 077bcd583..e2c54b392 100644 --- a/src/docx/oxml/xmlchemy.py +++ b/src/docx/oxml/xmlchemy.py @@ -5,17 +5,7 @@ from __future__ import annotations import re -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Dict, - List, - Sequence, - Tuple, - Type, - TypeVar, -) +from typing import TYPE_CHECKING, Any, Callable, Sequence, Type, TypeVar from lxml import etree from lxml.etree import ElementBase, _Element # pyright: ignore[reportPrivateUsage] @@ -65,7 +55,7 @@ def __eq__(self, other: object) -> bool: def __ne__(self, other: object) -> bool: return not self.__eq__(other) - def _attr_seq(self, attrs: str) -> List[str]: + def _attr_seq(self, attrs: str) -> list[str]: """Return a sequence of attribute strings parsed from `attrs`. Each attribute string is stripped of whitespace on both ends. @@ -85,12 +75,10 @@ def _eq_elm_strs(self, line: str, line_2: str): return False if close != close_2: return False - if text != text_2: - return False - return True + return text == text_2 @classmethod - def _parse_line(cls, line: str) -> Tuple[str, str, str, str]: + def _parse_line(cls, line: str) -> tuple[str, str, str, str]: """(front, attrs, close, text) 4-tuple result of parsing XML element `line`.""" match = cls._xml_elm_line_patt.match(line) if match is None: @@ -105,7 +93,7 @@ def _parse_line(cls, line: str) -> Tuple[str, str, str, str]: class MetaOxmlElement(type): """Metaclass for BaseOxmlElement.""" - def __init__(cls, clsname: str, bases: Tuple[type, ...], namespace: Dict[str, Any]): + def __init__(cls, clsname: str, bases: tuple[type, ...], namespace: dict[str, Any]): dispatchable = ( OneAndOnlyOne, OneOrMore, @@ -280,7 +268,7 @@ class _BaseChildElement: and ZeroOrMore. """ - def __init__(self, nsptagname: str, successors: Tuple[str, ...] = ()): + def __init__(self, nsptagname: str, successors: tuple[str, ...] = ()): super(_BaseChildElement, self).__init__() self._nsptagname = nsptagname self._successors = successors @@ -435,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): @@ -446,7 +433,7 @@ def populate_class_members( # pyright: ignore[reportIncompatibleMethodOverride] self, element_cls: MetaOxmlElement, group_prop_name: str, - successors: Tuple[str, ...], + successors: tuple[str, ...], ) -> None: """Add the appropriate methods to `element_cls`.""" self._element_cls = element_cls @@ -474,7 +461,7 @@ def get_or_change_to_child(obj: BaseOxmlElement): return child get_or_change_to_child.__doc__ = ( - "Return the ``<%s>`` child, replacing any other group element if" " found." + "Return the ``<%s>`` child, replacing any other group element if found." ) % self._nsptagname self._add_to_class(self._get_or_change_to_method_name, get_or_change_to_child) @@ -597,7 +584,7 @@ class ZeroOrOneChoice(_BaseChildElement): """Correspondes to an ``EG_*`` element group where at most one of its members may appear as a child.""" - def __init__(self, choices: Sequence[Choice], successors: Tuple[str, ...] = ()): + def __init__(self, choices: Sequence[Choice], successors: tuple[str, ...] = ()): self._choices = choices self._successors = successors diff --git a/src/docx/parts/comments.py b/src/docx/parts/comments.py new file mode 100644 index 000000000..0e4cc7438 --- /dev/null +++ b/src/docx/parts/comments.py @@ -0,0 +1,51 @@ +"""Contains comments added to the document.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, 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 + +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.""" + + 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.""" + return Comments(self._comments, self) + + @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 416bb1a27..4960264b1 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -5,8 +5,8 @@ from typing import IO, TYPE_CHECKING, cast from docx.document import Document -from docx.enum.style import WD_STYLE_TYPE from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.parts.comments import CommentsPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.numbering import NumberingPart from docx.parts.settings import SettingsPart @@ -16,6 +16,8 @@ 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 from docx.styles.style import BaseStyle @@ -42,6 +44,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.""" + return self._comments_part.comments + @property def core_properties(self) -> CoreProperties: """A |CoreProperties| object providing read/write access to the core properties @@ -89,14 +96,13 @@ def inline_shapes(self): return InlineShapes(self._element.body, self) @lazyproperty - def numbering_part(self): - """A |NumberingPart| object providing access to the numbering definitions for - this document. + def numbering_part(self) -> NumberingPart: + """A |NumberingPart| object providing access to the numbering definitions for this document. Creates an empty numbering part if one is not present. """ try: - return self.part_related_by(RT.NUMBERING) + return cast(NumberingPart, self.part_related_by(RT.NUMBERING)) except KeyError: numbering_part = NumberingPart.new() self.relate_to(numbering_part, RT.NUMBERING) @@ -119,6 +125,20 @@ 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. + """ + 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: """A |SettingsPart| object providing access to the document-level settings for diff --git a/src/docx/parts/numbering.py b/src/docx/parts/numbering.py index 54a430c1b..745c8458a 100644 --- a/src/docx/parts/numbering.py +++ b/src/docx/parts/numbering.py @@ -9,9 +9,8 @@ class NumberingPart(XmlPart): or glossary.""" @classmethod - def new(cls): - """Return newly created empty numbering part, containing only the root - ```` element.""" + def new(cls) -> "NumberingPart": + """Newly created numbering part, containing only the root ```` element.""" raise NotImplementedError @lazyproperty diff --git a/src/docx/parts/settings.py b/src/docx/parts/settings.py index 116facca2..7fe371f09 100644 --- a/src/docx/parts/settings.py +++ b/src/docx/parts/settings.py @@ -27,8 +27,7 @@ def __init__( @classmethod def default(cls, package: Package): - """Return a newly created settings part, containing a default `w:settings` - element tree.""" + """Return a newly created settings part, containing a default `w:settings` element tree.""" partname = PackURI("/word/settings.xml") content_type = CT.WML_SETTINGS element = cast("CT_Settings", parse_xml(cls._default_settings_xml())) diff --git a/src/docx/parts/styles.py b/src/docx/parts/styles.py index dffa762ef..6e065beee 100644 --- a/src/docx/parts/styles.py +++ b/src/docx/parts/styles.py @@ -36,9 +36,7 @@ def styles(self): @classmethod def _default_styles_xml(cls): """Return a bytestream containing XML for a default styles part.""" - path = os.path.join( - os.path.split(__file__)[0], "..", "templates", "default-styles.xml" - ) + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-styles.xml") with open(path, "rb") as f: xml_bytes = f.read() return xml_bytes diff --git a/src/docx/shared.py b/src/docx/shared.py index 491d42741..6c12dc91e 100644 --- a/src/docx/shared.py +++ b/src/docx/shared.py @@ -127,11 +127,9 @@ class RGBColor(Tuple[int, int, int]): def __new__(cls, r: int, g: int, b: int): msg = "RGBColor() takes three integer values 0-255" for val in (r, g, b): - if ( - not isinstance(val, int) # pyright: ignore[reportUnnecessaryIsInstance] - or val < 0 - or val > 255 - ): + if not isinstance(val, int): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError(msg) + if val < 0 or val > 255: raise ValueError(msg) return super(RGBColor, cls).__new__(cls, (r, g, b)) @@ -330,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/src/docx/styles/styles.py b/src/docx/styles/styles.py index 98a56e520..b05b3ebb1 100644 --- a/src/docx/styles/styles.py +++ b/src/docx/styles/styles.py @@ -40,10 +40,7 @@ def __getitem__(self, key: str): style_elm = self._element.get_by_id(key) if style_elm is not None: - msg = ( - "style lookup by style_id is deprecated. Use style name as " - "key instead." - ) + msg = "style lookup by style_id is deprecated. Use style name as key instead." warn(msg, UserWarning, stacklevel=2) return StyleFactory(style_elm) @@ -118,9 +115,7 @@ def _get_by_id(self, style_id: str | None, style_type: WD_STYLE_TYPE): return self.default(style_type) return StyleFactory(style) - def _get_style_id_from_name( - self, style_name: str, style_type: WD_STYLE_TYPE - ) -> str | None: + def _get_style_id_from_name(self, style_name: str, style_type: WD_STYLE_TYPE) -> str | None: """Return the id of the style of `style_type` corresponding to `style_name`. Returns |None| if that style is the default style for `style_type`. Raises @@ -129,17 +124,13 @@ def _get_style_id_from_name( """ return self._get_style_id_from_style(self[style_name], style_type) - def _get_style_id_from_style( - self, style: BaseStyle, style_type: WD_STYLE_TYPE - ) -> str | None: + def _get_style_id_from_style(self, style: BaseStyle, style_type: WD_STYLE_TYPE) -> str | None: """Id of `style`, or |None| if it is the default style of `style_type`. Raises |ValueError| if style is not of `style_type`. """ if style.type != style_type: - raise ValueError( - "assigned style is type %s, need type %s" % (style.type, style_type) - ) + raise ValueError("assigned style is type %s, need type %s" % (style.type, style_type)) if style == self.default(style_type): return None return style.style_id diff --git a/src/docx/templates/default-comments.xml b/src/docx/templates/default-comments.xml new file mode 100644 index 000000000..2a36ca987 --- /dev/null +++ b/src/docx/templates/default-comments.xml @@ -0,0 +1,12 @@ + + diff --git a/src/docx/text/font.py b/src/docx/text/font.py index acd60795b..0439f4547 100644 --- a/src/docx/text/font.py +++ b/src/docx/text/font.py @@ -398,11 +398,7 @@ def underline(self, value: bool | WD_UNDERLINE | None) -> None: # -- False == 0, which happen to match the mapping for WD_UNDERLINE.SINGLE # -- and .NONE respectively. val = ( - WD_UNDERLINE.SINGLE - if value is True - else WD_UNDERLINE.NONE - if value is False - else value + WD_UNDERLINE.SINGLE if value is True else WD_UNDERLINE.NONE if value is False else value ) rPr.u_val = val diff --git a/src/docx/text/run.py b/src/docx/text/run.py index 0e2f5bc17..57ea31fa4 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -173,6 +173,18 @@ 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. + """ + # -- 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: """Read/write. @@ -233,7 +245,7 @@ def underline(self) -> bool | WD_UNDERLINE | None: return self.font.underline @underline.setter - def underline(self, value: bool): + def underline(self, value: bool | WD_UNDERLINE | None): self.font.underline = value diff --git a/src/docx/text/tabstops.py b/src/docx/text/tabstops.py index 824085d2b..0f8c22c9c 100644 --- a/src/docx/text/tabstops.py +++ b/src/docx/text/tabstops.py @@ -50,9 +50,7 @@ def __len__(self): return 0 return len(tabs.tab_lst) - def add_tab_stop( - self, position, alignment=WD_TAB_ALIGNMENT.LEFT, leader=WD_TAB_LEADER.SPACES - ): + def add_tab_stop(self, position, alignment=WD_TAB_ALIGNMENT.LEFT, leader=WD_TAB_LEADER.SPACES): """Add a new tab stop at `position`, a |Length| object specifying the location of the tab stop relative to the paragraph edge. diff --git a/src/docx/types.py b/src/docx/types.py index 00bc100a1..06d1a571a 100644 --- a/src/docx/types.py +++ b/src/docx/types.py @@ -19,8 +19,7 @@ class ProvidesStoryPart(Protocol): """ @property - def part(self) -> StoryPart: - ... + def part(self) -> StoryPart: ... class ProvidesXmlPart(Protocol): @@ -32,5 +31,4 @@ class ProvidesXmlPart(Protocol): """ @property - def part(self) -> XmlPart: - ... + def part(self) -> XmlPart: ... diff --git a/tests/dml/test_color.py b/tests/dml/test_color.py index ea848e7d6..f9fcae0c6 100644 --- a/tests/dml/test_color.py +++ b/tests/dml/test_color.py @@ -1,57 +1,59 @@ -"""Test suite for docx.dml.color module.""" +# pyright: reportPrivateUsage=false + +"""Unit-test suite for the `docx.dml.color` module.""" + +from __future__ import annotations + +from typing import cast import pytest from docx.dml.color import ColorFormat from docx.enum.dml import MSO_COLOR_TYPE, MSO_THEME_COLOR +from docx.oxml.text.run import CT_R from docx.shared import RGBColor from ..unitutil.cxml import element, xml class DescribeColorFormat: - def it_knows_its_color_type(self, type_fixture): - color_format, expected_value = type_fixture - assert color_format.type == expected_value - - def it_knows_its_RGB_value(self, rgb_get_fixture): - color_format, expected_value = rgb_get_fixture - assert color_format.rgb == expected_value - - def it_can_change_its_RGB_value(self, rgb_set_fixture): - color_format, new_value, expected_xml = rgb_set_fixture - color_format.rgb = new_value - assert color_format._element.xml == expected_xml - - def it_knows_its_theme_color(self, theme_color_get_fixture): - color_format, expected_value = theme_color_get_fixture - assert color_format.theme_color == expected_value - - def it_can_change_its_theme_color(self, theme_color_set_fixture): - color_format, new_value, expected_xml = theme_color_set_fixture - color_format.theme_color = new_value - assert color_format._element.xml == expected_xml + """Unit-test suite for `docx.dml.color.ColorFormat` objects.""" - # fixtures --------------------------------------------- + @pytest.mark.parametrize( + ("r_cxml", "expected_value"), + [ + ("w:r", None), + ("w:r/w:rPr", None), + ("w:r/w:rPr/w:color{w:val=auto}", MSO_COLOR_TYPE.AUTO), + ("w:r/w:rPr/w:color{w:val=4224FF}", MSO_COLOR_TYPE.RGB), + ("w:r/w:rPr/w:color{w:themeColor=dark1}", MSO_COLOR_TYPE.THEME), + ( + "w:r/w:rPr/w:color{w:val=F00BA9,w:themeColor=accent1}", + MSO_COLOR_TYPE.THEME, + ), + ], + ) + def it_knows_its_color_type(self, r_cxml: str, expected_value: MSO_COLOR_TYPE | None): + assert ColorFormat(cast(CT_R, element(r_cxml))).type == expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("r_cxml", "rgb"), + [ ("w:r", None), ("w:r/w:rPr", None), ("w:r/w:rPr/w:color{w:val=auto}", None), ("w:r/w:rPr/w:color{w:val=4224FF}", "4224ff"), ("w:r/w:rPr/w:color{w:val=auto,w:themeColor=accent1}", None), ("w:r/w:rPr/w:color{w:val=F00BA9,w:themeColor=accent1}", "f00ba9"), - ] + ], ) - def rgb_get_fixture(self, request): - r_cxml, rgb = request.param - color_format = ColorFormat(element(r_cxml)) - expected_value = None if rgb is None else RGBColor.from_string(rgb) - return color_format, expected_value + def it_knows_its_RGB_value(self, r_cxml: str, rgb: str | None): + expected_value = RGBColor.from_string(rgb) if rgb else None + assert ColorFormat(cast(CT_R, element(r_cxml))).rgb == expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("r_cxml", "new_value", "expected_cxml"), + [ ("w:r", RGBColor(10, 20, 30), "w:r/w:rPr/w:color{w:val=0A141E}"), ("w:r/w:rPr", RGBColor(1, 2, 3), "w:r/w:rPr/w:color{w:val=010203}"), ( @@ -71,73 +73,60 @@ def rgb_get_fixture(self, request): ), ("w:r/w:rPr/w:color{w:val=234bcd,w:themeColor=dark1}", None, "w:r/w:rPr"), ("w:r", None, "w:r"), - ] + ], ) - def rgb_set_fixture(self, request): - r_cxml, new_value, expected_cxml = request.param - color_format = ColorFormat(element(r_cxml)) - expected_xml = xml(expected_cxml) - return color_format, new_value, expected_xml + def it_can_change_its_RGB_value( + self, r_cxml: str, new_value: RGBColor | None, expected_cxml: str + ): + color_format = ColorFormat(cast(CT_R, element(r_cxml))) + color_format.rgb = new_value + assert color_format._element.xml == xml(expected_cxml) - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("r_cxml", "expected_value"), + [ ("w:r", None), ("w:r/w:rPr", None), ("w:r/w:rPr/w:color{w:val=auto}", None), ("w:r/w:rPr/w:color{w:val=4224FF}", None), - ("w:r/w:rPr/w:color{w:themeColor=accent1}", "ACCENT_1"), - ("w:r/w:rPr/w:color{w:val=F00BA9,w:themeColor=dark1}", "DARK_1"), - ] + ("w:r/w:rPr/w:color{w:themeColor=accent1}", MSO_THEME_COLOR.ACCENT_1), + ("w:r/w:rPr/w:color{w:val=F00BA9,w:themeColor=dark1}", MSO_THEME_COLOR.DARK_1), + ], ) - def theme_color_get_fixture(self, request): - r_cxml, value = request.param - color_format = ColorFormat(element(r_cxml)) - expected_value = None if value is None else getattr(MSO_THEME_COLOR, value) - return color_format, expected_value + def it_knows_its_theme_color(self, r_cxml: str, expected_value: MSO_THEME_COLOR | None): + color_format = ColorFormat(cast(CT_R, element(r_cxml))) + assert color_format.theme_color == expected_value - @pytest.fixture( - params=[ - ("w:r", "ACCENT_1", "w:r/w:rPr/w:color{w:val=000000,w:themeColor=accent1}"), + @pytest.mark.parametrize( + ("r_cxml", "new_value", "expected_cxml"), + [ + ( + "w:r", + MSO_THEME_COLOR.ACCENT_1, + "w:r/w:rPr/w:color{w:val=000000,w:themeColor=accent1}", + ), ( "w:r/w:rPr", - "ACCENT_2", + MSO_THEME_COLOR.ACCENT_2, "w:r/w:rPr/w:color{w:val=000000,w:themeColor=accent2}", ), ( "w:r/w:rPr/w:color{w:val=101112}", - "ACCENT_3", + MSO_THEME_COLOR.ACCENT_3, "w:r/w:rPr/w:color{w:val=101112,w:themeColor=accent3}", ), ( "w:r/w:rPr/w:color{w:val=234bcd,w:themeColor=dark1}", - "LIGHT_2", + MSO_THEME_COLOR.LIGHT_2, "w:r/w:rPr/w:color{w:val=234bcd,w:themeColor=light2}", ), ("w:r/w:rPr/w:color{w:val=234bcd,w:themeColor=dark1}", None, "w:r/w:rPr"), ("w:r", None, "w:r"), - ] - ) - def theme_color_set_fixture(self, request): - r_cxml, member, expected_cxml = request.param - color_format = ColorFormat(element(r_cxml)) - new_value = None if member is None else getattr(MSO_THEME_COLOR, member) - expected_xml = xml(expected_cxml) - return color_format, new_value, expected_xml - - @pytest.fixture( - params=[ - ("w:r", None), - ("w:r/w:rPr", None), - ("w:r/w:rPr/w:color{w:val=auto}", MSO_COLOR_TYPE.AUTO), - ("w:r/w:rPr/w:color{w:val=4224FF}", MSO_COLOR_TYPE.RGB), - ("w:r/w:rPr/w:color{w:themeColor=dark1}", MSO_COLOR_TYPE.THEME), - ( - "w:r/w:rPr/w:color{w:val=F00BA9,w:themeColor=accent1}", - MSO_COLOR_TYPE.THEME, - ), - ] + ], ) - def type_fixture(self, request): - r_cxml, expected_value = request.param - color_format = ColorFormat(element(r_cxml)) - return color_format, expected_value + def it_can_change_its_theme_color( + self, r_cxml: str, new_value: MSO_THEME_COLOR | None, expected_cxml: str + ): + color_format = ColorFormat(cast(CT_R, element(r_cxml))) + color_format.theme_color = new_value + assert color_format._element.xml == xml(expected_cxml) diff --git a/tests/image/test_bmp.py b/tests/image/test_bmp.py index 15b322b66..27c0e8f5c 100644 --- a/tests/image/test_bmp.py +++ b/tests/image/test_bmp.py @@ -14,8 +14,8 @@ class DescribeBmp: def it_can_construct_from_a_bmp_stream(self, Bmp__init__): cx, cy, horz_dpi, vert_dpi = 26, 43, 200, 96 bytes_ = ( - b"fillerfillerfiller\x1A\x00\x00\x00\x2B\x00\x00\x00" - b"fillerfiller\xB8\x1E\x00\x00\x00\x00\x00\x00" + b"fillerfillerfiller\x1a\x00\x00\x00\x2b\x00\x00\x00" + b"fillerfiller\xb8\x1e\x00\x00\x00\x00\x00\x00" ) stream = io.BytesIO(bytes_) diff --git a/tests/image/test_gif.py b/tests/image/test_gif.py index a533da04d..4aa6581ba 100644 --- a/tests/image/test_gif.py +++ b/tests/image/test_gif.py @@ -13,7 +13,7 @@ class DescribeGif: def it_can_construct_from_a_gif_stream(self, Gif__init__): cx, cy = 42, 24 - bytes_ = b"filler\x2A\x00\x18\x00" + bytes_ = b"filler\x2a\x00\x18\x00" stream = io.BytesIO(bytes_) gif = Gif.from_stream(stream) diff --git a/tests/image/test_helpers.py b/tests/image/test_helpers.py index 9192564dc..03421ff5f 100644 --- a/tests/image/test_helpers.py +++ b/tests/image/test_helpers.py @@ -28,8 +28,8 @@ def it_can_read_a_long(self, read_long_fixture): @pytest.fixture( params=[ - (BIG_ENDIAN, b"\xBE\x00\x00\x00\x2A\xEF", 1, 42), - (LITTLE_ENDIAN, b"\xBE\xEF\x2A\x00\x00\x00", 2, 42), + (BIG_ENDIAN, b"\xbe\x00\x00\x00\x2a\xef", 1, 42), + (LITTLE_ENDIAN, b"\xbe\xef\x2a\x00\x00\x00", 2, 42), ] ) def read_long_fixture(self, request): diff --git a/tests/image/test_image.py b/tests/image/test_image.py index bd5ed0903..c13e87305 100644 --- a/tests/image/test_image.py +++ b/tests/image/test_image.py @@ -27,9 +27,7 @@ class DescribeImage: - def it_can_construct_from_an_image_blob( - self, blob_, BytesIO_, _from_stream_, stream_, image_ - ): + def it_can_construct_from_an_image_blob(self, blob_, BytesIO_, _from_stream_, stream_, image_): image = Image.from_blob(blob_) BytesIO_.assert_called_once_with(blob_) @@ -231,9 +229,7 @@ def filename_(self, request): @pytest.fixture def _from_stream_(self, request, image_): - return method_mock( - request, Image, "_from_stream", autospec=False, return_value=image_ - ) + return method_mock(request, Image, "_from_stream", autospec=False, return_value=image_) @pytest.fixture def height_prop_(self, request): diff --git a/tests/image/test_jpeg.py b/tests/image/test_jpeg.py index a558e1d4e..129a07d80 100644 --- a/tests/image/test_jpeg.py +++ b/tests/image/test_jpeg.py @@ -247,7 +247,7 @@ def it_can_construct_from_a_stream_and_offset(self, from_stream_fixture): ) def from_stream_fixture(self, request, _Marker__init_): marker_code, offset, length = request.param - bytes_ = b"\xFF\xD8\xFF\xE0\x00\x10" + bytes_ = b"\xff\xd8\xff\xe0\x00\x10" stream_reader = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) return stream_reader, marker_code, offset, _Marker__init_, length @@ -258,7 +258,7 @@ def _Marker__init_(self, request): class Describe_App0Marker: def it_can_construct_from_a_stream_and_offset(self, _App0Marker__init_): - bytes_ = b"\x00\x10JFIF\x00\x01\x01\x01\x00\x2A\x00\x18" + bytes_ = b"\x00\x10JFIF\x00\x01\x01\x01\x00\x2a\x00\x18" marker_code, offset, length = JPEG_MARKER_CODE.APP0, 0, 16 density_units, x_density, y_density = 1, 42, 24 stream = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) @@ -318,9 +318,7 @@ def it_can_construct_from_non_Exif_APP1_segment(self, _App1Marker__init_): app1_marker = _App1Marker.from_stream(stream, marker_code, offset) - _App1Marker__init_.assert_called_once_with( - ANY, marker_code, offset, length, 72, 72 - ) + _App1Marker__init_.assert_called_once_with(ANY, marker_code, offset, length, 72, 72) assert isinstance(app1_marker, _App1Marker) def it_gets_a_tiff_from_its_Exif_segment_to_help_construct(self, get_tiff_fixture): @@ -348,9 +346,7 @@ def _App1Marker__init_(self, request): def get_tiff_fixture(self, request, substream_, Tiff_, tiff_): bytes_ = b"xfillerxMM\x00*\x00\x00\x00\x42" stream_reader = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) - BytesIO_ = class_mock( - request, "docx.image.jpeg.io.BytesIO", return_value=substream_ - ) + BytesIO_ = class_mock(request, "docx.image.jpeg.io.BytesIO", return_value=substream_) offset, segment_length, segment_bytes = 0, 16, bytes_[8:] return ( stream_reader, @@ -390,7 +386,7 @@ def _tiff_from_exif_segment_(self, request, tiff_): class Describe_SofMarker: def it_can_construct_from_a_stream_and_offset(self, request, _SofMarker__init_): - bytes_ = b"\x00\x11\x00\x00\x2A\x00\x18" + bytes_ = b"\x00\x11\x00\x00\x2a\x00\x18" marker_code, offset, length = JPEG_MARKER_CODE.SOF0, 0, 17 px_width, px_height = 24, 42 stream = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) @@ -509,7 +505,7 @@ def _MarkerFinder__init_(self, request): ) def next_fixture(self, request): start, marker_code, segment_offset = request.param - bytes_ = b"\xFF\xD8\xFF\xE0\x00\x01\xFF\x00\xFF\xFF\xFF\xD9" + bytes_ = b"\xff\xd8\xff\xe0\x00\x01\xff\x00\xff\xff\xff\xd9" stream_reader = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) marker_finder = _MarkerFinder(stream_reader) expected_code_and_offset = (marker_code, segment_offset) @@ -626,9 +622,7 @@ def stream_(self, request): @pytest.fixture def StreamReader_(self, request, stream_reader_): - return class_mock( - request, "docx.image.jpeg.StreamReader", return_value=stream_reader_ - ) + return class_mock(request, "docx.image.jpeg.StreamReader", return_value=stream_reader_) @pytest.fixture def stream_reader_(self, request): diff --git a/tests/image/test_png.py b/tests/image/test_png.py index 61e7fdbed..5379b403b 100644 --- a/tests/image/test_png.py +++ b/tests/image/test_png.py @@ -30,9 +30,7 @@ class DescribePng: - def it_can_construct_from_a_png_stream( - self, stream_, _PngParser_, png_parser_, Png__init__ - ): + def it_can_construct_from_a_png_stream(self, stream_, _PngParser_, png_parser_, Png__init__): px_width, px_height, horz_dpi, vert_dpi = 42, 24, 36, 63 png_parser_.px_width = px_width png_parser_.px_height = px_height @@ -42,9 +40,7 @@ def it_can_construct_from_a_png_stream( png = Png.from_stream(stream_) _PngParser_.parse.assert_called_once_with(stream_) - Png__init__.assert_called_once_with( - ANY, px_width, px_height, horz_dpi, vert_dpi - ) + Png__init__.assert_called_once_with(ANY, px_width, px_height, horz_dpi, vert_dpi) assert isinstance(png, Png) def it_knows_its_content_type(self): @@ -157,9 +153,7 @@ def stream_(self, request): class Describe_Chunks: - def it_can_construct_from_a_stream( - self, stream_, _ChunkParser_, chunk_parser_, _Chunks__init_ - ): + def it_can_construct_from_a_stream(self, stream_, _ChunkParser_, chunk_parser_, _Chunks__init_): chunk_lst = [1, 2] chunk_parser_.iter_chunks.return_value = iter(chunk_lst) @@ -277,9 +271,7 @@ def chunk_2_(self, request): @pytest.fixture def _ChunkFactory_(self, request, chunk_lst_): - return function_mock( - request, "docx.image.png._ChunkFactory", side_effect=chunk_lst_ - ) + return function_mock(request, "docx.image.png._ChunkFactory", side_effect=chunk_lst_) @pytest.fixture def chunk_lst_(self, chunk_, chunk_2_): @@ -315,9 +307,7 @@ def iter_offsets_fixture(self): @pytest.fixture def StreamReader_(self, request, stream_rdr_): - return class_mock( - request, "docx.image.png.StreamReader", return_value=stream_rdr_ - ) + return class_mock(request, "docx.image.png.StreamReader", return_value=stream_rdr_) @pytest.fixture def stream_(self, request): @@ -409,7 +399,7 @@ def it_can_construct_from_a_stream_and_offset(self, from_offset_fixture): @pytest.fixture def from_offset_fixture(self): - bytes_ = b"\x00\x00\x00\x2A\x00\x00\x00\x18" + bytes_ = b"\x00\x00\x00\x2a\x00\x00\x00\x18" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) offset, px_width, px_height = 0, 42, 24 return stream_rdr, offset, px_width, px_height @@ -430,7 +420,7 @@ def it_can_construct_from_a_stream_and_offset(self, from_offset_fixture): @pytest.fixture def from_offset_fixture(self): - bytes_ = b"\x00\x00\x00\x2A\x00\x00\x00\x18\x01" + bytes_ = b"\x00\x00\x00\x2a\x00\x00\x00\x18\x01" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) offset, horz_px_per_unit, vert_px_per_unit, units_specifier = (0, 42, 24, 1) return (stream_rdr, offset, horz_px_per_unit, vert_px_per_unit, units_specifier) diff --git a/tests/image/test_tiff.py b/tests/image/test_tiff.py index b7f37afe5..35344eede 100644 --- a/tests/image/test_tiff.py +++ b/tests/image/test_tiff.py @@ -32,9 +32,7 @@ class DescribeTiff: - def it_can_construct_from_a_tiff_stream( - self, stream_, _TiffParser_, tiff_parser_, Tiff__init_ - ): + def it_can_construct_from_a_tiff_stream(self, stream_, _TiffParser_, tiff_parser_, Tiff__init_): px_width, px_height = 111, 222 horz_dpi, vert_dpi = 333, 444 tiff_parser_.px_width = px_width @@ -45,9 +43,7 @@ def it_can_construct_from_a_tiff_stream( tiff = Tiff.from_stream(stream_) _TiffParser_.parse.assert_called_once_with(stream_) - Tiff__init_.assert_called_once_with( - ANY, px_width, px_height, horz_dpi, vert_dpi - ) + Tiff__init_.assert_called_once_with(ANY, px_width, px_height, horz_dpi, vert_dpi) assert isinstance(tiff, Tiff) def it_knows_its_content_type(self): @@ -186,9 +182,7 @@ def stream_(self, request): @pytest.fixture def StreamReader_(self, request, stream_rdr_): - return class_mock( - request, "docx.image.tiff.StreamReader", return_value=stream_rdr_ - ) + return class_mock(request, "docx.image.tiff.StreamReader", return_value=stream_rdr_) @pytest.fixture def stream_rdr_(self, request, ifd0_offset_): @@ -244,9 +238,7 @@ def _IfdEntries__init_(self, request): @pytest.fixture def _IfdParser_(self, request, ifd_parser_): - return class_mock( - request, "docx.image.tiff._IfdParser", return_value=ifd_parser_ - ) + return class_mock(request, "docx.image.tiff._IfdParser", return_value=ifd_parser_) @pytest.fixture def ifd_parser_(self, request): @@ -386,9 +378,7 @@ def offset_(self, request): class Describe_IfdEntry: - def it_can_construct_from_a_stream_and_offset( - self, _parse_value_, _IfdEntry__init_, value_ - ): + def it_can_construct_from_a_stream_and_offset(self, _parse_value_, _IfdEntry__init_, value_): bytes_ = b"\x00\x01\x66\x66\x00\x00\x00\x02\x00\x00\x00\x03" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) offset, tag_code, value_count, value_offset = 0, 1, 2, 3 @@ -396,9 +386,7 @@ def it_can_construct_from_a_stream_and_offset( ifd_entry = _IfdEntry.from_stream(stream_rdr, offset) - _parse_value_.assert_called_once_with( - stream_rdr, offset, value_count, value_offset - ) + _parse_value_.assert_called_once_with(stream_rdr, offset, value_count, value_offset) _IfdEntry__init_.assert_called_once_with(ANY, tag_code, value_) assert isinstance(ifd_entry, _IfdEntry) @@ -432,7 +420,7 @@ def it_can_parse_an_ascii_string_IFD_entry(self): class Describe_ShortIfdEntry: def it_can_parse_a_short_int_IFD_entry(self): - bytes_ = b"foobaroo\x00\x2A" + bytes_ = b"foobaroo\x00\x2a" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) val = _ShortIfdEntry._parse_value(stream_rdr, 0, 1, None) assert val == 42 @@ -440,7 +428,7 @@ def it_can_parse_a_short_int_IFD_entry(self): class Describe_LongIfdEntry: def it_can_parse_a_long_int_IFD_entry(self): - bytes_ = b"foobaroo\x00\x00\x00\x2A" + bytes_ = b"foobaroo\x00\x00\x00\x2a" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) val = _LongIfdEntry._parse_value(stream_rdr, 0, 1, None) assert val == 42 @@ -448,7 +436,7 @@ def it_can_parse_a_long_int_IFD_entry(self): class Describe_RationalIfdEntry: def it_can_parse_a_rational_IFD_entry(self): - bytes_ = b"\x00\x00\x00\x2A\x00\x00\x00\x54" + bytes_ = b"\x00\x00\x00\x2a\x00\x00\x00\x54" stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) val = _RationalIfdEntry._parse_value(stream_rdr, None, 1, 0) assert val == 0.5 diff --git a/tests/opc/test_pkgreader.py b/tests/opc/test_pkgreader.py index 8e14f0e01..0aed52c8d 100644 --- a/tests/opc/test_pkgreader.py +++ b/tests/opc/test_pkgreader.py @@ -44,9 +44,7 @@ def it_can_construct_from_pkg_file( PhysPkgReader_.assert_called_once_with(pkg_file) from_xml.assert_called_once_with(phys_reader.content_types_xml) _srels_for.assert_called_once_with(phys_reader, "/") - _load_serialized_parts.assert_called_once_with( - phys_reader, pkg_srels, content_types - ) + _load_serialized_parts.assert_called_once_with(phys_reader, pkg_srels, content_types) phys_reader.close.assert_called_once_with() _init_.assert_called_once_with(ANY, content_types, pkg_srels, sparts) assert isinstance(pkg_reader, PackageReader) @@ -94,17 +92,11 @@ def it_can_load_serialized_parts(self, _SerializedPart_, _walk_phys_parts): Mock(name="spart_2"), ) # exercise --------------------- - retval = PackageReader._load_serialized_parts( - phys_reader, pkg_srels, content_types - ) + retval = PackageReader._load_serialized_parts(phys_reader, pkg_srels, content_types) # verify ----------------------- expected_calls = [ - call( - "/part/name1.xml", "app/vnd.type_1", "", "reltype1", "srels_1" - ), - call( - "/part/name2.xml", "app/vnd.type_2", "", "reltype2", "srels_2" - ), + call("/part/name1.xml", "app/vnd.type_1", "", "reltype1", "srels_1"), + call("/part/name2.xml", "app/vnd.type_2", "", "reltype2", "srels_2"), ] assert _SerializedPart_.call_args_list == expected_calls assert retval == expected_sparts @@ -208,9 +200,7 @@ def _init_(self, request): return initializer_mock(request, PackageReader) @pytest.fixture - def iter_sparts_fixture( - self, sparts_, partnames_, content_types_, reltypes_, blobs_ - ): + def iter_sparts_fixture(self, sparts_, partnames_, content_types_, reltypes_, blobs_): pkg_reader = PackageReader(None, None, sparts_) expected_iter_spart_items = [ (partnames_[0], content_types_[0], reltypes_[0], blobs_[0]), @@ -220,9 +210,7 @@ def iter_sparts_fixture( @pytest.fixture def _load_serialized_parts(self, request): - return method_mock( - request, PackageReader, "_load_serialized_parts", autospec=False - ) + return method_mock(request, PackageReader, "_load_serialized_parts", autospec=False) @pytest.fixture def partnames_(self, request): @@ -283,15 +271,11 @@ def it_can_construct_from_ct_item_xml(self, from_xml_fixture): assert ct_map._defaults == expected_defaults assert ct_map._overrides == expected_overrides - def it_matches_an_override_on_case_insensitive_partname( - self, match_override_fixture - ): + def it_matches_an_override_on_case_insensitive_partname(self, match_override_fixture): ct_map, partname, content_type = match_override_fixture assert ct_map[partname] == content_type - def it_falls_back_to_case_insensitive_extension_default_match( - self, match_default_fixture - ): + def it_falls_back_to_case_insensitive_extension_default_match(self, match_default_fixture): ct_map, partname, content_type = match_default_fixture assert ct_map[partname] == content_type diff --git a/tests/opc/test_rel.py b/tests/opc/test_rel.py index 7b7a98dfe..f56fecd22 100644 --- a/tests/opc/test_rel.py +++ b/tests/opc/test_rel.py @@ -77,18 +77,14 @@ def it_can_find_a_relationship_by_rId(self): rels["foobar"] = rel assert rels["foobar"] == rel - def it_can_find_or_add_a_relationship( - self, rels_with_matching_rel_, rels_with_missing_rel_ - ): + def it_can_find_or_add_a_relationship(self, rels_with_matching_rel_, rels_with_missing_rel_): rels, reltype, part, matching_rel = rels_with_matching_rel_ assert rels.get_or_add(reltype, part) == matching_rel rels, reltype, part, new_rel = rels_with_missing_rel_ assert rels.get_or_add(reltype, part) == new_rel - def it_can_find_or_add_an_external_relationship( - self, add_matching_ext_rel_fixture_ - ): + def it_can_find_or_add_an_external_relationship(self, add_matching_ext_rel_fixture_): rels, reltype, url, rId = add_matching_ext_rel_fixture_ _rId = rels.get_or_add_ext_rel(reltype, url) assert _rId == rId @@ -235,20 +231,14 @@ def rels_with_missing_rel_(self, request, rels, _Relationship_): @pytest.fixture def rels_with_rId_gap(self, request): rels = Relationships(None) - rel_with_rId1 = instance_mock( - request, _Relationship, name="rel_with_rId1", rId="rId1" - ) - rel_with_rId3 = instance_mock( - request, _Relationship, name="rel_with_rId3", rId="rId3" - ) + rel_with_rId1 = instance_mock(request, _Relationship, name="rel_with_rId1", rId="rId1") + rel_with_rId3 = instance_mock(request, _Relationship, name="rel_with_rId3", rId="rId3") rels["rId1"] = rel_with_rId1 rels["rId3"] = rel_with_rId3 return rels, "rId2" @pytest.fixture - def rels_with_target_known_by_reltype( - self, rels, _rel_with_target_known_by_reltype - ): + def rels_with_target_known_by_reltype(self, rels, _rel_with_target_known_by_reltype): rel, reltype, target_part = _rel_with_target_known_by_reltype rels[1] = rel return rels, reltype, target_part diff --git a/tests/oxml/parts/test_document.py b/tests/oxml/parts/test_document.py index 90b587674..149a65790 100644 --- a/tests/oxml/parts/test_document.py +++ b/tests/oxml/parts/test_document.py @@ -38,9 +38,6 @@ def clear_fixture(self, request): def section_break_fixture(self): body = element("w:body/w:sectPr/w:type{w:val=foobar}") expected_xml = xml( - "w:body/(" - " w:p/w:pPr/w:sectPr/w:type{w:val=foobar}," - " w:sectPr/w:type{w:val=foobar}" - ")" + "w:body/(w:p/w:pPr/w:sectPr/w:type{w:val=foobar},w:sectPr/w:type{w:val=foobar})" ) return body, expected_xml diff --git a/tests/oxml/test__init__.py b/tests/oxml/test__init__.py index 5f392df38..9f19094b4 100644 --- a/tests/oxml/test__init__.py +++ b/tests/oxml/test__init__.py @@ -12,15 +12,12 @@ class DescribeOxmlElement: def it_returns_an_lxml_element_with_matching_tag_name(self): element = OxmlElement("a:foo") assert isinstance(element, etree._Element) - assert element.tag == ( - "{http://schemas.openxmlformats.org/drawingml/2006/main}foo" - ) + assert element.tag == ("{http://schemas.openxmlformats.org/drawingml/2006/main}foo") def it_adds_supplied_attributes(self): element = OxmlElement("a:foo", {"a": "b", "c": "d"}) assert etree.tostring(element) == ( - '' + '' ).encode("utf-8") def it_adds_additional_namespace_declarations_when_supplied(self): @@ -43,7 +40,7 @@ def it_strips_whitespace_between_elements(self, whitespace_fixture): @pytest.fixture def whitespace_fixture(self): - pretty_xml_text = "\n" " text\n" "\n" + pretty_xml_text = "\n text\n\n" stripped_xml_text = "text" return pretty_xml_text, stripped_xml_text diff --git a/tests/oxml/test_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/oxml/test_styles.py b/tests/oxml/test_styles.py index 7677a8a9e..8814dd6aa 100644 --- a/tests/oxml/test_styles.py +++ b/tests/oxml/test_styles.py @@ -31,8 +31,7 @@ def it_can_add_a_style_of_type(self, add_fixture): "heading 1", WD_STYLE_TYPE.PARAGRAPH, True, - "w:styles/w:style{w:type=paragraph,w:styleId=Heading1}/w:name{w:val" - "=heading 1}", + "w:styles/w:style{w:type=paragraph,w:styleId=Heading1}/w:name{w:val=heading 1}", ), ] ) diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 46b2f4ed1..2c9e05344 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -19,7 +19,6 @@ class DescribeCT_Row: - @pytest.mark.parametrize( ("tr_cxml", "expected_cxml"), [ @@ -231,7 +230,7 @@ def it_knows_its_inner_content_block_item_elements(self): 'w:tr/(w:tc/w:p/w:r/w:t"a",w:tc/w:p/w:r/w:t"b")', 0, 2, - 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p/w:r/w:t"a",' 'w:p/w:r/w:t"b"))', + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p/w:r/w:t"a",w:p/w:r/w:t"b"))', ), ( "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p),w:tc/w:p)", @@ -266,7 +265,7 @@ def it_can_swallow_the_next_tc_help_merge( "w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", 0, 2, - "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=2880,w:type=dxa}," "w:gridSpan{w:val=2}),w:p))", + "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=2880,w:type=dxa},w:gridSpan{w:val=2}),w:p))", ), # neither have a width ( @@ -277,17 +276,17 @@ def it_can_swallow_the_next_tc_help_merge( ), # only second one has a width ( - "w:tr/(w:tc/w:p," "w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", + "w:tr/(w:tc/w:p,w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", 0, 2, "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", ), # only first one has a width ( - "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p)," "w:tc/w:p)", + "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p),w:tc/w:p)", 0, 2, - "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=1440,w:type=dxa}," "w:gridSpan{w:val=2}),w:p))", + "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=1440,w:type=dxa},w:gridSpan{w:val=2}),w:p))", ), ], ) diff --git a/tests/oxml/test_xmlchemy.py b/tests/oxml/test_xmlchemy.py index fca309851..76b53c957 100644 --- a/tests/oxml/test_xmlchemy.py +++ b/tests/oxml/test_xmlchemy.py @@ -131,7 +131,7 @@ def it_returns_unicode_text(self, type_fixture): @pytest.fixture def pretty_fixture(self, element): - expected_xml_text = "\n" " text\n" "\n" + expected_xml_text = "\n text\n\n" return element, expected_xml_text @pytest.fixture @@ -176,8 +176,7 @@ def it_knows_if_two_xml_lines_are_equivalent(self, xml_line_case): ('', "", None), ("t", "", "t"), ( - '2013-12-23T23:15:00Z", + '2013-12-23T23:15:00Z', "", @@ -250,22 +249,16 @@ def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): parent, choice, expected_xml = insert_fixture parent._insert_choice(choice) assert parent.xml == expected_xml - assert parent._insert_choice.__doc__.startswith( - "Return the passed ```` " - ) + assert parent._insert_choice.__doc__.startswith("Return the passed ```` ") def it_adds_an_add_method_for_the_child_element(self, add_fixture): parent, expected_xml = add_fixture choice = parent._add_choice() assert parent.xml == expected_xml assert isinstance(choice, CT_Choice) - assert parent._add_choice.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_choice.__doc__.startswith("Add a new ```` child element ") - def it_adds_a_get_or_change_to_method_for_the_child_element( - self, get_or_change_to_fixture - ): + def it_adds_a_get_or_change_to_method_for_the_child_element(self, get_or_change_to_fixture): parent, expected_xml = get_or_change_to_fixture choice = parent.get_or_change_to_choice() assert isinstance(choice, CT_Choice) @@ -302,10 +295,7 @@ def getter_fixture(self, request): @pytest.fixture def insert_fixture(self): parent = ( - a_parent() - .with_nsdecls() - .with_child(an_oomChild()) - .with_child(an_oooChild()) + a_parent().with_nsdecls().with_child(an_oomChild()).with_child(an_oooChild()) ).element choice = a_choice().with_nsdecls().element expected_xml = ( @@ -362,27 +352,21 @@ def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): parent, oomChild, expected_xml = insert_fixture parent._insert_oomChild(oomChild) assert parent.xml == expected_xml - assert parent._insert_oomChild.__doc__.startswith( - "Return the passed ```` " - ) + assert parent._insert_oomChild.__doc__.startswith("Return the passed ```` ") def it_adds_a_private_add_method_for_the_child_element(self, add_fixture): parent, expected_xml = add_fixture oomChild = parent._add_oomChild() assert parent.xml == expected_xml assert isinstance(oomChild, CT_OomChild) - assert parent._add_oomChild.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_oomChild.__doc__.startswith("Add a new ```` child element ") def it_adds_a_public_add_method_for_the_child_element(self, add_fixture): parent, expected_xml = add_fixture oomChild = parent.add_oomChild() assert parent.xml == expected_xml assert isinstance(oomChild, CT_OomChild) - assert parent._add_oomChild.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_oomChild.__doc__.startswith("Add a new ```` child element ") # fixtures ------------------------------------------------------- @@ -444,9 +428,7 @@ def it_adds_a_setter_property_for_the_attr(self, setter_fixture): assert parent.xml == expected_xml def it_adds_a_docstring_for_the_property(self): - assert CT_Parent.optAttr.__doc__.startswith( - "ST_IntegerType type-converted value of " - ) + assert CT_Parent.optAttr.__doc__.startswith("ST_IntegerType type-converted value of ") # fixtures ------------------------------------------------------- @@ -477,9 +459,7 @@ def it_adds_a_setter_property_for_the_attr(self, setter_fixture): assert parent.xml == expected_xml def it_adds_a_docstring_for_the_property(self): - assert CT_Parent.reqAttr.__doc__.startswith( - "ST_IntegerType type-converted value of " - ) + assert CT_Parent.reqAttr.__doc__.startswith("ST_IntegerType type-converted value of ") def it_raises_on_get_when_attribute_not_present(self): parent = a_parent().with_nsdecls().element @@ -532,27 +512,21 @@ def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): parent, zomChild, expected_xml = insert_fixture parent._insert_zomChild(zomChild) assert parent.xml == expected_xml - assert parent._insert_zomChild.__doc__.startswith( - "Return the passed ```` " - ) + assert parent._insert_zomChild.__doc__.startswith("Return the passed ```` ") def it_adds_an_add_method_for_the_child_element(self, add_fixture): parent, expected_xml = add_fixture zomChild = parent._add_zomChild() assert parent.xml == expected_xml assert isinstance(zomChild, CT_ZomChild) - assert parent._add_zomChild.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_zomChild.__doc__.startswith("Add a new ```` child element ") def it_adds_a_public_add_method_for_the_child_element(self, add_fixture): parent, expected_xml = add_fixture zomChild = parent.add_zomChild() assert parent.xml == expected_xml assert isinstance(zomChild, CT_ZomChild) - assert parent._add_zomChild.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_zomChild.__doc__.startswith("Add a new ```` child element ") def it_removes_the_property_root_name_used_for_declaration(self): assert not hasattr(CT_Parent, "zomChild") @@ -614,17 +588,13 @@ def it_adds_an_add_method_for_the_child_element(self, add_fixture): zooChild = parent._add_zooChild() assert parent.xml == expected_xml assert isinstance(zooChild, CT_ZooChild) - assert parent._add_zooChild.__doc__.startswith( - "Add a new ```` child element " - ) + assert parent._add_zooChild.__doc__.startswith("Add a new ```` child element ") def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): parent, zooChild, expected_xml = insert_fixture parent._insert_zooChild(zooChild) assert parent.xml == expected_xml - assert parent._insert_zooChild.__doc__.startswith( - "Return the passed ```` " - ) + assert parent._insert_zooChild.__doc__.startswith("Return the passed ```` ") def it_adds_a_get_or_add_method_for_the_child_element(self, get_or_add_fixture): parent, expected_xml = get_or_add_fixture @@ -743,9 +713,7 @@ class CT_Parent(BaseOxmlElement): (Choice("w:choice"), Choice("w:choice2")), successors=("w:oomChild", "w:oooChild"), ) - oomChild = OneOrMore( - "w:oomChild", successors=("w:oooChild", "w:zomChild", "w:zooChild") - ) + oomChild = OneOrMore("w:oomChild", successors=("w:oooChild", "w:zomChild", "w:zooChild")) oooChild = OneAndOnlyOne("w:oooChild") zomChild = ZeroOrMore("w:zomChild", successors=("w:zooChild",)) zooChild = ZeroOrOne("w:zooChild", successors=()) diff --git a/tests/oxml/text/test_hyperlink.py b/tests/oxml/text/test_hyperlink.py index f55ab9c22..f5cec4761 100644 --- a/tests/oxml/text/test_hyperlink.py +++ b/tests/oxml/text/test_hyperlink.py @@ -30,9 +30,7 @@ def it_has_a_relationship_that_contains_the_hyperlink_address(self): ("w:hyperlink{r:id=rId6,w:history=1}", True), ], ) - def it_knows_whether_it_has_been_clicked_on_aka_visited( - self, cxml: str, expected_value: bool - ): + def it_knows_whether_it_has_been_clicked_on_aka_visited(self, cxml: str, expected_value: bool): hyperlink = cast(CT_Hyperlink, element(cxml)) assert hyperlink.history is expected_value diff --git a/tests/oxml/unitdata/dml.py b/tests/oxml/unitdata/dml.py deleted file mode 100644 index 325a3f690..000000000 --- a/tests/oxml/unitdata/dml.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Test data builders for DrawingML XML elements.""" - -from ...unitdata import BaseBuilder - - -class CT_BlipBuilder(BaseBuilder): - __tag__ = "a:blip" - __nspfxs__ = ("a",) - __attrs__ = ("r:embed", "r:link", "cstate") - - -class CT_BlipFillPropertiesBuilder(BaseBuilder): - __tag__ = "pic:blipFill" - __nspfxs__ = ("pic",) - __attrs__ = () - - -class CT_GraphicalObjectBuilder(BaseBuilder): - __tag__ = "a:graphic" - __nspfxs__ = ("a",) - __attrs__ = () - - -class CT_GraphicalObjectDataBuilder(BaseBuilder): - __tag__ = "a:graphicData" - __nspfxs__ = ("a",) - __attrs__ = ("uri",) - - -class CT_InlineBuilder(BaseBuilder): - __tag__ = "wp:inline" - __nspfxs__ = ("wp",) - __attrs__ = ("distT", "distB", "distL", "distR") - - -class CT_PictureBuilder(BaseBuilder): - __tag__ = "pic:pic" - __nspfxs__ = ("pic",) - __attrs__ = () - - -def a_blip(): - return CT_BlipBuilder() - - -def a_blipFill(): - return CT_BlipFillPropertiesBuilder() - - -def a_graphic(): - return CT_GraphicalObjectBuilder() - - -def a_graphicData(): - return CT_GraphicalObjectDataBuilder() - - -def a_pic(): - return CT_PictureBuilder() - - -def an_inline(): - return CT_InlineBuilder() diff --git a/tests/parts/test_comments.py b/tests/parts/test_comments.py new file mode 100644 index 000000000..049c9e737 --- /dev/null +++ b/tests/parts/test_comments.py @@ -0,0 +1,87 @@ +"""Unit test suite for the docx.parts.hdrftr 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.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, 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 + ): + 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() + + 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 + + # -- 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 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) diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 3a86b5168..c27990baf 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -1,11 +1,17 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for the docx.parts.document module.""" 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 @@ -15,15 +21,26 @@ from docx.styles.style import BaseStyle from docx.styles.styles import Styles -from ..oxml.parts.unitdata.document import a_body, a_document -from ..unitutil.mock import class_mock, instance_mock, method_mock, property_mock +from ..unitutil.cxml import element +from ..unitutil.mock import ( + FixtureRequest, + Mock, + class_mock, + instance_mock, + method_mock, + property_mock, +) class DescribeDocumentPart: - def it_can_add_a_footer_part(self, package_, FooterPart_, footer_part_, relate_to_): + def it_can_add_a_footer_part( + self, package_: Mock, FooterPart_: Mock, footer_part_: Mock, relate_to_: Mock + ): FooterPart_.new.return_value = footer_part_ relate_to_.return_value = "rId12" - document_part = DocumentPart(None, None, None, package_) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) footer_part, rId = document_part.add_footer_part() @@ -32,10 +49,14 @@ def it_can_add_a_footer_part(self, package_, FooterPart_, footer_part_, relate_t assert footer_part is footer_part_ assert rId == "rId12" - def it_can_add_a_header_part(self, package_, HeaderPart_, header_part_, relate_to_): + def it_can_add_a_header_part( + self, package_: Mock, HeaderPart_: Mock, header_part_: Mock, relate_to_: Mock + ): HeaderPart_.new.return_value = header_part_ relate_to_.return_value = "rId7" - document_part = DocumentPart(None, None, None, package_) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) header_part, rId = document_part.add_header_part() @@ -44,19 +65,23 @@ def it_can_add_a_header_part(self, package_, HeaderPart_, header_part_, relate_t assert header_part is header_part_ assert rId == "rId7" - def it_can_drop_a_specified_header_part(self, drop_rel_): - document_part = DocumentPart(None, None, None, None) + def it_can_drop_a_specified_header_part(self, drop_rel_: Mock, package_: Mock): + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) document_part.drop_header_part("rId42") drop_rel_.assert_called_once_with(document_part, "rId42") def it_provides_access_to_a_footer_part_by_rId( - self, related_parts_prop_, related_parts_, footer_part_ + self, related_parts_prop_: Mock, related_parts_: Mock, footer_part_: Mock, package_: Mock ): related_parts_prop_.return_value = related_parts_ related_parts_.__getitem__.return_value = footer_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) footer_part = document_part.footer_part("rId9") @@ -64,50 +89,90 @@ def it_provides_access_to_a_footer_part_by_rId( assert footer_part is footer_part_ def it_provides_access_to_a_header_part_by_rId( - self, related_parts_prop_, related_parts_, header_part_ + self, related_parts_prop_: Mock, related_parts_: Mock, header_part_: Mock, package_: Mock ): related_parts_prop_.return_value = related_parts_ related_parts_.__getitem__.return_value = header_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) header_part = document_part.header_part("rId11") related_parts_.__getitem__.assert_called_once_with("rId11") assert header_part is header_part_ - def it_can_save_the_package_to_a_file(self, save_fixture): - document, file_ = save_fixture - document.save(file_) - document._package.save.assert_called_once_with(file_) + def it_can_save_the_package_to_a_file(self, package_: Mock): + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + document_part.save("foobar.docx") + + package_.save.assert_called_once_with("foobar.docx") - def it_provides_access_to_the_document_settings(self, settings_fixture): - document_part, settings_ = settings_fixture - settings = document_part.settings - assert settings is settings_ + 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_styles(self, styles_fixture): - document_part, styles_ = styles_fixture - styles = document_part.styles - assert styles is styles_ + def it_provides_access_to_the_document_settings( + self, _settings_part_prop_: Mock, settings_part_: Mock, settings_: Mock, package_: Mock + ): + settings_part_.settings = settings_ + _settings_part_prop_.return_value = settings_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) - def it_provides_access_to_its_core_properties(self, core_props_fixture): - document_part, core_properties_ = core_props_fixture - core_properties = document_part.core_properties - assert core_properties is core_properties_ + assert document_part.settings is settings_ + + def it_provides_access_to_the_document_styles( + self, _styles_part_prop_: Mock, styles_part_: Mock, styles_: Mock, package_: Mock + ): + styles_part_.styles = styles_ + _styles_part_prop_.return_value = styles_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + assert document_part.styles is styles_ + + def it_provides_access_to_its_core_properties(self, package_: Mock, core_properties_: Mock): + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + package_.core_properties = core_properties_ + + assert document_part.core_properties is core_properties_ def it_provides_access_to_the_inline_shapes_in_the_document( - self, inline_shapes_fixture + self, InlineShapes_: Mock, package_: Mock ): - document, InlineShapes_, body_elm = inline_shapes_fixture - inline_shapes = document.inline_shapes - InlineShapes_.assert_called_once_with(body_elm, document) + document_elm = element("w:document/w:body") + body_elm = document_elm[0] + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, document_elm, package_ + ) + + inline_shapes = document_part.inline_shapes + + InlineShapes_.assert_called_once_with(body_elm, document_part) assert inline_shapes is InlineShapes_.return_value def it_provides_access_to_the_numbering_part( - self, part_related_by_, numbering_part_ + self, part_related_by_: Mock, numbering_part_: Mock, package_: Mock ): part_related_by_.return_value = numbering_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) numbering_part = document_part.numbering_part @@ -115,11 +180,18 @@ def it_provides_access_to_the_numbering_part( assert numbering_part is numbering_part_ def and_it_creates_a_numbering_part_if_not_present( - self, part_related_by_, relate_to_, NumberingPart_, numbering_part_ + self, + part_related_by_: Mock, + relate_to_: Mock, + NumberingPart_: Mock, + numbering_part_: Mock, + package_: Mock, ): part_related_by_.side_effect = KeyError NumberingPart_.new.return_value = numbering_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) numbering_part = document_part.numbering_part @@ -127,31 +199,74 @@ def and_it_creates_a_numbering_part_if_not_present( relate_to_.assert_called_once_with(document_part, numbering_part_, RT.NUMBERING) assert numbering_part is numbering_part_ - def it_can_get_a_style_by_id(self, styles_prop_, styles_, style_): + def it_can_get_a_style_by_id( + self, styles_prop_: Mock, styles_: Mock, style_: Mock, package_: Mock + ): styles_prop_.return_value = styles_ styles_.get_by_id.return_value = style_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) style = document_part.get_style("BodyText", WD_STYLE_TYPE.PARAGRAPH) styles_.get_by_id.assert_called_once_with("BodyText", WD_STYLE_TYPE.PARAGRAPH) assert style is style_ - def it_can_get_the_id_of_a_style(self, style_, styles_prop_, styles_): + def it_can_get_the_id_of_a_style( + self, style_: Mock, styles_prop_: Mock, styles_: Mock, package_: Mock + ): styles_prop_.return_value = styles_ styles_.get_style_id.return_value = "BodyCharacter" - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) style_id = document_part.get_style_id(style_, WD_STYLE_TYPE.CHARACTER) 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_, settings_part_ + self, part_related_by_: Mock, settings_part_: Mock, package_: Mock ): part_related_by_.return_value = settings_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) settings_part = document_part._settings_part @@ -159,11 +274,18 @@ def it_provides_access_to_its_settings_part_to_help( assert settings_part is settings_part_ def and_it_creates_a_default_settings_part_if_not_present( - self, package_, part_related_by_, SettingsPart_, settings_part_, relate_to_ + self, + package_: Mock, + part_related_by_: Mock, + SettingsPart_: Mock, + settings_part_: Mock, + relate_to_: Mock, ): part_related_by_.side_effect = KeyError SettingsPart_.default.return_value = settings_part_ - document_part = DocumentPart(None, None, None, package_) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) settings_part = document_part._settings_part @@ -172,10 +294,12 @@ def and_it_creates_a_default_settings_part_if_not_present( assert settings_part is settings_part_ def it_provides_access_to_its_styles_part_to_help( - self, part_related_by_, styles_part_ + self, part_related_by_: Mock, styles_part_: Mock, package_: Mock ): part_related_by_.return_value = styles_part_ - document_part = DocumentPart(None, None, None, None) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) styles_part = document_part._styles_part @@ -183,11 +307,18 @@ def it_provides_access_to_its_styles_part_to_help( assert styles_part is styles_part_ def and_it_creates_a_default_styles_part_if_not_present( - self, package_, part_related_by_, StylesPart_, styles_part_, relate_to_ + self, + package_: Mock, + part_related_by_: Mock, + StylesPart_: Mock, + styles_part_: Mock, + relate_to_: Mock, ): part_related_by_.side_effect = KeyError StylesPart_.default.return_value = styles_part_ - document_part = DocumentPart(None, None, None, package_) + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) styles_part = document_part._styles_part @@ -195,135 +326,116 @@ def and_it_creates_a_default_styles_part_if_not_present( relate_to_.assert_called_once_with(document_part, styles_part_, RT.STYLES) assert styles_part is styles_part_ - # fixtures ------------------------------------------------------- + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def core_props_fixture(self, package_, core_properties_): - document_part = DocumentPart(None, None, None, package_) - package_.core_properties = core_properties_ - return document_part, core_properties_ + def comments_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, Comments) @pytest.fixture - def inline_shapes_fixture(self, request, InlineShapes_): - document_elm = (a_document().with_nsdecls().with_child(a_body())).element - body_elm = document_elm[0] - document = DocumentPart(None, None, document_elm, None) - return document, InlineShapes_, body_elm + def CommentsPart_(self, request: FixtureRequest) -> Mock: + return class_mock(request, "docx.parts.document.CommentsPart") @pytest.fixture - def save_fixture(self, package_): - document_part = DocumentPart(None, None, None, package_) - file_ = "foobar.docx" - return document_part, file_ + def comments_part_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, CommentsPart) @pytest.fixture - def settings_fixture(self, _settings_part_prop_, settings_part_, settings_): - document_part = DocumentPart(None, None, None, None) - _settings_part_prop_.return_value = settings_part_ - settings_part_.settings = settings_ - return document_part, settings_ - - @pytest.fixture - def styles_fixture(self, _styles_part_prop_, styles_part_, styles_): - document_part = DocumentPart(None, None, None, None) - _styles_part_prop_.return_value = styles_part_ - styles_part_.styles = styles_ - return document_part, styles_ - - # fixture components --------------------------------------------- + def _comments_part_prop_(self, request: FixtureRequest) -> Mock: + return property_mock(request, DocumentPart, "_comments_part") @pytest.fixture - def core_properties_(self, request): + def core_properties_(self, request: FixtureRequest): return instance_mock(request, CoreProperties) @pytest.fixture - def drop_rel_(self, request): + def drop_rel_(self, request: FixtureRequest): return method_mock(request, DocumentPart, "drop_rel", autospec=True) @pytest.fixture - def FooterPart_(self, request): + def FooterPart_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.FooterPart") @pytest.fixture - def footer_part_(self, request): + def footer_part_(self, request: FixtureRequest): return instance_mock(request, FooterPart) @pytest.fixture - def HeaderPart_(self, request): + def HeaderPart_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.HeaderPart") @pytest.fixture - def header_part_(self, request): + def header_part_(self, request: FixtureRequest): return instance_mock(request, HeaderPart) @pytest.fixture - def InlineShapes_(self, request): + def InlineShapes_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.InlineShapes") @pytest.fixture - def NumberingPart_(self, request): + def NumberingPart_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.NumberingPart") @pytest.fixture - def numbering_part_(self, request): + def numbering_part_(self, request: FixtureRequest): return instance_mock(request, NumberingPart) @pytest.fixture - def package_(self, request): + def package_(self, request: FixtureRequest): return instance_mock(request, Package) @pytest.fixture - def part_related_by_(self, request): + def part_related_by_(self, request: FixtureRequest): return method_mock(request, DocumentPart, "part_related_by") @pytest.fixture - def relate_to_(self, request): + def relate_to_(self, request: FixtureRequest): return method_mock(request, DocumentPart, "relate_to") @pytest.fixture - def related_parts_(self, request): + def related_parts_(self, request: FixtureRequest): return instance_mock(request, dict) @pytest.fixture - def related_parts_prop_(self, request): + def related_parts_prop_(self, request: FixtureRequest): return property_mock(request, DocumentPart, "related_parts") @pytest.fixture - def SettingsPart_(self, request): + def SettingsPart_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.SettingsPart") @pytest.fixture - def settings_(self, request): + def settings_(self, request: FixtureRequest): return instance_mock(request, Settings) @pytest.fixture - def settings_part_(self, request): + def settings_part_(self, request: FixtureRequest): return instance_mock(request, SettingsPart) @pytest.fixture - def _settings_part_prop_(self, request): + def _settings_part_prop_(self, request: FixtureRequest): return property_mock(request, DocumentPart, "_settings_part") @pytest.fixture - def style_(self, request): + def style_(self, request: FixtureRequest): return instance_mock(request, BaseStyle) @pytest.fixture - def styles_(self, request): + def styles_(self, request: FixtureRequest): return instance_mock(request, Styles) @pytest.fixture - def StylesPart_(self, request): + def StylesPart_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.StylesPart") @pytest.fixture - def styles_part_(self, request): + def styles_part_(self, request: FixtureRequest): return instance_mock(request, StylesPart) @pytest.fixture - def styles_prop_(self, request): + def styles_prop_(self, request: FixtureRequest): return property_mock(request, DocumentPart, "styles") @pytest.fixture - def _styles_part_prop_(self, request): + def _styles_part_prop_(self, request: FixtureRequest): return property_mock(request, DocumentPart, "_styles_part") diff --git a/tests/parts/test_hdrftr.py b/tests/parts/test_hdrftr.py index ee0cc7134..bb98acead 100644 --- a/tests/parts/test_hdrftr.py +++ b/tests/parts/test_hdrftr.py @@ -27,9 +27,7 @@ def it_is_used_by_loader_to_construct_footer_part( FooterPart_load_.assert_called_once_with(partname, content_type, blob, package_) assert part is footer_part_ - def it_can_create_a_new_footer_part( - self, package_, _default_footer_xml_, parse_xml_, _init_ - ): + def it_can_create_a_new_footer_part(self, package_, _default_footer_xml_, parse_xml_, _init_): ftr = element("w:ftr") package_.next_partname.return_value = "/word/footer24.xml" _default_footer_xml_.return_value = "" @@ -95,9 +93,7 @@ def it_is_used_by_loader_to_construct_header_part( HeaderPart_load_.assert_called_once_with(partname, content_type, blob, package_) assert part is header_part_ - def it_can_create_a_new_header_part( - self, package_, _default_header_xml_, parse_xml_, _init_ - ): + def it_can_create_a_new_header_part(self, package_, _default_header_xml_, parse_xml_, _init_): hdr = element("w:hdr") package_.next_partname.return_value = "/word/header42.xml" _default_header_xml_.return_value = "" diff --git a/tests/parts/test_image.py b/tests/parts/test_image.py index acf0b0727..395f57726 100644 --- a/tests/parts/test_image.py +++ b/tests/parts/test_image.py @@ -24,17 +24,13 @@ def it_is_used_by_PartFactory_to_construct_image_part( part = PartFactory(partname_, content_type, reltype, blob_, package_) - image_part_load_.assert_called_once_with( - partname_, content_type, blob_, package_ - ) + image_part_load_.assert_called_once_with(partname_, content_type, blob_, package_) assert part is image_part_ def it_can_construct_from_an_Image_instance(self, image_, partname_, _init_): image_part = ImagePart.from_image(image_, partname_) - _init_.assert_called_once_with( - ANY, partname_, image_.content_type, image_.blob, image_ - ) + _init_.assert_called_once_with(ANY, partname_, image_.content_type, image_.blob, image_) assert isinstance(image_part, ImagePart) def it_knows_its_default_dimensions_in_EMU(self, dimensions_fixture): diff --git a/tests/parts/test_numbering.py b/tests/parts/test_numbering.py index 7655206ec..1ed0f2a05 100644 --- a/tests/parts/test_numbering.py +++ b/tests/parts/test_numbering.py @@ -24,9 +24,7 @@ def it_provides_access_to_the_numbering_definitions(self, num_defs_fixture): # fixtures ------------------------------------------------------- @pytest.fixture - def num_defs_fixture( - self, _NumberingDefinitions_, numbering_elm_, numbering_definitions_ - ): + def num_defs_fixture(self, _NumberingDefinitions_, numbering_elm_, numbering_definitions_): numbering_part = NumberingPart(None, None, numbering_elm_, None) return ( numbering_part, diff --git a/tests/parts/test_settings.py b/tests/parts/test_settings.py index 581cc6173..73b8a5e9a 100644 --- a/tests/parts/test_settings.py +++ b/tests/parts/test_settings.py @@ -14,9 +14,7 @@ class DescribeSettingsPart: - def it_is_used_by_loader_to_construct_settings_part( - self, load_, package_, settings_part_ - ): + def it_is_used_by_loader_to_construct_settings_part(self, load_, package_, settings_part_): partname, blob = "partname", "blob" content_type = CT.WML_SETTINGS load_.return_value = settings_part_ @@ -61,9 +59,7 @@ def package_(self, request): @pytest.fixture def Settings_(self, request, settings_): - return class_mock( - request, "docx.parts.settings.Settings", return_value=settings_ - ) + return class_mock(request, "docx.parts.settings.Settings", return_value=settings_) @pytest.fixture def settings_(self, request): diff --git a/tests/parts/test_story.py b/tests/parts/test_story.py index b65abe8b7..9a1dc7fab 100644 --- a/tests/parts/test_story.py +++ b/tests/parts/test_story.py @@ -30,9 +30,7 @@ def it_can_get_or_add_an_image(self, package_, image_part_, image_, relate_to_): assert rId == "rId42" assert image is image_ - def it_can_get_a_style_by_id_and_type( - self, _document_part_prop_, document_part_, style_ - ): + def it_can_get_a_style_by_id_and_type(self, _document_part_prop_, document_part_, style_): style_id = "BodyText" style_type = WD_STYLE_TYPE.PARAGRAPH _document_part_prop_.return_value = document_part_ diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index b24e02733..6201f9927 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -75,9 +75,7 @@ def character_style_(self, request): @pytest.fixture def _TableStyle_(self, request, table_style_): - return class_mock( - request, "docx.styles.style._TableStyle", return_value=table_style_ - ) + return class_mock(request, "docx.styles.style._TableStyle", return_value=table_style_) @pytest.fixture def table_style_(self, request): @@ -529,17 +527,11 @@ def next_get_fixture(self, request): def next_set_fixture(self, request): style_name, next_style_name, style_cxml = request.param styles = element( - "w:styles/(" - "w:style{w:type=paragraph,w:styleId=H}," - "w:style{w:type=paragraph,w:styleId=B})" + "w:styles/(w:style{w:type=paragraph,w:styleId=H},w:style{w:type=paragraph,w:styleId=B})" ) style_elms = {"H": styles[0], "B": styles[1]} style = ParagraphStyle(style_elms[style_name]) - next_style = ( - None - if next_style_name is None - else ParagraphStyle(style_elms[next_style_name]) - ) + next_style = ParagraphStyle(style_elms[next_style_name]) if next_style_name else None expected_xml = xml(style_cxml) return style, next_style, expected_xml diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py index ea9346bdc..7493388d0 100644 --- a/tests/styles/test_styles.py +++ b/tests/styles/test_styles.py @@ -52,9 +52,7 @@ def it_can_add_a_new_style(self, add_fixture): style = styles.add_style(name, style_type, builtin) - styles._element.add_style_of_type.assert_called_once_with( - name_, style_type, builtin - ) + styles._element.add_style_of_type.assert_called_once_with(name_, style_type, builtin) StyleFactory_.assert_called_once_with(style_elm_) assert style is style_ @@ -110,9 +108,7 @@ def and_it_can_get_a_style_id_from_a_style_name(self, _get_style_id_from_name_): style_id = styles.get_style_id("Style Name", style_type) - _get_style_id_from_name_.assert_called_once_with( - styles, "Style Name", style_type - ) + _get_style_id_from_name_.assert_called_once_with(styles, "Style Name", style_type) assert style_id == "StyleId" def but_it_returns_None_for_a_style_or_name_of_None(self): @@ -132,9 +128,7 @@ def it_gets_a_style_by_id_to_help(self, _get_by_id_fixture): assert StyleFactory_.call_args_list == StyleFactory_calls assert style is style_ - def it_gets_a_style_id_from_a_name_to_help( - self, _getitem_, _get_style_id_from_style_, style_ - ): + def it_gets_a_style_id_from_a_name_to_help(self, _getitem_, _get_style_id_from_style_, style_): style_name, style_type, style_id_ = "Foo Bar", 1, "FooBar" _getitem_.return_value = style_ _get_style_id_from_style_.return_value = style_id_ @@ -173,9 +167,7 @@ def it_provides_access_to_the_latent_styles(self, latent_styles_fixture): ("Heading 1", "heading 1", WD_STYLE_TYPE.PARAGRAPH, True), ] ) - def add_fixture( - self, request, styles_elm_, _getitem_, style_elm_, StyleFactory_, style_ - ): + def add_fixture(self, request, styles_elm_, _getitem_, style_elm_, StyleFactory_, style_): name, name_, style_type, builtin = request.param styles = Styles(styles_elm_) _getitem_.return_value = None @@ -207,8 +199,7 @@ def add_raises_fixture(self, _getitem_): WD_STYLE_TYPE.PARAGRAPH, ), ( - "w:styles/(w:style{w:type=table,w:default=1},w:style{w:type=table,w" - ":default=1})", + "w:styles/(w:style{w:type=table,w:default=1},w:style{w:type=table,w:default=1})", True, WD_STYLE_TYPE.TABLE, ), @@ -387,9 +378,7 @@ def _get_style_id_from_style_(self, request): @pytest.fixture def LatentStyles_(self, request, latent_styles_): - return class_mock( - request, "docx.styles.styles.LatentStyles", return_value=latent_styles_ - ) + return class_mock(request, "docx.styles.styles.LatentStyles", return_value=latent_styles_) @pytest.fixture def latent_styles_(self, request): diff --git a/tests/test_api.py b/tests/test_api.py index b6e6818b5..6b5d3ae07 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,66 +2,55 @@ import pytest -import docx -from docx.api import Document +from docx.api import Document as DocumentFactoryFn +from docx.document import Document as DocumentCls from docx.opc.constants import CONTENT_TYPE as CT -from .unitutil.mock import class_mock, function_mock, instance_mock +from .unitutil.mock import FixtureRequest, Mock, class_mock, function_mock, instance_mock class DescribeDocument: - def it_opens_a_docx_file(self, open_fixture): - docx, Package_, document_ = open_fixture - document = Document(docx) - Package_.open.assert_called_once_with(docx) - assert document is document_ - - def it_opens_the_default_docx_if_none_specified(self, default_fixture): - docx, Package_, document_ = default_fixture - document = Document() - Package_.open.assert_called_once_with(docx) - assert document is document_ - - def it_raises_on_not_a_Word_file(self, raise_fixture): - not_a_docx = raise_fixture - with pytest.raises(ValueError, match="file 'foobar.xlsx' is not a Word file,"): - Document(not_a_docx) + """Unit-test suite for `docx.api.Document` factory function.""" - # fixtures ------------------------------------------------------- - - @pytest.fixture - def default_fixture(self, _default_docx_path_, Package_, document_): - docx = "barfoo.docx" - _default_docx_path_.return_value = docx + def it_opens_a_docx_file(self, Package_: Mock, document_: Mock): document_part = Package_.open.return_value.main_document_part document_part.document = document_ document_part.content_type = CT.WML_DOCUMENT_MAIN - return docx, Package_, document_ - @pytest.fixture - def open_fixture(self, Package_, document_): - docx = "foobar.docx" + document = DocumentFactoryFn("foobar.docx") + + Package_.open.assert_called_once_with("foobar.docx") + assert document is document_ + + def it_opens_the_default_docx_if_none_specified( + self, _default_docx_path_: Mock, Package_: Mock, document_: Mock + ): + _default_docx_path_.return_value = "default-document.docx" document_part = Package_.open.return_value.main_document_part document_part.document = document_ document_part.content_type = CT.WML_DOCUMENT_MAIN - return docx, Package_, document_ - @pytest.fixture - def raise_fixture(self, Package_): - not_a_docx = "foobar.xlsx" + document = DocumentFactoryFn() + + Package_.open.assert_called_once_with("default-document.docx") + assert document is document_ + + def it_raises_on_not_a_Word_file(self, Package_: Mock): Package_.open.return_value.main_document_part.content_type = "BOGUS" - return not_a_docx - # fixture components --------------------------------------------- + with pytest.raises(ValueError, match="file 'foobar.xlsx' is not a Word file,"): + DocumentFactoryFn("foobar.xlsx") + + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def _default_docx_path_(self, request): + def _default_docx_path_(self, request: FixtureRequest): return function_mock(request, "docx.api._default_docx_path") @pytest.fixture - def document_(self, request): - return instance_mock(request, docx.document.Document) + def document_(self, request: FixtureRequest): + return instance_mock(request, DocumentCls) @pytest.fixture - def Package_(self, request): + def Package_(self, request: FixtureRequest): return class_mock(request, "docx.api.Package") diff --git a/tests/test_blkcntnr.py b/tests/test_blkcntnr.py index 1549bd8ea..ab463663f 100644 --- a/tests/test_blkcntnr.py +++ b/tests/test_blkcntnr.py @@ -1,42 +1,61 @@ +# pyright: reportPrivateUsage=false + """Test suite for the docx.blkcntnr (block item container) module.""" +from __future__ import annotations + +from typing import cast + import pytest -from docx import Document +import docx from docx.blkcntnr import BlockItemContainer +from docx.document import Document +from docx.oxml.document import CT_Body from docx.shared import Inches from docx.table import Table from docx.text.paragraph import Paragraph from .unitutil.cxml import element, xml from .unitutil.file import snippet_seq, test_file -from .unitutil.mock import call, instance_mock, method_mock +from .unitutil.mock import FixtureRequest, Mock, call, instance_mock, method_mock class DescribeBlockItemContainer: """Unit-test suite for `docx.blkcntnr.BlockItemContainer`.""" - def it_can_add_a_paragraph(self, add_paragraph_fixture, _add_paragraph_): - text, style, paragraph_, add_run_calls = add_paragraph_fixture + @pytest.mark.parametrize( + ("text", "style"), [("", None), ("Foo", None), ("", "Bar"), ("Foo", "Bar")] + ) + def it_can_add_a_paragraph( + self, + text: str, + style: str | None, + blkcntnr: BlockItemContainer, + _add_paragraph_: Mock, + paragraph_: Mock, + ): + paragraph_.style = None _add_paragraph_.return_value = paragraph_ - blkcntnr = BlockItemContainer(None, None) paragraph = blkcntnr.add_paragraph(text, style) _add_paragraph_.assert_called_once_with(blkcntnr) - assert paragraph.add_run.call_args_list == add_run_calls + assert paragraph_.add_run.call_args_list == ([call(text)] if text else []) assert paragraph.style == style assert paragraph is paragraph_ - def it_can_add_a_table(self, add_table_fixture): - blkcntnr, rows, cols, width, expected_xml = add_table_fixture + def it_can_add_a_table(self, blkcntnr: BlockItemContainer): + rows, cols, width = 2, 2, Inches(2) + table = blkcntnr.add_table(rows, cols, width) + assert isinstance(table, Table) - assert table._element.xml == expected_xml + assert table._element.xml == snippet_seq("new-tbl")[0] assert table._parent is blkcntnr def it_can_iterate_its_inner_content(self): - document = Document(test_file("blk-inner-content.docx")) + document = docx.Document(test_file("blk-inner-content.docx")) inner_content = document.iter_inner_content() @@ -55,101 +74,78 @@ def it_can_iterate_its_inner_content(self): with pytest.raises(StopIteration): next(inner_content) - def it_provides_access_to_the_paragraphs_it_contains(self, paragraphs_fixture): - # test len(), iterable, and indexed access - blkcntnr, expected_count = paragraphs_fixture - paragraphs = blkcntnr.paragraphs - assert len(paragraphs) == expected_count - count = 0 - for idx, paragraph in enumerate(paragraphs): - assert isinstance(paragraph, Paragraph) - assert paragraphs[idx] is paragraph - count += 1 - assert count == expected_count - - def it_provides_access_to_the_tables_it_contains(self, tables_fixture): - # test len(), iterable, and indexed access - blkcntnr, expected_count = tables_fixture - tables = blkcntnr.tables - assert len(tables) == expected_count - count = 0 - for idx, table in enumerate(tables): - assert isinstance(table, Table) - assert tables[idx] is table - count += 1 - assert count == expected_count - - def it_adds_a_paragraph_to_help(self, _add_paragraph_fixture): - blkcntnr, expected_xml = _add_paragraph_fixture - new_paragraph = blkcntnr._add_paragraph() - assert isinstance(new_paragraph, Paragraph) - assert new_paragraph._parent == blkcntnr - assert blkcntnr._element.xml == expected_xml - - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ - ("", None), - ("Foo", None), - ("", "Bar"), - ("Foo", "Bar"), - ] - ) - def add_paragraph_fixture(self, request, paragraph_): - text, style = request.param - paragraph_.style = None - add_run_calls = [call(text)] if text else [] - return text, style, paragraph_, add_run_calls - - @pytest.fixture - def _add_paragraph_fixture(self, request): - blkcntnr_cxml, after_cxml = "w:body", "w:body/w:p" - blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) - expected_xml = xml(after_cxml) - return blkcntnr, expected_xml - - @pytest.fixture - def add_table_fixture(self): - blkcntnr = BlockItemContainer(element("w:body"), None) - rows, cols, width = 2, 2, Inches(2) - expected_xml = snippet_seq("new-tbl")[0] - return blkcntnr, rows, cols, width, expected_xml - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("blkcntnr_cxml", "expected_count"), + [ ("w:body", 0), ("w:body/w:p", 1), ("w:body/(w:p,w:p)", 2), ("w:body/(w:p,w:tbl)", 1), ("w:body/(w:p,w:tbl,w:p)", 2), - ] + ], ) - def paragraphs_fixture(self, request): - blkcntnr_cxml, expected_count = request.param - blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) - return blkcntnr, expected_count + def it_provides_access_to_the_paragraphs_it_contains( + self, blkcntnr_cxml: str, expected_count: int, document_: Mock + ): + blkcntnr = BlockItemContainer(cast(CT_Body, element(blkcntnr_cxml)), document_) + + paragraphs = blkcntnr.paragraphs - @pytest.fixture( - params=[ + # -- supports len() -- + assert len(paragraphs) == expected_count + # -- is iterable -- + assert all(isinstance(p, Paragraph) for p in paragraphs) + # -- is indexable -- + assert all(p is paragraphs[idx] for idx, p in enumerate(paragraphs)) + + @pytest.mark.parametrize( + ("blkcntnr_cxml", "expected_count"), + [ ("w:body", 0), ("w:body/w:tbl", 1), ("w:body/(w:tbl,w:tbl)", 2), ("w:body/(w:p,w:tbl)", 1), ("w:body/(w:tbl,w:tbl,w:p)", 2), - ] + ], ) - def tables_fixture(self, request): - blkcntnr_cxml, expected_count = request.param - blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) - return blkcntnr, expected_count + def it_provides_access_to_the_tables_it_contains( + self, blkcntnr_cxml: str, expected_count: int, document_: Mock + ): + blkcntnr = BlockItemContainer(cast(CT_Body, element(blkcntnr_cxml)), document_) + + tables = blkcntnr.tables + + # -- supports len() -- + assert len(tables) == expected_count + # -- is iterable -- + assert all(isinstance(t, Table) for t in tables) + # -- is indexable -- + assert all(t is tables[idx] for idx, t in enumerate(tables)) - # fixture components --------------------------------------------- + def it_adds_a_paragraph_to_help(self, document_: Mock): + blkcntnr = BlockItemContainer(cast(CT_Body, element("w:body")), document_) + + new_paragraph = blkcntnr._add_paragraph() + + assert isinstance(new_paragraph, Paragraph) + assert new_paragraph._parent == blkcntnr + assert blkcntnr._element.xml == xml("w:body/w:p") + + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def _add_paragraph_(self, request): + def _add_paragraph_(self, request: FixtureRequest): return method_mock(request, BlockItemContainer, "_add_paragraph") @pytest.fixture - def paragraph_(self, request): + def blkcntnr(self, document_: Mock): + blkcntnr_elm = cast(CT_Body, element("w:body")) + return BlockItemContainer(blkcntnr_elm, document_) + + @pytest.fixture + def document_(self, request: FixtureRequest): + return instance_mock(request, Document) + + @pytest.fixture + def paragraph_(self, request: FixtureRequest): return instance_mock(request, Paragraph) diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 000000000..0f292ec8a --- /dev/null +++ b/tests/test_comments.py @@ -0,0 +1,275 @@ +# pyright: reportPrivateUsage=false + +"""Unit test suite for the `docx.comments` module.""" + +from __future__ import annotations + +import datetime as dt +from typing import cast + +import pytest + +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_Comment, CT_Comments +from docx.oxml.ns import qn +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` objects.""" + + @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 + + 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) + + 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] + + 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) + + 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 + 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) + + +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 + + 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" + + 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" + + 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) + + @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, + 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"] + + 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 + def comments_part_(self, request: FixtureRequest): + return instance_mock(request, CommentsPart) diff --git a/tests/test_document.py b/tests/test_document.py index 6a2c5af88..53efacf8d 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -9,11 +9,12 @@ import pytest +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 from docx.opc.coreprops import CoreProperties -from docx.oxml.document import CT_Document +from docx.oxml.document import CT_Body, CT_Document from docx.parts.document import DocumentPart from docx.section import Section, Sections from docx.settings import Settings @@ -25,33 +26,63 @@ from docx.text.run import Run from .unitutil.cxml import element, xml -from .unitutil.mock import Mock, class_mock, instance_mock, method_mock, property_mock +from .unitutil.mock import ( + FixtureRequest, + Mock, + class_mock, + instance_mock, + method_mock, + property_mock, +) class DescribeDocument: - """Unit-test suite for `docx.Document`.""" + """Unit-test suite for `docx.document.Document`.""" + + def it_can_add_a_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.") - def it_can_add_a_heading(self, add_heading_fixture, add_paragraph_, paragraph_): - level, style = add_heading_fixture + 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")] + ) + def it_can_add_a_heading( + self, level: int, style: str, document: Document, add_paragraph_: Mock, paragraph_: Mock + ): add_paragraph_.return_value = paragraph_ - document = Document(None, None) paragraph = document.add_heading("Spam vs. Bacon", level) add_paragraph_.assert_called_once_with(document, "Spam vs. Bacon", style) assert paragraph is paragraph_ - def it_raises_on_heading_level_out_of_range(self): - document = Document(None, None) + def it_raises_on_heading_level_out_of_range(self, document: Document): with pytest.raises(ValueError, match="level must be in range 0-9, got -1"): document.add_heading(level=-1) with pytest.raises(ValueError, match="level must be in range 0-9, got 10"): document.add_heading(level=10) - def it_can_add_a_page_break(self, add_paragraph_, paragraph_, run_): + def it_can_add_a_page_break( + self, document: Document, add_paragraph_: Mock, paragraph_: Mock, run_: Mock + ): add_paragraph_.return_value = paragraph_ paragraph_.add_run.return_value = run_ - document = Document(None, None) paragraph = document.add_page_break() @@ -60,70 +91,143 @@ def it_can_add_a_page_break(self, add_paragraph_, paragraph_, run_): run_.add_break.assert_called_once_with(WD_BREAK.PAGE) assert paragraph is paragraph_ - def it_can_add_a_paragraph(self, add_paragraph_fixture): - document, text, style, paragraph_ = add_paragraph_fixture + @pytest.mark.parametrize( + ("text", "style"), [("", None), ("", "Heading 1"), ("foo\rbar", "Body Text")] + ) + def it_can_add_a_paragraph( + self, + text: str, + style: str | None, + document: Document, + body_: Mock, + body_prop_: Mock, + paragraph_: Mock, + ): + body_prop_.return_value = body_ + body_.add_paragraph.return_value = paragraph_ + paragraph = document.add_paragraph(text, style) - document._body.add_paragraph.assert_called_once_with(text, style) + + body_.add_paragraph.assert_called_once_with(text, style) assert paragraph is paragraph_ - def it_can_add_a_picture(self, add_picture_fixture): - document, path, width, height, run_, picture_ = add_picture_fixture + def it_can_add_a_picture( + self, document: Document, add_paragraph_: Mock, run_: Mock, picture_: Mock + ): + path, width, height = "foobar.png", 100, 200 + add_paragraph_.return_value.add_run.return_value = run_ + run_.add_picture.return_value = picture_ + picture = document.add_picture(path, width, height) + run_.add_picture.assert_called_once_with(path, width, height) assert picture is picture_ + @pytest.mark.parametrize( + ("sentinel_cxml", "start_type", "new_sentinel_cxml"), + [ + ("w:sectPr", WD_SECTION.EVEN_PAGE, "w:sectPr/w:type{w:val=evenPage}"), + ( + "w:sectPr/w:type{w:val=evenPage}", + WD_SECTION.ODD_PAGE, + "w:sectPr/w:type{w:val=oddPage}", + ), + ("w:sectPr/w:type{w:val=oddPage}", WD_SECTION.NEW_PAGE, "w:sectPr"), + ], + ) def it_can_add_a_section( - self, add_section_fixture, Section_, section_, document_part_ + self, + sentinel_cxml: str, + start_type: WD_SECTION, + new_sentinel_cxml: str, + Section_: Mock, + section_: Mock, + document_part_: Mock, ): - document_elm, start_type, expected_xml = add_section_fixture Section_.return_value = section_ - document = Document(document_elm, document_part_) + document = Document( + cast(CT_Document, element("w:document/w:body/(w:p,%s)" % sentinel_cxml)), + document_part_, + ) section = document.add_section(start_type) - assert document.element.xml == expected_xml + assert document.element.xml == xml( + "w:document/w:body/(w:p,w:p/w:pPr/%s,%s)" % (sentinel_cxml, new_sentinel_cxml) + ) sectPr = document.element.xpath("w:body/w:sectPr")[0] Section_.assert_called_once_with(sectPr, document_part_) assert section is section_ - def it_can_add_a_table(self, add_table_fixture): - document, rows, cols, style, width, table_ = add_table_fixture + def it_can_add_a_table( + self, + document: Document, + _block_width_prop_: Mock, + body_prop_: Mock, + body_: Mock, + table_: Mock, + ): + rows, cols, style = 4, 2, "Light Shading Accent 1" + body_prop_.return_value = body_ + body_.add_table.return_value = table_ + _block_width_prop_.return_value = width = 42 + table = document.add_table(rows, cols, style) - document._body.add_table.assert_called_once_with(rows, cols, width) + + body_.add_table.assert_called_once_with(rows, cols, width) assert table == table_ assert table.style == style - def it_can_save_the_document_to_a_file(self, save_fixture): - document, file_ = save_fixture - document.save(file_) - document._part.save.assert_called_once_with(file_) + def it_can_save_the_document_to_a_file(self, document_part_: Mock): + document = Document(cast(CT_Document, element("w:document")), document_part_) + + document.save("foobar.docx") + + document_part_.save.assert_called_once_with("foobar.docx") + + def it_provides_access_to_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 + ): + document_part_.core_properties = core_properties_ + document = Document(cast(CT_Document, element("w:document")), document_part_) - def it_provides_access_to_its_core_properties(self, core_props_fixture): - document, core_properties_ = core_props_fixture core_properties = document.core_properties + assert core_properties is core_properties_ - def it_provides_access_to_its_inline_shapes(self, inline_shapes_fixture): - document, inline_shapes_ = inline_shapes_fixture + def it_provides_access_to_its_inline_shapes(self, document_part_: Mock, inline_shapes_: Mock): + document_part_.inline_shapes = inline_shapes_ + document = Document(cast(CT_Document, element("w:document")), document_part_) + assert document.inline_shapes is inline_shapes_ def it_can_iterate_the_inner_content_of_the_document( self, body_prop_: Mock, body_: Mock, document_part_: Mock ): - document_elm = cast(CT_Document, element("w:document")) body_prop_.return_value = body_ body_.iter_inner_content.return_value = iter((1, 2, 3)) - document = Document(document_elm, document_part_) + document = Document(cast(CT_Document, element("w:document")), document_part_) assert list(document.iter_inner_content()) == [1, 2, 3] - def it_provides_access_to_its_paragraphs(self, paragraphs_fixture): - document, paragraphs_ = paragraphs_fixture + def it_provides_access_to_its_paragraphs( + self, document: Document, body_prop_: Mock, body_: Mock, paragraphs_: Mock + ): + body_prop_.return_value = body_ + body_.paragraphs = paragraphs_ paragraphs = document.paragraphs assert paragraphs is paragraphs_ - def it_provides_access_to_its_sections(self, document_part_, Sections_, sections_): - document_elm = element("w:document") + def it_provides_access_to_its_sections( + self, document_part_: Mock, Sections_: Mock, sections_: Mock + ): + document_elm = cast(CT_Document, element("w:document")) Sections_.return_value = sections_ document = Document(document_elm, document_part_) @@ -132,267 +236,188 @@ def it_provides_access_to_its_sections(self, document_part_, Sections_, sections Sections_.assert_called_once_with(document_elm, document_part_) assert sections is sections_ - def it_provides_access_to_its_settings(self, settings_fixture): - document, settings_ = settings_fixture - assert document.settings is settings_ + def it_provides_access_to_its_settings(self, document_part_: Mock, settings_: Mock): + document_part_.settings = settings_ + document = Document(cast(CT_Document, element("w:document")), document_part_) - def it_provides_access_to_its_styles(self, styles_fixture): - document, styles_ = styles_fixture - assert document.styles is styles_ + assert document.settings is settings_ - def it_provides_access_to_its_tables(self, tables_fixture): - document, tables_ = tables_fixture - tables = document.tables - assert tables is tables_ + def it_provides_access_to_its_styles(self, document_part_: Mock, styles_: Mock): + document_part_.styles = styles_ + document = Document(cast(CT_Document, element("w:document")), document_part_) - def it_provides_access_to_the_document_part(self, part_fixture): - document, part_ = part_fixture - assert document.part is part_ + assert document.styles is styles_ - def it_provides_access_to_the_document_body(self, body_fixture): - document, body_elm, _Body_, body_ = body_fixture - body = document._body - _Body_.assert_called_once_with(body_elm, document) - assert body is body_ + def it_provides_access_to_its_tables( + self, document: Document, body_prop_: Mock, body_: Mock, tables_: Mock + ): + body_prop_.return_value = body_ + body_.tables = tables_ - def it_determines_block_width_to_help(self, block_width_fixture): - document, expected_value = block_width_fixture - width = document._block_width - assert isinstance(width, Length) - assert width == expected_value + assert document.tables is tables_ - # fixtures ------------------------------------------------------- + def it_provides_access_to_the_document_part(self, document_part_: Mock): + document = Document(cast(CT_Document, element("w:document")), document_part_) + assert document.part is document_part_ - @pytest.fixture( - params=[ - (0, "Title"), - (1, "Heading 1"), - (2, "Heading 2"), - (9, "Heading 9"), - ] - ) - def add_heading_fixture(self, request): - level, style = request.param - return level, style - - @pytest.fixture( - params=[ - ("", None), - ("", "Heading 1"), - ("foo\rbar", "Body Text"), - ] - ) - def add_paragraph_fixture(self, request, body_prop_, paragraph_): - text, style = request.param - document = Document(None, None) - body_prop_.return_value.add_paragraph.return_value = paragraph_ - return document, text, style, paragraph_ - - @pytest.fixture - def add_picture_fixture(self, request, add_paragraph_, run_, picture_): - document = Document(None, None) - path, width, height = "foobar.png", 100, 200 - add_paragraph_.return_value.add_run.return_value = run_ - run_.add_picture.return_value = picture_ - return document, path, width, height, run_, picture_ + def it_provides_access_to_the_document_body( + self, _Body_: Mock, body_: Mock, document_part_: Mock + ): + _Body_.return_value = body_ + document_elm = cast(CT_Document, element("w:document/w:body")) + body_elm = document_elm[0] + document = Document(document_elm, document_part_) - @pytest.fixture( - params=[ - ("w:sectPr", WD_SECTION.EVEN_PAGE, "w:sectPr/w:type{w:val=evenPage}"), - ( - "w:sectPr/w:type{w:val=evenPage}", - WD_SECTION.ODD_PAGE, - "w:sectPr/w:type{w:val=oddPage}", - ), - ("w:sectPr/w:type{w:val=oddPage}", WD_SECTION.NEW_PAGE, "w:sectPr"), - ] - ) - def add_section_fixture(self, request): - sentinel, start_type, new_sentinel = request.param - document_elm = element("w:document/w:body/(w:p,%s)" % sentinel) - expected_xml = xml( - "w:document/w:body/(w:p,w:p/w:pPr/%s,%s)" % (sentinel, new_sentinel) - ) - return document_elm, start_type, expected_xml + body = document._body - @pytest.fixture - def add_table_fixture(self, _block_width_prop_, body_prop_, table_): - document = Document(None, None) - rows, cols, style = 4, 2, "Light Shading Accent 1" - body_prop_.return_value.add_table.return_value = table_ - _block_width_prop_.return_value = width = 42 - return document, rows, cols, style, width, table_ + _Body_.assert_called_once_with(body_elm, document) + assert body is body_ - @pytest.fixture - def block_width_fixture(self, sections_prop_, section_): - document = Document(None, None) + def it_determines_block_width_to_help( + self, document: Document, sections_prop_: Mock, section_: Mock + ): sections_prop_.return_value = [None, section_] section_.page_width = 6000 section_.left_margin = 1500 section_.right_margin = 1000 - expected_value = 3500 - return document, expected_value - - @pytest.fixture - def body_fixture(self, _Body_, body_): - document_elm = element("w:document/w:body") - body_elm = document_elm[0] - document = Document(document_elm, None) - return document, body_elm, _Body_, body_ - - @pytest.fixture - def core_props_fixture(self, document_part_, core_properties_): - document = Document(None, document_part_) - document_part_.core_properties = core_properties_ - return document, core_properties_ - @pytest.fixture - def inline_shapes_fixture(self, document_part_, inline_shapes_): - document = Document(None, document_part_) - document_part_.inline_shapes = inline_shapes_ - return document, inline_shapes_ + width = document._block_width - @pytest.fixture - def paragraphs_fixture(self, body_prop_, paragraphs_): - document = Document(None, None) - body_prop_.return_value.paragraphs = paragraphs_ - return document, paragraphs_ + assert isinstance(width, Length) + assert width == 3500 - @pytest.fixture - def part_fixture(self, document_part_): - document = Document(None, document_part_) - return document, document_part_ + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def save_fixture(self, document_part_): - document = Document(None, document_part_) - file_ = "foobar.docx" - return document, file_ + def add_paragraph_(self, request: FixtureRequest): + return method_mock(request, Document, "add_paragraph") @pytest.fixture - def settings_fixture(self, document_part_, settings_): - document = Document(None, document_part_) - document_part_.settings = settings_ - return document, settings_ + def _Body_(self, request: FixtureRequest): + return class_mock(request, "docx.document._Body") @pytest.fixture - def styles_fixture(self, document_part_, styles_): - document = Document(None, document_part_) - document_part_.styles = styles_ - return document, styles_ + def body_(self, request: FixtureRequest): + return instance_mock(request, _Body) @pytest.fixture - def tables_fixture(self, body_prop_, tables_): - document = Document(None, None) - body_prop_.return_value.tables = tables_ - return document, tables_ - - # fixture components --------------------------------------------- + def _block_width_prop_(self, request: FixtureRequest): + return property_mock(request, Document, "_block_width") @pytest.fixture - def add_paragraph_(self, request): - return method_mock(request, Document, "add_paragraph") + def body_prop_(self, request: FixtureRequest): + return property_mock(request, Document, "_body") @pytest.fixture - def _Body_(self, request, body_): - return class_mock(request, "docx.document._Body", return_value=body_) + def comment_(self, request: FixtureRequest): + return instance_mock(request, Comment) @pytest.fixture - def body_(self, request): - return instance_mock(request, _Body) + def comments_(self, request: FixtureRequest): + return instance_mock(request, Comments) @pytest.fixture - def _block_width_prop_(self, request): - return property_mock(request, Document, "_block_width") + def comments_prop_(self, request: FixtureRequest): + return property_mock(request, Document, "comments") @pytest.fixture - def body_prop_(self, request, body_): - return property_mock(request, Document, "_body", return_value=body_) + def core_properties_(self, request: FixtureRequest): + return instance_mock(request, CoreProperties) @pytest.fixture - def core_properties_(self, request): - return instance_mock(request, CoreProperties) + def document(self, document_part_: Mock) -> Document: + document_elm = cast(CT_Document, element("w:document")) + return Document(document_elm, document_part_) @pytest.fixture - def document_part_(self, request): + def document_part_(self, request: FixtureRequest): return instance_mock(request, DocumentPart) @pytest.fixture - def inline_shapes_(self, request): + def inline_shapes_(self, request: FixtureRequest): return instance_mock(request, InlineShapes) @pytest.fixture - def paragraph_(self, request): + def paragraph_(self, request: FixtureRequest): return instance_mock(request, Paragraph) @pytest.fixture - def paragraphs_(self, request): + def paragraphs_(self, request: FixtureRequest): return instance_mock(request, list) @pytest.fixture - def picture_(self, request): + def picture_(self, request: FixtureRequest): return instance_mock(request, InlineShape) @pytest.fixture - def run_(self, request): + def run_(self, request: FixtureRequest): return instance_mock(request, Run) @pytest.fixture - def Section_(self, request): + def 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") @pytest.fixture - def section_(self, request): + def section_(self, request: FixtureRequest): return instance_mock(request, Section) @pytest.fixture - def Sections_(self, request): + def Sections_(self, request: FixtureRequest): return class_mock(request, "docx.document.Sections") @pytest.fixture - def sections_(self, request): + def sections_(self, request: FixtureRequest): return instance_mock(request, Sections) @pytest.fixture - def sections_prop_(self, request): + def sections_prop_(self, request: FixtureRequest): return property_mock(request, Document, "sections") @pytest.fixture - def settings_(self, request): + def settings_(self, request: FixtureRequest): return instance_mock(request, Settings) @pytest.fixture - def styles_(self, request): + def styles_(self, request: FixtureRequest): return instance_mock(request, Styles) @pytest.fixture - def table_(self, request): - return instance_mock(request, Table, style="UNASSIGNED") + def table_(self, request: FixtureRequest): + return instance_mock(request, Table) @pytest.fixture - def tables_(self, request): + def tables_(self, request: FixtureRequest): return instance_mock(request, list) class Describe_Body: - def it_can_clear_itself_of_all_content_it_holds(self, clear_fixture): - body, expected_xml = clear_fixture - _body = body.clear_content() - assert body._body.xml == expected_xml - assert _body is body + """Unit-test suite for `docx.document._Body`.""" - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("cxml", "expected_cxml"), + [ ("w:body", "w:body"), ("w:body/w:p", "w:body"), ("w:body/w:sectPr", "w:body/w:sectPr"), ("w:body/(w:p, w:sectPr)", "w:body/w:sectPr"), - ] + ], ) - def clear_fixture(self, request): - before_cxml, after_cxml = request.param - body = _Body(element(before_cxml), None) - expected_xml = xml(after_cxml) - return body, expected_xml + def it_can_clear_itself_of_all_content_it_holds( + self, cxml: str, expected_cxml: str, document_: Mock + ): + body = _Body(cast(CT_Body, element(cxml)), document_) + + _body = body.clear_content() + + assert body._body.xml == xml(expected_cxml) + assert _body is body + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def document_(self, request: FixtureRequest): + return instance_mock(request, Document) diff --git a/tests/test_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) diff --git a/tests/test_enum.py b/tests/test_enum.py index 1b8a14f5b..79607a7e0 100644 --- a/tests/test_enum.py +++ b/tests/test_enum.py @@ -60,9 +60,7 @@ def and_it_can_find_the_member_from_None_when_a_member_maps_that(self): assert SomeXmlAttr.from_xml(None) == SomeXmlAttr.BAZ def but_it_raises_when_there_is_no_such_mapped_XML_value(self): - with pytest.raises( - ValueError, match="SomeXmlAttr has no XML mapping for 'baz'" - ): + with pytest.raises(ValueError, match="SomeXmlAttr has no XML mapping for 'baz'"): SomeXmlAttr.from_xml("baz") diff --git a/tests/test_package.py b/tests/test_package.py index eda5f0132..ac9839828 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -1,5 +1,9 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for docx.package module.""" +from __future__ import annotations + import pytest from docx.image.image import Image @@ -8,12 +12,21 @@ from docx.parts.image import ImagePart from .unitutil.file import docx_path -from .unitutil.mock import class_mock, instance_mock, method_mock, property_mock +from .unitutil.mock import ( + FixtureRequest, + Mock, + class_mock, + instance_mock, + method_mock, + property_mock, +) class DescribePackage: + """Unit-test suite for `docx.package.Package`.""" + def it_can_get_or_add_an_image_part_containing_a_specified_image( - self, image_parts_prop_, image_parts_, image_part_ + self, image_parts_prop_: Mock, image_parts_: Mock, image_part_: Mock ): image_parts_prop_.return_value = image_parts_ image_parts_.get_or_add_image_part.return_value = image_part_ @@ -26,29 +39,36 @@ def it_can_get_or_add_an_image_part_containing_a_specified_image( def it_gathers_package_image_parts_after_unmarshalling(self): package = Package.open(docx_path("having-images")) + image_parts = package.image_parts + assert len(image_parts) == 3 - for image_part in image_parts: - assert isinstance(image_part, ImagePart) + assert all(isinstance(p, ImagePart) for p in image_parts) # fixture components --------------------------------------------- @pytest.fixture - def image_part_(self, request): + def image_part_(self, request: FixtureRequest): return instance_mock(request, ImagePart) @pytest.fixture - def image_parts_(self, request): + def image_parts_(self, request: FixtureRequest): return instance_mock(request, ImageParts) @pytest.fixture - def image_parts_prop_(self, request): + def image_parts_prop_(self, request: FixtureRequest): return property_mock(request, Package, "image_parts") class DescribeImageParts: + """Unit-test suite for `docx.package.Package`.""" + def it_can_get_a_matching_image_part( - self, Image_, image_, _get_by_sha1_, image_part_ + self, + Image_: Mock, + image_: Mock, + _get_by_sha1_: Mock, + image_part_: Mock, ): Image_.from_file.return_value = image_ image_.sha1 = "f005ba11" @@ -62,7 +82,12 @@ def it_can_get_a_matching_image_part( assert image_part is image_part_ def but_it_adds_a_new_image_part_when_match_fails( - self, Image_, image_, _get_by_sha1_, _add_image_part_, image_part_ + self, + Image_: Mock, + image_: Mock, + _get_by_sha1_: Mock, + _add_image_part_: Mock, + image_part_: Mock, ): Image_.from_file.return_value = image_ image_.sha1 = "fa1afe1" @@ -77,73 +102,74 @@ def but_it_adds_a_new_image_part_when_match_fails( _add_image_part_.assert_called_once_with(image_parts, image_) assert image_part is image_part_ - def it_knows_the_next_available_image_partname(self, next_partname_fixture): - image_parts, ext, expected_partname = next_partname_fixture - assert image_parts._next_image_partname(ext) == expected_partname + @pytest.mark.parametrize( + ("existing_partname_numbers", "expected_partname_number"), + [ + ((2, 3), 1), + ((1, 3), 2), + ((1, 2), 3), + ], + ) + def it_knows_the_next_available_image_partname( + self, + request: FixtureRequest, + existing_partname_numbers: tuple[int, int], + expected_partname_number: int, + ): + image_parts = ImageParts() + for n in existing_partname_numbers: + image_parts.append( + instance_mock(request, ImagePart, partname=PackURI(f"/word/media/image{n}.png")) + ) + + next_partname = image_parts._next_image_partname("png") - def it_can_really_add_a_new_image_part( - self, _next_image_partname_, partname_, image_, ImagePart_, image_part_ + assert next_partname == PackURI("/word/media/image%d.png" % expected_partname_number) + + def it_can_add_a_new_image_part( + self, + _next_image_partname_: Mock, + image_: Mock, + ImagePart_: Mock, + image_part_: Mock, ): - _next_image_partname_.return_value = partname_ + partname = PackURI("/word/media/image7.png") + _next_image_partname_.return_value = partname ImagePart_.from_image.return_value = image_part_ image_parts = ImageParts() image_part = image_parts._add_image_part(image_) - ImagePart_.from_image.assert_called_once_with(image_, partname_) + ImagePart_.from_image.assert_called_once_with(image_, partname) assert image_part in image_parts assert image_part is image_part_ # fixtures ------------------------------------------------------- - @pytest.fixture(params=[((2, 3), 1), ((1, 3), 2), ((1, 2), 3)]) - def next_partname_fixture(self, request): - def image_part_with_partname_(n): - partname = image_partname(n) - return instance_mock(request, ImagePart, partname=partname) - - def image_partname(n): - return PackURI("/word/media/image%d.png" % n) - - existing_partname_numbers, expected_partname_number = request.param - image_parts = ImageParts() - for n in existing_partname_numbers: - image_part_ = image_part_with_partname_(n) - image_parts.append(image_part_) - ext = "png" - expected_image_partname = image_partname(expected_partname_number) - return image_parts, ext, expected_image_partname - - # fixture components --------------------------------------------- - @pytest.fixture - def _add_image_part_(self, request): + def _add_image_part_(self, request: FixtureRequest): return method_mock(request, ImageParts, "_add_image_part") @pytest.fixture - def _get_by_sha1_(self, request): + def _get_by_sha1_(self, request: FixtureRequest): return method_mock(request, ImageParts, "_get_by_sha1") @pytest.fixture - def Image_(self, request): + def Image_(self, request: FixtureRequest): return class_mock(request, "docx.package.Image") @pytest.fixture - def image_(self, request): + def image_(self, request: FixtureRequest): return instance_mock(request, Image) @pytest.fixture - def ImagePart_(self, request): + def ImagePart_(self, request: FixtureRequest): return class_mock(request, "docx.package.ImagePart") @pytest.fixture - def image_part_(self, request): + def image_part_(self, request: FixtureRequest): return instance_mock(request, ImagePart) @pytest.fixture - def _next_image_partname_(self, request): + def _next_image_partname_(self, request: FixtureRequest): return method_mock(request, ImageParts, "_next_image_partname") - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) diff --git a/tests/test_section.py b/tests/test_section.py index 333e755b7..54d665768 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -65,9 +65,7 @@ def it_can_access_its_Section_instances_by_index( ): document_elm = cast( CT_Document, - element( - "w:document/w:body/(w:p/w:pPr/w:sectPr,w:p/w:pPr/w:sectPr,w:sectPr)" - ), + element("w:document/w:body/(w:p/w:pPr/w:sectPr,w:p/w:pPr/w:sectPr,w:sectPr)"), ) sectPrs = document_elm.xpath("//w:sectPr") Section_.return_value = section_ @@ -87,9 +85,7 @@ def it_can_access_its_Section_instances_by_slice( ): document_elm = cast( CT_Document, - element( - "w:document/w:body/(w:p/w:pPr/w:sectPr,w:p/w:pPr/w:sectPr,w:sectPr)" - ), + element("w:document/w:body/(w:p/w:pPr/w:sectPr,w:p/w:pPr/w:sectPr,w:sectPr)"), ) sectPrs = document_elm.xpath("//w:sectPr") Section_.return_value = section_ @@ -103,7 +99,7 @@ def it_can_access_its_Section_instances_by_slice( ] assert section_lst == [section_, section_] - # fixture components --------------------------------------------- + # -- fixtures--------------------------------------------------------------------------------- @pytest.fixture def document_part_(self, request: FixtureRequest): @@ -170,9 +166,7 @@ def it_provides_access_to_its_even_page_footer( footer = section.even_page_footer - _Footer_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE - ) + _Footer_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) assert footer is footer_ def it_provides_access_to_its_even_page_header( @@ -184,9 +178,7 @@ def it_provides_access_to_its_even_page_header( header = section.even_page_header - _Header_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE - ) + _Header_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) assert header is header_ def it_provides_access_to_its_first_page_footer( @@ -198,9 +190,7 @@ def it_provides_access_to_its_first_page_footer( footer = section.first_page_footer - _Footer_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE - ) + _Footer_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE) assert footer is footer_ def it_provides_access_to_its_first_page_header( @@ -212,9 +202,7 @@ def it_provides_access_to_its_first_page_header( header = section.first_page_header - _Header_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE - ) + _Header_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE) assert header is header_ def it_provides_access_to_its_default_footer( @@ -226,9 +214,7 @@ def it_provides_access_to_its_default_footer( footer = section.footer - _Footer_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY - ) + _Footer_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) assert footer is footer_ def it_provides_access_to_its_default_header( @@ -240,9 +226,7 @@ def it_provides_access_to_its_default_header( header = section.header - _Header_.assert_called_once_with( - sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY - ) + _Header_.assert_called_once_with(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) assert header is header_ def it_can_iterate_its_inner_content(self): @@ -562,20 +546,16 @@ def header_(self, request: FixtureRequest): class Describe_BaseHeaderFooter: """Unit-test suite for `docx.section._BaseHeaderFooter`.""" - @pytest.mark.parametrize( - ("has_definition", "expected_value"), [(False, True), (True, False)] - ) + @pytest.mark.parametrize(("has_definition", "expected_value"), [(False, True), (True, False)]) def it_knows_when_its_linked_to_the_previous_header_or_footer( - self, has_definition: bool, expected_value: bool, _has_definition_prop_: Mock + self, + has_definition: bool, + expected_value: bool, + header: _BaseHeaderFooter, + _has_definition_prop_: Mock, ): _has_definition_prop_.return_value = has_definition - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) - - is_linked = header.is_linked_to_previous - - assert is_linked is expected_value + assert header.is_linked_to_previous is expected_value @pytest.mark.parametrize( ("has_definition", "value", "drop_calls", "add_calls"), @@ -592,14 +572,12 @@ def it_can_change_whether_it_is_linked_to_previous_header_or_footer( value: bool, drop_calls: int, add_calls: int, + header: _BaseHeaderFooter, _has_definition_prop_: Mock, _drop_definition_: Mock, _add_definition_: Mock, ): _has_definition_prop_.return_value = has_definition - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) header.is_linked_to_previous = value @@ -607,13 +585,10 @@ def it_can_change_whether_it_is_linked_to_previous_header_or_footer( assert _add_definition_.call_args_list == [call(header)] * add_calls def it_provides_access_to_the_header_or_footer_part_for_BlockItemContainer( - self, _get_or_add_definition_: Mock, header_part_: Mock + self, header: _BaseHeaderFooter, _get_or_add_definition_: Mock, header_part_: Mock ): # ---this override fulfills part of the BlockItemContainer subclass interface--- _get_or_add_definition_.return_value = header_part_ - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) header_part = header.part @@ -621,14 +596,11 @@ def it_provides_access_to_the_header_or_footer_part_for_BlockItemContainer( assert header_part is header_part_ def it_provides_access_to_the_hdr_or_ftr_element_to_help( - self, _get_or_add_definition_: Mock, header_part_: Mock + self, header: _BaseHeaderFooter, _get_or_add_definition_: Mock, header_part_: Mock ): hdr = element("w:hdr") _get_or_add_definition_.return_value = header_part_ header_part_.element = hdr - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) hdr_elm = header._element @@ -636,13 +608,14 @@ def it_provides_access_to_the_hdr_or_ftr_element_to_help( assert hdr_elm is hdr def it_gets_the_definition_when_it_has_one( - self, _has_definition_prop_: Mock, _definition_prop_: Mock, header_part_: Mock + self, + header: _BaseHeaderFooter, + _has_definition_prop_: Mock, + _definition_prop_: Mock, + header_part_: Mock, ): _has_definition_prop_.return_value = True _definition_prop_.return_value = header_part_ - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) header_part = header._get_or_add_definition() @@ -650,6 +623,7 @@ def it_gets_the_definition_when_it_has_one( def but_it_gets_the_prior_definition_when_it_is_linked( self, + header: _BaseHeaderFooter, _has_definition_prop_: Mock, _prior_headerfooter_prop_: Mock, prior_headerfooter_: Mock, @@ -658,9 +632,6 @@ def but_it_gets_the_prior_definition_when_it_is_linked( _has_definition_prop_.return_value = False _prior_headerfooter_prop_.return_value = prior_headerfooter_ prior_headerfooter_._get_or_add_definition.return_value = header_part_ - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) header_part = header._get_or_add_definition() @@ -669,6 +640,7 @@ def but_it_gets_the_prior_definition_when_it_is_linked( def and_it_adds_a_definition_when_it_is_linked_and_the_first_section( self, + header: _BaseHeaderFooter, _has_definition_prop_: Mock, _prior_headerfooter_prop_: Mock, _add_definition_: Mock, @@ -677,9 +649,6 @@ def and_it_adds_a_definition_when_it_is_linked_and_the_first_section( _has_definition_prop_.return_value = False _prior_headerfooter_prop_.return_value = None _add_definition_.return_value = header_part_ - header = _BaseHeaderFooter( - None, None, None # pyright: ignore[reportGeneralTypeIssues] - ) header_part = header._get_or_add_definition() @@ -696,6 +665,10 @@ def _add_definition_(self, request: FixtureRequest): def _definition_prop_(self, request: FixtureRequest): return property_mock(request, _BaseHeaderFooter, "_definition") + @pytest.fixture + def document_part_(self, request: FixtureRequest): + return instance_mock(request, DocumentPart) + @pytest.fixture def _drop_definition_(self, request: FixtureRequest): return method_mock(request, _BaseHeaderFooter, "_drop_definition") @@ -708,6 +681,11 @@ def _get_or_add_definition_(self, request: FixtureRequest): def _has_definition_prop_(self, request: FixtureRequest): return property_mock(request, _BaseHeaderFooter, "_has_definition") + @pytest.fixture + def header(self, document_part_: Mock) -> _BaseHeaderFooter: + sectPr = cast(CT_SectPr, element("w:sectPr")) + return _BaseHeaderFooter(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) + @pytest.fixture def header_part_(self, request: FixtureRequest): return instance_mock(request, HeaderPart) @@ -724,25 +702,21 @@ def _prior_headerfooter_prop_(self, request: FixtureRequest): class Describe_Footer: """Unit-test suite for `docx.section._Footer`.""" - def it_can_add_a_footer_part_to_help( - self, document_part_: Mock, footer_part_: Mock - ): - sectPr = element("w:sectPr{r:a=b}") + def it_can_add_a_footer_part_to_help(self, document_part_: Mock, footer_part_: Mock): + sectPr = cast(CT_SectPr, element("w:sectPr{r:a=b}")) document_part_.add_footer_part.return_value = footer_part_, "rId3" footer = _Footer(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) footer_part = footer._add_definition() document_part_.add_footer_part.assert_called_once_with() - assert sectPr.xml == xml( - "w:sectPr{r:a=b}/w:footerReference{w:type=default,r:id=rId3}" - ) + assert sectPr.xml == xml("w:sectPr{r:a=b}/w:footerReference{w:type=default,r:id=rId3}") assert footer_part is footer_part_ def it_provides_access_to_its_footer_part_to_help( self, document_part_: Mock, footer_part_: Mock ): - sectPr = element("w:sectPr/w:footerReference{w:type=even,r:id=rId3}") + sectPr = cast(CT_SectPr, element("w:sectPr/w:footerReference{w:type=even,r:id=rId3}")) document_part_.footer_part.return_value = footer_part_ footer = _Footer(sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) @@ -752,7 +726,9 @@ def it_provides_access_to_its_footer_part_to_help( assert footer_part is footer_part_ def it_can_drop_the_related_footer_part_to_help(self, document_part_: Mock): - sectPr = element("w:sectPr{r:a=b}/w:footerReference{w:type=first,r:id=rId42}") + sectPr = cast( + CT_SectPr, element("w:sectPr{r:a=b}/w:footerReference{w:type=first,r:id=rId42}") + ) footer = _Footer(sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE) footer._drop_definition() @@ -778,28 +754,26 @@ def it_provides_access_to_the_prior_Footer_to_help( self, request: FixtureRequest, document_part_: Mock, footer_: Mock ): doc_elm = element("w:document/(w:sectPr,w:sectPr)") - prior_sectPr, sectPr = doc_elm[0], doc_elm[1] + prior_sectPr, sectPr = cast(CT_SectPr, doc_elm[0]), cast(CT_SectPr, doc_elm[1]) footer = _Footer(sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) # ---mock must occur after construction of "real" footer--- _Footer_ = class_mock(request, "docx.section._Footer", return_value=footer_) prior_footer = footer._prior_headerfooter - _Footer_.assert_called_once_with( - prior_sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE - ) + _Footer_.assert_called_once_with(prior_sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) assert prior_footer is footer_ - def but_it_returns_None_when_its_the_first_footer(self): + def but_it_returns_None_when_its_the_first_footer(self, document_part_: Mock): doc_elm = cast(CT_Document, element("w:document/w:sectPr")) - sectPr = doc_elm[0] - footer = _Footer(sectPr, None, None) + sectPr = cast(CT_SectPr, doc_elm[0]) + footer = _Footer(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) prior_footer = footer._prior_headerfooter assert prior_footer is None - # -- fixtures ---------------------------------------------------- + # -- fixtures--------------------------------------------------------------------------------- @pytest.fixture def document_part_(self, request: FixtureRequest): @@ -815,25 +789,23 @@ def footer_part_(self, request: FixtureRequest): class Describe_Header: - def it_can_add_a_header_part_to_help( - self, document_part_: Mock, header_part_: Mock - ): - sectPr = element("w:sectPr{r:a=b}") + """Unit-test suite for `docx.section._Header`.""" + + def it_can_add_a_header_part_to_help(self, document_part_: Mock, header_part_: Mock): + sectPr = cast(CT_SectPr, element("w:sectPr{r:a=b}")) document_part_.add_header_part.return_value = header_part_, "rId3" header = _Header(sectPr, document_part_, WD_HEADER_FOOTER.FIRST_PAGE) header_part = header._add_definition() document_part_.add_header_part.assert_called_once_with() - assert sectPr.xml == xml( - "w:sectPr{r:a=b}/w:headerReference{w:type=first,r:id=rId3}" - ) + assert sectPr.xml == xml("w:sectPr{r:a=b}/w:headerReference{w:type=first,r:id=rId3}") assert header_part is header_part_ def it_provides_access_to_its_header_part_to_help( self, document_part_: Mock, header_part_: Mock ): - sectPr = element("w:sectPr/w:headerReference{w:type=default,r:id=rId8}") + sectPr = cast(CT_SectPr, element("w:sectPr/w:headerReference{w:type=default,r:id=rId8}")) document_part_.header_part.return_value = header_part_ header = _Header(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) @@ -843,7 +815,9 @@ def it_provides_access_to_its_header_part_to_help( assert header_part is header_part_ def it_can_drop_the_related_header_part_to_help(self, document_part_: Mock): - sectPr = element("w:sectPr{r:a=b}/w:headerReference{w:type=even,r:id=rId42}") + sectPr = cast( + CT_SectPr, element("w:sectPr{r:a=b}/w:headerReference{w:type=even,r:id=rId42}") + ) header = _Header(sectPr, document_part_, WD_HEADER_FOOTER.EVEN_PAGE) header._drop_definition() @@ -866,31 +840,29 @@ def it_knows_when_it_has_a_header_part_to_help( assert has_definition is expected_value def it_provides_access_to_the_prior_Header_to_help( - self, request, document_part_: Mock, header_: Mock + self, request: FixtureRequest, document_part_: Mock, header_: Mock ): doc_elm = element("w:document/(w:sectPr,w:sectPr)") - prior_sectPr, sectPr = doc_elm[0], doc_elm[1] + prior_sectPr, sectPr = cast(CT_SectPr, doc_elm[0]), cast(CT_SectPr, doc_elm[1]) header = _Header(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) # ---mock must occur after construction of "real" header--- _Header_ = class_mock(request, "docx.section._Header", return_value=header_) prior_header = header._prior_headerfooter - _Header_.assert_called_once_with( - prior_sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY - ) + _Header_.assert_called_once_with(prior_sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) assert prior_header is header_ - def but_it_returns_None_when_its_the_first_header(self): + def but_it_returns_None_when_its_the_first_header(self, document_part_: Mock): doc_elm = element("w:document/w:sectPr") - sectPr = doc_elm[0] - header = _Header(sectPr, None, None) + sectPr = cast(CT_SectPr, doc_elm[0]) + header = _Header(sectPr, document_part_, WD_HEADER_FOOTER.PRIMARY) prior_header = header._prior_headerfooter assert prior_header is None - # -- fixtures----------------------------------------------------- + # -- fixtures--------------------------------------------------------------------------------- @pytest.fixture def document_part_(self, request: FixtureRequest): diff --git a/tests/test_settings.py b/tests/test_settings.py index 9f430822d..ff07eda26 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,5 +1,9 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for the docx.settings module.""" +from __future__ import annotations + import pytest from docx.settings import Settings @@ -8,56 +12,37 @@ class DescribeSettings: - def it_knows_when_the_document_has_distinct_odd_and_even_headers( - self, odd_and_even_get_fixture - ): - settings_elm, expected_value = odd_and_even_get_fixture - settings = Settings(settings_elm) - - odd_and_even_pages_header_footer = settings.odd_and_even_pages_header_footer - - assert odd_and_even_pages_header_footer is expected_value - - def it_can_change_whether_the_document_has_distinct_odd_and_even_headers( - self, odd_and_even_set_fixture - ): - settings_elm, value, expected_xml = odd_and_even_set_fixture - settings = Settings(settings_elm) + """Unit-test suite for the `docx.settings.Settings` objects.""" - settings.odd_and_even_pages_header_footer = value - - assert settings_elm.xml == expected_xml - - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ ("w:settings", False), ("w:settings/w:evenAndOddHeaders", True), ("w:settings/w:evenAndOddHeaders{w:val=0}", False), ("w:settings/w:evenAndOddHeaders{w:val=1}", True), ("w:settings/w:evenAndOddHeaders{w:val=true}", True), - ] + ], ) - def odd_and_even_get_fixture(self, request): - settings_cxml, expected_value = request.param - settings_elm = element(settings_cxml) - return settings_elm, expected_value + def it_knows_when_the_document_has_distinct_odd_and_even_headers( + self, cxml: str, expected_value: bool + ): + assert Settings(element(cxml)).odd_and_even_pages_header_footer is expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("cxml", "new_value", "expected_cxml"), + [ ("w:settings", True, "w:settings/w:evenAndOddHeaders"), ("w:settings/w:evenAndOddHeaders", False, "w:settings"), - ( - "w:settings/w:evenAndOddHeaders{w:val=1}", - True, - "w:settings/w:evenAndOddHeaders", - ), + ("w:settings/w:evenAndOddHeaders{w:val=1}", True, "w:settings/w:evenAndOddHeaders"), ("w:settings/w:evenAndOddHeaders{w:val=off}", False, "w:settings"), - ] + ], ) - def odd_and_even_set_fixture(self, request): - settings_cxml, value, expected_cxml = request.param - settings_elm = element(settings_cxml) - expected_xml = xml(expected_cxml) - return settings_elm, value, expected_xml + def it_can_change_whether_the_document_has_distinct_odd_and_even_headers( + self, cxml: str, new_value: bool, expected_cxml: str + ): + settings = Settings(element(cxml)) + + settings.odd_and_even_pages_header_footer = new_value + + assert settings._settings.xml == xml(expected_cxml) diff --git a/tests/test_shape.py b/tests/test_shape.py index da307e48f..68998b90e 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -1,194 +1,129 @@ +# pyright: reportPrivateUsage=false + """Test suite for the docx.shape module.""" +from __future__ import annotations + +from typing import cast + import pytest +from docx.document import Document from docx.enum.shape import WD_INLINE_SHAPE +from docx.oxml.document import CT_Body from docx.oxml.ns import nsmap +from docx.oxml.shape import CT_Inline from docx.shape import InlineShape, InlineShapes -from docx.shared import Length - -from .oxml.unitdata.dml import ( - a_blip, - a_blipFill, - a_graphic, - a_graphicData, - a_pic, - an_inline, -) +from docx.shared import Emu, Length + from .unitutil.cxml import element, xml -from .unitutil.mock import loose_mock +from .unitutil.mock import FixtureRequest, Mock, instance_mock class DescribeInlineShapes: - def it_knows_how_many_inline_shapes_it_contains(self, inline_shapes_fixture): - inline_shapes, expected_count = inline_shapes_fixture - assert len(inline_shapes) == expected_count - - def it_can_iterate_over_its_InlineShape_instances(self, inline_shapes_fixture): - inline_shapes, inline_shape_count = inline_shapes_fixture - actual_count = 0 - for inline_shape in inline_shapes: - assert isinstance(inline_shape, InlineShape) - actual_count += 1 - assert actual_count == inline_shape_count - - def it_provides_indexed_access_to_inline_shapes(self, inline_shapes_fixture): - inline_shapes, inline_shape_count = inline_shapes_fixture - for idx in range(-inline_shape_count, inline_shape_count): - inline_shape = inline_shapes[idx] - assert isinstance(inline_shape, InlineShape) - - def it_raises_on_indexed_access_out_of_range(self, inline_shapes_fixture): - inline_shapes, inline_shape_count = inline_shapes_fixture - too_low = -1 - inline_shape_count - with pytest.raises(IndexError, match=r"inline shape index \[-3\] out of rang"): - inline_shapes[too_low] - too_high = inline_shape_count + """Unit-test suite for `docx.shape.InlineShapes` objects.""" + + def it_knows_how_many_inline_shapes_it_contains(self, body: CT_Body, document_: Mock): + inline_shapes = InlineShapes(body, document_) + assert len(inline_shapes) == 2 + + def it_can_iterate_over_its_InlineShape_instances(self, body: CT_Body, document_: Mock): + inline_shapes = InlineShapes(body, document_) + assert all(isinstance(s, InlineShape) for s in inline_shapes) + assert len(list(inline_shapes)) == 2 + + def it_provides_indexed_access_to_inline_shapes(self, body: CT_Body, document_: Mock): + inline_shapes = InlineShapes(body, document_) + for idx in range(-2, 2): + assert isinstance(inline_shapes[idx], InlineShape) + + def it_raises_on_indexed_access_out_of_range(self, body: CT_Body, document_: Mock): + inline_shapes = InlineShapes(body, document_) + + with pytest.raises(IndexError, match=r"inline shape index \[-3\] out of range"): + inline_shapes[-3] with pytest.raises(IndexError, match=r"inline shape index \[2\] out of range"): - inline_shapes[too_high] + inline_shapes[2] - def it_knows_the_part_it_belongs_to(self, inline_shapes_with_parent_): - inline_shapes, parent_ = inline_shapes_with_parent_ - part = inline_shapes.part - assert part is parent_.part + def it_knows_the_part_it_belongs_to(self, body: CT_Body, document_: Mock): + inline_shapes = InlineShapes(body, document_) + assert inline_shapes.part is document_.part - # fixtures ------------------------------------------------------- + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def inline_shapes_fixture(self): - body = element("w:body/w:p/(w:r/w:drawing/wp:inline, w:r/w:drawing/wp:inline)") - inline_shapes = InlineShapes(body, None) - expected_count = 2 - return inline_shapes, expected_count - - # fixture components --------------------------------------------- + def body(self) -> CT_Body: + return cast( + CT_Body, element("w:body/w:p/(w:r/w:drawing/wp:inline, w:r/w:drawing/wp:inline)") + ) @pytest.fixture - def inline_shapes_with_parent_(self, request): - parent_ = loose_mock(request, name="parent_") - inline_shapes = InlineShapes(None, parent_) - return inline_shapes, parent_ + def document_(self, request: FixtureRequest): + return instance_mock(request, Document) class DescribeInlineShape: - def it_knows_what_type_of_shape_it_is(self, shape_type_fixture): - inline_shape, inline_shape_type = shape_type_fixture - assert inline_shape.type == inline_shape_type - - def it_knows_its_display_dimensions(self, dimensions_get_fixture): - inline_shape, cx, cy = dimensions_get_fixture - width = inline_shape.width - height = inline_shape.height - assert isinstance(width, Length) - assert width == cx - assert isinstance(height, Length) - assert height == cy + """Unit-test suite for `docx.shape.InlineShape` objects.""" + + @pytest.mark.parametrize( + ("uri", "content_cxml", "expected_value"), + [ + # -- embedded picture -- + (nsmap["pic"], "/pic:pic/pic:blipFill/a:blip{r:embed=rId1}", WD_INLINE_SHAPE.PICTURE), + # -- linked picture -- + ( + nsmap["pic"], + "/pic:pic/pic:blipFill/a:blip{r:link=rId2}", + WD_INLINE_SHAPE.LINKED_PICTURE, + ), + # -- linked and embedded picture (not expected) -- + ( + nsmap["pic"], + "/pic:pic/pic:blipFill/a:blip{r:embed=rId1,r:link=rId2}", + WD_INLINE_SHAPE.LINKED_PICTURE, + ), + # -- chart -- + (nsmap["c"], "", WD_INLINE_SHAPE.CHART), + # -- SmartArt -- + (nsmap["dgm"], "", WD_INLINE_SHAPE.SMART_ART), + # -- something else we don't know about -- + ("foobar", "", WD_INLINE_SHAPE.NOT_IMPLEMENTED), + ], + ) + def it_knows_what_type_of_shape_it_is( + self, uri: str, content_cxml: str, expected_value: WD_INLINE_SHAPE + ): + cxml = "wp:inline/a:graphic/a:graphicData{uri=%s}%s" % (uri, content_cxml) + inline = cast(CT_Inline, element(cxml)) + inline_shape = InlineShape(inline) + assert inline_shape.type == expected_value - def it_can_change_its_display_dimensions(self, dimensions_set_fixture): - inline_shape, cx, cy, expected_xml = dimensions_set_fixture - inline_shape.width = cx - inline_shape.height = cy - assert inline_shape._inline.xml == expected_xml + def it_knows_its_display_dimensions(self): + inline = cast(CT_Inline, element("wp:inline/wp:extent{cx=333, cy=666}")) + inline_shape = InlineShape(inline) - # fixtures ------------------------------------------------------- + width, height = inline_shape.width, inline_shape.height - @pytest.fixture - def dimensions_get_fixture(self): - inline_cxml, expected_cx, expected_cy = ( - "wp:inline/wp:extent{cx=333, cy=666}", - 333, - 666, + assert isinstance(width, Length) + assert width == 333 + assert isinstance(height, Length) + assert height == 666 + + def it_can_change_its_display_dimensions(self): + inline_shape = InlineShape( + cast( + CT_Inline, + element( + "wp:inline/(wp:extent{cx=333,cy=666},a:graphic/a:graphicData/pic:pic/" + "pic:spPr/a:xfrm/a:ext{cx=333,cy=666})" + ), + ) ) - inline_shape = InlineShape(element(inline_cxml)) - return inline_shape, expected_cx, expected_cy - @pytest.fixture - def dimensions_set_fixture(self): - inline_cxml, new_cx, new_cy, expected_cxml = ( - "wp:inline/(wp:extent{cx=333,cy=666},a:graphic/a:graphicData/" - "pic:pic/pic:spPr/a:xfrm/a:ext{cx=333,cy=666})", - 444, - 888, - "wp:inline/(wp:extent{cx=444,cy=888},a:graphic/a:graphicData/" - "pic:pic/pic:spPr/a:xfrm/a:ext{cx=444,cy=888})", + inline_shape.width = Emu(444) + inline_shape.height = Emu(888) + + assert inline_shape._inline.xml == xml( + "wp:inline/(wp:extent{cx=444,cy=888},a:graphic/a:graphicData/pic:pic/pic:spPr/" + "a:xfrm/a:ext{cx=444,cy=888})" ) - inline_shape = InlineShape(element(inline_cxml)) - expected_xml = xml(expected_cxml) - return inline_shape, new_cx, new_cy, expected_xml - - @pytest.fixture( - params=[ - "embed pic", - "link pic", - "link+embed pic", - "chart", - "smart art", - "not implemented", - ] - ) - def shape_type_fixture(self, request): - if request.param == "embed pic": - inline = self._inline_with_picture(embed=True) - shape_type = WD_INLINE_SHAPE.PICTURE - - elif request.param == "link pic": - inline = self._inline_with_picture(link=True) - shape_type = WD_INLINE_SHAPE.LINKED_PICTURE - - elif request.param == "link+embed pic": - inline = self._inline_with_picture(embed=True, link=True) - shape_type = WD_INLINE_SHAPE.LINKED_PICTURE - - elif request.param == "chart": - inline = self._inline_with_uri(nsmap["c"]) - shape_type = WD_INLINE_SHAPE.CHART - - elif request.param == "smart art": - inline = self._inline_with_uri(nsmap["dgm"]) - shape_type = WD_INLINE_SHAPE.SMART_ART - - elif request.param == "not implemented": - inline = self._inline_with_uri("foobar") - shape_type = WD_INLINE_SHAPE.NOT_IMPLEMENTED - - return InlineShape(inline), shape_type - - # fixture components --------------------------------------------- - - def _inline_with_picture(self, embed=False, link=False): - picture_ns = nsmap["pic"] - - blip_bldr = a_blip() - if embed: - blip_bldr.with_embed("rId1") - if link: - blip_bldr.with_link("rId2") - - inline = ( - an_inline() - .with_nsdecls("wp", "r") - .with_child( - a_graphic() - .with_nsdecls() - .with_child( - a_graphicData() - .with_uri(picture_ns) - .with_child( - a_pic() - .with_nsdecls() - .with_child(a_blipFill().with_child(blip_bldr)) - ) - ) - ) - ).element - return inline - - def _inline_with_uri(self, uri): - inline = ( - an_inline() - .with_nsdecls("wp") - .with_child( - a_graphic().with_nsdecls().with_child(a_graphicData().with_uri(uri)) - ) - ).element - return inline diff --git a/tests/test_shared.py b/tests/test_shared.py index 3fbe54b07..fb6c273cb 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -1,17 +1,25 @@ """Test suite for the docx.shared module.""" +from __future__ import annotations + import pytest from docx.opc.part import XmlPart from docx.shared import Cm, ElementProxy, Emu, Inches, Length, Mm, Pt, RGBColor, Twips from .unitutil.cxml import element -from .unitutil.mock import instance_mock +from .unitutil.mock import FixtureRequest, Mock, instance_mock class DescribeElementProxy: - def it_knows_when_its_equal_to_another_proxy_object(self, eq_fixture): - proxy, proxy_2, proxy_3, not_a_proxy = eq_fixture + """Unit-test suite for `docx.shared.ElementProxy` objects.""" + + def it_knows_when_its_equal_to_another_proxy_object(self): + p, q = element("w:p"), element("w:p") + proxy = ElementProxy(p) + proxy_2 = ElementProxy(p) + proxy_3 = ElementProxy(q) + not_a_proxy = "Foobar" assert (proxy == proxy_2) is True assert (proxy == proxy_3) is False @@ -21,66 +29,33 @@ def it_knows_when_its_equal_to_another_proxy_object(self, eq_fixture): assert (proxy != proxy_3) is True assert (proxy != not_a_proxy) is True - def it_knows_its_element(self, element_fixture): - proxy, element = element_fixture - assert proxy.element is element - - def it_knows_its_part(self, part_fixture): - proxy, part_ = part_fixture - assert proxy.part is part_ - - # fixture -------------------------------------------------------- - - @pytest.fixture - def element_fixture(self): + def it_knows_its_element(self): p = element("w:p") proxy = ElementProxy(p) - return proxy, p - - @pytest.fixture - def eq_fixture(self): - p, q = element("w:p"), element("w:p") - proxy = ElementProxy(p) - proxy_2 = ElementProxy(p) - proxy_3 = ElementProxy(q) - not_a_proxy = "Foobar" - return proxy, proxy_2, proxy_3, not_a_proxy + assert proxy.element is p - @pytest.fixture - def part_fixture(self, other_proxy_, part_): + def it_knows_its_part(self, other_proxy_: Mock, part_: Mock): other_proxy_.part = part_ - proxy = ElementProxy(None, other_proxy_) - return proxy, part_ + proxy = ElementProxy(element("w:p"), other_proxy_) + assert proxy.part is part_ - # fixture components --------------------------------------------- + # -- fixture --------------------------------------------------------------------------------- @pytest.fixture - def other_proxy_(self, request): + def other_proxy_(self, request: FixtureRequest): return instance_mock(request, ElementProxy) @pytest.fixture - def part_(self, request): + def part_(self, request: FixtureRequest): return instance_mock(request, XmlPart) class DescribeLength: - def it_can_construct_from_convenient_units(self, construct_fixture): - UnitCls, units_val, emu = construct_fixture - length = UnitCls(units_val) - assert isinstance(length, Length) - assert length == emu - - def it_can_self_convert_to_convenient_units(self, units_fixture): - emu, units_prop_name, expected_length_in_units, type_ = units_fixture - length = Length(emu) - length_in_units = getattr(length, units_prop_name) - assert length_in_units == expected_length_in_units - assert isinstance(length_in_units, type_) - - # fixtures ------------------------------------------------------- + """Unit-test suite for `docx.shared.Length` objects.""" - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("UnitCls", "units_val", "emu"), + [ (Length, 914400, 914400), (Inches, 1.1, 1005840), (Cm, 2.53, 910799), @@ -88,32 +63,47 @@ def it_can_self_convert_to_convenient_units(self, units_fixture): (Mm, 13.8, 496800), (Pt, 24.5, 311150), (Twips, 360, 228600), - ] + ], ) - def construct_fixture(self, request): - UnitCls, units_val, emu = request.param - return UnitCls, units_val, emu - - @pytest.fixture( - params=[ - (914400, "inches", 1.0, float), - (914400, "cm", 2.54, float), - (914400, "emu", 914400, int), - (914400, "mm", 25.4, float), - (914400, "pt", 72.0, float), - (914400, "twips", 1440, int), - ] + def it_can_construct_from_convenient_units(self, UnitCls: type, units_val: float, emu: int): + length = UnitCls(units_val) + assert isinstance(length, Length) + assert length == emu + + @pytest.mark.parametrize( + ("prop_name", "expected_value", "expected_type"), + [ + ("inches", 1.0, float), + ("cm", 2.54, float), + ("emu", 914400, int), + ("mm", 25.4, float), + ("pt", 72.0, float), + ("twips", 1440, int), + ], ) - def units_fixture(self, request): - emu, units_prop_name, expected_length_in_units, type_ = request.param - return emu, units_prop_name, expected_length_in_units, type_ + def it_can_self_convert_to_convenient_units( + self, prop_name: str, expected_value: float, expected_type: type + ): + # -- use an inch for the initial value -- + length = Length(914400) + length_in_units = getattr(length, prop_name) + assert length_in_units == expected_value + assert isinstance(length_in_units, expected_type) class DescribeRGBColor: + """Unit-test suite for `docx.shared.RGBColor` objects.""" + def it_is_natively_constructed_using_three_ints_0_to_255(self): - RGBColor(0x12, 0x34, 0x56) - with pytest.raises(ValueError, match=r"RGBColor\(\) takes three integer valu"): - RGBColor("12", "34", "56") + rgb_color = RGBColor(0x12, 0x34, 0x56) + + assert isinstance(rgb_color, RGBColor) + # -- it is comparable to a tuple[int, int, int] -- + assert rgb_color == (18, 52, 86) + + def it_raises_with_helpful_error_message_on_wrong_types(self): + with pytest.raises(TypeError, match=r"RGBColor\(\) takes three integer valu"): + RGBColor("12", "34", "56") # pyright: ignore with pytest.raises(ValueError, match=r"\(\) takes three integer values 0-255"): RGBColor(-1, 34, 56) with pytest.raises(ValueError, match=r"RGBColor\(\) takes three integer valu"): @@ -124,7 +114,7 @@ def it_can_construct_from_a_hex_string_rgb_value(self): assert rgb == RGBColor(0x12, 0x34, 0x56) def it_can_provide_a_hex_string_rgb_value(self): - assert str(RGBColor(0x12, 0x34, 0x56)) == "123456" + assert str(RGBColor(0xF3, 0x8A, 0x56)) == "F38A56" def it_has_a_custom_repr(self): rgb_color = RGBColor(0x42, 0xF0, 0xBA) diff --git a/tests/text/test_font.py b/tests/text/test_font.py index 6a9da0223..471c5451b 100644 --- a/tests/text/test_font.py +++ b/tests/text/test_font.py @@ -62,9 +62,7 @@ def it_knows_its_typeface_name(self, r_cxml: str, expected_value: str | None): ), ], ) - def it_can_change_its_typeface_name( - self, r_cxml: str, value: str, expected_r_cxml: str - ): + def it_can_change_its_typeface_name(self, r_cxml: str, value: str, expected_r_cxml: str): r = cast(CT_R, element(r_cxml)) font = Font(r) expected_xml = xml(expected_r_cxml) @@ -95,9 +93,7 @@ def it_knows_its_size(self, r_cxml: str, expected_value: Length | None): ("w:r/w:rPr/w:sz{w:val=36}", None, "w:r/w:rPr"), ], ) - def it_can_change_its_size( - self, r_cxml: str, value: Length | None, expected_r_cxml: str - ): + def it_can_change_its_size(self, r_cxml: str, value: Length | None, expected_r_cxml: str): r = cast(CT_R, element(r_cxml)) font = Font(r) expected_xml = xml(expected_r_cxml) @@ -224,9 +220,7 @@ def it_can_change_its_bool_prop_settings( ("w:r/w:rPr/w:vertAlign{w:val=superscript}", False), ], ) - def it_knows_whether_it_is_subscript( - self, r_cxml: str, expected_value: bool | None - ): + def it_knows_whether_it_is_subscript(self, r_cxml: str, expected_value: bool | None): r = cast(CT_R, element(r_cxml)) font = Font(r) assert font.subscript == expected_value @@ -283,9 +277,7 @@ def it_can_change_whether_it_is_subscript( ("w:r/w:rPr/w:vertAlign{w:val=superscript}", True), ], ) - def it_knows_whether_it_is_superscript( - self, r_cxml: str, expected_value: bool | None - ): + def it_knows_whether_it_is_superscript(self, r_cxml: str, expected_value: bool | None): r = cast(CT_R, element(r_cxml)) font = Font(r) assert font.superscript == expected_value @@ -343,9 +335,7 @@ def it_can_change_whether_it_is_superscript( ("w:r/w:rPr/w:u{w:val=wave}", WD_UNDERLINE.WAVY), ], ) - def it_knows_its_underline_type( - self, r_cxml: str, expected_value: WD_UNDERLINE | bool | None - ): + def it_knows_its_underline_type(self, r_cxml: str, expected_value: WD_UNDERLINE | bool | None): r = cast(CT_R, element(r_cxml)) font = Font(r) assert font.underline is expected_value @@ -393,9 +383,7 @@ def it_can_change_its_underline_type( ("w:r/w:rPr/w:highlight{w:val=blue}", WD_COLOR.BLUE), ], ) - def it_knows_its_highlight_color( - self, r_cxml: str, expected_value: WD_COLOR | None - ): + def it_knows_its_highlight_color(self, r_cxml: str, expected_value: WD_COLOR | None): r = cast(CT_R, element(r_cxml)) font = Font(r) assert font.highlight_color is expected_value diff --git a/tests/text/test_pagebreak.py b/tests/text/test_pagebreak.py index c7494dca2..bc7848797 100644 --- a/tests/text/test_pagebreak.py +++ b/tests/text/test_pagebreak.py @@ -107,13 +107,7 @@ def it_produces_None_for_following_fragment_when_page_break_is_trailing( def it_can_split_off_the_following_paragraph_content_when_in_a_run( self, fake_parent: t.ProvidesStoryPart ): - p_cxml = ( - "w:p/(" - " w:pPr/w:ind" - ' ,w:r/(w:t"foo",w:lastRenderedPageBreak,w:t"bar")' - ' ,w:r/w:t"foo"' - ")" - ) + p_cxml = 'w:p/(w:pPr/w:ind,w:r/(w:t"foo",w:lastRenderedPageBreak,w:t"bar"),w:r/w:t"foo")' p = cast(CT_P, element(p_cxml)) lrpb = p.lastRenderedPageBreaks[0] page_break = RenderedPageBreak(lrpb, fake_parent) diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index c1451c3c1..0329b1dd3 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -85,9 +85,7 @@ def it_can_iterate_its_inner_content_items( def it_knows_its_paragraph_style(self, style_get_fixture): paragraph, style_id_, style_ = style_get_fixture style = paragraph.style - paragraph.part.get_style.assert_called_once_with( - style_id_, WD_STYLE_TYPE.PARAGRAPH - ) + paragraph.part.get_style.assert_called_once_with(style_id_, WD_STYLE_TYPE.PARAGRAPH) assert style is style_ def it_can_change_its_paragraph_style(self, style_set_fixture): @@ -95,9 +93,7 @@ def it_can_change_its_paragraph_style(self, style_set_fixture): paragraph.style = value - paragraph.part.get_style_id.assert_called_once_with( - value, WD_STYLE_TYPE.PARAGRAPH - ) + paragraph.part.get_style_id.assert_called_once_with(value, WD_STYLE_TYPE.PARAGRAPH) assert paragraph._p.xml == expected_xml @pytest.mark.parametrize( @@ -108,8 +104,7 @@ def it_can_change_its_paragraph_style(self, style_set_fixture): ("w:p/w:r/w:lastRenderedPageBreak", 1), ("w:p/w:hyperlink/w:r/w:lastRenderedPageBreak", 1), ( - "w:p/(w:r/w:lastRenderedPageBreak," - "w:hyperlink/w:r/w:lastRenderedPageBreak)", + "w:p/(w:r/w:lastRenderedPageBreak,w:hyperlink/w:r/w:lastRenderedPageBreak)", 2, ), ( @@ -144,8 +139,7 @@ def it_provides_access_to_the_rendered_page_breaks_it_contains( ('w:p/w:r/(w:t"foo", w:br, w:t"bar")', "foo\nbar"), ('w:p/w:r/(w:t"foo", w:cr, w:t"bar")', "foo\nbar"), ( - 'w:p/(w:r/w:t"click ",w:hyperlink{r:id=rId6}/w:r/w:t"here",' - 'w:r/w:t" for more")', + 'w:p/(w:r/w:t"click ",w:hyperlink{r:id=rId6}/w:r/w:t"here",w:r/w:t" for more")', "click here for more", ), ], @@ -385,9 +379,7 @@ def part_prop_(self, request, document_part_): @pytest.fixture def Run_(self, request, runs_): run_, run_2_ = runs_ - return class_mock( - request, "docx.text.paragraph.Run", side_effect=[run_, run_2_] - ) + return class_mock(request, "docx.text.paragraph.Run", side_effect=[run_, run_2_]) @pytest.fixture def r_(self, request): diff --git a/tests/text/test_run.py b/tests/text/test_run.py index 772c5ad82..910f445d1 100644 --- a/tests/text/test_run.py +++ b/tests/text/test_run.py @@ -11,27 +11,72 @@ 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 from docx.text.font import Font +from docx.text.paragraph import Paragraph from docx.text.run import Run from ..unitutil.cxml import element, xml -from ..unitutil.mock import class_mock, instance_mock, property_mock +from ..unitutil.mock import FixtureRequest, Mock, class_mock, instance_mock, property_mock class DescribeRun: """Unit-test suite for `docx.text.run.Run`.""" - def it_knows_its_bool_prop_states(self, bool_prop_get_fixture): - run, prop_name, expected_state = bool_prop_get_fixture - assert getattr(run, prop_name) == expected_state + @pytest.mark.parametrize( + ("r_cxml", "bool_prop_name", "expected_value"), + [ + ("w:r/w:rPr", "bold", None), + ("w:r/w:rPr/w:b", "bold", True), + ("w:r/w:rPr/w:b{w:val=on}", "bold", True), + ("w:r/w:rPr/w:b{w:val=off}", "bold", False), + ("w:r/w:rPr/w:b{w:val=1}", "bold", True), + ("w:r/w:rPr/w:i{w:val=0}", "italic", False), + ], + ) + def it_knows_its_bool_prop_states( + self, r_cxml: str, bool_prop_name: str, expected_value: bool | None, paragraph_: Mock + ): + run = Run(cast(CT_R, element(r_cxml)), paragraph_) + assert getattr(run, bool_prop_name) == expected_value + + @pytest.mark.parametrize( + ("initial_r_cxml", "bool_prop_name", "value", "expected_cxml"), + [ + # -- nothing to True, False, and None --------------------------- + ("w:r", "bold", True, "w:r/w:rPr/w:b"), + ("w:r", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), + ("w:r", "italic", None, "w:r/w:rPr"), + # -- default to True, False, and None --------------------------- + ("w:r/w:rPr/w:b", "bold", True, "w:r/w:rPr/w:b"), + ("w:r/w:rPr/w:b", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), + ("w:r/w:rPr/w:i", "italic", None, "w:r/w:rPr"), + # -- True to True, False, and None ------------------------------ + ("w:r/w:rPr/w:b{w:val=on}", "bold", True, "w:r/w:rPr/w:b"), + ("w:r/w:rPr/w:b{w:val=1}", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), + ("w:r/w:rPr/w:b{w:val=1}", "bold", None, "w:r/w:rPr"), + # -- False to True, False, and None ----------------------------- + ("w:r/w:rPr/w:i{w:val=false}", "italic", True, "w:r/w:rPr/w:i"), + ("w:r/w:rPr/w:i{w:val=0}", "italic", False, "w:r/w:rPr/w:i{w:val=0}"), + ("w:r/w:rPr/w:i{w:val=off}", "italic", None, "w:r/w:rPr"), + ], + ) + def it_can_change_its_bool_prop_settings( + self, + initial_r_cxml: str, + bool_prop_name: str, + value: bool | None, + expected_cxml: str, + paragraph_: Mock, + ): + run = Run(cast(CT_R, element(initial_r_cxml)), paragraph_) - def it_can_change_its_bool_prop_settings(self, bool_prop_set_fixture): - run, prop_name, value, expected_xml = bool_prop_set_fixture - setattr(run, prop_name, value) - assert run._r.xml == expected_xml + setattr(run, bool_prop_name, value) + + assert run._r.xml == xml(expected_cxml) @pytest.mark.parametrize( ("r_cxml", "expected_value"), @@ -43,11 +88,9 @@ def it_can_change_its_bool_prop_settings(self, bool_prop_set_fixture): ], ) def it_knows_whether_it_contains_a_page_break( - self, r_cxml: str, expected_value: bool + self, r_cxml: str, expected_value: bool, paragraph_: Mock ): - r = cast(CT_R, element(r_cxml)) - run = Run(r, None) # pyright: ignore[reportGeneralTypeIssues] - + run = Run(cast(CT_R, element(r_cxml)), paragraph_) assert run.contains_page_break == expected_value @pytest.mark.parametrize( @@ -80,48 +123,150 @@ def it_can_iterate_its_inner_content_items( actual = [type(item).__name__ for item in inner_content] assert actual == expected, f"expected: {expected}, got: {actual}" - def it_knows_its_character_style(self, style_get_fixture): - run, style_id_, style_ = style_get_fixture + def it_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 + ): + style_ = document_part_.get_style.return_value + part_prop_.return_value = document_part_ + style_id = "Barfoo" + run = Run(cast(CT_R, element(f"w:r/w:rPr/w:rStyle{{w:val={style_id}}}")), paragraph_) + style = run.style - run.part.get_style.assert_called_once_with(style_id_, WD_STYLE_TYPE.CHARACTER) + + document_part_.get_style.assert_called_once_with(style_id, WD_STYLE_TYPE.CHARACTER) assert style is style_ - def it_can_change_its_character_style(self, style_set_fixture): - run, value, expected_xml = style_set_fixture + @pytest.mark.parametrize( + ("r_cxml", "value", "style_id", "expected_cxml"), + [ + ("w:r", "Foo Font", "FooFont", "w:r/w:rPr/w:rStyle{w:val=FooFont}"), + ("w:r/w:rPr", "Foo Font", "FooFont", "w:r/w:rPr/w:rStyle{w:val=FooFont}"), + ( + "w:r/w:rPr/w:rStyle{w:val=FooFont}", + "Bar Font", + "BarFont", + "w:r/w:rPr/w:rStyle{w:val=BarFont}", + ), + ("w:r/w:rPr/w:rStyle{w:val=FooFont}", None, None, "w:r/w:rPr"), + ("w:r", None, None, "w:r/w:rPr"), + ], + ) + def it_can_change_its_character_style( + self, + r_cxml: str, + value: str | None, + style_id: str | None, + expected_cxml: str, + part_prop_: Mock, + paragraph_: Mock, + ): + part_ = part_prop_.return_value + part_.get_style_id.return_value = style_id + run = Run(cast(CT_R, element(r_cxml)), paragraph_) + run.style = value - run.part.get_style_id.assert_called_once_with(value, WD_STYLE_TYPE.CHARACTER) - assert run._r.xml == expected_xml - def it_knows_its_underline_type(self, underline_get_fixture): - run, expected_value = underline_get_fixture + part_.get_style_id.assert_called_once_with(value, WD_STYLE_TYPE.CHARACTER) + assert run._r.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("r_cxml", "expected_value"), + [ + ("w:r", None), + ("w:r/w:rPr/w:u", None), + ("w:r/w:rPr/w:u{w:val=single}", True), + ("w:r/w:rPr/w:u{w:val=none}", False), + ("w:r/w:rPr/w:u{w:val=double}", WD_UNDERLINE.DOUBLE), + ("w:r/w:rPr/w:u{w:val=wave}", WD_UNDERLINE.WAVY), + ], + ) + def it_knows_its_underline_type( + self, r_cxml: str, expected_value: bool | WD_UNDERLINE | None, paragraph_: Mock + ): + run = Run(cast(CT_R, element(r_cxml)), paragraph_) assert run.underline is expected_value - def it_can_change_its_underline_type(self, underline_set_fixture): - run, underline, expected_xml = underline_set_fixture - run.underline = underline - assert run._r.xml == expected_xml + @pytest.mark.parametrize( + ("initial_r_cxml", "new_underline", "expected_cxml"), + [ + ("w:r", True, "w:r/w:rPr/w:u{w:val=single}"), + ("w:r", False, "w:r/w:rPr/w:u{w:val=none}"), + ("w:r", None, "w:r/w:rPr"), + ("w:r", WD_UNDERLINE.SINGLE, "w:r/w:rPr/w:u{w:val=single}"), + ("w:r", WD_UNDERLINE.THICK, "w:r/w:rPr/w:u{w:val=thick}"), + ("w:r/w:rPr/w:u{w:val=single}", True, "w:r/w:rPr/w:u{w:val=single}"), + ("w:r/w:rPr/w:u{w:val=single}", False, "w:r/w:rPr/w:u{w:val=none}"), + ("w:r/w:rPr/w:u{w:val=single}", None, "w:r/w:rPr"), + ( + "w:r/w:rPr/w:u{w:val=single}", + WD_UNDERLINE.SINGLE, + "w:r/w:rPr/w:u{w:val=single}", + ), + ( + "w:r/w:rPr/w:u{w:val=single}", + WD_UNDERLINE.DOTTED, + "w:r/w:rPr/w:u{w:val=dotted}", + ), + ], + ) + def it_can_change_its_underline_type( + self, + initial_r_cxml: str, + new_underline: bool | WD_UNDERLINE | None, + expected_cxml: str, + paragraph_: Mock, + ): + run = Run(cast(CT_R, element(initial_r_cxml)), paragraph_) + + run.underline = new_underline + + assert run._r.xml == xml(expected_cxml) @pytest.mark.parametrize("invalid_value", ["foobar", 42, "single"]) - def it_raises_on_assign_invalid_underline_value(self, invalid_value: Any): - r = cast(CT_R, element("w:r/w:rPr")) - run = Run(r, None) + def it_raises_on_assign_invalid_underline_value(self, invalid_value: Any, paragraph_: Mock): + run = Run(cast(CT_R, element("w:r/w:rPr")), paragraph_) with pytest.raises(ValueError, match=" is not a valid WD_UNDERLINE"): run.underline = invalid_value - def it_provides_access_to_its_font(self, font_fixture): - run, Font_, font_ = font_fixture + def it_provides_access_to_its_font(self, Font_: Mock, font_: Mock, paragraph_: Mock): + Font_.return_value = font_ + run = Run(cast(CT_R, element("w:r")), paragraph_) + font = run.font + Font_.assert_called_once_with(run._element) assert font is font_ - def it_can_add_text(self, add_text_fixture, Text_): - r, text_str, expected_xml = add_text_fixture - run = Run(r, None) + @pytest.mark.parametrize( + ("r_cxml", "new_text", "expected_cxml"), + [ + ("w:r", "foo", 'w:r/w:t"foo"'), + ('w:r/w:t"foo"', "bar", 'w:r/(w:t"foo", w:t"bar")'), + ("w:r", "fo ", 'w:r/w:t{xml:space=preserve}"fo "'), + ("w:r", "f o", 'w:r/w:t"f o"'), + ], + ) + def it_can_add_text( + self, r_cxml: str, new_text: str, expected_cxml: str, Text_: Mock, paragraph_: Mock + ): + run = Run(cast(CT_R, element(r_cxml)), paragraph_) - _text = run.add_text(text_str) + text = run.add_text(new_text) - assert run._r.xml == expected_xml - assert _text is Text_.return_value + assert run._r.xml == xml(expected_cxml) + assert text is Text_.return_value @pytest.mark.parametrize( ("break_type", "expected_cxml"), @@ -134,28 +279,42 @@ def it_can_add_text(self, add_text_fixture, Text_): (WD_BREAK.LINE_CLEAR_ALL, "w:r/w:br{w:clear=all}"), ], ) - def it_can_add_a_break(self, break_type: WD_BREAK, expected_cxml: str): - r = cast(CT_R, element("w:r")) - run = Run(r, None) # pyright:ignore[reportGeneralTypeIssues] - expected_xml = xml(expected_cxml) + def it_can_add_a_break(self, break_type: WD_BREAK, expected_cxml: str, paragraph_: Mock): + run = Run(cast(CT_R, element("w:r")), paragraph_) run.add_break(break_type) - assert run._r.xml == expected_xml + assert run._r.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("r_cxml", "expected_cxml"), [('w:r/w:t"foo"', 'w:r/(w:t"foo", w:tab)')] + ) + def it_can_add_a_tab(self, r_cxml: str, expected_cxml: str, paragraph_: Mock): + run = Run(cast(CT_R, element(r_cxml)), paragraph_) - def it_can_add_a_tab(self, add_tab_fixture): - run, expected_xml = add_tab_fixture run.add_tab() - assert run._r.xml == expected_xml - def it_can_add_a_picture(self, add_picture_fixture): - run, image, width, height, inline = add_picture_fixture[:5] - expected_xml, InlineShape_, picture_ = add_picture_fixture[5:] + assert run._r.xml == xml(expected_cxml) + + def it_can_add_a_picture( + self, + part_prop_: Mock, + document_part_: Mock, + InlineShape_: Mock, + picture_: Mock, + paragraph_: Mock, + ): + part_prop_.return_value = document_part_ + run = Run(cast(CT_R, element("w:r/wp:x")), paragraph_) + image = "foobar.png" + width, height, inline = 1111, 2222, element("wp:inline{id=42}") + document_part_.new_pic_inline.return_value = inline + InlineShape_.return_value = picture_ picture = run.add_picture(image, width, height) - run.part.new_pic_inline.assert_called_once_with(image, width, height) - assert run._r.xml == expected_xml + document_part_.new_pic_inline.assert_called_once_with(image, width, height) + assert run._r.xml == xml("w:r/(wp:x,w:drawing/wp:inline{id=42})") InlineShape_.assert_called_once_with(inline) assert picture is picture_ @@ -174,15 +333,13 @@ def it_can_add_a_picture(self, add_picture_fixture): ], ) def it_can_remove_its_content_but_keep_formatting( - self, initial_r_cxml: str, expected_cxml: str + self, initial_r_cxml: str, expected_cxml: str, paragraph_: Mock ): - r = cast(CT_R, element(initial_r_cxml)) - run = Run(r, None) # pyright: ignore[reportGeneralTypeIssues] - expected_xml = xml(expected_cxml) + run = Run(cast(CT_R, element(initial_r_cxml)), paragraph_) cleared_run = run.clear() - assert run._r.xml == expected_xml + assert run._r.xml == xml(expected_cxml) assert cleared_run is run @pytest.mark.parametrize( @@ -194,212 +351,58 @@ def it_can_remove_its_content_but_keep_formatting( ('w:r/(w:br{w:type=page}, w:t"abc", w:t"def", w:tab)', "abcdef\t"), ], ) - def it_knows_the_text_it_contains(self, r_cxml: str, expected_text: str): - r = cast(CT_R, element(r_cxml)) - run = Run(r, None) # pyright: ignore[reportGeneralTypeIssues] + def it_knows_the_text_it_contains(self, r_cxml: str, expected_text: str, paragraph_: Mock): + run = Run(cast(CT_R, element(r_cxml)), paragraph_) assert run.text == expected_text - def it_can_replace_the_text_it_contains(self, text_set_fixture): - run, text, expected_xml = text_set_fixture - run.text = text - assert run._r.xml == expected_xml - - # fixtures ------------------------------------------------------- - - @pytest.fixture - def add_picture_fixture(self, part_prop_, document_part_, InlineShape_, picture_): - run = Run(element("w:r/wp:x"), None) - image = "foobar.png" - width, height, inline = 1111, 2222, element("wp:inline{id=42}") - expected_xml = xml("w:r/(wp:x,w:drawing/wp:inline{id=42})") - document_part_.new_pic_inline.return_value = inline - InlineShape_.return_value = picture_ - return (run, image, width, height, inline, expected_xml, InlineShape_, picture_) - - @pytest.fixture( - params=[ - ('w:r/w:t"foo"', 'w:r/(w:t"foo", w:tab)'), - ] - ) - def add_tab_fixture(self, request): - r_cxml, expected_cxml = request.param - run = Run(element(r_cxml), None) - expected_xml = xml(expected_cxml) - return run, expected_xml - - @pytest.fixture( - params=[ - ("w:r", "foo", 'w:r/w:t"foo"'), - ('w:r/w:t"foo"', "bar", 'w:r/(w:t"foo", w:t"bar")'), - ("w:r", "fo ", 'w:r/w:t{xml:space=preserve}"fo "'), - ("w:r", "f o", 'w:r/w:t"f o"'), - ] - ) - def add_text_fixture(self, request): - r_cxml, text, expected_cxml = request.param - r = element(r_cxml) - expected_xml = xml(expected_cxml) - return r, text, expected_xml - - @pytest.fixture( - params=[ - ("w:r/w:rPr", "bold", None), - ("w:r/w:rPr/w:b", "bold", True), - ("w:r/w:rPr/w:b{w:val=on}", "bold", True), - ("w:r/w:rPr/w:b{w:val=off}", "bold", False), - ("w:r/w:rPr/w:b{w:val=1}", "bold", True), - ("w:r/w:rPr/w:i{w:val=0}", "italic", False), - ] - ) - def bool_prop_get_fixture(self, request): - r_cxml, bool_prop_name, expected_value = request.param - run = Run(element(r_cxml), None) - return run, bool_prop_name, expected_value - - @pytest.fixture( - params=[ - # nothing to True, False, and None --------------------------- - ("w:r", "bold", True, "w:r/w:rPr/w:b"), - ("w:r", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), - ("w:r", "italic", None, "w:r/w:rPr"), - # default to True, False, and None --------------------------- - ("w:r/w:rPr/w:b", "bold", True, "w:r/w:rPr/w:b"), - ("w:r/w:rPr/w:b", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), - ("w:r/w:rPr/w:i", "italic", None, "w:r/w:rPr"), - # True to True, False, and None ------------------------------ - ("w:r/w:rPr/w:b{w:val=on}", "bold", True, "w:r/w:rPr/w:b"), - ("w:r/w:rPr/w:b{w:val=1}", "bold", False, "w:r/w:rPr/w:b{w:val=0}"), - ("w:r/w:rPr/w:b{w:val=1}", "bold", None, "w:r/w:rPr"), - # False to True, False, and None ----------------------------- - ("w:r/w:rPr/w:i{w:val=false}", "italic", True, "w:r/w:rPr/w:i"), - ("w:r/w:rPr/w:i{w:val=0}", "italic", False, "w:r/w:rPr/w:i{w:val=0}"), - ("w:r/w:rPr/w:i{w:val=off}", "italic", None, "w:r/w:rPr"), - ] - ) - def bool_prop_set_fixture(self, request): - initial_r_cxml, bool_prop_name, value, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, bool_prop_name, value, expected_xml - - @pytest.fixture - def font_fixture(self, Font_, font_): - run = Run(element("w:r"), None) - return run, Font_, font_ - - @pytest.fixture - def style_get_fixture(self, part_prop_): - style_id = "Barfoo" - r_cxml = "w:r/w:rPr/w:rStyle{w:val=%s}" % style_id - run = Run(element(r_cxml), None) - style_ = part_prop_.return_value.get_style.return_value - return run, style_id, style_ - - @pytest.fixture( - params=[ - ("w:r", "Foo Font", "FooFont", "w:r/w:rPr/w:rStyle{w:val=FooFont}"), - ("w:r/w:rPr", "Foo Font", "FooFont", "w:r/w:rPr/w:rStyle{w:val=FooFont}"), - ( - "w:r/w:rPr/w:rStyle{w:val=FooFont}", - "Bar Font", - "BarFont", - "w:r/w:rPr/w:rStyle{w:val=BarFont}", - ), - ("w:r/w:rPr/w:rStyle{w:val=FooFont}", None, None, "w:r/w:rPr"), - ("w:r", None, None, "w:r/w:rPr"), - ] - ) - def style_set_fixture(self, request, part_prop_): - r_cxml, value, style_id, expected_cxml = request.param - run = Run(element(r_cxml), None) - part_prop_.return_value.get_style_id.return_value = style_id - expected_xml = xml(expected_cxml) - return run, value, expected_xml - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("new_text", "expected_cxml"), + [ ("abc def", 'w:r/w:t"abc def"'), ("abc\tdef", 'w:r/(w:t"abc", w:tab, w:t"def")'), ("abc\ndef", 'w:r/(w:t"abc", w:br, w:t"def")'), ("abc\rdef", 'w:r/(w:t"abc", w:br, w:t"def")'), - ] - ) - def text_set_fixture(self, request): - new_text, expected_cxml = request.param - initial_r_cxml = 'w:r/w:t"should get deleted"' - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, new_text, expected_xml - - @pytest.fixture( - params=[ - ("w:r", None), - ("w:r/w:rPr/w:u", None), - ("w:r/w:rPr/w:u{w:val=single}", True), - ("w:r/w:rPr/w:u{w:val=none}", False), - ("w:r/w:rPr/w:u{w:val=double}", WD_UNDERLINE.DOUBLE), - ("w:r/w:rPr/w:u{w:val=wave}", WD_UNDERLINE.WAVY), - ] + ], ) - def underline_get_fixture(self, request): - r_cxml, expected_underline = request.param - run = Run(element(r_cxml), None) - return run, expected_underline + def it_can_replace_the_text_it_contains( + self, new_text: str, expected_cxml: str, paragraph_: Mock + ): + run = Run(cast(CT_R, element('w:r/w:t"should get deleted"')), paragraph_) - @pytest.fixture( - params=[ - ("w:r", True, "w:r/w:rPr/w:u{w:val=single}"), - ("w:r", False, "w:r/w:rPr/w:u{w:val=none}"), - ("w:r", None, "w:r/w:rPr"), - ("w:r", WD_UNDERLINE.SINGLE, "w:r/w:rPr/w:u{w:val=single}"), - ("w:r", WD_UNDERLINE.THICK, "w:r/w:rPr/w:u{w:val=thick}"), - ("w:r/w:rPr/w:u{w:val=single}", True, "w:r/w:rPr/w:u{w:val=single}"), - ("w:r/w:rPr/w:u{w:val=single}", False, "w:r/w:rPr/w:u{w:val=none}"), - ("w:r/w:rPr/w:u{w:val=single}", None, "w:r/w:rPr"), - ( - "w:r/w:rPr/w:u{w:val=single}", - WD_UNDERLINE.SINGLE, - "w:r/w:rPr/w:u{w:val=single}", - ), - ( - "w:r/w:rPr/w:u{w:val=single}", - WD_UNDERLINE.DOTTED, - "w:r/w:rPr/w:u{w:val=dotted}", - ), - ] - ) - def underline_set_fixture(self, request): - initial_r_cxml, new_underline, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, new_underline, expected_xml + run.text = new_text - # fixture components --------------------------------------------- + assert run._r.xml == xml(expected_cxml) + + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture - def document_part_(self, request): + def document_part_(self, request: FixtureRequest): return instance_mock(request, DocumentPart) @pytest.fixture - def Font_(self, request, font_): - return class_mock(request, "docx.text.run.Font", return_value=font_) + def Font_(self, request: FixtureRequest): + return class_mock(request, "docx.text.run.Font") @pytest.fixture - def font_(self, request): + def font_(self, request: FixtureRequest): return instance_mock(request, Font) @pytest.fixture - def InlineShape_(self, request): + def InlineShape_(self, request: FixtureRequest): return class_mock(request, "docx.text.run.InlineShape") @pytest.fixture - def part_prop_(self, request, document_part_): - return property_mock(request, Run, "part", return_value=document_part_) + def paragraph_(self, request: FixtureRequest): + return instance_mock(request, Paragraph) + + @pytest.fixture + def part_prop_(self, request: FixtureRequest): + return property_mock(request, Run, "part") @pytest.fixture - def picture_(self, request): + def picture_(self, request: FixtureRequest): return instance_mock(request, InlineShape) @pytest.fixture - def Text_(self, request): + def Text_(self, request: FixtureRequest): return class_mock(request, "docx.text.run._Text") diff --git a/tests/unitutil/file.py b/tests/unitutil/file.py index 795052c8e..226585bc7 100644 --- a/tests/unitutil/file.py +++ b/tests/unitutil/file.py @@ -43,9 +43,7 @@ def snippet_text(snippet_file_name: str): Return the unicode text read from the test snippet file having `snippet_file_name`. """ - snippet_file_path = os.path.join( - test_file_dir, "snippets", "%s.txt" % snippet_file_name - ) + snippet_file_path = os.path.join(test_file_dir, "snippets", "%s.txt" % snippet_file_name) with open(snippet_file_path, "rb") as f: snippet_bytes = f.read() return snippet_bytes.decode("utf-8") diff --git a/tests/unitutil/mock.py b/tests/unitutil/mock.py index d0e41ce93..de05cc206 100644 --- a/tests/unitutil/mock.py +++ b/tests/unitutil/mock.py @@ -75,16 +75,12 @@ def function_mock( return _patch.start() -def initializer_mock( - request: FixtureRequest, cls: type, autospec: bool = True, **kwargs: Any -): +def initializer_mock(request: FixtureRequest, cls: type, autospec: bool = True, **kwargs: Any): """Return mock for __init__() method on `cls`. The patch is reversed after pytest uses it. """ - _patch = patch.object( - cls, "__init__", autospec=autospec, return_value=None, **kwargs - ) + _patch = patch.object(cls, "__init__", autospec=autospec, return_value=None, **kwargs) request.addfinalizer(_patch.stop) return _patch.start() 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 new file mode 100644 index 000000000..7888c5298 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1107 @@ +version = 1 +revision = 1 +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 = "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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285 }, +] + +[[package]] +name = "behave" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "parse-type" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/4b/d0a8c23b6c8985e5544ea96d27105a273ea22051317f850c2cdbf2029fe4/behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", size = 701696 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/6c/ec9169548b6c4cb877aaa6773408ca08ae2a282805b958dbc163cb19822d/behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c", size = 136779 }, +] + +[[package]] +name = "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" +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 = "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" +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 }, + { 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/0a/c3ea9573b1dc2e151abfe88c7fe0c26d1892fe6ed02d0cdb30f0d57029d5/cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7", size = 42870 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786 }, +] + +[[package]] +name = "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" +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'" }, +] +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 = "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" +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 = "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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "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" +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 = "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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/1f/a3b6b74a451ceb84b471caa75c934d2430a4d84395d38ef201d539f38cd1/lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c", size = 8076838 }, + { url = "https://files.pythonhosted.org/packages/36/af/a567a55b3e47135b4d1f05a1118c24529104c003f95851374b3748139dc1/lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7", size = 4381827 }, + { url = "https://files.pythonhosted.org/packages/50/ba/4ee47d24c675932b3eb5b6de77d0f623c2db6dc466e7a1f199792c5e3e3a/lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf", size = 5204098 }, + { url = "https://files.pythonhosted.org/packages/f2/0f/b4db6dfebfefe3abafe360f42a3d471881687fd449a0b86b70f1f2683438/lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28", size = 4930261 }, + { url = "https://files.pythonhosted.org/packages/0b/1f/0bb1bae1ce056910f8db81c6aba80fec0e46c98d77c0f59298c70cd362a3/lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609", size = 5529621 }, + { url = "https://files.pythonhosted.org/packages/21/f5/e7b66a533fc4a1e7fa63dd22a1ab2ec4d10319b909211181e1ab3e539295/lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4", size = 4983231 }, + { url = "https://files.pythonhosted.org/packages/11/39/a38244b669c2d95a6a101a84d3c85ba921fea827e9e5483e93168bf1ccb2/lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7", size = 5084279 }, + { url = "https://files.pythonhosted.org/packages/db/64/48cac242347a09a07740d6cee7b7fd4663d5c1abd65f2e3c60420e231b27/lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f", size = 4927405 }, + { url = "https://files.pythonhosted.org/packages/98/89/97442835fbb01d80b72374f9594fe44f01817d203fa056e9906128a5d896/lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997", size = 5550169 }, + { url = "https://files.pythonhosted.org/packages/f1/97/164ca398ee654eb21f29c6b582685c6c6b9d62d5213abc9b8380278e9c0a/lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c", size = 5062691 }, + { url = "https://files.pythonhosted.org/packages/d0/bc/712b96823d7feb53482d2e4f59c090fb18ec7b0d0b476f353b3085893cda/lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b", size = 5133503 }, + { url = "https://files.pythonhosted.org/packages/d4/55/a62a39e8f9da2a8b6002603475e3c57c870cd9c95fd4b94d4d9ac9036055/lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b", size = 4999346 }, + { url = "https://files.pythonhosted.org/packages/ea/47/a393728ae001b92bb1a9e095e570bf71ec7f7fbae7688a4792222e56e5b9/lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563", size = 5627139 }, + { url = "https://files.pythonhosted.org/packages/5e/5f/9dcaaad037c3e642a7ea64b479aa082968de46dd67a8293c541742b6c9db/lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5", size = 5465609 }, + { url = "https://files.pythonhosted.org/packages/a7/0a/ebcae89edf27e61c45023005171d0ba95cb414ee41c045ae4caf1b8487fd/lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776", size = 5192285 }, + { url = "https://files.pythonhosted.org/packages/42/ad/cc8140ca99add7d85c92db8b2354638ed6d5cc0e917b21d36039cb15a238/lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7", size = 3477507 }, + { url = "https://files.pythonhosted.org/packages/e9/39/597ce090da1097d2aabd2f9ef42187a6c9c8546d67c419ce61b88b336c85/lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250", size = 3805104 }, + { url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240 }, + { url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685 }, + { url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164 }, + { url = "https://files.pythonhosted.org/packages/f9/94/bbc66e42559f9d04857071e3b3d0c9abd88579367fd2588a4042f641f57e/lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df", size = 4746206 }, + { url = "https://files.pythonhosted.org/packages/66/95/34b0679bee435da2d7cae895731700e519a8dfcab499c21662ebe671603e/lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e", size = 5342144 }, + { url = "https://files.pythonhosted.org/packages/e0/5d/abfcc6ab2fa0be72b2ba938abdae1f7cad4c632f8d552683ea295d55adfb/lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44", size = 4825124 }, + { url = "https://files.pythonhosted.org/packages/5a/78/6bd33186c8863b36e084f294fc0a5e5eefe77af95f0663ef33809cc1c8aa/lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba", size = 4876520 }, + { url = "https://files.pythonhosted.org/packages/3b/74/4d7ad4839bd0fc64e3d12da74fc9a193febb0fae0ba6ebd5149d4c23176a/lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba", size = 4765016 }, + { url = "https://files.pythonhosted.org/packages/24/0d/0a98ed1f2471911dadfc541003ac6dd6879fc87b15e1143743ca20f3e973/lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c", size = 5362884 }, + { url = "https://files.pythonhosted.org/packages/48/de/d4f7e4c39740a6610f0f6959052b547478107967362e8424e1163ec37ae8/lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8", size = 4902690 }, + { url = "https://files.pythonhosted.org/packages/07/8c/61763abd242af84f355ca4ef1ee096d3c1b7514819564cce70fd18c22e9a/lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86", size = 4944418 }, + { url = "https://files.pythonhosted.org/packages/f9/c5/6d7e3b63e7e282619193961a570c0a4c8a57fe820f07ca3fe2f6bd86608a/lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056", size = 4827092 }, + { url = "https://files.pythonhosted.org/packages/71/4a/e60a306df54680b103348545706a98a7514a42c8b4fbfdcaa608567bb065/lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7", size = 5418231 }, + { url = "https://files.pythonhosted.org/packages/27/f2/9754aacd6016c930875854f08ac4b192a47fe19565f776a64004aa167521/lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd", size = 5261798 }, + { url = "https://files.pythonhosted.org/packages/38/a2/0c49ec6941428b1bd4f280650d7b11a0f91ace9db7de32eb7aa23bcb39ff/lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751", size = 4988195 }, + { url = "https://files.pythonhosted.org/packages/7a/75/87a3963a08eafc46a86c1131c6e28a4de103ba30b5ae903114177352a3d7/lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4", size = 3474243 }, + { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197 }, + { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392 }, + { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103 }, + { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224 }, + { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913 }, + { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441 }, + { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580 }, + { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493 }, + { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679 }, + { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691 }, + { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075 }, + { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680 }, + { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253 }, + { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651 }, + { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315 }, + { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149 }, + { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095 }, + { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086 }, + { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613 }, + { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008 }, + { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915 }, + { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890 }, + { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644 }, + { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817 }, + { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916 }, + { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274 }, + { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757 }, + { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028 }, + { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487 }, + { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688 }, + { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043 }, + { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569 }, + { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270 }, + { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606 }, + { url = "https://files.pythonhosted.org/packages/1e/04/acd238222ea25683e43ac7113facc380b3aaf77c53e7d88c4f544cef02ca/lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e", size = 8082189 }, + { url = "https://files.pythonhosted.org/packages/d6/4e/cc7fe9ccb9999cc648492ce970b63c657606aefc7d0fba46b17aa2ba93fb/lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40", size = 4384950 }, + { url = "https://files.pythonhosted.org/packages/56/bf/acd219c489346d0243a30769b9d446b71e5608581db49a18c8d91a669e19/lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729", size = 5209823 }, + { url = "https://files.pythonhosted.org/packages/57/51/ec31cd33175c09aa7b93d101f56eed43d89e15504455d884d021df7166a7/lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87", size = 4931808 }, + { url = "https://files.pythonhosted.org/packages/e5/68/865d229f191514da1777125598d028dc88a5ea300d68c30e1f120bfd01bd/lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd", size = 5086067 }, + { url = "https://files.pythonhosted.org/packages/82/01/4c958c5848b4e263cd9e83dff6b49f975a5a0854feb1070dfe0bdcdf70a0/lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433", size = 4929026 }, + { url = "https://files.pythonhosted.org/packages/55/31/5327d8af74d7f35e645b40ae6658761e1fee59ebecaa6a8d295e495c2ca9/lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140", size = 5134245 }, + { url = "https://files.pythonhosted.org/packages/6f/c9/204eba2400beb0016dacc2c5335ecb1e37f397796683ffdb7f471e86bddb/lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5", size = 5001020 }, + { url = "https://files.pythonhosted.org/packages/07/53/979165f50a853dab1cf3b9e53105032d55f85c5993f94afc4d9a61a22877/lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142", size = 5192346 }, + { url = "https://files.pythonhosted.org/packages/17/2b/f37b5ae28949143f863ba3066b30eede6107fc9a503bd0d01677d4e2a1e0/lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6", size = 3478275 }, + { url = "https://files.pythonhosted.org/packages/9a/d5/b795a183680126147665a8eeda8e802c180f2f7661aa9a550bba5bcdae63/lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1", size = 3806275 }, + { url = "https://files.pythonhosted.org/packages/c6/b0/e4d1cbb8c078bc4ae44de9c6a79fec4e2b4151b1b4d50af71d799e76b177/lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55", size = 3892319 }, + { url = "https://files.pythonhosted.org/packages/5b/aa/e2bdefba40d815059bcb60b371a36fbfcce970a935370e1b367ba1cc8f74/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740", size = 4211614 }, + { url = "https://files.pythonhosted.org/packages/3c/5f/91ff89d1e092e7cfdd8453a939436ac116db0a665e7f4be0cd8e65c7dc5a/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5", size = 4306273 }, + { url = "https://files.pythonhosted.org/packages/be/7c/8c3f15df2ca534589717bfd19d1e3482167801caedfa4d90a575facf68a6/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37", size = 4208552 }, + { url = "https://files.pythonhosted.org/packages/7d/d8/9567afb1665f64d73fc54eb904e418d1138d7f011ed00647121b4dd60b38/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571", size = 4331091 }, + { url = "https://files.pythonhosted.org/packages/f1/ab/fdbbd91d8d82bf1a723ba88ec3e3d76c022b53c391b0c13cad441cdb8f9e/lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4", size = 3487862 }, + { url = "https://files.pythonhosted.org/packages/ad/fb/d19b67e4bb63adc20574ba3476cf763b3514df1a37551084b890254e4b15/lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530", size = 3891034 }, + { url = "https://files.pythonhosted.org/packages/c9/5d/6e1033ee0cdb2f9bc93164f9df14e42cb5bbf1bbed3bf67f687de2763104/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6", size = 4207420 }, + { url = "https://files.pythonhosted.org/packages/f3/4b/23ac79efc32d913259d66672c5f93daac7750a3d97cdc1c1a9a5d1c1b46c/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877", size = 4305106 }, + { url = "https://files.pythonhosted.org/packages/a4/7a/fe558bee63a62f7a75a52111c0a94556c1c1bdcf558cd7d52861de558759/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8", size = 4205587 }, + { url = "https://files.pythonhosted.org/packages/ed/5b/3207e6bd8d67c952acfec6bac9d1fa0ee353202e7c40b335ebe00879ab7d/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d", size = 4329077 }, + { url = "https://files.pythonhosted.org/packages/a1/25/d381abcfd00102d3304aa191caab62f6e3bcbac93ee248771db6be153dfd/lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987", size = 3486416 }, +] + +[[package]] +name = "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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "parse" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126 }, +] + +[[package]] +name = "parse-type" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/e9/a3b2ae5f8a852542788ac1f1865dcea0c549cc40af243f42cabfa0acf24d/parse_type-0.6.4.tar.gz", hash = "sha256:5e1ec10440b000c3f818006033372939e693a9ec0176f446d9303e4db88489a6", size = 96480 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/b3/f6cc950042bfdbe98672e7c834d930f85920fb7d3359f59096e8d2799617/parse_type-0.6.4-py2.py3-none-any.whl", hash = "sha256:83d41144a82d6b8541127bf212dd76c7f01baff680b498ce8a4d052a7a5bce4c", size = 27442 }, +] + +[[package]] +name = "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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pyparsing" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 }, +] + +[[package]] +name = "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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/9a/7ab2b333b921b2d6bfcffe05a0e0a0bbeff884bd6fb5ed50cd68e2898e53/pyright-1.1.401.tar.gz", hash = "sha256:788a82b6611fa5e34a326a921d86d898768cddf59edde8e93e56087d277cc6f1", size = 3894193 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/e6/1f908fce68b0401d41580e0f9acc4c3d1b248adcff00dfaad75cd21a1370/pyright-1.1.401-py3-none-any.whl", hash = "sha256:6fde30492ba5b0d7667c16ecaf6c699fab8d7a1263f6a18549e0b00bf7724c06", size = 5629193 }, +] + +[[package]] +name = "pytest" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797 }, +] + +[[package]] +name = "python-docx" +source = { editable = "." } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +dev = [ + { name = "alabaster" }, + { name = "behave" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "pyparsing" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "sphinx" }, + { name = "tox" }, + { name = "twine" }, + { name = "types-lxml-multi-subclass" }, +] + +[package.metadata] +requires-dist = [ + { name = "lxml", specifier = ">=3.1.0" }, + { name = "typing-extensions", specifier = ">=4.9.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "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 = "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" +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 = "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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516 }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083 }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024 }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324 }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416 }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197 }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615 }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080 }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315 }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640 }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364 }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462 }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028 }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992 }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944 }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669 }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928 }, +] + +[[package]] +name = "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" +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677 }, +] + +[[package]] +name = "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 }, +] + +[[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 = "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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ed/9f092ff479e2b5598941855f314a22953bb04b5fb38bcba3f880feb833ba/types_html5lib-1.1.11.20250516.tar.gz", hash = "sha256:65043a6718c97f7d52567cc0cdf41efbfc33b1f92c6c0c5e19f60a7ec69ae720", size = 16136 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/3b/cb5b23c7b51bf48b8c9f175abb9dce2f1ecd2d2c25f92ea9f4e3720e9398/types_html5lib-1.1.11.20250516-py3-none-any.whl", hash = "sha256:5e407b14b1bd2b9b1107cbd1e2e19d4a0c46d60febd231c7ab7313d7405663c1", size = 21770 }, +] + +[[package]] +name = "types-lxml-multi-subclass" +version = "2025.3.30" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "cssselect" }, + { name = "types-html5lib" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/3a/7f6d1d3b921404efef20ed1ddc2b6f1333e3f0bc5b91da37874e786ff835/types_lxml_multi_subclass-2025.3.30.tar.gz", hash = "sha256:7ac7a78e592fdba16951668968b21511adda49bbefbc0f130e55501b70e068b4", size = 153188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/8e/106b4c5a67e6d52475ef51008e6c27d4ad472690d619dc32e079d28a540b/types_lxml_multi_subclass-2025.3.30-py3-none-any.whl", hash = "sha256:b0563e4e49e66eb8093c44e74b262c59e3be6d3bb3437511e3a4843fd74044d1", size = 93475 }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839 }, +] + +[[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 }, +] + +[[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 }, +] + +[[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 }, +] pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy