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 8e0b1a588..69bba4161 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,29 @@ 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) +++++++++++++++++++ + +- Fix #1383 Revert lxml<=4.9.2 pin that breaks Python 3.12 install +- Fix #1385 Support use of Part._rels by python-docx-template +- Add support and testing for Python 3.12 + + +1.1.1 (2024-04-29) +++++++++++++++++++ + +- Fix #531, #1146 Index error on table with misaligned borders +- Fix #1335 Tolerate invalid float value in bottom-margin +- Fix #1337 Do not require typing-extensions at runtime + + 1.1.0 (2023-11-03) ++++++++++++++++++ diff --git a/Makefile b/Makefile index 0478b2bce..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,20 +23,21 @@ 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 {} \; + # find . -type f -name \*.pyc -exec rm {} \; + fd -e pyc -I -x rm rm -rf dist *.egg-info .coverage .DS_Store 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 @@ -49,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 cdb8b5455..aee0acfbf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -74,12 +74,14 @@ User Guide user/install user/quickstart user/documents + user/tables user/text user/sections user/hdrftr user/api-concepts user/styles-understanding user/styles-using + user/comments user/shapes @@ -95,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/docs/user/tables.rst b/docs/user/tables.rst new file mode 100644 index 000000000..40ef20933 --- /dev/null +++ b/docs/user/tables.rst @@ -0,0 +1,202 @@ +.. _tables: + +Working with Tables +=================== + +Word provides sophisticated capabilities to create tables. As usual, this power comes with +additional conceptual complexity. + +This complexity becomes most apparent when *reading* tables, in particular from documents drawn from +the wild where there is limited or no prior knowledge as to what the tables might contain or how +they might be structured. + +These are some of the important concepts you'll need to understand. + + +Concept: Simple (uniform) tables +-------------------------------- + +:: + + +---+---+---+ + | a | b | c | + +---+---+---+ + | d | e | f | + +---+---+---+ + | g | h | i | + +---+---+---+ + +The basic concept of a table is intuitive enough. You have *rows* and *columns*, and at each (row, +column) position is a different *cell*. It can be described as a *grid* or a *matrix*. Let's call +this concept a *uniform table*. A relational database table and a Pandas dataframe are both examples +of a uniform table. + +The following invariants apply to uniform tables: + +* Each row has the same number of cells, one for each column. +* Each column has the same number of cells, one for each row. + + +Complication 1: Merged Cells +---------------------------- + +:: + + +---+---+---+ +---+---+---+ + | a | b | | | b | c | + +---+---+---+ + a +---+---+ + | c | d | e | | | d | e | + +---+---+---+ +---+---+---+ + | f | g | h | | f | g | h | + +---+---+---+ +---+---+---+ + +While very suitable for data processing, a uniform table lacks expressive power desireable for +tables intended for a human reader. + +Perhaps the most important characteristic a uniform table lacks is *merged cells*. It is very common +to want to group multiple cells into one, for example to form a column-group heading or provide the +same value for a sequence of cells rather than repeat it for each cell. These make a rendered table +more *readable* by reducing the cognitive load on the human reader and make certain relationships +explicit that might easily be missed otherwise. + +Unfortunately, accommodating merged cells breaks both the invariants of a uniform table: + +* Each row can have a different number of cells. +* Each column can have a different number of cells. + +This challenges reading table contents programatically. One might naturally want to read the table +into a uniform matrix data structure like a 3 x 3 "2D array" (list of lists perhaps), but this is +not directly possible when the table is not known to be uniform. + + +Concept: The layout grid +------------------------ + +:: + + + - + - + - + + | | | | + + - + - + - + + | | | | + + - + - + - + + | | | | + + - + - + - + + +In Word, each table has a *layout grid*. + +- The layout grid is *uniform*. There is a layout position for every (layout-row, layout-column) + pair. +- The layout grid itself is not visible. However it is represented and referenced by certain + elements and attributes within the table XML +- Each table cell is located at a layout-grid position; i.e. the top-left corner of each cell is the + top-left corner of a layout-grid cell. +- Each table cell occupies one or more whole layout-grid cells. A merged cell will occupy multiple + layout-grid cells. No table cell can occupy a partial layout-grid cell. +- Another way of saying this is that every vertical boundary (left and right) of a cell aligns with + a layout-grid vertical boundary, likewise for horizontal boundaries. But not all layout-grid + boundaries need be occupied by a cell boundary of the table. + + +Complication 2: Omitted Cells +----------------------------- + +:: + + +---+---+ +---+---+---+ + | a | b | | a | b | c | + +---+---+---+ +---+---+---+ + | c | d | | d | + +---+---+ +---+---+---+ + | e | | e | f | g | + +---+ +---+---+---+ + +Word is unusual in that it allows cells to be omitted from the beginning or end (but not the middle) +of a row. A typical practical example is a table with both a row of column headings and a column of +row headings, but no top-left cell (position 0, 0), such as this XOR truth table. + +:: + + +---+---+ + | T | F | + +---+---+---+ + | T | F | T | + +---+---+---+ + | F | T | F | + +---+---+---+ + +In `python-docx`, omitted cells in a |_Row| object are represented by the ``.grid_cols_before`` and +``.grid_cols_after`` properties. In the example above, for the first row, ``.grid_cols_before`` +would equal ``1`` and ``.grid_cols_after`` would equal ``0``. + +Note that omitted cells are not just "empty" cells. They represent layout-grid positions that are +unoccupied by a cell and they cannot be represented by a |_Cell| object. This distinction becomes +important when trying to produce a uniform representation (e.g. a 2D array) for an arbitrary Word +table. + + +Concept: `python-docx` approximates uniform tables by default +------------------------------------------------------------- + +To accurately represent an arbitrary table would require a complex graph data structure. Navigating +this data structure would be at least as complex as navigating the `python-docx` object graph for a +table. When extracting content from a collection of arbitrary Word files, such as for indexing the +document, it is common to choose a simpler data structure and *approximate* the table in that +structure. + +Reflecting on how a relational table or dataframe represents tabular information, a straightforward +approximation would simply repeat merged-cell values for each layout-grid cell occupied by the +merged cell:: + + + +---+---+---+ +---+---+---+ + | a | b | -> | a | a | b | + +---+---+---+ +---+---+---+ + | | d | e | -> | c | d | e | + + c +---+---+ +---+---+---+ + | | f | g | -> | c | f | g | + +---+---+---+ +---+---+---+ + +This is what ``_Row.cells`` does by default. Conceptually:: + + >>> [tuple(c.text for c in r.cells) for r in table.rows] + [ + (a, a, b), + (c, d, e), + (c, f, g), + ] + +Note this only produces a uniform "matrix" of cells when there are no omitted cells. Dealing with +omitted cells requires a more sophisticated approach when maintaining column integrity is required:: + + # +---+---+ + # | a | b | + # +---+---+---+ + # | c | d | + # +---+---+ + # | e | + # +---+ + + def iter_row_cell_texts(row: _Row) -> Iterator[str]: + for _ in range(row.grid_cols_before): + yield "" + for c in row.cells: + yield c.text + for _ in range(row.grid_cols_after): + yield "" + + >>> [tuple(iter_row_cell_texts(r)) for r in table.rows] + [ + ("", "a", "b"), + ("c", "d", ""), + ("", "e", ""), + ] + + +Complication 3: Tables are Recursive +------------------------------------ + +Further complicating table processing is their recursive nature. In Word, as in HTML, a table cell +can itself include one or more tables. + +These can be detected using ``_Cell.tables`` or ``_Cell.iter_inner_content()``. The latter preserves +the document order of the table with respect to paragraphs also in the cell. diff --git a/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/cell.py b/features/steps/cell.py deleted file mode 100644 index 10896872b..000000000 --- a/features/steps/cell.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Step implementations for table cell-related features.""" - -from behave import given, then, when - -from docx import Document - -from helpers import test_docx - -# given =================================================== - - -@given("a table cell") -def given_a_table_cell(context): - table = Document(test_docx("tbl-2x2-table")).tables[0] - context.cell = table.cell(0, 0) - - -# when ===================================================== - - -@when("I add a 2 x 2 table into the first cell") -def when_I_add_a_2x2_table_into_the_first_cell(context): - context.table_ = context.cell.add_table(2, 2) - - -@when("I assign a string to the cell text attribute") -def when_assign_string_to_cell_text_attribute(context): - cell = context.cell - text = "foobar" - cell.text = text - context.expected_text = text - - -# then ===================================================== - - -@then("cell.tables[0] is a 2 x 2 table") -def then_cell_tables_0_is_a_2x2_table(context): - cell = context.cell - table = cell.tables[0] - assert len(table.rows) == 2 - assert len(table.columns) == 2 - - -@then("the cell contains the string I assigned") -def then_cell_contains_string_assigned(context): - cell, expected_text = context.cell, context.expected_text - text = cell.paragraphs[0].runs[0].text - msg = "expected '%s', got '%s'" % (expected_text, text) - assert text == expected_text, msg diff --git a/features/steps/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/coreprops.py b/features/steps/coreprops.py index 0f6b6a854..90467fb67 100644 --- a/features/steps/coreprops.py +++ b/features/steps/coreprops.py @@ -1,8 +1,9 @@ """Gherkin step implementations for core properties-related features.""" -from datetime import datetime, timedelta +import datetime as dt from behave import given, then, when +from behave.runner import Context from docx import Document from docx.opc.coreprops import CoreProperties @@ -13,12 +14,12 @@ @given("a document having known core properties") -def given_a_document_having_known_core_properties(context): +def given_a_document_having_known_core_properties(context: Context): context.document = Document(test_docx("doc-coreprops")) @given("a document having no core properties part") -def given_a_document_having_no_core_properties_part(context): +def given_a_document_having_no_core_properties_part(context: Context): context.document = Document(test_docx("doc-no-coreprops")) @@ -26,24 +27,24 @@ def given_a_document_having_no_core_properties_part(context): @when("I access the core properties object") -def when_I_access_the_core_properties_object(context): +def when_I_access_the_core_properties_object(context: Context): context.document.core_properties @when("I assign new values to the properties") -def when_I_assign_new_values_to_the_properties(context): +def when_I_assign_new_values_to_the_properties(context: Context): context.propvals = ( ("author", "Creator"), ("category", "Category"), ("comments", "Description"), ("content_status", "Content Status"), - ("created", datetime(2013, 6, 15, 12, 34, 56)), + ("created", dt.datetime(2013, 6, 15, 12, 34, 56, tzinfo=dt.timezone.utc)), ("identifier", "Identifier"), ("keywords", "key; word; keyword"), ("language", "Language"), ("last_modified_by", "Last Modified By"), - ("last_printed", datetime(2013, 6, 15, 12, 34, 56)), - ("modified", datetime(2013, 6, 15, 12, 34, 56)), + ("last_printed", dt.datetime(2013, 6, 15, 12, 34, 56, tzinfo=dt.timezone.utc)), + ("modified", dt.datetime(2013, 6, 15, 12, 34, 56, tzinfo=dt.timezone.utc)), ("revision", 9), ("subject", "Subject"), ("title", "Title"), @@ -58,39 +59,39 @@ def when_I_assign_new_values_to_the_properties(context): @then("a core properties part with default values is added") -def then_a_core_properties_part_with_default_values_is_added(context): +def then_a_core_properties_part_with_default_values_is_added(context: Context): core_properties = context.document.core_properties assert core_properties.title == "Word Document" assert core_properties.last_modified_by == "python-docx" assert core_properties.revision == 1 # core_properties.modified only stores time with seconds resolution, so # comparison needs to be a little loose (within two seconds) - modified_timedelta = datetime.utcnow() - core_properties.modified - max_expected_timedelta = timedelta(seconds=2) + modified_timedelta = dt.datetime.now(dt.timezone.utc) - core_properties.modified + max_expected_timedelta = dt.timedelta(seconds=2) assert modified_timedelta < max_expected_timedelta @then("I can access the core properties object") -def then_I_can_access_the_core_properties_object(context): +def then_I_can_access_the_core_properties_object(context: Context): document = context.document core_properties = document.core_properties assert isinstance(core_properties, CoreProperties) @then("the core property values match the known values") -def then_the_core_property_values_match_the_known_values(context): +def then_the_core_property_values_match_the_known_values(context: Context): known_propvals = ( ("author", "Steve Canny"), ("category", "Category"), ("comments", "Description"), ("content_status", "Content Status"), - ("created", datetime(2014, 12, 13, 22, 2, 0)), + ("created", dt.datetime(2014, 12, 13, 22, 2, 0, tzinfo=dt.timezone.utc)), ("identifier", "Identifier"), ("keywords", "key; word; keyword"), ("language", "Language"), ("last_modified_by", "Steve Canny"), - ("last_printed", datetime(2014, 12, 13, 22, 2, 42)), - ("modified", datetime(2014, 12, 13, 22, 6, 0)), + ("last_printed", dt.datetime(2014, 12, 13, 22, 2, 42, tzinfo=dt.timezone.utc)), + ("modified", dt.datetime(2014, 12, 13, 22, 6, 0, tzinfo=dt.timezone.utc)), ("revision", 2), ("subject", "Subject"), ("title", "Title"), @@ -106,7 +107,7 @@ def then_the_core_property_values_match_the_known_values(context): @then("the core property values match the new values") -def then_the_core_property_values_match_the_new_values(context): +def then_the_core_property_values_match_the_new_values(context: Context): core_properties = context.document.core_properties for name, expected_value in context.propvals: value = getattr(core_properties, name) diff --git a/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/features/cel-add-table.feature b/features/tbl-cell-add-table.feature similarity index 100% rename from features/cel-add-table.feature rename to features/tbl-cell-add-table.feature diff --git a/features/tbl-cell-props.feature b/features/tbl-cell-props.feature index 609d2f442..456ed39a4 100644 --- a/features/tbl-cell-props.feature +++ b/features/tbl-cell-props.feature @@ -4,6 +4,17 @@ Feature: Get and set table cell properties I need a way to get and set the properties of a table cell + Scenario Outline: Get _Cell.grid_span + Given a _Cell object spanning layout-grid cells + Then cell.grid_span is + + Examples: Cell.grid_span value cases + | count | + | 1 | + | 2 | + | 4 | + + Scenario Outline: Get _Cell.vertical_alignment Given a _Cell object with vertical alignment as cell Then cell.vertical_alignment is diff --git a/features/cel-text.feature b/features/tbl-cell-text.feature similarity index 100% rename from features/cel-text.feature rename to features/tbl-cell-text.feature diff --git a/features/tbl-row-props.feature b/features/tbl-row-props.feature index 377f2853e..1b006f204 100644 --- a/features/tbl-row-props.feature +++ b/features/tbl-row-props.feature @@ -4,6 +4,28 @@ Feature: Get and set table row properties I need a way to get and set the properties of a table row + Scenario Outline: Get Row.grid_cols_after + Given a table row ending with empty grid columns + Then row.grid_cols_after is + + Examples: Row.grid_cols_after value cases + | count | + | 0 | + | 1 | + | 2 | + + + Scenario Outline: Get Row.grid_cols_before + Given a table row starting with empty grid columns + Then row.grid_cols_before is + + Examples: Row.grid_cols_before value cases + | count | + | 0 | + | 1 | + | 3 | + + Scenario Outline: Get Row.height_rule Given a table row having height rule Then row.height_rule is diff --git a/pyproject.toml b/pyproject.toml index d35c790c7..b3dc0be02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,14 +23,30 @@ classifiers = [ ] dependencies = [ "lxml>=3.1.0", - "typing_extensions", + "typing_extensions>=4.9.0", ] description = "Create, read, and update Microsoft Word .docx files." 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,8 +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] -target-version = ["py37", "py38", "py39", "py310", "py311"] +[tool.pyright] +include = ["src/docx", "tests"] +pythonPlatform = "All" +pythonVersion = "3.9" +reportImportCycles = false +reportUnnecessaryCast = true +reportUnnecessaryTypeIgnoreComment = true +stubPath = "./typings" +typeCheckingMode = "strict" +verboseOutput = true +venvPath = "." +venv = ".venv" [tool.pytest.ini_options] filterwarnings = [ @@ -69,10 +95,13 @@ python_functions = ["it_", "its_", "they_", "and_", "but_"] [tool.ruff] exclude = [] +line-length = 100 +target-version = "py38" + +[tool.ruff.lint] ignore = [ "COM812", # -- over-aggressively insists on trailing commas where not desired -- "PT001", # -- wants @pytest.fixture() instead of @pytest.fixture -- - "PT005", # -- wants @pytest.fixture() instead of @pytest.fixture -- ] select = [ "C4", # -- flake8-comprehensions -- @@ -88,11 +117,11 @@ select = [ "UP032", # -- Use f-string instead of `.format()` call -- "UP034", # -- Avoid extraneous parentheses -- ] -target-version = "py37" -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["docx"] known-local-folder = ["helpers"] [tool.setuptools.dynamic] version = {attr = "docx.__version__"} + diff --git a/pyrightconfig.json b/pyrightconfig.json deleted file mode 100644 index 161e49d2b..000000000 --- a/pyrightconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "exclude": [ - "**/__pycache__", - "**/.*" - ], - "ignore": [ - ], - "include": [ - "src/docx/", - "tests" - ], - "pythonPlatform": "All", - "pythonVersion": "3.7", - "reportImportCycles": true, - "reportUnnecessaryCast": true, - "reportUnnecessaryTypeIgnoreComment": true, - "stubPath": "./typings", - "typeCheckingMode": "strict", - "useLibraryCodeForTypes": true, - "verboseOutput": true -} diff --git a/requirements-dev.txt b/requirements-dev.txt index 45e5f78c3..14d8740e3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,7 @@ -r requirements-test.txt build +ruff setuptools>=61.0.0 tox twine +types-lxml diff --git a/requirements-docs.txt b/requirements-docs.txt index 11f9d2cd2..90edd8e31 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,4 +1,5 @@ Sphinx==1.8.6 Jinja2==2.11.3 MarkupSafe==0.23 +alabaster<0.7.14 -e . diff --git a/requirements-test.txt b/requirements-test.txt index 85d9f6ba3..b542c1af7 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,4 +2,6 @@ behave>=1.2.3 pyparsing>=2.0.1 pytest>=2.5 +pytest-coverage +pytest-xdist ruff diff --git a/src/docx/__init__.py b/src/docx/__init__.py index b214045d1..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.0" +__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/api.py b/src/docx/api.py index a17c1dad4..aea876458 100644 --- a/src/docx/api.py +++ b/src/docx/api.py @@ -6,13 +6,17 @@ from __future__ import annotations import os -from typing import IO +from typing import IO, TYPE_CHECKING, cast from docx.opc.constants import CONTENT_TYPE as CT from docx.package import Package +if TYPE_CHECKING: + from docx.document import Document as DocumentObject + from docx.parts.document import DocumentPart -def Document(docx: str | IO[bytes] | None = None): + +def Document(docx: str | IO[bytes] | None = None) -> DocumentObject: """Return a |Document| object loaded from `docx`, where `docx` can be either a path to a ``.docx`` file (a string) or a file-like object. @@ -20,7 +24,7 @@ def Document(docx: str | IO[bytes] | None = None): loaded. """ docx = _default_docx_path() if docx is None else docx - document_part = Package.open(docx).main_document_part + document_part = cast("DocumentPart", Package.open(docx).main_document_part) if document_part.content_type != CT.WML_DOCUMENT_MAIN: tmpl = "file '%s' is not a Word file, content type is '%s'" raise ValueError(tmpl % (docx, document_part.content_type)) diff --git a/src/docx/blkcntnr.py b/src/docx/blkcntnr.py index 1327e6d08..82c7ef727 100644 --- a/src/docx/blkcntnr.py +++ b/src/docx/blkcntnr.py @@ -18,7 +18,8 @@ from docx.text.paragraph import Paragraph if TYPE_CHECKING: - from docx import types as t + 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): @@ -41,9 +42,7 @@ def __init__(self, element: BlockItemElement, parent: t.ProvidesStoryPart): super(BlockItemContainer, self).__init__(parent) self._element = element - def add_paragraph( - self, text: str = "", style: str | ParagraphStyle | None = None - ) -> Paragraph: + def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph: """Return paragraph newly added to the end of the content in this container. The paragraph has `text` in a single run if present, and is given paragraph @@ -69,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]: @@ -77,11 +76,7 @@ def iter_inner_content(self) -> Iterator[Paragraph | Table]: from docx.table import Table for element in self._element.inner_content_elements: - yield ( - Paragraph(element, self) - if isinstance(element, CT_P) - else Table(element, self) - ) + yield (Paragraph(element, self) if isinstance(element, CT_P) else Table(element, self)) @property def paragraphs(self): diff --git a/src/docx/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 4deb8aa8e..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: - from docx import types as t + 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. @@ -56,9 +106,7 @@ def add_page_break(self): paragraph.add_run().add_break(WD_BREAK.PAGE) return paragraph - def add_paragraph( - self, text: str = "", style: str | ParagraphStyle | None = None - ) -> Paragraph: + def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph: """Return paragraph newly added to the end of the document. The paragraph is populated with `text` and having paragraph style `style`. @@ -109,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.""" @@ -180,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: @@ -200,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 03c9c5ab8..00d1f51bb 100644 --- a/src/docx/drawing/__init__.py +++ b/src/docx/drawing/__init__.py @@ -2,10 +2,15 @@ from __future__ import annotations -from docx import types as t +from typing import TYPE_CHECKING + from docx.oxml.drawing import CT_Drawing from docx.shared import Parented +if TYPE_CHECKING: + import docx.types as t + from docx.image.image import Image + class Drawing(Parented): """Container for a DrawingML object.""" @@ -14,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/__init__.py b/src/docx/enum/__init__.py index bfab52d36..e69de29bb 100644 --- a/src/docx/enum/__init__.py +++ b/src/docx/enum/__init__.py @@ -1,11 +0,0 @@ -"""Enumerations used in python-docx.""" - - -class Enumeration: - @classmethod - def from_xml(cls, xml_val): - return cls._xml_to_idx[xml_val] - - @classmethod - def to_xml(cls, enum_val): - return cls._idx_to_xml[enum_val] diff --git a/src/docx/enum/base.py b/src/docx/enum/base.py index e37e74299..66e989757 100644 --- a/src/docx/enum/base.py +++ b/src/docx/enum/base.py @@ -4,9 +4,10 @@ import enum import textwrap -from typing import Any, Dict, Type, TypeVar +from typing import TYPE_CHECKING, Any, Dict, Type, TypeVar -from typing_extensions import Self +if TYPE_CHECKING: + from typing_extensions import Self _T = TypeVar("_T", bound="BaseXmlEnum") @@ -36,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 @@ -69,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 # pyright: ignore[reportGeneralTypeIssues] + 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: @@ -129,9 +134,7 @@ def _member_defs(self): """A single string containing the aggregated member definitions section of the documentation page.""" members = self._clsdict["__members__"] - member_defs = [ - self._member_def(member) for member in members if member.name is not None - ] + member_defs = [self._member_def(member) for member in members if member.name is not None] return "\n".join(member_defs) @property diff --git a/src/docx/image/__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 945432872..e5e7f8a13 100644 --- a/src/docx/image/image.py +++ b/src/docx/image/image.py @@ -11,8 +11,6 @@ import os from typing import IO, Tuple -from typing_extensions import Self - from docx.image.exceptions import UnrecognizedImageError from docx.shared import Emu, Inches, Length, lazyproperty @@ -28,14 +26,14 @@ def __init__(self, blob: bytes, filename: str, image_header: BaseImageHeader): self._image_header = image_header @classmethod - def from_blob(cls, blob: bytes) -> Self: + def from_blob(cls, blob: bytes) -> Image: """Return a new |Image| subclass instance parsed from the image binary contained in `blob`.""" stream = io.BytesIO(blob) return cls._from_stream(stream, blob) @classmethod - def from_file(cls, image_descriptor): + def from_file(cls, image_descriptor: str | IO[bytes]): """Return a new |Image| subclass instance loaded from the image file identified by `image_descriptor`, a path or file-like object.""" if isinstance(image_descriptor, str): @@ -57,7 +55,7 @@ def blob(self): return self._blob @property - def content_type(self): + def content_type(self) -> str: """MIME content type for this image, e.g. ``'image/jpeg'`` for a JPEG image.""" return self._image_header.content_type @@ -116,7 +114,7 @@ def height(self) -> Inches: return Inches(self.px_height / self.vert_dpi) def scaled_dimensions( - self, width: int | None = None, height: int | None = None + self, width: int | Length | None = None, height: int | Length | None = None ) -> Tuple[Length, Length]: """(cx, cy) pair representing scaled dimensions of this image. @@ -167,12 +165,11 @@ def _from_stream( return cls(blob, filename, image_header) -def _ImageHeaderFactory(stream): - """Return a |BaseImageHeader| subclass instance that knows how to parse the headers - of the image in `stream`.""" +def _ImageHeaderFactory(stream: IO[bytes]): + """A |BaseImageHeader| subclass instance that can parse headers of image in `stream`.""" from docx.image import SIGNATURES - def read_32(stream): + def read_32(stream: IO[bytes]): stream.seek(0) return stream.read(32) @@ -188,32 +185,27 @@ def read_32(stream): class BaseImageHeader: """Base class for image header subclasses like |Jpeg| and |Tiff|.""" - def __init__(self, px_width, px_height, horz_dpi, vert_dpi): + def __init__(self, px_width: int, px_height: int, horz_dpi: int, vert_dpi: int): self._px_width = px_width self._px_height = px_height self._horz_dpi = horz_dpi self._vert_dpi = vert_dpi @property - def content_type(self): + def content_type(self) -> str: """Abstract property definition, must be implemented by all subclasses.""" - msg = ( - "content_type property must be implemented by all subclasses of " - "BaseImageHeader" - ) + msg = "content_type property must be implemented by all subclasses of BaseImageHeader" raise NotImplementedError(msg) @property - def default_ext(self): + def default_ext(self) -> str: """Default filename extension for images of this type. An abstract property definition, must be implemented by all subclasses. """ - msg = ( - "default_ext property must be implemented by all subclasses of " - "BaseImageHeader" + raise NotImplementedError( + "default_ext property must be implemented by all subclasses of BaseImageHeader" ) - raise NotImplementedError(msg) @property def px_width(self): diff --git a/src/docx/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/image/tiff.py b/src/docx/image/tiff.py index b84d9f10f..1194929af 100644 --- a/src/docx/image/tiff.py +++ b/src/docx/image/tiff.py @@ -98,11 +98,7 @@ def _dpi(self, resolution_tag): return 72 # resolution unit defaults to inches (2) - resolution_unit = ( - ifd_entries[TIFF_TAG.RESOLUTION_UNIT] - if TIFF_TAG.RESOLUTION_UNIT in ifd_entries - else 2 - ) + resolution_unit = ifd_entries.get(TIFF_TAG.RESOLUTION_UNIT, 2) if resolution_unit == 1: # aspect ratio only return 72 diff --git a/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 2fd9a75c8..62f0c5ab1 100644 --- a/src/docx/opc/coreprops.py +++ b/src/docx/opc/coreprops.py @@ -3,12 +3,22 @@ These are broadly-standardized attributes like author, last-modified, etc. """ +from __future__ import annotations + +import datetime as dt +from typing import TYPE_CHECKING + +from docx.oxml.coreprops import CT_CoreProperties + +if TYPE_CHECKING: + from docx.oxml.coreprops import CT_CoreProperties + class CoreProperties: """Corresponds to part named ``/docProps/core.xml``, containing the core document properties for this document package.""" - def __init__(self, element): + def __init__(self, element: CT_CoreProperties): self._element = element @property @@ -16,7 +26,7 @@ def author(self): return self._element.author_text @author.setter - def author(self, value): + def author(self, value: str): self._element.author_text = value @property @@ -24,7 +34,7 @@ def category(self): return self._element.category_text @category.setter - def category(self, value): + def category(self, value: str): self._element.category_text = value @property @@ -32,7 +42,7 @@ def comments(self): return self._element.comments_text @comments.setter - def comments(self, value): + def comments(self, value: str): self._element.comments_text = value @property @@ -40,7 +50,7 @@ def content_status(self): return self._element.contentStatus_text @content_status.setter - def content_status(self, value): + def content_status(self, value: str): self._element.contentStatus_text = value @property @@ -48,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 @@ -56,7 +66,7 @@ def identifier(self): return self._element.identifier_text @identifier.setter - def identifier(self, value): + def identifier(self, value: str): self._element.identifier_text = value @property @@ -64,7 +74,7 @@ def keywords(self): return self._element.keywords_text @keywords.setter - def keywords(self, value): + def keywords(self, value: str): self._element.keywords_text = value @property @@ -72,7 +82,7 @@ def language(self): return self._element.language_text @language.setter - def language(self, value): + def language(self, value: str): self._element.language_text = value @property @@ -80,7 +90,7 @@ def last_modified_by(self): return self._element.lastModifiedBy_text @last_modified_by.setter - def last_modified_by(self, value): + def last_modified_by(self, value: str): self._element.lastModifiedBy_text = value @property @@ -88,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 @@ -96,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 @@ -104,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 @@ -112,7 +122,7 @@ def subject(self): return self._element.subject_text @subject.setter - def subject(self, value): + def subject(self, value: str): self._element.subject_text = value @property @@ -120,7 +130,7 @@ def title(self): return self._element.title_text @title.setter - def title(self, value): + def title(self, value: str): self._element.title_text = value @property @@ -128,5 +138,5 @@ def version(self): return self._element.version_text @version.setter - def version(self, value): + def version(self, value: str): self._element.version_text = value diff --git a/src/docx/opc/oxml.py b/src/docx/opc/oxml.py index 570dcf413..7d3c489d6 100644 --- a/src/docx/opc/oxml.py +++ b/src/docx/opc/oxml.py @@ -1,3 +1,5 @@ +# pyright: reportPrivateUsage=false + """Temporary stand-in for main oxml module. This module came across with the PackageReader transplant. Probably much will get @@ -5,6 +7,10 @@ deleted or only hold the package related custom element classes. """ +from __future__ import annotations + +from typing import cast + from lxml import etree from docx.opc.constants import NAMESPACE as NS @@ -27,12 +33,12 @@ # =========================================================================== -def parse_xml(text: str) -> etree._Element: # pyright: ignore[reportPrivateUsage] +def parse_xml(text: str) -> etree._Element: """`etree.fromstring()` replacement that uses oxml parser.""" return etree.fromstring(text, oxml_parser) -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. @@ -44,7 +50,7 @@ def qn(tag): return "{%s}%s" % (uri, tagroot) -def serialize_part_xml(part_elm): +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 @@ -53,7 +59,7 @@ def serialize_part_xml(part_elm): 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. @@ -71,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. @@ -80,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): @@ -95,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) @@ -117,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) @@ -132,11 +138,10 @@ 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, reltype, target, target_mode=RTM.INTERNAL): + def new(rId: str, reltype: str, target: str, target_mode: str = RTM.INTERNAL): """Return a new ```` element.""" xml = '' % nsmap["pr"] relationship = parse_xml(xml) @@ -176,7 +181,7 @@ def target_mode(self): class CT_Relationships(BaseOxmlElement): """```` element, the root element in a .rels file.""" - def add_rel(self, rId, reltype, target, is_external=False): + def add_rel(self, rId: str, reltype: str, target: str, is_external: bool = False): """Add a child ```` element with attributes set according to parameter values.""" target_mode = RTM.EXTERNAL if is_external else RTM.INTERNAL @@ -184,11 +189,10 @@ def add_rel(self, rId, reltype, target, is_external=False): self.append(relationship) @staticmethod - def new(): + def new() -> CT_Relationships: """Return a new ```` element.""" xml = '' % nsmap["pr"] - relationships = parse_xml(xml) - return relationships + return cast(CT_Relationships, parse_xml(xml)) @property def Relationship_lst(self): diff --git a/src/docx/opc/package.py b/src/docx/opc/package.py index b5bdc0e7c..3c1cdca22 100644 --- a/src/docx/opc/package.py +++ b/src/docx/opc/package.py @@ -1,5 +1,9 @@ """Objects that implement reading and writing OPC packages.""" +from __future__ import annotations + +from typing import IO, TYPE_CHECKING, Iterator, cast + from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.packuri import PACKAGE_URI, PackURI from docx.opc.part import PartFactory @@ -7,7 +11,14 @@ from docx.opc.pkgreader import PackageReader from docx.opc.pkgwriter import PackageWriter from docx.opc.rel import Relationships -from docx.opc.shared import lazyproperty +from docx.shared import lazyproperty + +if TYPE_CHECKING: + from 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] class OpcPackage: @@ -17,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. @@ -30,16 +38,18 @@ def after_unmarshal(self): pass @property - def core_properties(self): + def core_properties(self) -> CoreProperties: """|CoreProperties| object providing read/write access to the Dublin Core properties for this document.""" return self._core_properties_part.core_properties - def iter_rels(self): + def iter_rels(self) -> Iterator[_Relationship]: """Generate exactly one reference to each relationship in the package by performing a depth-first traversal of the rels graph.""" - def walk_rels(source, visited=None): + def walk_rels( + source: OpcPackage | Part, visited: list[Part] | None = None + ) -> Iterator[_Relationship]: visited = [] if visited is None else visited for rel in source.rels.values(): yield rel @@ -56,7 +66,7 @@ def walk_rels(source, visited=None): for rel in walk_rels(self): yield rel - def iter_parts(self): + def iter_parts(self) -> Iterator[Part]: """Generate exactly one reference to each of the parts in the package by performing a depth-first traversal of the rels graph.""" @@ -76,7 +86,7 @@ def walk_parts(source, visited=[]): for part in walk_parts(self): yield part - def load_rel(self, reltype, target, rId, is_external=False): + def load_rel(self, reltype: str, target: Part | str, rId: str, is_external: bool = False): """Return newly added |_Relationship| instance of `reltype` between this part and `target` with key `rId`. @@ -96,7 +106,7 @@ def main_document_part(self): """ return self.part_related_by(RT.OFFICE_DOCUMENT) - def next_partname(self, template): + def next_partname(self, template: str) -> PackURI: """Return a |PackURI| instance representing partname matching `template`. The returned part-name has the next available numeric suffix to distinguish it @@ -111,14 +121,14 @@ def next_partname(self, template): return PackURI(candidate_partname) @classmethod - def open(cls, pkg_file): + 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() Unmarshaller.unmarshal(pkg_reader, package, PartFactory) return package - def part_related_by(self, reltype): + def part_related_by(self, reltype: str) -> Part: """Return part to which this package has a relationship of `reltype`. Raises |KeyError| if no such relationship is found and |ValueError| if more than @@ -127,13 +137,16 @@ def part_related_by(self, reltype): return self.rels.part_with_reltype(reltype) @property - def parts(self): + def parts(self) -> list[Part]: """Return a list containing a reference to each of the parts in this package.""" return list(self.iter_parts()) - def relate_to(self, part, reltype): - """Return rId key of relationship to `part`, from the existing relationship if - there is one, otherwise a newly created one.""" + def relate_to(self, part: Part, reltype: str): + """Return rId key of new or existing relationship to `part`. + + If a relationship of `reltype` to `part` already exists, its rId is returned. Otherwise a + new relationship is created and that rId is returned. + """ rel = self.rels.get_or_add(reltype, part) return rel.rId @@ -143,21 +156,23 @@ def rels(self): relationships for this package.""" return Relationships(PACKAGE_URI.baseURI) - def save(self, pkg_file): - """Save this package to `pkg_file`, where `file` can be either a path to a file - (a string) or a file-like object.""" + def save(self, pkg_file: str | IO[bytes]): + """Save this package to `pkg_file`. + + `pkg_file` can be either a file-path or a file-like object. + """ for part in self.parts: part.before_marshal() PackageWriter.write(pkg_file, self.rels, self.parts) @property - def _core_properties_part(self): + def _core_properties_part(self) -> CorePropertiesPart: """|CorePropertiesPart| object related to this package. Creates a default core properties part if one is not present (not common). """ try: - return self.part_related_by(RT.CORE_PROPERTIES) + return cast(CorePropertiesPart, self.part_related_by(RT.CORE_PROPERTIES)) except KeyError: core_properties_part = CorePropertiesPart.default(self) self.relate_to(core_properties_part, RT.CORE_PROPERTIES) @@ -190,9 +205,7 @@ def _unmarshal_parts(pkg_reader, package, part_factory): """ parts = {} for partname, content_type, reltype, blob in pkg_reader.iter_sparts(): - parts[partname] = part_factory( - partname, content_type, reltype, blob, package - ) + parts[partname] = part_factory(partname, content_type, reltype, blob, package) return parts @staticmethod @@ -202,7 +215,5 @@ def _unmarshal_relationships(pkg_reader, package, parts): in `parts`.""" for source_uri, srel in pkg_reader.iter_srels(): source = package if source_uri == "/" else parts[source_uri] - target = ( - srel.target_ref if srel.is_external else parts[srel.target_partname] - ) + target = srel.target_ref if srel.is_external else parts[srel.target_partname] source.load_rel(srel.reltype, target, srel.rId, srel.is_external) diff --git a/src/docx/opc/packuri.py b/src/docx/opc/packuri.py index fe330d89b..89437b164 100644 --- a/src/docx/opc/packuri.py +++ b/src/docx/opc/packuri.py @@ -3,35 +3,35 @@ Also some useful known pack URI strings such as PACKAGE_URI. """ +from __future__ import annotations + import posixpath import re 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. """ _filename_re = re.compile("([a-zA-Z]+)([1-9][0-9]*)?") - def __new__(cls, pack_uri_str): + def __new__(cls, pack_uri_str: str): if pack_uri_str[0] != "/": tmpl = "PackURI must begin with slash, got '%s'" raise ValueError(tmpl % pack_uri_str) return str.__new__(cls, pack_uri_str) @staticmethod - def from_rel_ref(baseURI, relative_ref): - """Return a |PackURI| instance containing the absolute pack URI formed by - translating `relative_ref` onto `baseURI`.""" + def from_rel_ref(baseURI: str, relative_ref: str) -> PackURI: + """The absolute PackURI formed by translating `relative_ref` onto `baseURI`.""" joined_uri = posixpath.join(baseURI, relative_ref) abs_uri = posixpath.abspath(joined_uri) return PackURI(abs_uri) @property - def baseURI(self): + def baseURI(self) -> str: """The base URI of this pack URI, the directory portion, roughly speaking. E.g. ``'/ppt/slides'`` for ``'/ppt/slides/slide1.xml'``. For the package pseudo- @@ -40,9 +40,8 @@ def baseURI(self): return posixpath.split(self)[0] @property - def ext(self): - """The extension portion of this pack URI, e.g. ``'xml'`` for - ``'/word/document.xml'``. + def ext(self) -> str: + """The extension portion of this pack URI, e.g. ``'xml'`` for ``'/word/document.xml'``. Note the period is not included. """ @@ -84,7 +83,7 @@ def membername(self): """ return self[1:] - def relative_ref(self, baseURI): + def relative_ref(self, baseURI: str): """Return string containing relative reference to package item from `baseURI`. E.g. PackURI('/ppt/slideLayouts/slideLayout1.xml') would return diff --git a/src/docx/opc/part.py b/src/docx/opc/part.py index a4ad3e7b2..cbb4ab556 100644 --- a/src/docx/opc/part.py +++ b/src/docx/opc/part.py @@ -1,16 +1,20 @@ +# pyright: reportImportCycles=false + """Open Packaging Convention (OPC) objects related to package parts.""" from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Dict, Type +from typing import TYPE_CHECKING, Callable, Type, cast from docx.opc.oxml import serialize_part_xml from docx.opc.packuri import PackURI from docx.opc.rel import Relationships -from docx.opc.shared import cls_method_fn, lazyproperty +from docx.opc.shared import cls_method_fn from docx.oxml.parser import parse_xml +from docx.shared import lazyproperty if TYPE_CHECKING: + from docx.oxml.xmlchemy import BaseOxmlElement from docx.package import Package @@ -23,7 +27,7 @@ class Part: def __init__( self, - partname: str, + partname: PackURI, content_type: str, blob: bytes | None = None, package: Package | None = None, @@ -55,13 +59,13 @@ def before_marshal(self): pass @property - def blob(self): + def blob(self) -> bytes: """Contents of this package part as a sequence of bytes. May be text or binary. Intended to be overridden by subclasses. Default behavior is to return load blob. """ - return self._blob + return self._blob or b"" @property def content_type(self): @@ -78,12 +82,13 @@ def drop_rel(self, rId: str): del self.rels[rId] @classmethod - def load(cls, partname: str, content_type: str, blob: bytes, package: Package): + def load(cls, partname: PackURI, content_type: str, blob: bytes, package: Package): return cls(partname, content_type, blob, package) - def load_rel(self, reltype, target, rId, is_external=False): - """Return newly added |_Relationship| instance of `reltype` between this part - and `target` with key `rId`. + def load_rel(self, reltype: str, target: Part | str, rId: str, is_external: bool = False): + """Return newly added |_Relationship| instance of `reltype`. + + The new relationship relates the `target` part to this part with key `rId`. Target mode is set to ``RTM.EXTERNAL`` if `is_external` is |True|. Intended for use during load from a serialized package, where the rId is well-known. Other @@ -103,7 +108,7 @@ def partname(self): return self._partname @partname.setter - def partname(self, partname): + def partname(self, partname: str): if not isinstance(partname, PackURI): tmpl = "partname must be instance of PackURI, got '%s'" raise TypeError(tmpl % type(partname).__name__) @@ -118,16 +123,16 @@ def part_related_by(self, reltype: str) -> Part: """ return self.rels.part_with_reltype(reltype) - def relate_to(self, target: Part, reltype: str, is_external: bool = False) -> str: + def relate_to(self, target: Part | str, reltype: str, is_external: bool = False) -> str: """Return rId key of relationship of `reltype` to `target`. The returned `rId` is from an existing relationship if there is one, otherwise a new relationship is created. """ if is_external: - return self.rels.get_or_add_ext_rel(reltype, target) + return self.rels.get_or_add_ext_rel(reltype, cast(str, target)) else: - rel = self.rels.get_or_add(reltype, target) + rel = self.rels.get_or_add(reltype, cast(Part, target)) return rel.rId @property @@ -140,18 +145,21 @@ def related_parts(self): @lazyproperty def rels(self): """|Relationships| instance holding the relationships for this part.""" - return Relationships(self._partname.baseURI) + # -- prevent breakage in `python-docx-template` by retaining legacy `._rels` attribute -- + self._rels = Relationships(self._partname.baseURI) + return self._rels - def target_ref(self, rId): + def target_ref(self, rId: str) -> str: """Return URL contained in target ref of relationship identified by `rId`.""" rel = self.rels[rId] return rel.target_ref - def _rel_ref_count(self, rId): - """Return the count of references in this part's XML to the relationship - identified by `rId`.""" - rIds = self._element.xpath("//@r:id") - return len([_rId for _rId in rIds if _rId == rId]) + def _rel_ref_count(self, rId: str) -> int: + """Return the count of references in this part to the relationship identified by `rId`. + + Only an XML part can contain references, so this is 0 for `Part`. + """ + return 0 class PartFactory: @@ -168,12 +176,12 @@ class PartFactory: """ part_class_selector: Callable[[str, str], Type[Part] | None] | None - part_type_for: Dict[str, Type[Part]] = {} + part_type_for: dict[str, Type[Part]] = {} default_part_type = Part def __new__( cls, - partname: str, + partname: PackURI, content_type: str, reltype: str, blob: bytes, @@ -203,7 +211,9 @@ class XmlPart(Part): reserializing the XML payload and managing relationships to other parts. """ - def __init__(self, partname, content_type, element, package): + def __init__( + self, partname: PackURI, content_type: str, element: BaseOxmlElement, package: Package + ): super(XmlPart, self).__init__(partname, content_type, package=package) self._element = element @@ -217,7 +227,7 @@ def element(self): return self._element @classmethod - def load(cls, partname, content_type, blob, package): + def load(cls, partname: PackURI, content_type: str, blob: bytes, package: Package): element = parse_xml(blob) return cls(partname, content_type, element, package) @@ -229,3 +239,9 @@ def part(self): That chain of delegation ends here for child objects. """ return self + + def _rel_ref_count(self, rId: str) -> int: + """Return the count of references in this part's XML to the relationship + identified by `rId`.""" + rIds = cast("list[str]", self._element.xpath("//@r:id")) + return len([_rId for _rId in rIds if _rId == rId]) diff --git a/src/docx/opc/parts/coreprops.py b/src/docx/opc/parts/coreprops.py index 6e26e1d05..fda011218 100644 --- a/src/docx/opc/parts/coreprops.py +++ b/src/docx/opc/parts/coreprops.py @@ -1,6 +1,9 @@ """Core properties part, corresponds to ``/docProps/core.xml`` part in package.""" -from datetime import datetime +from __future__ import annotations + +import datetime as dt +from typing import TYPE_CHECKING from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.coreprops import CoreProperties @@ -8,13 +11,19 @@ from docx.opc.part import XmlPart from docx.oxml.coreprops import CT_CoreProperties +if TYPE_CHECKING: + from docx.opc.package import OpcPackage + class CorePropertiesPart(XmlPart): - """Corresponds to part named ``/docProps/core.xml``, containing the core document - properties for this document package.""" + """Corresponds to part named ``/docProps/core.xml``. + + The "core" is short for "Dublin Core" and contains document metadata relatively common across + documents of all types, not just DOCX. + """ @classmethod - def default(cls, package): + def default(cls, package: OpcPackage): """Return a new |CorePropertiesPart| object initialized with default values for its base properties.""" core_properties_part = cls._new(package) @@ -22,7 +31,7 @@ def default(cls, package): core_properties.title = "Word Document" core_properties.last_modified_by = "python-docx" core_properties.revision = 1 - core_properties.modified = datetime.utcnow() + core_properties.modified = dt.datetime.now(dt.timezone.utc) return core_properties_part @property @@ -32,7 +41,7 @@ def core_properties(self): return CoreProperties(self.element) @classmethod - def _new(cls, package): + def _new(cls, package: OpcPackage) -> CorePropertiesPart: partname = PackURI("/docProps/core.xml") content_type = CT.OPC_CORE_PROPERTIES coreProperties = CT_CoreProperties.new() diff --git a/src/docx/opc/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/pkgwriter.py b/src/docx/opc/pkgwriter.py index 75af6ac75..e63516979 100644 --- a/src/docx/opc/pkgwriter.py +++ b/src/docx/opc/pkgwriter.py @@ -4,6 +4,10 @@ OpcPackage.save(). """ +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterable + from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.oxml import CT_Types, serialize_part_xml from docx.opc.packuri import CONTENT_TYPES_URI, PACKAGE_URI @@ -11,6 +15,9 @@ from docx.opc.shared import CaseInsensitiveDict from docx.opc.spec import default_content_types +if TYPE_CHECKING: + from docx.opc.part import Part + class PackageWriter: """Writes a zip-format OPC package to `pkg_file`, where `pkg_file` can be either a @@ -38,13 +45,13 @@ def _write_content_types_stream(phys_writer, parts): phys_writer.write(CONTENT_TYPES_URI, cti.blob) @staticmethod - def _write_parts(phys_writer, parts): + def _write_parts(phys_writer: PhysPkgWriter, parts: Iterable[Part]): """Write the blob of each part in `parts` to the package, along with a rels item for its relationships if and only if it has any.""" for part in parts: phys_writer.write(part.partname, part.blob) - if len(part._rels): - phys_writer.write(part.partname.rels_uri, part._rels.xml) + if len(part.rels): + phys_writer.write(part.partname.rels_uri, part.rels.xml) @staticmethod def _write_pkg_rels(phys_writer, pkg_rels): diff --git a/src/docx/opc/rel.py b/src/docx/opc/rel.py index efac5e06b..153b308d0 100644 --- a/src/docx/opc/rel.py +++ b/src/docx/opc/rel.py @@ -2,10 +2,13 @@ from __future__ import annotations -from typing import Any, Dict +from typing import TYPE_CHECKING, Any, Dict, cast from docx.opc.oxml import CT_Relationships +if TYPE_CHECKING: + from docx.opc.part import Part + class Relationships(Dict[str, "_Relationship"]): """Collection object for |_Relationship| instances, having list semantics.""" @@ -13,10 +16,10 @@ class Relationships(Dict[str, "_Relationship"]): def __init__(self, baseURI: str): super(Relationships, self).__init__() self._baseURI = baseURI - self._target_parts_by_rId: Dict[str, Any] = {} + self._target_parts_by_rId: dict[str, Any] = {} def add_relationship( - self, reltype: str, target: str | Any, rId: str, is_external: bool = False + self, reltype: str, target: Part | str, rId: str, is_external: bool = False ) -> "_Relationship": """Return a newly added |_Relationship| instance.""" rel = _Relationship(rId, reltype, target, self._baseURI, is_external) @@ -25,7 +28,7 @@ def add_relationship( self._target_parts_by_rId[rId] = target return rel - def get_or_add(self, reltype, target_part): + def get_or_add(self, reltype: str, target_part: Part) -> _Relationship: """Return relationship of `reltype` to `target_part`, newly added if not already present in collection.""" rel = self._get_matching(reltype, target_part) @@ -34,7 +37,7 @@ def get_or_add(self, reltype, target_part): rel = self.add_relationship(reltype, target_part, rId) return rel - def get_or_add_ext_rel(self, reltype, target_ref): + def get_or_add_ext_rel(self, reltype: str, target_ref: str) -> str: """Return rId of external relationship of `reltype` to `target_ref`, newly added if not already present in collection.""" rel = self._get_matching(reltype, target_ref, is_external=True) @@ -43,7 +46,7 @@ def get_or_add_ext_rel(self, reltype, target_ref): rel = self.add_relationship(reltype, target_ref, rId, is_external=True) return rel.rId - def part_with_reltype(self, reltype): + def part_with_reltype(self, reltype: str) -> Part: """Return target part of rel with matching `reltype`, raising |KeyError| if not found and |ValueError| if more than one matching relationship is found.""" rel = self._get_rel_of_type(reltype) @@ -56,7 +59,7 @@ def related_parts(self): return self._target_parts_by_rId @property - def xml(self): + def xml(self) -> str: """Serialize this relationship collection into XML suitable for storage as a .rels file in an OPC package.""" rels_elm = CT_Relationships.new() @@ -64,26 +67,26 @@ def xml(self): rels_elm.add_rel(rel.rId, rel.reltype, rel.target_ref, rel.is_external) return rels_elm.xml - def _get_matching(self, reltype, target, is_external=False): + def _get_matching( + self, reltype: str, target: Part | str, is_external: bool = False + ) -> _Relationship | None: """Return relationship of matching `reltype`, `target`, and `is_external` from collection, or None if not found.""" - def matches(rel, reltype, target, is_external): + def matches(rel: _Relationship, reltype: str, target: Part | str, is_external: bool): if rel.reltype != reltype: return False if rel.is_external != is_external: 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): return rel return None - def _get_rel_of_type(self, reltype): + def _get_rel_of_type(self, reltype: str): """Return single relationship of type `reltype` from the collection. Raises |KeyError| if no matching relationship is found. Raises |ValueError| if @@ -99,7 +102,7 @@ def _get_rel_of_type(self, reltype): return matching[0] @property - def _next_rId(self): + def _next_rId(self) -> str: # pyright: ignore[reportReturnType] """Next available rId in collection, starting from 'rId1' and making use of any gaps in numbering, e.g. 'rId2' for rIds ['rId1', 'rId3'].""" for n in range(1, len(self) + 2): @@ -111,7 +114,9 @@ def _next_rId(self): class _Relationship: """Value object for relationship to part.""" - def __init__(self, rId: str, reltype, target, baseURI, external=False): + def __init__( + self, rId: str, reltype: str, target: Part | str, baseURI: str, external: bool = False + ): super(_Relationship, self).__init__() self._rId = rId self._reltype = reltype @@ -120,29 +125,29 @@ def __init__(self, rId: str, reltype, target, baseURI, external=False): self._is_external = bool(external) @property - def is_external(self): + def is_external(self) -> bool: return self._is_external @property - def reltype(self): + def reltype(self) -> str: return self._reltype @property - def rId(self): + def rId(self) -> str: return self._rId @property - def target_part(self): + def target_part(self) -> Part: if self._is_external: raise ValueError( - "target_part property on _Relationship is undef" - "ined when target mode is External" + "target_part property on _Relationship is undefined when target mode is External" ) - return self._target + return cast("Part", self._target) @property def target_ref(self) -> str: if self._is_external: - return self._target + return cast(str, self._target) else: - return self._target.partname.relative_ref(self._baseURI) + target = cast("Part", self._target) + return target.partname.relative_ref(self._baseURI) diff --git a/src/docx/opc/shared.py b/src/docx/opc/shared.py index 1862f66db..9d4c0a6d3 100644 --- a/src/docx/opc/shared.py +++ b/src/docx/opc/shared.py @@ -1,7 +1,13 @@ """Objects shared by opc modules.""" +from __future__ import annotations -class CaseInsensitiveDict(dict): +from typing import Any, Dict, TypeVar + +_T = TypeVar("_T") + + +class CaseInsensitiveDict(Dict[str, Any]): """Mapping type that behaves like dict except that it matches without respect to the case of the key. @@ -23,23 +29,3 @@ def __setitem__(self, key, value): def cls_method_fn(cls: type, method_name: str): """Return method of `cls` having `method_name`.""" return getattr(cls, method_name) - - -def lazyproperty(f): - """@lazyprop decorator. - - Decorated method will be called only on first access to calculate a cached property - value. After that, the cached value is returned. - """ - cache_attr_name = "_%s" % f.__name__ # like '_foobar' for prop 'foobar' - docstring = f.__doc__ - - def get_prop_value(obj): - try: - return getattr(obj, cache_attr_name) - except AttributeError: - value = f(obj) - setattr(obj, cache_attr_name, value) - return value - - return property(get_prop_value, doc=docstring) diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index 621ef279a..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, @@ -149,6 +156,7 @@ CT_TblGridCol, CT_TblLayoutType, CT_TblPr, + CT_TblPrEx, CT_TblWidth, CT_Tc, CT_TcPr, @@ -158,12 +166,15 @@ ) register_element_cls("w:bidiVisual", CT_OnOff) +register_element_cls("w:gridAfter", CT_DecimalNumber) +register_element_cls("w:gridBefore", CT_DecimalNumber) register_element_cls("w:gridCol", CT_TblGridCol) register_element_cls("w:gridSpan", CT_DecimalNumber) register_element_cls("w:tbl", CT_Tbl) register_element_cls("w:tblGrid", CT_TblGrid) register_element_cls("w:tblLayout", CT_TblLayoutType) register_element_cls("w:tblPr", CT_TblPr) +register_element_cls("w:tblPrEx", CT_TblPrEx) register_element_cls("w:tblStyle", CT_String) register_element_cls("w:tc", CT_Tc) register_element_cls("w:tcPr", CT_TcPr) @@ -174,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, @@ -213,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, @@ -230,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 2cafcd960..fcff0c7ba 100644 --- a/src/docx/oxml/coreprops.py +++ b/src/docx/oxml/coreprops.py @@ -1,13 +1,18 @@ """Custom element classes for core properties-related XML elements.""" +from __future__ import annotations + +import datetime as dt import re -from datetime import datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any, Callable, cast from docx.oxml.ns import nsdecls, qn from docx.oxml.parser import parse_xml from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrOne +if TYPE_CHECKING: + from lxml.etree import _Element as etree_Element # pyright: ignore[reportPrivateUsage] + class CT_CoreProperties(BaseOxmlElement): """`` element, the root element of the Core Properties part. @@ -17,6 +22,8 @@ class CT_CoreProperties(BaseOxmlElement): present in the XML. String elements are limited in length to 255 unicode characters. """ + get_or_add_revision: Callable[[], etree_Element] + category = ZeroOrOne("cp:category", successors=()) contentStatus = ZeroOrOne("cp:contentStatus", successors=()) created = ZeroOrOne("dcterms:created", successors=()) @@ -28,7 +35,9 @@ class CT_CoreProperties(BaseOxmlElement): lastModifiedBy = ZeroOrOne("cp:lastModifiedBy", successors=()) lastPrinted = ZeroOrOne("cp:lastPrinted", successors=()) modified = ZeroOrOne("dcterms:modified", successors=()) - revision = ZeroOrOne("cp:revision", successors=()) + revision: etree_Element | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "cp:revision", successors=() + ) subject = ZeroOrOne("dc:subject", successors=()) title = ZeroOrOne("dc:title", successors=()) version = ZeroOrOne("cp:version", successors=()) @@ -36,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") @@ -68,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 @@ -76,68 +85,68 @@ 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 - def created_datetime(self, value): + 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 - def identifier_text(self, value): + 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 - def keywords_text(self, value): + 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 - def language_text(self, value): + 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 - def lastModifiedBy_text(self, value): + 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 - def lastPrinted_datetime(self, value): + def lastPrinted_datetime(self, value: dt.datetime): self._set_element_datetime("lastPrinted", value) @property - def modified_datetime(self): + def modified_datetime(self) -> dt.datetime | None: return self._datetime_of_element("modified") @modified_datetime.setter - def modified_datetime(self, value): + def modified_datetime(self, value: dt.datetime): self._set_element_datetime("modified", value) @property - def revision_number(self): + def revision_number(self) -> int: """Integer value of revision property.""" revision = self.revision if revision is None: return 0 - revision_str = revision.text + revision_str = str(revision.text) try: revision = int(revision_str) except ValueError: @@ -149,39 +158,39 @@ def revision_number(self): return revision @revision_number.setter - def revision_number(self, value): + def revision_number(self, value: int): """Set revision property to string value of integer `value`.""" - if not isinstance(value, int) or value < 1: + if not isinstance(value, int) or value < 1: # pyright: ignore[reportUnnecessaryIsInstance] tmpl = "revision property requires positive int, got '%s'" raise ValueError(tmpl % value) revision = self.get_or_add_revision() revision.text = str(value) @property - def subject_text(self): + def subject_text(self) -> str: return self._text_of_element("subject") @subject_text.setter - def subject_text(self, value): + def subject_text(self, value: str): self._set_element_text("subject", value) @property - def title_text(self): + def title_text(self) -> str: return self._text_of_element("title") @title_text.setter - def title_text(self, value): + def title_text(self, value: str): self._set_element_text("title", value) @property - def version_text(self): + def version_text(self) -> str: return self._text_of_element("version") @version_text.setter - def version_text(self, value): + def version_text(self, value: str): self._set_element_text("version", value) - def _datetime_of_element(self, property_name): + def _datetime_of_element(self, property_name: str) -> dt.datetime | None: element = getattr(self, property_name) if element is None: return None @@ -192,7 +201,7 @@ def _datetime_of_element(self, property_name): # invalid datetime strings are ignored return None - def _get_or_add(self, prop_name): + def _get_or_add(self, prop_name: str) -> BaseOxmlElement: """Return element returned by "get_or_add_" method for `prop_name`.""" get_or_add_method_name = "get_or_add_%s" % prop_name get_or_add_method = getattr(self, get_or_add_method_name) @@ -200,8 +209,8 @@ def _get_or_add(self, prop_name): return element @classmethod - def _offset_dt(cls, dt, offset_str): - """A |datetime| instance offset from `dt` by timezone offset in `offset_str`. + def _offset_dt(cls, dt_: dt.datetime, offset_str: str) -> dt.datetime: + """A |datetime| instance offset from `dt_` by timezone offset in `offset_str`. `offset_str` is like `"-07:00"`. """ @@ -212,13 +221,13 @@ def _offset_dt(cls, dt, offset_str): sign_factor = -1 if sign == "+" else 1 hours = int(hours_str) * sign_factor minutes = int(minutes_str) * sign_factor - td = timedelta(hours=hours, minutes=minutes) - return dt + td + td = dt.timedelta(hours=hours, minutes=minutes) + return dt_ + td _offset_pattern = re.compile(r"([+-])(\d\d):(\d\d)") @classmethod - def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): + def _parse_W3CDTF_to_datetime(cls, w3cdtf_str: str) -> dt.datetime: # valid W3CDTF date cases: # yyyy e.g. "2003" # yyyy-mm e.g. "2003-12" @@ -235,22 +244,22 @@ def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): # "-07:30", so we have to do it ourselves parseable_part = w3cdtf_str[:19] offset_str = w3cdtf_str[19:] - dt = None + dt_ = None for tmpl in templates: try: - dt = datetime.strptime(parseable_part, tmpl) + dt_ = dt.datetime.strptime(parseable_part, tmpl) except ValueError: continue - if dt is None: + if dt_ is None: tmpl = "could not parse W3CDTF datetime string '%s'" raise ValueError(tmpl % w3cdtf_str) if len(offset_str) == 6: - return cls._offset_dt(dt, offset_str) - return dt + dt_ = cls._offset_dt(dt_, offset_str) + return dt_.replace(tzinfo=dt.timezone.utc) - def _set_element_datetime(self, prop_name, value): + 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, datetime): + if not isinstance(value, dt.datetime): # pyright: ignore[reportUnnecessaryIsInstance] tmpl = "property requires object, got %s" raise ValueError(tmpl % type(value)) element = self._get_or_add(prop_name) diff --git a/src/docx/oxml/document.py b/src/docx/oxml/document.py index cc27f5aa9..36819ef75 100644 --- a/src/docx/oxml/document.py +++ b/src/docx/oxml/document.py @@ -15,7 +15,7 @@ class CT_Document(BaseOxmlElement): """```` element, the root element of a document.xml file.""" - body = ZeroOrOne("w:body") + body: CT_Body = ZeroOrOne("w:body") # pyright: ignore[reportAssignmentType] @property def sectPr_lst(self) -> List[CT_SectPr]: @@ -44,7 +44,7 @@ class CT_Body(BaseOxmlElement): p = ZeroOrMore("w:p", successors=("w:sectPr",)) tbl = ZeroOrMore("w:tbl", successors=("w:sectPr",)) - sectPr: CT_SectPr | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + sectPr: CT_SectPr | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:sectPr", successors=() ) diff --git a/src/docx/oxml/ns.py b/src/docx/oxml/ns.py index 3238864e9..ce03940f7 100644 --- a/src/docx/oxml/ns.py +++ b/src/docx/oxml/ns.py @@ -1,8 +1,8 @@ """Namespace-related objects.""" -from typing import Any, Dict +from __future__ import annotations -from typing_extensions import Self +from typing import 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): @@ -41,7 +41,7 @@ def clark_name(self) -> str: return "{%s}%s" % (self._ns_uri, self._local_part) @classmethod - def from_clark_name(cls, clark_name: str) -> Self: + def from_clark_name(cls, clark_name: str) -> NamespacePrefixedTag: nsuri, local_name = clark_name[1:].split("}") nstag = "%s:%s" % (pfxmap[nsuri], local_name) return cls(nstag) diff --git a/src/docx/oxml/parser.py b/src/docx/oxml/parser.py index 7e6a0fb49..e16ba30ba 100644 --- a/src/docx/oxml/parser.py +++ b/src/docx/oxml/parser.py @@ -1,3 +1,5 @@ +# pyright: reportImportCycles=false + """XML parser for python-docx.""" from __future__ import annotations @@ -18,7 +20,7 @@ oxml_parser.set_element_class_lookup(element_class_lookup) -def parse_xml(xml: str) -> "BaseOxmlElement": +def parse_xml(xml: str | bytes) -> "BaseOxmlElement": """Root lxml element obtained by parsing XML character string `xml`. The custom parser is used, so custom element classes are produced for elements in @@ -43,7 +45,7 @@ def OxmlElement( nsptag_str: str, attrs: Dict[str, str] | None = None, nsdecls: Dict[str, str] | None = None, -) -> BaseOxmlElement: +) -> BaseOxmlElement | etree._Element: # pyright: ignore[reportPrivateUsage] """Return a 'loose' lxml element having the tag specified by `nsptag_str`. The tag in `nsptag_str` must contain the standard namespace prefix, e.g. `a:tbl`. diff --git a/src/docx/oxml/section.py b/src/docx/oxml/section.py index a4090898a..71072e2df 100644 --- a/src/docx/oxml/section.py +++ b/src/docx/oxml/section.py @@ -51,38 +51,34 @@ def inner_content_elements(self) -> List[CT_P | CT_Tbl]: class CT_HdrFtrRef(BaseOxmlElement): """`w:headerReference` and `w:footerReference` elements.""" - type_: WD_HEADER_FOOTER = ( - RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:type", WD_HEADER_FOOTER - ) - ) - rId: str = RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "r:id", XsdString + type_: WD_HEADER_FOOTER = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:type", WD_HEADER_FOOTER ) + rId: str = RequiredAttribute("r:id", XsdString) # pyright: ignore[reportAssignmentType] class CT_PageMar(BaseOxmlElement): """```` element, defining page margins.""" - top: Length | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + top: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:top", ST_SignedTwipsMeasure ) - right: Length | None = OptionalAttribute( # pyright: ignore + right: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:right", ST_TwipsMeasure ) - bottom: Length | None = OptionalAttribute( # pyright: ignore + bottom: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:bottom", ST_SignedTwipsMeasure ) - left: Length | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + left: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:left", ST_TwipsMeasure ) - header: Length | None = OptionalAttribute( # pyright: ignore + header: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:header", ST_TwipsMeasure ) - footer: Length | None = OptionalAttribute( # pyright: ignore + footer: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:footer", ST_TwipsMeasure ) - gutter: Length | None = OptionalAttribute( # pyright: ignore + gutter: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:gutter", ST_TwipsMeasure ) @@ -90,16 +86,14 @@ class CT_PageMar(BaseOxmlElement): class CT_PageSz(BaseOxmlElement): """```` element, defining page dimensions and orientation.""" - w: Length | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + w: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:w", ST_TwipsMeasure ) - h: Length | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + h: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:h", ST_TwipsMeasure ) - orient: WD_ORIENTATION = ( - OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:orient", WD_ORIENTATION, default=WD_ORIENTATION.PORTRAIT - ) + orient: WD_ORIENTATION = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:orient", WD_ORIENTATION, default=WD_ORIENTATION.PORTRAIT ) @@ -139,16 +133,16 @@ class CT_SectPr(BaseOxmlElement): ) headerReference = ZeroOrMore("w:headerReference", successors=_tag_seq) footerReference = ZeroOrMore("w:footerReference", successors=_tag_seq) - type: CT_SectType | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + type: CT_SectType | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:type", successors=_tag_seq[3:] ) - pgSz: CT_PageSz | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + pgSz: CT_PageSz | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:pgSz", successors=_tag_seq[4:] ) - pgMar: CT_PageMar | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + pgMar: CT_PageMar | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:pgMar", successors=_tag_seq[5:] ) - titlePg: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + titlePg: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:titlePg", successors=_tag_seq[14:] ) del _tag_seq @@ -187,9 +181,7 @@ def bottom_margin(self) -> Length | None: @bottom_margin.setter def bottom_margin(self, value: int | Length | None): pgMar = self.get_or_add_pgMar() - pgMar.bottom = ( - value if value is None or isinstance(value, Length) else Length(value) - ) + pgMar.bottom = value if value is None or isinstance(value, Length) else Length(value) def clone(self) -> CT_SectPr: """Return an exact duplicate of this ```` element tree suitable for @@ -217,9 +209,7 @@ def footer(self) -> Length | None: @footer.setter def footer(self, value: int | Length | None): pgMar = self.get_or_add_pgMar() - pgMar.footer = ( - value if value is None or isinstance(value, Length) else Length(value) - ) + pgMar.footer = value if value is None or isinstance(value, Length) else Length(value) def get_footerReference(self, type_: WD_HEADER_FOOTER) -> CT_HdrFtrRef | None: """Return footerReference element of `type_` or None if not present.""" @@ -251,9 +241,7 @@ def gutter(self) -> Length | None: @gutter.setter def gutter(self, value: int | Length | None): pgMar = self.get_or_add_pgMar() - pgMar.gutter = ( - value if value is None or isinstance(value, Length) else Length(value) - ) + pgMar.gutter = value if value is None or isinstance(value, Length) else Length(value) @property def header(self) -> Length | None: @@ -270,9 +258,7 @@ def header(self) -> Length | None: @header.setter def header(self, value: int | Length | None): pgMar = self.get_or_add_pgMar() - pgMar.header = ( - value if value is None or isinstance(value, Length) else Length(value) - ) + pgMar.header = value if value is None or isinstance(value, Length) else Length(value) def iter_inner_content(self) -> Iterator[CT_P | CT_Tbl]: """Generate all `w:p` and `w:tbl` elements in this section. @@ -295,9 +281,7 @@ def left_margin(self) -> Length | None: @left_margin.setter def left_margin(self, value: int | Length | None): pgMar = self.get_or_add_pgMar() - pgMar.left = ( - value if value is None or isinstance(value, Length) else Length(value) - ) + pgMar.left = value if value is None or isinstance(value, Length) else Length(value) @property def orientation(self) -> WD_ORIENTATION: @@ -442,8 +426,8 @@ def top_margin(self, value: Length | None): class CT_SectType(BaseOxmlElement): """```` element, defining the section start type.""" - val: WD_SECTION_START | None = ( # pyright: ignore[reportGeneralTypeIssues] - OptionalAttribute("w:val", WD_SECTION_START) + val: WD_SECTION_START | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_SECTION_START ) diff --git a/src/docx/oxml/settings.py b/src/docx/oxml/settings.py index fd39fbd99..d5bb41a6d 100644 --- a/src/docx/oxml/settings.py +++ b/src/docx/oxml/settings.py @@ -1,11 +1,21 @@ """Custom element classes related to document settings.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable + from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrOne +if TYPE_CHECKING: + from docx.oxml.shared import CT_OnOff + class CT_Settings(BaseOxmlElement): """`w:settings` element, root element for the settings part.""" + get_or_add_evenAndOddHeaders: Callable[[], CT_OnOff] + _remove_evenAndOddHeaders: Callable[[], None] + _tag_seq = ( "w:writeProtection", "w:view", @@ -106,11 +116,13 @@ class CT_Settings(BaseOxmlElement): "w:decimalSymbol", "w:listSeparator", ) - evenAndOddHeaders = ZeroOrOne("w:evenAndOddHeaders", successors=_tag_seq[48:]) + evenAndOddHeaders: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:evenAndOddHeaders", successors=_tag_seq[48:] + ) del _tag_seq @property - def evenAndOddHeaders_val(self): + def evenAndOddHeaders_val(self) -> bool: """Value of `w:evenAndOddHeaders/@w:val` or |None| if not present.""" evenAndOddHeaders = self.evenAndOddHeaders if evenAndOddHeaders is None: @@ -118,8 +130,9 @@ def evenAndOddHeaders_val(self): return evenAndOddHeaders.val @evenAndOddHeaders_val.setter - def evenAndOddHeaders_val(self, value): - if value in [None, False]: + def evenAndOddHeaders_val(self, value: bool | None): + if value is None or value is False: self._remove_evenAndOddHeaders() - else: - self.get_or_add_evenAndOddHeaders().val = value + return + + self.get_or_add_evenAndOddHeaders().val = value diff --git a/src/docx/oxml/shape.py b/src/docx/oxml/shape.py index 05c96697a..c6df8e7b8 100644 --- a/src/docx/oxml/shape.py +++ b/src/docx/oxml/shape.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from docx.oxml.ns import nsdecls from docx.oxml.parser import parse_xml @@ -34,48 +34,58 @@ class CT_Blip(BaseOxmlElement): """```` element, specifies image source and adjustments such as alpha and tint.""" - embed = OptionalAttribute("r:embed", ST_RelationshipId) - link = OptionalAttribute("r:link", ST_RelationshipId) + embed: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "r:embed", ST_RelationshipId + ) + link: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "r:link", ST_RelationshipId + ) class CT_BlipFillProperties(BaseOxmlElement): """```` element, specifies picture properties.""" - blip = ZeroOrOne("a:blip", successors=("a:srcRect", "a:tile", "a:stretch")) + blip: CT_Blip = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:blip", successors=("a:srcRect", "a:tile", "a:stretch") + ) class CT_GraphicalObject(BaseOxmlElement): """```` element, container for a DrawingML object.""" - graphicData = OneAndOnlyOne("a:graphicData") + graphicData: CT_GraphicalObjectData = OneAndOnlyOne( # pyright: ignore[reportAssignmentType] + "a:graphicData" + ) class CT_GraphicalObjectData(BaseOxmlElement): """```` element, container for the XML of a DrawingML object.""" - pic = ZeroOrOne("pic:pic") - uri = RequiredAttribute("uri", XsdToken) + pic: CT_Picture = ZeroOrOne("pic:pic") # pyright: ignore[reportAssignmentType] + uri: str = RequiredAttribute("uri", XsdToken) # pyright: ignore[reportAssignmentType] class CT_Inline(BaseOxmlElement): """`` element, container for an inline shape.""" - extent = OneAndOnlyOne("wp:extent") - docPr = OneAndOnlyOne("wp:docPr") - graphic = OneAndOnlyOne("a:graphic") + extent: CT_PositiveSize2D = OneAndOnlyOne("wp:extent") # pyright: ignore[reportAssignmentType] + docPr: CT_NonVisualDrawingProps = OneAndOnlyOne( # pyright: ignore[reportAssignmentType] + "wp:docPr" + ) + graphic: CT_GraphicalObject = OneAndOnlyOne( # pyright: ignore[reportAssignmentType] + "a:graphic" + ) @classmethod def new(cls, cx: Length, cy: Length, shape_id: int, pic: CT_Picture) -> CT_Inline: """Return a new ```` element populated with the values passed as parameters.""" - inline = parse_xml(cls._inline_xml()) + inline = cast(CT_Inline, parse_xml(cls._inline_xml())) inline.extent.cx = cx inline.extent.cy = cy inline.docPr.id = shape_id inline.docPr.name = "Picture %d" % shape_id - inline.graphic.graphicData.uri = ( - "http://schemas.openxmlformats.org/drawingml/2006/picture" - ) + inline.graphic.graphicData.uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" inline.graphic.graphicData._insert_pic(pic) return inline @@ -90,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 @@ -126,15 +135,17 @@ class CT_NonVisualPictureProperties(BaseOxmlElement): class CT_Picture(BaseOxmlElement): """```` element, a DrawingML picture.""" - nvPicPr = OneAndOnlyOne("pic:nvPicPr") - blipFill = OneAndOnlyOne("pic:blipFill") - spPr = OneAndOnlyOne("pic:spPr") + nvPicPr: CT_PictureNonVisual = OneAndOnlyOne( # pyright: ignore[reportAssignmentType] + "pic:nvPicPr" + ) + blipFill: CT_BlipFillProperties = OneAndOnlyOne( # pyright: ignore[reportAssignmentType] + "pic:blipFill" + ) + spPr: CT_ShapeProperties = OneAndOnlyOne("pic:spPr") # pyright: ignore[reportAssignmentType] @classmethod - def new(cls, pic_id, filename, rId, cx, cy): - """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 @@ -190,8 +201,12 @@ class CT_PositiveSize2D(BaseOxmlElement): Specifies the size of a DrawingML drawing. """ - cx = RequiredAttribute("cx", ST_PositiveCoordinate) - cy = RequiredAttribute("cy", ST_PositiveCoordinate) + cx: Length = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "cx", ST_PositiveCoordinate + ) + cy: Length = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "cy", ST_PositiveCoordinate + ) class CT_PresetGeometry2D(BaseOxmlElement): diff --git a/src/docx/oxml/shared.py b/src/docx/oxml/shared.py index 1774560ac..8cfcd2be1 100644 --- a/src/docx/oxml/shared.py +++ b/src/docx/oxml/shared.py @@ -15,10 +15,10 @@ class CT_DecimalNumber(BaseOxmlElement): containing a text representation of a decimal number (e.g. 42) in its ``val`` attribute.""" - val = RequiredAttribute("w:val", ST_DecimalNumber) + val: int = RequiredAttribute("w:val", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] @classmethod - def new(cls, nsptagname, val): + def new(cls, nsptagname: str, val: int): """Return a new ``CT_DecimalNumber`` element having tagname `nsptagname` and ``val`` attribute set to `val`.""" return OxmlElement(nsptagname, attrs={qn("w:val"): str(val)}) @@ -31,7 +31,7 @@ class CT_OnOff(BaseOxmlElement): "off". Defaults to `True`, so `` for example means "bold is turned on". """ - val: bool = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + val: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:val", ST_OnOff, default=True ) @@ -42,14 +42,11 @@ class CT_String(BaseOxmlElement): In those cases, it containing a style name in its `val` attribute. """ - val: str = RequiredAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:val", ST_String - ) + val: str = RequiredAttribute("w:val", ST_String) # pyright: ignore[reportAssignmentType] @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 debb5dc3c..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 @@ -36,12 +37,10 @@ def convert_from_xml(cls, str_value: str) -> Any: return int(str_value) @classmethod - def convert_to_xml(cls, value: Any) -> str: - ... + def convert_to_xml(cls, value: Any) -> str: ... @classmethod - def validate(cls, value: Any) -> None: - ... + def validate(cls, value: Any) -> None: ... @classmethod def validate_int(cls, value: object): @@ -49,9 +48,7 @@ def validate_int(cls, value: object): raise TypeError("value must be , got %s" % type(value)) @classmethod - def validate_int_in_range( - cls, value: int, min_inclusive: int, max_inclusive: int - ) -> None: + def validate_int_in_range(cls, value: int, min_inclusive: int, max_inclusive: int) -> None: cls.validate_int(value) if value < min_inclusive or value > max_inclusive: raise ValueError( @@ -129,8 +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 ) @@ -218,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 @@ -248,8 +296,7 @@ def validate(cls, value: Any) -> None: # must be an RGBColor object --- if not isinstance(value, RGBColor): raise ValueError( - "rgb color value must be RGBColor object, got %s %s" - % (type(value), value) + "rgb color value must be RGBColor object, got %s %s" % (type(value), value) ) @@ -316,7 +363,7 @@ class ST_SignedTwipsMeasure(XsdInt): def convert_from_xml(cls, str_value: str) -> Length: if "i" in str_value or "m" in str_value or "p" in str_value: return ST_UniversalMeasure.convert_from_xml(str_value) - return Twips(int(str_value)) + return Twips(int(round(float(str_value)))) @classmethod def convert_to_xml(cls, value: int | Length) -> str: diff --git a/src/docx/oxml/styles.py b/src/docx/oxml/styles.py index e0a3eaeaf..fb0e5d0dd 100644 --- a/src/docx/oxml/styles.py +++ b/src/docx/oxml/styles.py @@ -128,12 +128,10 @@ class CT_Style(BaseOxmlElement): rPr = ZeroOrOne("w:rPr", successors=_tag_seq[18:]) del _tag_seq - type: WD_STYLE_TYPE | None = ( - OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:type", WD_STYLE_TYPE - ) + type: WD_STYLE_TYPE | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:type", WD_STYLE_TYPE ) - styleId: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + styleId: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:styleId", ST_String ) default = OptionalAttribute("w:default", ST_OnOff) diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index 48a6d8c2f..9457da207 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -2,12 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, List +from typing import TYPE_CHECKING, Callable, cast -from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_ROW_HEIGHT_RULE +from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_ROW_HEIGHT_RULE, WD_TABLE_DIRECTION from docx.exceptions import InvalidSpanError from docx.oxml.ns import nsdecls, qn from docx.oxml.parser import parse_xml +from docx.oxml.shared import CT_DecimalNumber from docx.oxml.simpletypes import ( ST_Merge, ST_TblLayoutType, @@ -15,6 +16,7 @@ ST_TwipsMeasure, XsdInt, ) +from docx.oxml.text.paragraph import CT_P from docx.oxml.xmlchemy import ( BaseOxmlElement, OneAndOnlyOne, @@ -24,57 +26,93 @@ ZeroOrMore, ZeroOrOne, ) -from docx.shared import Emu, Twips +from docx.shared import Emu, Length, Twips if TYPE_CHECKING: - from docx.oxml.text.paragraph import CT_P - from docx.shared import Length + from docx.enum.table import WD_TABLE_ALIGNMENT + from docx.enum.text import WD_ALIGN_PARAGRAPH + from docx.oxml.shared import CT_OnOff, CT_String + from docx.oxml.text.parfmt import CT_Jc class CT_Height(BaseOxmlElement): - """Used for ```` to specify a row height and row height rule.""" + """Used for `w:trHeight` to specify a row height and row height rule.""" - val = OptionalAttribute("w:val", ST_TwipsMeasure) - hRule = OptionalAttribute("w:hRule", WD_ROW_HEIGHT_RULE) + val: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:val", ST_TwipsMeasure + ) + hRule: WD_ROW_HEIGHT_RULE | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:hRule", WD_ROW_HEIGHT_RULE + ) class CT_Row(BaseOxmlElement): """```` element.""" - tblPrEx = ZeroOrOne("w:tblPrEx") # custom inserter below - trPr = ZeroOrOne("w:trPr") # custom inserter below + add_tc: Callable[[], CT_Tc] + get_or_add_trPr: Callable[[], CT_TrPr] + _add_trPr: Callable[[], CT_TrPr] + + tc_lst: list[CT_Tc] + # -- custom inserter below -- + tblPrEx: CT_TblPrEx | None = ZeroOrOne("w:tblPrEx") # pyright: ignore[reportAssignmentType] + # -- custom inserter below -- + trPr: CT_TrPr | None = ZeroOrOne("w:trPr") # pyright: ignore[reportAssignmentType] tc = ZeroOrMore("w:tc") - def tc_at_grid_col(self, idx): - """The ```` element appearing at grid column `idx`. + @property + def grid_after(self) -> int: + """The number of unpopulated layout-grid cells at the end of this row.""" + trPr = self.trPr + if trPr is None: + return 0 + return trPr.grid_after + + @property + def grid_before(self) -> int: + """The number of unpopulated layout-grid cells at the start of this row.""" + trPr = self.trPr + if trPr is None: + return 0 + return trPr.grid_before + + def tc_at_grid_offset(self, grid_offset: int) -> CT_Tc: + """The `tc` element in this tr at exact `grid offset`. - Raises |ValueError| if no ``w:tc`` element begins at that grid column. + Raises ValueError when this `w:tr` contains no `w:tc` with exact starting `grid_offset`. """ - grid_col = 0 + # -- account for omitted cells at the start of the row -- + remaining_offset = grid_offset - self.grid_before + for tc in self.tc_lst: - if grid_col == idx: + # -- We've gone past grid_offset without finding a tc, no sense searching further. -- + if remaining_offset < 0: + break + # -- We've arrived at grid_offset, this is the `w:tc` we're looking for. -- + if remaining_offset == 0: return tc - grid_col += tc.grid_span - if grid_col > idx: - raise ValueError("no cell on grid column %d" % idx) - raise ValueError("index out of bounds") + # -- We're not there yet, skip forward the number of layout-grid cells this cell + # -- occupies. + remaining_offset -= tc.grid_span + + raise ValueError(f"no `tc` element at grid_offset={grid_offset}") @property - def tr_idx(self): - """The index of this ```` element within its parent ```` - element.""" - return self.getparent().tr_lst.index(self) + def tr_idx(self) -> int: + """Index of this `w:tr` element within its parent `w:tbl` element.""" + tbl = cast(CT_Tbl, self.getparent()) + return tbl.tr_lst.index(self) @property - def trHeight_hRule(self): - """Return the value of `w:trPr/w:trHeight@w:hRule`, or |None| if not present.""" + def trHeight_hRule(self) -> WD_ROW_HEIGHT_RULE | None: + """The value of `./w:trPr/w:trHeight/@w:hRule`, or |None| if not present.""" trPr = self.trPr if trPr is None: return None return trPr.trHeight_hRule @trHeight_hRule.setter - def trHeight_hRule(self, value): + def trHeight_hRule(self, value: WD_ROW_HEIGHT_RULE | None): trPr = self.get_or_add_trPr() trPr.trHeight_hRule = value @@ -87,14 +125,14 @@ def trHeight_val(self): return trPr.trHeight_val @trHeight_val.setter - def trHeight_val(self, value): + def trHeight_val(self, value: Length | None): trPr = self.get_or_add_trPr() trPr.trHeight_val = value - def _insert_tblPrEx(self, tblPrEx): + def _insert_tblPrEx(self, tblPrEx: CT_TblPrEx): self.insert(0, tblPrEx) - def _insert_trPr(self, trPr): + def _insert_trPr(self, trPr: CT_TrPr): tblPrEx = self.tblPrEx if tblPrEx is not None: tblPrEx.addnext(trPr) @@ -108,13 +146,16 @@ def _new_tc(self): class CT_Tbl(BaseOxmlElement): """```` element.""" - tblPr = OneAndOnlyOne("w:tblPr") - tblGrid = OneAndOnlyOne("w:tblGrid") + add_tr: Callable[[], CT_Row] + tr_lst: list[CT_Row] + + tblPr: CT_TblPr = OneAndOnlyOne("w:tblPr") # pyright: ignore[reportAssignmentType] + tblGrid: CT_TblGrid = OneAndOnlyOne("w:tblGrid") # pyright: ignore[reportAssignmentType] tr = ZeroOrMore("w:tr") @property - def bidiVisual_val(self): - """Value of `w:tblPr/w:bidiVisual/@w:val` or |None| if not present. + def bidiVisual_val(self) -> bool | None: + """Value of `./w:tblPr/w:bidiVisual/@w:val` or |None| if not present. Controls whether table cells are displayed right-to-left or left-to-right. """ @@ -124,12 +165,12 @@ def bidiVisual_val(self): return bidiVisual.val @bidiVisual_val.setter - def bidiVisual_val(self, value): + def bidiVisual_val(self, value: WD_TABLE_DIRECTION | None): tblPr = self.tblPr if value is None: - tblPr._remove_bidiVisual() + tblPr._remove_bidiVisual() # pyright: ignore[reportPrivateUsage] else: - tblPr.get_or_add_bidiVisual().val = value + tblPr.get_or_add_bidiVisual().val = bool(value) @property def col_count(self): @@ -153,111 +194,118 @@ def new_tbl(cls, rows: int, cols: int, width: Length) -> CT_Tbl: `width` is distributed evenly between the columns. """ - return parse_xml(cls._tbl_xml(rows, cols, width)) + return cast(CT_Tbl, parse_xml(cls._tbl_xml(rows, cols, width))) @property - def tblStyle_val(self): - """Value of `w:tblPr/w:tblStyle/@w:val` (a table style id) or |None| if not - present.""" + def tblStyle_val(self) -> str | None: + """`w:tblPr/w:tblStyle/@w:val` (a table style id) or |None| if not present.""" tblStyle = self.tblPr.tblStyle if tblStyle is None: return None return tblStyle.val @tblStyle_val.setter - def tblStyle_val(self, styleId): + def tblStyle_val(self, styleId: str | None) -> None: """Set the value of `w:tblPr/w:tblStyle/@w:val` (a table style id) to `styleId`. If `styleId` is None, remove the `w:tblStyle` element. """ tblPr = self.tblPr - tblPr._remove_tblStyle() + tblPr._remove_tblStyle() # pyright: ignore[reportPrivateUsage] if styleId is None: return - tblPr._add_tblStyle().val = styleId + tblPr._add_tblStyle().val = styleId # pyright: ignore[reportPrivateUsage] @classmethod def _tbl_xml(cls, rows: int, cols: int, width: Length) -> str: - col_width = Emu(width / cols) if cols > 0 else Emu(0) + col_width = Emu(width // cols) if cols > 0 else Emu(0) return ( - "\n" - " \n" - ' \n' - ' \n' - " \n" - "%s" # tblGrid - "%s" # trs - "\n" - ) % ( - nsdecls("w"), - cls._tblGrid_xml(cols, col_width), - cls._trs_xml(rows, cols, col_width), + f"\n" + f" \n" + f' \n' + f' \n' + f" \n" + f"{cls._tblGrid_xml(cols, col_width)}" + f"{cls._trs_xml(rows, cols, col_width)}" + f"\n" ) @classmethod - def _tblGrid_xml(cls, col_count, col_width): + def _tblGrid_xml(cls, col_count: int, col_width: Length) -> str: xml = " \n" - for i in range(col_count): + for _ in range(col_count): xml += ' \n' % col_width.twips xml += " \n" return xml @classmethod - def _trs_xml(cls, row_count, col_count, col_width): - xml = "" - for i in range(row_count): - xml += (" \n" "%s" " \n") % cls._tcs_xml( - col_count, col_width - ) - return xml + def _trs_xml(cls, row_count: int, col_count: int, col_width: Length) -> str: + return f" \n{cls._tcs_xml(col_count, col_width)} \n" * row_count @classmethod - def _tcs_xml(cls, col_count, col_width): - xml = "" - for i in range(col_count): - xml += ( - " \n" - " \n" - ' \n' - " \n" - " \n" - " \n" - ) % col_width.twips - return xml + def _tcs_xml(cls, col_count: int, col_width: Length) -> str: + return ( + f" \n" + f" \n" + f' \n' + f" \n" + f" \n" + f" \n" + ) * col_count class CT_TblGrid(BaseOxmlElement): - """```` element, child of ````, holds ```` elements - that define column count, width, etc.""" + """`w:tblGrid` element. + + Child of `w:tbl`, holds `w:gridCol> elements that define column count, width, etc. + """ + + add_gridCol: Callable[[], CT_TblGridCol] + gridCol_lst: list[CT_TblGridCol] gridCol = ZeroOrMore("w:gridCol", successors=("w:tblGridChange",)) class CT_TblGridCol(BaseOxmlElement): - """```` element, child of ````, defines a table column.""" + """`w:gridCol` element, child of `w:tblGrid`, defines a table column.""" - w = OptionalAttribute("w:w", ST_TwipsMeasure) + w: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:w", ST_TwipsMeasure + ) @property - def gridCol_idx(self): - """The index of this ```` element within its parent ```` - element.""" - return self.getparent().gridCol_lst.index(self) + def gridCol_idx(self) -> int: + """Index of this `w:gridCol` element within its parent `w:tblGrid` element.""" + tblGrid = cast(CT_TblGrid, self.getparent()) + return tblGrid.gridCol_lst.index(self) class CT_TblLayoutType(BaseOxmlElement): - """```` element, specifying whether column widths are fixed or can be - automatically adjusted based on content.""" + """`w:tblLayout` element. - type = OptionalAttribute("w:type", ST_TblLayoutType) + Specifies whether column widths are fixed or can be automatically adjusted based on + content. + """ + + type: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:type", ST_TblLayoutType + ) class CT_TblPr(BaseOxmlElement): """```` element, child of ````, holds child elements that define table properties such as style and borders.""" + get_or_add_bidiVisual: Callable[[], CT_OnOff] + get_or_add_jc: Callable[[], CT_Jc] + get_or_add_tblLayout: Callable[[], CT_TblLayoutType] + _add_tblStyle: Callable[[], CT_String] + _remove_bidiVisual: Callable[[], None] + _remove_jc: Callable[[], None] + _remove_tblStyle: Callable[[], None] + _tag_seq = ( "w:tblStyle", "w:tblpPr", @@ -278,31 +326,35 @@ class CT_TblPr(BaseOxmlElement): "w:tblDescription", "w:tblPrChange", ) - tblStyle = ZeroOrOne("w:tblStyle", successors=_tag_seq[1:]) - bidiVisual = ZeroOrOne("w:bidiVisual", successors=_tag_seq[4:]) - jc = ZeroOrOne("w:jc", successors=_tag_seq[8:]) - tblLayout = ZeroOrOne("w:tblLayout", successors=_tag_seq[13:]) + tblStyle: CT_String | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:tblStyle", successors=_tag_seq[1:] + ) + bidiVisual: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:bidiVisual", successors=_tag_seq[4:] + ) + jc: CT_Jc | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:jc", successors=_tag_seq[8:] + ) + tblLayout: CT_TblLayoutType | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:tblLayout", successors=_tag_seq[13:] + ) del _tag_seq @property - def alignment(self): - """Member of :ref:`WdRowAlignment` enumeration or |None|, based on the contents - of the `w:val` attribute of `./w:jc`. - - |None| if no `w:jc` element is present. - """ + def alignment(self) -> WD_TABLE_ALIGNMENT | None: + """Horizontal alignment of table, |None| if `./w:jc` is not present.""" jc = self.jc if jc is None: return None - return jc.val + return cast("WD_TABLE_ALIGNMENT | None", jc.val) @alignment.setter - def alignment(self, value): + def alignment(self, value: WD_TABLE_ALIGNMENT | None): self._remove_jc() if value is None: return jc = self.get_or_add_jc() - jc.val = value + jc.val = cast("WD_ALIGN_PARAGRAPH", value) @property def autofit(self) -> bool: @@ -328,33 +380,40 @@ def style(self): return tblStyle.val @style.setter - def style(self, value): + def style(self, value: str | None): self._remove_tblStyle() if value is None: return - self._add_tblStyle(val=value) + self._add_tblStyle().val = value + + +class CT_TblPrEx(BaseOxmlElement): + """`w:tblPrEx` element, exceptions to table-properties. + + Applied at a lower level, like a `w:tr` to modify the appearance. Possibly used when + two tables are merged. For more see: + http://officeopenxml.com/WPtablePropertyExceptions.php + """ class CT_TblWidth(BaseOxmlElement): - """Used for ```` and ```` elements and many others, to specify a - table-related width.""" + """Used for `w:tblW` and `w:tcW` and others, specifies a table-related width.""" # the type for `w` attr is actually ST_MeasurementOrPercent, but using # XsdInt for now because only dxa (twips) values are being used. It's not # entirely clear what the semantics are for other values like -01.4mm - w = RequiredAttribute("w:w", XsdInt) + w: int = RequiredAttribute("w:w", XsdInt) # pyright: ignore[reportAssignmentType] type = RequiredAttribute("w:type", ST_TblWidth) @property - def width(self): - """Return the EMU length value represented by the combined ``w:w`` and - ``w:type`` attributes.""" + def width(self) -> Length | None: + """EMU length indicated by the combined `w:w` and `w:type` attrs.""" if self.type != "dxa": return None return Twips(self.w) @width.setter - def width(self, value): + def width(self, value: Length): self.type = "dxa" self.w = Emu(value).twips @@ -363,17 +422,19 @@ class CT_Tc(BaseOxmlElement): """`w:tc` table cell element.""" add_p: Callable[[], CT_P] - p_lst: List[CT_P] - tbl_lst: List[CT_Tbl] - + get_or_add_tcPr: Callable[[], CT_TcPr] + p_lst: list[CT_P] + tbl_lst: list[CT_Tbl] _insert_tbl: Callable[[CT_Tbl], CT_Tbl] + _new_p: Callable[[], CT_P] - tcPr = ZeroOrOne("w:tcPr") # bunches of successors, overriding insert + # -- tcPr has many successors, `._insert_tcPr()` is overridden below -- + tcPr: CT_TcPr | None = ZeroOrOne("w:tcPr") # pyright: ignore[reportAssignmentType] p = OneOrMore("w:p") tbl = OneOrMore("w:tbl") @property - def bottom(self): + def bottom(self) -> int: """The row index that marks the bottom extent of the vertical span of this cell. This is one greater than the index of the bottom-most row of the span, similar @@ -386,37 +447,44 @@ def bottom(self): return self._tr_idx + 1 def clear_content(self): - """Remove all content child elements, preserving the ```` element if - present. + """Remove all content elements, preserving `w:tcPr` element if present. - Note that this leaves the ```` element in an invalid state because it - doesn't contain at least one block-level element. It's up to the caller to add a - ````child element as the last content element. + Note that this leaves the `w:tc` element in an invalid state because it doesn't + contain at least one block-level element. It's up to the caller to add a + `w:p`child element as the last content element. """ - new_children = [] - tcPr = self.tcPr - if tcPr is not None: - new_children.append(tcPr) - self[:] = new_children + # -- remove all cell inner-content except a `w:tcPr` when present. -- + for e in self.xpath("./*[not(self::w:tcPr)]"): + self.remove(e) @property - def grid_span(self): + def grid_offset(self) -> int: + """Starting offset of `tc` in the layout-grid columns of its table. + + A cell in the leftmost grid-column has offset 0. + """ + grid_before = self._tr.grid_before + preceding_tc_grid_spans = sum( + tc.grid_span for tc in self.xpath("./preceding-sibling::w:tc") + ) + return grid_before + preceding_tc_grid_spans + + @property + def grid_span(self) -> int: """The integer number of columns this cell spans. Determined by ./w:tcPr/w:gridSpan/@val, it defaults to 1. """ tcPr = self.tcPr - if tcPr is None: - return 1 - return tcPr.grid_span + return 1 if tcPr is None else tcPr.grid_span @grid_span.setter - def grid_span(self, value): + def grid_span(self, value: int): tcPr = self.get_or_add_tcPr() tcPr.grid_span = value @property - def inner_content_elements(self) -> List[CT_P | CT_Tbl]: + def inner_content_elements(self) -> list[CT_P | CT_Tbl]: """Generate all `w:p` and `w:tbl` elements in this document-body. Elements appear in document order. Elements shaded by nesting in a `w:ins` or @@ -433,138 +501,141 @@ def iter_block_items(self): yield child @property - def left(self): + def left(self) -> int: """The grid column index at which this ```` element appears.""" - return self._grid_col + return self.grid_offset - def merge(self, other_tc): - """Return the top-left ```` element of a new span formed by merging the - rectangular region defined by using this tc element and `other_tc` as diagonal - corners.""" + def merge(self, other_tc: CT_Tc) -> CT_Tc: + """Return top-left `w:tc` element of a new span. + + Span is formed by merging the rectangular region defined by using this tc + element and `other_tc` as diagonal corners. + """ top, left, height, width = self._span_dimensions(other_tc) - top_tc = self._tbl.tr_lst[top].tc_at_grid_col(left) + top_tc = self._tbl.tr_lst[top].tc_at_grid_offset(left) top_tc._grow_to(width, height) return top_tc @classmethod - def new(cls): - """Return a new ```` element, containing an empty paragraph as the - required EG_BlockLevelElt.""" - return parse_xml("\n" " \n" "" % nsdecls("w")) + def new(cls) -> CT_Tc: + """A new `w:tc` element, containing an empty paragraph as the required EG_BlockLevelElt.""" + return cast(CT_Tc, parse_xml("" % nsdecls("w"))) @property - def right(self): + def right(self) -> int: """The grid column index that marks the right-side extent of the horizontal span of this cell. This is one greater than the index of the right-most column of the span, similar to how a slice of the cell's columns would be specified. """ - return self._grid_col + self.grid_span + return self.grid_offset + self.grid_span @property - def top(self): + def top(self) -> int: """The top-most row index in the vertical span of this cell.""" if self.vMerge is None or self.vMerge == ST_Merge.RESTART: return self._tr_idx return self._tc_above.top @property - def vMerge(self): - """The value of the ./w:tcPr/w:vMerge/@val attribute, or |None| if the w:vMerge - element is not present.""" + def vMerge(self) -> str | None: + """Value of ./w:tcPr/w:vMerge/@val, |None| if w:vMerge is not present.""" tcPr = self.tcPr if tcPr is None: return None return tcPr.vMerge_val @vMerge.setter - def vMerge(self, value): + def vMerge(self, value: str | None): tcPr = self.get_or_add_tcPr() tcPr.vMerge_val = value @property - def width(self): - """Return the EMU length value represented in the ``./w:tcPr/w:tcW`` child - element or |None| if not present.""" + def width(self) -> Length | None: + """EMU length represented in `./w:tcPr/w:tcW` or |None| if not present.""" tcPr = self.tcPr if tcPr is None: return None return tcPr.width @width.setter - def width(self, value): + def width(self, value: Length): tcPr = self.get_or_add_tcPr() tcPr.width = value - def _add_width_of(self, other_tc): + def _add_width_of(self, other_tc: CT_Tc): """Add the width of `other_tc` to this cell. Does nothing if either this tc or `other_tc` does not have a specified width. """ if self.width and other_tc.width: - self.width += other_tc.width + self.width = Length(self.width + other_tc.width) - @property - def _grid_col(self): - """The grid column at which this cell begins.""" - tr = self._tr - idx = tr.tc_lst.index(self) - preceding_tcs = tr.tc_lst[:idx] - return sum(tc.grid_span for tc in preceding_tcs) + def _grow_to(self, width: int, height: int, top_tc: CT_Tc | None = None): + """Grow this cell to `width` grid columns and `height` rows. - def _grow_to(self, width, height, top_tc=None): - """Grow this cell to `width` grid columns and `height` rows by expanding - horizontal spans and creating continuation cells to form vertical spans.""" + This is accomplished by expanding horizontal spans and creating continuation + cells to form vertical spans. + """ - def vMerge_val(top_tc): - if top_tc is not self: - return ST_Merge.CONTINUE - if height == 1: - return None - return ST_Merge.RESTART + def vMerge_val(top_tc: CT_Tc): + return ( + ST_Merge.CONTINUE + if top_tc is not self + else None + if height == 1 + else ST_Merge.RESTART + ) top_tc = self if top_tc is None else top_tc self._span_to_width(width, top_tc, vMerge_val(top_tc)) if height > 1: - self._tc_below._grow_to(width, height - 1, top_tc) + tc_below = self._tc_below + assert tc_below is not None + tc_below._grow_to(width, height - 1, top_tc) - def _insert_tcPr(self, tcPr): - """``tcPr`` has a bunch of successors, but it comes first if it appears, so just - overriding and using insert(0, ...) rather than spelling out successors.""" + def _insert_tcPr(self, tcPr: CT_TcPr) -> CT_TcPr: + """Override default `._insert_tcPr()`.""" + # -- `tcPr`` has a large number of successors, but always comes first if it appears, + # -- so just using insert(0, ...) rather than spelling out successors. self.insert(0, tcPr) return tcPr @property - def _is_empty(self): - """True if this cell contains only a single empty ```` element.""" + def _is_empty(self) -> bool: + """True if this cell contains only a single empty `w:p` element.""" block_items = list(self.iter_block_items()) if len(block_items) > 1: return False - p = block_items[0] # cell must include at least one element - if len(p.r_lst) == 0: - return True - return False - - def _move_content_to(self, other_tc): - """Append the content of this cell to `other_tc`, leaving this cell with a - single empty ```` element.""" + # -- 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] + 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`. + + Leaves this cell with a single empty ```` element. + """ if other_tc is self: return if self._is_empty: return other_tc._remove_trailing_empty_p() - # appending moves each element from self to other_tc + # -- appending moves each element from self to other_tc -- for block_element in self.iter_block_items(): other_tc.append(block_element) - # add back the required minimum single empty element + # -- add back the required minimum single empty element -- self.append(self._new_p()) - def _new_tbl(self): - return CT_Tbl.new() + def _new_tbl(self) -> None: + raise NotImplementedError( + "use CT_Tbl.new_tbl() to add a new table, specifying rows and columns" + ) @property - def _next_tc(self): + def _next_tc(self) -> CT_Tc | None: """The `w:tc` element immediately following this one in this row, or |None| if this is the last `w:tc` element in the row.""" following_tcs = self.xpath("./following-sibling::w:tc") @@ -572,32 +643,33 @@ def _next_tc(self): def _remove(self): """Remove this `w:tc` element from the XML tree.""" - self.getparent().remove(self) + parent_element = self.getparent() + assert parent_element is not None + parent_element.remove(self) def _remove_trailing_empty_p(self): - """Remove the last content element from this cell if it is an empty ```` - element.""" + """Remove last content element from this cell if it's an empty `w:p` element.""" block_items = list(self.iter_block_items()) last_content_elm = block_items[-1] - if last_content_elm.tag != qn("w:p"): + if not isinstance(last_content_elm, CT_P): return p = last_content_elm if len(p.r_lst) > 0: return self.remove(p) - def _span_dimensions(self, other_tc): + def _span_dimensions(self, other_tc: CT_Tc) -> tuple[int, int, int, int]: """Return a (top, left, height, width) 4-tuple specifying the extents of the merged cell formed by using this tc and `other_tc` as opposite corner extents.""" - def raise_on_inverted_L(a, b): + def raise_on_inverted_L(a: CT_Tc, b: CT_Tc): if a.top == b.top and a.bottom != b.bottom: raise InvalidSpanError("requested span not rectangular") if a.left == b.left and a.right != b.right: raise InvalidSpanError("requested span not rectangular") - def raise_on_tee_shaped(a, b): + def raise_on_tee_shaped(a: CT_Tc, b: CT_Tc): top_most, other = (a, b) if a.top < b.top else (b, a) if top_most.top < other.top and top_most.bottom > other.bottom: raise InvalidSpanError("requested span not rectangular") @@ -616,9 +688,10 @@ def raise_on_tee_shaped(a, b): return top, left, bottom - top, right - left - def _span_to_width(self, grid_width, top_tc, vMerge): - """Incorporate and then remove `w:tc` elements to the right of this one until - this cell spans `grid_width`. + def _span_to_width(self, grid_width: int, top_tc: CT_Tc, vMerge: str | None): + """Incorporate `w:tc` elements to the right until this cell spans `grid_width`. + + Incorporated `w:tc` elements are removed (replaced by gridSpan value). Raises |ValueError| if `grid_width` cannot be exactly achieved, such as when a merged cell would drive the span width greater than `grid_width` or if not @@ -632,7 +705,7 @@ def _span_to_width(self, grid_width, top_tc, vMerge): self._swallow_next_tc(grid_width, top_tc) self.vMerge = vMerge - def _swallow_next_tc(self, grid_width, top_tc): + def _swallow_next_tc(self, grid_width: int, top_tc: CT_Tc): """Extend the horizontal span of this `w:tc` element to incorporate the following `w:tc` element in the row and then delete that following `w:tc` element. @@ -643,7 +716,7 @@ def _swallow_next_tc(self, grid_width, top_tc): than `grid_width` or if there is no next `` element in the row. """ - def raise_on_invalid_swallow(next_tc): + def raise_on_invalid_swallow(next_tc: CT_Tc | None): if next_tc is None: raise InvalidSpanError("not enough grid columns") if self.grid_span + next_tc.grid_span > grid_width: @@ -651,48 +724,48 @@ def raise_on_invalid_swallow(next_tc): next_tc = self._next_tc raise_on_invalid_swallow(next_tc) + assert next_tc is not None next_tc._move_content_to(top_tc) self._add_width_of(next_tc) self.grid_span += next_tc.grid_span next_tc._remove() @property - def _tbl(self): + def _tbl(self) -> CT_Tbl: """The tbl element this tc element appears in.""" - return self.xpath("./ancestor::w:tbl[position()=1]")[0] + return cast(CT_Tbl, self.xpath("./ancestor::w:tbl[position()=1]")[0]) @property - def _tc_above(self): + def _tc_above(self) -> CT_Tc: """The `w:tc` element immediately above this one in its grid column.""" - return self._tr_above.tc_at_grid_col(self._grid_col) + return self._tr_above.tc_at_grid_offset(self.grid_offset) @property - def _tc_below(self): + def _tc_below(self) -> CT_Tc | None: """The tc element immediately below this one in its grid column.""" tr_below = self._tr_below if tr_below is None: return None - return tr_below.tc_at_grid_col(self._grid_col) + return tr_below.tc_at_grid_offset(self.grid_offset) @property - def _tr(self): + def _tr(self) -> CT_Row: """The tr element this tc element appears in.""" - return self.xpath("./ancestor::w:tr[position()=1]")[0] + return cast(CT_Row, self.xpath("./ancestor::w:tr[position()=1]")[0]) @property - def _tr_above(self): + def _tr_above(self) -> CT_Row: """The tr element prior in sequence to the tr this cell appears in. Raises |ValueError| if called on a cell in the top-most row. """ - tr_lst = self._tbl.tr_lst - tr_idx = tr_lst.index(self._tr) - if tr_idx == 0: - raise ValueError("no tr above topmost tr") - return tr_lst[tr_idx - 1] + tr_aboves = self.xpath("./ancestor::w:tr[position()=1]/preceding-sibling::w:tr[1]") + if not tr_aboves: + raise ValueError("no tr above topmost tr in w:tbl") + return tr_aboves[0] @property - def _tr_below(self): + def _tr_below(self) -> CT_Row | None: """The tr element next in sequence after the tr this cell appears in, or |None| if this cell appears in the last row.""" tr_lst = self._tbl.tr_lst @@ -703,7 +776,7 @@ def _tr_below(self): return None @property - def _tr_idx(self): + def _tr_idx(self) -> int: """The row index of the tr element this tc element appears in.""" return self._tbl.tr_lst.index(self._tr) @@ -711,6 +784,14 @@ def _tr_idx(self): class CT_TcPr(BaseOxmlElement): """```` element, defining table cell properties.""" + get_or_add_gridSpan: Callable[[], CT_DecimalNumber] + get_or_add_tcW: Callable[[], CT_TblWidth] + get_or_add_vAlign: Callable[[], CT_VerticalJc] + _add_vMerge: Callable[[], CT_VMerge] + _remove_gridSpan: Callable[[], None] + _remove_vAlign: Callable[[], None] + _remove_vMerge: Callable[[], None] + _tag_seq = ( "w:cnfStyle", "w:tcW", @@ -731,25 +812,31 @@ class CT_TcPr(BaseOxmlElement): "w:cellMerge", "w:tcPrChange", ) - tcW = ZeroOrOne("w:tcW", successors=_tag_seq[2:]) - gridSpan = ZeroOrOne("w:gridSpan", successors=_tag_seq[3:]) - vMerge = ZeroOrOne("w:vMerge", successors=_tag_seq[5:]) - vAlign = ZeroOrOne("w:vAlign", successors=_tag_seq[12:]) + tcW: CT_TblWidth | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:tcW", successors=_tag_seq[2:] + ) + gridSpan: CT_DecimalNumber | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:gridSpan", successors=_tag_seq[3:] + ) + vMerge: CT_VMerge | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:vMerge", successors=_tag_seq[5:] + ) + vAlign: CT_VerticalJc | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:vAlign", successors=_tag_seq[12:] + ) del _tag_seq @property - def grid_span(self): + def grid_span(self) -> int: """The integer number of columns this cell spans. Determined by ./w:gridSpan/@val, it defaults to 1. """ gridSpan = self.gridSpan - if gridSpan is None: - return 1 - return gridSpan.val + return 1 if gridSpan is None else gridSpan.val @grid_span.setter - def grid_span(self, value): + def grid_span(self, value: int): self._remove_gridSpan() if value > 1: self.get_or_add_gridSpan().val = value @@ -767,7 +854,7 @@ def vAlign_val(self): return vAlign.val @vAlign_val.setter - def vAlign_val(self, value): + def vAlign_val(self, value: WD_CELL_VERTICAL_ALIGNMENT | None): if value is None: self._remove_vAlign() return @@ -783,22 +870,21 @@ def vMerge_val(self): return vMerge.val @vMerge_val.setter - def vMerge_val(self, value): + def vMerge_val(self, value: str | None): self._remove_vMerge() if value is not None: self._add_vMerge().val = value @property - def width(self): - """Return the EMU length value represented in the ```` child element or - |None| if not present or its type is not 'dxa'.""" + def width(self) -> Length | None: + """EMU length in `./w:tcW` or |None| if not present or its type is not 'dxa'.""" tcW = self.tcW if tcW is None: return None return tcW.width @width.setter - def width(self, value): + def width(self, value: Length): tcW = self.get_or_add_tcW() tcW.width = value @@ -806,6 +892,8 @@ def width(self, value): class CT_TrPr(BaseOxmlElement): """```` element, defining table row properties.""" + get_or_add_trHeight: Callable[[], CT_Height] + _tag_seq = ( "w:cnfStyle", "w:divId", @@ -823,19 +911,37 @@ class CT_TrPr(BaseOxmlElement): "w:del", "w:trPrChange", ) - trHeight = ZeroOrOne("w:trHeight", successors=_tag_seq[8:]) + gridAfter: CT_DecimalNumber | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:gridAfter", successors=_tag_seq[4:] + ) + gridBefore: CT_DecimalNumber | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:gridBefore", successors=_tag_seq[3:] + ) + trHeight: CT_Height | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:trHeight", successors=_tag_seq[8:] + ) del _tag_seq @property - def trHeight_hRule(self): + def grid_after(self) -> int: + """The number of unpopulated layout-grid cells at the end of this row.""" + gridAfter = self.gridAfter + return 0 if gridAfter is None else gridAfter.val + + @property + def grid_before(self) -> int: + """The number of unpopulated layout-grid cells at the start of this row.""" + gridBefore = self.gridBefore + return 0 if gridBefore is None else gridBefore.val + + @property + def trHeight_hRule(self) -> WD_ROW_HEIGHT_RULE | None: """Return the value of `w:trHeight@w:hRule`, or |None| if not present.""" trHeight = self.trHeight - if trHeight is None: - return None - return trHeight.hRule + return None if trHeight is None else trHeight.hRule @trHeight_hRule.setter - def trHeight_hRule(self, value): + def trHeight_hRule(self, value: WD_ROW_HEIGHT_RULE | None): if value is None and self.trHeight is None: return trHeight = self.get_or_add_trHeight() @@ -845,12 +951,10 @@ def trHeight_hRule(self, value): def trHeight_val(self): """Return the value of `w:trHeight@w:val`, or |None| if not present.""" trHeight = self.trHeight - if trHeight is None: - return None - return trHeight.val + return None if trHeight is None else trHeight.val @trHeight_val.setter - def trHeight_val(self, value): + def trHeight_val(self, value: Length | None): if value is None and self.trHeight is None: return trHeight = self.get_or_add_trHeight() @@ -860,10 +964,14 @@ def trHeight_val(self, value): class CT_VerticalJc(BaseOxmlElement): """`w:vAlign` element, specifying vertical alignment of cell.""" - val = RequiredAttribute("w:val", WD_CELL_VERTICAL_ALIGNMENT) + val: WD_CELL_VERTICAL_ALIGNMENT = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_CELL_VERTICAL_ALIGNMENT + ) class CT_VMerge(BaseOxmlElement): """```` element, specifying vertical merging behavior of a cell.""" - val = OptionalAttribute("w:val", ST_Merge, default=ST_Merge.CONTINUE) + val: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:val", ST_Merge, default=ST_Merge.CONTINUE + ) diff --git a/src/docx/oxml/text/font.py b/src/docx/oxml/text/font.py index 0e183cf65..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[reportGeneralTypeIssues] - "w:ascii", ST_String - ) - hAnsi: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] - "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,23 +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:]) @@ -257,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: @@ -268,10 +245,7 @@ def subscript(self, value: bool | None) -> None: elif bool(value) is True: self.get_or_add_vertAlign().val = ST_VerticalAlignRun.SUBSCRIPT # -- assert bool(value) is False -- - elif ( - self.vertAlign is not None - and self.vertAlign.val == ST_VerticalAlignRun.SUBSCRIPT - ): + elif self.vertAlign is not None and self.vertAlign.val == ST_VerticalAlignRun.SUBSCRIPT: self._remove_vertAlign() @property @@ -284,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): @@ -295,10 +267,7 @@ def superscript(self, value: bool | None): elif bool(value) is True: self.get_or_add_vertAlign().val = ST_VerticalAlignRun.SUPERSCRIPT # -- assert bool(value) is False -- - elif ( - self.vertAlign is not None - and self.vertAlign.val == ST_VerticalAlignRun.SUPERSCRIPT - ): + elif self.vertAlign is not None and self.vertAlign.val == ST_VerticalAlignRun.SUPERSCRIPT: self._remove_vertAlign() @property @@ -353,16 +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[reportGeneralTypeIssues] - "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/hyperlink.py b/src/docx/oxml/text/hyperlink.py index 77d409f6a..38a33ff15 100644 --- a/src/docx/oxml/text/hyperlink.py +++ b/src/docx/oxml/text/hyperlink.py @@ -21,13 +21,13 @@ class CT_Hyperlink(BaseOxmlElement): r_lst: List[CT_R] - rId: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] - "r:id", XsdString - ) - anchor: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + rId: str | None = OptionalAttribute("r:id", XsdString) # pyright: ignore[reportAssignmentType] + anchor: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:anchor", ST_String ) - history = OptionalAttribute("w:history", ST_OnOff, default=True) + history: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:history", ST_OnOff, default=True + ) r = ZeroOrMore("w:r") @@ -36,8 +36,8 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: """All `w:lastRenderedPageBreak` descendants of this hyperlink.""" return self.xpath("./w:r/w:lastRenderedPageBreak") - @property # pyright: ignore[reportIncompatibleVariableOverride] - def text(self) -> str: + @property + def text(self) -> str: # pyright: ignore[reportIncompatibleMethodOverride] """The textual content of this hyperlink. `CT_Hyperlink` stores the hyperlink-text as one or more `w:r` children. diff --git a/src/docx/oxml/text/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/paragraph.py b/src/docx/oxml/text/paragraph.py index f771dd74f..63e96f312 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -26,7 +26,7 @@ class CT_P(BaseOxmlElement): hyperlink_lst: List[CT_Hyperlink] r_lst: List[CT_R] - pPr: CT_PPr | None = ZeroOrOne("w:pPr") # pyright: ignore[reportGeneralTypeIssues] + pPr: CT_PPr | None = ZeroOrOne("w:pPr") # pyright: ignore[reportAssignmentType] hyperlink = ZeroOrMore("w:hyperlink") r = ZeroOrMore("w:r") @@ -92,8 +92,8 @@ def style(self, style: str | None): pPr = self.get_or_add_pPr() pPr.style = style - @property # pyright: ignore[reportIncompatibleVariableOverride] - def text(self): + @property + def text(self): # pyright: ignore[reportIncompatibleMethodOverride] """The textual content of this paragraph. Inner-content child elements like `w:r` and `w:hyperlink` are translated to diff --git a/src/docx/oxml/text/parfmt.py b/src/docx/oxml/text/parfmt.py index 49ea01003..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, @@ -28,22 +29,34 @@ class CT_Ind(BaseOxmlElement): """```` element, specifying paragraph indentation.""" - left = OptionalAttribute("w:left", ST_SignedTwipsMeasure) - right = OptionalAttribute("w:right", ST_SignedTwipsMeasure) - firstLine = OptionalAttribute("w:firstLine", ST_TwipsMeasure) - hanging = OptionalAttribute("w:hanging", ST_TwipsMeasure) + left: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:left", ST_SignedTwipsMeasure + ) + right: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:right", ST_SignedTwipsMeasure + ) + firstLine: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:firstLine", ST_TwipsMeasure + ) + hanging: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:hanging", ST_TwipsMeasure + ) class CT_Jc(BaseOxmlElement): """```` element, specifying paragraph justification.""" - val = RequiredAttribute("w:val", WD_ALIGN_PARAGRAPH) + val: WD_ALIGN_PARAGRAPH = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_ALIGN_PARAGRAPH + ) class CT_PPr(BaseOxmlElement): """```` element, containing the properties for a paragraph.""" + 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] @@ -86,7 +99,7 @@ class CT_PPr(BaseOxmlElement): "w:sectPr", "w:pPrChange", ) - pStyle: CT_String | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + pStyle: CT_String | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:pStyle", successors=_tag_seq[1:] ) keepNext = ZeroOrOne("w:keepNext", successors=_tag_seq[2:]) @@ -96,13 +109,18 @@ class CT_PPr(BaseOxmlElement): numPr = ZeroOrOne("w:numPr", successors=_tag_seq[7:]) tabs = ZeroOrOne("w:tabs", successors=_tag_seq[11:]) spacing = ZeroOrOne("w:spacing", successors=_tag_seq[22:]) - ind = ZeroOrOne("w:ind", successors=_tag_seq[23:]) + ind: CT_Ind | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:ind", successors=_tag_seq[23:] + ) jc = ZeroOrOne("w:jc", successors=_tag_seq[27:]) + outlineLvl: CT_DecimalNumber = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:outlineLvl", successors=_tag_seq[31:] + ) sectPr = ZeroOrOne("w:sectPr", successors=_tag_seq[35:]) del _tag_seq @property - def first_line_indent(self): + def first_line_indent(self) -> Length | None: """A |Length| value calculated from the values of `w:ind/@w:firstLine` and `w:ind/@w:hanging`. @@ -120,7 +138,7 @@ def first_line_indent(self): return firstLine @first_line_indent.setter - def first_line_indent(self, value): + def first_line_indent(self, value: Length | None): if self.ind is None and value is None: return ind = self.get_or_add_ind() @@ -133,7 +151,7 @@ def first_line_indent(self, value): ind.firstLine = value @property - def ind_left(self): + def ind_left(self) -> Length | None: """The value of `w:ind/@w:left` or |None| if not present.""" ind = self.ind if ind is None: @@ -141,14 +159,14 @@ def ind_left(self): return ind.left @ind_left.setter - def ind_left(self, value): + def ind_left(self, value: Length | None): if value is None and self.ind is None: return ind = self.get_or_add_ind() ind.left = value @property - def ind_right(self): + def ind_right(self) -> Length | None: """The value of `w:ind/@w:right` or |None| if not present.""" ind = self.ind if ind is None: @@ -156,7 +174,7 @@ def ind_right(self): return ind.right @ind_right.setter - def ind_right(self, value): + def ind_right(self, value: Length | None): if value is None and self.ind is None: return ind = self.get_or_add_ind() @@ -338,9 +356,15 @@ class CT_TabStop(BaseOxmlElement): only needs a __str__ method. """ - val = RequiredAttribute("w:val", WD_TAB_ALIGNMENT) - leader = OptionalAttribute("w:leader", WD_TAB_LEADER, default=WD_TAB_LEADER.SPACES) - pos = RequiredAttribute("w:pos", ST_SignedTwipsMeasure) + val: WD_TAB_ALIGNMENT = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_TAB_ALIGNMENT + ) + leader: WD_TAB_LEADER | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:leader", WD_TAB_LEADER, default=WD_TAB_LEADER.SPACES + ) + pos: Length = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:pos", ST_SignedTwipsMeasure + ) def __str__(self) -> str: """Text equivalent of a `w:tab` element appearing in a run. diff --git a/src/docx/oxml/text/run.py b/src/docx/oxml/text/run.py index f17d33845..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 @@ -29,7 +30,7 @@ class CT_R(BaseOxmlElement): _add_drawing: Callable[[], CT_Drawing] _add_t: Callable[..., CT_Text] - rPr: CT_RPr | None = ZeroOrOne("w:rPr") # pyright: ignore[reportGeneralTypeIssues] + rPr: CT_RPr | None = ZeroOrOne("w:rPr") # pyright: ignore[reportAssignmentType] br = ZeroOrMore("w:br") cr = ZeroOrMore("w:cr") drawing = ZeroOrMore("w:drawing") @@ -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.""" @@ -120,12 +134,11 @@ def text(self) -> str: equivalent. """ return "".join( - str(e) - for e in self.xpath("w:br | w:cr | w:noBreakHyphen | w:ptab | w:t | w:tab") + str(e) for e in self.xpath("w:br | w:cr | w:noBreakHyphen | w:ptab | w:t | w:tab") ) - @text.setter # pyright: ignore[reportIncompatibleVariableOverride] - def text(self, text: str): + @text.setter + def text(self, text: str): # pyright: ignore[reportIncompatibleMethodOverride] self.clear_content() _RunContentAppender.append_to_run_from_text(self, text) @@ -133,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 @@ -141,12 +171,10 @@ def _insert_rPr(self, rPr: CT_RPr) -> CT_RPr: class CT_Br(BaseOxmlElement): """`` element, indicating a line, page, or column break in a run.""" - type: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] + type: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:type", ST_BrType, default="textWrapping" ) - clear: str | None = OptionalAttribute( # pyright: ignore[reportGeneralTypeIssues] - "w:clear", ST_BrClear - ) + clear: str | None = OptionalAttribute("w:clear", ST_BrClear) # pyright: ignore def __str__(self) -> str: """Text equivalent of this element. Actual value depends on break type. diff --git a/src/docx/oxml/xmlchemy.py b/src/docx/oxml/xmlchemy.py index d075f88f1..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, @@ -126,16 +114,12 @@ class BaseAttribute: Provides common methods. """ - def __init__( - self, attr_name: str, simple_type: Type[BaseXmlEnum] | Type[BaseSimpleType] - ): + def __init__(self, attr_name: str, simple_type: Type[BaseXmlEnum] | Type[BaseSimpleType]): super(BaseAttribute, self).__init__() self._attr_name = attr_name self._simple_type = simple_type - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" self._element_cls = element_cls self._prop_name = prop_name @@ -159,14 +143,12 @@ def _clark_name(self): return self._attr_name @property - def _getter(self) -> Callable[[BaseOxmlElement], Any | None]: - ... + def _getter(self) -> Callable[[BaseOxmlElement], Any | None]: ... @property def _setter( self, - ) -> Callable[[BaseOxmlElement, Any | None], None]: - ... + ) -> Callable[[BaseOxmlElement, Any | None], None]: ... class OptionalAttribute(BaseAttribute): @@ -181,7 +163,7 @@ def __init__( self, attr_name: str, simple_type: Type[BaseXmlEnum] | Type[BaseSimpleType], - default: BaseXmlEnum | BaseSimpleType | None = None, + default: BaseXmlEnum | BaseSimpleType | str | bool | None = None, ): super(OptionalAttribute, self).__init__(attr_name, simple_type) self._default = default @@ -259,8 +241,7 @@ def get_attr_value(obj: BaseOxmlElement) -> Any | None: attr_str_value = obj.get(self._clark_name) if attr_str_value is None: raise InvalidXmlError( - "required '%s' attribute not present on element %s" - % (self._attr_name, obj.tag) + "required '%s' attribute not present on element %s" % (self._attr_name, obj.tag) ) return self._simple_type.from_xml(attr_str_value) @@ -287,14 +268,12 @@ 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 - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Baseline behavior for adding the appropriate methods to `element_cls`.""" self._element_cls = element_cls self._prop_name = prop_name @@ -444,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): @@ -455,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 @@ -483,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) @@ -508,9 +486,7 @@ class OneAndOnlyOne(_BaseChildElement): def __init__(self, nsptagname: str): super(OneAndOnlyOne, self).__init__(nsptagname, ()) - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" super(OneAndOnlyOne, self).populate_class_members(element_cls, prop_name) self._add_getter() @@ -528,9 +504,7 @@ def get_child_element(obj: BaseOxmlElement): ) return child - get_child_element.__doc__ = ( - "Required ``<%s>`` child element." % self._nsptagname - ) + get_child_element.__doc__ = "Required ``<%s>`` child element." % self._nsptagname return get_child_element @@ -538,9 +512,7 @@ class OneOrMore(_BaseChildElement): """Defines a repeating child element for MetaOxmlElement that must appear at least once.""" - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" super(OneOrMore, self).populate_class_members(element_cls, prop_name) self._add_list_getter() @@ -554,9 +526,7 @@ def populate_class_members( class ZeroOrMore(_BaseChildElement): """Defines an optional repeating child element for MetaOxmlElement.""" - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" super(ZeroOrMore, self).populate_class_members(element_cls, prop_name) self._add_list_getter() @@ -570,9 +540,7 @@ def populate_class_members( class ZeroOrOne(_BaseChildElement): """Defines an optional child element for MetaOxmlElement.""" - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" super(ZeroOrOne, self).populate_class_members(element_cls, prop_name) self._add_getter() @@ -604,9 +572,7 @@ def _add_remover(self): def _remove_child(obj: BaseOxmlElement): obj.remove_all(self._nsptagname) - _remove_child.__doc__ = ( - "Remove all ``<%s>`` child elements." - ) % self._nsptagname + _remove_child.__doc__ = ("Remove all ``<%s>`` child elements.") % self._nsptagname self._add_to_class(self._remove_method_name, _remove_child) @lazyproperty @@ -618,20 +584,16 @@ 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 - def populate_class_members( - self, element_cls: MetaOxmlElement, prop_name: str - ) -> None: + def populate_class_members(self, element_cls: MetaOxmlElement, prop_name: str) -> None: """Add the appropriate methods to `element_cls`.""" super(ZeroOrOneChoice, self).populate_class_members(element_cls, prop_name) self._add_choice_getter() for choice in self._choices: - choice.populate_class_members( - element_cls, self._prop_name, self._successors - ) + choice.populate_class_members(element_cls, self._prop_name, self._successors) self._add_group_remover() def _add_choice_getter(self): @@ -649,9 +611,7 @@ def _remove_choice_group(obj: BaseOxmlElement): for tagname in self._member_nsptagnames: obj.remove_all(tagname) - _remove_choice_group.__doc__ = ( - "Remove the current choice group child element if present." - ) + _remove_choice_group.__doc__ = "Remove the current choice group child element if present." self._add_to_class(self._remove_choice_group_method_name, _remove_choice_group) @property @@ -680,9 +640,7 @@ def _remove_choice_group_method_name(self): # -- lxml typing isn't quite right here, just ignore this error on _Element -- -class BaseOxmlElement( # pyright: ignore[reportGeneralTypeIssues] - etree.ElementBase, metaclass=MetaOxmlElement -): +class BaseOxmlElement(etree.ElementBase, metaclass=MetaOxmlElement): """Effective base class for all custom element classes. Adds standardized behavior to all classes in one place. @@ -726,9 +684,7 @@ def xml(self) -> str: """ return serialize_for_reading(self) - def xpath( # pyright: ignore[reportIncompatibleMethodOverride] - self, xpath_str: str - ) -> Any: + def xpath(self, xpath_str: str) -> Any: # pyright: ignore[reportIncompatibleMethodOverride] """Override of `lxml` _Element.xpath() method. Provides standard Open XML namespace mapping (`nsmap`) in centralized location. diff --git a/src/docx/package.py b/src/docx/package.py index 12a166bf3..7ea47e6e1 100644 --- a/src/docx/package.py +++ b/src/docx/package.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import IO +from typing import IO, cast from docx.image.image import Image from docx.opc.constants import RELATIONSHIP_TYPE as RT @@ -44,16 +44,16 @@ def _gather_image_parts(self): continue if rel.target_part in self.image_parts: continue - self.image_parts.append(rel.target_part) + self.image_parts.append(cast("ImagePart", rel.target_part)) class ImageParts: """Collection of |ImagePart| objects corresponding to images in the package.""" def __init__(self): - self._image_parts = [] + self._image_parts: list[ImagePart] = [] - def __contains__(self, item): + def __contains__(self, item: object): return self._image_parts.__contains__(item) def __iter__(self): @@ -62,7 +62,7 @@ def __iter__(self): def __len__(self): return self._image_parts.__len__() - def append(self, item): + def append(self, item: ImagePart): self._image_parts.append(item) def get_or_add_image_part(self, image_descriptor: str | IO[bytes]) -> ImagePart: @@ -77,15 +77,14 @@ def get_or_add_image_part(self, image_descriptor: str | IO[bytes]) -> ImagePart: return matching_image_part return self._add_image_part(image) - def _add_image_part(self, image): - """Return an |ImagePart| instance newly created from image and appended to the - collection.""" + def _add_image_part(self, image: Image): + """Return |ImagePart| instance newly created from `image` and appended to the collection.""" partname = self._next_image_partname(image.ext) image_part = ImagePart.from_image(image, partname) self.append(image_part) return image_part - def _get_by_sha1(self, sha1): + def _get_by_sha1(self, sha1: str) -> ImagePart | None: """Return the image part in this collection having a SHA1 hash matching `sha1`, or |None| if not found.""" for image_part in self._image_parts: @@ -93,7 +92,7 @@ def _get_by_sha1(self, sha1): return image_part return None - def _next_image_partname(self, ext): + def _next_image_partname(self, ext: str) -> PackURI: """The next available image partname, starting from ``/word/media/image1.{ext}`` where unused numbers are reused. @@ -101,7 +100,7 @@ def _next_image_partname(self, ext): not include the leading period. """ - def image_partname(n): + def image_partname(n: int) -> PackURI: return PackURI("/word/media/image%d.%s" % (n, ext)) used_numbers = [image_part.partname.idx for image_part in self] diff --git a/src/docx/parts/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 a157764b9..4960264b1 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -2,11 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast +from typing import IO, TYPE_CHECKING, cast from docx.document import Document -from docx.enum.style import WD_STYLE_TYPE 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,10 @@ 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 @@ -41,7 +45,12 @@ def add_header_part(self): return header_part, rId @property - def core_properties(self): + 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 of this document.""" return self.package.core_properties @@ -87,26 +96,25 @@ 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) return numbering_part - def save(self, path_or_stream): + def save(self, path_or_stream: str | IO[bytes]): """Save this document to `path_or_stream`, which can be either a path to a filesystem location (a string) or a file-like object.""" self.package.save(path_or_stream) @property - def settings(self): + def settings(self) -> Settings: """A |Settings| object providing access to the settings in the settings part of this document.""" return self._settings_part.settings @@ -118,14 +126,28 @@ def styles(self): return self._styles_part.styles @property - def _settings_part(self): + 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 this document. Creates a default settings part if one is not present. """ try: - return self.part_related_by(RT.SETTINGS) + return cast(SettingsPart, self.part_related_by(RT.SETTINGS)) except KeyError: settings_part = SettingsPart.default(self.package) self.relate_to(settings_part, RT.SETTINGS) diff --git a/src/docx/parts/hdrftr.py b/src/docx/parts/hdrftr.py index 46821d780..35113801c 100644 --- a/src/docx/parts/hdrftr.py +++ b/src/docx/parts/hdrftr.py @@ -1,17 +1,23 @@ """Header and footer part objects.""" +from __future__ import annotations + import os +from typing import TYPE_CHECKING from docx.opc.constants import CONTENT_TYPE as CT from docx.oxml.parser import parse_xml from docx.parts.story import StoryPart +if TYPE_CHECKING: + from docx.package import Package + class FooterPart(StoryPart): """Definition of a section footer.""" @classmethod - def new(cls, package): + def new(cls, package: Package): """Return newly created footer part.""" partname = package.next_partname("/word/footer%d.xml") content_type = CT.WML_FOOTER @@ -21,9 +27,7 @@ def new(cls, package): @classmethod def _default_footer_xml(cls): """Return bytes containing XML for a default footer part.""" - path = os.path.join( - os.path.split(__file__)[0], "..", "templates", "default-footer.xml" - ) + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-footer.xml") with open(path, "rb") as f: xml_bytes = f.read() return xml_bytes @@ -33,7 +37,7 @@ class HeaderPart(StoryPart): """Definition of a section header.""" @classmethod - def new(cls, package): + def new(cls, package: Package): """Return newly created header part.""" partname = package.next_partname("/word/header%d.xml") content_type = CT.WML_HEADER @@ -43,9 +47,7 @@ def new(cls, package): @classmethod def _default_header_xml(cls): """Return bytes containing XML for a default header part.""" - path = os.path.join( - os.path.split(__file__)[0], "..", "templates", "default-header.xml" - ) + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-header.xml") with open(path, "rb") as f: xml_bytes = f.read() return xml_bytes diff --git a/src/docx/parts/image.py b/src/docx/parts/image.py index e4580df74..5aec07077 100644 --- a/src/docx/parts/image.py +++ b/src/docx/parts/image.py @@ -3,11 +3,16 @@ from __future__ import annotations import hashlib +from typing import TYPE_CHECKING from docx.image.image import Image from docx.opc.part import Part from docx.shared import Emu, Inches +if TYPE_CHECKING: + from docx.opc.package import OpcPackage + from docx.opc.packuri import PackURI + class ImagePart(Part): """An image part. @@ -16,7 +21,7 @@ class ImagePart(Part): """ def __init__( - self, partname: str, content_type: str, blob: bytes, image: Image | None = None + self, partname: PackURI, content_type: str, blob: bytes, image: Image | None = None ): super(ImagePart, self).__init__(partname, content_type, blob) self._image = image @@ -36,7 +41,7 @@ def default_cy(self): vertical dots per inch (dpi).""" px_height = self.image.px_height horz_dpi = self.image.horz_dpi - height_in_emu = 914400 * px_height / horz_dpi + height_in_emu = int(round(914400 * px_height / horz_dpi)) return Emu(height_in_emu) @property @@ -52,7 +57,7 @@ def filename(self): return "image.%s" % self.partname.ext @classmethod - def from_image(cls, image, partname): + def from_image(cls, image: Image, partname: PackURI): """Return an |ImagePart| instance newly created from `image` and assigned `partname`.""" return ImagePart(partname, image.content_type, image.blob, image) @@ -64,7 +69,7 @@ def image(self) -> Image: return self._image @classmethod - def load(cls, partname, content_type, blob, package): + def load(cls, partname: PackURI, content_type: str, blob: bytes, package: OpcPackage): """Called by ``docx.opc.package.PartFactory`` to load an image part from a package being opened by ``Document(...)`` call.""" return cls(partname, content_type, blob) @@ -72,4 +77,4 @@ def load(cls, partname, content_type, blob, package): @property def sha1(self): """SHA1 hash digest of the blob of this image part.""" - return hashlib.sha1(self._blob).hexdigest() + return hashlib.sha1(self.blob).hexdigest() diff --git a/src/docx/parts/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 d83c9d5ca..7fe371f09 100644 --- a/src/docx/parts/settings.py +++ b/src/docx/parts/settings.py @@ -1,6 +1,9 @@ """|SettingsPart| and closely related objects.""" +from __future__ import annotations + import os +from typing import TYPE_CHECKING, cast from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.packuri import PackURI @@ -8,31 +11,40 @@ from docx.oxml.parser import parse_xml from docx.settings import Settings +if TYPE_CHECKING: + from docx.oxml.settings import CT_Settings + from docx.package import Package + class SettingsPart(XmlPart): """Document-level settings part of a WordprocessingML (WML) package.""" + def __init__( + self, partname: PackURI, content_type: str, element: CT_Settings, package: Package + ): + super().__init__(partname, content_type, element, package) + self._settings = element + @classmethod - def default(cls, package): - """Return a newly created settings part, containing a default `w:settings` - element tree.""" + def default(cls, package: Package): + """Return a newly created settings part, containing a default `w:settings` element tree.""" partname = PackURI("/word/settings.xml") content_type = CT.WML_SETTINGS - element = parse_xml(cls._default_settings_xml()) + element = cast("CT_Settings", parse_xml(cls._default_settings_xml())) return cls(partname, content_type, element, package) @property - def settings(self): - """A |Settings| proxy object for the `w:settings` element in this part, - containing the document-level settings for this document.""" - return Settings(self.element) + def settings(self) -> Settings: + """A |Settings| proxy object for the `w:settings` element in this part. + + Contains the document-level settings for this document. + """ + return Settings(self._settings) @classmethod def _default_settings_xml(cls): """Return a bytestream containing XML for a default settings part.""" - path = os.path.join( - os.path.split(__file__)[0], "..", "templates", "default-settings.xml" - ) + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-settings.xml") with open(path, "rb") as f: xml_bytes = f.read() return xml_bytes diff --git a/src/docx/parts/story.py b/src/docx/parts/story.py index b5c8ac882..7482c91a8 100644 --- a/src/docx/parts/story.py +++ b/src/docx/parts/story.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import IO, TYPE_CHECKING, Tuple +from typing import IO, TYPE_CHECKING, Tuple, cast from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.part import XmlPart @@ -60,8 +60,8 @@ def get_style_id( def new_pic_inline( self, image_descriptor: str | IO[bytes], - width: Length | None = None, - height: Length | None = None, + width: int | Length | None = None, + height: int | Length | None = None, ) -> CT_Inline: """Return a newly-created `w:inline` element. @@ -92,4 +92,4 @@ def _document_part(self) -> DocumentPart: """|DocumentPart| object for this package.""" package = self.package assert package is not None - return package.main_document_part + return cast("DocumentPart", package.main_document_part) diff --git a/src/docx/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/py.typed b/src/docx/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/docx/section.py b/src/docx/section.py index f72b60867..982a14370 100644 --- a/src/docx/section.py +++ b/src/docx/section.py @@ -160,11 +160,7 @@ def iter_inner_content(self) -> Iterator[Paragraph | Table]: Items appear in document order. """ for element in self._sectPr.iter_inner_content(): - yield ( - Paragraph(element, self) # pyright: ignore[reportGeneralTypeIssues] - if isinstance(element, CT_P) - else Table(element, self) - ) + yield (Paragraph(element, self) if isinstance(element, CT_P) else Table(element, self)) @property def left_margin(self) -> Length | None: @@ -269,12 +265,10 @@ def __init__(self, document_elm: CT_Document, document_part: DocumentPart): self._document_part = document_part @overload - def __getitem__(self, key: int) -> Section: - ... + def __getitem__(self, key: int) -> Section: ... @overload - def __getitem__(self, key: slice) -> List[Section]: - ... + def __getitem__(self, key: slice) -> List[Section]: ... def __getitem__(self, key: int | slice) -> Section | List[Section]: if isinstance(key, slice): diff --git a/src/docx/settings.py b/src/docx/settings.py index 78f816e87..0a5aa2f36 100644 --- a/src/docx/settings.py +++ b/src/docx/settings.py @@ -1,7 +1,16 @@ """Settings object, providing access to document-level settings.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + from docx.shared import ElementProxy +if TYPE_CHECKING: + import docx.types as t + from docx.oxml.settings import CT_Settings + from docx.oxml.xmlchemy import BaseOxmlElement + class Settings(ElementProxy): """Provides access to document-level settings for a document. @@ -9,14 +18,18 @@ class Settings(ElementProxy): Accessed using the :attr:`.Document.settings` property. """ + def __init__(self, element: BaseOxmlElement, parent: t.ProvidesXmlPart | None = None): + super().__init__(element, parent) + self._settings = cast("CT_Settings", element) + @property - def odd_and_even_pages_header_footer(self): + def odd_and_even_pages_header_footer(self) -> bool: """True if this document has distinct odd and even page headers and footers. Read/write. """ - return self._element.evenAndOddHeaders_val + return self._settings.evenAndOddHeaders_val @odd_and_even_pages_header_footer.setter - def odd_and_even_pages_header_footer(self, value): - self._element.evenAndOddHeaders_val = value + def odd_and_even_pages_header_footer(self, value: bool): + self._settings.evenAndOddHeaders_val = value diff --git a/src/docx/shape.py b/src/docx/shape.py index b91ecbf64..cd35deb35 100644 --- a/src/docx/shape.py +++ b/src/docx/shape.py @@ -3,26 +3,36 @@ A shape is a visual object that appears on the drawing layer of a document. """ +from __future__ import annotations + +from typing import TYPE_CHECKING + from docx.enum.shape import WD_INLINE_SHAPE from docx.oxml.ns import nsmap from docx.shared import Parented +if TYPE_CHECKING: + from docx.oxml.document import CT_Body + from docx.oxml.shape import CT_Inline + from docx.parts.story import StoryPart + from docx.shared import Length + class InlineShapes(Parented): - """Sequence of |InlineShape| instances, supporting len(), iteration, and indexed - access.""" + """Sequence of |InlineShape| instances, supporting len(), iteration, and indexed access.""" - def __init__(self, body_elm, parent): + def __init__(self, body_elm: CT_Body, parent: StoryPart): super(InlineShapes, self).__init__(parent) self._body = body_elm - def __getitem__(self, idx): + def __getitem__(self, idx: int): """Provide indexed access, e.g. 'inline_shapes[idx]'.""" try: inline = self._inline_lst[idx] except IndexError: msg = "inline shape index [%d] out of range" % idx raise IndexError(msg) + return InlineShape(inline) def __iter__(self): @@ -42,12 +52,12 @@ class InlineShape: """Proxy for an ```` element, representing the container for an inline graphical object.""" - def __init__(self, inline): + def __init__(self, inline: CT_Inline): super(InlineShape, self).__init__() self._inline = inline @property - def height(self): + def height(self) -> Length: """Read/write. The display height of this inline shape as an |Emu| instance. @@ -55,7 +65,7 @@ def height(self): return self._inline.extent.cy @height.setter - def height(self, cy): + def height(self, cy: Length): self._inline.extent.cy = cy self._inline.graphic.graphicData.pic.spPr.cy = cy @@ -88,6 +98,6 @@ def width(self): return self._inline.extent.cx @width.setter - def width(self, cx): + def width(self, cx: Length): self._inline.extent.cx = cx self._inline.graphic.graphicData.pic.spPr.cx = cx diff --git a/src/docx/shared.py b/src/docx/shared.py index 7b696202f..6c12dc91e 100644 --- a/src/docx/shared.py +++ b/src/docx/shared.py @@ -16,7 +16,7 @@ ) if TYPE_CHECKING: - from docx import types as t + import docx.types as t from docx.opc.part import XmlPart from docx.oxml.xmlchemy import BaseOxmlElement from docx.parts.story import StoryPart @@ -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)) @@ -284,9 +282,7 @@ class ElementProxy: common type of class in python-docx other than custom element (oxml) classes. """ - def __init__( - self, element: BaseOxmlElement, parent: t.ProvidesXmlPart | None = None - ): + def __init__(self, element: BaseOxmlElement, parent: t.ProvidesXmlPart | None = None): self._element = element self._parent = parent @@ -332,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/table.py b/src/docx/table.py index 31372284c..545c46884 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -2,27 +2,37 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Tuple, overload +from typing import TYPE_CHECKING, Iterator, cast, overload + +from typing_extensions import TypeAlias from docx.blkcntnr import BlockItemContainer from docx.enum.style import WD_STYLE_TYPE +from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT from docx.oxml.simpletypes import ST_Merge -from docx.shared import Inches, Parented, lazyproperty +from docx.oxml.table import CT_TblGridCol +from docx.shared import Inches, Parented, StoryChild, lazyproperty if TYPE_CHECKING: - from docx import types as t - from docx.enum.table import WD_TABLE_ALIGNMENT, WD_TABLE_DIRECTION - from docx.oxml.table import CT_Tbl, CT_TblPr + import docx.types as t + from docx.enum.table import WD_ROW_HEIGHT_RULE, WD_TABLE_ALIGNMENT, WD_TABLE_DIRECTION + from docx.oxml.table import CT_Row, CT_Tbl, CT_TblPr, CT_Tc from docx.shared import Length - from docx.styles.style import _TableStyle # pyright: ignore[reportPrivateUsage] + from docx.styles.style import ( + ParagraphStyle, + _TableStyle, # pyright: ignore[reportPrivateUsage] + ) + +TableParent: TypeAlias = "Table | _Columns | _Rows" -class Table(Parented): +class Table(StoryChild): """Proxy class for a WordprocessingML ```` element.""" - def __init__(self, tbl: CT_Tbl, parent: t.StoryChild): + def __init__(self, tbl: CT_Tbl, parent: t.ProvidesStoryPart): super(Table, self).__init__(parent) - self._element = self._tbl = tbl + self._element = tbl + self._tbl = tbl def add_column(self, width: Length): """Return a |_Column| object of `width`, newly added rightmost to the table.""" @@ -40,7 +50,8 @@ def add_row(self): tr = tbl.add_tr() for gridCol in tbl.tblGrid.gridCol_lst: tc = tr.add_tc() - tc.width = gridCol.w + if gridCol.w is not None: + tc.width = gridCol.w return _Row(tr, self) @property @@ -79,7 +90,7 @@ def cell(self, row_idx: int, col_idx: int) -> _Cell: cell_idx = col_idx + (row_idx * self._column_count) return self._cells[cell_idx] - def column_cells(self, column_idx: int) -> List[_Cell]: + def column_cells(self, column_idx: int) -> list[_Cell]: """Sequence of cells in the column at `column_idx` in this table.""" cells = self._cells idxs = range(column_idx, len(cells), self._column_count) @@ -90,8 +101,11 @@ def columns(self): """|_Columns| instance representing the sequence of columns in this table.""" return _Columns(self._tbl, self) - def row_cells(self, row_idx: int) -> List[_Cell]: - """Sequence of cells in the row at `row_idx` in this table.""" + def row_cells(self, row_idx: int) -> list[_Cell]: + """DEPRECATED: Use `table.rows[row_idx].cells` instead. + + Sequence of cells in the row at `row_idx` in this table. + """ column_count = self._column_count start = row_idx * column_count end = start + column_count @@ -116,10 +130,10 @@ def style(self) -> _TableStyle | None: `Light Shading - Accent 1` becomes `Light Shading Accent 1`. """ style_id = self._tbl.tblStyle_val - return self.part.get_style(style_id, WD_STYLE_TYPE.TABLE) + return cast("_TableStyle | None", self.part.get_style(style_id, WD_STYLE_TYPE.TABLE)) @style.setter - def style(self, style_or_name: _TableStyle | None): + def style(self, style_or_name: _TableStyle | str | None): style_id = self.part.get_style_id(style_or_name, WD_STYLE_TYPE.TABLE) self._tbl.tblStyle_val = style_id @@ -140,21 +154,21 @@ def table_direction(self) -> WD_TABLE_DIRECTION | None: For example: `WD_TABLE_DIRECTION.LTR`. |None| indicates the value is inherited from the style hierarchy. """ - return self._element.bidiVisual_val + return cast("WD_TABLE_DIRECTION | None", self._tbl.bidiVisual_val) @table_direction.setter def table_direction(self, value: WD_TABLE_DIRECTION | None): self._element.bidiVisual_val = value @property - def _cells(self) -> List[_Cell]: + def _cells(self) -> list[_Cell]: """A sequence of |_Cell| objects, one for each cell of the layout grid. If the table contains a span, one or more |_Cell| object references are repeated. """ col_count = self._column_count - cells = [] + cells: list[_Cell] = [] for tc in self._tbl.iter_tcs(): for grid_span_idx in range(tc.grid_span): if tc.vMerge == ST_Merge.CONTINUE: @@ -178,11 +192,12 @@ def _tblPr(self) -> CT_TblPr: class _Cell(BlockItemContainer): """Table cell.""" - def __init__(self, tc, parent): - super(_Cell, self).__init__(tc, parent) + def __init__(self, tc: CT_Tc, parent: TableParent): + super(_Cell, self).__init__(tc, cast("t.ProvidesStoryPart", parent)) + self._parent = parent self._tc = self._element = tc - def add_paragraph(self, text="", style=None): + def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None): """Return a paragraph newly added to the end of the content in this cell. If present, `text` is added to the paragraph in a single run. If specified, the @@ -195,9 +210,12 @@ def add_paragraph(self, text="", style=None): """ return super(_Cell, self).add_paragraph(text, style) - def add_table(self, rows, cols): - """Return a table newly added to this cell after any existing cell content, - having `rows` rows and `cols` columns. + def add_table( # pyright: ignore[reportIncompatibleMethodOverride] + self, rows: int, cols: int + ) -> Table: + """Return a table newly added to this cell after any existing cell content. + + The new table will have `rows` rows and `cols` columns. An empty paragraph is added after the table because Word requires a paragraph element as the last element in every cell. @@ -207,7 +225,16 @@ def add_table(self, rows, cols): self.add_paragraph() return table - def merge(self, other_cell): + @property + def grid_span(self) -> int: + """Number of layout-grid cells this cell spans horizontally. + + A "normal" cell has a grid-span of 1. A horizontally merged cell has a grid-span of 2 or + more. + """ + return self._tc.grid_span + + def merge(self, other_cell: _Cell): """Return a merged cell created by spanning the rectangular region having this cell and `other_cell` as diagonal corners. @@ -244,7 +271,7 @@ def text(self) -> str: return "\n".join(p.text for p in self.paragraphs) @text.setter - def text(self, text): + def text(self, text: str): """Write-only. Set entire contents of cell to the string `text`. Any existing content or @@ -270,7 +297,7 @@ def vertical_alignment(self): return tcPr.vAlign_val @vertical_alignment.setter - def vertical_alignment(self, value): + def vertical_alignment(self, value: WD_CELL_VERTICAL_ALIGNMENT | None): tcPr = self._element.get_or_add_tcPr() tcPr.vAlign_val = value @@ -280,34 +307,35 @@ def width(self): return self._tc.width @width.setter - def width(self, value): + def width(self, value: Length): self._tc.width = value class _Column(Parented): """Table column.""" - def __init__(self, gridCol, parent): + def __init__(self, gridCol: CT_TblGridCol, parent: TableParent): super(_Column, self).__init__(parent) + self._parent = parent self._gridCol = gridCol @property - def cells(self): + def cells(self) -> tuple[_Cell, ...]: """Sequence of |_Cell| instances corresponding to cells in this column.""" return tuple(self.table.column_cells(self._index)) @property - def table(self): + def table(self) -> Table: """Reference to the |Table| object this column belongs to.""" return self._parent.table @property - def width(self): + def width(self) -> Length | None: """The width of this column in EMU, or |None| if no explicit width is set.""" return self._gridCol.w @width.setter - def width(self, value): + def width(self, value: Length | None): self._gridCol.w = value @property @@ -322,11 +350,12 @@ class _Columns(Parented): Supports ``len()``, iteration and indexed access. """ - def __init__(self, tbl, parent): + def __init__(self, tbl: CT_Tbl, parent: TableParent): super(_Columns, self).__init__(parent) + self._parent = parent self._tbl = tbl - def __getitem__(self, idx): + def __getitem__(self, idx: int): """Provide indexed access, e.g. 'columns[0]'.""" try: gridCol = self._gridCol_lst[idx] @@ -343,7 +372,7 @@ def __len__(self): return len(self._gridCol_lst) @property - def table(self): + def table(self) -> Table: """Reference to the |Table| object this column collection belongs to.""" return self._parent.table @@ -358,42 +387,119 @@ def _gridCol_lst(self): class _Row(Parented): """Table row.""" - def __init__(self, tr, parent): + def __init__(self, tr: CT_Row, parent: TableParent): super(_Row, self).__init__(parent) + self._parent = parent self._tr = self._element = tr @property - def cells(self) -> Tuple[_Cell]: - """Sequence of |_Cell| instances corresponding to cells in this row.""" - return tuple(self.table.row_cells(self._index)) + def cells(self) -> tuple[_Cell, ...]: + """Sequence of |_Cell| instances corresponding to cells in this row. + + Note that Word allows table rows to start later than the first column and end before the + last column. + + - Only cells actually present are included in the return value. + - This implies the length of this cell sequence may differ between rows of the same table. + - If you are reading the cells from each row to form a rectangular "matrix" data structure + of the table cell values, you will need to account for empty leading and/or trailing + layout-grid positions using `.grid_cols_before` and `.grid_cols_after`. + + """ + + def iter_tc_cells(tc: CT_Tc) -> Iterator[_Cell]: + """Generate a cell object for each layout-grid cell in `tc`. + + In particular, a `` element with a horizontal "span" with generate the same cell + multiple times, one for each grid-cell being spanned. This approximates a row in a + "uniform" table, where each row has a cell for each column in the table. + """ + # -- a cell comprising the second or later row of a vertical span is indicated by + # -- tc.vMerge="continue" (the default value of the `w:vMerge` attribute, when it is + # -- present in the XML). The `w:tc` element at the same grid-offset in the prior row + # -- is guaranteed to be the same width (gridSpan). So we can delegate content + # -- discovery to that prior-row `w:tc` element (recursively) until we arrive at the + # -- "root" cell -- for the vertical span. + if tc.vMerge == "continue": + yield from iter_tc_cells(tc._tc_above) # pyright: ignore[reportPrivateUsage] + return + + # -- Otherwise, vMerge is either "restart" or None, meaning this `tc` holds the actual + # -- content of the cell (whether it is vertically merged or not). + cell = _Cell(tc, self.table) + for _ in range(tc.grid_span): + yield cell + + def _iter_row_cells() -> Iterator[_Cell]: + """Generate `_Cell` instance for each populated layout-grid cell in this row.""" + for tc in self._tr.tc_lst: + yield from iter_tc_cells(tc) + + return tuple(_iter_row_cells()) @property - def height(self): + def grid_cols_after(self) -> int: + """Count of unpopulated grid-columns after the last cell in this row. + + Word allows a row to "end early", meaning that one or more cells are not present at the + end of that row. + + Note these are not simply "empty" cells. The renderer reads this value and "skips" this + many columns after drawing the last cell. + + Note this also implies that not all rows are guaranteed to have the same number of cells, + e.g. `_Row.cells` could have length `n` for one row and `n - m` for the next row in the same + table. Visually this appears as a column (at the beginning or end, not in the middle) with + one or more cells missing. + """ + return self._tr.grid_after + + @property + def grid_cols_before(self) -> int: + """Count of unpopulated grid-columns before the first cell in this row. + + Word allows a row to "start late", meaning that one or more cells are not present at the + beginning of that row. + + Note these are not simply "empty" cells. The renderer reads this value and skips forward to + the table layout-grid position of the first cell in this row; the renderer "skips" this many + columns before drawing the first cell. + + Note this also implies that not all rows are guaranteed to have the same number of cells, + e.g. `_Row.cells` could have length `n` for one row and `n - m` for the next row in the same + table. + """ + return self._tr.grid_before + + @property + def height(self) -> Length | None: """Return a |Length| object representing the height of this cell, or |None| if no explicit height is set.""" return self._tr.trHeight_val @height.setter - def height(self, value): + def height(self, value: Length | None): self._tr.trHeight_val = value @property - def height_rule(self): - """Return the height rule of this cell as a member of the :ref:`WdRowHeightRule` - enumeration, or |None| if no explicit height_rule is set.""" + def height_rule(self) -> WD_ROW_HEIGHT_RULE | None: + """Return the height rule of this cell as a member of the :ref:`WdRowHeightRule`. + + This value is |None| if no explicit height_rule is set. + """ return self._tr.trHeight_hRule @height_rule.setter - def height_rule(self, value): + def height_rule(self, value: WD_ROW_HEIGHT_RULE | None): self._tr.trHeight_hRule = value @property - def table(self): + def table(self) -> Table: """Reference to the |Table| object this row belongs to.""" return self._parent.table @property - def _index(self): + def _index(self) -> int: """Index of this row in its table, starting from zero.""" return self._tr.tr_idx @@ -404,19 +510,18 @@ class _Rows(Parented): Supports ``len()``, iteration, indexed access, and slicing. """ - def __init__(self, tbl, parent): + def __init__(self, tbl: CT_Tbl, parent: TableParent): super(_Rows, self).__init__(parent) + self._parent = parent self._tbl = tbl @overload - def __getitem__(self, idx: int) -> _Row: - ... + def __getitem__(self, idx: int) -> _Row: ... @overload - def __getitem__(self, idx: slice) -> List[_Row]: - ... + def __getitem__(self, idx: slice) -> list[_Row]: ... - def __getitem__(self, idx: int | slice) -> _Row | List[_Row]: + def __getitem__(self, idx: int | slice) -> _Row | list[_Row]: """Provide indexed access, (e.g. `rows[0]` or `rows[1:3]`)""" return list(self)[idx] @@ -427,6 +532,6 @@ def __len__(self): return len(self._tbl.tr_lst) @property - def table(self): + def table(self) -> Table: """Reference to the |Table| object this row collection belongs to.""" return self._parent.table diff --git a/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/hyperlink.py b/src/docx/text/hyperlink.py index 705a97ee4..a23df1c74 100644 --- a/src/docx/text/hyperlink.py +++ b/src/docx/text/hyperlink.py @@ -7,13 +7,15 @@ from __future__ import annotations -from typing import List +from typing import TYPE_CHECKING -from docx import types as t -from docx.oxml.text.hyperlink import CT_Hyperlink from docx.shared import Parented from docx.text.run import Run +if TYPE_CHECKING: + import docx.types as t + from docx.oxml.text.hyperlink import CT_Hyperlink + class Hyperlink(Parented): """Proxy object wrapping a `` element. @@ -78,7 +80,7 @@ def fragment(self) -> str: return self._hyperlink.anchor or "" @property - def runs(self) -> List[Run]: + def runs(self) -> list[Run]: """List of |Run| instances in this hyperlink. Together these define the visible text of the hyperlink. The text of a hyperlink diff --git a/src/docx/text/pagebreak.py b/src/docx/text/pagebreak.py index a5e68b5aa..0977ccea9 100644 --- a/src/docx/text/pagebreak.py +++ b/src/docx/text/pagebreak.py @@ -4,11 +4,11 @@ from typing import TYPE_CHECKING -from docx import types as t from docx.oxml.text.pagebreak import CT_LastRenderedPageBreak from docx.shared import Parented if TYPE_CHECKING: + import docx.types as t from docx.text.paragraph import Paragraph diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 0a5d67674..234ea66cb 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -4,9 +4,6 @@ from typing import TYPE_CHECKING, Iterator, List, cast -from typing_extensions import Self - -from docx import types as t from docx.enum.style import WD_STYLE_TYPE from docx.oxml.text.run import CT_R from docx.shared import StoryChild @@ -17,6 +14,7 @@ from docx.text.run import Run if TYPE_CHECKING: + import docx.types as t from docx.enum.text import WD_PARAGRAPH_ALIGNMENT from docx.oxml.text.paragraph import CT_P from docx.styles.style import CharacterStyle @@ -29,9 +27,7 @@ def __init__(self, p: CT_P, parent: t.ProvidesStoryPart): super(Paragraph, self).__init__(parent) self._p = self._element = p - def add_run( - self, text: str | None = None, style: str | CharacterStyle | None = None - ) -> Run: + def add_run(self, text: str | None = None, style: str | CharacterStyle | None = None) -> Run: """Append run containing `text` and having character-style `style`. `text` can contain tab (``\\t``) characters, which are converted to the @@ -82,7 +78,7 @@ def hyperlinks(self) -> List[Hyperlink]: def insert_paragraph_before( self, text: str | None = None, style: str | ParagraphStyle | None = None - ) -> Self: + ) -> Paragraph: """Return a newly created paragraph, inserted directly before this paragraph. If `text` is supplied, the new paragraph contains that text in a single run. If @@ -123,9 +119,7 @@ def rendered_page_breaks(self) -> List[RenderedPageBreak]: Most often an empty list, sometimes contains one page-break, but can contain more than one is rare or contrived cases. """ - return [ - RenderedPageBreak(lrpb, self) for lrpb in self._p.lastRenderedPageBreaks - ] + return [RenderedPageBreak(lrpb, self) for lrpb in self._p.lastRenderedPageBreaks] @property def runs(self) -> List[Run]: diff --git a/src/docx/text/run.py b/src/docx/text/run.py index 44c41c0fe..57ea31fa4 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -4,7 +4,6 @@ from typing import IO, TYPE_CHECKING, Iterator, cast -from docx import types as t from docx.drawing import Drawing from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_BREAK @@ -17,6 +16,7 @@ from docx.text.pagebreak import RenderedPageBreak if TYPE_CHECKING: + import docx.types as t from docx.enum.text import WD_UNDERLINE from docx.oxml.text.run import CT_R, CT_Text from docx.shared import Length @@ -59,8 +59,8 @@ def add_break(self, break_type: WD_BREAK = WD_BREAK.LINE): def add_picture( self, image_path_or_stream: str | IO[bytes], - width: Length | None = None, - height: Length | None = None, + width: int | Length | None = None, + height: int | Length | None = None, ) -> InlineShape: """Return |InlineShape| containing image identified by `image_path_or_stream`. @@ -170,11 +170,21 @@ def iter_inner_content(self) -> Iterator[str | Drawing | RenderedPageBreak]: yield item elif isinstance(item, CT_LastRenderedPageBreak): yield RenderedPageBreak(item, self) - elif isinstance( # pyright: ignore[reportUnnecessaryIsInstance] - item, CT_Drawing - ): + elif isinstance(item, CT_Drawing): # pyright: ignore[reportUnnecessaryIsInstance] yield Drawing(item, self) + 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. @@ -185,9 +195,7 @@ def style(self) -> CharacterStyle: property to |None| removes any directly-applied character style. """ style_id = self._r.style - return cast( - CharacterStyle, self.part.get_style(style_id, WD_STYLE_TYPE.CHARACTER) - ) + return cast(CharacterStyle, self.part.get_style(style_id, WD_STYLE_TYPE.CHARACTER)) @style.setter def style(self, style_or_name: str | CharacterStyle | None): @@ -237,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/parts/test_coreprops.py b/tests/opc/parts/test_coreprops.py index 1db650353..b754d2d7e 100644 --- a/tests/opc/parts/test_coreprops.py +++ b/tests/opc/parts/test_coreprops.py @@ -1,47 +1,53 @@ """Unit test suite for the docx.opc.parts.coreprops module.""" -from datetime import datetime, timedelta +from __future__ import annotations + +import datetime as dt import pytest from docx.opc.coreprops import CoreProperties +from docx.opc.package import OpcPackage +from docx.opc.packuri import PackURI from docx.opc.parts.coreprops import CorePropertiesPart -from docx.oxml.coreprops import CT_CoreProperties -from ...unitutil.mock import class_mock, instance_mock +from ...unitutil.cxml import element +from ...unitutil.mock import FixtureRequest, Mock, class_mock, instance_mock class DescribeCorePropertiesPart: - def it_provides_access_to_its_core_props_object(self, coreprops_fixture): - core_properties_part, CoreProperties_ = coreprops_fixture + """Unit-test suite for `docx.opc.parts.coreprops.CorePropertiesPart` objects.""" + + def it_provides_access_to_its_core_props_object(self, CoreProperties_: Mock, package_: Mock): + core_properties_part = CorePropertiesPart( + PackURI("/part/name"), "content/type", element("cp:coreProperties"), package_ + ) + core_properties = core_properties_part.core_properties + CoreProperties_.assert_called_once_with(core_properties_part.element) assert isinstance(core_properties, CoreProperties) - def it_can_create_a_default_core_properties_part(self): - core_properties_part = CorePropertiesPart.default(None) + def it_can_create_a_default_core_properties_part(self, package_: Mock): + core_properties_part = CorePropertiesPart.default(package_) + assert isinstance(core_properties_part, CorePropertiesPart) + # -- core_properties = core_properties_part.core_properties assert core_properties.title == "Word Document" assert core_properties.last_modified_by == "python-docx" assert core_properties.revision == 1 - delta = datetime.utcnow() - core_properties.modified - max_expected_delta = timedelta(seconds=2) + assert core_properties.modified is not None + delta = dt.datetime.now(dt.timezone.utc) - core_properties.modified + max_expected_delta = dt.timedelta(seconds=2) assert delta < max_expected_delta # fixtures --------------------------------------------- @pytest.fixture - def coreprops_fixture(self, element_, CoreProperties_): - core_properties_part = CorePropertiesPart(None, None, element_, None) - return core_properties_part, CoreProperties_ - - # fixture components ----------------------------------- - - @pytest.fixture - def CoreProperties_(self, request): + def CoreProperties_(self, request: FixtureRequest): return class_mock(request, "docx.opc.parts.coreprops.CoreProperties") @pytest.fixture - def element_(self, request): - return instance_mock(request, CT_CoreProperties) + def package_(self, request: FixtureRequest): + return instance_mock(request, OpcPackage) diff --git a/tests/opc/test_coreprops.py b/tests/opc/test_coreprops.py index 2978ad5ae..5d9743397 100644 --- a/tests/opc/test_coreprops.py +++ b/tests/opc/test_coreprops.py @@ -1,160 +1,153 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for the docx.opc.coreprops module.""" -from datetime import datetime +from __future__ import annotations + +import datetime as dt +from typing import TYPE_CHECKING, cast import pytest from docx.opc.coreprops import CoreProperties from docx.oxml.parser import parse_xml +if TYPE_CHECKING: + from docx.oxml.coreprops import CT_CoreProperties + class DescribeCoreProperties: - def it_knows_the_string_property_values(self, text_prop_get_fixture): - core_properties, prop_name, expected_value = text_prop_get_fixture + """Unit-test suite for `docx.opc.coreprops.CoreProperties` objects.""" + + @pytest.mark.parametrize( + ("prop_name", "expected_value"), + [ + ("author", "python-docx"), + ("category", ""), + ("comments", ""), + ("content_status", "DRAFT"), + ("identifier", "GXS 10.2.1ab"), + ("keywords", "foo bar baz"), + ("language", "US-EN"), + ("last_modified_by", "Steve Canny"), + ("subject", "Spam"), + ("title", "Word Document"), + ("version", "1.2.88"), + ], + ) + def it_knows_the_string_property_values( + self, prop_name: str, expected_value: str, core_properties: CoreProperties + ): actual_value = getattr(core_properties, prop_name) assert actual_value == expected_value - def it_can_change_the_string_property_values(self, text_prop_set_fixture): - core_properties, prop_name, value, expected_xml = text_prop_set_fixture - setattr(core_properties, prop_name, value) - assert core_properties._element.xml == expected_xml - - def it_knows_the_date_property_values(self, date_prop_get_fixture): - core_properties, prop_name, expected_datetime = date_prop_get_fixture - actual_datetime = getattr(core_properties, prop_name) - assert actual_datetime == expected_datetime + @pytest.mark.parametrize( + ("prop_name", "tagname", "value"), + [ + ("author", "dc:creator", "scanny"), + ("category", "cp:category", "silly stories"), + ("comments", "dc:description", "Bar foo to you"), + ("content_status", "cp:contentStatus", "FINAL"), + ("identifier", "dc:identifier", "GT 5.2.xab"), + ("keywords", "cp:keywords", "dog cat moo"), + ("language", "dc:language", "GB-EN"), + ("last_modified_by", "cp:lastModifiedBy", "Billy Bob"), + ("subject", "dc:subject", "Eggs"), + ("title", "dc:title", "Dissertation"), + ("version", "cp:version", "81.2.8"), + ], + ) + def it_can_change_the_string_property_values(self, prop_name: str, tagname: str, value: str): + coreProperties = self.coreProperties(tagname="", str_val="") + core_properties = CoreProperties(cast("CT_CoreProperties", parse_xml(coreProperties))) - def it_can_change_the_date_property_values(self, date_prop_set_fixture): - core_properties, prop_name, value, expected_xml = date_prop_set_fixture setattr(core_properties, prop_name, value) - assert core_properties._element.xml == expected_xml - - def it_knows_the_revision_number(self, revision_get_fixture): - core_properties, expected_revision = revision_get_fixture - assert core_properties.revision == expected_revision - - def it_can_change_the_revision_number(self, revision_set_fixture): - core_properties, revision, expected_xml = revision_set_fixture - core_properties.revision = revision - assert core_properties._element.xml == expected_xml - # fixtures ------------------------------------------------------- + assert core_properties._element.xml == self.coreProperties(tagname, value) - @pytest.fixture( - params=[ - ("created", datetime(2012, 11, 17, 16, 37, 40)), - ("last_printed", datetime(2014, 6, 4, 4, 28)), + @pytest.mark.parametrize( + ("prop_name", "expected_datetime"), + [ + ("created", dt.datetime(2012, 11, 17, 16, 37, 40, tzinfo=dt.timezone.utc)), + ("last_printed", dt.datetime(2014, 6, 4, 4, 28, tzinfo=dt.timezone.utc)), ("modified", None), - ] + ], ) - def date_prop_get_fixture(self, request, core_properties): - prop_name, expected_datetime = request.param - return core_properties, prop_name, expected_datetime + def it_knows_the_date_property_values( + self, prop_name: str, expected_datetime: dt.datetime, core_properties: CoreProperties + ): + actual_datetime = getattr(core_properties, prop_name) + assert actual_datetime == expected_datetime - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("prop_name", "tagname", "value", "str_val", "attrs"), + [ ( "created", "dcterms:created", - datetime(2001, 2, 3, 4, 5), + dt.datetime(2001, 2, 3, 4, 5), "2001-02-03T04:05:00Z", ' xsi:type="dcterms:W3CDTF"', ), ( "last_printed", "cp:lastPrinted", - datetime(2014, 6, 4, 4), + dt.datetime(2014, 6, 4, 4), "2014-06-04T04:00:00Z", "", ), ( "modified", "dcterms:modified", - datetime(2005, 4, 3, 2, 1), + dt.datetime(2005, 4, 3, 2, 1), "2005-04-03T02:01:00Z", ' xsi:type="dcterms:W3CDTF"', ), - ] + ], ) - def date_prop_set_fixture(self, request): - prop_name, tagname, value, str_val, attrs = request.param - coreProperties = self.coreProperties(None, None) - core_properties = CoreProperties(parse_xml(coreProperties)) + def it_can_change_the_date_property_values( + self, prop_name: str, tagname: str, value: dt.datetime, str_val: str, attrs: str + ): + coreProperties = self.coreProperties(tagname="", str_val="") + core_properties = CoreProperties(cast("CT_CoreProperties", parse_xml(coreProperties))) expected_xml = self.coreProperties(tagname, str_val, attrs) - return core_properties, prop_name, value, expected_xml - @pytest.fixture( - params=[("42", 42), (None, 0), ("foobar", 0), ("-17", 0), ("32.7", 0)] - ) - def revision_get_fixture(self, request): - str_val, expected_revision = request.param - tagname = "" if str_val is None else "cp:revision" - coreProperties = self.coreProperties(tagname, str_val) - core_properties = CoreProperties(parse_xml(coreProperties)) - return core_properties, expected_revision - - @pytest.fixture( - params=[ - (42, "42"), - ] + setattr(core_properties, prop_name, value) + + assert core_properties._element.xml == expected_xml + + @pytest.mark.parametrize( + ("str_val", "expected_value"), + [("42", 42), (None, 0), ("foobar", 0), ("-17", 0), ("32.7", 0)], ) - def revision_set_fixture(self, request): - value, str_val = request.param - coreProperties = self.coreProperties(None, None) - core_properties = CoreProperties(parse_xml(coreProperties)) + def it_knows_the_revision_number(self, str_val: str | None, expected_value: int): + tagname, str_val = ("cp:revision", str_val) if str_val else ("", "") + coreProperties = self.coreProperties(tagname, str_val or "") + core_properties = CoreProperties(cast("CT_CoreProperties", parse_xml(coreProperties))) + + assert core_properties.revision == expected_value + + @pytest.mark.parametrize(("value", "str_val"), [(42, "42")]) + def it_can_change_the_revision_number(self, value: int, str_val: str): + coreProperties = self.coreProperties(tagname="", str_val="") + core_properties = CoreProperties(cast("CT_CoreProperties", parse_xml(coreProperties))) expected_xml = self.coreProperties("cp:revision", str_val) - return core_properties, value, expected_xml - @pytest.fixture( - params=[ - ("author", "python-docx"), - ("category", ""), - ("comments", ""), - ("content_status", "DRAFT"), - ("identifier", "GXS 10.2.1ab"), - ("keywords", "foo bar baz"), - ("language", "US-EN"), - ("last_modified_by", "Steve Canny"), - ("subject", "Spam"), - ("title", "Word Document"), - ("version", "1.2.88"), - ] - ) - def text_prop_get_fixture(self, request, core_properties): - prop_name, expected_value = request.param - return core_properties, prop_name, expected_value + core_properties.revision = value - @pytest.fixture( - params=[ - ("author", "dc:creator", "scanny"), - ("category", "cp:category", "silly stories"), - ("comments", "dc:description", "Bar foo to you"), - ("content_status", "cp:contentStatus", "FINAL"), - ("identifier", "dc:identifier", "GT 5.2.xab"), - ("keywords", "cp:keywords", "dog cat moo"), - ("language", "dc:language", "GB-EN"), - ("last_modified_by", "cp:lastModifiedBy", "Billy Bob"), - ("subject", "dc:subject", "Eggs"), - ("title", "dc:title", "Dissertation"), - ("version", "cp:version", "81.2.8"), - ] - ) - def text_prop_set_fixture(self, request): - prop_name, tagname, value = request.param - coreProperties = self.coreProperties(None, None) - core_properties = CoreProperties(parse_xml(coreProperties)) - expected_xml = self.coreProperties(tagname, value) - return core_properties, prop_name, value, expected_xml + assert core_properties._element.xml == expected_xml - # fixture components --------------------------------------------- + # fixtures ------------------------------------------------------- - def coreProperties(self, tagname, str_val, attrs=""): + def coreProperties(self, tagname: str, str_val: str, attrs: str = "") -> str: tmpl = ( - '%s\n' + "%s\n" ) if not tagname: child_element = "" @@ -166,27 +159,30 @@ def coreProperties(self, tagname, str_val, attrs=""): @pytest.fixture def core_properties(self): - element = parse_xml( - b"" - b'\n\n' - b" DRAFT\n" - b" python-docx\n" - b' 2012-11-17T11:07:' - b"40-05:30\n" - b" \n" - b" GXS 10.2.1ab\n" - b" US-EN\n" - b" 2014-06-04T04:28:00Z\n" - b" foo bar baz\n" - b" Steve Canny\n" - b" 4\n" - b" Spam\n" - b" Word Document\n" - b" 1.2.88\n" - b"\n" + element = cast( + "CT_CoreProperties", + parse_xml( + b"" + b'\n\n' + b" DRAFT\n" + b" python-docx\n" + b' 2012-11-17T11:07:' + b"40-05:30\n" + b" \n" + b" GXS 10.2.1ab\n" + b" US-EN\n" + b" 2014-06-04T04:28:00Z\n" + b" foo bar baz\n" + b" Steve Canny\n" + b" 4\n" + b" Spam\n" + b" Word Document\n" + b" 1.2.88\n" + b"\n" + ), ) return CoreProperties(element) diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index 7fdeaa422..d8fcef453 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -1,5 +1,9 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for docx.opc.package module""" +from __future__ import annotations + import pytest from docx.opc.constants import RELATIONSHIP_TYPE as RT @@ -12,8 +16,8 @@ from docx.opc.rel import Relationships, _Relationship from ..unitutil.mock import ( + FixtureRequest, Mock, - PropertyMock, call, class_mock, instance_mock, @@ -25,6 +29,8 @@ class DescribeOpcPackage: + """Unit-test suite for `docx.opc.package.OpcPackage` objects.""" + def it_can_open_a_pkg_file(self, PackageReader_, PartFactory_, Unmarshaller_): # mockery ---------------------- pkg_file = Mock(name="pkg_file") @@ -42,19 +48,26 @@ def it_initializes_its_rels_collection_on_first_reference(self, Relationships_): Relationships_.assert_called_once_with(PACKAGE_URI.baseURI) assert rels == Relationships_.return_value - def it_can_add_a_relationship_to_a_part(self, pkg_with_rels_, rel_attrs_): - reltype, target, rId = rel_attrs_ - pkg = pkg_with_rels_ - # exercise --------------------- - pkg.load_rel(reltype, target, rId) - # verify ----------------------- - pkg._rels.add_relationship.assert_called_once_with(reltype, target, rId, False) + def it_can_add_a_relationship_to_a_part(self, rels_prop_: Mock, rels_: Mock, part_: Mock): + rels_prop_.return_value = rels_ + pkg = OpcPackage() + + pkg.load_rel("http://rel/type", part_, "rId99") - def it_can_establish_a_relationship_to_another_part(self, relate_to_part_fixture_): - pkg, part_, reltype, rId = relate_to_part_fixture_ - _rId = pkg.relate_to(part_, reltype) - pkg.rels.get_or_add.assert_called_once_with(reltype, part_) - assert _rId == rId + rels_.add_relationship.assert_called_once_with("http://rel/type", part_, "rId99", False) + + def it_can_establish_a_relationship_to_another_part( + self, rels_prop_: Mock, rels_: Mock, rel_: Mock, part_: Mock + ): + rel_.rId = "rId99" + rels_.get_or_add.return_value = rel_ + rels_prop_.return_value = rels_ + pkg = OpcPackage() + + rId = pkg.relate_to(part_, "http://rel/type") + + rels_.get_or_add.assert_called_once_with("http://rel/type", part_) + assert rId == "rId99" def it_can_provide_a_list_of_the_parts_it_contains(self): # mockery ---------------------- @@ -64,7 +77,7 @@ def it_can_provide_a_list_of_the_parts_it_contains(self): with patch.object(OpcPackage, "iter_parts", return_value=parts): assert pkg.parts == [parts[0], parts[1]] - def it_can_iterate_over_parts_by_walking_rels_graph(self): + def it_can_iterate_over_parts_by_walking_rels_graph(self, rels_prop_: Mock): # +----------+ +--------+ # | pkg_rels |-----> | part_1 | # +----------+ +--------+ @@ -77,7 +90,7 @@ def it_can_iterate_over_parts_by_walking_rels_graph(self): part1.rels = {1: Mock(name="rel1", is_external=False, target_part=part2)} part2.rels = {1: Mock(name="rel2", is_external=False, target_part=part1)} pkg = OpcPackage() - pkg._rels = { + rels_prop_.return_value = { 1: Mock(name="rel3", is_external=False, target_part=part1), 2: Mock(name="rel4", is_external=True), } @@ -106,21 +119,22 @@ def it_can_find_a_part_related_by_reltype(self, related_part_fixture_): pkg.rels.part_with_reltype.assert_called_once_with(reltype) assert related_part is related_part_ - def it_can_save_to_a_pkg_file(self, pkg_file_, PackageWriter_, parts, parts_): + def it_can_save_to_a_pkg_file( + self, pkg_file_: Mock, PackageWriter_: Mock, parts_prop_: Mock, parts_: list[Mock] + ): + parts_prop_.return_value = parts_ pkg = OpcPackage() pkg.save(pkg_file_) for part in parts_: part.before_marshal.assert_called_once_with() - PackageWriter_.write.assert_called_once_with(pkg_file_, pkg._rels, parts_) + PackageWriter_.write.assert_called_once_with(pkg_file_, pkg.rels, parts_) def it_provides_access_to_the_core_properties(self, core_props_fixture): opc_package, core_properties_ = core_props_fixture core_properties = opc_package.core_properties assert core_properties is core_properties_ - def it_provides_access_to_the_core_properties_part_to_help( - self, core_props_part_fixture - ): + def it_provides_access_to_the_core_properties_part_to_help(self, core_props_part_fixture): opc_package, core_properties_part_ = core_props_part_fixture core_properties_part = opc_package._core_properties_part assert core_properties_part is core_properties_part_ @@ -135,9 +149,7 @@ def it_creates_a_default_core_props_part_if_none_present( core_properties_part = opc_package._core_properties_part CorePropertiesPart_.default.assert_called_once_with(opc_package) - relate_to_.assert_called_once_with( - opc_package, core_properties_part_, RT.CORE_PROPERTIES - ) + relate_to_.assert_called_once_with(opc_package, core_properties_part_, RT.CORE_PROPERTIES) assert core_properties_part is core_properties_part_ # fixtures --------------------------------------------- @@ -161,134 +173,106 @@ def core_props_part_fixture(self, part_related_by_, core_properties_part_): def next_partname_fixture(self, request, iter_parts_): existing_partname_ns, next_partname_n = request.param parts_ = [ - instance_mock( - request, Part, name="part[%d]" % idx, partname="/foo/bar/baz%d.xml" % n - ) + instance_mock(request, Part, name="part[%d]" % idx, partname="/foo/bar/baz%d.xml" % n) for idx, n in enumerate(existing_partname_ns) ] expected_value = "/foo/bar/baz%d.xml" % next_partname_n return parts_, expected_value @pytest.fixture - def relate_to_part_fixture_(self, request, pkg, rels_, reltype): - rId = "rId99" - rel_ = instance_mock(request, _Relationship, name="rel_", rId=rId) - rels_.get_or_add.return_value = rel_ - pkg._rels = rels_ - part_ = instance_mock(request, Part, name="part_") - return pkg, part_, reltype, rId - - @pytest.fixture - def related_part_fixture_(self, request, rels_, reltype): + def related_part_fixture_(self, request: FixtureRequest, rels_prop_: Mock, rels_: Mock): related_part_ = instance_mock(request, Part, name="related_part_") rels_.part_with_reltype.return_value = related_part_ pkg = OpcPackage() - pkg._rels = rels_ - return pkg, reltype, related_part_ + rels_prop_.return_value = rels_ + return pkg, "http://rel/type", related_part_ # fixture components ----------------------------------- @pytest.fixture - def CorePropertiesPart_(self, request): + def CorePropertiesPart_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.CorePropertiesPart") @pytest.fixture - def core_properties_(self, request): + def core_properties_(self, request: FixtureRequest): return instance_mock(request, CoreProperties) @pytest.fixture - def core_properties_part_(self, request): + def core_properties_part_(self, request: FixtureRequest): return instance_mock(request, CorePropertiesPart) @pytest.fixture - def _core_properties_part_prop_(self, request): + def _core_properties_part_prop_(self, request: FixtureRequest): return property_mock(request, OpcPackage, "_core_properties_part") @pytest.fixture - def iter_parts_(self, request): + def iter_parts_(self, request: FixtureRequest): return method_mock(request, OpcPackage, "iter_parts") @pytest.fixture - def PackageReader_(self, request): + def PackageReader_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.PackageReader") @pytest.fixture - def PackURI_(self, request): + def PackURI_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.PackURI") @pytest.fixture - def packuri_(self, request): + def packuri_(self, request: FixtureRequest): return instance_mock(request, PackURI) @pytest.fixture - def PackageWriter_(self, request): + def PackageWriter_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.PackageWriter") @pytest.fixture - def PartFactory_(self, request): + def PartFactory_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.PartFactory") @pytest.fixture - def part_related_by_(self, request): - return method_mock(request, OpcPackage, "part_related_by") + def part_(self, request: FixtureRequest): + return instance_mock(request, Part) @pytest.fixture - def parts(self, parts_): - """ - Return a mock patching property OpcPackage.parts, reversing the - patch after each use. - """ - p = patch.object( - OpcPackage, "parts", new_callable=PropertyMock, return_value=parts_ - ) - yield p.start() - p.stop() + def part_related_by_(self, request: FixtureRequest): + return method_mock(request, OpcPackage, "part_related_by") @pytest.fixture - def parts_(self, request): + def parts_(self, request: FixtureRequest): part_ = instance_mock(request, Part, name="part_") part_2_ = instance_mock(request, Part, name="part_2_") return [part_, part_2_] @pytest.fixture - def pkg(self, request): - return OpcPackage() + def parts_prop_(self, request: FixtureRequest): + return property_mock(request, OpcPackage, "parts") @pytest.fixture - def pkg_file_(self, request): + def pkg_file_(self, request: FixtureRequest): return loose_mock(request) @pytest.fixture - def pkg_with_rels_(self, request, rels_): - pkg = OpcPackage() - pkg._rels = rels_ - return pkg - - @pytest.fixture - def Relationships_(self, request): + def Relationships_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.Relationships") @pytest.fixture - def rel_attrs_(self, request): - reltype = "http://rel/type" - target_ = instance_mock(request, Part, name="target_") - rId = "rId99" - return reltype, target_, rId + def rel_(self, request: FixtureRequest): + return instance_mock(request, _Relationship) @pytest.fixture - def relate_to_(self, request): + def relate_to_(self, request: FixtureRequest): return method_mock(request, OpcPackage, "relate_to") @pytest.fixture - def rels_(self, request): + def rels_(self, request: FixtureRequest): return instance_mock(request, Relationships) @pytest.fixture - def reltype(self, request): - return "http://rel/type" + def rels_prop_(self, request: FixtureRequest): + return property_mock(request, OpcPackage, "rels") @pytest.fixture - def Unmarshaller_(self, request): + def Unmarshaller_(self, request: FixtureRequest): return class_mock(request, "docx.opc.package.Unmarshaller") @@ -306,9 +290,7 @@ def it_can_unmarshal_from_a_pkg_reader( Unmarshaller.unmarshal(pkg_reader_, pkg_, part_factory_) _unmarshal_parts_.assert_called_once_with(pkg_reader_, pkg_, part_factory_) - _unmarshal_relationships_.assert_called_once_with( - pkg_reader_, pkg_, parts_dict_ - ) + _unmarshal_relationships_.assert_called_once_with(pkg_reader_, pkg_, parts_dict_) for part in parts_dict_.values(): part.after_unmarshal.assert_called_once_with() pkg_.after_unmarshal.assert_called_once_with() @@ -406,13 +388,13 @@ def it_can_unmarshal_relationships(self): # fixtures --------------------------------------------- @pytest.fixture - def blobs_(self, request): + def blobs_(self, request: FixtureRequest): blob_ = loose_mock(request, spec=str, name="blob_") blob_2_ = loose_mock(request, spec=str, name="blob_2_") return blob_, blob_2_ @pytest.fixture - def content_types_(self, request): + def content_types_(self, request: FixtureRequest): content_type_ = loose_mock(request, spec=str, name="content_type_") content_type_2_ = loose_mock(request, spec=str, name="content_type_2_") return content_type_, content_type_2_ @@ -424,13 +406,13 @@ def part_factory_(self, request, parts_): return part_factory_ @pytest.fixture - def partnames_(self, request): + def partnames_(self, request: FixtureRequest): partname_ = loose_mock(request, spec=str, name="partname_") partname_2_ = loose_mock(request, spec=str, name="partname_2_") return partname_, partname_2_ @pytest.fixture - def parts_(self, request): + def parts_(self, request: FixtureRequest): part_ = instance_mock(request, Part, name="part_") part_2_ = instance_mock(request, Part, name="part_2") return part_, part_2_ @@ -442,7 +424,7 @@ def parts_dict_(self, request, partnames_, parts_): return {partname_: part_, partname_2_: part_2_} @pytest.fixture - def pkg_(self, request): + def pkg_(self, request: FixtureRequest): return instance_mock(request, OpcPackage) @pytest.fixture @@ -460,17 +442,15 @@ def pkg_reader_(self, request, partnames_, content_types_, reltypes_, blobs_): return pkg_reader_ @pytest.fixture - def reltypes_(self, request): + def reltypes_(self, request: FixtureRequest): reltype_ = instance_mock(request, str, name="reltype_") reltype_2_ = instance_mock(request, str, name="reltype_2") return reltype_, reltype_2_ @pytest.fixture - def _unmarshal_parts_(self, request): + def _unmarshal_parts_(self, request: FixtureRequest): return method_mock(request, Unmarshaller, "_unmarshal_parts", autospec=False) @pytest.fixture - def _unmarshal_relationships_(self, request): - return method_mock( - request, Unmarshaller, "_unmarshal_relationships", autospec=False - ) + def _unmarshal_relationships_(self, request: FixtureRequest): + return method_mock(request, Unmarshaller, "_unmarshal_relationships", autospec=False) diff --git a/tests/opc/test_part.py b/tests/opc/test_part.py index 163912154..dbbcaf262 100644 --- a/tests/opc/test_part.py +++ b/tests/opc/test_part.py @@ -1,5 +1,9 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for docx.opc.part module""" +from __future__ import annotations + import pytest from docx.opc.package import OpcPackage @@ -11,6 +15,7 @@ from ..unitutil.cxml import element from ..unitutil.mock import ( ANY, + FixtureRequest, Mock, class_mock, cls_attr_mock, @@ -18,249 +23,170 @@ initializer_mock, instance_mock, loose_mock, + property_mock, ) class DescribePart: - def it_can_be_constructed_by_PartFactory( - self, partname_, content_type_, blob_, package_, __init_ - ): - part = Part.load(partname_, content_type_, blob_, package_) + """Unit-test suite for `docx.opc.part.Part` objects.""" + + def it_can_be_constructed_by_PartFactory(self, package_: Mock, init__: Mock): + part = Part.load(PackURI("/part/name"), "content/type", b"1be2", package_) - __init_.assert_called_once_with(ANY, partname_, content_type_, blob_, package_) + init__.assert_called_once_with(ANY, "/part/name", "content/type", b"1be2", package_) assert isinstance(part, Part) - def it_knows_its_partname(self, partname_get_fixture): - part, expected_partname = partname_get_fixture - assert part.partname == expected_partname + def it_knows_its_partname(self): + part = Part(PackURI("/part/name"), "content/type") + assert part.partname == "/part/name" - def it_can_change_its_partname(self, partname_set_fixture): - part, new_partname = partname_set_fixture - part.partname = new_partname - assert part.partname == new_partname + def it_can_change_its_partname(self): + part = Part(PackURI("/old/part/name"), "content/type") + part.partname = PackURI("/new/part/name") + assert part.partname == "/new/part/name" - def it_knows_its_content_type(self, content_type_fixture): - part, expected_content_type = content_type_fixture - assert part.content_type == expected_content_type + def it_knows_its_content_type(self): + part = Part(PackURI("/part/name"), "content/type") + assert part.content_type == "content/type" - def it_knows_the_package_it_belongs_to(self, package_get_fixture): - part, expected_package = package_get_fixture - assert part.package == expected_package + def it_knows_the_package_it_belongs_to(self, package_: Mock): + part = Part(PackURI("/part/name"), "content/type", package=package_) + assert part.package is package_ - def it_can_be_notified_after_unmarshalling_is_complete(self, part): + def it_can_be_notified_after_unmarshalling_is_complete(self): + part = Part(PackURI("/part/name"), "content/type") part.after_unmarshal() - def it_can_be_notified_before_marshalling_is_started(self, part): + def it_can_be_notified_before_marshalling_is_started(self): + part = Part(PackURI("/part/name"), "content/type") part.before_marshal() - def it_uses_the_load_blob_as_its_blob(self, blob_fixture): - part, load_blob = blob_fixture - assert part.blob is load_blob + def it_uses_the_load_blob_as_its_blob(self): + blob = b"abcde" + part = Part(PackURI("/part/name"), "content/type", blob) + assert part.blob is blob # fixtures --------------------------------------------- @pytest.fixture - def blob_fixture(self, blob_): - part = Part(None, None, blob_, None) - return part, blob_ + def init__(self, request: FixtureRequest): + return initializer_mock(request, Part) @pytest.fixture - def content_type_fixture(self): - content_type = "content/type" - part = Part(None, content_type, None, None) - return part, content_type + def package_(self, request: FixtureRequest): + return instance_mock(request, OpcPackage) - @pytest.fixture - def package_get_fixture(self, package_): - part = Part(None, None, None, package_) - return part, package_ - @pytest.fixture - def part(self): - part = Part(None, None) - return part +class DescribePartRelationshipManagementInterface: + """Unit-test suite for `docx.opc.package.Part` relationship behaviors.""" - @pytest.fixture - def partname_get_fixture(self): - partname = PackURI("/part/name") - part = Part(partname, None, None, None) - return part, partname + def it_provides_access_to_its_relationships( + self, Relationships_: Mock, partname_: Mock, rels_: Mock + ): + Relationships_.return_value = rels_ + part = Part(partname_, "content_type") - @pytest.fixture - def partname_set_fixture(self): - old_partname = PackURI("/old/part/name") - new_partname = PackURI("/new/part/name") - part = Part(old_partname, None, None, None) - return part, new_partname + rels = part.rels - # fixture components --------------------------------------------- + Relationships_.assert_called_once_with(partname_.baseURI) + assert rels is rels_ - @pytest.fixture - def blob_(self, request): - return instance_mock(request, bytes) + def it_can_load_a_relationship(self, rels_prop_: Mock, rels_: Mock, other_part_: Mock): + rels_prop_.return_value = rels_ + part = Part("partname", "content_type") - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) + part.load_rel("http://rel/type", other_part_, "rId42") - @pytest.fixture - def __init_(self, request): - return initializer_mock(request, Part) + rels_.add_relationship.assert_called_once_with( + "http://rel/type", other_part_, "rId42", False + ) - @pytest.fixture - def package_(self, request): - return instance_mock(request, OpcPackage) + def it_can_establish_a_relationship_to_another_part( + self, rels_prop_: Mock, rels_: Mock, rel_: Mock, other_part_: Mock + ): + rels_prop_.return_value = rels_ + rels_.get_or_add.return_value = rel_ + rel_.rId = "rId18" + part = Part("partname", "content_type") - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) + rId = part.relate_to(other_part_, "http://rel/type") + rels_.get_or_add.assert_called_once_with("http://rel/type", other_part_) + assert rId == "rId18" -class DescribePartRelationshipManagementInterface: - def it_provides_access_to_its_relationships(self, rels_fixture): - part, Relationships_, partname_, rels_ = rels_fixture - rels = part.rels - Relationships_.assert_called_once_with(partname_.baseURI) - assert rels is rels_ + def it_can_establish_an_external_relationship(self, rels_prop_: Mock, rels_: Mock): + rels_prop_.return_value = rels_ + rels_.get_or_add_ext_rel.return_value = "rId27" + part = Part("partname", "content_type") - def it_can_load_a_relationship(self, load_rel_fixture): - part, rels_, reltype_, target_, rId_ = load_rel_fixture - part.load_rel(reltype_, target_, rId_) - rels_.add_relationship.assert_called_once_with(reltype_, target_, rId_, False) - - def it_can_establish_a_relationship_to_another_part(self, relate_to_part_fixture): - part, target_, reltype_, rId_ = relate_to_part_fixture - rId = part.relate_to(target_, reltype_) - part.rels.get_or_add.assert_called_once_with(reltype_, target_) - assert rId is rId_ - - def it_can_establish_an_external_relationship(self, relate_to_url_fixture): - part, url_, reltype_, rId_ = relate_to_url_fixture - rId = part.relate_to(url_, reltype_, is_external=True) - part.rels.get_or_add_ext_rel.assert_called_once_with(reltype_, url_) - assert rId is rId_ - - def it_can_drop_a_relationship(self, drop_rel_fixture): - part, rId, rel_should_be_gone = drop_rel_fixture - part.drop_rel(rId) - if rel_should_be_gone: - assert rId not in part.rels - else: - assert rId in part.rels - - def it_can_find_a_related_part_by_reltype(self, related_part_fixture): - part, reltype_, related_part_ = related_part_fixture - related_part = part.part_related_by(reltype_) - part.rels.part_with_reltype.assert_called_once_with(reltype_) - assert related_part is related_part_ - - def it_can_find_a_related_part_by_rId(self, related_parts_fixture): - part, related_parts_ = related_parts_fixture - assert part.related_parts is related_parts_ - - def it_can_find_the_uri_of_an_external_relationship(self, target_ref_fixture): - part, rId_, url_ = target_ref_fixture - url = part.target_ref(rId_) - assert url == url_ + rId = part.relate_to("https://hyper/link", "http://rel/type", is_external=True) - # fixtures --------------------------------------------- + rels_.get_or_add_ext_rel.assert_called_once_with("http://rel/type", "https://hyper/link") + assert rId == "rId27" - @pytest.fixture( - params=[ - ("w:p", True), - ("w:p/r:a{r:id=rId42}", True), - ("w:p/r:a{r:id=rId42}/r:b{r:id=rId42}", False), - ] - ) - def drop_rel_fixture(self, request, part): - part_cxml, rel_should_be_dropped = request.param - rId = "rId42" - part._element = element(part_cxml) - part._rels = {rId: None} - return part, rId, rel_should_be_dropped + def it_can_drop_a_relationship(self, rels_prop_: Mock): + rels_prop_.return_value = {"rId42": None} + part = Part(PackURI("/partname"), "content_type") - @pytest.fixture - def load_rel_fixture(self, part, rels_, reltype_, part_, rId_): - part._rels = rels_ - return part, rels_, reltype_, part_, rId_ + part.drop_rel("rId42") - @pytest.fixture - def relate_to_part_fixture(self, request, part, reltype_, part_, rels_, rId_): - part._rels = rels_ - target_ = part_ - return part, target_, reltype_, rId_ + assert "rId42" not in part.rels - @pytest.fixture - def relate_to_url_fixture(self, request, part, rels_, url_, reltype_, rId_): - part._rels = rels_ - return part, url_, reltype_, rId_ + def it_can_find_a_related_part_by_reltype( + self, rels_prop_: Mock, rels_: Mock, other_part_: Mock + ): + rels_prop_.return_value = rels_ + rels_.part_with_reltype.return_value = other_part_ + part = Part("partname", "content_type") - @pytest.fixture - def related_part_fixture(self, request, part, rels_, reltype_, part_): - part._rels = rels_ - return part, reltype_, part_ + related_part = part.part_related_by("http://rel/type") - @pytest.fixture - def related_parts_fixture(self, request, part, rels_, related_parts_): - part._rels = rels_ - return part, related_parts_ + rels_.part_with_reltype.assert_called_once_with("http://rel/type") + assert related_part is other_part_ - @pytest.fixture - def rels_fixture(self, Relationships_, partname_, rels_): - part = Part(partname_, None) - return part, Relationships_, partname_, rels_ + def it_can_find_a_related_part_by_rId(self, rels_prop_: Mock, rels_: Mock, other_part_: Mock): + rels_prop_.return_value = rels_ + rels_.related_parts = {"rId24": other_part_} + part = Part("partname", "content_type") - @pytest.fixture - def target_ref_fixture(self, request, part, rId_, rel_, url_): - part._rels = {rId_: rel_} - return part, rId_, url_ + assert part.related_parts["rId24"] is other_part_ - # fixture components --------------------------------------------- - - @pytest.fixture - def part(self): - return Part(None, None) + def it_can_find_the_uri_of_an_external_relationship( + self, rels_prop_: Mock, rel_: Mock, other_part_: Mock + ): + rels_prop_.return_value = {"rId7": rel_} + rel_.target_ref = "https://hyper/link" + part = Part("partname", "content_type") - @pytest.fixture - def part_(self, request): - return instance_mock(request, Part) + url = part.target_ref("rId7") - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) + assert url == "https://hyper/link" - @pytest.fixture - def Relationships_(self, request, rels_): - return class_mock(request, "docx.opc.part.Relationships", return_value=rels_) + # fixtures --------------------------------------------- @pytest.fixture - def rel_(self, request, rId_, url_): - return instance_mock(request, _Relationship, rId=rId_, target_ref=url_) + def other_part_(self, request: FixtureRequest): + return instance_mock(request, Part) @pytest.fixture - def rels_(self, request, part_, rel_, rId_, related_parts_): - rels_ = instance_mock(request, Relationships) - rels_.part_with_reltype.return_value = part_ - rels_.get_or_add.return_value = rel_ - rels_.get_or_add_ext_rel.return_value = rId_ - rels_.related_parts = related_parts_ - return rels_ + def partname_(self, request: FixtureRequest): + return instance_mock(request, PackURI) @pytest.fixture - def related_parts_(self, request): - return instance_mock(request, dict) + def Relationships_(self, request: FixtureRequest): + return class_mock(request, "docx.opc.part.Relationships") @pytest.fixture - def reltype_(self, request): - return instance_mock(request, str) + def rel_(self, request: FixtureRequest): + return instance_mock(request, _Relationship) @pytest.fixture - def rId_(self, request): - return instance_mock(request, str) + def rels_(self, request: FixtureRequest): + return instance_mock(request, Relationships) @pytest.fixture - def url_(self, request): - return instance_mock(request, str) + def rels_prop_(self, request: FixtureRequest): + return property_mock(request, Part, "rels") class DescribePartFactory: @@ -278,9 +204,7 @@ def it_constructs_part_from_selector_if_defined(self, cls_selector_fixture): part = PartFactory(partname, content_type, reltype, blob, package) # verify ----------------------- cls_selector_fn_.assert_called_once_with(content_type, reltype) - CustomPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) + CustomPartClass_.load.assert_called_once_with(partname, content_type, blob, package) assert part is part_of_custom_type_ def it_constructs_custom_part_type_for_registered_content_types( @@ -292,9 +216,7 @@ def it_constructs_custom_part_type_for_registered_content_types( PartFactory.part_type_for[content_type] = CustomPartClass_ part = PartFactory(partname, content_type, reltype, blob, package) # verify ----------------------- - CustomPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) + CustomPartClass_.load.assert_called_once_with(partname, content_type, blob, package) assert part is part_of_custom_type_ def it_constructs_part_using_default_class_when_no_custom_registered( @@ -302,9 +224,7 @@ def it_constructs_part_using_default_class_when_no_custom_registered( ): partname, content_type, reltype, blob, package = part_args_2_ part = PartFactory(partname, content_type, reltype, blob, package) - DefaultPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) + DefaultPartClass_.load.assert_called_once_with(partname, content_type, blob, package) assert part is part_of_default_type_ # fixtures --------------------------------------------- @@ -319,9 +239,7 @@ def blob_2_(self, request): @pytest.fixture def cls_method_fn_(self, request, cls_selector_fn_): - return function_mock( - request, "docx.opc.part.cls_method_fn", return_value=cls_selector_fn_ - ) + return function_mock(request, "docx.opc.part.cls_method_fn", return_value=cls_selector_fn_) @pytest.fixture def cls_selector_fixture( @@ -405,9 +323,7 @@ def part_args_(self, request, partname_, content_type_, reltype_, package_, blob return partname_, content_type_, reltype_, blob_, package_ @pytest.fixture - def part_args_2_( - self, request, partname_2_, content_type_2_, reltype_2_, package_2_, blob_2_ - ): + def part_args_2_(self, request, partname_2_, content_type_2_, reltype_2_, package_2_, blob_2_): return partname_2_, content_type_2_, reltype_2_, blob_2_, package_2_ @pytest.fixture @@ -426,9 +342,7 @@ def it_can_be_constructed_by_PartFactory( part = XmlPart.load(partname_, content_type_, blob_, package_) parse_xml_.assert_called_once_with(blob_) - __init_.assert_called_once_with( - ANY, partname_, content_type_, element_, package_ - ) + __init_.assert_called_once_with(ANY, partname_, content_type_, element_, package_) assert isinstance(part, XmlPart) def it_can_serialize_to_xml(self, blob_fixture): @@ -441,6 +355,24 @@ def it_knows_its_the_part_for_its_child_objects(self, part_fixture): xml_part = part_fixture assert xml_part.part is xml_part + @pytest.mark.parametrize( + ("part_cxml", "rel_should_be_dropped"), + [ + ("w:p", True), + ("w:p/r:a{r:id=rId42}", True), + ("w:p/r:a{r:id=rId42}/r:b{r:id=rId42}", False), + ], + ) + def it_only_drops_a_relationship_with_zero_reference_count( + self, part_cxml: str, rel_should_be_dropped: bool, rels_prop_: Mock, package_: Mock + ): + rels_prop_.return_value = {"rId42": None} + part = XmlPart(PackURI("/partname"), "content_type", element(part_cxml), package_) + + part.drop_rel("rId42") + + assert ("rId42" not in part.rels) is rel_should_be_dropped + # fixtures ------------------------------------------------------- @pytest.fixture @@ -482,6 +414,10 @@ def parse_xml_(self, request, element_): def partname_(self, request): return instance_mock(request, PackURI) + @pytest.fixture + def rels_prop_(self, request: FixtureRequest): + return property_mock(request, XmlPart, "rels") + @pytest.fixture def serialize_part_xml_(self, request): return function_mock(request, "docx.opc.part.serialize_part_xml") 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_pkgwriter.py b/tests/opc/test_pkgwriter.py index 747300f82..aff8b22d9 100644 --- a/tests/opc/test_pkgwriter.py +++ b/tests/opc/test_pkgwriter.py @@ -1,5 +1,9 @@ +# pyright: reportPrivateUsage=false + """Test suite for opc.pkgwriter module.""" +from __future__ import annotations + import pytest from docx.opc.constants import CONTENT_TYPE as CT @@ -7,9 +11,10 @@ from docx.opc.part import Part from docx.opc.phys_pkg import _ZipPkgWriter from docx.opc.pkgwriter import PackageWriter, _ContentTypesItem +from docx.opc.rel import Relationships from ..unitutil.mock import ( - MagicMock, + FixtureRequest, Mock, call, class_mock, @@ -54,41 +59,48 @@ def it_can_write_a_pkg_rels_item(self): # verify ----------------------- phys_writer.write.assert_called_once_with("/_rels/.rels", pkg_rels.xml) - def it_can_write_a_list_of_parts(self): - # mockery ---------------------- - phys_writer = Mock(name="phys_writer") - rels = MagicMock(name="rels") - rels.__len__.return_value = 1 - part1 = Mock(name="part1", _rels=rels) - part2 = Mock(name="part2", _rels=[]) - # exercise --------------------- - PackageWriter._write_parts(phys_writer, [part1, part2]) - # verify ----------------------- + def it_can_write_a_list_of_parts( + self, phys_pkg_writer_: Mock, part_: Mock, part_2_: Mock, rels_: Mock + ): + rels_.__len__.return_value = 1 + part_.rels = rels_ + part_2_.rels = [] + + PackageWriter._write_parts(phys_pkg_writer_, [part_, part_2_]) + expected_calls = [ - call(part1.partname, part1.blob), - call(part1.partname.rels_uri, part1._rels.xml), - call(part2.partname, part2.blob), + call(part_.partname, part_.blob), + call(part_.partname.rels_uri, part_.rels.xml), + call(part_2_.partname, part_2_.blob), ] - assert phys_writer.write.mock_calls == expected_calls + assert phys_pkg_writer_.write.mock_calls == expected_calls # fixtures --------------------------------------------- @pytest.fixture - def blob_(self, request): + def blob_(self, request: FixtureRequest): return instance_mock(request, str) @pytest.fixture - def cti_(self, request, blob_): + def cti_(self, request: FixtureRequest, blob_): return instance_mock(request, _ContentTypesItem, blob=blob_) @pytest.fixture - def _ContentTypesItem_(self, request, cti_): + def _ContentTypesItem_(self, request: FixtureRequest, cti_): _ContentTypesItem_ = class_mock(request, "docx.opc.pkgwriter._ContentTypesItem") _ContentTypesItem_.from_parts.return_value = cti_ return _ContentTypesItem_ @pytest.fixture - def parts_(self, request): + def part_(self, request: FixtureRequest): + return instance_mock(request, Part) + + @pytest.fixture + def part_2_(self, request: FixtureRequest): + return instance_mock(request, Part) + + @pytest.fixture + def parts_(self, request: FixtureRequest): return instance_mock(request, list) @pytest.fixture @@ -98,9 +110,13 @@ def PhysPkgWriter_(self): p.stop() @pytest.fixture - def phys_pkg_writer_(self, request): + def phys_pkg_writer_(self, request: FixtureRequest): return instance_mock(request, _ZipPkgWriter) + @pytest.fixture + def rels_(self, request: FixtureRequest): + return instance_mock(request, Relationships) + @pytest.fixture def write_cti_fixture(self, _ContentTypesItem_, parts_, phys_pkg_writer_, blob_): return _ContentTypesItem_, parts_, phys_pkg_writer_, blob_ @@ -123,7 +139,7 @@ def _write_methods(self): patch3.stop() @pytest.fixture - def xml_for_(self, request): + def xml_for_(self, request: FixtureRequest): return method_mock(request, _ContentTypesItem, "xml_for") @@ -135,11 +151,9 @@ def it_can_compose_content_types_element(self, xml_for_fixture): # fixtures --------------------------------------------- - def _mock_part(self, request, name, partname_str, content_type): + def _mock_part(self, request: FixtureRequest, name, partname_str, content_type): partname = PackURI(partname_str) - return instance_mock( - request, Part, name=name, partname=partname, content_type=content_type - ) + return instance_mock(request, Part, name=name, partname=partname, content_type=content_type) @pytest.fixture( params=[ @@ -152,7 +166,7 @@ def _mock_part(self, request, name, partname_str, content_type): ("Override", "/zebra/foo.bar", "app/vnd.foobar"), ] ) - def xml_for_fixture(self, request): + def xml_for_fixture(self, request: FixtureRequest): elm_type, partname_str, content_type = request.param part_ = self._mock_part(request, "part_", partname_str, content_type) cti = _ContentTypesItem.from_parts([part_]) @@ -168,9 +182,7 @@ def xml_for_fixture(self, request): types_bldr.with_child( a_Default().with_Extension("rels").with_ContentType(CT.OPC_RELATIONSHIPS) ) - types_bldr.with_child( - a_Default().with_Extension("xml").with_ContentType(CT.XML) - ) + types_bldr.with_child(a_Default().with_Extension("xml").with_ContentType(CT.XML)) if elm_type == "Override": override_bldr = an_Override() 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 395c812a6..2c9e05344 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -1,3 +1,5 @@ +# pyright: reportPrivateUsage=false + """Test suite for the docx.oxml.text module.""" from __future__ import annotations @@ -13,174 +15,70 @@ from ..unitutil.cxml import element, xml from ..unitutil.file import snippet_seq -from ..unitutil.mock import call, instance_mock, method_mock, property_mock +from ..unitutil.mock import FixtureRequest, Mock, call, instance_mock, method_mock, property_mock class DescribeCT_Row: - def it_can_add_a_trPr(self, add_trPr_fixture): - tr, expected_xml = add_trPr_fixture - tr._add_trPr() - assert tr.xml == expected_xml - - def it_raises_on_tc_at_grid_col(self, tc_raise_fixture): - tr, idx = tc_raise_fixture - with pytest.raises(ValueError): # noqa: PT011 - tr.tc_at_grid_col(idx) - - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tr_cxml", "expected_cxml"), + [ ("w:tr", "w:tr/w:trPr"), ("w:tr/w:tblPrEx", "w:tr/(w:tblPrEx,w:trPr)"), ("w:tr/w:tc", "w:tr/(w:trPr,w:tc)"), ("w:tr/(w:sdt,w:del,w:tc)", "w:tr/(w:trPr,w:sdt,w:del,w:tc)"), - ] + ], ) - def add_trPr_fixture(self, request): - tr_cxml, expected_cxml = request.param - tr = element(tr_cxml) - expected_xml = xml(expected_cxml) - return tr, expected_xml + def it_can_add_a_trPr(self, tr_cxml: str, expected_cxml: str): + tr = cast(CT_Row, element(tr_cxml)) + tr._add_trPr() + assert tr.xml == xml(expected_cxml) - @pytest.fixture(params=[(0, 0, 3), (1, 0, 1)]) - def tc_raise_fixture(self, request): - snippet_idx, row_idx, col_idx = request.param - tbl = parse_xml(snippet_seq("tbl-cells")[snippet_idx]) - tr = tbl.tr_lst[row_idx] - return tr, col_idx + @pytest.mark.parametrize(("snippet_idx", "row_idx", "col_idx"), [(0, 0, 3), (1, 0, 1)]) + def it_raises_on_tc_at_grid_col(self, snippet_idx: int, row_idx: int, col_idx: int): + tr = cast(CT_Tbl, parse_xml(snippet_seq("tbl-cells")[snippet_idx])).tr_lst[row_idx] + with pytest.raises(ValueError, match=f"no `tc` element at grid_offset={col_idx}"): + tr.tc_at_grid_offset(col_idx) class DescribeCT_Tc: + """Unit-test suite for `docx.oxml.table.CT_Tc` objects.""" + + @pytest.mark.parametrize( + ("tr_cxml", "tc_idx", "expected_value"), + [ + ("w:tr/(w:tc/w:p,w:tc/w:p)", 0, 0), + ("w:tr/(w:tc/w:p,w:tc/w:p)", 1, 1), + ("w:tr/(w:trPr/w:gridBefore{w:val=2},w:tc/w:p,w:tc/w:p)", 0, 2), + ("w:tr/(w:trPr/w:gridBefore{w:val=2},w:tc/w:p,w:tc/w:p)", 1, 3), + ("w:tr/(w:trPr/w:gridBefore{w:val=4},w:tc/w:p,w:tc/w:p,w:tc/w:p,w:tc/w:p)", 2, 6), + ], + ) + def it_knows_its_grid_offset(self, tr_cxml: str, tc_idx: int, expected_value: int): + tr = cast(CT_Row, element(tr_cxml)) + tc = tr.tc_lst[tc_idx] + + assert tc.grid_offset == expected_value + def it_can_merge_to_another_tc( - self, tr_, _span_dimensions_, _tbl_, _grow_to_, top_tc_ + self, tr_: Mock, _span_dimensions_: Mock, _tbl_: Mock, _grow_to_: Mock, top_tc_: Mock ): top_tr_ = tr_ - tc, other_tc = element("w:tc"), element("w:tc") + tc, other_tc = cast(CT_Tc, element("w:tc")), cast(CT_Tc, element("w:tc")) top, left, height, width = 0, 1, 2, 3 _span_dimensions_.return_value = top, left, height, width _tbl_.return_value.tr_lst = [tr_] - tr_.tc_at_grid_col.return_value = top_tc_ + tr_.tc_at_grid_offset.return_value = top_tc_ merged_tc = tc.merge(other_tc) _span_dimensions_.assert_called_once_with(tc, other_tc) - top_tr_.tc_at_grid_col.assert_called_once_with(left) + top_tr_.tc_at_grid_offset.assert_called_once_with(left) top_tc_._grow_to.assert_called_once_with(width, height) assert merged_tc is top_tc_ - def it_knows_its_extents_to_help(self, extents_fixture): - tc, attr_name, expected_value = extents_fixture - extent = getattr(tc, attr_name) - assert extent == expected_value - - def it_calculates_the_dimensions_of_a_span_to_help(self, span_fixture): - tc, other_tc, expected_dimensions = span_fixture - dimensions = tc._span_dimensions(other_tc) - assert dimensions == expected_dimensions - - def it_raises_on_invalid_span(self, span_raise_fixture): - tc, other_tc = span_raise_fixture - with pytest.raises(InvalidSpanError): - tc._span_dimensions(other_tc) - - def it_can_grow_itself_to_help_merge(self, grow_to_fixture): - tc, width, height, top_tc, expected_calls = grow_to_fixture - tc._grow_to(width, height, top_tc) - assert tc._span_to_width.call_args_list == expected_calls - - def it_can_extend_its_horz_span_to_help_merge( - self, top_tc_, grid_span_, _move_content_to_, _swallow_next_tc_ - ): - grid_span_.side_effect = [1, 3, 4] - grid_width, vMerge = 4, "continue" - tc = element("w:tc") - - tc._span_to_width(grid_width, top_tc_, vMerge) - - _move_content_to_.assert_called_once_with(tc, top_tc_) - assert _swallow_next_tc_.call_args_list == [ - call(tc, grid_width, top_tc_), - call(tc, grid_width, top_tc_), - ] - assert tc.vMerge == vMerge - - def it_knows_its_inner_content_block_item_elements(self): - tc = cast(CT_Tc, element("w:tc/(w:p,w:tbl,w:p)")) - assert [type(e) for e in tc.inner_content_elements] == [CT_P, CT_Tbl, CT_P] - - def it_can_swallow_the_next_tc_help_merge(self, swallow_fixture): - tc, grid_width, top_tc, tr, expected_xml = swallow_fixture - tc._swallow_next_tc(grid_width, top_tc) - assert tr.xml == expected_xml - - def it_adds_cell_widths_on_swallow(self, add_width_fixture): - tc, grid_width, top_tc, tr, expected_xml = add_width_fixture - tc._swallow_next_tc(grid_width, top_tc) - assert tr.xml == expected_xml - - def it_raises_on_invalid_swallow(self, swallow_raise_fixture): - tc, grid_width, top_tc, tr = swallow_raise_fixture - with pytest.raises(InvalidSpanError): - tc._swallow_next_tc(grid_width, top_tc) - - def it_can_move_its_content_to_help_merge(self, move_fixture): - tc, tc_2, expected_tc_xml, expected_tc_2_xml = move_fixture - tc._move_content_to(tc_2) - assert tc.xml == expected_tc_xml - assert tc_2.xml == expected_tc_2_xml - - def it_raises_on_tr_above(self, tr_above_raise_fixture): - tc = tr_above_raise_fixture - with pytest.raises(ValueError, match="no tr above topmost tr"): - tc._tr_above - - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ - # both cells have a width - ( - "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p)," - "w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", - 0, - 2, - "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=2880,w:type=dxa}," - "w:gridSpan{w:val=2}),w:p))", - ), - # neither have a width - ( - "w:tr/(w:tc/w:p,w:tc/w:p)", - 0, - 2, - "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", - ), - # only second one has a width - ( - "w:tr/(w:tc/w:p," "w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", - 0, - 2, - "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", - ), - # only first one has a width - ( - "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p)," "w:tc/w:p)", - 0, - 2, - "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=1440,w:type=dxa}," - "w:gridSpan{w:val=2}),w:p))", - ), - ] - ) - def add_width_fixture(self, request): - tr_cxml, tc_idx, grid_width, expected_tr_cxml = request.param - tr = element(tr_cxml) - tc = top_tc = tr[tc_idx] - expected_tr_xml = xml(expected_tr_cxml) - return tc, grid_width, top_tc, tr, expected_tr_xml - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("snippet_idx", "row", "col", "attr_name", "expected_value"), + [ (0, 0, 0, "top", 0), (2, 0, 1, "top", 0), (2, 1, 1, "top", 0), @@ -195,63 +93,22 @@ def add_width_fixture(self, request): (4, 1, 1, "bottom", 3), (0, 0, 0, "right", 1), (1, 0, 0, "right", 2), - (0, 0, 0, "right", 1), (4, 2, 1, "right", 3), - ] + ], ) - def extents_fixture(self, request): - snippet_idx, row, col, attr_name, expected_value = request.param + def it_knows_its_extents_to_help( + self, snippet_idx: int, row: int, col: int, attr_name: str, expected_value: int + ): tbl = self._snippet_tbl(snippet_idx) tc = tbl.tr_lst[row].tc_lst[col] - return tc, attr_name, expected_value - @pytest.fixture( - params=[ - (0, 0, 0, 2, 1), - (0, 0, 1, 1, 2), - (0, 1, 1, 2, 2), - (1, 0, 0, 2, 2), - (2, 0, 0, 2, 2), - (2, 1, 2, 1, 2), - ] - ) - def grow_to_fixture(self, request, _span_to_width_): - snippet_idx, row, col, width, height = request.param - tbl = self._snippet_tbl(snippet_idx) - tc = tbl.tr_lst[row].tc_lst[col] - start = 0 if height == 1 else 1 - end = start + height - expected_calls = [ - call(width, tc, None), - call(width, tc, "restart"), - call(width, tc, "continue"), - call(width, tc, "continue"), - ][start:end] - return tc, width, height, None, expected_calls - - @pytest.fixture( - params=[ - ("w:tc/w:p", "w:tc/w:p", "w:tc/w:p", "w:tc/w:p"), - ("w:tc/w:p", "w:tc/w:p/w:r", "w:tc/w:p", "w:tc/w:p/w:r"), - ("w:tc/w:p/w:r", "w:tc/w:p", "w:tc/w:p", "w:tc/w:p/w:r"), - ("w:tc/(w:p/w:r,w:sdt)", "w:tc/w:p", "w:tc/w:p", "w:tc/(w:p/w:r,w:sdt)"), - ( - "w:tc/(w:p/w:r,w:sdt)", - "w:tc/(w:tbl,w:p)", - "w:tc/w:p", - "w:tc/(w:tbl,w:p/w:r,w:sdt)", - ), - ] - ) - def move_fixture(self, request): - tc_cxml, tc_2_cxml, expected_tc_cxml, expected_tc_2_cxml = request.param - tc, tc_2 = element(tc_cxml), element(tc_2_cxml) - expected_tc_xml = xml(expected_tc_cxml) - expected_tc_2_xml = xml(expected_tc_2_cxml) - return tc, tc_2, expected_tc_xml, expected_tc_2_xml - - @pytest.fixture( - params=[ + extent = getattr(tc, attr_name) + + assert extent == expected_value + + @pytest.mark.parametrize( + ("snippet_idx", "row", "col", "row_2", "col_2", "expected_value"), + [ (0, 0, 0, 0, 1, (0, 0, 1, 2)), (0, 0, 1, 2, 1, (0, 1, 3, 1)), (0, 2, 2, 1, 1, (1, 1, 2, 2)), @@ -262,17 +119,28 @@ def move_fixture(self, request): (2, 0, 1, 1, 0, (0, 0, 2, 2)), (2, 1, 2, 0, 1, (0, 1, 2, 2)), (4, 0, 1, 0, 0, (0, 0, 1, 3)), - ] + ], ) - def span_fixture(self, request): - snippet_idx, row, col, row_2, col_2, expected_value = request.param + def it_calculates_the_dimensions_of_a_span_to_help( + self, + snippet_idx: int, + row: int, + col: int, + row_2: int, + col_2: int, + expected_value: tuple[int, int, int, int], + ): tbl = self._snippet_tbl(snippet_idx) tc = tbl.tr_lst[row].tc_lst[col] - tc_2 = tbl.tr_lst[row_2].tc_lst[col_2] - return tc, tc_2, expected_value + other_tc = tbl.tr_lst[row_2].tc_lst[col_2] + + dimensions = tc._span_dimensions(other_tc) - @pytest.fixture( - params=[ + assert dimensions == expected_value + + @pytest.mark.parametrize( + ("snippet_idx", "row", "col", "row_2", "col_2"), + [ (1, 0, 0, 1, 0), # inverted-L horz (1, 1, 0, 0, 0), # same in opposite order (2, 0, 2, 0, 1), # inverted-L vert @@ -280,17 +148,72 @@ def span_fixture(self, request): (5, 1, 0, 2, 1), # same, opposite side (6, 1, 0, 0, 1), # tee-shape vert bar (6, 0, 1, 1, 2), # same, opposite side - ] + ], ) - def span_raise_fixture(self, request): - snippet_idx, row, col, row_2, col_2 = request.param + def it_raises_on_invalid_span( + self, snippet_idx: int, row: int, col: int, row_2: int, col_2: int + ): tbl = self._snippet_tbl(snippet_idx) tc = tbl.tr_lst[row].tc_lst[col] - tc_2 = tbl.tr_lst[row_2].tc_lst[col_2] - return tc, tc_2 + other_tc = tbl.tr_lst[row_2].tc_lst[col_2] + + with pytest.raises(InvalidSpanError): + tc._span_dimensions(other_tc) + + @pytest.mark.parametrize( + ("snippet_idx", "row", "col", "width", "height"), + [ + (0, 0, 0, 2, 1), + (0, 0, 1, 1, 2), + (0, 1, 1, 2, 2), + (1, 0, 0, 2, 2), + (2, 0, 0, 2, 2), + (2, 1, 2, 1, 2), + ], + ) + def it_can_grow_itself_to_help_merge( + self, snippet_idx: int, row: int, col: int, width: int, height: int, _span_to_width_: Mock + ): + tbl = self._snippet_tbl(snippet_idx) + tc = tbl.tr_lst[row].tc_lst[col] + start = 0 if height == 1 else 1 + end = start + height + + tc._grow_to(width, height, None) - @pytest.fixture( - params=[ + assert ( + _span_to_width_.call_args_list + == [ + call(width, tc, None), + call(width, tc, "restart"), + call(width, tc, "continue"), + call(width, tc, "continue"), + ][start:end] + ) + + def it_can_extend_its_horz_span_to_help_merge( + self, top_tc_: Mock, grid_span_: Mock, _move_content_to_: Mock, _swallow_next_tc_: Mock + ): + grid_span_.side_effect = [1, 3, 4] + grid_width, vMerge = 4, "continue" + tc = cast(CT_Tc, element("w:tc")) + + tc._span_to_width(grid_width, top_tc_, vMerge) + + _move_content_to_.assert_called_once_with(tc, top_tc_) + assert _swallow_next_tc_.call_args_list == [ + call(tc, grid_width, top_tc_), + call(tc, grid_width, top_tc_), + ] + assert tc.vMerge == vMerge + + def it_knows_its_inner_content_block_item_elements(self): + tc = cast(CT_Tc, element("w:tc/(w:p,w:tbl,w:p)")) + assert [type(e) for e in tc.inner_content_elements] == [CT_P, CT_Tbl, CT_P] + + @pytest.mark.parametrize( + ("tr_cxml", "tc_idx", "grid_width", "expected_cxml"), + [ ( "w:tr/(w:tc/w:p,w:tc/w:p)", 0, @@ -307,8 +230,7 @@ def span_raise_fixture(self, request): 'w:tr/(w:tc/w:p/w:r/w:t"a",w:tc/w:p/w:r/w:t"b")', 0, 2, - 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p/w:r/w:t"a",' - 'w:p/w:r/w:t"b"))', + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p/w:r/w:t"a",w:p/w:r/w:t"b"))', ), ( "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p),w:tc/w:p)", @@ -322,75 +244,145 @@ def span_raise_fixture(self, request): 3, "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=3},w:p))", ), - ] + ], + ) + def it_can_swallow_the_next_tc_help_merge( + self, tr_cxml: str, tc_idx: int, grid_width: int, expected_cxml: str + ): + tr = cast(CT_Row, element(tr_cxml)) + tc = top_tc = tr.tc_lst[tc_idx] + + tc._swallow_next_tc(grid_width, top_tc) + + assert tr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tr_cxml", "tc_idx", "grid_width", "expected_cxml"), + [ + # both cells have a width + ( + "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p)," + "w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", + 0, + 2, + "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=2880,w:type=dxa},w:gridSpan{w:val=2}),w:p))", + ), + # neither have a width + ( + "w:tr/(w:tc/w:p,w:tc/w:p)", + 0, + 2, + "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", + ), + # only second one has a width + ( + "w:tr/(w:tc/w:p,w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))", + 0, + 2, + "w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", + ), + # only first one has a width + ( + "w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p),w:tc/w:p)", + 0, + 2, + "w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=1440,w:type=dxa},w:gridSpan{w:val=2}),w:p))", + ), + ], ) - def swallow_fixture(self, request): - tr_cxml, tc_idx, grid_width, expected_tr_cxml = request.param - tr = element(tr_cxml) - tc = top_tc = tr[tc_idx] - expected_tr_xml = xml(expected_tr_cxml) - return tc, grid_width, top_tc, tr, expected_tr_xml - - @pytest.fixture( - params=[ + def it_adds_cell_widths_on_swallow( + self, tr_cxml: str, tc_idx: int, grid_width: int, expected_cxml: str + ): + tr = cast(CT_Row, element(tr_cxml)) + tc = top_tc = tr.tc_lst[tc_idx] + tc._swallow_next_tc(grid_width, top_tc) + assert tr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tr_cxml", "tc_idx", "grid_width"), + [ ("w:tr/w:tc/w:p", 0, 2), ("w:tr/(w:tc/w:p,w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))", 0, 2), - ] + ], + ) + def it_raises_on_invalid_swallow(self, tr_cxml: str, tc_idx: int, grid_width: int): + tr = cast(CT_Row, element(tr_cxml)) + tc = top_tc = tr.tc_lst[tc_idx] + + with pytest.raises(InvalidSpanError): + tc._swallow_next_tc(grid_width, top_tc) + + @pytest.mark.parametrize( + ("tc_cxml", "tc_2_cxml", "expected_tc_cxml", "expected_tc_2_cxml"), + [ + ("w:tc/w:p", "w:tc/w:p", "w:tc/w:p", "w:tc/w:p"), + ("w:tc/w:p", "w:tc/w:p/w:r", "w:tc/w:p", "w:tc/w:p/w:r"), + ("w:tc/w:p/w:r", "w:tc/w:p", "w:tc/w:p", "w:tc/w:p/w:r"), + ("w:tc/(w:p/w:r,w:sdt)", "w:tc/w:p", "w:tc/w:p", "w:tc/(w:p/w:r,w:sdt)"), + ( + "w:tc/(w:p/w:r,w:sdt)", + "w:tc/(w:tbl,w:p)", + "w:tc/w:p", + "w:tc/(w:tbl,w:p/w:r,w:sdt)", + ), + ], ) - def swallow_raise_fixture(self, request): - tr_cxml, tc_idx, grid_width = request.param - tr = element(tr_cxml) - tc = top_tc = tr[tc_idx] - return tc, grid_width, top_tc, tr - - @pytest.fixture(params=[(0, 0, 0), (4, 0, 0)]) - def tr_above_raise_fixture(self, request): - snippet_idx, row_idx, col_idx = request.param - tbl = parse_xml(snippet_seq("tbl-cells")[snippet_idx]) + def it_can_move_its_content_to_help_merge( + self, tc_cxml: str, tc_2_cxml: str, expected_tc_cxml: str, expected_tc_2_cxml: str + ): + tc, tc_2 = cast(CT_Tc, element(tc_cxml)), cast(CT_Tc, element(tc_2_cxml)) + + tc._move_content_to(tc_2) + + assert tc.xml == xml(expected_tc_cxml) + assert tc_2.xml == xml(expected_tc_2_cxml) + + @pytest.mark.parametrize(("snippet_idx", "row_idx", "col_idx"), [(0, 0, 0), (4, 0, 0)]) + def it_raises_on_tr_above(self, snippet_idx: int, row_idx: int, col_idx: int): + tbl = cast(CT_Tbl, parse_xml(snippet_seq("tbl-cells")[snippet_idx])) tc = tbl.tr_lst[row_idx].tc_lst[col_idx] - return tc - # fixture components --------------------------------------------- + with pytest.raises(ValueError, match="no tr above topmost tr"): + tc._tr_above + + # fixtures ------------------------------------------------------- @pytest.fixture - def grid_span_(self, request): + def grid_span_(self, request: FixtureRequest): return property_mock(request, CT_Tc, "grid_span") @pytest.fixture - def _grow_to_(self, request): + def _grow_to_(self, request: FixtureRequest): return method_mock(request, CT_Tc, "_grow_to") @pytest.fixture - def _move_content_to_(self, request): + def _move_content_to_(self, request: FixtureRequest): return method_mock(request, CT_Tc, "_move_content_to") @pytest.fixture - def _span_dimensions_(self, request): + def _span_dimensions_(self, request: FixtureRequest): return method_mock(request, CT_Tc, "_span_dimensions") @pytest.fixture - def _span_to_width_(self, request): + def _span_to_width_(self, request: FixtureRequest): return method_mock(request, CT_Tc, "_span_to_width", autospec=False) - def _snippet_tbl(self, idx): - """ - Return a element for snippet at `idx` in 'tbl-cells' snippet - file. - """ - return parse_xml(snippet_seq("tbl-cells")[idx]) + def _snippet_tbl(self, idx: int) -> CT_Tbl: + """A element for snippet at `idx` in 'tbl-cells' snippet file.""" + return cast(CT_Tbl, parse_xml(snippet_seq("tbl-cells")[idx])) @pytest.fixture - def _swallow_next_tc_(self, request): + def _swallow_next_tc_(self, request: FixtureRequest): return method_mock(request, CT_Tc, "_swallow_next_tc") @pytest.fixture - def _tbl_(self, request): + def _tbl_(self, request: FixtureRequest): return property_mock(request, CT_Tc, "_tbl") @pytest.fixture - def top_tc_(self, request): + def top_tc_(self, request: FixtureRequest): return instance_mock(request, CT_Tc) @pytest.fixture - def tr_(self, request): + def tr_(self, request: FixtureRequest): return instance_mock(request, CT_Row) diff --git a/tests/oxml/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/oxml/unitdata/table.py b/tests/oxml/unitdata/table.py deleted file mode 100644 index 4f760c1a8..000000000 --- a/tests/oxml/unitdata/table.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Test data builders for text XML elements.""" - -from ...unitdata import BaseBuilder -from .shared import CT_StringBuilder - - -class CT_RowBuilder(BaseBuilder): - __tag__ = "w:tr" - __nspfxs__ = ("w",) - __attrs__ = ("w:w",) - - -class CT_TblBuilder(BaseBuilder): - __tag__ = "w:tbl" - __nspfxs__ = ("w",) - __attrs__ = () - - -class CT_TblGridBuilder(BaseBuilder): - __tag__ = "w:tblGrid" - __nspfxs__ = ("w",) - __attrs__ = ("w:w",) - - -class CT_TblGridColBuilder(BaseBuilder): - __tag__ = "w:gridCol" - __nspfxs__ = ("w",) - __attrs__ = ("w:w",) - - -class CT_TblPrBuilder(BaseBuilder): - __tag__ = "w:tblPr" - __nspfxs__ = ("w",) - __attrs__ = () - - -class CT_TblWidthBuilder(BaseBuilder): - __tag__ = "w:tblW" - __nspfxs__ = ("w",) - __attrs__ = ("w:w", "w:type") - - -class CT_TcBuilder(BaseBuilder): - __tag__ = "w:tc" - __nspfxs__ = ("w",) - __attrs__ = ("w:id",) - - -class CT_TcPrBuilder(BaseBuilder): - __tag__ = "w:tcPr" - __nspfxs__ = ("w",) - __attrs__ = () - - -def a_gridCol(): - return CT_TblGridColBuilder() - - -def a_tbl(): - return CT_TblBuilder() - - -def a_tblGrid(): - return CT_TblGridBuilder() - - -def a_tblPr(): - return CT_TblPrBuilder() - - -def a_tblStyle(): - return CT_StringBuilder("w:tblStyle") - - -def a_tblW(): - return CT_TblWidthBuilder() - - -def a_tc(): - return CT_TcBuilder() - - -def a_tcPr(): - return CT_TcPrBuilder() - - -def a_tr(): - return CT_RowBuilder() diff --git a/tests/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/test_table.py b/tests/test_table.py index 0ef273e3f..479d670c6 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -1,7 +1,14 @@ +# pyright: reportPrivateUsage=false + """Test suite for the docx.table module.""" +from __future__ import annotations + +from typing import cast + import pytest +from docx.document import Document from docx.enum.style import WD_STYLE_TYPE from docx.enum.table import ( WD_ALIGN_VERTICAL, @@ -10,37 +17,45 @@ WD_TABLE_DIRECTION, ) from docx.oxml.parser import parse_xml -from docx.oxml.table import CT_Tc +from docx.oxml.table import CT_Row, CT_Tbl, CT_TblGridCol, CT_Tc from docx.parts.document import DocumentPart -from docx.shared import Inches +from docx.shared import Emu, Inches, Length from docx.table import Table, _Cell, _Column, _Columns, _Row, _Rows from docx.text.paragraph import Paragraph -from .oxml.unitdata.table import a_gridCol, a_tbl, a_tblGrid, a_tc, a_tr -from .oxml.unitdata.text import a_p from .unitutil.cxml import element, xml from .unitutil.file import snippet_seq -from .unitutil.mock import instance_mock, property_mock +from .unitutil.mock import FixtureRequest, Mock, instance_mock, property_mock class DescribeTable: - def it_can_add_a_row(self, add_row_fixture): - table, expected_xml = add_row_fixture + """Unit-test suite for `docx.table._Rows` objects.""" + + def it_can_add_a_row(self, document_: Mock): + snippets = snippet_seq("add-row-col") + tbl = cast(CT_Tbl, parse_xml(snippets[0])) + table = Table(tbl, document_) + row = table.add_row() - assert table._tbl.xml == expected_xml + + assert table._tbl.xml == snippets[1] assert isinstance(row, _Row) assert row._tr is table._tbl.tr_lst[-1] assert row._parent is table - def it_can_add_a_column(self, add_column_fixture): - table, width, expected_xml = add_column_fixture - column = table.add_column(width) - assert table._tbl.xml == expected_xml + def it_can_add_a_column(self, document_: Mock): + snippets = snippet_seq("add-row-col") + tbl = cast(CT_Tbl, parse_xml(snippets[0])) + table = Table(tbl, document_) + + column = table.add_column(Inches(1.5)) + + assert table._tbl.xml == snippets[2] assert isinstance(column, _Column) assert column._gridCol is table._tbl.tblGrid.gridCol_lst[-1] assert column._parent is table - def it_provides_access_to_a_cell_by_row_and_col_indices(self, table): + def it_provides_access_to_a_cell_by_row_and_col_indices(self, table: Table): for row_idx in range(2): for col_idx in range(2): cell = table.cell(row_idx, col_idx) @@ -49,153 +64,95 @@ def it_provides_access_to_a_cell_by_row_and_col_indices(self, table): tc = tr.tc_lst[col_idx] assert tc is cell._tc - def it_provides_access_to_the_table_rows(self, table): + def it_provides_access_to_the_table_rows(self, table: Table): rows = table.rows assert isinstance(rows, _Rows) - def it_provides_access_to_the_table_columns(self, table): + def it_provides_access_to_the_table_columns(self, table: Table): columns = table.columns assert isinstance(columns, _Columns) - def it_provides_access_to_the_cells_in_a_column(self, col_cells_fixture): - table, column_idx, expected_cells = col_cells_fixture - column_cells = table.column_cells(column_idx) - assert column_cells == expected_cells - - def it_provides_access_to_the_cells_in_a_row(self, row_cells_fixture): - table, row_idx, expected_cells = row_cells_fixture - row_cells = table.row_cells(row_idx) - assert row_cells == expected_cells - - def it_knows_its_alignment_setting(self, alignment_get_fixture): - table, expected_value = alignment_get_fixture - assert table.alignment == expected_value - - def it_can_change_its_alignment_setting(self, alignment_set_fixture): - table, new_value, expected_xml = alignment_set_fixture - table.alignment = new_value - assert table._tbl.xml == expected_xml - - def it_knows_whether_it_should_autofit(self, autofit_get_fixture): - table, expected_value = autofit_get_fixture - assert table.autofit is expected_value - - def it_can_change_its_autofit_setting(self, autofit_set_fixture): - table, new_value, expected_xml = autofit_set_fixture - table.autofit = new_value - assert table._tbl.xml == expected_xml - - def it_knows_it_is_the_table_its_children_belong_to(self, table_fixture): - table = table_fixture - assert table.table is table - - def it_knows_its_direction(self, direction_get_fixture): - table, expected_value = direction_get_fixture - assert table.table_direction == expected_value - - def it_can_change_its_direction(self, direction_set_fixture): - table, new_value, expected_xml = direction_set_fixture - table.table_direction = new_value - assert table._element.xml == expected_xml - - def it_knows_its_table_style(self, style_get_fixture): - table, style_id_, style_ = style_get_fixture - style = table.style - table.part.get_style.assert_called_once_with(style_id_, WD_STYLE_TYPE.TABLE) - assert style is style_ - - def it_can_change_its_table_style(self, style_set_fixture): - table, value, expected_xml = style_set_fixture - table.style = value - table.part.get_style_id.assert_called_once_with(value, WD_STYLE_TYPE.TABLE) - assert table._tbl.xml == expected_xml + def it_provides_access_to_the_cells_in_a_column( + self, _cells_: Mock, _column_count_: Mock, document_: Mock + ): + table = Table(cast(CT_Tbl, element("w:tbl")), document_) + _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] + _column_count_.return_value = 3 + column_idx = 1 - def it_provides_access_to_its_cells_to_help(self, cells_fixture): - table, cell_count, unique_count, matches = cells_fixture - cells = table._cells - assert len(cells) == cell_count - assert len(set(cells)) == unique_count - for matching_idxs in matches: - comparator_idx = matching_idxs[0] - for idx in matching_idxs[1:]: - assert cells[idx] is cells[comparator_idx] + column_cells = table.column_cells(column_idx) - def it_knows_its_column_count_to_help(self, column_count_fixture): - table, expected_value = column_count_fixture - column_count = table._column_count - assert column_count == expected_value + assert column_cells == [1, 4, 7] - # fixtures ------------------------------------------------------- + def it_provides_access_to_the_cells_in_a_row( + self, _cells_: Mock, _column_count_: Mock, document_: Mock + ): + table = Table(cast(CT_Tbl, element("w:tbl")), document_) + _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] + _column_count_.return_value = 3 - @pytest.fixture - def add_column_fixture(self): - snippets = snippet_seq("add-row-col") - tbl = parse_xml(snippets[0]) - table = Table(tbl, None) - width = Inches(1.5) - expected_xml = snippets[2] - return table, width, expected_xml + row_cells = table.row_cells(1) - @pytest.fixture - def add_row_fixture(self): - snippets = snippet_seq("add-row-col") - tbl = parse_xml(snippets[0]) - table = Table(tbl, None) - expected_xml = snippets[1] - return table, expected_xml + assert row_cells == [3, 4, 5] - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tbl_cxml", "expected_value"), + [ ("w:tbl/w:tblPr", None), ("w:tbl/w:tblPr/w:jc{w:val=center}", WD_TABLE_ALIGNMENT.CENTER), ("w:tbl/w:tblPr/w:jc{w:val=right}", WD_TABLE_ALIGNMENT.RIGHT), ("w:tbl/w:tblPr/w:jc{w:val=left}", WD_TABLE_ALIGNMENT.LEFT), - ] + ], ) - def alignment_get_fixture(self, request): - tbl_cxml, expected_value = request.param - table = Table(element(tbl_cxml), None) - return table, expected_value + def it_knows_its_alignment_setting( + self, tbl_cxml: str, expected_value: WD_TABLE_ALIGNMENT | None, document_: Mock + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) + assert table.alignment == expected_value - @pytest.fixture( - params=[ - ( - "w:tbl/w:tblPr", - WD_TABLE_ALIGNMENT.LEFT, - "w:tbl/w:tblPr/w:jc{w:val=left}", - ), + @pytest.mark.parametrize( + ("tbl_cxml", "new_value", "expected_cxml"), + [ + ("w:tbl/w:tblPr", WD_TABLE_ALIGNMENT.LEFT, "w:tbl/w:tblPr/w:jc{w:val=left}"), ( "w:tbl/w:tblPr/w:jc{w:val=left}", WD_TABLE_ALIGNMENT.RIGHT, "w:tbl/w:tblPr/w:jc{w:val=right}", ), ("w:tbl/w:tblPr/w:jc{w:val=right}", None, "w:tbl/w:tblPr"), - ] + ], ) - def alignment_set_fixture(self, request): - tbl_cxml, new_value, expected_tbl_cxml = request.param - table = Table(element(tbl_cxml), None) - expected_xml = xml(expected_tbl_cxml) - return table, new_value, expected_xml - - @pytest.fixture( - params=[ + def it_can_change_its_alignment_setting( + self, + tbl_cxml: str, + new_value: WD_TABLE_ALIGNMENT | None, + expected_cxml: str, + document_: Mock, + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) + table.alignment = new_value + assert table._tbl.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tbl_cxml", "expected_value"), + [ ("w:tbl/w:tblPr", True), ("w:tbl/w:tblPr/w:tblLayout", True), ("w:tbl/w:tblPr/w:tblLayout{w:type=autofit}", True), ("w:tbl/w:tblPr/w:tblLayout{w:type=fixed}", False), - ] + ], ) - def autofit_get_fixture(self, request): - tbl_cxml, expected_autofit = request.param - table = Table(element(tbl_cxml), None) - return table, expected_autofit + def it_knows_whether_it_should_autofit( + self, tbl_cxml: str, expected_value: bool, document_: Mock + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) + assert table.autofit is expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tbl_cxml", "new_value", "expected_cxml"), + [ ("w:tbl/w:tblPr", True, "w:tbl/w:tblPr/w:tblLayout{w:type=autofit}"), ("w:tbl/w:tblPr", False, "w:tbl/w:tblPr/w:tblLayout{w:type=fixed}"), - ("w:tbl/w:tblPr", None, "w:tbl/w:tblPr/w:tblLayout{w:type=fixed}"), ( "w:tbl/w:tblPr/w:tblLayout{w:type=fixed}", True, @@ -206,60 +163,36 @@ def autofit_get_fixture(self, request): False, "w:tbl/w:tblPr/w:tblLayout{w:type=fixed}", ), - ] + ], ) - def autofit_set_fixture(self, request): - tbl_cxml, new_value, expected_tbl_cxml = request.param - table = Table(element(tbl_cxml), None) - expected_xml = xml(expected_tbl_cxml) - return table, new_value, expected_xml - - @pytest.fixture( - params=[ - (0, 9, 9, ()), - (1, 9, 8, ((0, 1),)), - (2, 9, 8, ((1, 4),)), - (3, 9, 6, ((0, 1, 3, 4),)), - (4, 9, 4, ((0, 1), (3, 6), (4, 5, 7, 8))), - ] - ) - def cells_fixture(self, request): - snippet_idx, cell_count, unique_count, matches = request.param - tbl_xml = snippet_seq("tbl-cells")[snippet_idx] - table = Table(parse_xml(tbl_xml), None) - return table, cell_count, unique_count, matches - - @pytest.fixture - def col_cells_fixture(self, _cells_, _column_count_): - table = Table(None, None) - _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] - _column_count_.return_value = 3 - column_idx = 1 - expected_cells = [1, 4, 7] - return table, column_idx, expected_cells + def it_can_change_its_autofit_setting( + self, tbl_cxml: str, new_value: bool, expected_cxml: str, document_: Mock + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) + table.autofit = new_value + assert table._tbl.xml == xml(expected_cxml) - @pytest.fixture - def column_count_fixture(self): - tbl_cxml = "w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)" - expected_value = 3 - table = Table(element(tbl_cxml), None) - return table, expected_value + def it_knows_it_is_the_table_its_children_belong_to(self, table: Table): + assert table.table is table - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tbl_cxml", "expected_value"), + [ ("w:tbl/w:tblPr", None), ("w:tbl/w:tblPr/w:bidiVisual", WD_TABLE_DIRECTION.RTL), ("w:tbl/w:tblPr/w:bidiVisual{w:val=0}", WD_TABLE_DIRECTION.LTR), ("w:tbl/w:tblPr/w:bidiVisual{w:val=on}", WD_TABLE_DIRECTION.RTL), - ] + ], ) - def direction_get_fixture(self, request): - tbl_cxml, expected_value = request.param - table = Table(element(tbl_cxml), None) - return table, expected_value - - @pytest.fixture( - params=[ + def it_knows_its_direction( + self, tbl_cxml: str, expected_value: WD_TABLE_DIRECTION | None, document_: Mock + ): + tbl = cast(CT_Tbl, element(tbl_cxml)) + assert Table(tbl, document_).table_direction == expected_value + + @pytest.mark.parametrize( + ("tbl_cxml", "new_value", "expected_cxml"), + [ ("w:tbl/w:tblPr", WD_TABLE_DIRECTION.RTL, "w:tbl/w:tblPr/w:bidiVisual"), ( "w:tbl/w:tblPr/w:bidiVisual", @@ -272,33 +205,28 @@ def direction_get_fixture(self, request): "w:tbl/w:tblPr/w:bidiVisual", ), ("w:tbl/w:tblPr/w:bidiVisual{w:val=1}", None, "w:tbl/w:tblPr"), - ] + ], ) - def direction_set_fixture(self, request): - tbl_cxml, new_value, expected_cxml = request.param - table = Table(element(tbl_cxml), None) - expected_xml = xml(expected_cxml) - return table, new_value, expected_xml + def it_can_change_its_direction( + self, tbl_cxml: str, new_value: WD_TABLE_DIRECTION, expected_cxml: str, document_: Mock + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) + table.table_direction = new_value + assert table._element.xml == xml(expected_cxml) - @pytest.fixture - def row_cells_fixture(self, _cells_, _column_count_): - table = Table(None, None) - _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] - _column_count_.return_value = 3 - row_idx = 1 - expected_cells = [3, 4, 5] - return table, row_idx, expected_cells + def it_knows_its_table_style(self, part_prop_: Mock, document_part_: Mock, document_: Mock): + part_prop_.return_value = document_part_ + style_ = document_part_.get_style.return_value + table = Table(cast(CT_Tbl, element("w:tbl/w:tblPr/w:tblStyle{w:val=BarBaz}")), document_) - @pytest.fixture - def style_get_fixture(self, part_prop_): - style_id = "Barbaz" - tbl_cxml = "w:tbl/w:tblPr/w:tblStyle{w:val=%s}" % style_id - table = Table(element(tbl_cxml), None) - style_ = part_prop_.return_value.get_style.return_value - return table, style_id, style_ - - @pytest.fixture( - params=[ + style = table.style + + document_part_.get_style.assert_called_once_with("BarBaz", WD_STYLE_TYPE.TABLE) + assert style is style_ + + @pytest.mark.parametrize( + ("tbl_cxml", "new_value", "style_id", "expected_cxml"), + [ ("w:tbl/w:tblPr", "Tbl A", "TblA", "w:tbl/w:tblPr/w:tblStyle{w:val=TblA}"), ( "w:tbl/w:tblPr/w:tblStyle{w:val=TblA}", @@ -307,155 +235,166 @@ def style_get_fixture(self, part_prop_): "w:tbl/w:tblPr/w:tblStyle{w:val=TblB}", ), ("w:tbl/w:tblPr/w:tblStyle{w:val=TblB}", None, None, "w:tbl/w:tblPr"), - ] + ], + ) + def it_can_change_its_table_style( + self, + tbl_cxml: str, + new_value: str | None, + style_id: str | None, + expected_cxml: str, + document_: Mock, + part_prop_: Mock, + document_part_: Mock, + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) + part_prop_.return_value = document_part_ + document_part_.get_style_id.return_value = style_id + + table.style = new_value + + document_part_.get_style_id.assert_called_once_with(new_value, WD_STYLE_TYPE.TABLE) + assert table._tbl.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("snippet_idx", "cell_count", "unique_count", "matches"), + [ + (0, 9, 9, ()), + (1, 9, 8, ((0, 1),)), + (2, 9, 8, ((1, 4),)), + (3, 9, 6, ((0, 1, 3, 4),)), + (4, 9, 4, ((0, 1), (3, 6), (4, 5, 7, 8))), + ], ) - def style_set_fixture(self, request, part_prop_): - tbl_cxml, value, style_id, expected_cxml = request.param - table = Table(element(tbl_cxml), None) - part_prop_.return_value.get_style_id.return_value = style_id - expected_xml = xml(expected_cxml) - return table, value, expected_xml + def it_provides_access_to_its_cells_to_help( + self, + snippet_idx: int, + cell_count: int, + unique_count: int, + matches: tuple[tuple[int, ...]], + document_: Mock, + ): + tbl_xml = snippet_seq("tbl-cells")[snippet_idx] + table = Table(cast(CT_Tbl, parse_xml(tbl_xml)), document_) - @pytest.fixture - def table_fixture(self): - table = Table(None, None) - return table + cells = table._cells - # fixture components --------------------------------------------- + assert len(cells) == cell_count + assert len(set(cells)) == unique_count + for matching_idxs in matches: + comparator_idx = matching_idxs[0] + for idx in matching_idxs[1:]: + assert cells[idx] is cells[comparator_idx] + + def it_knows_its_column_count_to_help(self, document_: Mock): + tbl_cxml = "w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)" + expected_value = 3 + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) + + column_count = table._column_count + + assert column_count == expected_value + + # fixtures ------------------------------------------------------- @pytest.fixture - def _cells_(self, request): + def _cells_(self, request: FixtureRequest): return property_mock(request, Table, "_cells") @pytest.fixture - def _column_count_(self, request): + def _column_count_(self, request: FixtureRequest): return property_mock(request, Table, "_column_count") @pytest.fixture - def document_part_(self, request): + def document_(self, request: FixtureRequest): + return instance_mock(request, Document) + + @pytest.fixture + def document_part_(self, request: FixtureRequest): return instance_mock(request, DocumentPart) @pytest.fixture - def part_prop_(self, request, document_part_): - return property_mock(request, Table, "part", return_value=document_part_) + def part_prop_(self, request: FixtureRequest): + return property_mock(request, Table, "part") @pytest.fixture - def table(self): - tbl = _tbl_bldr(rows=2, cols=2).element - table = Table(tbl, None) - return table + def table(self, document_: Mock): + tbl_cxml = "w:tbl/(w:tblGrid/(w:gridCol,w:gridCol),w:tr/(w:tc,w:tc),w:tr/(w:tc,w:tc))" + return Table(cast(CT_Tbl, element(tbl_cxml)), document_) class Describe_Cell: - def it_knows_what_text_it_contains(self, text_get_fixture): - cell, expected_text = text_get_fixture + """Unit-test suite for `docx.table._Cell` objects.""" + + @pytest.mark.parametrize( + ("tc_cxml", "expected_value"), + [ + ("w:tc", 1), + ("w:tc/w:tcPr", 1), + ("w:tc/w:tcPr/w:gridSpan{w:val=1}", 1), + ("w:tc/w:tcPr/w:gridSpan{w:val=4}", 4), + ], + ) + def it_knows_its_grid_span(self, tc_cxml: str, expected_value: int, parent_: Mock): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + assert cell.grid_span == expected_value + + @pytest.mark.parametrize( + ("tc_cxml", "expected_text"), + [ + ("w:tc", ""), + ('w:tc/w:p/w:r/w:t"foobar"', "foobar"), + ('w:tc/(w:p/w:r/w:t"foo",w:p/w:r/w:t"bar")', "foo\nbar"), + ('w:tc/(w:tcPr,w:p/w:r/w:t"foobar")', "foobar"), + ('w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:br,w:t"ar",w:br)', "fo\tob\nar\n"), + ], + ) + def it_knows_what_text_it_contains(self, tc_cxml: str, expected_text: str, parent_: Mock): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) text = cell.text assert text == expected_text - def it_can_replace_its_content_with_a_string_of_text(self, text_set_fixture): - cell, text, expected_xml = text_set_fixture - cell.text = text - assert cell._tc.xml == expected_xml - - def it_knows_its_vertical_alignment(self, alignment_get_fixture): - cell, expected_value = alignment_get_fixture - vertical_alignment = cell.vertical_alignment - assert vertical_alignment == expected_value - - def it_can_change_its_vertical_alignment(self, alignment_set_fixture): - cell, new_value, expected_xml = alignment_set_fixture - cell.vertical_alignment = new_value - assert cell._element.xml == expected_xml - - def it_knows_its_width_in_EMU(self, width_get_fixture): - cell, expected_width = width_get_fixture - assert cell.width == expected_width - - def it_can_change_its_width(self, width_set_fixture): - cell, value, expected_xml = width_set_fixture - cell.width = value - assert cell.width == value - assert cell._tc.xml == expected_xml - - def it_provides_access_to_the_paragraphs_it_contains(self, paragraphs_fixture): - cell = paragraphs_fixture - paragraphs = cell.paragraphs - assert len(paragraphs) == 2 - count = 0 - for idx, paragraph in enumerate(paragraphs): - assert isinstance(paragraph, Paragraph) - assert paragraph is paragraphs[idx] - count += 1 - assert count == 2 - - def it_provides_access_to_the_tables_it_contains(self, tables_fixture): - # test len(), iterable, and indexed access - cell, expected_count = tables_fixture - tables = cell.tables - assert len(tables) == expected_count - count = 0 - for idx, table in enumerate(tables): - assert isinstance(table, Table) - assert tables[idx] is table - count += 1 - assert count == expected_count - - def it_can_add_a_paragraph(self, add_paragraph_fixture): - cell, expected_xml = add_paragraph_fixture - p = cell.add_paragraph() - assert cell._tc.xml == expected_xml - assert isinstance(p, Paragraph) - - def it_can_add_a_table(self, add_table_fixture): - cell, expected_xml = add_table_fixture - table = cell.add_table(rows=2, cols=2) - assert cell._element.xml == expected_xml - assert isinstance(table, Table) - - def it_can_merge_itself_with_other_cells(self, merge_fixture): - cell, other_cell, merged_tc_ = merge_fixture - merged_cell = cell.merge(other_cell) - cell._tc.merge.assert_called_once_with(other_cell._tc) - assert isinstance(merged_cell, _Cell) - assert merged_cell._tc is merged_tc_ - assert merged_cell._parent is cell._parent - - # fixtures ------------------------------------------------------- - - @pytest.fixture( - params=[ - ("w:tc", "w:tc/w:p"), - ("w:tc/w:p", "w:tc/(w:p, w:p)"), - ("w:tc/w:tbl", "w:tc/(w:tbl, w:p)"), - ] + @pytest.mark.parametrize( + ("tc_cxml", "new_text", "expected_cxml"), + [ + ("w:tc/w:p", "foobar", 'w:tc/w:p/w:r/w:t"foobar"'), + ( + "w:tc/w:p", + "fo\tob\rar\n", + 'w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:br,w:t"ar",w:br)', + ), + ( + "w:tc/(w:tcPr, w:p, w:tbl, w:p)", + "foobar", + 'w:tc/(w:tcPr, w:p/w:r/w:t"foobar")', + ), + ], ) - def add_paragraph_fixture(self, request): - tc_cxml, after_tc_cxml = request.param - cell = _Cell(element(tc_cxml), None) - expected_xml = xml(after_tc_cxml) - return cell, expected_xml - - @pytest.fixture - def add_table_fixture(self, request): - cell = _Cell(element("w:tc/w:p"), None) - expected_xml = snippet_seq("new-tbl")[1] - return cell, expected_xml - - @pytest.fixture( - params=[ + def it_can_replace_its_content_with_a_string_of_text( + self, tc_cxml: str, new_text: str, expected_cxml: str, parent_: Mock + ): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + cell.text = new_text + assert cell._tc.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tc_cxml", "expected_value"), + [ ("w:tc", None), ("w:tc/w:tcPr", None), ("w:tc/w:tcPr/w:vAlign{w:val=bottom}", WD_ALIGN_VERTICAL.BOTTOM), ("w:tc/w:tcPr/w:vAlign{w:val=top}", WD_ALIGN_VERTICAL.TOP), - ] + ], ) - def alignment_get_fixture(self, request): - tc_cxml, expected_value = request.param - cell = _Cell(element(tc_cxml), None) - return cell, expected_value - - @pytest.fixture( - params=[ + def it_knows_its_vertical_alignment( + self, tc_cxml: str, expected_value: WD_ALIGN_VERTICAL | None, parent_: Mock + ): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + assert cell.vertical_alignment == expected_value + + @pytest.mark.parametrize( + ("tc_cxml", "new_value", "expected_cxml"), + [ ("w:tc", WD_ALIGN_VERTICAL.TOP, "w:tc/w:tcPr/w:vAlign{w:val=top}"), ( "w:tc/w:tcPr", @@ -470,330 +409,300 @@ def alignment_get_fixture(self, request): ("w:tc/w:tcPr/w:vAlign{w:val=center}", None, "w:tc/w:tcPr"), ("w:tc", None, "w:tc/w:tcPr"), ("w:tc/w:tcPr", None, "w:tc/w:tcPr"), - ] - ) - def alignment_set_fixture(self, request): - cxml, new_value, expected_cxml = request.param - cell = _Cell(element(cxml), None) - expected_xml = xml(expected_cxml) - return cell, new_value, expected_xml - - @pytest.fixture - def merge_fixture(self, tc_, tc_2_, parent_, merged_tc_): - cell, other_cell = _Cell(tc_, parent_), _Cell(tc_2_, parent_) - tc_.merge.return_value = merged_tc_ - return cell, other_cell, merged_tc_ - - @pytest.fixture - def paragraphs_fixture(self): - return _Cell(element("w:tc/(w:p, w:p)"), None) - - @pytest.fixture( - params=[ - ("w:tc", 0), - ("w:tc/w:tbl", 1), - ("w:tc/(w:tbl,w:tbl)", 2), - ("w:tc/(w:p,w:tbl)", 1), - ("w:tc/(w:tbl,w:tbl,w:p)", 2), - ] + ], ) - def tables_fixture(self, request): - cell_cxml, expected_count = request.param - cell = _Cell(element(cell_cxml), None) - return cell, expected_count - - @pytest.fixture( - params=[ - ("w:tc", ""), - ('w:tc/w:p/w:r/w:t"foobar"', "foobar"), - ('w:tc/(w:p/w:r/w:t"foo",w:p/w:r/w:t"bar")', "foo\nbar"), - ('w:tc/(w:tcPr,w:p/w:r/w:t"foobar")', "foobar"), - ('w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:br,w:t"ar",w:br)', "fo\tob\nar\n"), - ] - ) - def text_get_fixture(self, request): - tc_cxml, expected_text = request.param - cell = _Cell(element(tc_cxml), None) - return cell, expected_text + def it_can_change_its_vertical_alignment( + self, tc_cxml: str, new_value: WD_ALIGN_VERTICAL | None, expected_cxml: str, parent_: Mock + ): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + cell.vertical_alignment = new_value + assert cell._element.xml == xml(expected_cxml) - @pytest.fixture( - params=[ - ("w:tc/w:p", "foobar", 'w:tc/w:p/w:r/w:t"foobar"'), - ( - "w:tc/w:p", - "fo\tob\rar\n", - 'w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:br,w:t"ar",w:br)', - ), - ( - "w:tc/(w:tcPr, w:p, w:tbl, w:p)", - "foobar", - 'w:tc/(w:tcPr, w:p/w:r/w:t"foobar")', - ), - ] - ) - def text_set_fixture(self, request): - tc_cxml, new_text, expected_cxml = request.param - cell = _Cell(element(tc_cxml), None) - expected_xml = xml(expected_cxml) - return cell, new_text, expected_xml - - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tc_cxml", "expected_value"), + [ ("w:tc", None), ("w:tc/w:tcPr", None), ("w:tc/w:tcPr/w:tcW{w:w=25%,w:type=pct}", None), ("w:tc/w:tcPr/w:tcW{w:w=1440,w:type=dxa}", 914400), - ] + ], ) - def width_get_fixture(self, request): - tc_cxml, expected_width = request.param - cell = _Cell(element(tc_cxml), None) - return cell, expected_width + def it_knows_its_width_in_EMU(self, tc_cxml: str, expected_value: int | None, parent_: Mock): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + assert cell.width == expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tc_cxml", "new_value", "expected_cxml"), + [ ("w:tc", Inches(1), "w:tc/w:tcPr/w:tcW{w:w=1440,w:type=dxa}"), ( "w:tc/w:tcPr/w:tcW{w:w=25%,w:type=pct}", Inches(2), "w:tc/w:tcPr/w:tcW{w:w=2880,w:type=dxa}", ), - ] + ], + ) + def it_can_change_its_width( + self, tc_cxml: str, new_value: Length, expected_cxml: str, parent_: Mock + ): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + cell.width = new_value + assert cell.width == new_value + assert cell._tc.xml == xml(expected_cxml) + + def it_provides_access_to_the_paragraphs_it_contains(self, parent_: Mock): + cell = _Cell(cast(CT_Tc, element("w:tc/(w:p, w:p)")), parent_) + + paragraphs = cell.paragraphs + + # -- every w:p produces a Paragraph instance -- + assert len(paragraphs) == 2 + assert all(isinstance(p, Paragraph) for p in paragraphs) + # -- the return value is iterable and indexable -- + assert all(p is paragraphs[idx] for idx, p in enumerate(paragraphs)) + + @pytest.mark.parametrize( + ("tc_cxml", "expected_table_count"), + [ + ("w:tc", 0), + ("w:tc/w:tbl", 1), + ("w:tc/(w:tbl,w:tbl)", 2), + ("w:tc/(w:p,w:tbl)", 1), + ("w:tc/(w:tbl,w:tbl,w:p)", 2), + ], + ) + def it_provides_access_to_the_tables_it_contains( + self, tc_cxml: str, expected_table_count: int, parent_: Mock + ): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) + + tables = cell.tables + + # --- test len(), iterable, and indexed access + assert len(tables) == expected_table_count + assert all(isinstance(t, Table) for t in tables) + assert all(t is tables[idx] for idx, t in enumerate(tables)) + + @pytest.mark.parametrize( + ("tc_cxml", "expected_cxml"), + [ + ("w:tc", "w:tc/w:p"), + ("w:tc/w:p", "w:tc/(w:p, w:p)"), + ("w:tc/w:tbl", "w:tc/(w:tbl, w:p)"), + ], ) - def width_set_fixture(self, request): - tc_cxml, new_value, expected_cxml = request.param - cell = _Cell(element(tc_cxml), None) - expected_xml = xml(expected_cxml) - return cell, new_value, expected_xml + def it_can_add_a_paragraph(self, tc_cxml: str, expected_cxml: str, parent_: Mock): + cell = _Cell(cast(CT_Tc, element(tc_cxml)), parent_) - # fixture components --------------------------------------------- + p = cell.add_paragraph() + + assert isinstance(p, Paragraph) + assert cell._tc.xml == xml(expected_cxml) + + def it_can_add_a_table(self, parent_: Mock): + cell = _Cell(cast(CT_Tc, element("w:tc/w:p")), parent_) + + table = cell.add_table(rows=2, cols=2) + + assert isinstance(table, Table) + assert cell._element.xml == snippet_seq("new-tbl")[1] + + def it_can_merge_itself_with_other_cells( + self, tc_: Mock, tc_2_: Mock, parent_: Mock, merged_tc_: Mock + ): + cell, other_cell = _Cell(tc_, parent_), _Cell(tc_2_, parent_) + tc_.merge.return_value = merged_tc_ + + merged_cell = cell.merge(other_cell) + + assert isinstance(merged_cell, _Cell) + tc_.merge.assert_called_once_with(other_cell._tc) + assert merged_cell._tc is merged_tc_ + assert merged_cell._parent is cell._parent + + # fixtures ------------------------------------------------------- @pytest.fixture - def merged_tc_(self, request): + def merged_tc_(self, request: FixtureRequest): return instance_mock(request, CT_Tc) @pytest.fixture - def parent_(self, request): + def parent_(self, request: FixtureRequest): return instance_mock(request, Table) @pytest.fixture - def tc_(self, request): + def tc_(self, request: FixtureRequest): return instance_mock(request, CT_Tc) @pytest.fixture - def tc_2_(self, request): + def tc_2_(self, request: FixtureRequest): return instance_mock(request, CT_Tc) class Describe_Column: - def it_provides_access_to_its_cells(self, cells_fixture): - column, column_idx, expected_cells = cells_fixture - cells = column.cells - column.table.column_cells.assert_called_once_with(column_idx) - assert cells == expected_cells - - def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): - column, table_ = table_fixture - assert column.table is table_ - - def it_knows_its_width_in_EMU(self, width_get_fixture): - column, expected_width = width_get_fixture - assert column.width == expected_width + """Unit-test suite for `docx.table._Cell` objects.""" - def it_can_change_its_width(self, width_set_fixture): - column, value, expected_xml = width_set_fixture - column.width = value - assert column.width == value - assert column._gridCol.xml == expected_xml + def it_provides_access_to_its_cells(self, _index_prop_: Mock, table_prop_: Mock, table_: Mock): + table_prop_.return_value = table_ + _index_prop_.return_value = 4 + column = _Column(cast(CT_TblGridCol, element("w:gridCol{w:w=500}")), table_) + table_.column_cells.return_value = [3, 2, 1] - def it_knows_its_index_in_table_to_help(self, index_fixture): - column, expected_idx = index_fixture - assert column._index == expected_idx + cells = column.cells - # fixtures ------------------------------------------------------- + table_.column_cells.assert_called_once_with(4) + assert cells == (3, 2, 1) - @pytest.fixture - def cells_fixture(self, _index_, table_prop_, table_): - column = _Column(None, None) - _index_.return_value = column_idx = 4 - expected_cells = (3, 2, 1) - table_.column_cells.return_value = list(expected_cells) - return column, column_idx, expected_cells - - @pytest.fixture - def index_fixture(self): - tbl = element("w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)") - gridCol, expected_idx = tbl.tblGrid[1], 1 - column = _Column(gridCol, None) - return column, expected_idx + def it_provides_access_to_the_table_it_belongs_to(self, table_: Mock): + table_.table = table_ + column = _Column(cast(CT_TblGridCol, element("w:gridCol{w:w=500}")), table_) - @pytest.fixture - def table_fixture(self, parent_, table_): - column = _Column(None, parent_) - parent_.table = table_ - return column, table_ + assert column.table is table_ - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("gridCol_cxml", "expected_width"), + [ ("w:gridCol{w:w=4242}", 2693670), ("w:gridCol{w:w=1440}", 914400), ("w:gridCol{w:w=2.54cm}", 914400), ("w:gridCol{w:w=54mm}", 1944000), ("w:gridCol{w:w=12.5pt}", 158750), ("w:gridCol", None), - ] + ], ) - def width_get_fixture(self, request): - gridCol_cxml, expected_width = request.param - column = _Column(element(gridCol_cxml), None) - return column, expected_width - - @pytest.fixture( - params=[ - ("w:gridCol", 914400, "w:gridCol{w:w=1440}"), - ("w:gridCol{w:w=4242}", 457200, "w:gridCol{w:w=720}"), + def it_knows_its_width_in_EMU( + self, gridCol_cxml: str, expected_width: int | None, table_: Mock + ): + column = _Column(cast(CT_TblGridCol, element(gridCol_cxml)), table_) + assert column.width == expected_width + + @pytest.mark.parametrize( + ("gridCol_cxml", "new_value", "expected_cxml"), + [ + ("w:gridCol", Emu(914400), "w:gridCol{w:w=1440}"), + ("w:gridCol{w:w=4242}", Inches(0.5), "w:gridCol{w:w=720}"), ("w:gridCol{w:w=4242}", None, "w:gridCol"), ("w:gridCol", None, "w:gridCol"), - ] + ], ) - def width_set_fixture(self, request): - gridCol_cxml, new_value, expected_cxml = request.param - column = _Column(element(gridCol_cxml), None) - expected_xml = xml(expected_cxml) - return column, new_value, expected_xml + def it_can_change_its_width( + self, gridCol_cxml: str, new_value: Length | None, expected_cxml: str, table_: Mock + ): + column = _Column(cast(CT_TblGridCol, element(gridCol_cxml)), table_) + + column.width = new_value + + assert column.width == new_value + assert column._gridCol.xml == xml(expected_cxml) - # fixture components --------------------------------------------- + def it_knows_its_index_in_table_to_help(self, table_: Mock): + tbl = cast(CT_Tbl, element("w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)")) + gridCol = tbl.tblGrid.gridCol_lst[1] + column = _Column(gridCol, table_) + assert column._index == 1 + + # fixtures ------------------------------------------------------- @pytest.fixture - def _index_(self, request): + def _index_prop_(self, request: FixtureRequest): return property_mock(request, _Column, "_index") @pytest.fixture - def parent_(self, request): + def parent_(self, request: FixtureRequest): return instance_mock(request, Table) @pytest.fixture - def table_(self, request): + def table_(self, request: FixtureRequest): return instance_mock(request, Table) @pytest.fixture - def table_prop_(self, request, table_): - return property_mock(request, _Column, "table", return_value=table_) + def table_prop_(self, request: FixtureRequest): + return property_mock(request, _Column, "table") class Describe_Columns: - def it_knows_how_many_columns_it_contains(self, columns_fixture): - columns, column_count = columns_fixture - assert len(columns) == column_count - - def it_can_interate_over_its__Column_instances(self, columns_fixture): - columns, column_count = columns_fixture - actual_count = 0 - for column in columns: - assert isinstance(column, _Column) - actual_count += 1 - assert actual_count == column_count - - def it_provides_indexed_access_to_columns(self, columns_fixture): - columns, column_count = columns_fixture - for idx in range(-column_count, column_count): - column = columns[idx] - assert isinstance(column, _Column) - - def it_raises_on_indexed_access_out_of_range(self, columns_fixture): - columns, column_count = columns_fixture - too_low = -1 - column_count - too_high = column_count - with pytest.raises(IndexError): - columns[too_low] - with pytest.raises(IndexError): - columns[too_high] + """Unit-test suite for `docx.table._Columns` objects.""" - def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): - columns, table_ = table_fixture - assert columns.table is table_ + def it_has_sequence_behaviors(self, table_: Mock): + columns = _Columns(cast(CT_Tbl, element("w:tbl/w:tblGrid/(w:gridCol,w:gridCol)")), table_) - # fixtures ------------------------------------------------------- + # -- it supports len() -- + assert len(columns) == 2 + # -- it is iterable -- + assert len(tuple(c for c in columns)) == 2 + assert all(type(c) is _Column for c in columns) + # -- it is indexable -- + assert all(type(columns[i]) is _Column for i in range(2)) - @pytest.fixture - def columns_fixture(self): - column_count = 2 - tbl = _tbl_bldr(rows=2, cols=column_count).element - columns = _Columns(tbl, None) - return columns, column_count + def it_raises_on_indexed_access_out_of_range(self, table_: Mock): + columns = _Columns(cast(CT_Tbl, element("w:tbl/w:tblGrid/(w:gridCol,w:gridCol)")), table_) - @pytest.fixture - def table_fixture(self, table_): - columns = _Columns(None, table_) + with pytest.raises(IndexError): + columns[2] + with pytest.raises(IndexError): + columns[-3] + + def it_provides_access_to_the_table_it_belongs_to(self, table_: Mock): + columns = _Columns(cast(CT_Tbl, element("w:tbl")), table_) table_.table = table_ - return columns, table_ - # fixture components --------------------------------------------- + assert columns.table is table_ + + # fixtures ------------------------------------------------------- @pytest.fixture - def table_(self, request): + def table_(self, request: FixtureRequest): return instance_mock(request, Table) class Describe_Row: - def it_knows_its_height(self, height_get_fixture): - row, expected_height = height_get_fixture - assert row.height == expected_height - - def it_can_change_its_height(self, height_set_fixture): - row, value, expected_xml = height_set_fixture - row.height = value - assert row._tr.xml == expected_xml - - def it_knows_its_height_rule(self, height_rule_get_fixture): - row, expected_rule = height_rule_get_fixture - assert row.height_rule == expected_rule - - def it_can_change_its_height_rule(self, height_rule_set_fixture): - row, rule, expected_xml = height_rule_set_fixture - row.height_rule = rule - assert row._tr.xml == expected_xml - - def it_provides_access_to_its_cells(self, cells_fixture): - row, row_idx, expected_cells = cells_fixture - cells = row.cells - row.table.row_cells.assert_called_once_with(row_idx) - assert cells == expected_cells - - def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): - row, table_ = table_fixture - assert row.table is table_ - - def it_knows_its_index_in_table_to_help(self, idx_fixture): - row, expected_idx = idx_fixture - assert row._index == expected_idx - - # fixtures ------------------------------------------------------- - - @pytest.fixture - def cells_fixture(self, _index_, table_prop_, table_): - row = _Row(None, None) - _index_.return_value = row_idx = 6 - expected_cells = (1, 2, 3) - table_.row_cells.return_value = list(expected_cells) - return row, row_idx, expected_cells - - @pytest.fixture( - params=[ + """Unit-test suite for `docx.table._Row` objects.""" + + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ + ("w:tr", 0), + ("w:tr/w:trPr", 0), + ("w:tr/w:trPr/w:gridAfter{w:val=0}", 0), + ("w:tr/w:trPr/w:gridAfter{w:val=4}", 4), + ], + ) + def it_knows_its_grid_cols_after(self, tr_cxml: str, expected_value: int | None, parent_: Mock): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + assert row.grid_cols_after == expected_value + + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ + ("w:tr", 0), + ("w:tr/w:trPr", 0), + ("w:tr/w:trPr/w:gridBefore{w:val=0}", 0), + ("w:tr/w:trPr/w:gridBefore{w:val=3}", 3), + ], + ) + def it_knows_its_grid_cols_before( + self, tr_cxml: str, expected_value: int | None, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + assert row.grid_cols_before == expected_value + + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ ("w:tr", None), ("w:tr/w:trPr", None), ("w:tr/w:trPr/w:trHeight", None), ("w:tr/w:trPr/w:trHeight{w:val=0}", 0), ("w:tr/w:trPr/w:trHeight{w:val=1440}", 914400), - ] + ], ) - def height_get_fixture(self, request): - tr_cxml, expected_height = request.param - row = _Row(element(tr_cxml), None) - return row, expected_height + def it_knows_its_height(self, tr_cxml: str, expected_value: int | None, parent_: Mock): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + assert row.height == expected_value - @pytest.fixture( - params=[ + @pytest.mark.parametrize( + ("tr_cxml", "new_value", "expected_cxml"), + [ ("w:tr", Inches(1), "w:tr/w:trPr/w:trHeight{w:val=1440}"), ("w:tr/w:trPr", Inches(1), "w:tr/w:trPr/w:trHeight{w:val=1440}"), ("w:tr/w:trPr/w:trHeight", Inches(1), "w:tr/w:trPr/w:trHeight{w:val=1440}"), @@ -806,16 +715,18 @@ def height_get_fixture(self, request): ("w:tr", None, "w:tr/w:trPr"), ("w:tr/w:trPr", None, "w:tr/w:trPr"), ("w:tr/w:trPr/w:trHeight", None, "w:tr/w:trPr/w:trHeight"), - ] + ], ) - def height_set_fixture(self, request): - tr_cxml, new_value, expected_cxml = request.param - row = _Row(element(tr_cxml), None) - expected_xml = xml(expected_cxml) - return row, new_value, expected_xml - - @pytest.fixture( - params=[ + def it_can_change_its_height( + self, tr_cxml: str, new_value: Length | None, expected_cxml: str, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + row.height = new_value + assert row._tr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ ("w:tr", None), ("w:tr/w:trPr", None), ("w:tr/w:trPr/w:trHeight{w:val=0, w:hRule=auto}", WD_ROW_HEIGHT.AUTO), @@ -827,15 +738,17 @@ def height_set_fixture(self, request): "w:tr/w:trPr/w:trHeight{w:val=2880, w:hRule=exact}", WD_ROW_HEIGHT.EXACTLY, ), - ] + ], ) - def height_rule_get_fixture(self, request): - tr_cxml, expected_rule = request.param - row = _Row(element(tr_cxml), None) - return row, expected_rule - - @pytest.fixture( - params=[ + def it_knows_its_height_rule( + self, tr_cxml: str, expected_value: WD_ROW_HEIGHT | None, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + assert row.height_rule == expected_value + + @pytest.mark.parametrize( + ("tr_cxml", "new_value", "expected_cxml"), + [ ("w:tr", WD_ROW_HEIGHT.AUTO, "w:tr/w:trPr/w:trHeight{w:hRule=auto}"), ( "w:tr/w:trPr", @@ -860,143 +773,148 @@ def height_rule_get_fixture(self, request): ("w:tr", None, "w:tr/w:trPr"), ("w:tr/w:trPr", None, "w:tr/w:trPr"), ("w:tr/w:trPr/w:trHeight", None, "w:tr/w:trPr/w:trHeight"), - ] + ], + ) + def it_can_change_its_height_rule( + self, tr_cxml: str, new_value: WD_ROW_HEIGHT | None, expected_cxml: str, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + row.height_rule = new_value + assert row._tr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tbl_cxml", "row_idx", "expected_len"), + [ + # -- cell corresponds to single layout-grid cell -- + ("w:tbl/w:tr/w:tc/w:p", 0, 1), + # -- cell has a horizontal span -- + ("w:tbl/w:tr/w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p)", 0, 2), + # -- cell is in latter row of vertical span -- + ( + "w:tbl/(w:tr/w:tc/(w:tcPr/w:vMerge{w:val=restart},w:p)," + "w:tr/w:tc/(w:tcPr/w:vMerge,w:p))", + 1, + 1, + ), + # -- cell both has horizontal span and is latter row of vertical span -- + ( + "w:tbl/(w:tr/w:tc/(w:tcPr/(w:gridSpan{w:val=2},w:vMerge{w:val=restart}),w:p)," + "w:tr/w:tc/(w:tcPr/(w:gridSpan{w:val=2},w:vMerge),w:p))", + 1, + 2, + ), + ], ) - def height_rule_set_fixture(self, request): - tr_cxml, new_rule, expected_cxml = request.param - row = _Row(element(tr_cxml), None) - expected_xml = xml(expected_cxml) - return row, new_rule, expected_xml + def it_provides_access_to_its_cells( + self, tbl_cxml: str, row_idx: int, expected_len: int, parent_: Mock + ): + tbl = cast(CT_Tbl, element(tbl_cxml)) + tr = tbl.tr_lst[row_idx] + table = Table(tbl, parent_) + row = _Row(tr, table) - @pytest.fixture - def idx_fixture(self): - tbl = element("w:tbl/(w:tr,w:tr,w:tr)") - tr, expected_idx = tbl[1], 1 - row = _Row(tr, None) - return row, expected_idx + cells = row.cells - @pytest.fixture - def table_fixture(self, parent_, table_): - row = _Row(None, parent_) + assert len(cells) == expected_len + assert all(type(c) is _Cell for c in cells) + + def it_provides_access_to_the_table_it_belongs_to(self, parent_: Mock, table_: Mock): parent_.table = table_ - return row, table_ + row = _Row(cast(CT_Row, element("w:tr")), parent_) + assert row.table is table_ - # fixture components --------------------------------------------- + def it_knows_its_index_in_table_to_help(self, parent_: Mock): + tbl = element("w:tbl/(w:tr,w:tr,w:tr)") + row = _Row(cast(CT_Row, tbl[1]), parent_) + assert row._index == 1 + + # fixtures ------------------------------------------------------- @pytest.fixture - def _index_(self, request): + def _index_prop_(self, request: FixtureRequest): return property_mock(request, _Row, "_index") @pytest.fixture - def parent_(self, request): + def parent_(self, request: FixtureRequest): return instance_mock(request, Table) @pytest.fixture - def table_(self, request): + def table_(self, request: FixtureRequest): return instance_mock(request, Table) @pytest.fixture - def table_prop_(self, request, table_): - return property_mock(request, _Row, "table", return_value=table_) + def table_prop_(self, request: FixtureRequest, table_: Mock): + return property_mock(request, _Row, "table") class Describe_Rows: - def it_knows_how_many_rows_it_contains(self, rows_fixture): - rows, row_count = rows_fixture - assert len(rows) == row_count - - def it_can_iterate_over_its__Row_instances(self, rows_fixture): - rows, row_count = rows_fixture - actual_count = 0 - for row in rows: - assert isinstance(row, _Row) - actual_count += 1 - assert actual_count == row_count + """Unit-test suite for `docx.table._Rows` objects.""" + + @pytest.mark.parametrize( + ("tbl_cxml", "expected_len"), + [ + ("w:tbl", 0), + ("w:tbl/w:tr", 1), + ("w:tbl/(w:tr,w:tr)", 2), + ("w:tbl/(w:tr,w:tr,w:tr)", 3), + ], + ) + def it_has_sequence_behaviors(self, tbl_cxml: str, expected_len: int, parent_: Mock): + tbl = cast(CT_Tbl, element(tbl_cxml)) + table = Table(tbl, parent_) + rows = _Rows(tbl, table) + + # -- it supports len() -- + assert len(rows) == expected_len + # -- it is iterable -- + assert len(tuple(r for r in rows)) == expected_len + assert all(type(r) is _Row for r in rows) + # -- it is indexable -- + assert all(type(rows[i]) is _Row for i in range(expected_len)) + + @pytest.mark.parametrize( + ("tbl_cxml", "out_of_range_idx"), + [ + ("w:tbl", 0), + ("w:tbl", 1), + ("w:tbl", -1), + ("w:tbl/w:tr", 1), + ("w:tbl/w:tr", -2), + ("w:tbl/(w:tr,w:tr,w:tr)", 3), + ("w:tbl/(w:tr,w:tr,w:tr)", -4), + ], + ) + def it_raises_on_indexed_access_out_of_range( + self, tbl_cxml: str, out_of_range_idx: int, parent_: Mock + ): + rows = _Rows(cast(CT_Tbl, element(tbl_cxml)), parent_) - def it_provides_indexed_access_to_rows(self, rows_fixture): - rows, row_count = rows_fixture - for idx in range(-row_count, row_count): - row = rows[idx] - assert isinstance(row, _Row) + with pytest.raises(IndexError, match="list index out of range"): + rows[out_of_range_idx] + + @pytest.mark.parametrize(("start", "end", "expected_len"), [(1, 3, 2), (0, -1, 2)]) + def it_provides_sliced_access_to_rows( + self, start: int, end: int, expected_len: int, parent_: Mock + ): + tbl = cast(CT_Tbl, element("w:tbl/(w:tr,w:tr,w:tr)")) + rows = _Rows(tbl, parent_) - def it_provides_sliced_access_to_rows(self, slice_fixture): - rows, start, end, expected_count = slice_fixture slice_of_rows = rows[start:end] - assert len(slice_of_rows) == expected_count - tr_lst = rows._tbl.tr_lst + + assert len(slice_of_rows) == expected_len for idx, row in enumerate(slice_of_rows): - assert tr_lst.index(row._tr) == start + idx + assert tbl.tr_lst.index(row._tr) == start + idx assert isinstance(row, _Row) - def it_raises_on_indexed_access_out_of_range(self, rows_fixture): - rows, row_count = rows_fixture - too_low = -1 - row_count - too_high = row_count + def it_provides_access_to_the_table_it_belongs_to(self, parent_: Mock): + tbl = cast(CT_Tbl, element("w:tbl")) + table = Table(tbl, parent_) + rows = _Rows(tbl, table) - with pytest.raises(IndexError, match="list index out of range"): - rows[too_low] - with pytest.raises(IndexError, match="list index out of range"): - rows[too_high] - - def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): - rows, table_ = table_fixture - assert rows.table is table_ + assert rows.table is table # fixtures ------------------------------------------------------- @pytest.fixture - def rows_fixture(self): - row_count = 2 - tbl = _tbl_bldr(rows=row_count, cols=2).element - rows = _Rows(tbl, None) - return rows, row_count - - @pytest.fixture( - params=[ - (3, 1, 3, 2), - (3, 0, -1, 2), - ] - ) - def slice_fixture(self, request): - row_count, start, end, expected_count = request.param - tbl = _tbl_bldr(rows=row_count, cols=2).element - rows = _Rows(tbl, None) - return rows, start, end, expected_count - - @pytest.fixture - def table_fixture(self, table_): - rows = _Rows(None, table_) - table_.table = table_ - return rows, table_ - - # fixture components --------------------------------------------- - - @pytest.fixture - def table_(self, request): - return instance_mock(request, Table) - - -# fixtures ----------------------------------------------------------- - - -def _tbl_bldr(rows, cols): - tblGrid_bldr = a_tblGrid() - for i in range(cols): - tblGrid_bldr.with_child(a_gridCol()) - tbl_bldr = a_tbl().with_nsdecls().with_child(tblGrid_bldr) - for i in range(rows): - tr_bldr = _tr_bldr(cols) - tbl_bldr.with_child(tr_bldr) - return tbl_bldr - - -def _tc_bldr(): - return a_tc().with_child(a_p()) - - -def _tr_bldr(cols): - tr_bldr = a_tr() - for i in range(cols): - tc_bldr = _tc_bldr() - tr_bldr.with_child(tc_bldr) - return tr_bldr + def parent_(self, request: FixtureRequest): + return instance_mock(request, Document) 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/cxml.py b/tests/unitutil/cxml.py index c7b7d172c..e76cabd74 100644 --- a/tests/unitutil/cxml.py +++ b/tests/unitutil/cxml.py @@ -89,7 +89,7 @@ def from_token(cls, token): Return an ``Element`` object constructed from a parser element token. """ tagname = token.tagname - attrs = [(name, value) for name, value in token.attr_list] + attrs = [tuple(a) for a in token.attr_list] text = token.text return cls(tagname, attrs, text) @@ -263,9 +263,7 @@ def grammar(): child_node_list << (open_paren + delimitedList(node) + close_paren | node) root_node = ( - element("element") - + Group(Optional(slash + child_node_list))("child_node_list") - + stringEnd + element("element") + Group(Optional(slash + child_node_list))("child_node_list") + stringEnd ).setParseAction(connect_root_node_children) return root_node 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 1c4e3aea7..1f4741b6f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37, py38, py39, py310, py311 +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 }, +]