0.5.0 — degrade deeply nested HTML instead of raising, convert content on access - #37
Conversation
markdownify walks the parsed document recursively at about two CPython frames per nesting level, so roughly 494 levels of HTML exhausted the default 1000-frame limit. The converter ran as a pydantic inbound validator, so the RecursionError surfaced from inside model_validate -- a bare builtin escaping a package whose error surface is supposed to derive from GlpiError. from_transport now measures nesting first with a flat non-recursive O(n) scan and, past MAX_HTML_DEPTH (200), strips tags instead of parsing. It degrades and never truncates: every character the converting path would have produced also appears in the degraded rendering. Anything the parser still raises is wrapped in the new GlpiContentError, which inherits GlpiError only -- no ValueError, since no release ever raised a bare ValueError at these sites. The limit is set from headroom rather than from the observed cliff: a 200-level document peaks at a measured 403 frames and the client contributes 8 to 37 at the call site, so it holds until the caller's own stack passes roughly 590 frames. sys.setrecursionlimit is rejected, with the reasoning recorded beside the constant and enforced by an AST guard in the raise-site audit: a library must not mutate global interpreter state on its consumers' behalf, and raising the limit past the C stack turns a catchable RecursionError into an interpreter crash. Both halves were fuzzed against html.parser over 15000 documents with zero depth under-counts, zero over-counts and zero text losses. Four rules the scan needs that are not the obvious ones: a closing tag pops by name or is ignored, an attribute value may contain < and >, a childless leaf is still a level, and the markup forms must be matched in one left-to-right alternation to reproduce the parser's dispatch order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion BREAKING CHANGE: the six read models carrying rich text now store the wire value in content_html (and description_html on GetKBArticle) and expose content as a cached_property. Reading .content is unchanged, but "content" is no longer a field: model_dump() emits content_html holding HTML where it used to emit content holding Markdown, and "content" not in GetTicket.model_fields. Converting inside the validator meant every record on a page paid for a conversion nobody had asked for, and one unconvertible body failed the whole page, because _resource_list builds every item in one comprehension. Converting on access moves the cost and the blast radius to the record whose body is actually read: a list read converts nothing, and a failure is confined to one record. The validation alias accepts both spellings, so GLPI's payloads and GetTicket(content=...) both still work. Write models stay eager -- a caller's own Markdown is worth validating where it was supplied, and there is no list path there to make lazy. This needed a latent bug in GlpiModel fixed first: _capture_unknown_fields runs before pydantic resolves aliases, so it was diverting the wire's "content" into extra_payload and leaving the body None with an HTTP 200 and no warning. It now compares against every alias a field declares, resolved once per model class and cached. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents the content-conversion fix and the content_html rename, and bumps the version everywhere the version-agreement test looks: pyproject.toml, __version__ and the nine skill stamps. 0.4.3 is tagged and the field rename is breaking, so these notes get their own minor version rather than being filed under a released heading. The user guide gains a rich-text conversion section covering where conversion happens, the depth ceiling and how to catch GlpiContentError; the api reference documents the new exception and the content helpers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review of the depth scan found the degraded path printing a tag it had failed to parse, and probing that turned up three more rules taken from the wrong place -- my reading of the HTML5 spec rather than CPython's tolerant parser, which is what markdownify actually builds its tree with. All four are now measured against html.parser's own event stream. - A quote opens an attribute value only as the first character after the `=`: attrfind_tolerant spells the bare alternative (?!['"])[^>\s]*, so `<p title=don't>` carries the value `don't` and an unquoted value may hold `<` as well. Reading that apostrophe as an opening quote ran the value on to the next apostrophe, the tag then matched nothing, and `"<div>" * 300 + "<p title=don't>Le serveur ne repond plus.</p>"` degraded to `"<p title=don't>Le serveur ne repond plus."` -- markup emitted into text a person reads, from the one path whose whole promise is text. An apostrophe needs no malice to reach a French body. - tagfind_tolerant runs a tag name to whitespace, `/` or `>`, so `<style=>` is an element named `style=` and never enters raw-text mode. A self-closed `<script/>` does not either: parse_starttag calls set_cdata_mode only on the branch that is not self-closing. Reading either as raw text swallowed the rest of the document, so `"<style=>" + "<div>" * 600` measured 1 level against a real 601, went to markdownify and raised -- the unbounded under-count the ceiling exists to prevent, and the same defect as the quoted-attribute one already fixed here. - An attribute name may contain `<`, so after a quoted value the parser keeps scanning to the next `>`. - A declaration is text on neither path only when it is closed. A `<!weird` left unterminated at end of input is flushed as character data by close(), so dropping it lost the tail of a body. Re-fuzzed with those shapes in the corpus: 15000 documents with zero depth under-counts either direction and no document losing text beyond the character-reference case already documented, plus 4000 deep documents built from the same malformed shapes with none reaching markdownify. Parity with markdownify was measured directly for each new case rather than inferred. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The adversarial review of the depth scan came back after this PR was opened and found a real defect, fixed in 7c20758. Worth recording what it was, because probing it turned up three more of the same kind. The degraded path was printing a tag it had failed to parse. The cause: I had taken the attribute rules from the HTML5 spec rather than from CPython's tolerant parser, which is what
Re-fuzzed with all of those shapes in the corpus: 15000 documents, zero depth under-counts in either direction, no document losing text beyond the character-reference case already documented, and 4000 deep documents built from the same malformed shapes with none reaching One caveat on the review itself: it was cut short by a spend limit. The |
The suite could not pass before a release. `from_env` requires `server_timezone` -- GLPI does not advertise its own, so the client refuses to guess -- but this test exported only six variables and failed in 0.4s, before reaching the network. Pre-existing on main, and the fixture already carries the value. With it, the whole integration suite is green against a live GLPI 11: 44 passed, 4 skipped (one entity not configured in local secrets, three for the Fields plugin, which is no longer installed on that instance). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… justify `status` became a write field in 73d0a9b on the strength of four measurements against a live GLPI 11, and only the reasoning survived in the repository. `probe_wire_format.py` is already tracked for the same reason: the v2 API answers 200 to fields it does not recognise, so these questions cannot be settled from inside the repository and a future reader who doubts the answer needs the probe, not a summary of it. - probe_ticket_status.py -- which route writes `status`, each case reading the ticket back because a 200 proves nothing. - probe_ticket_status_scope.py -- whether POST honours it, and whether the advertised id set is actually closed. - probe_status_is_configurable.py -- whether statuses are instance data or hardcoded, three independent read-only checks with positive controls. - probe_ticket_status_99.py -- parks one ticket on status 99 for UI inspection. Unlike every other probe here it deliberately leaves that ticket behind, says so, and prints the id to delete. All four take credentials from `secrets/` by filename and record no instance data: no hostnames, entity or user names, ids or counts. The two that write create their own tickets, titled "safe to delete", and force-delete them in a `finally`. probe_sync_config_targets.py stays untracked deliberately: it serves another project and its docstring records this instance's inventory, which does not belong in a public repository. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`<p>line1<br>line2</p><p>para2<br />line4</p>` converted to `line1 \nline2\n\npara2`. `line4` was gone, with no error, on the ordinary conversion path -- the path every ticket body takes. The cause is in beautifulsoup4 (measured on 4.14.3), not markdownify. Its html.parser builder auto-closes a bare `<br>` and records the name in `already_closed_empty_element` so that a later `</br>` can be ignored as redundant; when no `</br>` arrives the entry stays. The next `<br />` reaches the builder as `handle_startendtag`, opens a real element and closes it itself -- and that close finds the stale entry, treats the element as already closed, and leaves it open. Every following sibling becomes a child of the `<br>`, and `convert_br` ignores an element's children. `get_text` walks children, which is why the tree looks intact and why this was invisible until the degraded path was compared against the converting one and said *more*. Note the paragraph boundary above: the two spellings need not be near each other, because a name once recorded poisons the rest of the document. One bare `<br>` anywhere before one `<br />` is the whole precondition, and a GLPI body is edited by more than one client. `<img>` and `<hr>` are the other two converters that discard children and lost text the same way. `from_transport` now writes self-closing void tags bare before converting, which removes the `handle_startendtag` path where the asymmetry lives. Measured, not assumed: over 4000 fuzzed documents of each spelling alone not one output changed, and over 4000 mixing them, 102 recovered text and none lost any. Every spelling that reaches `handle_startendtag` is covered (`<br/>`, `<br />`, tab and newline variants, `<BR />`); the two that do not (`<br / >`, `<br/ >`) arrive as ordinary start tags and were never affected. The rewrite touches only void names in real tag position, so a `<div/>`, a `<br />` inside an attribute value, a comment and a `<script>` body are all left alone. It costs 1.1ms on an 11KB body against markdownify's 25ms, and nothing at all when the body has no `/>`. Worth reporting upstream to beautifulsoup4: the entry is keyed by name alone and never expires, so any document mixing the two spellings of one void element is affected, whatever the consumer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round two of the adversarial review returned five confirmed findings.
Two were unbounded depth under-counts of exactly the kind the ceiling
exists to prevent, and every one of the five came from taking a rule from
somewhere other than the thing that implements it.
Depth, both unbounded and both also deleting prose at any depth:
- `parse_endtag` falls back to `rawdata.find(">")`, so an end tag skips
nothing; CPython's own comment concedes the case, "this is not 100%
correct, since we might have things like `</tag attr=">">`". Reading one
with attribute rules made `'</x a="><div>">' * 600` measure 0 against a
real 600, and raise. End tags now have their own branch.
- `locatestarttagend_tolerant` reaches a quoted value only through an
attribute name, and a name may itself begin with `=`. So `<div ="<p>`
opens no value and ends at the first `>`, where treating any `=` before
a quote as a value indicator made `'<div ="' + "<p>" * 600` measure 1
against a real 600. The attribute pattern is now a sequence of
attributes rather than a run of permitted characters.
The name in that sequence carries the parser's "starts after a quote,
whitespace or `/`" rule, which turned out to be load-bearing twice: it is
what makes `<div ="` a name, and it is what stops the pattern
backtracking catastrophically. My first attempt omitted it and a
400-byte `'<div a="' * 50` did not finish.
Cost, which the review also caught as a false claim rather than only a
slow path: a body of nothing but `<div a="` cost O(n**2) -- 32 KB in
6.6 s -- because `re` restarts at every `<` where html.parser buffers an
incomplete tag and never looks back. No `>` anywhere means no element
anywhere, so that is answered in constant time now. The remaining
non-linear shape is documented rather than papered over: bounding the
attribute repetition would make a tag past the bound fail to match, which
is an under-count, and unbounded again once such tags nest.
Taxonomy, on the write path: outbound conversion runs in a
PlainSerializer, and pydantic-core catches everything a serializer raises
and re-raises PydanticSerializationError -- a ValueError, not a
GlpiError, with `__cause__` and `__context__` both None. So on every
create_*/update_* carrying a body, `except GlpiError` did not fire and the
underlying fault was unrecoverable. C1's whole point, gone on half the
surface. The fault is now stashed as it is raised and restored around
model_dump with its cause intact; a serialisation failure that is not
content becomes GlpiValidationError rather than being mislabelled.
Aliases: one field spelled two ways shadowed itself. Pydantic consumes
the first alias and extra="allow" files the rest as model extras -- and
an extra named after a field is emitted by model_dump *instead of* that
field, so the attribute reported one body and the object's own dump
reported the other. The redundant spelling is dropped before Pydantic
resolves anything, and content_html is now the first choice, so a dump
carrying both round-trips back to the raw body.
Numbers, re-measured because the review found several that did not
reproduce: the cliff is 494 (495 for `<ul><li>`), not 492; a 200-level
document peaks at 412 frames, not 403; `_strip_tags` runs at 2.2-11.5
MB/s depending on tag density, not 4-8, and is 2.3x to 18x faster than
the call it stands in for, not "roughly ten times". The call-site frame
counts described a call site this branch deleted -- conversion no longer
happens inside the fetch, so it is 5 frames below the caller reading
.content, the same 5 whether the model came from model_validate or from
get_ticket, and 9 on the write path. The void-element set is copied from
`bs4.builder`, not `bs4.builder._htmlparser`.
Re-fuzzed with end tags carrying attributes and name-less `="` in the
corpus, which is what round two's corpus lacked: 15000 documents with 0
depth under-counts against the tree that is actually converted and 0
losing a prose word, plus 3000 deep documents with none reaching
markdownify.
One finding is left as documented behaviour rather than fixed:
`model_copy(update={"content": ...})` updates nothing, because `content`
is a cached_property and the value lands in model extras. It is named
outright in the module docstring and the changelog now, since a caller
redacting a body that way gets an object whose .content still holds the
original.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng so The review's last confirmed finding was a comment, not code: READ_CONTENT_MODELS claimed that naming the six read models explicitly meant "adding a read model with a content field and forgetting the property is a failure here". It is not. A hand-written list cannot notice a model that is missing from it -- a seventh read model would simply be a model these cases never mention, which is silence. Two discovery guards now walk the GlpiModel class tree instead: every Get* model carrying a <slot>_html field must appear in the list with that slot named, and no Get* model anywhere may declare content or description as a field, which is the shadowing check widened from six models to all of them. Both were mutation-tested: an unlisted seventh read model and one that shadows its own property each fail the guard that is meant to catch it. Discovery also answered the question the list was hiding -- whether any other model carries rich text this change missed. Six Get* models declare `comment` as a plain field, and it converts nothing in either direction: GetUser.comment hands back raw HTML. That asymmetry with ticket content predates this branch and is left alone, but it is now visible rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Round two of the review ran the four dimensions that were cut short, then the two verifiers that died on a spend limit. 12 findings, 14 adversarial verifications, 12 confirmed, 2 refuted. All of them are now addressed in The two that matteredBoth were unbounded depth under-counts — the one direction the ceiling exists to prevent — and both also deleted prose from the degraded path at any depth. Both came from taking a rule from the HTML5 spec instead of from the function that implements it.
The attribute pattern is now a sequence of attributes rather than a run of permitted characters, and end tags have their own branch. The name in that sequence carries the parser's "starts after a quote, whitespace or The rest
Refuted, correctly: the claim that the Left as documented behaviour
VerificationRe-fuzzed with end tags carrying attributes and name-less |
A third adversarial round found five more unbounded depth under-counts, all of the same kind as rounds one and two: the pattern reproducing html.parser's dispatch disagreed with html.parser. A comment closes on `--\s*>` and not only on `-->`; `</ script>` ends raw text; `<![IGNORE[` opens a marked section; `</ div foo>` is a bogus comment rather than an end tag; and `<a href=/>` -- an ordinary root-relative link -- leaves the element *open*, because the unquoted value swallows the `/`. Each made a document measure one level deep where the real tree was hundreds, so `'<a href=/>' * 494` cleared the ceiling and raised the RecursionError the ceiling exists to prevent. Two more were cost, and both were mine from the previous round: a run of whitespace inside a failing tag made the attribute pattern backtrack as `(a+)*`, so a 39-byte body took 20.8 s and a 29 KB mail body with one unbalanced quote took 44 s. Seven defects in three rounds, each fix breeding the next, is a pattern about the approach rather than about any of them. So the pattern is gone. Depth, void-tag canonicalisation and the degraded rendering now come from one pass of an html.parser subclass, which cannot be wrong about html.parser. It is not a new dependency or a new risk either: markdownify builds its tree with bs4, bs4 builds it with this same html.parser, so every pathology the parser has was already in the pipeline -- measured on the shapes that made the pattern backtrack, the markdownify call costs what the scan costs, to within a few per cent. The recursion the ceiling guards is markdownify's walk of the finished tree; the parse that builds it is an iterative loop. Reading the parser's own event stream also removed the workarounds that had accumulated around not having it. `handle_startendtag` fires exactly when the stripped tag remainder is `/>`, which is exactly the condition that trips the bs4 already_closed_empty_element defect, so the void-tag rewrite now fires on those tags and no others. convert_charrefs is False because that is what bs4 passes, so the whole-name reference rule applies on both paths. Three consequences beyond the five defects: - A derailed scan silently reinstated the <br> data loss fixed in 373649a, because _canonicalise_void_elements read the same pattern. Measured, `"<p>one<br>two</p><script>x</ script><p>three<br />TAIL</p>"` lost TAIL outright, and <img> and <hr> lost their tails the same way. Three functions were poisoned by one pattern, which is also why _strip_tags had to convert: the backtracking lived there too, reachable by any document both deep and malformed. - A document the parser rejects now degrades instead of raising. `<![FOO[` makes _markupbase raise AssertionError, which bs4 re-raises as ParserRejectedMarkup; the caller used to get a GlpiContentError and none of their text, and now gets their words. - A processing instruction no longer leaves `<?` and `>` in the degraded text. The converting path prints the body alone, so this does too. Cost, end to end and on byte-identical output: 0.77x to 1.52x of the previous implementation on realistic bodies -- faster on sparse prose, where the scan beats the pattern four to one -- and the guard stays 6 to 18% of the markdownify call it protects. The exponential shapes are flat: 39 bytes of the whitespace bomb went from 20.8 s to 0.12 ms, and 20 KB of it costs 0.67 ms. The one shape where html.parser is worse than linear is a document carrying no `>` at all, where close() advances a character at a time and rescans the tail: 32 KB costs it 13 s. That is answered in constant time by the guard already present for the pattern's own O(n**2) on the same input, and is unreachable from from_transport, which needs a `>` to find an element at all. Re-fuzzed against a ground-truth walk of the tree bs4 really builds, over an alphabet carrying every construct all three rounds raised -- including the four whose absence is why the previous 10.5M-document corpus could not have found any of this: `-- >`, `</ script>`, `<![IGNORE[` and runs of whitespace and quotes inside a tag. 470000 documents with 0 depth under-counts, 0 prose losses and 0 crashes, plus 60000 hostile documents through from_transport with nothing but GlpiError escaping. Two findings from that round were retracted rather than fixed, being artifacts of how the check counted rather than defects: the `</x a=">` and `<p title=don't>` under-counts were text-leaf accounting, and `<![if x]>` does not reproduce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI has been red on this branch all day, on every Python version, while the suite passed locally. The cause is not the module: `html.parser`'s reading of a *malformed* construct is not stable across CPython patch releases, and six content tests had written down the literal output of the interpreter they were authored on. Measured on the same documents, three builds disagree. An unterminated `<script>` body is discarded on `close()` by 3.12.3 and flushed as character data by 3.12.14. A comment with no `-->` is handed back as data by 3.12.3 and 3.12.11 and swallowed by 3.12.14. An end tag carrying a quoted `>` -- `</p title="> SECRET ">` -- keeps the tail as text on 3.12.3 and 3.12.11 and consumes it on 3.12.14. And `<![FOO[` raises AssertionError from `_markupbase` on 3.12.3 and 3.12.11 but not on 3.12.14, so the give-up path is reachable on some interpreters and dead on others. Local is 3.12.3, CI is 3.12.14; 3.12.11 was installed to confirm the middle point. None of that is a defect in this module, and none of it is something a literal can track. What the module promises is parity -- a body must not say less because of the path it took -- so the expectation is now computed from the converting path instead of written down. Both paths read the same parser, so they move together, and the relation asserted is inclusion rather than equality, which is what was ever true: the degraded rendering is a superset. Prose is compared as its letters in order, because whitespace falls differently at a markup boundary in both directions and the converter adds punctuation of its own. The construct-by-construct table keeps its measured `kept` booleans as documentation, and now asserts them only where the running parser still agrees with the measurement; the parity assertion beside them holds unconditionally. `<![FOO[` is asked of the interpreter rather than assumed, so the test covers both the build that rejects the document and the build that does not. The handler stays: the package supports 3.10 through 3.14, and the rejection is live on several of them. The padding that pushes a body past the ceiling is now closed before the construct under test rather than wrapped around it. Wrapping changed what the construct meant -- an unterminated `<!weird` runs to the next `>`, which inside a wrapper is the `>` of a `</div>`, so the same text was a bogus comment there and character data at end of input. Verified on 3.12.3 and 3.12.11: 1351 passed on both. Live integration against GLPI 11 after the parser rewrite: 44 passed, 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The give-up path for an unknown marked-section keyword is live on 3.10 through 3.12.11 and gone on 3.12.14, and the same three builds disagree about an unterminated <script>, a comment with no --> and an end tag carrying a quoted >. Stating it unconditionally was wrong; the depth decision tracks the parser on every version, but the text a broken construct contributes to a degraded body is the interpreter's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #37 +/- ##
==========================================
+ Coverage 97.24% 97.31% +0.06%
==========================================
Files 80 80
Lines 2614 2826 +212
==========================================
+ Hits 2542 2750 +208
- Misses 72 76 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
MAX_HTML_DEPTH and the scan behind it are gone. from_transport now runs markdownify and catches the RecursionError, stripping the body to its text when the walk does not fit. The bound was wrong in both directions. Too low: the budget is not 1000 frames, it is whatever is left of the stack when the conversion starts, and that belongs to the caller -- so a bound fixed in advance had to assume the worst, and 200 against a measured cliff of 494 flattened everything in between. Measured, a 300-level and a 400-level body now come back as Markdown with their links, emphasis and lists intact where they used to come back as plain text, with no error to notice and no way for a caller to ask for better. And too fragile: predicting the depth meant reproducing html.parser's idea of the tree, and three rounds of adversarial review found seven ways for the estimate to land under the real depth -- each one sending a document to markdownify and into the very RecursionError the bound existed to prevent. Trying the conversion cannot be wrong about whether the conversion fits. That is the whole argument: the failure mode this guard existed to prevent was only ever reachable through the guard's own arithmetic. A document html.parser refuses outright takes the same path. `<![FOO[` makes _markupbase raise AssertionError on the interpreters where the keyword is unknown, and bs4 re-raises it as ParserRejectedMarkup; the caller used to get a GlpiContentError and none of their words. Two costs, both measured and both stated in the changelog. The outcome now depends on the caller's remaining stack, so the same body can convert from one call site and degrade from a deeper one; nothing is lost either way, since the degraded rendering keeps every character of prose, but it is no longer a property of the body alone. And a body too deep to convert pays the failed attempt before it degrades -- 2.0x to 2.6x the old cost at 600 and 5000 levels. Ordinary bodies got faster, 0.87x to 0.90x, because the scan they used to pay for on every single read is gone. The parser scan stays, minus its stack and its high-water mark: it still backs the void-tag rewrite and the degraded renderer, which inherited every one of the pattern's misreadings and its backtracking. A thread with a larger stack was considered and rejected with sys.setrecursionlimit, and for the same reason -- the recursion limit is a counter rather than a measurement, so a deeper thread still needs the global limit raised to use it. That reasoning moved to the module docstring, where the AST guard in the raise-site audit still enforces it. Tests follow the same principle. Six suites asserted the scan's arithmetic against a parallel implementation; they are re-pointed at the property that survives, over the same corpora -- that the two renderings of one body do not disagree about what is markup, which is how those misreadings would surface now. The fallback is called directly rather than provoked with a 600-level body, because provoking it cost this suite 333 seconds under coverage instrumentation against 108 now, and because the routing is one test's job rather than fifty's. Verified on CPython 3.12.3 and 3.12.11: 1343 passed, 97.45% coverage. 257000 fuzzed documents with 0 non-GlpiError escapes, 0 prose losses and 0 crashes. Sphinx, mypy strict, ruff and unasync --check clean. BREAKING CHANGE: glpi_python_client.content.conversion.MAX_HTML_DEPTH is removed. It was added earlier in this same unreleased cycle and appears in no published release, so no released API changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deeply nested HTML made
markdownifyraiseRecursionErrorfrom insidemodel_validate, so aget_ticketcall could fail with a bare builtin, and one bad body took its whole page down with it. Fixing that opened a seam that three rounds of adversarial review then worked through; the last round is what replaced the markup scan with the parser rather than an imitation of it.Changes
RecursionErrorno longer escapes the package. Both conversion directions wrap parser faults in a newGlpiContentError(aGlpiErrorleaf,__cause__preserved), soexcept GlpiErrorcatches them.Deep nesting degrades instead of raising, and the ceiling is discovered rather than guessed.
from_transportattempts the conversion and answers theRecursionErrorby stripping the document to its text. It never truncates: every character of prose the converting path would have produced also appears in the degraded rendering.Attempting it replaced a fixed
MAX_HTML_DEPTH = 200bound that was wrong in both directions. Too low — the budget is not 1000 frames but whatever is left of the stack when the conversion starts, which belongs to the caller, so a bound fixed in advance had to assume the worst and flattened everything between 200 and the real cliff of ~494. Measured, a 300- and a 400-level body now come back as Markdown with their links, emphasis and lists intact where they used to come back as plain text, with no error to notice. And too fragile — predicting the depth meant reproducinghtml.parser's idea of the tree, and three review rounds found seven ways for the estimate to land under the real depth, each one sending a document into the veryRecursionErrorthe bound existed to prevent. Trying the conversion cannot be wrong about whether the conversion fits.Costs, measured: a body too deep to convert now pays the failed attempt first (2.0–2.6× at 600 and 5000 levels), and the outcome depends on the caller's remaining stack, so the same body can convert from one call site and degrade from a deeper one. Ordinary bodies got faster (0.87–0.90×) because the scan they used to pay for on every read is gone.
The depth scan reads
html.parser's events instead of imitating its dispatch. Three rounds of adversarial review found one unbounded depth under-count after another in the pattern that stood in for the parser, each fix breeding the next. The last round alone found five: a comment closing on--\s*>rather than only-->,</ script>ending raw text,<![IGNORE[opening a marked section,</ div foo>being a bogus comment rather than an end tag, and<a href=/>leaving an element open because the unquoted value swallows the/. Each made a document measure one level deep where the real tree was hundreds, so'<a href=/>' * 494cleared the ceiling and raised. Two further findings were exponential backtracking, where a 39-byte body took 20.8 s.The depth prediction those defects were found in is gone entirely (above), but the same pattern also backed the void-tag rewrite and the degraded renderer, which inherited every misreading and the backtracking. Both now come from one pass of an
html.parsersubclass, which cannot be wrong abouthtml.parser. Not a new dependency or a new risk:markdownifybuilds its tree withbs4andbs4builds it with this same parser, so every pathology it has was already in the pipeline. The exponential shapes are now flat (20.8 s → 0.12 ms).Rich text converts on access, not during validation. The six read models store the wire value in
content_html/description_htmland exposecontent/descriptionas acached_property. A list read converts nothing, and an unconvertible body affects only the record whose body is read. Write models stay eager, so caller Markdown is still checked where it was supplied.A body using both spellings of
<br>no longer loses everything after the second.bs4records an auto-closed<br>by name inalready_closed_empty_element, so a later<br />closes against the stale entry and stays open, andconvert_brdiscards its children.<p>line1<br>line2</p><p>para2<br />line4</p>converted to'line1 \nline2\n\npara2'. Void tags are written bare before conversion;<img>and<hr>lost text the same way.GlpiContentErrornow survives the write path. Outbound conversion runs in aPlainSerializer, and pydantic-core catches whatever a serializer raises and re-raisesPydanticSerializationError— aValueError, not aGlpiError, with__cause__and__context__bothNone. So on everycreate_*/update_*carrying a body,except GlpiErrordid not fire. The fault is stashed and restored aroundmodel_dumpwith its cause intact; a serialisation failure that is not content becomesGlpiValidationErrorrather than being mislabelled.GlpiModelno longer swallows aliased payload keys, or lets a field shadow itself._capture_unknown_fieldsran before pydantic resolved aliases and diverted the wire'scontentintoextra_payload, leaving the bodyNonewith an HTTP 200 and no warning. A payload spelling one field two ways now keeps only the spelling pydantic would use — previously the loser became a model extra thatmodel_dumpemitted over the real field, so the attribute reported one body and the object's own dump reported the other.A document
html.parserrefuses now degrades to text instead of raising.<![FOO[makes_markupbaseraiseAssertionErroron the interpreters where the keyword is unknown — 3.10 through 3.12.11 as measured, no longer 3.12.14 — andbs4re-raises it asParserRejectedMarkup. Where that happens the caller used to get an error and none of their words, and now gets their text.sys.setrecursionlimitand a larger thread stack both rejected, and for the same reason: the recursion limit is a counter rather than a measurement of the stack, so a deeper thread still needs the process-global limit raised to use it. The reasoning lives in the module docstring and is enforced by an AST guard in the raise-site audit.The content tests assert the parity promise rather than one interpreter's output.
html.parser's reading of a malformed construct is not stable across CPython patch releases — measured, 3.12.3, 3.12.11 and 3.12.14 disagree about an unterminated<script>, a comment with no-->, an end tag carrying a quoted>, and whether<![FOO[raises at all. Six tests had written down the literal output of the build they were authored on, which is what had CI red on this branch. The expectation is now computed from the converting path: both paths read the same parser, so they move together, and the relation asserted is the one that was ever true — the degraded rendering is a superset, not an equal.Tests and docs. The content round-trip suite asserts the read-model list is complete by discovery rather than claiming it in prose (both guards mutation-tested).
test_from_envexportsGLPI_SERVER_TIMEZONE, which it needed since 0.4.3. Four live probes that justify the ticket-status typing are kept beside it. New user-guide section on rich-text conversion;GlpiContentErrorand the content helpers in the API reference. Version bumped to 0.5.0 acrosspyproject.toml,__version__and the nine skill stamps.Breaking
MAX_HTML_DEPTHis removed fromglpi_python_client.content.conversion. It was added earlier in this same unreleased cycle and appears in no published release, so no released API changes."content"is no longer a field on the six read models. Reading.contentis unchanged, butmodel_dump()now emitscontent_htmlholding HTML where it emittedcontentholding Markdown, and"content" not in GetTicket.model_fields. The validation alias accepts both spellings, so GLPI payloads andGetTicket(content=...)both still populate the field.Verification
All gates green, and CI green on Python 3.10, 3.11, 3.12, 3.13 and 3.14. 1343 tests, 97.45% coverage, mypy strict, ruff,
unasync_build.py --check, zero-warning Sphinx build. Also run locally against CPython 3.12.3 and 3.12.11, since the malformed-construct handling differs between them.The converter was fuzzed over an alphabet carrying every construct all three review rounds raised — including
-- >,</ script>,<![IGNORE[and runs of whitespace and quotes inside a tag, whose absence is why the earlier corpus could not have found the last round's defects. 257000 documents with 0 non-GlpiErrorescapes, 0 prose losses and 0 crashes, on top of the 470000 documents that verified the depth scan against a ground-truthbs4walk before it was removed. 10 MB single-tag, 200k-attribute and 50 MB-prose inputs all handled.End to end, a page containing a 600-deep body returns all its tickets, with only that one degraded. Live integration against GLPI 11, re-run after the parser rewrite: 44 passed, 4 skipped.
Known limitations, not fixed here
model_copy(update={"content": ...})updates nothing, becausecontentis now acached_propertyand the value lands in model extras. Named outright in the module docstring and the changelog, since a caller redacting a body that way gets an object whose.contentstill holds the original. Rewritecontent_html, or rebuild throughmodel_validate.A
cached_propertydoes not invalidate. Assigning tocontent_htmlaftercontenthas been read leaves the cached Markdown in place, and so doesmodel_copy(update={"content_html": ...}). Treat a read model as immutable once validated.A fenced code block loses its language tag.
markdownifydrops theclass="language-python"thatfenced_codeemits, so```pythonround-trips to a bare fence. A limitation of the library pair, not of an extension list.How a malformed body degrades depends on the CPython patch level.
html.parserchanged its reading of an unterminated<script>, a comment with no-->and an end tag carrying a quoted>between 3.12.3 and 3.12.14. The scan tracks the parser rather than a snapshot of it, so the depth decision stays correct on every version — but the exact text a broken construct contributes is the interpreter's, not this package's. Well-formed content is unaffected.httpxcan still raise outside the taxonomy on a real write — a lone surrogate asUnicodeEncodeError, a non-JSONextra_payloadvalue asTypeError— because the package'sTransportRecordernever JSON-encodes, so the suite structurally cannot see it. Tracked separately from this PR.🤖 Generated with Claude Code