Skip to content

0.5.0 — degrade deeply nested HTML instead of raising, convert content on access - #37

Merged
baraline merged 14 commits into
mainfrom
fix/html-depth-recursion-and-lazy-content
Sep 9, 2026
Merged

0.5.0 — degrade deeply nested HTML instead of raising, convert content on access#37
baraline merged 14 commits into
mainfrom
fix/html-depth-recursion-and-lazy-content

Conversation

@baraline

@baraline baraline commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Deeply nested HTML made markdownify raise RecursionError from inside model_validate, so a get_ticket call 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

  • RecursionError no longer escapes the package. Both conversion directions wrap parser faults in a new GlpiContentError (a GlpiError leaf, __cause__ preserved), so except GlpiError catches them.

  • Deep nesting degrades instead of raising, and the ceiling is discovered rather than guessed. from_transport attempts the conversion and answers the RecursionError by 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 = 200 bound 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 reproducing html.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 very RecursionError the 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=/>' * 494 cleared 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.parser subclass, which cannot be wrong about html.parser. Not a new dependency or a new risk: markdownify builds its tree with bs4 and bs4 builds 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_html and expose content / description as a cached_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. bs4 records an auto-closed <br> by name in already_closed_empty_element, so a later <br /> closes against the stale entry and stays open, and convert_br discards 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.

  • GlpiContentError now survives the write path. Outbound conversion runs in a PlainSerializer, and pydantic-core catches whatever 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. The fault is stashed and restored around model_dump with its cause intact; a serialisation failure that is not content becomes GlpiValidationError rather than being mislabelled.

  • GlpiModel no longer swallows aliased payload keys, or lets a field shadow itself. _capture_unknown_fields ran before pydantic resolved aliases and diverted the wire's content into extra_payload, leaving the body None with 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 that model_dump emitted over the real field, so the attribute reported one body and the object's own dump reported the other.

  • A document html.parser refuses now degrades to text instead of raising. <![FOO[ makes _markupbase raise AssertionError on the interpreters where the keyword is unknown — 3.10 through 3.12.11 as measured, no longer 3.12.14 — and bs4 re-raises it as ParserRejectedMarkup. Where that happens the caller used to get an error and none of their words, and now gets their text.

  • sys.setrecursionlimit and 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_env exports GLPI_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; GlpiContentError and the content helpers in the API reference. Version bumped to 0.5.0 across pyproject.toml, __version__ and the nine skill stamps.

Breaking

MAX_HTML_DEPTH is removed from glpi_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 .content is unchanged, but model_dump() now emits content_html holding HTML where it emitted content holding Markdown, and "content" not in GetTicket.model_fields. The validation alias accepts both spellings, so GLPI payloads and GetTicket(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-GlpiError escapes, 0 prose losses and 0 crashes, on top of the 470000 documents that verified the depth scan against a ground-truth bs4 walk 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, because content is now a cached_property and 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 .content still holds the original. Rewrite content_html, or rebuild through model_validate.

  • A cached_property does not invalidate. Assigning to content_html after content has been read leaves the cached Markdown in place, and so does model_copy(update={"content_html": ...}). Treat a read model as immutable once validated.

  • A fenced code block loses its language tag. markdownify drops the class="language-python" that fenced_code emits, so ```python round-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.parser changed 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.

  • httpx can still raise outside the taxonomy on a real write — a lone surrogate as UnicodeEncodeError, a non-JSON extra_payload value as TypeError — because the package's TransportRecorder never JSON-encodes, so the suite structurally cannot see it. Tracked separately from this PR.

🤖 Generated with Claude Code

baraline and others added 4 commits September 8, 2026 09:21
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>
@baraline

baraline commented Sep 8, 2026

Copy link
Copy Markdown
Owner Author

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. "<div>" * 300 + "<p title=don't>Le serveur ne repond plus.</p>" came back as "<p title=don't>Le serveur ne repond plus." — raw markup in the one output whose whole promise is text, from a body an apostrophe is enough to produce.

The cause: I had taken the attribute rules from the HTML5 spec rather than from CPython's tolerant parser, which is what markdownify actually builds its tree with. Measured against html.parser's own event stream:

  • A quote opens a value only as the first character after the =, so <p title=don't> carries the value don't, and an unquoted value may contain <.
  • tagfind_tolerant runs a tag name to whitespace, / or > — so <style=> is an element named style= and never enters raw-text mode, and a self-closed <script/> does not either. Reading either as raw text swallowed the rest of the document: "<style=>" + "<div>" * 600 measured 1 level against a real 601, reached markdownify and raised — an unbounded under-count of exactly the kind the ceiling exists to prevent.
  • 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 at end of input is flushed as text by close(), and dropping it lost the tail of the body.

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 markdownify. Parity with markdownify was measured for each new case instead of inferred. Suite is now 1312 tests, coverage 97.50%, conversion.py still at 100%.

One caveat on the review itself: it was cut short by a spend limit. The strip-fidelity dimension reported and its findings are handled; depth-scan, pydantic-surface, error-taxonomy and docs-and-tests never returned, so those four dimensions have had no independent adversarial pass.

baraline and others added 5 commits September 8, 2026 10:30
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>
@baraline

baraline commented Sep 8, 2026

Copy link
Copy Markdown
Owner Author

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 e4eef06 and ea7798e.

The two that mattered

Both 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.

  • 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.
  • 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, and end tags have their own branch. 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 what stops the pattern backtracking catastrophically — my first attempt omitted it and a 400-byte '<div a="' * 50 never finished.

The rest

  • GlpiContentError did not survive the write path. Outbound conversion runs in a PlainSerializer, and pydantic-core re-raises everything as PydanticSerializationError — a ValueError, not a GlpiError, with __cause__ and __context__ both None. 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. Now restored around model_dump with its cause intact; a serialisation failure that is not content becomes GlpiValidationError rather than being mislabelled.
  • One field spelled two ways shadowed itself in model_dump — 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 alias choice, so a dump carrying both round-trips back to the raw body.
  • O(n²) on a body of nothing but <div a=" (32 KB in 6.6 s). No > anywhere means no element anywhere, answered in constant time now. The remaining non-linear shape is documented rather than papered over, because the obvious fix is worse: bounding the attribute repetition makes a tag past the bound fail to match, which is an under-count, unbounded again once such tags nest.
  • Numbers 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–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 via model_validate or client.get_ticket — and 9 on the write path. The void set is copied from bs4.builder, not bs4.builder._htmlparser.
  • A test that claimed a protection it could not give. READ_CONTENT_MODELS said naming the six read models explicitly made a forgotten seventh "a failure here". A hand-written list cannot notice what is missing from it. Two discovery guards now walk the class tree, and both were mutation-tested against the mistake they exist to catch.

Refuted, correctly: the claim that the setrecursionlimit AST guard misses the getattr/sys.__dict__/aliased-import spellings, and one docs claim.

Left as documented behaviour

model_copy(update={"content": ...}) updates nothingcontent is a cached_property, so the value lands in model extras. Someone redacting a body that way gets an object whose .content still holds the original. Named outright in the module docstring and the changelog rather than fixed, since the fix would mean intercepting model_copy.

Verification

Re-fuzzed with end tags carrying attributes and name-less =" in the corpus — what round two's corpus lacked: 15000 documents, 0 depth under-counts against the tree that is actually converted, 0 losing a prose word, plus 3000 deep documents with none reaching markdownify. Gates: 1348 unit tests, coverage 97.53%, mypy strict, ruff, unasync --check, zero-warning docs, and 44 passed / 4 skipped against the live GLPI 11 preprod instance.

baraline and others added 3 commits September 8, 2026 23:40
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-commenter

codecov-commenter commented Sep 8, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 96.99571% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.31%. Comparing base (6815f17) to head (3e906d3).

Files with missing lines Patch % Lines
glpi_python_client/content/conversion.py 94.39% 6 Missing ⚠️
glpi_python_client/models/_base.py 97.95% 1 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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>
@baraline baraline changed the title 0.5.0 — bound HTML nesting depth, convert content on access 0.5.0 — degrade deeply nested HTML instead of raising, convert content on access Sep 9, 2026
@baraline
baraline merged commit 0d43528 into main Sep 9, 2026
8 checks passed
@baraline
baraline deleted the fix/html-depth-recursion-and-lazy-content branch September 9, 2026 07:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants