diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15ca8b3..8fa972a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: matrix: # Keep in step with the `Programming Language :: Python` classifiers in # pyproject.toml and with the same matrix in release.yml. - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -47,13 +47,13 @@ jobs: # including a one-file local run -- measure the whole package and fail. Now # that addopts is clean, THIS job is where the floor is actually asserted; # delete it and fail_under becomes a number nothing reads. `needs: test` - # keeps the failure legible: a broken test reports as a broken test on five - # Python versions, not as an under-coverage number here. + # keeps the failure legible: a broken test reports as a broken test on every + # Python version in the matrix, not as an under-coverage number here. # # One version, not the matrix: coverage of this package does not vary by - # interpreter (there is no version-gated code), so five runs would produce - # five identical percentages, five uploads, and a Codecov report whose - # totals depend on which one landed last. + # interpreter (there is no version-gated code), so one run per matrix entry + # would produce identical percentages, one upload each, and a Codecov + # report whose totals depend on which one landed last. needs: test runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4963eb..685221e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,18 +26,19 @@ jobs: fail-fast: false matrix: # Mirrors ci.yml and the `Programming Language :: Python` classifiers in - # pyproject.toml. requires-python is >=3.10; the upper end is bounded by + # pyproject.toml. requires-python is >=3.11; the upper end is bounded by # what the suite is verified on, not by anything in the code. The # generated-_sync/ gate is the reason a new interpreter is not just # added on faith: it is a byte-equality check whose output comes from # tokenize-rt re-tokenizing the source, so a tokenizer change can fail # it on one version alone. The suite and that gate were both run on - # 3.13.14 and 3.14.6 before either was listed here (600 passed, same - # coverage as 3.10, _sync/ regenerated identical under both). + # 3.13.14 and 3.14.6 before either was listed here (600 passed, the + # same coverage as 3.10 then gave, _sync/ regenerated identical under + # both). # These entries are minor versions, not pinned patches: setup-python # resolves a bare "3.14" to the newest STABLE 3.14.x and never to a # pre-release, which needs allow-prereleases. - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Check out repository @@ -59,8 +60,9 @@ jobs: # than left to skip on absent credentials, so a leaked secret could not # make a release run go live. --strict-markers/--strict-config must be # passed here, not in addopts, where pytest 9 silently ignores them. - # Coverage runs implicitly: --cov lives in pyproject's addopts and - # [tool.coverage.report] fail_under = 95 gates it. + # This job does not gate coverage: pyproject has no addopts, so no + # --cov runs here. In CI the 95% floor is enforced only by ci.yml's + # `coverage` job, and locally by the pre-push hook. - name: Run tests run: python -m pytest -m "not integration" --strict-markers --strict-config @@ -135,10 +137,10 @@ jobs: # easyvista_python_client.__version__ -- and the git tag is a third. PyPI # takes whatever pyproject says, so a tag that disagrees publishes a # release nobody can find by version, and a __version__ that disagrees - # misreports at runtime. Both are unfixable after upload. The repo's only - # existing tag, 0.1.0, is UNPREFIXED; v-prefixing starts at v0.2.0. The - # leading v is therefore stripped before comparing, so both forms - # validate. + # misreports at runtime. Both are unfixable after upload. Every tag so + # far (0.1.0, 0.2.0, 0.3.0) is unprefixed, as CHANGELOG.md says tags + # are. A leading v is still stripped before comparing, as a tolerance, + # so a v-prefixed tag validates too. - name: Validate release tag matches package version if: github.event_name == 'release' shell: bash @@ -149,10 +151,7 @@ jobs: normalized_tag="${release_tag#v}" pyproject_version="$(python - <<'PY' from pathlib import Path - try: - from tomllib import loads as toml_loads - except ModuleNotFoundError: - from tomli import loads as toml_loads + from tomllib import loads as toml_loads pyproject = toml_loads(Path('pyproject.toml').read_text(encoding='utf-8')) print(pyproject['project']['version']) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d4ed1f3..cfdf9a8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,25 @@ repos: rev: v2.3.0 hooks: - id: mypy - additional_dependencies: ["pydantic>=2.8", "httpx", "tenacity"] + # The runtime dependencies, then the TYPED packages of the `content` + # extra. The hook's venv holds only this list, so without those four + # every converter base class (MarkdownConverter, MDRenderer, + # RenderTreeNode, MarkdownIt) resolves to Any and strict mode's + # disallow_subclassing_any fails content/conversion.py even though + # `mypy easyvista_python_client` in CI is clean. The extra's other two, + # cmarkgfm and mdformat-tables, ship no type information, so they are + # Any either way and are left out; cmarkgfm would also make every + # contributor without a wheel for their platform compile it before + # any commit. Each line here must equal the extra's requirement in + # pyproject; testing/test_public_api.py fails if one drifts. + additional_dependencies: + - "pydantic>=2.8" + - "httpx" + - "tenacity" + - "beautifulsoup4>=4.15" + - "markdown-it-py>=3.0,<4" + - "markdownify>=1.2.3,<1.3" + - "mdformat>=0.7.22,<0.8" # Must mirror [tool.mypy] exclude in pyproject.toml. pre-commit passes # filenames explicitly, and mypy ignores its own `exclude` for files named # on the command line -- so this regex is the ONLY thing keeping the hook diff --git a/CHANGELOG.md b/CHANGELOG.md index 74596ca..9dfab7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,456 @@ is the error. Tags carry no `v` prefix. ## [Unreleased] +## [0.4.0] - 2026-10-02 + +Adds Markdown <-> memo HTML conversion as an optional extra, drops Python +3.10, and **stops a ticket's workflow changing by accident -- which is a +breaking release.** `set_status` is gone (it was the vendor close request under +a name that hid it), `close_ticket` requires an explicit opt-in, and every +write the package can tell may change a ticket's workflow is refused before it +is sent unless the call says so. What it cannot tell is not covered (see +*Notes*). + +A `0.4.0` section was first prepared on 2026-09-30 around a different +converter. It was never tagged or uploaded, and this section replaces it; +`### Changed since the 2026-09-30 preparation` says what moved, for anyone who +built against that branch. + +**Upgrading.** **Python 3.10 is no longer supported** (see `### Removed`). On +3.10, pip keeps resolving `0.3.0`, the last release that installs there. The +converter is purely additive: the core package imports none of the extra's +dependencies. Every other breaking change is in the workflow guard, and each is +marked `**BREAKING**` in `### Changed` or `### Removed`. Read `### Upgrading` +below. + +### Added + +- **The workflow guard.** `WorkflowEffect` (`INTERRUPTS`, `ADVANCES`, + `UNKNOWN`) and `EasyvistaWorkflowEffectRefused`, both exported at the package + root, and `easyvista_python_client.workflow` (`workflow_triggers`, + `classify_workflow_effects`), which names what a write may do to a ticket's + workflow. `EasyvistaWorkflowEffectRefused` is a `ValueError`, **not** an + `EasyvistaError`: the refused write is never sent, so there is no status code + and nothing transient, and it carries `effects` and `triggers` (what named + them). A ticket's status follows its workflow -- "Advancing through the steps + of a workflow changes the status of a ticket." (tier 1) -- so a write that + touches workflow state is not a bookkeeping write. +- An `allow_workflow_effect=` keyword, taking one `WorkflowEffect` or an + iterable of them, on `send`, `update_ticket`, `create_action`, `create_task`, + `update_action` and `end_action` (default: allows nothing), and **required** + on `close_ticket`. `RequestSpec.allow_workflow_effect` and + `RequestSpec.allowing()` carry the same opt-in on a request spec. +- `resources.actions.build_get_action(..., fields=...)` projects the item read, + as the list builders already did. +- `reassign_action(action_id, *, group_id=None, done_by_id=None)` on both + clients, with `resources.actions.build_reassign_action`: reassign an action + (for example, escalate the open workflow step to another group) without + ending it. It is not refused by the workflow guard. The vendor documents no + reassignment route (tier 1), so the effect was measured: 2026-10-02, one + instance, two tickets, so it may not generalise -- the group was stored, the + step stayed open, the ticket's status did not move and no new action rows + appeared; the ticket's owning group was read on one of the two tickets and + did not follow the action's. The person write (`done_by_id`) is unmeasured. +- `easyvista_python_client.content.EasyvistaContentConverter`, behind the new + optional extra `easyvista-python-client[content]`. It has two static + methods. `from_transport(value, *, plain_text_is_markdown=False)` reads a + memo, rich-text HTML or plain text, as Markdown. `to_transport(value)` + renders Markdown as the HTML a memo is written with. + + **The Markdown is CommonMark with GFM tables.** Writing renders it with + `cmark-gfm`, with only its `table` extension: a newline is a line break, and + raw HTML passes through. Reading converts HTML with `markdownify`, then + re-renders the result with `mdformat`, which keeps only the escapes + CommonMark needs. + + **Text in a memo is literal, and the Markdown spells it so.** A typed + `__init__` reads as `\_\_init\_\_`, `\\serveur` as `\\\serveur`, `# titre` + at a line start as `\# titre`, and `` as `\`. Ordinary prose + such as `fichier_de_test_v2.xlsx`, `C:\Temp`, `R&D` or `Ticket #4521` comes + back as typed. The aim is that rendering the Markdown displays what the + memo displayed, and that reading that back gives the same Markdown; the + measurements below say how far that held, and `docs/content.rst` lists the + shapes where it does not. + + What else a reader sees: + - A memo with no HTML element is read as literal lines, a hard break per + line. Pass `plain_text_is_markdown=True` for a value that is your own + Markdown: it then passes through, stripped, unless it starts with `<` and + holds a real HTML element anywhere. So Markdown opening with an autolink + and carrying inline HTML further on is read as HTML, and loses the + autolink. + - A memo holding one real HTML element is read as HTML throughout, so + Markdown syntax beside it is kept as literal characters. The obsolete + elements (``, `
`, ...) count as HTML. + - Underline, highlight and strike, which CommonMark cannot spell, are kept + as raw ``, ``, `` and ``. + - A table nested in a table is flattened to its words, every one kept and in + order, and a table without a header row gains an empty one. + - Nested lists nest and keep their numbers. ``` goes out as a live ``", "", id="raw-html" + ), + pytest.param( + "[x](javascript:alert(1))", + '

x

', + id="link-target", + ), + pytest.param( # python-markdown, before 0.4.0, left this as raw markup + "", + '

javascript:alert(1)

', + id="autolink", + ), + ], +) +def test_the_write_path_renders_what_it_is_given_live(markdown: str, html: str) -> None: + assert render(markdown) == html + + +def test_the_read_path_keeps_a_memos_javascript_link() -> None: + assert read('

x

') == ( + "[x]()" + ) + + +def test_markup_a_memo_displays_as_text_stays_text_both_ways() -> None: + markdown = read("

<script>

") + + assert markdown == "\\
code
", + "
  • code
