feat(content): Markdown <-> memo HTML converter, literal-safe (0.4.0) - #6
Merged
Merged
Conversation
…rter Adds easyvista_python_client.content.EasyvistaContentConverter, with two static methods: from_transport reads an EasyVista memo (rich-text HTML or plain text) as canonical Markdown, and to_transport renders Markdown as HTML. It is a port of glpi_python_client's content/conversion.py at 0d43528, helper for helper: markdownify inbound, python-markdown outbound, the same options and extensions, the RecursionError / ParserRejectedMarkup fallback that degrades a too-deep body to its text instead of raising, the parser-driven scan behind it, and the <br /> rewrite. The module docstring says the two files should move together. Why here: until now this package could only strip HTML to plain text, so a downstream GLPI-to-EasyVista sync wrote its own converter. On 2026-09-30 a GLPI description holding a pasted URL and a titled link reached EasyVista with neither link clickable (tier 4, one instance: EasyVista stored exactly the HTML it was sent). That converter escaped the pivot's < a second time, fused a link title into the href, cut a URL at its first ")" and read two lone asterisks as emphasis. Markdown to ITSM-format conversion belongs in the connector library, and the GLPI library's converter already handled every one of those cases. Each is pinned here as a regression, with the host replaced by example.org. EasyvistaContentError joins the core taxonomy under EasyvistaError, raised for any converter fault other than depth, and is exported at top level so it can be caught without the extra. The one code divergence: the three libraries are an optional extra, easyvista-python-client[content], rather than hard dependencies, so the core stays httpx + pydantic + tenacity. Importing the subpackage without them raises an ImportError naming the pip command, and a fresh-interpreter test pins that `import easyvista_python_client` loads none of them. The three are repeated in dev (CI runs the tests) and docs (autodoc imports the module); a test fails if the copies drift. Re-measured rather than copied, 2026-09-30: the inbound depth cliff is 493 levels on CPython 3.12-3.14 but 328 on 3.10, which spends about three frames per level; and the beautifulsoup4 <br> / <br /> defect no longer reproduces on 4.15.0. The rewrite stays, since the extra accepts 4.12, and a new test checks it is still applied, which no output test can notice on 4.15. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t is not sanitised The round-trip corpus from glpi_python_client's content tests, adapted and extended with the cases the converter port was made for: autolinks, link titles, parenthesised URLs, lone asterisks, accents, a query string, the 2026-09-30 synced description, and what the memo that defect stored reads back as. As there, it is an inventory rather than a property: the eight cases a write-then-read cycle does not reproduce exactly are strict xfails carrying the measured reason, so fixing one fails the suite until the list is updated. The weaker property is the one a two-way sync depends on, and it is new here: whatever one cycle changes, a second changes nothing more. It holds over the same corpus except for two cases, both recorded -- a nested list, which a second cycle flattens, and angle-bracket text, whose doubled space collapses one cycle after the word itself is lost. A memo-side twin reads a memo, writes that Markdown back and reads it again; the four memos whose first write is itself lossy are named, among them a plain-text memo's lone newlines, which become hard breaks once. Four tests pin, on purpose, that the converter is not a sanitiser, as in glpi_python_client: raw HTML and a javascript: target are rendered live, an executable scheme in angle brackets is not made a link, and text a memo displays as markup reads back as raw markup. A caller relaying Markdown it did not write has to neutralise those itself, and these say exactly what it has to cover. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…o format Adds docs/content.rst to the user guide: what a memo holds, what each direction does, what survives a round trip, where the converter comes from, and -- in its own section -- that it is not a sanitiser, with the four things a caller relaying Markdown it did not write has to neutralise. The API reference gains the converter's autodoc entry under a new "Rich-text content" section; installation.rst and the README name the extra. docs/vendor-api-reference.md records the only evidence the converter's premise rests on, as tier 4: on 2026-09-30, on one instance, a ticket's COMMENT memo written through the API with HTML was stored byte for byte and the web UI rendered its paragraphs. No vendor documentation of the memo format is recorded, and the new open item O-MEMOFORMAT lists what is still unmeasured: what the UI's own editor writes, whether the UI treats the newlines between blocks as HTML whitespace, and what it shows for raw markup. The ticket-workflow and ticket-actions skills each gain a gotcha: a memo stores what it is sent, nothing renders Markdown for you, and the optional extra converts both ways. Prose only -- the skills contract admits imports from the package root alone, and the converter deliberately lives in a subpackage the root does not import. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Bumps the version in the four places docs/publishing.rst enumerates -- pyproject.toml, __version__, the literal test_public_api.py asserts, and every skill's metadata.version -- and moves the converter work into a dated 0.4.0 section of CHANGELOG.md, with the compare links updated to bare tags. A minor bump for new public surface, not for a break: the content extra is optional, the core package imports none of it, and the one new root export is EasyvistaContentError. Nothing an existing caller does changes. Prepared locally only. The section is dated 2026-09-30, the day it was prepared; if the release is cut later, move the date with it, since the tag is what defines the version. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A review pass over the prose the previous commits added, holding each factual claim to its evidence tier. - The content page opened by saying a memo "holds the HTML it was sent" before the tier-4 caveat that limits it to one measured sample; it now says a memo holds whatever it was sent, HTML where that was measured. The same sentence in from_transport now carries its tier. - The ticket-workflow skill told the reader to expect HTML back from resolve_memo, and said the UI rendered the memo "as rich text". What was measured is that the UI rendered <p> elements as paragraphs, and a memo can equally hold plain text or unrendered Markdown; both now say so. - from_transport's Raises section and EasyvistaContentError's docstring said the error is raised only for non-depth faults. It is also raised in the one depth case the converter cannot degrade: a caller whose stack is too short even to strip the tags. Both now name it. - Smaller corrections on the page: a plain-text memo comes back stripped, not literally unchanged; the dropped whitespace was measured at the end of a paragraph, not at both edges; the fixed-point sentence now says what it means; and the example says why it reads COMMENT but writes description. - The copied void-element set still equals beautifulsoup4 4.15.0's private table, checked 2026-09-30, and the comment now records it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The reader handed markdownify's output on as Markdown, and markdownify escapes nothing it is not asked to, so text a memo displayed changed meaning as soon as anything rendered it: \\serveur lost a backslash, __init__ became bold, "* point" and "> merci" lines made a list and a quote, #4521 at a line start made a heading, a | in a cell split the row, and a memo showing <script> read back as a live <script>. glpi_python_client fixed that in 588e97d and 7ea833a; this is the same converter, brought over by a three-way merge of 0d43528 -> 4fc3bed onto this port, so the EasyVista names, messages, memo notes and the optional extra's import guard are kept -- the guard now wraps every one of the three libraries' imports the new code needs. A character is escaped exactly where python-markdown, with the four extensions to_transport uses, would read it as syntax, and nowhere else: ordinary prose comes back byte for byte. Nested lists nest at four spaces and keep their numbers, script, style and title bodies are dropped, the obsolete elements make a memo HTML, and every pass is linear. Parity, measured: the two conversion.py files have the same 86 top-level definitions and the same ASTs once docstrings are stripped and the converter, its error, the import guard and the error messages are normalised; and over 3,275 bodies -- the realistic corpus, 3,000 fuzzed bodies and every HTML the tests pin -- both converters give identical Markdown and HTML in each library's venv (CPython 3.12 with bs4 4.14.3, markdown 3.10.2 and markdownify 1.2.2; CPython 3.10 with 4.15.0, 3.10.3 and 1.2.3), and the two venvs agree with each other. Tests: test_literal_text.py is glpi_python_client's, ported whole -- the property that what a memo displays the Markdown displays, compared with an HTML parser, and that the Markdown is a fixed point, over the regressions, realistic bodies and a seeded fuzzer, plus the inventory of what Markdown cannot carry. test_conversion.py follows the converter: the defect memo now reads back as the "<" text it displayed and writes back as the very paragraph EasyVista stores; displayed markup comes back as text; the nested-list and defect-readback round trips and the nested-list and defect-memo read-backs are no longer losses; the degraded path is compared with what the converting path displays; and the script/style parity case says the converting path drops those bodies while the degraded one keeps them. The deepest stack-can-hold case is 250 rather than 300: CPython 3.10's cliff is 328 levels from a shallow stack, and 300 left 14 levels of margin under pytest. The content extra, and the dev and docs extras that copy it, need markdownify>=1.2: the converter subclasses MarkdownConverter and relies on the 1.x hooks, which 0.13 does not have. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…EMOFORMAT docs/content.rst describes the reader as it is now: text in a memo is literal and the Markdown spells it so, with the example checked against the code; ordinary prose carries no escape; nested lists nest; a memo holding one real HTML element is read as HTML throughout. "It is not a sanitiser" keeps the three outbound cases and states the one inbound change -- text a memo displays as markup reads back as text, where it used to come back live. "What survives a round trip" drops the nested list from the losses, adds the memo-side property and the inventory of what Markdown cannot carry, and the port reference moves to 4fc3bed. The 0.4.0 changelog entry, which has not shipped, is corrected to match: markdownify>=1.2 and why, the port commit, what the reader escapes, and six lossy round-trip shapes rather than eight. The two skills that document the content extra say not to strip the backslashes and not to mix Markdown and HTML in one memo. models/request.py no longer calls the memo format "unverified (O4)": it is whatever its writer sent, measured on one instance, and what is still unknown is open item O-MEMOFORMAT in docs/vendor-api-reference.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CPython 3.10 reaches end of life in October 2026 (PEP 619), and glpi_python_client, whose converter the content extra ports, dropped it in 524304a. requires-python is now >=3.11, the 3.10 classifier is gone, and the CI and release matrices run 3.11-3.14. typing-extensions and tomli, needed only on 3.10, go with it, and ruff and mypy target 3.11. datetime.UTC replaces timezone.utc (the same object); the timestamp parser accepts and refuses exactly the values it did. On 3.10, pip keeps resolving 0.3.0, which has no content extra; README and the installation page say so. Three release.yml comments were wrong before this change and are corrected: the release job gates no coverage (ci.yml's coverage job does), every tag is unprefixed, and "same coverage as 3.10" is history. The two model test files carry ruff 0.16.0's formatting, so the pre-commit hook does not rewrite them at commit time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rk-gfm The content extra followed glpi_python_client's python-markdown design (4fc3bed). glpi_python_client replaced that design in 917f030: markdownify plus mdformat inbound, cmark-gfm outbound. This package now carries that converter, plus 15 fixes measured on this package's synthetic corpus and on a 367-memo preproduction sample: alt-text escapes, ~~~ and ! before a link, a ^ alt, a second <, <br> in inline code, tables in headings and links, <center>, raw <u>/<mark>/<ins>, linear list numbering and edge splitting, unreadable colspan/start, and the CVE-2025-6069 tail. The extra now installs beautifulsoup4>=4.15, cmarkgfm>=2025.10.22, markdown-it-py>=3.0,<4, markdownify>=1.2.3,<1.3, mdformat>=0.7.22,<0.8 and mdformat-tables>=1.0,<1.1; markdown is gone. Tests: GLPI's display oracle, round-trip and contract tests are ported, plus: - one regression test per fix (GLPI 917f030 fails 36 of 54); - seeded property families, including underline/highlight and blocks in flattened cells (GLPI fails 39-100% of each); - growth tests asserting time(4n)/time(n) < 8: a linear pass reads 2.4-6.8, a reverted fix 11-37; - the "not a sanitiser" contract, pinned exactly; - the 107 HTML bodies of this package's earlier regression tests, held to display plus fixed point. The pre-commit mypy hook installs the extra's four typed packages with pyproject's bounds, and a test keeps them in step; cmarkgfm and mdformat-tables add no types. Nothing checked that requires-python, the classifiers and the two CI matrices agree, so dropping 3.10 had to chase four lists by hand: test_the_supported_pythons_agree_everywhere_they_are_written now binds them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uded docs/content.rst describes the CommonMark-with-GFM-tables dialect, what the reader escapes, and the dependency bounds. Two bounds were measured against the next major; two are precautionary caps on releases that do not exist yet. The round trip is stated as an aim with its measurement: on 367 memos read from one preproduction instance on 2026-10-01 and measured 2026-10-02 (tier 4, may not generalise), no word changed and all were fixed points; 65 displayed differently in listed, word-keeping ways. A "Known holes" list names the synthetic shapes that do lose words or display: ragged rows, a | in a cell's code or link, caption/colgroup without tbody, an image title holding ", a list or <hr> in a cell, and more. Also stated plainly: - plain_text_is_markdown=True reads Markdown that opens with < and holds HTML as HTML; - the converter is not a sanitiser; - a caller near the recursion limit can still get a bare RecursionError. Tier 1: the vendor's comment-log page (read 2026-10-02) sets a custom comment field to "Text area". That leans towards HTML display, against the converter's literal-lines reading of a tag-less memo, but says nothing of the built-in memos. O-MEMOFORMAT records it. The CHANGELOG's 0.4.0 entry matches, and says the 15 fixes are still to be proposed to glpi_python_client. README and the installation page list the extra's six packages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6 +/- ##
==========================================
+ Coverage 98.86% 98.92% +0.05%
==========================================
Files 37 40 +3
Lines 2116 2595 +479
==========================================
+ Hits 2092 2567 +475
- Misses 24 28 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…emove set_status; add reassign_action (#7) * feat(workflow): name what a write may do to a ticket's workflow Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workflow): refuse an encoded slash or a backslash in a path The classifier split a path on '/' before percent-decoding, so requests%2FI1%2Fclose was one opaque segment and named nothing, yet a server may read it as a separator. Refuse it, and a backslash, outright, as dot segments already are. Fails closed: whether the server decodes %2F or treats a backslash as a separator is not measured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workflow): judge a request by every method it names, and cite the vendor A method-override header that said GET replaced the real method, so a POST to requests/{rfc}/close with X-HTTP-Method-Override: GET was classified as a read and named nothing; with two override headers the first won. A request is now a read only when the real method and every override value are reads, and a DELETE among them applies the DELETE rule. A tuple body is scanned like a list, as httpx serialises it as a JSON array. Cite the update-page exclusions and the requalification passage with URL, tier and read date, and state every refusal and the override rule in the public workflow_triggers docstring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(transport)!: refuse a workflow write unless the call allows it A request naming a workflow effect is refused before any I/O unless its spec allows it; an allowed one is sent once, never retried. set_status, close_ticket and end_action opt in to the effect they have always had, so their behaviour is unchanged for now; the interim opt-ins are replaced by the caller's own in the tasks that rework those methods. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(client)!: take allow_workflow_effect on every writer; refuse a non-numeric action id send, update_ticket, update_action, create_action and create_task take a keyword-only allow_workflow_effect (a WorkflowEffect or an iterable of them, default none) and widen the request spec with it, so the transport's guard refuses a write that may change a ticket's workflow unless the call named the effect. The typed writers send nothing different without it: the fields their models declare need no opt-in. update_action refuses anything but a positive integer action id. PUT actions/{rfc_number} is the vendor's end-action route on the same path template as PUT actions/{action_id}, so an RFC number passed where an action id belongs would not edit one action; with an end_action body it would end every open action on the ticket. Action.action_id is legitimately None across the package, so None is refused rather than addressing actions/None. build_get_action gains an optional fields projection, as the list builder has. BREAKING: update_action raises ValueError for an RFC number, a bool, a float, a blank string or a non-positive id, where it used to send the request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(client): pin the allowed path of every writer; qualify the end-action claim update_action, create_action and create_task had only refusal tests, which pass whether or not allow_workflow_effect reaches the request spec: a writer that accepted the keyword and dropped .allowing(...) would have kept the suite green. Each now has a positive test that the allowed call sends exactly once, to the right route, and that the body on the wire carries the gated key. The update_ticket allowed-path test asserts the body the same way. Checked by mutation: with .allowing(...) removed from those three writers, the three new tests fail with EasyvistaWorkflowEffectRefused; restored, they pass. _require_action_id's docstring (and the test that mirrors it) said an RFC number with an end_action body ends every open action on the ticket. That holds only when the body names no action_id, as build_end_action's own docstring says; qualify it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(client)!: remove set_status; close_ticket requires allow_workflow_effect set_status was the vendor CLOSE request under a name that hid it: per the vendor close page it interrupts the workflow and closes the open actions whatever status it names. There is no status setter on this API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(client): cite the workflow page for the status clause; fix two stale counts RequestUpdate's status_id bullet now cites docs.easyvista.com/docs/workflow.md for "a ticket's status follows its workflow", beside the close page. build_close_ticket says delete_actions deletes the unfinished actions rather than ending them, as close_ticket already does. The live-suite docstring drops one ticket_factory ticket (21 -> 20 tickets, 18 -> 17) now that the set_status test is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(live): pin WORKFLOW_ID on an action's item read, which end_action's guard needs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(live): scan the newest tickets for the WORKFLOW_ID probe The default ticket order is oldest-first, and on the measured instance (2026-10-02) the oldest tickets carry only an already-ended CALL action and no workflow step, so both live tests skipped. Sort REQUEST_ID DESC so the scan reads recent tickets, which carry both kinds of action. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(actions)!: end_action refuses to end a workflow step unless ADVANCES is allowed Ending a workflow step's action advances the ticket's workflow, so end_action now makes one projected read (ACTION_ID, WORKFLOW_ID) first and raises EasyvistaWorkflowEffectRefused, with no end request sent, when the action is a workflow step or the record names no WORKFLOW_ID at all. end_all=True is refused outright. allow_workflow_effect= WorkflowEffect.ADVANCES opts in and skips the read. Ending the caller's own action needs no opt-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(actions): end_action validates its action id and checks the read named it A blank or whitespace action_id got past the workflow-step guard: nothing checked it, so the guard's read addressed the collection (GET actions/), took the first row's empty WORKFLOW_ID and let the end through. A probe that answered a different ACTION_ID was accepted the same way. build_end_action now requires a positive integer (the rule update_action already applies) and sends it as an integer, so a blank or an RFC number is refused before any request. The guard reads the integer id and refuses when the record names a different ACTION_ID; an absent ACTION_ID stays allowed. Also: pin the step-specific refusal and its triggers, pin that UNKNOWN does not skip the guard, and reword EasyvistaWorkflowEffectRefused so it no longer says "no request made" of the end_action path, where one read precedes the refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(live): census what reassigning a workflow step does Adds the live census behind a typed reassign_action: what PUT actions/{id} with a group or a person does to a ticket's open workflow step (does the column store, does the step stay open, does the ticket's status and end date stay, how many action rows appear), plus the title write the sync makes on every sweep. The census WRITES and is not run by this commit; it runs only with the user's explicit approval. The live_reassign_config fixture is module-local so the file also runs from a checkout whose conftest.py lacks it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(live): make the workflow census gated, safe and conclusive Review fixes to the reassignment census, which WRITES to a live instance and is not run by this commit. - Opt-in: all three census tests take a session-scoped census_opt_in fixture first and skip unless EASYVISTA_TEST_RUN_WORKFLOW_CENSUS=1, so nothing is created without the user's approval. - Conclusive: before and after both go through one projected item read; the column must be named, differ from the target, and (for the group) be non-empty before the write. The step's REQUEST_ID must equal the fresh ticket's, so a PUT can never reach another ticket's action. - Time: the after-state is read twice (immediately and after 5 s); the verdict is taken on the second read and both are printed. - Key spelling: if the lower-case key did not store, the upper-case key is sent to the same step and both outcomes are recorded. - Failure output: every comparison is bound to a boolean and asserted with a label-only message, so a failure never renders an Action or Request repr. A raised write prints only the exception type and status code, still takes the after reads, then re-raises. - Misconfigured targets and a step with no action id stop before any ticket or write; the title test now prints a CENSUS line and asserts the ticket's END_DATE_UT unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(live): fail a raised census write with a label, not the exception A write that raises used to be re-raised, so pytest printed the transport's message, which carries server prose this suite keeps out of test output. Now the type name and status code are printed, the after-state reads are still taken and printed, and the test fails with a label-only message; the original exception is not chained. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(vendor-api-reference): the ticket workflow model, and close O-CLOSE-DEFAULT at tier 1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(workflow): re-attribute the status sentence, document workflowstart, name the guard's sets exactly The sentence 'Advancing through the steps of a workflow changes the status of a ticket.' is on references-tables.md (Statuses), not workflow.md; every citation now says so. The vendor's PUT requests/{rfc}/workflowstart is added to the table and to workflow.py's count of workflow-touching writes. The 'Named' list is now a table copied from workflow.py's sets, route by route. The 'everything else' row is narrowed to what each vendor page shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(skills): there is no status setter; the workflow guard; check required keywords The skills still told agents to use set_status for a status and showed close_ticket without the now-required allow_workflow_effect, and agents run a skill's snippet verbatim. Rewrite them to match the guard: no status write exists, close_ticket is the vendor close request and interrupts the workflow, end_action reads the action first and refuses a workflow step unless ADVANCES is allowed, and a refused write is a ValueError raised before sending and never retried. The contract test now also fails a snippet that omits a required keyword-only parameter of a client method (a **kwargs splat is exempt). Seen failing on the close_ticket snippet before the skills were fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(skills): attribute the close-request claims to their evidence; self-test the keyword check Review fixes for the skills commit. - The synchroniser incident is now attributed: root cause established 2026-10-01/02 from the synchroniser's code plus the vendor close page (tier 1); the drain across a close-envelope status write measured 2026-09-01 on one instance, one ticket (tier 4, may not generalise). - "Ends the open actions ... whatever status" no longer reads as settled: ending is this package's reading of the page, which documents final statuses only. Same softening in the instance-discovery skill. - Procedure 5 says ending a workflow step needs ADVANCES; the end_action paragraph names the single read first, that a failing read propagates (a 403 there is not a permission verdict), and the ACTION_ID mismatch refusal. - The "drains the open set" claim carries its basis; the vendor update pages are cited by URL. - The required-keyword check is a helper with its own parametrised test (missing keyword, **kwargs splat, compliant call), so it cannot go inert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: changing a ticket's status, the guard in the guide and README, create starts the workflow Add a user-guide section on how a ticket's status follows its workflow (no setter; end_action advances, close_ticket interrupts, suspend/reopen are unwrapped), the guard, the end_action pre-read and the retry rule. Thread allow_workflow_effect through every close_ticket example, record the vendor default for an omitted status_GUID at tier 1, and stop describing end_date_ut as stamped on closure. Give create_ticket a docstring that says it starts the workflow, mark PostRequest.workflow_start as a measured no-op, and note the refusal in to_api and the unknown-key error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: restore the unmeasured-WORKFLOW_ID hedge; say who must pass allow_workflow_effect Ending your own action needs no opt-in, except that whether an action created under the workflow step by create_action carries a WORKFLOW_ID is unmeasured: if it does, end_action refuses it without ADVANCES. State that in the guide and the README. Say allow_workflow_effect is required on close_ticket and optional (default: nothing allowed) on the other writers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): 0.4.0 is breaking -- the workflow guard Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): scope the workflow-guard claims to their evidence Close-page effects credited as documented (deleting; ending is the package's reading), the synchroniser claim scoped to its evidence, the status-setter wording aligned with the vendor reference, the extra_headers gap and the send()-only scope stated, the single-attempt change marked BREAKING, the Retracted line quoted verbatim, and the skill list corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(actions): add reassign_action, backed by a live census PUT actions/{id} with a group (and/or a person) reassigns an action without ending it. The vendor documents no such route (tier 1), so the docstring carries the census: measured 2026-10-02 on one instance, two tickets -- the group stored, the step stayed open, the ticket's status did not move, no new action rows appeared, and the ticket's own owning group did not follow. The person write is unmeasured and says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: record the reassign_action census, and say how to escalate a step Closes the 'not yet measured' sentence in the vendor-API reference with the 2026-10-02 census (tier 4, one instance, two tickets), adds a 'Reassign an action' section to the ticket-actions skill, a sentence to the user guide's status section, and the 0.4.0 changelog entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: scope the reassign_action census to the tickets that carry it The first ticket alone showed the type-20 step, its unchanged WORKFLOW_ID, the empty DONE_BY_ID and that no ticket field changed; both tickets showed the stored group, the step open, status and END_DATE_UT unmoved, the open set unchanged, no new action rows, and re-reads at once and 5 s later. The owning group was read on one ticket only, and now every text says so. Also: say where the group and person ids come from (GET groups answered 403 on the measured instance), list reassign_action in the ticket-actions skill's description and method count, and use the YOUR_* placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workflow): name the end-action route, and classify configured override headers Two fail-closed gaps the final branch review found. workflow_triggers named a write to actions/<x> only by its body, but PUT actions/{rfc_number} is the vendor's end-action route: update_action refuses an RFC id client-side, yet send("PUT", "actions/<rfc>", json={"description": ...}) went out unclassified. A write to an actions/<x> path whose segment is not ASCII digits now names ("actions/{rfc}", ADVANCES) beside any column rule. end_action's own path already allows ADVANCES after its pre-flight. BaseTransport.gate classified spec.headers only, while headers() merges config.extra_headers onto every request, so a method-override header set in the configuration could hide a write behind a GET. It now reads {**config.extra_headers, **spec.headers}: the headers as they go on the wire, the spec winning. The gate docstring also names the encoded-slash and backslash refusals it was missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: make the 0.4.0 breaking-change list true, and hedge the absence claims Final-review fixes for the workflow guard branch. CHANGELOG: the intro says every break is marked BREAKING in Changed or Removed. Two were not: the spec-builder break (a build_close_ticket / build_end_action spec needs .allowing(...); build_end_action and build_update_action refuse a non-positive-integer id) and the method-override-header classification. Both are now BREAKING bullets in Changed, with an Upgrading line for the first. The named-routes bullet and the vendor reference's table add the end-action route, say the column rules apply to requests/ and actions/ routes only, and drop the claim that config.extra_headers is unclassified (it is now read). vendor-api-reference: workflow_start was still "unverified until tested"; it records the dated tier-4 measurement (2026-09-01, one instance, two tickets byte-identical) beside the tier-1 fact that the create page documents no such parameter. Flat absence claims ("there is no status setter", "there is none", "no status write") in the README, user guide, update_ticket and close_ticket docstrings, RequestUpdate and the ticket-workflow skill now say the vendor documents none and this package has none. integration_tests: test_live_workflow_guard.py's two read-only tests used bare asserts on Action attributes, which print the model repr (href host) on failure; they go through _require. conftest's write count now mentions the opt-in workflow census. test_live_smoke's stale "also used by" sentence is reworded. Wording: a paraphrase loses its quotation marks; directory.py dates the end_date_ut-at-resolution claim; reassign_action says "closest API equivalent" and drops the unverified French label; "short list" is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: use a synthetic RFC where a real instance ticket number was quoted Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
This package could only strip memo HTML down to plain text. A downstream GLPI ↔ EasyVista sync therefore wrote its own Markdown converter. On 2026-09-30 a description it synced arrived with neither of its links clickable (tier 4, measured on one instance). Conversion belongs in the connector library, so this branch adds one.
What
easyvista_python_client.content.EasyvistaContentConverter, a port of glpi_python_client's Markdown ↔ HTML converter in its literal-safe version (fix(content)!: spell literal text so python-markdown reads it as text (0.6.0) glpi_python_client#39).pip install "easyvista-python-client[content]", which bringsbeautifulsoup4>=4.12,markdown>=3.6andmarkdownify>=1.2.import easyvista_python_clientdoes not load these packages. Importing the subpackage without them raises anImportErrorthat names the extra.EasyvistaContentError(EasyvistaError), in the core package.to_transportuses, would read it as syntax. These read back as themselves:__init__and\\serveur;#4521at the start of a line;|inside a table cell;<script>.<script>,<style>and<title>bodies are dropped.javascript:targets in the caller's Markdown go out live, as in the GLPI library. Callers neutralise them.docs/content.rst, the API reference, two skills) and a CHANGELOG entry.Parity with glpi_python_client
Verification
pytest -m "not integration" --strict-markers --strict-configwith coverage: 1934 passed, 23 xfailed, 98.8 % (the gate is 95 %).ruff,mypy,unasync_build.py --check,scripts/lint_hand_written_sync.py --checkandsphinx -E -Wpass. Sphinx was built offline, with intersphinx disabled locally. The wheel and sdist were checked for test files.Still to do before this leaves draft
These are the same items as on the GLPI PR, landing on both branches together so the converters stay equivalent:
TicketContext.to_markdown;Also: 0.4.0 is declared by commits that hold two different readers, the earlier port and this literal-safe one. Releasing only this branch's head as 0.4.0 keeps the number unambiguous.
🤖 Generated with Claude Code