Invariants, errors, and testing¶
The invariants¶
These are the architectural commitments. Each is enforced by a test.
-
No imports between capability modules.
styles/,controls/,fields/,protection/(and the v0.2 / v0.3 capabilities) may import fromcore/only — never from each other. Enforced bytests/test_import_invariant.py, which walks the AST of every.pyfile in each capability directory and asserts no import names another capability. The one deliberate exception iscli/: it is the composition layer and imports across capabilities by design, so it is excluded from the invariant.lint/is the second composing layer and sits in the same position. -
All XML element construction goes through
core/oxml.py. No barelxml.etree.SubElementorOxmlElementcalls in capability modules. No string-formatted XML anywhere. The convention makes it possible to add validation/logging hooks later without rewriting every call site. See schema-strict insertion. -
Each ID namespace has its own registry.
IdRegistrymints SDTw:idvalues;CommentIdRegistry,BookmarkIdRegistry,FootnoteIdRegistry,EndnoteIdRegistrymint values in their own uniqueness domains. All five subclass the internal_IdRegistryBaseincore/ids.pyso thenext/reserve/issuedmechanics live in one place; subclasses override_seed_from_documentto pick up the right existing values. Capability modules either receive a registry as a parameter or construct one scoped to the call. Ther:idrelationship namespace is python-docx's domain and is not wrapped by docx_plus. -
No magic attributes on python-docx objects. Library state lives in
docx_plus-owned objects (IdRegistry,StyleProxy, and in Phase 4,FormBuilder). Neversetattr(doc, "_my_state", ...). -
All public functions have type hints.
mypy --strictpasses ondocx_plus/. The test suite uses looser hints. -
All public functions have Google-style docstrings. Module docstring, function summary, Args/Returns/Raises sections. Enforced by ruff's
Druleset (pyproject.toml:70-83);_testing/,examples/, andtests/are exempt. -
Errors are typed. Every raised library-level error subclasses
DocxPlusError(defined incore/__init__.py). Some dual-inheritValueError,TypeError, orKeyErrorfor callers that still catch the stdlib bases. See below. -
No unrequested side effects on the input document. Functions that mutate document state document the mutation in the docstring.
resolve_*andread_*functions are pure reads.
Error hierarchy¶
Every named exception the library defines subclasses DocxPlusError,
and many dual-inherit a stdlib base when an existing API contract (or
SPEC sentence) calls for it. That is not quite "every exception the
library raises": argument-shape validation in several modules —
bookmarks/, comments/, revisions/ (bad target shapes), fields/,
layout/, notes/, publishing/, tables/shading, core/borders,
and the non-level checks in numbering/define — still raises bare
ValueError / TypeError, as do resolve_effective_formatting and
apply_style for a target of the wrong Python type. v0.6.2 closed the
gaps that were reachable through a valid call: stop_below
(InvalidLayerError), apply_list / restart_list level and start
(InvalidLevelError), num_id / num_level on a style
(InvalidStylePropertyError), parts= on the sweep
(InvalidSweepPartError), and registry exhaustion
(RegistryExhaustedError, the one RuntimeError in the tree).
| Exception | Bases | Raised from | Meaning |
|---|---|---|---|
DocxPlusError |
Exception |
core/__init__.py |
Root of the hierarchy. Catch this to catch every library error |
DuplicateIdError |
DocxPlusError, ValueError |
core/ids.py |
IdRegistry.reserve(n) called on an already-issued value |
IdRangeError |
DocxPlusError, ValueError |
core/ids.py |
A reserved id falls outside the 31-bit positive range OOXML ids must occupy |
InvalidNamespaceError |
DocxPlusError, ValueError |
core/ns.py |
qn() given a malformed name or an unknown namespace prefix |
StyleExistsError |
DocxPlusError |
styles/modify.py |
create_style called on an ID already defined |
StyleNotFoundError |
DocxPlusError |
styles/modify.py |
apply_style/modify_style/delete_style referenced an undefined ID |
StyleInUseError |
DocxPlusError |
styles/modify.py |
delete_style (without force=True) on a referenced style |
UnknownStylePropertyError |
DocxPlusError, TypeError |
styles/modify.py |
Unrecognised **properties kwarg. SPEC §5 says these raise TypeError; dual inheritance lets both contracts hold |
InvalidColorError |
DocxPlusError, ValueError |
styles/modify.py |
A color_rgb value on create_style/modify_style that isn't a valid RRGGBB hex string |
StyleCascadeError |
DocxPlusError |
styles/inspect.py |
basedOn chain cycles or exceeds depth 11 |
MissingPartError |
DocxPlusError |
styles/inspect.py |
A referenced part is required but absent (currently unused — see cascade layer 4) |
ThemeError |
DocxPlusError |
styles/theme.py |
Structurally invalid theme input to the transform functions |
MissingNamespaceError |
DocxPlusError |
controls/builder.py |
FormBuilder constructed against a doc whose root doesn't declare w14 |
ControlNotFoundError |
DocxPlusError, KeyError |
controls/read.py |
set_control_value/clear_control referenced a tag or control_id that doesn't exist, or neither |
DuplicateTagError |
DocxPlusError, ValueError |
controls/read.py |
A tag doesn't identify exactly one control: read_controls found two SDTs sharing a non-empty key, or a writer's tag matched several. An absent or empty tag is not a duplicate — it's unkeyable, so read_controls omits it and list_controls reports it |
ValueNotInListError |
DocxPlusError, ValueError |
controls/read.py |
set_control_value against a dropdown got a value that matches no item (combobox is exempt — it accepts freeform) |
ControlTypeError |
DocxPlusError, TypeError |
controls/read.py |
set_control_value got a value whose Python type doesn't match the control type (e.g. str to a checkbox) |
InvalidDropdownItemError |
DocxPlusError, TypeError |
controls/builder.py |
A dropdown/combobox items entry that isn't a str or a (display, value) tuple |
fields/ and protection/ deliberately add no new error classes.
Their argument types are Literal[...] so mypy catches misuse
statically; runtime misuse produces a structurally-valid file with a
semantically-wrong attribute that Word surfaces in its UI. The
alternative — runtime validation duplicating the type system — would
add noise without catching real bugs.
The v0.2 modules (comments/, layout/, bookmarks/, notes/,
publishing/) follow the same pattern. They surface only ValueError
and TypeError for argument-shape problems (bad bookmark names,
empty paragraph targets, wrong tuple shapes for run-range targets,
out-of-range set_line_numbering arguments) and reuse
DuplicateIdError / IdRangeError from core/ids.py through their
namespace-specific registries.
The v0.2 in-place expansion added two missing-lookup errors for the new edit verbs:
| Exception | Bases | Raised from | Meaning |
|---|---|---|---|
CommentNotFoundError |
DocxPlusError, KeyError |
comments/anchor.py |
edit_comment against an id that doesn't exist in comments.xml (or when the comments part itself is absent) |
NoteNotFoundError |
DocxPlusError, KeyError |
notes/write.py |
edit_footnote / edit_endnote against an id that doesn't exist in the corresponding part |
v0.3 through v0.6.2 added the rest of the tree:
| Exception | Bases | Raised from | Meaning |
|---|---|---|---|
RevisionNotFoundError |
DocxPlusError, KeyError |
revisions/mark.py |
accept_revision / reject_revision on a missing id |
CliError |
DocxPlusError, ValueError |
cli/_io.py |
A user-facing CLI failure — bad path, missing output, un-coercible value. The dispatcher prints it and exits 1 |
InvalidMergeError |
DocxPlusError, ValueError |
tables/merge.py |
A merge that cannot be performed as requested — a non-rectangular selection (wraps python-docx's InvalidSpanError) or a normalisation failure |
InvalidLevelError |
DocxPlusError, ValueError |
numbering/define.py |
A malformed LevelDefinition, level list, or level index; since v0.6.2 also what apply_list / restart_list raise for a level outside 0–8 or a negative start |
ListDefinitionNotFoundError |
DocxPlusError, KeyError |
numbering/apply.py |
restart_list on a numId that numbering.xml does not define |
DuplicateBookmarkNameError |
DocxPlusError, ValueError |
core/ids.py |
BookmarkNameRegistry.reserve on a name already in use |
UnknownRuleError |
DocxPlusError, KeyError |
lint/registry.py |
A selector, or a profile, named a rule id or tag that does not exist |
InvalidProfileError |
DocxPlusError, ValueError |
lint/profile.py |
The lint profile is unreadable or malformed |
InvalidFixError |
DocxPlusError, ValueError |
lint/plan.py |
A rule produced a fix plan_fixes cannot order safely — one that both deletes and does positional work |
RegistryExhaustedError |
DocxPlusError, RuntimeError |
core/ids.py |
v0.6.2. A registry has no value left to issue. Effectively unreachable, but typed so the one path in core/ that could abort a command is still a DocxPlusError |
InvalidLayerError |
DocxPlusError, ValueError |
styles/inspect.py |
v0.6.2. resolve_effective_formatting(stop_below=...) named something that is not a Layer |
InvalidStylePropertyError |
DocxPlusError, ValueError |
styles/modify.py |
v0.6.2. The right property name with the wrong value — a negative num_id, a num_level outside 0–8, or num_level on a style that links no numbering definition. Distinct from UnknownStylePropertyError, which is the wrong name |
InvalidSweepPartError |
DocxPlusError, ValueError |
styles/sweep.py |
v0.6.2. iter_resolved_paragraphs(parts=...) named something that is not a SweepPart |
The dual-inheritance pattern (DuplicateIdError, UnknownStylePropertyError,
the four Phase 4 controls/read.py errors) exists because SPEC sentences
predating §9.7's typed-error invariant documented
ValueError / TypeError / KeyError as the raised type. Rather than
breaking the spec contract, both bases sit on the class — except
ValueError and except DocxPlusError both catch.
Testing strategy¶
SPEC §10 specifies three layers:
- Layer 1 — structural unit tests. One file per module, fast, no
I/O beyond reading fixtures. 2113 tests at v0.6.2
(2101 pass; 12 LibreOffice round-trips skip without
soffice), at 96% coverage against a 90% gate. Of these, 631 were collected at v0.2.0: v0.1's surface (319 tests) plus the v0.2 cycle —core/parts(13),comments/(35),layout/(47),bookmarks/+ cross-refs (26),notes/(34),styles/table conditional (13),publishing/(23) — plus example smoke tests for the new demos, plus the regression coverage added by the pre-publication code/docs review (cascade correctness, schema/part wiring, error taxonomy, publishing validation, and the six newly-writable run toggles). v0.3 added the balance:revisions/(mark / read / accept-reject / settings / registry) and thecli/subcommands. v0.4 addedtests/test_comments_threads.py(reply anchoring and marker ordering, thread-wide resolve / reopen, nested reads, foreign / malformedcommentsExtended.xmltolerance) plus thecommentsCLI subcommand. - Layer 2 — round-trip tests. Build → save → reopen with
python-docx→ assert. The high-value class for OOXML correctness (IMPLEMENTATION.md §8). Phase 5 added round-trips for every field type plus the protect/unprotect cycle;TEST_GAPS.mdI1 lists the remaining gaps on the modify side. - Layer 3 — headless render smoke. Run each example, convert to
PDF with LibreOffice headless, assert exit-0 and page count. Gated
on the
requires_libreofficepytest marker.
Test fixtures live in tests/fixtures/build_fixtures.py (the build
script is the source of truth, not the .docx files it produces —
.gitignore excludes the generated docx files). empty.docx,
multistyle.docx, themed.docx, and existing_form.docx are built
on demand.
Shared assertions live in docx_plus/_testing/ooxml_asserts.py:
assert_ids_unique, assert_para_ids_unique, assert_style_defined,
count_controls, assert_protected, assert_field_dirty. The module is
internal — not re-exported from the top-level package — and is built out
lazily as later tests demand more helpers. Of the SPEC §10 helper list,
only assert_style_not_defined and assert_no_orphan_relationships
remain unwritten.
For a frozen snapshot of where the suite has real holes, see
TEST_GAPS.md.