", + "
    • code
    ", + '
    • i
      code
    • ' + "
    ", + ], + "struck": [ + "

    ancien nouveau

    ", + "

    ancien nouveau

    ", + "

    ancien nouveau

    ", + ], + "hidden": [ + "

    Bonjour

    ", + "RE: Imprimante

    Bonjour

    " + "", + ], + "misreading": [ + "

    # pas un titre

    ", + "

    #4521 est un doublon

    ", + "

    2026. Une annee

    ", + ], + "upper-case": [ + "

    Bonjour

    ", + "
    Bonjour", + "
    Bonjour
    ", + ], +} + + +@pytest.mark.parametrize( + "html", + [ + pytest.param(html, id=f"{group}-{index}") + for group, bodies in EARLIER_REGRESSIONS.items() + for index, html in enumerate(bodies) + ], +) +def test_an_earlier_regression_body_survives(html: str) -> None: + assert_survives(html) + + +@pytest.mark.parametrize( + ("html", "expected"), + [ + pytest.param( + "

    Voir fichier_de_test_v2.xlsx et mon_fichier_final.docx

    ", + "Voir fichier_de_test_v2.xlsx et mon_fichier_final.docx", + id="file-names", + ), + pytest.param( + r"

    Chemin C:\Temp\logs et C:\Users\Admin\Documents

    ", + r"Chemin C:\Temp\logs et C:\Users\Admin\Documents", + id="windows-paths", + ), + pytest.param( + "

    Le ticket # 3 et le #4521 sont liés, C# aussi

    ", + "Le ticket # 3 et le #4521 sont liés, C# aussi", + id="hashes", + ), + pytest.param( + "

    Service R&D, bâtiment A & B

    ", + "Service R&D, bâtiment A & B", + id="ampersands", + ), + pytest.param( + "

    [INFO] tâche [1] terminée (voir note)

    ", + "[INFO] tâche [1] terminée (voir note)", + id="brackets", + ), + pytest.param( + "

    Voir https://example.org/doc?a=1&b=2 ou support@example.org

    ", + "Voir https://example.org/doc?a=1&b=2 ou support@example.org", + id="bare-url-and-address", + ), + pytest.param( + "

    Fichier mon_fichier_final.docx et __init__

    ", + r"Fichier mon_fichier_final.docx et \_\_init\_\_", + id="dunder", + ), + ], +) +def test_ordinary_prose_reads_back_as_typed(html: str, expected: str) -> None: + """Prose carries no escape, and literal syntax is escaped only to stay text.""" + + assert assert_survives(html) == expected + + +def test_a_table_nested_in_a_cell_keeps_its_text() -> None: + """A table inside a table keeps every word. + + E-mail signatures and notification templates are often laid out so. + """ + + html = ( + "
    Signature
    " + "
    Jean DupontService IT
    " + ) + + markdown = read(html) + + assert "Jean Dupont" in render(markdown) + assert "Service IT" in render(markdown) + + +@pytest.mark.parametrize( + "html", + [ + pytest.param( + "

    Bonjour

    ", id="style" + ), + pytest.param("

    Bonjour

    ", id="script"), + pytest.param( + "RE: Imprimante" + "

    Bonjour

    ", + id="title", + ), + ], +) +def test_what_a_browser_does_not_display_is_dropped(html: str) -> None: + assert read(html) == "Bonjour" + + +def test_a_fence_keeps_its_language() -> None: + markdown = "```powershell\nGet-Service\n```" + + assert read(render(markdown)) == markdown + + +# --------------------------------------------------------------------------- +# A notification template: a layout table around a table of label/value cells +# --------------------------------------------------------------------------- +# +# New here. E-mail templates are commonly laid out as an outer one-cell +# table around an inner one-row table: each label in bold over its value, +# spacer cells between them, and here one label in a cell of its own beside +# its value's cell. That shape exercises the flattening of a table nested in +# a cell, fix 9 of test_fixes.py (bold that ends on punctuation at a +# flattened cell's edge still closes -- GLPI 917f030 lost the fixed point +# there, measured 2026-10-02) and
    in a cell, all at once. Every word +# below is invented. + +_LABELS = ["Zorvane", "Plimet", "Quastor", "Brindel"] +_VALUES = ["trelm 4471", "oskar-vint", "maludi pref", "kobra 12/09"] + + +def _notification_template() -> str: + cells = [] + for label, value in zip(_LABELS, _VALUES, strict=True): + cells.append("") # a spacer cell + cells.append( + f"
    {label}


    {value}

    " + ) + cells.append("Velusk :sanquo") + inner = "" + "".join(cells) + "
    " + footer = ( + 'Grovak le suivi ' + 'Trelune' + ) + return f"
    {inner}
    {footer}
    " + + +def test_a_notification_template_keeps_every_word_in_order() -> None: + """Every word, in order, each label still bold and the link still a link. + + The layout does not survive: GFM has no nested table, so the inner row + becomes one line of text in the outer cell, and a GFM table always has a + header row, so the outer table gains an empty one. Both are limits of + the format. Apart from that header row, the display is the same. + """ + + html = _notification_template() + + markdown = read(html) + rendered = render(markdown) + + assert text_words(rendered) == text_words(html) + name, rows = cast(tuple[str, tuple[object, ...]], displayed(html)[0]) + empty_header = ((True, ()),) # the header row GFM requires: one empty cell + assert displayed(rendered) == ((name, (empty_header, *rows)),) + for label in [*_LABELS, "Velusk :"]: + assert f"**{label}**" in markdown + assert "](https://example.org/suivi?ref=PX-7)" in markdown + assert "![Trelune](https://example.org/marque.png)" in markdown + assert "ev-cell" not in markdown and "data-ev-" not in markdown + assert read(rendered) == markdown + + +# --------------------------------------------------------------------------- +# Plain text and the write path +# --------------------------------------------------------------------------- +# +# A memo with no HTML element in it is read as literal lines, a line break +# per line. How EasyVista's own UI displays such a memo is UNVERIFIED -- the +# vendor's form editor documents a MEMO object (text) beside a TEXT AREA +# object that accepts HTML [doc:https://docs.easyvista.com/docs/form], and +# its comment-log page configures a custom field gathering a request's +# comments as a "Text area" +# [doc:https://docs.easyvista.com/docs/service-manager-comment-log-creation, +# read 2026-10-02]. That leans towards the UI showing memo text as HTML, +# not as the literal lines read here, but neither page says which object +# the built-in description or the action history is. These tests pin what +# the converter does, not what EasyVista shows. + + +@pytest.mark.parametrize( + "text", + [ + "__init__ et _x_", + "# pas un titre", + "* point un\n* point deux", + r"\\serveur\partage", + "if x0", + "Bonjour,\n\nMerci.", + pytest.param("Velk ondra\r\nPrastin", id="crlf"), + ], +) +def test_a_plain_text_body_reads_as_the_text_it_is(text: str) -> None: + """A body with no HTML element is literal text, its lines lines. + + The ``crlf`` case is new: a CR LF separates lines as LF does. An HTML + reading of the same characters would show a space instead. + """ + + markdown = read(text) + shown = displayed(render(markdown)) + lines = text.replace("<", "<").splitlines() + expected = displayed("

    " + "
    ".join(lines) + "

    ") + + assert shown == expected + assert read(render(markdown)) == markdown + + +def test_an_entity_in_a_plain_text_body_is_read_literally() -> None: + """New here: with no element, `` `` is six characters of text, not a space. + + Pinned because it is the consequence of reading a tag-less memo as text, + and the one a reviewer would most likely expect the other way; which + reading matches EasyVista's UI is unverified (see the section comment). + """ + + markdown = read("Trasvel ok") + + assert displayed(render(markdown)) == displayed("

    Trasvel&nbsp;ok

    ") + + +@pytest.mark.parametrize( + "markdown", + [ + "Run **passwd**, then check `logs`.", + "| a | b
    c |\n| --- | --- |", + "Press Ctrl + C.", + "line one\nline two", + ], +) +def test_the_write_path_keeps_caller_markdown_verbatim(markdown: str) -> None: + """``plain_text_is_markdown=True`` never rewrites the caller's Markdown.""" + + assert read(markdown, plain_text_is_markdown=True) == markdown + + +def test_the_write_path_still_converts_an_html_document() -> None: + assert read("

    A bold move

    ", plain_text_is_markdown=True) == ( + "A **bold** move" + ) + + +@pytest.mark.parametrize( + ("markdown", "expected"), + [ + pytest.param( + " puis
    suite **gras**", + "puis\\\nsuite \\*\\*gras\\*\\*", + id="autolink-first", + ), + pytest.param( + " puis Ctrl **gras**", + "puis `Ctrl` \\*\\*gras\\*\\*", + id="angle-text-first", + ), + ], +) +def test_markdown_opening_with_angle_brackets_and_holding_html_is_read_as_html( + markdown: str, expected: str +) -> None: + """A limit, pinned so that the documentation stays true. + + ``plain_text_is_markdown=True`` passes a value through unless it starts + with ``<`` and holds a real HTML element *anywhere*. So Markdown that + opens with an autolink or with angle-bracketed text, and carries inline + HTML further on, is read as HTML: the autolink, an unknown element to + the HTML parser, is dropped, and the Markdown syntax is escaped as text. + Inherited from ``glpi_python_client`` 917f030, whose converter makes the + same check. + """ + + assert read(markdown, plain_text_is_markdown=True) == expected + + +# --------------------------------------------------------------------------- +# Markdown first +# --------------------------------------------------------------------------- + +#: What an integrator writes into a ticket, an action or a comment. +CALLER_MARKDOWN = [ + "The printer is **offline**.", + "Line one\nline two", + "# Procédure\n\n1. Arrêter le service\n2. Vider le cache\n3. Redémarrer", + "- Réseau\n - switch 3\n - borne wifi\n- Imprimante", + "- Réseau\n - switch 3\n- Imprimante", + "1. Sauvegarder\n 1. la base\n 2. les fichiers\n2. Redémarrer", + 'Voir [la procédure](https://example.org/kb/42 "KB 42").', + "Lien direct : ", + "![capture](https://example.org/c.png)", + "Lancer `ipconfig /all` puis envoyer le résultat.", + "```powershell\nGet-Service | Where-Object Status -eq Running\n```", + "> Le 30/09, Jean a écrit :\n> merci", + "| Poste | IP |\n| :--- | ---: |\n| PC12 | 10.0.0.12 |", + "| Commande | Effet |\n| --- | --- |\n| `ps aux \\| grep java` | processus |", + "Fichier `mon_fichier_final.docx` et chemin C:\\Temp\\logs.", + "Contact : support@example.org, R&D, 5 * 3 = 15.", + "Résolu ✅ — merci 👍", + "**Cause :** disque plein.\n\n**Solution :** purge des journaux.", + "Avant :\n\n---\n\nAprès.", + "Étapes :\n- ouvrir la session\n- lancer Outlook", +] + + +@pytest.mark.parametrize("markdown", CALLER_MARKDOWN) +def test_caller_markdown_displays_the_same_after_the_round_trip(markdown: str) -> None: + html = render(markdown) + back = read(html) + + assert displayed(render(back)) == displayed(html) + assert read(render(back)) == back diff --git a/easyvista_python_client/directory.py b/easyvista_python_client/directory.py index 73e8b16..bd0697f 100644 --- a/easyvista_python_client/directory.py +++ b/easyvista_python_client/directory.py @@ -60,7 +60,8 @@ #: #: ``END_DATE_UT`` is included on purpose: a status id is per-instance and says #: nothing portable about openness, while ``END_DATE_UT`` is empty on an open -#: ticket and stamped on a closed one. ``STATUS`` (the nested object) and +#: ticket and stamped once it is resolved or closed (at resolution, not +#: closure: measured 2026-09-02, one instance). ``STATUS`` (the nested object) and #: ``STATUS_ID`` are both requested so ``.reference("STATUS")`` resolves a label #: where the instance returns one and an id where it does not. #: diff --git a/easyvista_python_client/exceptions.py b/easyvista_python_client/exceptions.py index 313479e..5a367d2 100644 --- a/easyvista_python_client/exceptions.py +++ b/easyvista_python_client/exceptions.py @@ -2,6 +2,11 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .workflow import WorkflowEffect + class EasyvistaError(Exception): """Base class for all EasyVista client errors.""" @@ -51,3 +56,62 @@ class EasyvistaServerError(EasyvistaError): class EasyvistaConnectionError(EasyvistaError): """Transport-level failure (timeout, connection refused, etc.).""" + + +class EasyvistaContentError(EasyvistaError): + """A rich-text value could not be converted between memo HTML and Markdown. + + Raised by + :class:`~easyvista_python_client.content.EasyvistaContentConverter`, in + either direction, when a parser fails for any reason other than the + memo being nested too deeply -- or when the caller's stack is too short + even to strip a memo's tags; the underlying fault is always attached as + ``__cause__``. The converter is the optional ``content`` extra, but this + class is part of the core package, so ``except EasyvistaContentError`` + works whether or not the extra is installed. + + It exists so that no failure of the content layer escapes the package's + taxonomy. The conversion runs third-party parsers (``markdownify`` and + ``mdformat`` inbound, ``cmark-gfm`` outbound), and a parser fault would + otherwise reach the caller as a bare builtin that ``except EasyvistaError`` + does not catch. HTML nested too deeply to convert is not an error at all: + it is answered with the memo's text instead. + + No request is involved, so the converter raises it with a message alone + and ``status_code``, ``ev_code``, ``ev_message`` and ``body`` stay + ``None``. Mirrors ``glpi_python_client``'s ``GlpiContentError``. + """ + + +class EasyvistaWorkflowEffectRefused(ValueError): + """A write that may change a ticket's workflow was refused before it was sent. + + The refused write is never sent. The transport raises this before it sends + a request that names a :class:`~easyvista_python_client.WorkflowEffect` the + call did not allow through ``allow_workflow_effect=``; and ``end_action`` + raises it before its end request, when the action it was asked to end is a + workflow step or cannot be shown not to be. On the ``end_action`` path one + read of that action may precede the refusal -- it is how the action was + judged -- but the end request is never made. + + **Deliberately not an** :class:`EasyvistaError`. The refused write was + never sent, so there is no status code from it and nothing transient: the + same call can never succeed on a retry, and a caller that treats a + status-code-less ``EasyvistaError`` as "try again later" would retry it for + ever. It subclasses ``ValueError`` because it refuses the arguments, as + this package's other local refusals do. + + ``effects`` holds the refused effects; ``triggers`` the ``(what, effect)`` + pairs that named them -- a body key, a column, or a route. + """ + + def __init__( + self, + message: str, + *, + effects: frozenset[WorkflowEffect] = frozenset(), + triggers: tuple[tuple[str, WorkflowEffect], ...] = (), + ) -> None: + super().__init__(message) + self.effects = effects + self.triggers = triggers diff --git a/easyvista_python_client/models/common.py b/easyvista_python_client/models/common.py index f14894a..ae8aa8d 100644 --- a/easyvista_python_client/models/common.py +++ b/easyvista_python_client/models/common.py @@ -4,7 +4,7 @@ import re from collections.abc import Sequence -from datetime import datetime, timezone +from datetime import UTC, datetime from decimal import Decimal from typing import Annotated, Any @@ -140,7 +140,7 @@ def _parse_with_context_formats(value: Any, info: ValidationInfo) -> datetime | parsed = datetime.strptime(value.strip(), pattern) except (TypeError, ValueError): continue - return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) return None @@ -204,10 +204,13 @@ def _empty_str_to_none_datetime(value: Any, info: ValidationInfo) -> Any: EasyVista returns ISO 8601 with an explicit UTC offset and millisecond precision (``2026-08-17T15:40:41.610+02:00``), and ``""`` for an unset date — -verified live 2026-08-17. Python 3.10's ``fromisoformat`` rejects the 3-digit -fraction outright, which is why this goes through +verified live 2026-08-17. This goes through :func:`~easyvista_python_client.parse_ev_datetime` rather than letting pydantic -parse the string itself. A naive ``datetime`` passed in directly (not just a +parse the string itself because pydantic's parser is far more permissive than +that format and turns an ISO-basic or epoch-shaped value into a plausible but +wrong instant (see :func:`_empty_str_to_none_datetime`); the original reason, +Python 3.10's ``fromisoformat`` rejecting the 3-digit fraction, went with the +3.10 floor. A naive ``datetime`` passed in directly (not just a wire string) is normalized to aware UTC the same way, so the ``| None`` aside, this type's value is always timezone-aware, never naive. """ @@ -288,7 +291,9 @@ def _point_unknown_keys_at_extra_payload(cls, data: Any) -> Any: "as extra_payload={...}: it merges last and reaches the wire as " "written. Some fields are absent here because they were measured " "to misbehave, not merely because they are undocumented, so " - "re-read afterwards -- a 200 is not a receipt on this API." + "re-read afterwards -- a 200 is not a receipt on this API. A key " + "that may change a ticket's workflow is refused before sending " + "unless the call passes allow_workflow_effect=." ) custom_fields: dict[str, Any] = Field(default_factory=dict) @@ -307,7 +312,9 @@ def to_api(self) -> dict[str, Any]: measured against a single instance, and a deployment where that field behaves differently needs a way through that is not a fork. Because it bypasses the model it also bypasses the model's validation: whatever is - put here reaches the wire as written. + put here reaches the wire as written -- unless it may change a ticket's + workflow, in which case the transport refuses the request before + sending it; see :mod:`easyvista_python_client.workflow`. **The merge is case-insensitive.** The vendor documents the ticket create body's field names as case-insensitive (tier 1 -- diff --git a/easyvista_python_client/models/request.py b/easyvista_python_client/models/request.py index 3a3f02b..9d20cfa 100644 --- a/easyvista_python_client/models/request.py +++ b/easyvista_python_client/models/request.py @@ -55,8 +55,10 @@ class Request(EasyvistaModel): title: str | None = Field(default=None, alias="TITLE") # The list view returns DESCRIPTION inline (a string); the single-ticket GET # expands it into an HREF reference object (``{"HREF": ".../description"}``). - # Accept either so both read paths validate. Whether the resolved text is - # HTML or plain text is still unverified (spec open item O4). + # Accept either so both read paths validate. The resolved text is whatever + # its writer sent, HTML or plain text: measured 2026-09-30 on one instance + # (tier 4, may not generalise), and what is still unknown is open item + # O-MEMOFORMAT in docs/vendor-api-reference.md. description: str | dict[str, Any] | None = Field(default=None, alias="DESCRIPTION") external_reference: str | None = Field(default=None, alias="EXTERNAL_REFERENCE") @@ -228,16 +230,21 @@ class PostRequest(EasyvistaWriteModel): you can read again, follow the create with ``update_ticket(rfc, RequestUpdate(description=...))``. - ``workflow_start`` is a boolean and is sent even when ``False``, so a - caller disabling the workflow is not silently overridden -- that part is - real and unchanged. Its provenance is not like the fields above, though: - it does **not** appear anywhere in the vendor's own create-body - documentation. It is declared only in the instance's own OpenAPI schema - for ``POST /requests`` -- "Optional. If true, starts the workflow for the - created incident." -- which makes it **tier 3, illustrative only**: that - schema is example-derived, not a normative contract (see - ``docs/vendor-api-reference.md``). Treat it as unverified until tested - against the deployment you use it on. + ``workflow_start`` is a boolean and is sent as given, ``False`` included. + Its provenance is not like the fields above: it does **not** appear + anywhere in the vendor's own create-body documentation. It is declared only + in the instance's own OpenAPI schema for ``POST /requests`` -- "Optional. + If true, starts the workflow for the created incident." -- which makes it + **tier 3, illustrative only**: that schema is example-derived, not a + normative contract (see ``docs/vendor-api-reference.md``). + + Measured a no-op (tier 4, 2026-09-01, one instance: two tickets identical + but for this flag came back byte-identical), so ``workflow_start=False`` is + not a way to create a ticket without its workflow. The vendor create page + documents no such parameter and states the workflow is started. A + workflow-less create is the separate virtual-agent route, + ``POST requests/without-workflow``, which the workflow guard refuses unless + allowed (see :mod:`easyvista_python_client.workflow`). """ catalog_guid: str | None = None @@ -354,11 +361,14 @@ class RequestUpdate(EasyvistaWriteModel): ``extra="forbid"`` now makes ``RequestUpdate(status_id=...)`` raise at construction instead. - Set a status with :meth:`~easyvista_python_client.EasyvistaClient.set_status`, - which sends the documented ``{"closed": {"status_GUID": ...}}`` body. That - route reaches **every** status, not just terminal ones -- all six statuses - tried landed on exactly the one requested. It is addressed by - ``STATUS_GUID``, not by ``STATUS_ID``. + The vendor documents no status write that leaves the workflow alone, and + this package has none: a ticket's status follows its workflow, and the one + request the vendor documents that sets a status, + :meth:`~easyvista_python_client.EasyvistaClient.close_ticket`, interrupts + it (tier 1: "Advancing through the steps of a workflow changes the + status of a ticket." on + https://docs.easyvista.com/docs/references-tables.md, Statuses section, + and the vendor close page). * ``severity_id`` -- rejected with HTTP 590 (code 2013). Tier 4: measured on one instance, 2026-08-17. * ``urgency_id`` -- ``URGENCY_ID`` raised HTTP 590 *and the value still @@ -381,7 +391,12 @@ class RequestUpdate(EasyvistaWriteModel): To send ``status_id``, ``severity_id`` or ``urgency_id`` anyway on a deployment where they work, use ``extra_payload`` -- and re-read the - ticket afterwards, because a 200 from this endpoint is not a receipt. + ticket afterwards, because a 200 from this endpoint is not a receipt. A + ``status_id`` sent that way is refused before sending unless the call + passes ``allow_workflow_effect=WorkflowEffect.UNKNOWN`` to + ``update_ticket``, because a status column holds workflow state (see + :mod:`easyvista_python_client.workflow`); ``severity_id`` and ``urgency_id`` + are not refused. ``extra_payload`` does **not** help with priority: there is no writable column for it to reach. diff --git a/easyvista_python_client/models/tests/test_common.py b/easyvista_python_client/models/tests/test_common.py index 413da6d..f22ade2 100644 --- a/easyvista_python_client/models/tests/test_common.py +++ b/easyvista_python_client/models/tests/test_common.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta, timezone import pydantic import pytest @@ -155,7 +155,7 @@ def test_a_naive_datetime_input_comes_back_aware(): aware -- OptionalDateTime promises "An aware `datetime | None`" for every accepted input, not only for strings.""" got = _Probe.model_validate({"when": datetime(2026, 1, 1, 9, 0, 0)}).when - assert got == datetime(2026, 1, 1, 9, 0, 0, tzinfo=timezone.utc) + assert got == datetime(2026, 1, 1, 9, 0, 0, tzinfo=UTC) # --- the caller's own timestamp formats, opt-in and empty by default --------- @@ -192,7 +192,7 @@ def test_a_named_format_is_accepted_and_stamped_utc(): {"when": "17/08/2026 15:40:00"}, context={"datetime_input_formats": ["%d/%m/%Y %H:%M:%S"]}, ).when - assert got == datetime(2026, 8, 17, 15, 40, 0, tzinfo=timezone.utc) + assert got == datetime(2026, 8, 17, 15, 40, 0, tzinfo=UTC) def test_a_context_format_never_shadows_the_native_iso_form(): @@ -230,6 +230,7 @@ def test_an_unknown_field_names_itself_and_extra_payload(): assert "ctalog_guid" in message assert "extra_payload" in message assert "a 200 is not a receipt on this API." in message + assert "allow_workflow_effect=" in message def test_a_known_field_is_not_intercepted(): @@ -254,8 +255,9 @@ def test_extra_payload_serializes_verbatim_without_prefix() -> None: def test_extra_payload_overrides_a_declared_field() -> None: """A caller reaching past the model wins; losing silently would be worse.""" - payload = PostRequest(catalog_code="X", title="declared", - extra_payload={"title": "override"}) + payload = PostRequest( + catalog_code="X", title="declared", extra_payload={"title": "override"} + ) assert payload.to_api()["title"] == "override" diff --git a/easyvista_python_client/models/tests/test_request.py b/easyvista_python_client/models/tests/test_request.py index 38fffef..e8f873a 100644 --- a/easyvista_python_client/models/tests/test_request.py +++ b/easyvista_python_client/models/tests/test_request.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import pydantic import pytest @@ -135,8 +135,8 @@ def test_request_declares_title_and_core_scalars(): assert req.owner_id == 14 assert req.external_reference == "REF-1" # No offset in the fixture -> parse_ev_datetime treats it as UTC. - assert req.submit_date_ut == datetime(2026, 1, 1, 9, 0, 0, tzinfo=timezone.utc) - assert req.last_update == datetime(2026, 1, 2, 10, 30, 0, tzinfo=timezone.utc) + assert req.submit_date_ut == datetime(2026, 1, 1, 9, 0, 0, tzinfo=UTC) + assert req.last_update == datetime(2026, 1, 2, 10, 30, 0, tzinfo=UTC) def test_request_coerces_empty_string_numerics_to_none(): @@ -220,15 +220,9 @@ def test_request_declares_the_official_time_fields(): } ) # No offset in the fixtures -> parse_ev_datetime treats them as UTC. - assert ticket.creation_date_ut == datetime( - 2026, 7, 28, 9, 0, 0, tzinfo=timezone.utc - ) - assert ticket.max_resolution_date_ut == datetime( - 2026, 7, 30, 9, 0, 0, tzinfo=timezone.utc - ) - assert ticket.expected_date_ut == datetime( - 2026, 7, 29, 9, 0, 0, tzinfo=timezone.utc - ) + assert ticket.creation_date_ut == datetime(2026, 7, 28, 9, 0, 0, tzinfo=UTC) + assert ticket.max_resolution_date_ut == datetime(2026, 7, 30, 9, 0, 0, tzinfo=UTC) + assert ticket.expected_date_ut == datetime(2026, 7, 29, 9, 0, 0, tzinfo=UTC) assert ticket.end_date_ut is None # "" sentinel assert ticket.sla_id == 4 assert ticket.time_used_to_solve_request == "3600" @@ -333,8 +327,9 @@ def test_request_update_refuses_status_id(): invisible: measured live, a flat status write is rejected 590 when sent alone and -- far worse -- returns 200, applies its companion field and drops the status silently when sent beside one. Anything that reinstates this field - reinstates a write that reports success and stores nothing. The status route - is ``set_status`` / the ``{"closed": {"status_GUID": ...}}`` envelope. + reinstates a write that reports success and stores nothing. The only request + that sets a status is ``close_ticket`` (the ``{"closed": {"status_GUID": + ...}}`` envelope), and it interrupts the workflow. """ with pytest.raises(ValidationError) as excinfo: RequestUpdate(status_id=2) @@ -431,9 +426,7 @@ def test_department_id_and_recipient_id_accept_the_documented_string() -> None: longer rewritten: the string reaches the wire as written, and an int still reaches it as an int. """ - body = PostRequest( - catalog_code="X", department_id="9", recipient_id="42" - ).to_api() + body = PostRequest(catalog_code="X", department_id="9", recipient_id="42").to_api() assert body["department_id"] == "9" assert body["recipient_id"] == "42" ints = PostRequest(catalog_code="X", department_id=9, recipient_id=42).to_api() diff --git a/easyvista_python_client/resources/actions.py b/easyvista_python_client/resources/actions.py index 19a1901..16d2053 100644 --- a/easyvista_python_client/resources/actions.py +++ b/easyvista_python_client/resources/actions.py @@ -22,6 +22,30 @@ ) +def _require_action_id(action_id: object) -> str: + """Return ``action_id`` as the digit string ``actions/{id}`` addresses. + + ``PUT actions/{rfc_number}`` is the vendor's END-ACTION route, on the same + path template as ``PUT actions/{action_id}`` (tier 1, + https://docs.easyvista.com/docs/webservice-rest.md). An RFC number here + would therefore not edit one action: it addresses the end-action route + instead, where an ``end_action`` body naming no ``action_id`` ends every + open action on the ticket. ``Action.action_id`` is legitimately + ``None`` across this package (a create response carries none; a projection + without ``ACTION_ID`` drops it), so ``None`` is refused rather than + addressing ``actions/None``. + """ + if action_id is None or isinstance(action_id, (bool, float)): + raise ValueError(f"an action id must be a positive integer, got {action_id!r}") + text = str(action_id).strip() + if not (text.isascii() and text.isdigit()) or int(text) <= 0: + raise ValueError( + f"an action id must be a positive integer, got {action_id!r}; an RFC " + "number addresses the end-action route on the same path instead" + ) + return text + + def build_create_action( rfc_number: str, payload: PostAction, @@ -140,6 +164,7 @@ def parse(data: Any) -> list[Action]: def build_get_action( action_id: str | int, *, + fields: Iterable[str] | str | None = None, context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Action]]: """Fetch ONE action by id. @@ -152,8 +177,10 @@ def build_get_action( no ``requests/{rfc}/actions/{id}`` route at all. See :func:`build_search_actions` for why the HTTP 403 an earlier note recorded against that path was never evidence of a permission restriction. + + ``fields`` projects the item read, as on the list. """ - return build_get(ACTIONS, action_id, context=context) + return build_get(ACTIONS, action_id, fields=fields, context=context) def build_update_action( @@ -169,8 +196,63 @@ def build_update_action( ``requests/{rfc}/actions/{id}`` route to send them to. See :func:`build_search_actions` for why the HTTP 403 an earlier note recorded against that path did not distinguish a denied route from an absent one. + + The id must be a positive integer -- see ``_require_action_id`` for why an + RFC number is refused. + """ + record_id = _require_action_id(action_id) + return build_update(ACTIONS, record_id, payload, context=context) + + +#: The body keys a reassignment sends. ``group_id`` is the lower-case spelling +#: the 2026-10-02 census found stored (one instance, 2/2 tickets), so the +#: upper-case retry was never needed. ``done_by_id`` is the same lower-case +#: convention as ``PostAction``, but a write to a person was **not measured**. +_REASSIGN_GROUP_KEY = "group_id" +_REASSIGN_DONE_BY_KEY = "done_by_id" + + +def _positive_int(value: object, name: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"{name} must be a positive integer, got {value!r}") + return value + + +def build_reassign_action( + action_id: str | int, + *, + group_id: int | None = None, + done_by_id: int | None = None, + context: dict[str, Any] | None = None, +) -> tuple[RequestSpec, Callable[[Any], Action]]: + """Build ``PUT actions/{id}`` reassigning an action to a group and/or person. + + The vendor documents no reassignment route: the UI's transfer is a wizard, + and ``PUT actions/{id}`` accepts "all the fields from the AM_ACTION table + except" a list that does not name the group or done-by columns (tier 1, + https://docs.easyvista.com/docs/rest-api-update-an-action.md). What this + write does was measured, not documented -- see the client's + ``reassign_action``. + + The id must be a positive integer, as for :func:`build_update_action`; + ``group_id`` and ``done_by_id`` must be positive integers, and at least one + is required. """ - return build_update(ACTIONS, action_id, payload, context=context) + path_id = _require_action_id(action_id) + body: dict[str, int] = {} + if group_id is not None: + body[_REASSIGN_GROUP_KEY] = _positive_int(group_id, "group_id") + if done_by_id is not None: + body[_REASSIGN_DONE_BY_KEY] = _positive_int(done_by_id, "done_by_id") + if not body: + raise ValueError("reassign_action needs group_id, done_by_id, or both") + spec = RequestSpec("PUT", f"actions/{path_id}", json=body) + + def parse(data: Any) -> Action: + records = extract_records(data, ACTIONS.envelope_key) + return Action.model_validate(records[0] if records else data, context=context) + + return spec, parse def build_end_action( @@ -216,6 +298,10 @@ def build_end_action( End Date" (measured 2026-09-01 on one instance -- one instance, one date, so it may not generalise). ``elapsed_time`` is a number of **minutes**. + ``action_id`` must be a positive integer, as for ``update_action``: it is + sent as an integer, and a blank, an RFC number or any other value is + refused rather than named in the body. + A blank ``rfc_number`` is refused rather than allowed to build ``PUT actions/``, which addresses the collection instead of a ticket. """ @@ -244,7 +330,10 @@ def build_end_action( ) end: dict[str, Any] = {} if action_id is not None: - end["action_id"] = action_id + # An integer on the wire, as ``update_action`` addresses one: a blank + # or an RFC number is no action id, and would otherwise let the + # client's pre-flight read address the collection (``GET actions/``). + end["action_id"] = int(_require_action_id(action_id)) if start_date is not None: end["start_date"] = start_date if end_date is not None: diff --git a/easyvista_python_client/resources/requests.py b/easyvista_python_client/resources/requests.py index 992d708..7440c09 100644 --- a/easyvista_python_client/resources/requests.py +++ b/easyvista_python_client/resources/requests.py @@ -83,23 +83,22 @@ def build_close_ticket( catalog_guid: str | None = None, context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Request]]: - """Build the ``{"closed": {...}}`` PUT spec — the API's status-set route. - - Despite the wire name, this envelope is **not limited to closing**. It is the - only working way to set a ticket's status, and it reaches every status: - handed each of six different ``STATUS_GUID``s in turn, a fresh ticket landed - on exactly the status requested every time -- including non-terminal ones - like "A prendre en compte" and "En cours". Nothing was forced to the closed - status. - - Note the addressing: ``status_GUID``, not ``STATUS_ID``. There is no flat - status update on this API -- see :class:`RequestUpdate` for what happens if - you try one. :func:`build_set_status` is the same spec under a name that says - what it does. - - ``delete_actions`` drops the ticket's actions; the vendor types it a - **boolean** and this builder passes either spelling through unchanged, since - EasyVista accepts ``true``/``false``, ``0``/``1`` and the quoted strings. + """Build the ``{"closed": {...}}`` PUT spec — the vendor's close request. + + This is the vendor's CLOSE request, and it is not a status setter. Per the + close page (tier 1, re-read 2026-10-02) it interrupts the ticket's + workflow, sets ``status_GUID`` as "the final status of the ticket", ends + or (with ``delete_actions``) deletes the unfinished actions, and inserts + an anticipated closing action -- none of it conditional on the status + sent. The client's ``close_ticket`` requires the caller to allow + ``WorkflowEffect.INTERRUPTS``; this builder returns a spec the transport + refuses until that is done. Note the addressing: ``status_GUID``, not + ``STATUS_ID``. + + ``delete_actions`` deletes the ticket's unfinished actions rather than + ending them (tier 1, the same page); the vendor types it a **boolean** and + this builder passes either spelling through unchanged, since EasyVista + accepts ``true``/``false``, ``0``/``1`` and the quoted strings. **The route is the vendor's own.** ``PUT requests/{rfc_number}`` with a ``closed`` wrapper is what the documentation specifies @@ -107,10 +106,9 @@ def build_close_ticket( a workaround for the ``PUT|PATCH requests/{rfc_number}/close`` path that also appears in an instance's OpenAPI. Every field below is tier 1, and every one is **optional**: omitting ``end_date`` stamps now, and omitting - ``status_guid`` simply leaves the key out of the body. **Where the ticket - then lands is not established here** -- the behaviour is not recorded in - ``docs/vendor-api-reference.md`` and no live test exercises the omitted - form (open item O-CLOSE-DEFAULT). + ``status_guid`` leaves the key out of the body, and the vendor documents + the default as the Closed meta-status (tier 1, the same page; never + measured here). ``catalog_guid`` requalifies the ticket as it closes -- the vendor notes it is needed only for that. ``end_date`` takes the instance's own date format, @@ -142,22 +140,3 @@ def parse(data: Any) -> Request: ) return spec, parse - - -def build_set_status( - rfc_number: str, - *, - status_guid: str, - comment: str | None = None, - context: dict[str, Any] | None = None, -) -> tuple[RequestSpec, Callable[[Any], Request]]: - """Build a spec that sets ``rfc_number``'s status to ``status_guid``. - - The same request :func:`build_close_ticket` builds, named for what it - actually does. ``status_guid`` is required here rather than optional: the - envelope without one is a close request with nothing to close to, and making - that unexpressible is the point of having this function at all. - """ - return build_close_ticket( - rfc_number, status_guid=status_guid, comment=comment, context=context - ) diff --git a/easyvista_python_client/resources/tests/test_actions.py b/easyvista_python_client/resources/tests/test_actions.py index 942352d..fd5a755 100644 --- a/easyvista_python_client/resources/tests/test_actions.py +++ b/easyvista_python_client/resources/tests/test_actions.py @@ -261,10 +261,36 @@ def test_build_end_action_sends_a_falsy_elapsed_time(value): assert spec.json["end_action"]["elapsed_time"] == value -def test_build_end_action_sends_a_falsy_action_id(): - """``action_id=0`` must address action 0, not become the bulk form.""" - spec, _ = a.build_end_action("I1", action_id=0) - assert spec.json["end_action"]["action_id"] == 0 +def test_build_end_action_never_turns_a_falsy_action_id_into_the_bulk_form(): + """``action_id=0`` is refused, not read as "no id" and not sent. + + Truthiness must not decide between naming an action and the id-less bulk + form (``is not None`` does); and 0 is not a valid action id at all, so it + is refused with the rest of the non-positive ids. + """ + with pytest.raises(ValueError, match="action id"): + a.build_end_action("I1", action_id=0) + + +@pytest.mark.parametrize("good", [42, "42", " 42 "]) +def test_build_end_action_puts_the_action_id_in_the_body_as_an_integer(good): + spec, _ = a.build_end_action("I1", action_id=good) + assert spec.json == {"end_action": {"action_id": 42}} + assert type(spec.json["end_action"]["action_id"]) is int + + +@pytest.mark.parametrize( + "bad", ["I250101_00001", "", " ", 0, -1, "-1", True, "12a", "²", 1.5] +) +def test_build_end_action_refuses_anything_but_a_positive_action_id(bad): + """The same rule as ``update_action``: an RFC number or a blank is no action id. + + A blank would also make the client's pre-flight read address the + collection (``GET actions/``), whose first row says nothing about the + action being ended. + """ + with pytest.raises(ValueError, match="action id"): + a.build_end_action("I1", action_id=bad) def test_build_end_action_passes_start_date_through(): @@ -274,7 +300,7 @@ def test_build_end_action_passes_start_date_through(): end_date="01/09/2026 17:15:00", elapsed_time="15", doneby_mail="a@b.c", ) assert spec.json["end_action"] == { - "action_id": "7", + "action_id": 7, "start_date": "01/09/2026 17:00:00", "end_date": "01/09/2026 17:15:00", "elapsed_time": "15", @@ -298,3 +324,74 @@ def test_build_end_action_parses_the_href_only_response_without_raising(): _, parser = a.build_end_action("I1", action_id=1) parsed = parser({"HREF": "https://host/api/v1/50004/requests/I1"}) assert parsed.action_id is None + + +@pytest.mark.parametrize( + "bad", ["I250101_00001", "", " ", 0, -1, "-1", True, None, "12a", "²", 1.5] +) +def test_build_update_action_refuses_anything_but_a_positive_action_id(bad): + """``PUT actions/{rfc_number}`` is the end-action route on the same template. + + An RFC number where an action id belongs would not edit one action: it + would address the end-action route, where an ``end_action`` body naming no + ``action_id`` ends every open action on the ticket. + """ + with pytest.raises(ValueError, match="action id"): + build_update_action(bad, ActionUpdate(description="x")) + + +@pytest.mark.parametrize("good", [60350, "60350", " 60350 "]) +def test_build_update_action_addresses_the_digit_string(good): + spec, _ = build_update_action(good, ActionUpdate(description="x")) + assert spec.path == "actions/60350" + + +def test_build_get_action_can_project_fields(): + spec, _ = build_get_action(42, fields=["ACTION_ID", "WORKFLOW_ID"]) + assert spec.path == "actions/42" + assert spec.params == {"fields": "ACTION_ID,WORKFLOW_ID"} + + +def test_build_reassign_action_puts_the_group_and_person(): + spec, _ = a.build_reassign_action(60350, group_id=57, done_by_id=12) + assert (spec.method, spec.path) == ("PUT", "actions/60350") + assert spec.json == {a._REASSIGN_GROUP_KEY: 57, a._REASSIGN_DONE_BY_KEY: 12} + + +def test_build_reassign_action_sends_only_what_it_was_given(): + spec, _ = a.build_reassign_action(60350, group_id=57) + assert spec.json == {a._REASSIGN_GROUP_KEY: 57} + spec, _ = a.build_reassign_action(60350, done_by_id=12) + assert spec.json == {a._REASSIGN_DONE_BY_KEY: 12} + + +def test_build_reassign_action_needs_a_target(): + with pytest.raises(ValueError, match="group_id, done_by_id"): + a.build_reassign_action(60350) + + +@pytest.mark.parametrize("bad", [0, -3, True, "57", 1.0]) +def test_build_reassign_action_refuses_a_non_positive_or_non_int_id(bad): + with pytest.raises(ValueError): + a.build_reassign_action(60350, group_id=bad) + with pytest.raises(ValueError): + a.build_reassign_action(60350, done_by_id=bad) + + +def test_build_reassign_action_refuses_an_rfc_as_the_action(): + with pytest.raises(ValueError, match="action id"): + a.build_reassign_action("I250101_00001", group_id=57) + + +def test_build_reassign_action_parses_an_empty_echo_without_raising(): + _, parser = a.build_reassign_action(60350, group_id=57) + assert parser({}).action_id is None + assert parser({"records": [{"ACTION_ID": 60350, "GROUP_ID": 57}]}).group_id == 57 + + +def test_build_reassign_action_body_is_not_a_workflow_effect(): + """The group and person are data the workflow reads, not workflow state.""" + from easyvista_python_client.workflow import workflow_triggers + + spec, _ = a.build_reassign_action(60350, group_id=57, done_by_id=12) + assert not workflow_triggers(spec.method, spec.path, spec.json) diff --git a/easyvista_python_client/resources/tests/test_requests.py b/easyvista_python_client/resources/tests/test_requests.py index c0a5cb7..a38bd3d 100644 --- a/easyvista_python_client/resources/tests/test_requests.py +++ b/easyvista_python_client/resources/tests/test_requests.py @@ -110,25 +110,8 @@ def test_build_close_ticket_full_documented_shape(): } -def test_build_set_status_sends_the_closed_envelope(): - """``set_status`` is the ``closed`` envelope, addressed by GUID. - - Pins both halves of the call shape that took several wrong turns to find: the - body is wrapped in ``closed`` (not flat), and the key is ``status_GUID`` (not - ``STATUS_ID``). The envelope is not limited to closing -- six different status - GUIDs each landed on exactly the status requested. - """ - spec, _parser = r.build_set_status("I1", status_guid="{G}", comment="c") - assert spec.method == "PUT" - assert spec.path == "requests/I1" - assert spec.json == {"closed": {"status_GUID": "{G}", "comment": "c"}} - - -def test_build_set_status_matches_build_close_ticket(): - """The two builders are the same request; only the name differs.""" - a, _ = r.build_set_status("I1", status_guid="{G}") - b, _ = r.build_close_ticket("I1", status_guid="{G}") - assert (a.method, a.path, a.json) == (b.method, b.path, b.json) +def test_build_set_status_is_gone(): + assert not hasattr(r, "build_set_status") def test_close_ticket_carries_the_two_previously_undeclared_documented_fields(): diff --git a/easyvista_python_client/testing/test_method_invocation.py b/easyvista_python_client/testing/test_method_invocation.py index a68ac5b..00768c3 100644 --- a/easyvista_python_client/testing/test_method_invocation.py +++ b/easyvista_python_client/testing/test_method_invocation.py @@ -36,6 +36,7 @@ PostRequest, PostTask, RequestUpdate, + WorkflowEffect, ) #: One payload that satisfies every parser in the package. @@ -52,6 +53,10 @@ { "RFC_NUMBER": "I1", "ACTION_ID": 1, + # Empty, not absent: end_action's guard reads this column off the + # action it is asked to end, and an empty one is the caller's own + # action (the safe path the registry should model). + "WORKFLOW_ID": "", "ASSET_ID": 1, "DEPARTMENT_ID": 1, "EMPLOYEE_ID": 1, @@ -75,8 +80,7 @@ # The escape hatch: an arbitrary route, parsed by nobody. PAYLOAD satisfies # it because `send` returns the raw JSON body unchanged. "send": (("GET", "requests"), {}), - "close_ticket": (("I1",), {}), - "set_status": (("I1",), {"status_guid": "{0000-0000}"}), + "close_ticket": (("I1",), {"allow_workflow_effect": WorkflowEffect.INTERRUPTS}), "count_tickets": ((), {}), "create_action": (("I1", PostAction(action_type_id=94, group_id=3)), {}), # action_id named explicitly: omitted, the vendor form ends EVERY open @@ -114,6 +118,7 @@ "iter_tickets": ((), {"max_records": 1}), "list_actions": (("I1",), {}), "list_documents": (("I1",), {}), + "reassign_action": ((1,), {"group_id": 3}), "resolve_memo": (("requests/I1/description",), {}), "search_assets": ((), {}), "search_departments": ((), {}), diff --git a/easyvista_python_client/testing/test_public_api.py b/easyvista_python_client/testing/test_public_api.py index 9eaf806..619b224 100644 --- a/easyvista_python_client/testing/test_public_api.py +++ b/easyvista_python_client/testing/test_public_api.py @@ -1,8 +1,66 @@ +import re +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + import easyvista_python_client +_REPO_ROOT = Path(__file__).resolve().parents[2] + +#: Each distribution in the optional ``content`` extra, mapped to the module it +#: installs. The two names differ for three of the six, so neither can be +#: derived from the other; ``test_the_content_import_names_match_the_extra`` +#: keeps the keys equal to what pyproject.toml actually declares. +_CONTENT_IMPORT_NAMES = { + "beautifulsoup4": "bs4", + "cmarkgfm": "cmarkgfm", + "markdown-it-py": "markdown_it", + "markdownify": "markdownify", + "mdformat": "mdformat", + "mdformat-tables": "mdformat_tables", +} + +#: The import names of the optional ``content`` extra's distributions. +_CONTENT_MODULES = tuple(sorted(_CONTENT_IMPORT_NAMES.values())) + + +def _optional_dependencies() -> dict[str, list[str]]: + """``[project.optional-dependencies]`` as pyproject.toml declares it.""" + config = tomllib.loads((_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + extras: dict[str, list[str]] = config["project"]["optional-dependencies"] + return extras + + +def _distribution_name(requirement: str) -> str: + """The normalized project name a PEP 508 requirement string names.""" + match = re.match(r"[A-Za-z0-9._-]+", requirement.strip()) + assert match, f"not a requirement: {requirement!r}" + return re.sub(r"[-_.]+", "-", match.group(0)).lower() + + +def _run_python(code: str) -> subprocess.CompletedProcess[str]: + """Run ``code`` in a fresh interpreter that imports this checkout. + + A fresh one because this process has already imported the extra for the + converter's own tests, so ``sys.modules`` here can answer nothing about + what a bare ``import easyvista_python_client`` loads. ``-c`` puts the + working directory first on ``sys.path``, which is what makes the child + import the tree under test rather than whatever else is installed. + """ + return subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=False, + cwd=_REPO_ROOT, + ) + def test_package_imports_and_has_version(): - assert easyvista_python_client.__version__ == "0.3.0" + assert easyvista_python_client.__version__ == "0.4.0" def test_public_exports_available(): @@ -84,3 +142,167 @@ def test_every_async_client_method_is_awaitable(): assert inspect.iscoroutinefunction(member) or inspect.isasyncgenfunction( member ), f"{name} is neither a coroutine nor an async generator" + + +def test_content_error_is_exported_beside_its_siblings(): + """The error ships in the core, so it is catchable without the extra.""" + import easyvista_python_client as evc + + assert "EasyvistaContentError" in evc.__all__ + assert issubclass(evc.EasyvistaContentError, evc.EasyvistaError) + + +def test_importing_the_package_does_not_import_the_content_extra(): + """``import easyvista_python_client`` must not pay for the converter.""" + result = _run_python( + "import sys\n" + "import easyvista_python_client\n" + f"print(sorted(m for m in {_CONTENT_MODULES!r} if m in sys.modules))\n" + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "[]" + + +def test_the_package_works_without_the_content_extra(): + """With the extra uninstalled, only the ``content`` subpackage refuses. + + ``sys.modules[name] = None`` makes the next import of ``name`` raise + ``ImportError``, which is what an environment without the extra does. + The core must import, its error class must be there to catch, and the + subpackage must say how to install what it needs. + """ + result = _run_python( + "import sys\n" + f"for name in {_CONTENT_MODULES!r}:\n" + " sys.modules[name] = None\n" + "import easyvista_python_client\n" + "print(easyvista_python_client.EasyvistaContentError.__name__)\n" + "try:\n" + " import easyvista_python_client.content\n" + "except ImportError as exc:\n" + " print(exc)\n" + "else:\n" + " raise SystemExit('the content subpackage imported without its extra')\n" + ) + + assert result.returncode == 0, result.stderr + first, _, rest = result.stdout.partition("\n") + assert first.strip() == "EasyvistaContentError" + assert 'pip install "easyvista-python-client[content]"' in rest + + +def test_dev_and_docs_install_the_content_extra(): + """CI installs ``.[dev]`` and Read the Docs ``.[docs]``; both need the extra. + + Without it in ``dev`` the converter's tests cannot import on CI, and + without it in ``docs`` autodoc cannot import the module it documents. The + requirements are written out in each extra, bounds included, and this is + what keeps the copies in step. + """ + extras = _optional_dependencies() + content = set(extras["content"]) + + assert content, "the content extra declares nothing" + assert content <= set(extras["dev"]), sorted(content - set(extras["dev"])) + assert content <= set(extras["docs"]), sorted(content - set(extras["docs"])) + + +def test_the_pre_commit_mypy_hook_pins_the_content_extra_as_pyproject_does(): + """The mypy hook's copies of the extra's requirements equal pyproject's. + + The hook builds its own venv from ``additional_dependencies``, so the + extra's typed packages are written out there a second time, and nothing + else keeps that copy in step. Read with a regex rather than a YAML parser, + which the test environment does not otherwise need. The hook lists only + the typed packages, so a package of the extra may be absent from it, but + one that is listed must carry the extra's exact bounds. + """ + config = (_REPO_ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8") + hook = re.search(r"- id: mypy\n(.*?)\n\s*exclude:", config, re.DOTALL) + assert hook, "no mypy hook found in .pre-commit-config.yaml" + listed = re.findall(r'^\s*-\s*"([^"]+)"\s*$', hook.group(1), re.MULTILINE) + extra = {_distribution_name(r): r for r in _optional_dependencies()["content"]} + + copies = { + _distribution_name(r): r for r in listed if _distribution_name(r) in extra + } + + assert copies, "the mypy hook lists none of the content extra's packages" + drifted = {name: (r, extra[name]) for name, r in copies.items() if r != extra[name]} + assert not drifted, f"hook requirement != pyproject's: {drifted}" + + +def test_the_supported_pythons_agree_everywhere_they_are_written(): + """requires-python, the classifiers and both CI matrices name one range. + + Each lists the supported interpreters by hand, and a floor raised in one + place and not the others is what this package's drop of 3.10 had to + chase through all four. + """ + config = tomllib.loads((_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = config["project"] + prefix = "Programming Language :: Python :: 3." + classified = sorted( + int(c.removeprefix(prefix)) + for c in project["classifiers"] + if c.startswith(prefix) and c.removeprefix(prefix).isdecimal() + ) + floor = re.fullmatch(r">=3\.(\d+)", project["requires-python"]) + assert floor, project["requires-python"] + assert classified, "no Python 3.x classifier" + assert classified[0] == int(floor.group(1)) + assert classified == list(range(classified[0], classified[-1] + 1)) + + for workflow in ("ci.yml", "release.yml"): + text = (_REPO_ROOT / ".github" / "workflows" / workflow).read_text( + encoding="utf-8" + ) + matrices = re.findall(r"python-version:\s*\[([^\]]*)\]", text) + assert len(matrices) == 1, f"{workflow}: {len(matrices)} matrices" + versions = re.findall(r'"3\.(\d+)"', matrices[0]) + assert sorted(int(v) for v in versions) == classified, workflow + + +def test_the_content_import_names_match_the_extra(): + """``_CONTENT_MODULES`` names exactly the modules the extra installs. + + The two import-isolation tests above are only as good as that list: a + distribution added to the extra but missing here is never checked, and one + dropped from the extra but left here is checked for nothing -- which is how + ``markdown`` outlived the python-markdown converter it was listed for. So + the list is bound to pyproject.toml by distribution name. + """ + declared = {_distribution_name(r) for r in _optional_dependencies()["content"]} + + assert declared == set(_CONTENT_IMPORT_NAMES), ( + f"in pyproject only: {sorted(declared - set(_CONTENT_IMPORT_NAMES))}; " + f"listed here only: {sorted(set(_CONTENT_IMPORT_NAMES) - declared)}" + ) + + +@pytest.mark.parametrize("module", _CONTENT_MODULES) +def test_each_content_dependency_alone_triggers_the_install_hint(module): + """Any ONE missing distribution makes the subpackage name the extra. + + A partial install is the realistic failure -- a pinned environment that + predates a dependency the extra gained -- and it must fail at import with + the install command, not later with a bare ``ModuleNotFoundError`` from + inside a conversion. A fresh child per module rather than one child + blocking and unblocking in turn: un-caching a package does not un-cache its + submodules, so a re-import in the same process can fail for reasons that + have nothing to do with the guard. + """ + result = _run_python( + "import sys\n" + f"sys.modules[{module!r}] = None\n" + "try:\n" + " import easyvista_python_client.content\n" + "except ImportError as exc:\n" + " print(exc)\n" + "else:\n" + " raise SystemExit('the content subpackage imported without it')\n" + ) + + assert result.returncode == 0, result.stderr + assert 'pip install "easyvista-python-client[content]"' in result.stdout diff --git a/easyvista_python_client/testing/test_unasync_codegen.py b/easyvista_python_client/testing/test_unasync_codegen.py index b35157e..0fe3c41 100644 --- a/easyvista_python_client/testing/test_unasync_codegen.py +++ b/easyvista_python_client/testing/test_unasync_codegen.py @@ -32,6 +32,7 @@ import ast import importlib.util import pathlib +import tomllib import pytest @@ -124,8 +125,8 @@ def _rewritable_names(node: ast.AST, keys: set[str]) -> list[str]: (``ast.MatchAs.name``), ``case [*AsyncRetrying]:`` (``ast.MatchStar.name``) and ``case {**aclose}:`` (``ast.MatchMapping.rest``) each bind a plain name the same way, and none of them is an ``ast.Name`` either. ``match`` - is valid on this project's 3.10 floor, so it is a live construct even - though nothing in the tree uses it today. + is valid on every Python this project supports (it arrived in 3.10), so + it is a live construct even though nothing in the tree uses it today. The exemption for a deliberate rename is scoped to the *specific* role it is legitimate in -- a class name for ``AsyncEasyvistaClient``, a @@ -262,11 +263,6 @@ def test_coverage_omits_the_generated_modules_and_nothing_else(build): ratio. A *hand-written* module wrongly listed in ``omit`` is measured by nothing, which is the defect this test was written for. """ - try: - import tomllib - except ModuleNotFoundError: # Python 3.10 - import tomli as tomllib - config = tomllib.loads((_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) omit = set(config["tool"]["coverage"]["run"]["omit"]) diff --git a/easyvista_python_client/tests/test_exceptions.py b/easyvista_python_client/tests/test_exceptions.py index 1c19ff7..22bc63b 100644 --- a/easyvista_python_client/tests/test_exceptions.py +++ b/easyvista_python_client/tests/test_exceptions.py @@ -3,6 +3,7 @@ from easyvista_python_client.exceptions import ( EasyvistaAuthError, EasyvistaConnectionError, + EasyvistaContentError, EasyvistaError, EasyvistaNotFound, EasyvistaRateLimitError, @@ -28,7 +29,20 @@ def test_base_error_carries_context(): EasyvistaRateLimitError, EasyvistaServerError, EasyvistaConnectionError, + EasyvistaContentError, ], ) def test_subclasses_are_easyvista_errors(cls): assert issubclass(cls, EasyvistaError) + + +def test_content_error_carries_no_http_context(): + """A conversion fault involves no request, so there is nothing to report. + + The converter raises it with a message alone; every HTTP attribute the + base class defines stays ``None`` rather than borrowing a meaning. + """ + err = EasyvistaContentError("could not convert") + for attribute in ("status_code", "ev_code", "ev_message", "body"): + assert getattr(err, attribute) is None, attribute + assert str(err) == "could not convert" diff --git a/easyvista_python_client/tests/test_reporting.py b/easyvista_python_client/tests/test_reporting.py index 0b05674..f364913 100644 --- a/easyvista_python_client/tests/test_reporting.py +++ b/easyvista_python_client/tests/test_reporting.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -12,7 +12,8 @@ def test_parse_offset_with_3_digit_milliseconds(): - # EasyVista's CREATION_DATE_UT format; 3.10's fromisoformat rejects 3-digit ms. + # EasyVista's CREATION_DATE_UT format, 3-digit ms (which 3.10's + # fromisoformat rejected, back when 3.10 was supported). dt = _parse_iso_datetime("2025-11-28T11:35:22.900+01:00") assert dt is not None assert dt.year == 2025 and dt.month == 11 and dt.day == 28 @@ -21,7 +22,7 @@ def test_parse_offset_with_3_digit_milliseconds(): def test_parse_trailing_z_is_utc(): dt = _parse_iso_datetime("2025-01-02T03:04:05Z") - assert dt == datetime(2025, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + assert dt == datetime(2025, 1, 2, 3, 4, 5, tzinfo=UTC) def test_parse_no_fraction(): @@ -31,13 +32,13 @@ def test_parse_no_fraction(): def test_parse_naive_string_becomes_utc(): dt = _parse_iso_datetime("2025-06-15T08:00:00") - assert dt == datetime(2025, 6, 15, 8, 0, 0, tzinfo=timezone.utc) + assert dt == datetime(2025, 6, 15, 8, 0, 0, tzinfo=UTC) def test_parse_datetime_passthrough_makes_naive_utc(): naive = datetime(2025, 6, 15, 8, 0, 0) - assert _parse_iso_datetime(naive) == naive.replace(tzinfo=timezone.utc) - aware = datetime(2025, 6, 15, 8, 0, 0, tzinfo=timezone.utc) + assert _parse_iso_datetime(naive) == naive.replace(tzinfo=UTC) + aware = datetime(2025, 6, 15, 8, 0, 0, tzinfo=UTC) assert _parse_iso_datetime(aware) == aware diff --git a/easyvista_python_client/tests/test_timestamps.py b/easyvista_python_client/tests/test_timestamps.py index af22e09..f98c943 100644 --- a/easyvista_python_client/tests/test_timestamps.py +++ b/easyvista_python_client/tests/test_timestamps.py @@ -65,13 +65,15 @@ def test_format_refuses_a_naive_datetime(): def test_the_iso_basic_form_is_refused_on_every_python(basic): """Separator-less ISO input must return ``None`` regardless of interpreter. - This is a portability guard, not a formatting preference. ``fromisoformat`` - accepts the ISO "basic" form from Python 3.11 and rejects it on 3.10, and - this package supports 3.10 through 3.14 -- so before the explicit refusal - the *same wire value* parsed to an instant on four of the five supported + This began as a portability guard, not a formatting preference. + ``fromisoformat`` accepts the ISO "basic" form from Python 3.11 and rejects + it on 3.10, and when this package still supported 3.10 through 3.14 the + *same wire value* parsed to an instant on four of the five supported versions and raised on the fifth. CI caught it exactly that way: the 3.10 job was green while 3.11 and 3.12 failed ``test_a_numeric_shaped_value_raises_instead_of_becoming_an_epoch_instant``. + With 3.11 as the floor every supported interpreter accepts these, so the + explicit refusal is now the only thing between them and a parsed instant. EasyVista's timestamps always carry separators, so none of these is one of its values on any interpreter, and accepting them would let a genuine diff --git a/easyvista_python_client/tests/test_workflow.py b/easyvista_python_client/tests/test_workflow.py new file mode 100644 index 0000000..2d00230 --- /dev/null +++ b/easyvista_python_client/tests/test_workflow.py @@ -0,0 +1,269 @@ +"""The workflow-effect classifier: what a request may do to a ticket's workflow.""" + +import pytest + +from easyvista_python_client import EasyvistaError, EasyvistaWorkflowEffectRefused +from easyvista_python_client.workflow import ( + WorkflowEffect, + as_effects, + classify_workflow_effects, + workflow_triggers, +) + +I, A, U = ( # noqa: E741 -- short aliases keep the parametrised table readable + WorkflowEffect.INTERRUPTS, + WorkflowEffect.ADVANCES, + WorkflowEffect.UNKNOWN, +) + + +@pytest.mark.parametrize( + ("method", "path", "body", "expected"), + [ + # The vendor's workflow-control bodies, on the routes they belong to. + ("PUT", "requests/I1", {"closed": {"status_GUID": "{G}"}}, {I}), + ("PUT", "actions/I1", {"end_action": {"action_id": 1}}, {A}), + ("PUT", "requests/I1", {"suspended": {}}, {U}), + ("PUT", "requests/I1", {"restarted": {}}, {U}), + # ... matched case-insensitively, on any path, and inside a list body. + ("PUT", "requests/I1", {"Closed": {}}, {I}), + ("PUT", "actions/60350", {"END_ACTION": {}}, {A}), + ("PUT", "departments/7", {"closed": {}}, {I}), + ("PUT", "requests/I1", [{"closed": {}}], {I}), + # httpx serialises a tuple as a JSON array, so it is scanned like a list. + ("PUT", "requests/I1", ({"closed": {}},), {I}), + # Ticket columns that hold or select workflow state. + ("PUT", "requests/I1", {"STATUS_ID": 12}, {U}), + ("PUT", "requests/I1", {"status_guid": "{G}"}, {U}), + ("PUT", "requests/I1", {"SD_CATALOG_ID": 3}, {U}), + ("PUT", "requests/I1", {"initial_sd_catalog_id": 3}, {U}), + ("PUT", "requests/I1", {"catalog_guid": "{C}"}, {U}), + ("PUT", "requests/I1", {"catalog_code": "X"}, {U}), + ("PUT", "requests/I1", {"parent_request_id": 9}, {U}), + # Action columns that end, re-type, re-parent or move an action. + ("PUT", "actions/60350", {"END_DATE_UT": "01/01/2026 10:00:00"}, {U}), + ("PUT", "actions/60350", {"workflow_id": 1}, {U}), + ("PUT", "actions/60350", {"ACTION_TYPE_ID": 20}, {U}), + ("PUT", "actions/60350", {"parent_action_id": 1}, {U}), + ("PUT", "actions/60350", {"request_id": 5}, {U}), + # Create routes: an action born ended, a task tied to a step. + ( + "POST", + "requests/I1/actions", + {"action_type_id": 94, "end_date_ut": "x"}, + {U}, + ), + ( + "POST", + "requests/I1/tasks", + {"action_type_id": 94, "parent_action_id": 1}, + {U}, + ), + # Workflow routes. + ("PUT", "requests/I1/close", {}, {I}), + ("PATCH", "requests/I1/suspend", {}, {U}), + ("PUT", "requests/I1/restart", {}, {U}), + ("PUT", "requests/I1/workflowstart", None, {U}), + ("DELETE", "requests/I1", None, {U}), + ("POST", "requests/without-workflow", {"requests": [{}]}, {U}), + # Two effects at once. + ("PUT", "requests/I1", {"closed": {}, "status_id": 8}, {I, U}), + # PUT actions/{rfc_number} is the vendor's end-action route, so a write + # to an action path that is not an integer id is named whatever it sends. + ("PUT", "actions/S1", {"description": "d"}, {A}), + ("PUT", "actions/S261002_00002", {"description": "d"}, {A}), + ("PUT", "actions/S1", {"workflow_id": 1}, {A, U}), + ("DELETE", "actions/S1", None, {A}), + # An ASCII-digit id is an action id; a non-ASCII digit is not one. + ("PUT", "actions/٣", {"description": "d"}, {A}), + ], +) +def test_names_what_a_write_may_do_to_the_workflow(method, path, body, expected): + assert classify_workflow_effects(method, path, body) == frozenset(expected) + + +@pytest.mark.parametrize( + ("method", "path", "body"), + [ + # What the package's typed writes send, and what itsm_synchronisation sends. + ("POST", "requests", {"requests": [{"catalog_code": "C", "title": "t"}]}), + ( + "PUT", + "requests/I1", + { + "title": "t", + "description": "d", + "impact_id": 3, + "owner_id": 7, + "external_reference": "m", + }, + ), + ("PUT", "actions/60350", {"description": "edited"}), + # An integer id is an action, not the end-action route's RFC number. + ("PUT", "actions/60350", {"description": "d"}), + # A read names nothing, whatever the path; so does the collection. + ("GET", "actions/S1", None), + ("POST", "actions", {"actions": [{}]}), + # Reassignment is supported, so it is not named (design decision e). + ("PUT", "actions/60350", {"GROUP_ID": 57, "DONE_BY_ID": 12}), + ("PUT", "actions/60350", {"group_id": 57}), + ( + "POST", + "requests/I1/actions", + {"action_type_id": 94, "group_id": 3, "parent_action_id": 1}, + ), + ( + "POST", + "requests/I1/tasks", + {"action_type_id": 94, "group_id": 3, "end_date_ut": "x"}, + ), + ("POST", "requests/I1/documents", None), + ("DELETE", "requests/I1/documents/5", None), + ("POST", "groups", {"groups": [{}]}), + # Reads name nothing, whatever the body. + ("GET", "requests/I1", None), + ("HEAD", "requests/I1", None), + ("GET", "requests/I1", {"closed": {}}), + ], +) +def test_names_nothing_for_ordinary_writes_and_reads(method, path, body): + assert classify_workflow_effects(method, path, body) == frozenset() + + +def test_a_method_override_header_is_read_as_the_method(): + effects = classify_workflow_effects( + "GET", "requests/I1", {"closed": {}}, {"X-HTTP-Method-Override": "put"} + ) + assert effects == {I} + + +@pytest.mark.parametrize( + ("method", "path", "body", "headers", "expected"), + [ + # An override that says "read" must not turn a real write into a read. + ("POST", "requests/I1/close", {}, {"X-HTTP-Method-Override": "GET"}, {I}), + ("PUT", "requests/I1", {"closed": {}}, {"X-HTTP-Method": "GET"}, {I}), + ("PUT", "requests/I1", {"closed": {}}, {"x-method-override": " head "}, {I}), + # Two override headers: a read one must not hide a write one, in either order. + ( + "GET", + "requests/I1", + {"closed": {}}, + {"X-HTTP-Method": "GET", "X-HTTP-Method-Override": "PUT"}, + {I}, + ), + ( + "GET", + "requests/I1", + {"closed": {}}, + {"X-HTTP-Method-Override": "PUT", "X-HTTP-Method": "GET"}, + {I}, + ), + # An empty or unrecognised override is not a read. + ("GET", "requests/I1", {"closed": {}}, {"X-HTTP-Method-Override": ""}, {I}), + # DELETE semantics apply if any of the methods is a DELETE. + ("POST", "requests/I1", {}, {"X-HTTP-Method-Override": "DELETE"}, {U}), + ("DELETE", "requests/I1", None, {"X-HTTP-Method-Override": "GET"}, {U}), + # Only when every method is a read is the request a read. + ( + "GET", + "requests/I1", + {"closed": {}}, + {"X-HTTP-Method-Override": "HEAD"}, + set(), + ), + ("GET", "requests/I1", {"closed": {}}, {"Accept": "PUT"}, set()), + ], +) +def test_a_request_is_a_read_only_when_every_method_it_names_is_a_read( + method, path, body, headers, expected +): + effects = classify_workflow_effects(method, path, body, headers) + assert effects == frozenset(expected) + + +def test_query_string_case_and_doubled_slashes_do_not_hide_a_route(): + assert classify_workflow_effects("PUT", "/requests//I1/?x=1", {"status_id": 1}) == { + U + } + assert classify_workflow_effects("PUT", "REQUESTS/I1/CLOSE", {}) == {I} + + +@pytest.mark.parametrize( + "path", + [ + "x/../requests/I1", + "./requests/I1", + "requests/I1/.", + "requests/%2e%2e/I1", + "../50005/requests/I1", + ], +) +def test_a_dot_segment_is_refused_outright(path): + with pytest.raises(ValueError, match="dot segment"): + workflow_triggers("PUT", path, {"title": "t"}) + + +@pytest.mark.parametrize( + "path", + [ + "requests%2FI1%2Fclose", + "requests%2fI1/close", + "requests\\I1\\close", + "requests/I1%5Cclose", + ], +) +def test_an_encoded_slash_or_a_backslash_is_refused_outright(path): + with pytest.raises(ValueError, match="encoded slash or a backslash"): + workflow_triggers("PUT", path, {}) + + +def test_the_end_action_route_is_named_beside_the_body_that_selects_it(): + assert workflow_triggers("PUT", "actions/I1", {"end_action": {"action_id": 1}}) == ( + ("end_action", A), + ("actions/{rfc}", A), + ) + + +def test_triggers_name_the_key_that_matched_envelopes_first(): + assert workflow_triggers("PUT", "requests/I1", {"STATUS_ID": 8, "closed": {}}) == ( + ("closed", I), + ("status_id", U), + ) + + +@pytest.mark.parametrize( + ("allow", "expected"), + [(I, {I}), ((), set()), ([I, A], {I, A}), (frozenset({U}), {U})], +) +def test_as_effects_accepts_a_member_or_an_iterable_of_members(allow, expected): + assert as_effects(allow) == frozenset(expected) + + +@pytest.mark.parametrize( + "allow", + [ + WorkflowEffect, + "interrupts", + b"x", + ["interrupts"], + [I, "advances"], + 1, + None, + {"a": I}, + ], +) +def test_as_effects_refuses_anything_else(allow): + with pytest.raises(TypeError): + as_effects(allow) + + +def test_the_refusal_is_a_value_error_and_not_an_easyvista_error(): + exc = EasyvistaWorkflowEffectRefused( + "no", effects=frozenset({I}), triggers=(("closed", I),) + ) + assert isinstance(exc, ValueError) + assert not isinstance(exc, EasyvistaError) + assert exc.effects == {I} + assert exc.triggers == (("closed", I),) + assert str(exc) == "no" diff --git a/easyvista_python_client/timestamps.py b/easyvista_python_client/timestamps.py index fc5be40..ad259d6 100644 --- a/easyvista_python_client/timestamps.py +++ b/easyvista_python_client/timestamps.py @@ -33,7 +33,7 @@ from __future__ import annotations import re -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any _FRACTION_RE = re.compile(r"\.(\d+)") @@ -48,18 +48,24 @@ def parse_ev_datetime(value: Any) -> datetime | None: """Parse an EasyVista timestamp to a timezone-aware ``datetime``, or ``None``. Accepts a ``datetime`` (returned as-is; a naive one is treated as UTC) or an - ISO-8601 string. Normalizes for Python 3.10's stricter ``fromisoformat``: - maps a trailing ``Z`` to ``+00:00`` and pads/truncates fractional seconds to - 6 digits — EasyVista sends 3, which 3.10 rejects outright. Unparseable input - returns ``None`` rather than raising, so a single malformed column never - fails a whole record. + ISO-8601 string. Before ``fromisoformat`` it maps a trailing ``Z`` or ``z`` + to ``+00:00`` and pads/truncates fractional seconds to 6 digits. Both rules + date from the Python 3.10 floor, whose ``fromisoformat`` rejected + EasyVista's 3-digit fraction outright. From 3.11 ``fromisoformat`` takes a + ``Z`` and any fraction length itself but still refuses a lowercase ``z`` + (measured 2026-10-02 on 3.11.13 and 3.14.6), so the rules are kept and the + set of accepted values did not move when 3.10 was dropped. Unparseable + input returns ``None`` rather than raising, so a single malformed column + never fails a whole record. **A value must start with an extended ISO date** (``YYYY-MM-DD``) or it is - refused, on every interpreter. From 3.11 ``fromisoformat`` also accepts the - ISO *basic* forms — ``"20260817"``, ``"20260817T154041.610"``, week dates - like ``"2026W331"`` — which 3.10 rejects, so without this rule the same wire - value parsed to an instant on four of the five supported Pythons and raised - on the fifth. CI found it precisely that way: 3.10 green, 3.11 and 3.12 red. + refused, on every interpreter. ``fromisoformat`` on every supported Python + (3.11+) also accepts the ISO *basic* forms — ``"20260817"``, + ``"20260817T154041.610"``, week dates like ``"2026W331"`` — so without this + rule each would parse to a plausible instant. The rule predates the 3.11 + floor: 3.10 rejected those forms, so the same wire value then parsed to an + instant on four of the five supported Pythons and raised on the fifth, and + CI found it precisely that way: 3.10 green, 3.11 and 3.12 red. The rule is stated positively because the reject-list version of it was wrong: "digits only" catches ``"20260817"`` and misses both a basic @@ -71,7 +77,7 @@ def parse_ev_datetime(value: Any) -> datetime | None: which is tried after this returns ``None``. """ if isinstance(value, datetime): - return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return value if value.tzinfo else value.replace(tzinfo=UTC) if not isinstance(value, str) or not value.strip(): return None text = value.strip() @@ -92,7 +98,7 @@ def parse_ev_datetime(value: Any) -> datetime | None: parsed = datetime.fromisoformat(text) except ValueError: return None - return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) def format_ev_datetime(value: datetime) -> str: diff --git a/easyvista_python_client/workflow.py b/easyvista_python_client/workflow.py new file mode 100644 index 0000000..b15f398 --- /dev/null +++ b/easyvista_python_client/workflow.py @@ -0,0 +1,337 @@ +"""What a write may do to a ticket's workflow -- decided before it is sent. + +EasyVista drives a ticket's status from its workflow, not from a field: "A +workflow is a process that handles a type of tickets, arranged in a sequence of +actions performed in steps." (https://docs.easyvista.com/docs/workflow.md) and +"Advancing through the steps of a workflow changes the status of a ticket." +(https://docs.easyvista.com/docs/references-tables.md, Statuses section); both +tier 1, read 2026-10-02. Of the REST writes the vendor documents, four touch +the workflow: + +* creating a ticket starts it -- "3. The workflow associated with the ticket + is started." (https://docs.easyvista.com/docs/rest-api-create-an-incident-request.md); +* ``PUT requests/{rfc_number}/workflowstart`` starts the workflow of a ticket + created through the virtual-agent route, which does not start it + (https://docs.easyvista.com/docs/ev-service-manager-rest-api-start-ticket-workflow-via-virtual-agent.md); + this module names it by the sub-route rule below, as ``UNKNOWN``; +* the ``closed`` body on ``PUT requests/{rfc_number}`` interrupts it -- "1. The + workflow of the ticket is interrupted." + (https://docs.easyvista.com/docs/rest-api-close-an-incident-request.md), + whatever status it names; +* the ``end_action`` body on ``PUT actions/{rfc_number}`` ends actions, and + ending a workflow step's action moves the workflow on. The REST page is + silent about the workflow; the support is the UI's Finish wizard ("The + workflow will proceed to the next step." -- + https://docs.easyvista.com/docs/action.md) and one measurement (2026-09-01, + one instance, 2/2, so it may not generalise). + +Everything else is undocumented in workflow terms, which is not the same as +neutral: a per-instance business rule can run "On Insert/On Update" of any +record (https://docs.easyvista.com/docs/business-rule.md). So this module +names what it can, and the transport refuses anything named unless the call +site allowed it explicitly, with ``allow_workflow_effect=``. + +**What gets named.** (1) The vendor's workflow-control bodies -- ``closed``, +``end_action``, ``suspended``, ``restarted`` -- as a top-level body key, in any +casing, on any path. (2) On ticket and action routes, the columns that hold or +select workflow state: a status, a catalog (which selects the workflow), the +workflow, stage and step links, and on an existing action its end date, type, +parent or ticket (creating an action or a task names a narrower set, below). +(3) Ticket sub-routes that are workflow commands rather than records, and +``requests/without-workflow`` and the deletion of a ticket. (4) A write to +``actions/`` where ```` is not an integer id: ``PUT +actions/{rfc_number}`` is the end-action route, so the route is named +(``ADVANCES``) whatever the body says. The column rules in (2) apply to the +``requests/`` and ``actions/`` routes only; a write to any other route family +is not classified by column. Not named: data the workflow merely +reads -- text, owner, group, done-by, impact, urgency. Reassigning an action's +group or person is therefore not refused. + +**This is a deny-list, and a deny-list of columns cannot be complete**: the +vendor's update pages accept "all the fields from the SD_REQUEST table except +those mentioned below" for a ticket +(https://docs.easyvista.com/docs/rest-api-update-an-incident-request.md) and +"all the fields from the AM_ACTION table except those mentioned below" for an +action (https://docs.easyvista.com/docs/rest-api-update-an-action.md), each +followed by a list of exclusions (tier 1, read 2026-10-02). What is not +named here is unclassified, not proven neutral. +""" + +from __future__ import annotations + +import enum +from collections.abc import Iterable, Mapping +from typing import Any +from urllib.parse import unquote + + +class WorkflowEffect(enum.Enum): + """What a write may do to a ticket's workflow. + + ``INTERRUPTS``: the vendor documents the write as stopping the workflow + (the ``closed`` body). ``ADVANCES``: the write ends actions, and ending a + workflow step moves the workflow on (the ``end_action`` body). + ``UNKNOWN``: the write touches workflow state, or a route that does, and + nothing documents or measures what follows. + + Passed to ``allow_workflow_effect=`` as one member or an iterable of + members. Deliberately not a ``str`` enum, so ``"interrupts"`` is refused + rather than matched. + """ + + INTERRUPTS = "interrupts" + ADVANCES = "advances" + UNKNOWN = "unknown" + + +#: The vendor's workflow-control bodies, matched as a top-level key on ANY path. +_ENVELOPES: Mapping[str, WorkflowEffect] = { + "closed": WorkflowEffect.INTERRUPTS, + "end_action": WorkflowEffect.ADVANCES, + "suspended": WorkflowEffect.UNKNOWN, + "restarted": WorkflowEffect.UNKNOWN, +} + +#: Columns on ``requests/{rfc}`` that hold or select workflow state. The vendor +#: excludes ``status_id``, ``sd_catalog_id``, ``initial_sd_catalog_id`` and +#: ``parent_request_id`` from the update body outright +#: (https://docs.easyvista.com/docs/rest-api-update-an-incident-request.md, +#: tier 1, read 2026-10-02). The vendor documents requalifying the ticket's +#: category as starting a new workflow: "Requalify the category of the object. +#: A new workflow will then start." (https://docs.easyvista.com/docs/action.md, +#: tier 1, read 2026-10-02). Reading the catalog columns as that category is +#: this module's inference, not a measurement. +_REQUEST_COLUMNS = frozenset( + { + "status_id", + "status_guid", + "sd_catalog_id", + "initial_sd_catalog_id", + "catalog_guid", + "catalog_code", + "parent_request_id", + } +) + +#: Columns on ``actions/{id}`` that end, re-type, re-parent or move an action. +_ACTION_COLUMNS = frozenset( + { + "end_date_ut", + "end_date", + "status_id_on_terminate", + "workflow_id", + "stage_id", + "process_step_id", + "parent_action_id", + "action_type_id", + "action_type_guid", + "action_type_name", + "request_id", + "rfc_number", + } +) + +#: Columns that would create an action already ended or tied into a step. +_CREATE_ACTION_COLUMNS = frozenset( + { + "end_date_ut", + "end_date", + "status_id_on_terminate", + "workflow_id", + "stage_id", + "process_step_id", + } +) + +#: The same for a task, which is born ended: its end date is ordinary, a +#: parent is not. +_CREATE_TASK_COLUMNS = frozenset( + { + "status_id_on_terminate", + "workflow_id", + "stage_id", + "process_step_id", + "parent_action_id", + } +) + +#: Ticket sub-resources whose writes create or delete records. Any other +#: ``requests/{rfc}/`` write -- ``close``, ``suspend``, ``restart``, +#: ``workflowstart``, or whatever a deployment adds -- is a command and is named. +_RECORD_SUBRESOURCES = frozenset({"actions", "tasks", "documents"}) + +_READ_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + +_METHOD_OVERRIDE_HEADERS = frozenset( + {"x-http-method-override", "x-http-method", "x-method-override"} +) + + +def as_effects( + allow: WorkflowEffect | Iterable[WorkflowEffect], +) -> frozenset[WorkflowEffect]: + """Normalise an ``allow_workflow_effect=`` argument to a frozenset of members. + + Accepts one :class:`WorkflowEffect` or an iterable of them; ``()`` allows + nothing. Anything else raises ``TypeError``: a string, because + ``"interrupts"`` is not a member and iterating it yields letters; and the + enum **class** itself, which is iterable and would allow every effect from a + one-token typo for ``WorkflowEffect.INTERRUPTS``. + """ + if isinstance(allow, WorkflowEffect): + return frozenset({allow}) + if isinstance(allow, (str, bytes, type, Mapping)) or not isinstance( + allow, Iterable + ): + raise TypeError( + "allow_workflow_effect takes a WorkflowEffect member or an iterable " + f"of members, not {allow!r}" + ) + effects = frozenset(allow) + strays = sorted( + repr(item) for item in effects if not isinstance(item, WorkflowEffect) + ) + if strays: + raise TypeError( + "allow_workflow_effect takes WorkflowEffect members only; got " + + ", ".join(strays) + ) + return effects + + +def _segments(path: str) -> list[str]: + """``path``'s non-empty segments, percent-decoded and case-folded. + + Refuses a ``.`` or ``..`` segment: httpx removes dot segments from the URL + it sends, so ``x/../requests/I1`` reaches ``requests/I1`` -- a route this + check would otherwise not have read. No API route needs one. + + Also refuses a backslash anywhere in the path, and a segment whose + percent-decoded form contains ``/`` or ``\\`` (``requests%2FI1%2Fclose``, + ``requests/I1%5Cclose``): splitting on ``/`` before decoding would read + either as one opaque segment and miss the route, yet a server may read it as + a separator. This fails closed -- whether the server decodes ``%2F`` or + treats ``\\`` as a separator is not measured, and no API route needs either. + """ + bare = path.split("?", 1)[0].split("#", 1)[0] + decoded = [unquote(part) for part in bare.split("/") if part] + if "\\" in bare or any("/" in part or "\\" in part for part in decoded): + raise ValueError( + f"refusing path {path!r}: it contains an encoded slash or a " + "backslash, which a server may read as a path separator, so the " + "request could reach a different route from the one this check read" + ) + segments = [part.casefold() for part in decoded] + if any(part in {".", ".."} for part in segments): + raise ValueError( + f"refusing path {path!r}: it contains a dot segment, which the HTTP " + "client collapses, so the request would reach a different route " + "from the one written" + ) + return segments + + +def _methods(method: str, headers: Mapping[str, str] | None) -> set[str]: + """The real method and every method-override header value, upper-cased. + + A server that honours an override header runs the override, not the real + method, and a request may carry several such headers, so any one of them + could be the one that is honoured. The request is therefore judged by all + of them: it is a read only if every one of them is a read. + """ + methods = {method.strip().upper()} + for name, value in (headers or {}).items(): + if name.casefold() in _METHOD_OVERRIDE_HEADERS: + methods.add(str(value).strip().upper()) + return methods + + +def _body_keys(body: Any) -> set[str]: + # httpx serialises a tuple as a JSON array, so it is scanned like a list. + records = ( + [body] + if isinstance(body, Mapping) + else [item for item in body if isinstance(item, Mapping)] + if isinstance(body, (list, tuple)) + else [] + ) + return {str(key).casefold() for record in records for key in record} + + +def workflow_triggers( + method: str, + path: str, + body: Any = None, + headers: Mapping[str, str] | None = None, +) -> tuple[tuple[str, WorkflowEffect], ...]: + """Every ``(what, effect)`` this request names, envelopes first. + + ``what`` is the case-folded body key, or the route, that matched. Empty for + a read and for an ordinary write. + + A request is a read only when the real ``method`` **and** the value of every + method-override header in ``headers`` (``X-HTTP-Method-Override``, + ``X-HTTP-Method``, ``X-Method-Override``, matched case-insensitively) are + all reads (``GET``, ``HEAD``, ``OPTIONS``). Otherwise it is classified as a + write, so an override that says ``GET`` cannot hide a write, and a ``DELETE`` + among them applies the ``DELETE`` rule. A body given as a mapping, or as a + list or tuple of mappings, is scanned for its top-level keys. + + Raises ``ValueError``, whatever the method, for a path that contains a dot + segment (``.`` or ``..``), a percent-encoded slash or backslash, or a raw + backslash. The first is collapsed by the HTTP client and the others may be + read by a server as a separator, so each could reach a route other than the + one this function read. + """ + segments = _segments(path) + methods = _methods(method, headers) + if methods <= _READ_METHODS: + return () + keys = _body_keys(body) + found: list[tuple[str, WorkflowEffect]] = [ + (key, _ENVELOPES[key]) for key in sorted(keys) if key in _ENVELOPES + ] + + def columns(names: frozenset[str]) -> None: + found.extend((key, WorkflowEffect.UNKNOWN) for key in sorted(keys & names)) + + head = segments[0] if segments else "" + if head == "requests" and len(segments) == 2: + if segments[1] == "without-workflow": + found.append(("requests/without-workflow", WorkflowEffect.UNKNOWN)) + else: + if "DELETE" in methods: + found.append(("DELETE requests/{rfc}", WorkflowEffect.UNKNOWN)) + columns(_REQUEST_COLUMNS) + elif head == "requests" and len(segments) >= 3: + sub = segments[2] + if sub == "actions": + columns(_CREATE_ACTION_COLUMNS) + elif sub == "tasks": + columns(_CREATE_TASK_COLUMNS) + elif sub not in _RECORD_SUBRESOURCES: + effect = ( + WorkflowEffect.INTERRUPTS if sub == "close" else WorkflowEffect.UNKNOWN + ) + found.append((f"requests/{{rfc}}/{sub}", effect)) + elif head == "actions" and len(segments) == 2: + columns(_ACTION_COLUMNS) + # ``PUT actions/{rfc_number}`` is the vendor's end-action route, and the + # same path shape as an action edit; only the segment tells them apart. + # An integer id addresses an action. Anything else is the ticket's RFC + # number, which selects the end-action route whatever the body names. + if not (segments[1].isascii() and segments[1].isdigit()): + found.append(("actions/{rfc}", WorkflowEffect.ADVANCES)) + return tuple(found) + + +def classify_workflow_effects( + method: str, + path: str, + body: Any = None, + headers: Mapping[str, str] | None = None, +) -> frozenset[WorkflowEffect]: + """The set of effects :func:`workflow_triggers` names for this request.""" + return frozenset( + effect for _, effect in workflow_triggers(method, path, body, headers) + ) diff --git a/integration_tests/conftest.py b/integration_tests/conftest.py index 3895a9c..e66297d 100644 --- a/integration_tests/conftest.py +++ b/integration_tests/conftest.py @@ -7,8 +7,8 @@ and no ``EASYVISTA_TEST_*`` environment simply skips the suite rather than failing it. -They are not read-only. A full run creates and closes **21 tickets** (one shared -``rich_ticket``, two ``probe_tickets``, and 18 from ``ticket_factory``), plus 8 +They are not read-only. A full run creates and closes **20 tickets** (one shared +``rich_ticket``, two ``probe_tickets``, and 17 from ``ticket_factory``), plus 8 actions, 5 document uploads and **6 to 14 ticket updates** (4 fixed PUTs -- title, rename, description, external reference -- plus the ``IMPACT_ID`` / ``OWNER_ID`` read-back in the ticket-identity test, which tries up to 5 @@ -16,7 +16,10 @@ of which it may reject outright); ``test_live_smoke`` additionally issues one create the server is *expected to reject*, so no ticket persists from it. Every created ticket is registered for cleanup before it is asserted on, and closed -in teardown. Point them at a preprod/test instance, never production. +in teardown. The opt-in workflow census in ``test_live_workflow_guard.py`` adds +up to 3 tickets and reassigns workflow steps, which may notify the target group +or person; it runs only when ``EASYVISTA_TEST_RUN_WORKFLOW_CENSUS=1``. Point +them at a preprod/test instance, never production. Credentials resolve from an uppercase env var first, then a lowercase file under ``secrets/``: @@ -87,6 +90,7 @@ EasyvistaRateLimitError, EasyvistaServerError, PostRequest, + WorkflowEffect, ev_equals_filter, is_safe_ev_value, ) @@ -304,8 +308,10 @@ def live_write_client(live_config: EasyvistaConfig) -> Iterator[EasyvistaClient] tell a safe GET from a ``create_action``. Rather than weaken the retry that makes reads trustworthy, the non-idempotent verbs get their own client with retries off: ``create_ticket``, ``create_action`` and ``add_document``. - ``update_ticket`` (fixed-value PUTs) and ``close_ticket`` are idempotent and - stay on ``live_client``. + ``update_ticket`` (fixed-value PUTs) stays on ``live_client``; + ``close_ticket`` is not idempotent in effect (each call inserts an + anticipated closing action), but the transport sends an allowed close once + on any client. ``replace`` on a frozen dataclass re-runs ``__post_init__``, which is required because ``_server_normalized`` is ``field(init=False)``. @@ -588,12 +594,15 @@ def _close_tracked( Error records carry the exception's TYPE and status code, never the exception object: ``str(exc)`` is the transport's message, which interpolates server prose this suite did not author (P2). + + Teardown interrupts each ticket's workflow on purpose -- that is what closing is. """ errors: list[tuple[str, str, int | None]] = [] for rfc in tracked: try: client.close_ticket( rfc, + allow_workflow_effect=WorkflowEffect.INTERRUPTS, status_guid=cfg["status_guid"], delete_actions=1, comment=reason, diff --git a/integration_tests/test_fixture_helpers.py b/integration_tests/test_fixture_helpers.py index 8129657..4a27e58 100644 --- a/integration_tests/test_fixture_helpers.py +++ b/integration_tests/test_fixture_helpers.py @@ -508,7 +508,9 @@ def __init__(self, failing: set[str] | None = None) -> None: self.closed: list[str] = [] self._failing = failing or set() - def close_ticket(self, rfc, *, status_guid, delete_actions, comment): + def close_ticket( + self, rfc, *, allow_workflow_effect, status_guid, delete_actions, comment + ): if rfc in self._failing: raise EasyvistaConnectionError("connection failed") self.closed.append(rfc) diff --git a/integration_tests/test_live_change_window.py b/integration_tests/test_live_change_window.py index 9b3634a..4a91c4f 100644 --- a/integration_tests/test_live_change_window.py +++ b/integration_tests/test_live_change_window.py @@ -16,7 +16,7 @@ from __future__ import annotations import uuid -from datetime import timedelta, timezone +from datetime import UTC, timedelta from itertools import pairwise import pytest @@ -299,8 +299,8 @@ def test_only_some_timestamp_renderings_are_accepted_as_an_interval_bound( later = parse_ev_datetime(late) assert moment is not None, "split_instants did not yield a parseable literal" assert later is not None, "split_instants did not yield a parseable literal" - as_utc = moment.astimezone(timezone.utc) - later_utc = later.astimezone(timezone.utc) + as_utc = moment.astimezone(UTC) + later_utc = later.astimezone(UTC) # For the date-only rendering the second bound is the day AFTER the late # instant, not its own day: on an instance whose sampled stamps all fall on # one day the two dates would otherwise be equal and the differential empty. diff --git a/integration_tests/test_live_instance_discovery.py b/integration_tests/test_live_instance_discovery.py index 99db717..b07e727 100644 --- a/integration_tests/test_live_instance_discovery.py +++ b/integration_tests/test_live_instance_discovery.py @@ -54,8 +54,8 @@ def test_describe_instance_profiles_the_live_deployment( "no statuses discovered; check profile.unavailable['STATUS'] -- a " "denial and an empty table are different things" ) - # The GUID is the value set_status and close_ticket actually address a - # status by, and it is only ever readable off a sampled ticket. + # The GUID is the value close_ticket actually addresses a status by, and + # it is only ever readable off a sampled ticket. assert any(s.guid for s in statuses), ( "no discovered status carried a STATUS_GUID; the sample reached no " "ticket, or the nested STATUS object stopped carrying one" diff --git a/integration_tests/test_live_smoke.py b/integration_tests/test_live_smoke.py index 09506b4..4970e7b 100644 --- a/integration_tests/test_live_smoke.py +++ b/integration_tests/test_live_smoke.py @@ -4,18 +4,19 @@ env vars or ``secrets/easyvista_test_*`` files. Never runs in CI (which runs ``pytest -m "not integration"``). NEVER point at production. -This module WRITES. It creates up to three tickets and closes every one: +This module WRITES. It creates up to two tickets and closes every one: * one under-specified create the server is expected to reject -- which still creates the row (measured: 9 of 9 rejected creates left one), so it is reconciled by its ``external_reference`` marker and closed. An earlier version of this file claimed "no ticket persists from this module ... read-only-safe by construction"; that was wrong and leaked one ticket per live run; -* one create with the full documented body, to prove the ids land; -* one from ``ticket_factory`` for the ``set_status`` check. +* one create with the full documented body, to prove the ids land. -The ticket-creating fixture lives in ``conftest.py`` and is also used by -``test_live_search_syntax``. +This module creates and closes its own tickets, by marker. The shared +ticket-creating fixtures (``rich_ticket``, ``probe_tickets``, +``ticket_factory``) live in ``conftest.py`` and serve the other live modules, +``test_live_search_syntax`` among them. Every assertion here is by shape, and every one routes through ``_assertions`` or a pre-bound local (design principle P2). pytest's assertion rewriter reports @@ -44,6 +45,7 @@ EasyvistaValidationError, PostRequest, Request, + WorkflowEffect, ev_equals_filter, ) from integration_tests._assertions import assert_shape @@ -206,42 +208,6 @@ def test_the_documented_create_body_lands_every_id( _close_by_marker(live_client, live_write_config, marker) -def test_set_status_reaches_a_non_terminal_status( - live_client: EasyvistaClient, - live_write_client: EasyvistaClient, - live_write_config: dict[str, str], - ticket_factory, -) -> None: - """``set_status`` sets an arbitrary status, not only a closing one. - - The API has no flat status update -- ``RequestUpdate`` carries no - ``status_id`` for that reason -- and the ``{"closed": {"status_GUID": ...}}`` - envelope is the only route. Its wire name suggests it only closes; measured, - it reaches every status tried. - - This pins the non-terminal case specifically, because that is the surprising - half and the half a future reader is most likely to "simplify" away. The GUID - is read off the instance rather than hardcoded: status GUIDs are per-instance - configuration, so a literal here would be a value this repo must not carry - and would be wrong on any other deployment anyway. - """ - rfc = ticket_factory() - before = live_client.get_ticket(rfc).status_id - target_guid, target_id = _a_different_status(live_client, exclude=before) - if target_guid is None: - pytest.skip("no second status with a readable GUID on this instance") - - live_write_client.set_status( - rfc, status_guid=target_guid, comment="capability-suite status probe" - ) - after = live_client.get_ticket(rfc).status_id - # Bound as bools: the ids are instance configuration, not suite-authored (P2). - moved = str(after) != str(before) - landed_on_target = str(after) == str(target_id) - assert moved, "set_status did not change the ticket's status" - assert landed_on_target, "set_status landed on a status other than the one asked" - - def _close_by_marker(client: EasyvistaClient, cfg: dict[str, str], marker: str) -> None: """Close every ticket carrying ``marker``, however it got there. @@ -268,35 +234,10 @@ def _close_by_marker(client: EasyvistaClient, cfg: dict[str, str], marker: str) continue try: client.close_ticket( - rfc, status_guid=cfg["status_guid"], comment="smoke cleanup" + rfc, + allow_workflow_effect=WorkflowEffect.INTERRUPTS, + status_guid=cfg["status_guid"], + comment="smoke cleanup", ) except EasyvistaError: continue - - -def _a_different_status( - client: EasyvistaClient, *, exclude: object -) -> tuple[str | None, str | None]: - """Return ``(status_guid, status_id)`` for some status that is not ``exclude``. - - Read off the instance because status GUIDs are per-instance configuration: a - literal would be a value this repo must not carry, and would be wrong on any - other deployment. Found by sampling tickets and taking the first whose status - differs -- the nested ``STATUS`` object carries both the id and the GUID, - but only on an UNPROJECTED read, so no ``fields`` is passed here. - """ - try: - sampled = client.search_tickets(sort="LAST_UPDATE DESC", max_rows=60) - except EasyvistaError: - return None, None - for record in sampled.records: - status = record.model_extra.get("STATUS") if record.model_extra else None - if not isinstance(status, dict): - continue - sid = status.get("STATUS_ID") - guid = status.get("STATUS_GUID") - if sid is None or not guid: - continue - if str(sid) != str(exclude): - return str(guid), str(sid) - return None, None diff --git a/integration_tests/test_live_workflow_guard.py b/integration_tests/test_live_workflow_guard.py new file mode 100644 index 0000000..a413642 --- /dev/null +++ b/integration_tests/test_live_workflow_guard.py @@ -0,0 +1,506 @@ +"""Live checks behind the workflow guard. + +``end_action`` refuses to end a workflow step unless the caller allows +``WorkflowEffect.ADVANCES``, and it tells a step from the caller's own action +with one projected item read: ``WORKFLOW_ID`` set means a step. That only works +if the read names the column on BOTH kinds of action -- if it omits the key on +a caller's action, the guard (which fails closed) would refuse every end. + +The first two tests here read only. Tests below the census marker WRITE: each +takes the ``census_opt_in`` fixture first, so they run only when +``EASYVISTA_TEST_RUN_WORKFLOW_CENSUS=1`` is set in the environment, which is +how the user's explicit approval is given. Without it they skip before any +ticket is created. +""" + +from __future__ import annotations + +import os +import time +from collections.abc import Callable +from pathlib import Path +from typing import NamedTuple + +import pytest + +from easyvista_python_client import Action, ActionUpdate, EasyvistaClient, RequestUpdate + +#: Recent tickets scanned for one action of each kind; bounded, so a quiet +#: instance skips instead of sweeping the whole table. +_TICKETS_TO_SCAN = 15 +_LIST_PROJECTION = ["ACTION_ID", "ACTION_TYPE_ID", "WORKFLOW_ID", "END_DATE_UT"] +_PROBE_FIELDS = ["ACTION_ID", "WORKFLOW_ID"] + + +def _one_of_each(client: EasyvistaClient) -> tuple[int, int]: + """Return (a workflow-step action id, a non-workflow action id).""" + step: int | None = None + other: int | None = None + # The default 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 the scan reads the newest tickets instead. + for ticket in client.iter_tickets( + fields=["RFC_NUMBER"], sort="REQUEST_ID DESC", max_records=_TICKETS_TO_SCAN + ): + if not ticket.rfc_number: + continue + for action in client.iter_actions( + ticket.rfc_number, fields=_LIST_PROJECTION, max_records=200 + ): + if action.action_id is None: + continue + if action.workflow_id is not None: + step = step or action.action_id + else: + other = other or action.action_id + if step is not None and other is not None: + return step, other + pytest.skip( + f"no ticket among the {_TICKETS_TO_SCAN} scanned carries both a workflow " + "step and a non-workflow action" + ) + + +def _probe(client: EasyvistaClient, action_id: int): + # The same projection end_action's guard asks for, through the public read. + return client.get_action(action_id, params={"fields": ",".join(_PROBE_FIELDS)}) + + +def test_the_projected_item_read_names_workflow_id_on_both_kinds_of_action( + live_client: EasyvistaClient, +) -> None: + step, other = _one_of_each(live_client) + step_row = _probe(live_client, step) + other_row = _probe(live_client, other) + # Through _require, never a bare assert: on failure the rewriter would print + # the whole Action, href host included (P2). + _require( + "workflow_id" in step_row.model_fields_set, + "the projected item read omits WORKFLOW_ID on a workflow step", + ) + _require(step_row.workflow_id is not None, "a workflow step has no WORKFLOW_ID") + _require( + "workflow_id" in other_row.model_fields_set, + "the projected item read omits WORKFLOW_ID on a non-workflow action: " + "end_action's pre-flight would refuse every caller action", + ) + _require(other_row.workflow_id is None, "a non-workflow action has a WORKFLOW_ID") + + +def test_the_plain_item_read_names_workflow_id_on_both_kinds_of_action( + live_client: EasyvistaClient, +) -> None: + """Recorded for comparison; the guard uses the projected read.""" + step, other = _one_of_each(live_client) + step_row = live_client.get_action(step) + other_row = live_client.get_action(other) + _require( + "workflow_id" in step_row.model_fields_set, + "the plain item read omits WORKFLOW_ID on a workflow step", + ) + _require( + "workflow_id" in other_row.model_fields_set, + "the plain item read omits WORKFLOW_ID on a non-workflow action", + ) + + +# --- census: WRITES, run only with the user's explicit approval ------------ + +_OPT_IN_VARIABLE = "EASYVISTA_TEST_RUN_WORKFLOW_CENSUS" +_SECRETS_DIR = Path(__file__).resolve().parents[1] / "secrets" + +#: Seconds between the immediate after-read and the settled one. The assertions +#: run on the settled read; both are printed, so a write that lands late, or +#: reverts, shows up in the output instead of reading as a clean pass. +_SETTLE_SECONDS = 5 + + +@pytest.fixture(scope="session") +def census_opt_in() -> None: + """Skip the census unless the environment says the user approved the writes. + + Session-scoped and listed first by every census test, so it is evaluated + before any other fixture and before a ticket can be created. + """ + if os.environ.get(_OPT_IN_VARIABLE) != "1": + pytest.skip( + "the workflow census WRITES to the live instance (creates tickets, " + f"reassigns a workflow step); set {_OPT_IN_VARIABLE}=1 to run it" + ) + + +def _resolve_local(env_names: tuple[str, ...], filename: str) -> str | None: + """Env var first, then ``secrets/``; ``None`` when neither is set.""" + for name in env_names: + value = os.environ.get(name) + if value and value.strip(): + return value.strip() + path = _SECRETS_DIR / filename + if path.is_file(): + text = path.read_text(encoding="utf-8").strip() + if text: + return text + return None + + +# Module-local on purpose: this file must also run from a checkout whose +# conftest.py lacks this fixture. +@pytest.fixture(scope="session") +def live_reassign_config() -> dict[str, str]: + """The group (and optionally the person) a workflow step is reassigned to. + + Separate from every other write config so that an instance without one + skips only the reassignment census. The group must differ from the one a + fresh ticket's workflow step is assigned to, and reassigning to the group + or to the person may notify them. + """ + group = _resolve_local( + ("EASYVISTA_TEST_REASSIGN_GROUP_ID",), "easyvista_test_reassign_group_id" + ) + if not group: + pytest.skip( + "the reassignment census needs EASYVISTA_TEST_REASSIGN_GROUP_ID " + "(or secrets/easyvista_test_reassign_group_id)" + ) + resolved = {"group_id": group} + person = _resolve_local( + ("EASYVISTA_TEST_REASSIGN_DONE_BY_ID",), "easyvista_test_reassign_done_by_id" + ) + if person: + resolved["done_by_id"] = person + return resolved + + +_CENSUS_PROJECTION = [ + "ACTION_ID", + "ACTION_TYPE_ID", + "WORKFLOW_ID", + "END_DATE_UT", + "GROUP_ID", + "DONE_BY_ID", + "REQUEST_ID", +] + +#: The item read the before and after states both go through, so a before/after +#: difference can never come from comparing two different read paths. +_ITEM_FIELDS = "ACTION_ID,REQUEST_ID,GROUP_ID,DONE_BY_ID,END_DATE_UT,WORKFLOW_ID" + + +def _require(condition: object, label: str) -> None: + """Assert ``condition``; the failure text is ``label`` and nothing else. + + The caller evaluates the condition as an argument, and it is bound to a + plain local here before the assert, so pytest's assertion rewriter has no + operand to render. An ``assert action.group_id == target`` would print the + whole ``Action`` -- hrefs and labels -- on failure (P2; see ``_assertions.py``). + """ + __tracebackhide__ = True + ok = bool(condition) + assert ok, label + + +def _target(config: dict[str, str], key: str) -> int: + """The configured id as a positive int; fails with a label, never the value. + + Called before ``ticket_factory()`` so a misconfiguration creates no ticket. + """ + try: + value = int(config[key]) + except ValueError: + value = 0 + if value <= 0: + pytest.fail(f"the configured {key} must be a positive integer", pytrace=False) + return value + + +class _Snapshot(NamedTuple): + """Everything the census compares, read in one pass.""" + + request_id: int | None + status_id: int | None + ticket_end: object + ticket_end_named: bool + open_ids: frozenset[int] + row_ids: frozenset[int] + step: Action + + +def _actions(client: EasyvistaClient, rfc: str) -> list[Action]: + rows = list(client.iter_actions(rfc, fields=_CENSUS_PROJECTION, max_records=500)) + for row in rows: + _require( + "end_date_ut" in row.model_fields_set, + "END_DATE_UT is not named by the projected list read", + ) + _require( + isinstance(row.action_id, int), + "a row of the projected list read carries no ACTION_ID", + ) + return rows + + +def _the_open_step(client: EasyvistaClient, rfc: str) -> Action: + steps = [ + row + for row in _actions(client, rfc) + if row.end_date_ut is None and row.workflow_id is not None + ] + _require( + len(steps) == 1, "the ticket does not carry exactly one open workflow step" + ) + step = steps[0] + _require( + isinstance(step.action_id, int) and step.action_id > 0, + "the open workflow step carries no usable ACTION_ID", + ) + return step + + +def _read(client: EasyvistaClient, action_id: int) -> Action: + return client.get_action(action_id, params={"fields": _ITEM_FIELDS}) + + +def _snapshot(client: EasyvistaClient, rfc: str, step_id: int) -> _Snapshot: + ticket = client.get_ticket(rfc) + rows = _actions(client, rfc) + return _Snapshot( + request_id=ticket.request_id, + status_id=ticket.status_id, + ticket_end=ticket.end_date_ut, + ticket_end_named="end_date_ut" in ticket.model_fields_set, + open_ids=frozenset( + row.action_id + for row in rows + if row.end_date_ut is None and row.action_id is not None + ), + row_ids=frozenset(row.action_id for row in rows if row.action_id is not None), + step=_read(client, step_id), + ) + + +def _describe(before: _Snapshot, after: _Snapshot, column: str | None) -> str: + stored = f"stored={getattr(after.step, column)} " if column else "" + return ( + f"{stored}step_open={after.step.end_date_ut is None} " + f"status {before.status_id}->{after.status_id} " + f"ticket_end_date_ut {before.ticket_end}->{after.ticket_end} " + f"open {sorted(before.open_ids)}->{sorted(after.open_ids)} " + f"new_rows={sorted(after.row_ids - before.row_ids)}" + ) + + +def _check_before(step: Action, before: _Snapshot) -> None: + """Preconditions that make a later "unchanged" verdict mean something. + + Run before the write, so a vacuous read (a column that is not named, a step + that belongs to another ticket) stops the census with nothing sent. + """ + _require(before.request_id is not None, "the fresh ticket carries no REQUEST_ID") + _require(step.request_id is not None, "the listed step carries no REQUEST_ID") + _require( + step.request_id == before.request_id, + "the listed step does not belong to the fresh ticket", + ) + _require( + before.step.request_id == before.request_id, + "the step's item read does not belong to the fresh ticket", + ) + _require(before.status_id is not None, "the ticket's STATUS_ID reads empty") + _require( + before.ticket_end_named, + "END_DATE_UT is not named by the ticket read, so 'unchanged' is vacuous", + ) + _require( + "end_date_ut" in before.step.model_fields_set, + "END_DATE_UT is not named by the projected item read", + ) + _require( + before.step.end_date_ut is None, "the step is already ended before the write" + ) + _require(before.step.workflow_id is not None, "the step reads as no workflow step") + + +def _check_unchanged(before: _Snapshot, after: _Snapshot) -> None: + """The workflow-neutral half of the verdict, on the settled read.""" + _require( + "end_date_ut" in after.step.model_fields_set, + "END_DATE_UT is not named by the settled item read", + ) + _require(after.step.end_date_ut is None, "the write ENDED the workflow step") + _require( + after.open_ids == before.open_ids, + "the write changed the ticket's open actions", + ) + _require(after.status_id == before.status_id, "the write moved the ticket's status") + _require( + after.ticket_end == before.ticket_end, + "the write changed the ticket's END_DATE_UT", + ) + + +def _write_and_observe( + client: EasyvistaClient, + rfc: str, + step_id: int, + label: str, + before: _Snapshot, + send: Callable[[], object], + column: str | None = None, +) -> _Snapshot: + """Send one write, then read the state twice; a failed write still reads. + + A raised write is reduced to its type name and status code, which are + printed; the exception itself is dropped, because its message is server + prose this suite keeps out of test output (P2). The reads are still taken + and printed, and then the test fails with a label-only message. + Returns the settled snapshot. + """ + failure: str | None = None + try: + send() + except Exception as exc: + failure = ( + f"census write failed: {type(exc).__name__} " + f"status_code={getattr(exc, 'status_code', None)}" + ) + print(f"CENSUS {rfc} {label}: {failure}") + immediate = _snapshot(client, rfc, step_id) + print(f"CENSUS {rfc} {label} immediate: {_describe(before, immediate, column)}") + time.sleep(_SETTLE_SECONDS) + settled = _snapshot(client, rfc, step_id) + print( + f"CENSUS {rfc} {label} +{_SETTLE_SECONDS}s: " + f"{_describe(before, settled, column)}" + ) + if failure is not None: + pytest.fail(failure, pytrace=False) + return settled + + +def _census( + client: EasyvistaClient, + write_client: EasyvistaClient, + rfc: str, + step: Action, + column: str, + target: int, + *, + require_current: bool, +) -> None: + """Write ``target`` into ``column`` of the step and record what moved. + + The lower-case body key is sent first, as ``PostAction``'s verified create + body spells it. If the column did not store, the upper-case key goes to the + SAME step, so the key spelling is settled without a second ticket; both + outcomes are printed and the test passes if either stored. + + ``require_current`` is for a column a workflow step is born with (the + group). ``DONE_BY_ID`` is documented empty on a generated step, so there an + empty before-value is the expected shape; the column must still be NAMED by + the read, which is what tells empty from unprojected. + """ + step_id = step.action_id # a positive int: _the_open_step refuses anything else + before = _snapshot(client, rfc, step_id) + _check_before(step, before) + name = column.upper() + _require( + column in before.step.model_fields_set, + f"{name} is not named by the projected item read", + ) + current = getattr(before.step, column) + if require_current: + _require( + current is not None, f"{name} reads empty on the step before the write" + ) + _require(current != target, f"{name} already equals the target before the write") + print(f"CENSUS {rfc}: before {column}={current} target={target}") + + outcomes: dict[str, bool] = {} + for key in (column, name): + body = {key: target} + settled = _write_and_observe( + client, + rfc, + step_id, + f"key={key}", + before, + lambda body=body: write_client.update_action( + step_id, ActionUpdate(extra_payload=body) + ), + column, + ) + _check_unchanged(before, settled) + outcomes[key] = getattr(settled.step, column) == target + if outcomes[key]: + break + print(f"CENSUS {rfc}: stored by key spelling {outcomes}") + _require( + any(outcomes.values()), + f"neither {column!r} nor {name!r} was stored -- a 200 is not a receipt", + ) + + +def test_reassigning_the_workflow_step_to_a_group_keeps_it_open( + census_opt_in, + live_client, + live_write_client, + ticket_factory, + live_reassign_config, +) -> None: + target = _target(live_reassign_config, "group_id") + rfc = ticket_factory() + step = _the_open_step(live_client, rfc) + _census( + live_client, + live_write_client, + rfc, + step, + "group_id", + target, + require_current=True, + ) + + +def test_reassigning_the_workflow_step_to_a_person_keeps_it_open( + census_opt_in, + live_client, + live_write_client, + ticket_factory, + live_reassign_config, +) -> None: + if "done_by_id" not in live_reassign_config: + pytest.skip("EASYVISTA_TEST_REASSIGN_DONE_BY_ID not configured") + target = _target(live_reassign_config, "done_by_id") + rfc = ticket_factory() + step = _the_open_step(live_client, rfc) + _census( + live_client, + live_write_client, + rfc, + step, + "done_by_id", + target, + require_current=False, + ) + + +def test_the_ticket_writes_the_sync_makes_keep_the_workflow_step_open( + census_opt_in, live_client, live_write_client, ticket_factory +) -> None: + """Title is what the sync writes each sweep; only description was censused.""" + rfc = ticket_factory() + step = _the_open_step(live_client, rfc) + step_id = step.action_id # a positive int: _the_open_step refuses anything else + before = _snapshot(live_client, rfc, step_id) + _check_before(step, before) + settled = _write_and_observe( + live_client, + rfc, + step_id, + "title", + before, + lambda: live_write_client.update_ticket( + rfc, RequestUpdate(title=f"{rfc} census title") + ), + ) + _check_unchanged(before, settled) diff --git a/pyproject.toml b/pyproject.toml index 0cf2ea9..fdb23b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,10 +44,10 @@ exclude = [ [project] name = "easyvista-python-client" -version = "0.3.0" +version = "0.4.0" description = "Typed Python client for the EasyVista Service Manager REST API" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "easyvista-python-client contributors" }] @@ -57,7 +57,6 @@ classifiers = [ "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -70,7 +69,6 @@ dependencies = [ "httpx>=0.27", "pydantic>=2.8", "tenacity>=8.2", - "typing-extensions>=4.7; python_version < '3.11'", ] [project.urls] @@ -80,7 +78,39 @@ Issues = "https://github.com/baraline/easyvista_python_client/issues" Source = "https://github.com/baraline/easyvista_python_client" [project.optional-dependencies] +# The Markdown <-> memo HTML converter, `easyvista_python_client.content`. +# Optional so the core stays httpx + pydantic + tenacity: nothing outside that +# subpackage imports these, and importing it without them raises an +# ImportError naming `pip install "easyvista-python-client[content]"`. They are +# repeated in `dev` (CI installs `.[dev]` and runs the converter's tests) and +# in `docs` (Read the Docs installs `.[docs]`, and autodoc imports the module); +# testing/test_public_api.py fails if a copy drifts. +# +# The upper bounds are deliberate: the reader extends markdownify's converters +# and mdformat's renderer, private surfaces that may move. markdown-it-py <4 and +# mdformat <0.8 were measured against the next major (2026-10-02): mdformat-tables +# 1.0.0 itself requires mdformat<0.8, which holds markdown-it-py below 4; the +# alt-text fix needs markdown-it-py 3's `text_special` tokens (2.x measured +# worse), and markdown-it-py 4 ends a ragged table early. markdownify <1.3 and +# mdformat-tables <1.1 are precautionary caps on releases that did not exist +# on 2026-10-02. Raise a bound only with the converter's tests re-run against +# the new version. +content = [ + "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", + "mdformat-tables>=1.0,<1.1", +] + dev = [ + "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", + "mdformat-tables>=1.0,<1.1", "build>=1.2", "mypy>=1.11", "numpydoc>=1.8", @@ -93,7 +123,6 @@ dev = [ "pre-commit>=4.0", "sphinx>=7.2,<8.2", "sphinx-rtd-theme>=2.0", - "tomli>=2.0; python_version < '3.11'", # >=7.0 for Metadata-Version 2.5 support: hatchling >=1.32 stamps 2.5, and # twine <=6.2 monkeypatches packaging's valid-metadata list to end at 2.4, so # `twine check` fails a perfectly good wheel and sdist (measured). @@ -119,10 +148,15 @@ dev = [ ] docs = [ + "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", + "mdformat-tables>=1.0,<1.1", "numpydoc>=1.8", "sphinx>=7.2,<8.2", "sphinx-rtd-theme>=2.0", - "tomli>=2.0; python_version < '3.11'", ] [tool.hatch.build.targets.wheel] @@ -158,7 +192,7 @@ include = [ [tool.ruff] line-length = 88 -target-version = "py310" +target-version = "py311" # The sync client tree is generated by unasync_build.py from _async/ and must # stay byte-identical to what regeneration produces -- that identity is what # the CI gate checks. The conflict is narrow but permanent: stripping the @@ -173,7 +207,7 @@ extend-exclude = ["easyvista_python_client/_sync"] select = ["B", "E", "F", "I", "RUF", "UP"] [tool.mypy] -python_version = "3.10" +python_version = "3.11" strict = true ignore_missing_imports = true exclude = ["tests/", "integration_tests/", "testing/"] diff --git a/scripts/run_coverage_gate.py b/scripts/run_coverage_gate.py index c6468e9..13b23e5 100644 --- a/scripts/run_coverage_gate.py +++ b/scripts/run_coverage_gate.py @@ -116,7 +116,11 @@ def main() -> int: "Could not find an interpreter with the project's dev dependencies " "installed, so the coverage gate did not run. Tried:\n " + "\n ".join(str(path) for path in tried) - + "\n\nCreate the environment CONTRIBUTING.md describes:\n" + + "\n\nAn interpreter older than requires-python in pyproject.toml " + "(3.11) fails this probe too, even with every dependency installed: " + "the package itself no longer imports there.\n\n" + "Create the environment CONTRIBUTING.md describes, on Python 3.11 or " + "newer:\n" " python -m venv .venv\n" ' .venv\\Scripts\\python.exe -m pip install -e ".[dev]"', file=sys.stderr, diff --git a/scripts/tests/test_skills_contract.py b/scripts/tests/test_skills_contract.py index 93bf957..cae3431 100644 --- a/scripts/tests/test_skills_contract.py +++ b/scripts/tests/test_skills_contract.py @@ -20,7 +20,12 @@ only names imported from the package root and keywords passed to a client method or a write model are looked up. - **No positional arguments and no arity.** Only ``keyword=`` arguments are - matched against the signature. + matched against the signature -- with one exception: a **required + keyword-only** parameter (``close_ticket``'s ``allow_workflow_effect``) must + be passed by every snippet that calls the method, since a snippet that omits + it raises ``TypeError`` when an agent runs it verbatim. A call that splats + ``**kwargs`` is exempt, because the splat may supply it. A positional + parameter's arity is still not checked. - **No required fields and no value types.** ``PostAsset(catalog_id="1")`` passes even though the field is an ``int``, and a write model missing a mandatory field passes too -- nothing is ever instantiated. @@ -298,6 +303,25 @@ def _write_model_name(func: ast.expr) -> str | None: return None +def _missing_required_keywords( + call: ast.Call, signature: inspect.Signature +) -> set[str]: + """Required keyword-only parameters of ``signature`` that ``call`` omits. + + A call that splats ``**kwargs`` may be supplying any of them, so its + required ones cannot be judged from the source text and none is reported. + """ + if any(keyword.arg is None for keyword in call.keywords): + return set() + required = { + name + for name, param in signature.parameters.items() + if param.kind is inspect.Parameter.KEYWORD_ONLY + and param.default is inspect.Parameter.empty + } + return required - {keyword.arg for keyword in call.keywords} + + def _snippet_trees(skill: Path) -> list[ast.Module]: text = (skill / "SKILL.md").read_text(encoding="utf-8") return [ast.parse(block) for block in _python_blocks(text)] @@ -371,6 +395,12 @@ def test_client_methods_and_keywords_exist(skill: Path) -> None: f"{skill.name} passes {keyword.arg}= to client.{method}(), " f"which accepts {sorted(accepted)}" ) + missing = _missing_required_keywords(call, signature) + assert not missing, ( + f"{skill.name} calls client.{method}() without its required " + f"keyword-only parameter(s) {sorted(missing)}; an agent runs a " + "skill's snippet verbatim, so the snippet would raise TypeError" + ) @pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) @@ -461,6 +491,47 @@ def test_snippet_hosts_are_synthetic(skill: Path) -> None: ) +@pytest.mark.parametrize( + ("source", "expected"), + [ + # The case the check exists for: a required keyword is left out. + ('client.close_ticket("R", status_guid="g")', {"allow_workflow_effect"}), + # A splat may supply it, so the call is exempt rather than reported. + ('client.close_ticket("R", **opts)', set()), + ('client.close_ticket("R", status_guid="g", **opts)', set()), + # A compliant call reports nothing, whatever else it passes. + ('client.close_ticket("R", allow_workflow_effect=effect)', set()), + ( + 'client.close_ticket("R", allow_workflow_effect=effect, status_guid="g")', + set(), + ), + ], +) +def test_required_keyword_check_sees_what_it_should( + source: str, expected: set[str] +) -> None: + """The required-keyword check is itself checked, so it cannot go inert. + + ``test_client_methods_and_keywords_exist`` only ever sees the skills as they + are. If the helper silently returned an empty set, every skill would pass + it. This feeds it synthetic snippets and a real signature that does carry a + required keyword-only parameter. + """ + signature = inspect.signature(ev.EasyvistaClient.close_ticket) + required = { + name + for name, param in signature.parameters.items() + if param.kind is inspect.Parameter.KEYWORD_ONLY + and param.default is inspect.Parameter.empty + } + assert "allow_workflow_effect" in required, ( + "close_ticket no longer has a required keyword-only parameter, so this " + "self-test no longer exercises the check; pick another method" + ) + (call,) = _client_calls(ast.parse(source)) + assert _missing_required_keywords(call, signature) == expected + + def test_write_models_map_is_complete() -> None: """Every exported EasyvistaWriteModel subclass maps in _WRITE_MODELS. diff --git a/scripts/validate_docs_examples.py b/scripts/validate_docs_examples.py index 004da90..f5d0948 100644 --- a/scripts/validate_docs_examples.py +++ b/scripts/validate_docs_examples.py @@ -332,7 +332,13 @@ def signatures() -> None: "create_tickets": {"tickets"}, "get_ticket": {"rfc_number"}, "update_ticket": {"rfc_number", "update"}, - "close_ticket": {"rfc_number", "status_guid", "delete_actions", "comment"}, + "close_ticket": { + "rfc_number", + "allow_workflow_effect", + "status_guid", + "delete_actions", + "comment", + }, "create_action": {"rfc_number", "action"}, "list_actions": {"rfc_number"}, "iter_actions": {"rfc_number", "fields", "page_size", "max_records"}, @@ -805,6 +811,7 @@ def run_live_writes( PostRequest, Request, RequestUpdate, + WorkflowEffect, ) created_rfcs: list[str] = [] @@ -935,11 +942,13 @@ def create_asset() -> None: if autoclose and status_guid: for target in list(created_rfcs): r.check_perm( - f"close_ticket('{target}', status_guid=..., delete_actions=1," - " comment=...)", + f"close_ticket('{target}'," + " allow_workflow_effect=WorkflowEffect.INTERRUPTS," + " status_guid=..., delete_actions=1, comment=...)", partial( client.close_ticket, target, + allow_workflow_effect=WorkflowEffect.INTERRUPTS, status_guid=status_guid, delete_actions=1, comment="Resolved by validation", diff --git a/scripts/validate_live_content_fidelity.py b/scripts/validate_live_content_fidelity.py index e88fa88..17f78fd 100644 --- a/scripts/validate_live_content_fidelity.py +++ b/scripts/validate_live_content_fidelity.py @@ -676,7 +676,12 @@ def run_tolerance( -> ``update_ticket(description=probe)`` -> read back at ``/comment`` -> classify. Each probe ticket is closed right after unless ``close_each`` is False. """ - from easyvista_python_client import EasyvistaError, PostRequest, RequestUpdate + from easyvista_python_client import ( + EasyvistaError, + PostRequest, + RequestUpdate, + WorkflowEffect, + ) results: list[Probe] = [] for index, (label, payload) in enumerate(COMMENT_PROBES, start=1): @@ -723,6 +728,7 @@ def run_tolerance( try: client.close_ticket( rfc, + allow_workflow_effect=WorkflowEffect.INTERRUPTS, status_guid=status_guid, delete_actions=1, comment="tolerance probe cleanup", @@ -742,8 +748,11 @@ def run_tolerance( # cleanup # --------------------------------------------------------------------------- # def do_close(client: EasyvistaClient, rfc: str, status_guid: str) -> None: + from easyvista_python_client import WorkflowEffect + client.close_ticket( rfc, + allow_workflow_effect=WorkflowEffect.INTERRUPTS, status_guid=status_guid, delete_actions=1, comment="Cloture apres validation de fidelite du contenu", diff --git a/skills/easyvista-asset-workflow/SKILL.md b/skills/easyvista-asset-workflow/SKILL.md index e9d5b2d..41a8753 100644 --- a/skills/easyvista-asset-workflow/SKILL.md +++ b/skills/easyvista-asset-workflow/SKILL.md @@ -2,10 +2,10 @@ name: easyvista-asset-workflow description: "Create, fetch, search and iterate EasyVista assets with easyvista_python_client — create_asset, get_asset, search_assets and iter_assets with PostAsset and Asset. Use for equipment, hardware or CI records: registering a new asset, looking one up by tag, or listing a department's assets." license: MIT -compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the assets resource." +compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the assets resource." metadata: package: easyvista-python-client - version: "0.3.0" + version: "0.4.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-client-setup/SKILL.md b/skills/easyvista-client-setup/SKILL.md index 269b851..91a733e 100644 --- a/skills/easyvista-client-setup/SKILL.md +++ b/skills/easyvista-client-setup/SKILL.md @@ -2,10 +2,10 @@ name: easyvista-client-setup description: "Create and configure the synchronous easyvista_python_client.EasyvistaClient or the asynchronous AsyncEasyvistaClient — server/account/api_version, Bearer token or HTTP Basic credentials, EasyvistaConfig.from_env, timeouts, retries, TLS verification, default page size, and the EasyvistaError hierarchy. Use before calling any EasyVista API, or when the user asks how to connect to EasyVista with easyvista_python_client." license: MIT -compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and valid EasyVista credentials." +compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and valid EasyVista credentials." metadata: package: easyvista-python-client - version: "0.3.0" + version: "0.4.0" --- `easyvista_python_client` ships both clients over one surface: @@ -34,8 +34,11 @@ with `async for`; `await client.stream_document(...)` raises `TypeError`. anything with credentials in the environment. 6. Keep `verify_ssl=True` unless the user confirms an internal endpoint that cannot present a valid chain. -7. Raise `max_retries` above its `0` default only for flaky networks; 429 and - 5xx are retried with exponential backoff, and 590 deliberately is not. +7. Raise `max_retries` above its `0` default only for flaky networks, and keep + it `0` for a client that writes: 429, 5xx and connection errors are retried + with exponential backoff **for every verb** (a resent create can duplicate + the ticket), and 590 deliberately is not. A write allowed to change the + workflow is sent once whatever `max_retries` says. 8. Use the client as a context manager so its HTTP session closes; call `client.close()` (`await client.aclose()`) when it outlives the block. @@ -51,7 +54,7 @@ Every `EasyvistaConfig` field, and its default: | `login` | `None` | HTTP Basic credential, paired with `password` | | `password` | `None` | HTTP Basic credential, paired with `login` | | `timeout` | `30.0` | Seconds | -| `max_retries` | `0` | Applies to 429 and 5xx only | +| `max_retries` | `0` | Applies to 429, 5xx and connection errors, for every verb — except that a write allowed to change the workflow is sent once. Keep it 0 for writers: a resent create can duplicate the ticket. | | `verify_ssl` | `True` | `True`/`False`, a CA-bundle path, or an `ssl.SSLContext` — a private CA does **not** require disabling verification | | `default_max_rows` | `100` | Page size when `max_rows` / `page_size` is omitted | | `api_version` | `"v1"` | Used to build `api_root` | @@ -92,7 +95,8 @@ config = EasyvistaConfig( ## Reaching a route this package does not wrap `client.send()` is the escape hatch. This package wraps roughly ten of the -paths an instance advertises; `send` reaches the rest with the same retries +paths an instance advertises; `send` reaches the rest with the same retries, +the same workflow guard — pass `allow_workflow_effect=` for a workflow write — and the same error mapping, returning the decoded JSON unchanged. ```python @@ -237,9 +241,14 @@ with EasyvistaClient.from_env() as client: | 429 | `EasyvistaRateLimitError` | Retried when `max_retries > 0`. | | 5xx | `EasyvistaServerError` | Retried when `max_retries > 0`. | | Transport failure (timeout, refused connection) | `EasyvistaConnectionError` | No response was obtained at all. | +| *(none: raised before sending)* | `EasyvistaWorkflowEffectRefused` — a `ValueError`, **not** an `EasyvistaError` | A write that may change the workflow, refused before sending; never retry it. | -Every one of these carries `status_code`, `ev_code` and `ev_message`, and all -derive from `EasyvistaError`. +Every HTTP-derived one of these carries `status_code`, `ev_code` and +`ev_message`, and all derive from `EasyvistaError`. The last row is the +exception on both counts: it has no response to carry a status from, so it is +not an `EasyvistaError`, and `except EasyvistaError` does not catch it. Never +retry it: pass the right `allow_workflow_effect=` when changing the workflow is +the intent, and otherwise drop the write. ## Gotchas @@ -261,6 +270,8 @@ derive from `EasyvistaError`. - `from_env()` accepts no overrides, unlike the sister GLPI client's. - `default_max_rows` (100) is the page size used when `max_rows` / `page_size` is omitted — it is not a total cap; the `iter_*` methods page past it. -- Retries are off by default (`max_retries=0`). +- Retries are off by default (`max_retries=0`). Leave them off for a client that + writes: the retry covers every verb, and a resent create can duplicate the + ticket. - Closing matters: the client owns an HTTP session. Prefer the context manager. diff --git a/skills/easyvista-directory/SKILL.md b/skills/easyvista-directory/SKILL.md index 3042650..ac21e27 100644 --- a/skills/easyvista-directory/SKILL.md +++ b/skills/easyvista-directory/SKILL.md @@ -2,10 +2,10 @@ name: easyvista-directory description: "Look up and provision EasyVista departments and employees with easyvista_python_client — get_department, search_departments, iter_departments, find_departments, get_department_comment, create_department, update_department and the matching employee methods, plus Reference and FieldClassification for reading instance-specific columns. Use to resolve a department by name or code, list a department's people, read a directory memo, or create/update directory records." license: MIT -compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the departments and employees resources (writes are additionally profile-gated)." +compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the departments and employees resources (writes are additionally profile-gated)." metadata: package: easyvista-python-client - version: "0.3.0" + version: "0.4.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-document-workflow/SKILL.md b/skills/easyvista-document-workflow/SKILL.md index 285202f..85a4ecd 100644 --- a/skills/easyvista-document-workflow/SKILL.md +++ b/skills/easyvista-document-workflow/SKILL.md @@ -2,10 +2,10 @@ name: easyvista-document-workflow description: "Attach, list, download, stream and delete files on an EasyVista ticket with easyvista_python_client — add_document, list_documents, download_document, stream_document and delete_document with the Document model. Use for ticket attachments, uploading evidence or logs to a request, fetching an attachment's bytes whole or chunk by chunk without buffering a large file, or removing one." license: MIT -compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the documents sub-resource." +compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the documents sub-resource." metadata: package: easyvista-python-client - version: "0.3.0" + version: "0.4.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-instance-discovery/SKILL.md b/skills/easyvista-instance-discovery/SKILL.md index 5a61b6d..9709d48 100644 --- a/skills/easyvista-instance-discovery/SKILL.md +++ b/skills/easyvista-instance-discovery/SKILL.md @@ -1,11 +1,11 @@ --- name: easyvista-instance-discovery -description: "Discover what one EasyVista deployment actually exposes with easyvista_python_client — get_api_spec reads the instance's own OpenAPI, list_reference_table reads any list route into column-free records, discover resolves one reference name to the ids/labels/codes/GUIDs in use, and describe_instance profiles the lot into an InstanceProfile. Use before hardcoding any id, when a ticket create is rejected for an unknown catalog, urgency, impact or group, when you need a STATUS_GUID for set_status or close_ticket, or when you need to know which routes a deployment declares at all." +description: "Discover what one EasyVista deployment actually exposes with easyvista_python_client — get_api_spec reads the instance's own OpenAPI, list_reference_table reads any list route into column-free records, discover resolves one reference name to the ids/labels/codes/GUIDs in use, and describe_instance profiles the lot into an InstanceProfile. Use before hardcoding any id, when a ticket create is rejected for an unknown catalog, urgency, impact or group, when you need a STATUS_GUID for close_ticket, or when you need to know which routes a deployment declares at all." license: MIT -compatibility: "Requires Python 3.10+, easyvista-python-client, and network access to an EasyVista Service Manager REST API. Every call here is a GET; nothing is created, updated or deleted." +compatibility: "Requires Python 3.11+, easyvista-python-client, and network access to an EasyVista Service Manager REST API. Every call here is a GET; nothing is created, updated or deleted." metadata: package: easyvista-python-client - version: "0.3.0" + version: "0.4.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, @@ -39,7 +39,9 @@ start-up and fail loudly; never freeze one into code. named there. 2. For one reference, `discover(name)`. Use `.id` for a write model's `*_id` field, `.code` for `PostRequest(catalog_code=...)`, and `.guid` for - `set_status` / `close_ticket`. + `close_ticket` — the vendor close request, which stops the ticket's workflow + (documented for final statuses; nothing exempts a non-final one), so it is + not a way to pick an intermediate status (see `easyvista-ticket-workflow`). 3. For a route this package does not model at all, `list_reference_table(path)` — check `get_api_spec()["paths"]` to see which your deployment declares. 4. Never cache an id across deployments. Re-resolve, or fail loudly. @@ -56,7 +58,7 @@ with EasyvistaClient.from_env() as client: print("gap:", gap, reason) for status in client.discover("STATUS"): - # .guid is what set_status and close_ticket address a status by. + # .guid is what close_ticket addresses a status by. print(status.id, status.label, status.guid) for catalog in client.discover("CATALOG_REQUEST"): diff --git a/skills/easyvista-reporting-and-context/SKILL.md b/skills/easyvista-reporting-and-context/SKILL.md index 5632bc2..bf1d543 100644 --- a/skills/easyvista-reporting-and-context/SKILL.md +++ b/skills/easyvista-reporting-and-context/SKILL.md @@ -2,10 +2,10 @@ name: easyvista-reporting-and-context description: "Aggregate EasyVista tickets into counts and per-dimension breakdowns, and assemble one-call context bundles, with easyvista_python_client — count_tickets, ticket_statistics, aggregate_tickets, TicketStatistics, get_ticket_context, TicketContext.to_markdown and get_department_context. Use for ticket dashboards, per-status or per-department counts, and for exporting a ticket or a department as an LLM-ready document." license: MIT -compatibility: "Requires Python 3.10+, easyvista-python-client, and network access to an EasyVista Service Manager REST API." +compatibility: "Requires Python 3.11+, easyvista-python-client, and network access to an EasyVista Service Manager REST API." metadata: package: easyvista-python-client - version: "0.3.0" + version: "0.4.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-search-syntax/SKILL.md b/skills/easyvista-search-syntax/SKILL.md index 51699ca..a880b19 100644 --- a/skills/easyvista-search-syntax/SKILL.md +++ b/skills/easyvista-search-syntax/SKILL.md @@ -2,10 +2,10 @@ name: easyvista-search-syntax description: "Write correct EasyVista server-side search expressions for search_tickets, iter_tickets, count_tickets, search_assets, search_departments and search_employees using ev_equals_filter, ev_in_filter, ev_contains_filter, ev_starts_with_filter, ev_since_filter, ev_between_filter, escape_ev_value and is_safe_ev_value. Use whenever building a search= argument, filtering EasyVista records, filtering by a date/time window, or debugging a filter that returned everything or nothing — EasyVista silently ignores conditions it cannot honour and returns the whole table." license: MIT -compatibility: "Requires Python 3.10+, easyvista-python-client, and network access to an EasyVista Service Manager REST API." +compatibility: "Requires Python 3.11+, easyvista-python-client, and network access to an EasyVista Service Manager REST API." metadata: package: easyvista-python-client - version: "0.3.0" + version: "0.4.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-ticket-actions/SKILL.md b/skills/easyvista-ticket-actions/SKILL.md index a1d44ec..112c67d 100644 --- a/skills/easyvista-ticket-actions/SKILL.md +++ b/skills/easyvista-ticket-actions/SKILL.md @@ -1,11 +1,11 @@ --- name: easyvista-ticket-actions -description: "Read and write the action log on an EasyVista ticket with easyvista_python_client — create_task and PostTask (the one call that posts a COMMENT: a task is an action born already ended, so its text shows in the history), plus create_action, end_action (an action is born OPEN and its text does not show until ended), list_actions, iter_actions, get_action and update_action with PostAction, Action and ActionUpdate. Covers why there is no private-comment flag and that visibility is the action TYPE instead, how to recover a created action's id, how to page a whole log past the one-page cap, and how to resolve an action's note text, which the list endpoint does not return. Use for ticket comments, followups, work notes, internal or private comments, progress entries or any per-ticket action history." +description: "Read and write the action log on an EasyVista ticket with easyvista_python_client — create_task and PostTask (the one call that posts a COMMENT: a task is an action born already ended, so its text shows in the history), plus create_action, end_action (an action is born OPEN and its text does not show until ended), list_actions, iter_actions, get_action, update_action and reassign_action (hand an action, such as the open workflow step, to another group or person without ending it) with PostAction, Action and ActionUpdate. Covers why there is no private-comment flag and that visibility is the action TYPE instead, how to recover a created action's id, how to page a whole log past the one-page cap, and how to resolve an action's note text, which the list endpoint does not return. Use for ticket comments, followups, work notes, internal or private comments, progress entries or any per-ticket action history, and to reassign, escalate or transfer an action." license: MIT -compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the actions sub-resource." +compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the actions sub-resource." metadata: package: easyvista-python-client - version: "0.3.0" + version: "0.4.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, @@ -14,9 +14,10 @@ metadata: > `easyvista-client-setup`. Actions are EasyVista's per-ticket work log — the closest equivalent to a -followup. Six methods: `create_action(rfc, action)`, `list_actions(rfc)`, -`iter_actions(rfc)`, `get_action(action_id)`, `update_action(action_id, -update)` and `end_action(rfc, action_id=...)`. The list and item shapes differ +followup. Eight methods: `create_task(rfc, task)`, `create_action(rfc, action)`, +`list_actions(rfc)`, `iter_actions(rfc)`, `get_action(action_id)`, +`update_action(action_id, update)`, `reassign_action(action_id, group_id=...)` +and `end_action(rfc, action_id=...)`. The list and item shapes differ substantially, which is where most mistakes come from. ## Two shapes of the same record @@ -195,10 +196,43 @@ with EasyvistaClient.from_env() as client: > spawned a new open type-1 *Validation Self Service* action. A control the > same day showed ending a type-94 action the caller had created left both the > status and the action count untouched. So ending your own action is inert; -> ending a workflow step is a state change on the ticket. **Omitting -> `action_id` ends every open action**, which on a ticket whose only open one -> is its workflow step means resolving it — name the action unless you mean -> that. +> ending a workflow step is a state change on the ticket. +> +> **`end_action` therefore guards it.** It makes one read first (an item read +> projecting `ACTION_ID` and `WORKFLOW_ID`) and refuses a workflow step +> (`WORKFLOW_ID` set), a record that comes back without `WORKFLOW_ID`, or a +> record whose `ACTION_ID` is not the one you asked for, unless you pass +> `allow_workflow_effect=WorkflowEffect.ADVANCES`. If that read fails, its error +> propagates and the end request is not sent; a 403 there says nothing about +> whether ending is permitted. Ending your own action needs no opt-in; whether +> an action created under the step carries a `WORKFLOW_ID` is unmeasured — if it +> does, the end is refused, and you opt in. `WORKFLOW_ID` is what separates the +> engine's rows from a caller's (tier 4: 1500 of 1500 rows, 2026-09-02, one +> instance, so it may not generalise). The refusal is +> `EasyvistaWorkflowEffectRefused`, a `ValueError` (not an `EasyvistaError`), +> raised before the end request; an allowed end is sent once, never retried. +> `action_id` must be a positive integer. +> +> **`end_all=True` ends every open action, the workflow step included; it needs +> `WorkflowEffect.ADVANCES`.** Omitting `action_id` is not that form: a bare +> `action_id=None` is refused, because `Action.action_id` is legitimately `None` +> on a create response or a projection without `ACTION_ID`. + +To end a workflow step on purpose, say so at the call site: + +```python +from easyvista_python_client import EasyvistaClient, WorkflowEffect + +with EasyvistaClient.from_env() as client: + client.end_action( + "YOUR_RFC_NUMBER", + action_id=YOUR_WORKFLOW_STEP_ACTION_ID, + # The workflow moves on to its next step. Status ids are per instance, + # so read the ticket back rather than assuming where it landed. + allow_workflow_effect=WorkflowEffect.ADVANCES, + ) + print(client.get_ticket("YOUR_RFC_NUMBER").reference("STATUS").display) +``` > **Retraction (2026-09-01).** An earlier revision of this skill said every > documented form returned `590 Action not found` and called that an @@ -321,6 +355,47 @@ Two asymmetries worth knowing: and PATCH on `actions/{id}` — there is no DELETE verb — so there is deliberately no `delete_action`. +## Reassign an action + +`reassign_action(action_id, group_id=..., done_by_id=...)` hands an action to +another group and/or person without ending it — the way to escalate the open +workflow step. At least one id is required; both are positive integers, and both +are per-deployment, so look them up (next section) rather than hardcoding them. A +group id is the one that may not be readable: on the measured instance +(2026-10-02, one instance) `GET groups` answered 403, which this API also answers +for an absent route, so if yours does too, take the group id from your +administrator or from a record that already carries one. `done_by_id` is an +employee id: find one with `search_employees` or `get_employee`. + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + actions = client.iter_actions( + "YOUR_RFC_NUMBER", + fields=["ACTION_ID", "WORKFLOW_ID", "GROUP_ID", "END_DATE_UT"], + ) + # The open workflow step: WORKFLOW_ID set, no end date yet. + step = next(a for a in actions if a.is_workflow_generated and not a.end_date_ut) + client.reassign_action(step.action_id, group_id=YOUR_OTHER_GROUP_ID) + # Re-read: this API answers 200 while dropping a field it did not store. + print(client.get_action(step.action_id).group_id) +``` + +The vendor documents no REST reassignment route (tier 1, +`rest-api-update-an-action.md` lists no group column among its exclusions, and the +UI's transfer is a wizard), so the effect is measured, not specified. Measured +2026-10-02 on one instance (Service Manager 2025.3; two tickets, so it may not +generalise): the group was stored (`GROUP_ID` 57 to 50 on the open workflow step +of both tickets); the step stayed open and the ticket's status did not move; the +open actions were unchanged and no new action rows appeared. **The ticket's own +`OWNING_GROUP_ID` does not follow the action's group** (it stayed 57 on the first +ticket, the only one read for it), so reassigning a step is not reassigning the +ticket. Reassigning to a person (`done_by_id`) was **not measured**, and whether +the UI wizard's notifications fire is not observable from the API. +`reassign_action` is not refused by the workflow guard, because the group and the +person are data the workflow reads, not workflow state. + ## Discover the ids first `action_type_id` and `group_id` are instance-specific. One call finds both — @@ -375,9 +450,14 @@ with the EasyVista administrator, then pin the ids in your own configuration. and an open action renders in the UI as a pending row with its text NOT shown, which reads as though the note was lost. Finish it with `end_action(rfc, action_id=...)` (see the section above for the fields - and for what ending a *workflow* action does to the ticket). Note the - create route is parent-resolved: it needs exactly one open action on the - ticket, or an explicit `parent_action_id` naming an open one. + and for what ending a *workflow* action does to the ticket). + `end_action` reads the action first and refuses a workflow step + (`WORKFLOW_ID` set) or a record without `WORKFLOW_ID` unless you pass + `allow_workflow_effect=WorkflowEffect.ADVANCES`; ending your own action + needs no opt-in, but whether an action created under a step carries a + `WORKFLOW_ID` is unmeasured — if it does, the end is refused, and you opt + in. Note the create route is parent-resolved: it needs exactly one open + action on the ticket, or an explicit `parent_action_id` naming an open one. 4. To address the action or task you just created, diff `list_actions` across the call — the create response cannot give you the id (see Gotchas). 5. To read note text, either call `get_action` and resolve the memo href with @@ -397,7 +477,7 @@ with EasyvistaClient.from_env() as client: client.create_task( "YOUR_RFC_NUMBER", PostTask( - action_type_id=1, + action_type_id=94, group_id=1, description="Called the user back; printer power-cycled.", ), @@ -406,7 +486,8 @@ with EasyvistaClient.from_env() as client: Only when the work is genuinely still to be done, an **action** instead. It is born open, so its text does not render in the history until it is ended — -finish it with `end_action` (above) once the work is done: +finish it with `end_action` (above) once the work is done; that needs no opt-in +for an action you created, subject to the `WORKFLOW_ID` caveat above: ```python from easyvista_python_client import EasyvistaClient, PostAction @@ -415,7 +496,7 @@ with EasyvistaClient.from_env() as client: action = client.create_action( "YOUR_RFC_NUMBER", PostAction( - action_type_id=1, + action_type_id=94, group_id=1, description="Chase the supplier for a replacement drum.", ), @@ -423,9 +504,10 @@ with EasyvistaClient.from_env() as client: print(action.href) ``` -`action_type_id=1` and `group_id=1` above are placeholders — use the ids -`client.discover("ACTION_TYPE")` and `client.discover("GROUP")` printed for -your instance. +`action_type_id=94` and `group_id=1` above are placeholders — 94 is the +public-comment type on one measured instance, where type 1 is a workflow step +type (measured 2026-09-01, one instance; it may not generalise). Read your own +with `client.discover("ACTION_TYPE")` and `client.discover("GROUP")`. ```python from easyvista_python_client import EasyvistaClient, PostAction @@ -436,7 +518,7 @@ with EasyvistaClient.from_env() as client: # The create response carries no ACTION_ID, so diff the list around it. before = {a.action_id for a in client.list_actions(rfc)} client.create_action( - rfc, PostAction(action_type_id=1, group_id=1, description="Triaged.") + rfc, PostAction(action_type_id=94, group_id=1, description="Triaged.") ) after = client.list_actions(rfc) created = [a for a in after if a.action_id not in before] @@ -520,14 +602,52 @@ with EasyvistaClient.from_env() as client: `description or comment` — which is what `get_ticket_context` and `TicketContext.to_markdown` now do, resolving the `COMMENT` memo only when `DESCRIPTION` comes back empty. +- **Note text is stored as sent, and nothing renders Markdown for you.** A + memo holds what it was written with -- HTML written through the API was + stored byte for byte (measured 2026-09-30 on a ticket memo, one instance, + tier 4, may not generalise) -- so a resolved note may be HTML, and Markdown + written as-is is stored as-is. The optional `content` + extra (`pip install "easyvista-python-client[content]"`) converts both ways: + `EasyvistaContentConverter.to_transport(markdown)` for the `description` you + write, `EasyvistaContentConverter.from_transport(memo)` on what + `resolve_memo` returns. Import it from the `easyvista_python_client.content` + subpackage. Its Markdown is **CommonMark with GFM tables**: a newline you + write is a line break, but `~~strike~~`, bare `www.` links and `- [ ]` task + boxes are not extensions it enables, so write `...` and + `` instead. An unescaped `__init__` you write renders as a bold + `init`. Reading spells a note's text as literal text (`__init__` comes back + as `\_\_init\_\_`, a displayed `` as `\`), so render the Markdown + rather than stripping its backslashes. Underline and strike come back as raw + `` and `` tags, and a note with no HTML in it reads as literal lines. + It sanitises nothing: raw HTML, `javascript:` link targets and + `` autolinks go out live, and reading keeps a memo's + `javascript:` links, so neutralise both in Markdown you did not write — a + comment sync relaying another ITSM's text is exactly that case. See + `docs/content.rst` for what survives a round trip. - **`create_action` resolves an implicit parent** and needs exactly **one** open action on the ticket: zero gives `590 "Parent action not found or incorrect"`, two or more gives `590 "Ambiguous query : many parent actions found"`, and an explicit `parent_action_id` naming an **open** action succeeds either way (an ended one is refused). A fresh ticket carries exactly one open workflow action, - and every `set_status` drains the open set to zero — so in practice a bare - `create_action` works only on a ticket nobody has moved yet. `create_task` is - not parent-resolved and is unaffected. + and a close request drains the open set to zero (the vendor close page says + the unfinished actions are deleted, or by our reading ended — tier 1; one + ticket was censused on 2026-09-01 on one instance — tier 4, so it may not + generalise) — so in practice a bare `create_action` works only on a ticket + nobody has moved yet. `create_task` is not parent-resolved and is unaffected. +- **The workflow guard covers the other action writes too.** `create_action`, + `create_task` and `update_action` refuse a body whose `extra_payload` ties the + record into the workflow — `WORKFLOW_ID`, `STAGE_ID`, `PROCESS_STEP_ID` or + `STATUS_ID_ON_TERMINATE`; on an action also an end date (a task is born ended, + so its end date is ordinary); on an update also a type, a parent or a ticket + link; on a task also `PARENT_ACTION_ID` — with + `EasyvistaWorkflowEffectRefused`, before any request, unless the call passes + `allow_workflow_effect=`. The fields `PostAction`, `PostTask` and + `ActionUpdate` declare need no opt-in. That is a deny-list of columns, and one + cannot be complete: the vendor's + [update-an-action page](https://docs.easyvista.com/docs/rest-api-update-an-action.md) + accepts "all the fields from the AM_ACTION table except those mentioned below" + (tier 1, read 2026-10-02), so a column it does not name is unclassified, not + proven neutral. - `action.action_type` is a nested object on the live API, not a string. Use `action.reference("ACTION_TYPE").display` for the label. - Resolving every body costs two extra requests per action (item fetch, then diff --git a/skills/easyvista-ticket-workflow/SKILL.md b/skills/easyvista-ticket-workflow/SKILL.md index 86de65e..585a591 100644 --- a/skills/easyvista-ticket-workflow/SKILL.md +++ b/skills/easyvista-ticket-workflow/SKILL.md @@ -2,10 +2,10 @@ name: easyvista-ticket-workflow description: "Create, read, search, paginate, update and close EasyVista tickets (requests) with easyvista_python_client — PostRequest, Request, RequestUpdate, create_ticket, create_tickets, get_ticket, search_tickets, iter_tickets, count_tickets, update_ticket and close_ticket. Use for any ticket/incident/request operation, including discovering the instance-specific catalog codes and ids a create needs." license: MIT -compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the requests resource." +compatibility: "Requires Python 3.11+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the requests resource." metadata: package: easyvista-python-client - version: "0.3.0" + version: "0.4.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, @@ -84,14 +84,25 @@ deployment needs before you build a payload for it. `update_ticket(rfc, RequestUpdate(description=...))`. `RequestUpdate` also accepts `title`, `impact_id`, `owner_id` and `external_reference` (capped at 50 characters) after create — see the Gotchas for what it deliberately - omits, and use `set_status(rfc, status_guid=...)` for a status. + omits. The vendor documents no status write, and this package has none: a + ticket's status follows its workflow. To + complete a workflow step, end its open action with `end_action(rfc, + action_id=..., allow_workflow_effect=WorkflowEffect.ADVANCES)` — without + `ADVANCES`, ending a workflow step is refused (see + `easyvista-ticket-actions`); `close_ticket` is the vendor CLOSE request and + needs `allow_workflow_effect=WorkflowEffect.INTERRUPTS`. 6. Read one ticket with `get_ticket(rfc)`; search a page with `search_tickets(...)`, which returns a `SearchResult` carrying `.records`, `.record_count` (this page) and `.total_record_count` (every match on the server); walk every match with `iter_tickets(...)`, which yields `Request` objects directly and pages for you. -7. Close with `close_ticket(rfc, status_guid=..., delete_actions=..., - comment=...)`. +7. Close only when closing is the intent, with `close_ticket(rfc, + allow_workflow_effect=WorkflowEffect.INTERRUPTS, status_guid=..., + delete_actions=..., comment=...)`. The close request interrupts the + workflow, ends (by our reading of the page) or, with `delete_actions`, + deletes the unfinished actions, and inserts an anticipated closing action + (vendor close page, tier 1) — documented for final statuses; nothing exempts + the others. Then re-read the ticket: a 200 is not a receipt. ## Examples @@ -165,18 +176,29 @@ with EasyvistaClient.from_env() as client: ``` ```python -from easyvista_python_client import EasyvistaClient +from easyvista_python_client import EasyvistaClient, WorkflowEffect with EasyvistaClient.from_env() as client: closed = client.close_ticket( "YOUR_RFC_NUMBER", + # Required: the close request interrupts the ticket's workflow. + allow_workflow_effect=WorkflowEffect.INTERRUPTS, status_guid="YOUR_CLOSED_STATUS_GUID", - delete_actions=1, + delete_actions=1, # DELETES the unfinished actions comment="Resolved: printer power-cycled.", ) print(closed.rfc_number) + + # A 200 is not a receipt on this API: re-read. end_date_ut is stamped at + # resolution or closure, so a value here means "resolved or closed". + print(client.get_ticket("YOUR_RFC_NUMBER").end_date_ut) ``` +`allow_workflow_effect` is a required keyword of `close_ticket` (leaving it out is +a `TypeError`), and a value that does not include `WorkflowEffect.INTERRUPTS` is +refused before any request is sent (see the first Gotcha). `WorkflowEffect` and +`EasyvistaWorkflowEffectRefused` are both importable from the package root. + ```python from easyvista_python_client import EasyvistaClient, PostRequest @@ -192,6 +214,35 @@ with EasyvistaClient.from_env() as client: ## Gotchas +- **`close_ticket` is not a status setter.** It stops the workflow. Using it to + land an intermediate status (the package's former status setter was this same + request) ended the ticket's initial workflow action — that is how a + synchroniser closed tickets early. The root cause was established on + 2026-10-01/02 from the synchroniser's code (it sent the close request right + after every create and on every status push) and from the vendor close page + (tier 1): that page lists four processing steps, none conditional on the + status sent — the workflow is interrupted, the status is set, the unfinished + actions are deleted (or, by our reading, ended) and an anticipated closing + action is inserted + ([vendor close page](https://docs.easyvista.com/docs/rest-api-close-an-incident-request.md)). + The drain of the open action across such a status write was measured on + 2026-09-01 on one instance (one ticket censused, target status id 24 there; + tier 4, so it may not generalise). The page documents *final* statuses only, + so for a non-final one it is an extrapolation the page neither exempts nor + covers. Writes that may change the + workflow are refused unless the call passes `allow_workflow_effect=`; the + refusal is `EasyvistaWorkflowEffectRefused`, a `ValueError` (not an + `EasyvistaError`), raised before any request, and an allowed workflow write is + sent once, never retried. +- **`update_ticket` cannot set a status either, and refuses the attempt.** A + `status_id`, `status_guid`, catalog or `parent_request_id` key smuggled in + through `extra_payload` raises `EasyvistaWorkflowEffectRefused` unless you + opt in with `allow_workflow_effect=`. The vendor's + [update-an-incident-request page](https://docs.easyvista.com/docs/rest-api-update-an-incident-request.md) + excludes `status_id`, `sd_catalog_id`, `initial_sd_catalog_id` and + `parent_request_id` from its body outright (tier 1, read 2026-10-02), so an + opt-in is permission to *send* it, not evidence the server will honour it; + re-read after any such write. - **Timestamp columns are aware `datetime`, so a record dump is not JSON-serialisable.** `submit_date_ut`, `creation_date_ut`, `max_resolution_date_ut`, `expected_date_ut`, `end_date_ut` and `last_update` @@ -221,6 +272,30 @@ with EasyvistaClient.from_env() as client: deployment actually populates is a per-instance configuration choice. Read it back with `resolve_memo("requests/{rfc}/comment")`, or take `TicketContext.comment`, which resolves it for you. +- **A memo stores exactly what you send, and nothing renders Markdown for + you.** Measured 2026-09-30 on one instance (tier 4, may not generalise): a + ticket memo written through the API with HTML was stored byte for byte, and + the web UI rendered its `

    ` elements as paragraphs. So what `resolve_memo` + returns is whatever was written -- HTML, plain text, or Markdown nobody + rendered -- and Markdown you write is stored as Markdown. The optional `content` + extra (`pip install "easyvista-python-client[content]"`) converts both ways: + `EasyvistaContentConverter.to_transport(markdown)` before the write, + `EasyvistaContentConverter.from_transport(memo)` on what `resolve_memo` + returns. It lives in the `easyvista_python_client.content` subpackage, not + the package root, so it is not imported unless you ask for it. Its Markdown + is **CommonMark with GFM tables**, rendered by cmark-gfm: a newline is a + line break, raw HTML passes through, and no other GFM extension is on, so + `~~strike~~` stays literal (write `...`). Reading spells a memo's + text as literal text -- a typed `__init__` comes back as `\_\_init\_\_`, + `# titre` at a line start as `\# titre`, `` as `\` -- so + render the Markdown to display it rather than stripping the backslashes. + A memo with no HTML element reads as literal lines; pass + `plain_text_is_markdown=True` when the value is your own Markdown. Write a + memo as HTML or as Markdown, not both: one real HTML element makes the + whole value HTML. A nested table comes back as its words, and a table + without a header row gains an empty one. It sanitises nothing: raw HTML, + `javascript:` link targets and `` autolinks in the Markdown + go out live, so neutralise them in Markdown you did not write. - A `description` passed to **`PostRequest`** at create time was not readable back through either memo on the verified instance. Follow the create with an `update_ticket` when the body must be retrievable.