0.2.0: portability escape hatches, end_action, and the docs to match - #4
Merged
Merged
Conversation
Adds format_ev_datetime, the inverse the interval filter builders need. Behaviour-preserving for reporting: its eight existing tests are unchanged.
ev_since_filter/ev_between_filter emit FIELD:(a;b), the only range grammar this API honours. ev_contains_filter/ev_starts_with_filter use '~' with an explicit wildcard, correcting the belief that '~' is exact-match only.
_TIMESTAMP_RE accepted well-shaped but impossible timestamps (9999-99-99, 25:61:61) and Unicode digits, and a dropped condition returns the whole table rather than an error, so a typo'd watermark silently degraded a sync sweep to a full-table read. _interval_bound now also requires parse_ev_datetime(text) to succeed. Switch \d -> [0-9] and .match()+$ -> fullmatch() so the anchoring no longer depends on the prior .strip(). Add acceptance-side coverage for the five renderings measured live, and soften the ev_since_filter docstring's "cannot be malformed" overclaim about datetime input. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…unsorted EasyVista honours 'FIELD DESC' but silently drops 'FIELD:DESC', returning the default order. Measured live 2026-08-17; closes O-DIR-1.
The colon-vs-space rule was measured on LAST_UPDATE (a date column), never on RFC_NUMBER. Applying it to RFC_NUMBER is sound inference from a syntactic rule, not a live-verified fact about that field -- Task 9's live guard is what actually pins RFC_NUMBER DESC. Prose-only: no constant, assertion, or behaviour changed.
BREAKING: Request/Employee timestamp fields are datetime, not str. The format is offset-bearing ISO 8601 with ms precision (verified live), so no caller-side server_timezone is needed. Write models untouched: write format unverified. Also fixes tests/test_reporting.py::test_window_excludes_missing_or_unparseable_dates (renamed to test_window_excludes_missing_dates): its "garbage"-dated ticket can no longer be constructed, since Request.model_validate now rejects a malformed CREATION_DATE_UT before aggregate_tickets ever sees it. That coverage moved to models/tests/test_common.py's new unparseable-timestamp test.
CRITICAL: _fields._text() and references._scalar() returned "" / None for
anything that wasn't a str/int, so a retyped timestamp column silently
vanished from every consumer of a model_dump(by_alias=True) dict:
TicketContext.to_markdown() dropped its Created/Updated rows, and
Request.reference("LAST_UPDATE") / aggregate_tickets(dimensions=(...)) on a
timestamp column resolved to nothing. Both extractors now render a datetime
via format_ev_datetime (falling back to plain .isoformat() for the naive case,
since neither extractor may raise), fixed once at the shared root rather than
patched per call site.
Also fixes the Important finding that OptionalDateTime didn't honour its own
"aware" promise for a datetime passed in directly (only strings routed through
parse_ev_datetime's naive->UTC normalization; a bare second isinstance(value,
str) guard skipped it for everything else) -- dropped, so every value now
routes through parse_ev_datetime uniformly.
Three minors: test_employee.py now directly asserts last_update parses (it
had no failing pre-existing assertion, so was previously only covered
transitively); the unparseable-timestamp test now pins that the
ValidationError names the field (errors()[0]["loc"] == ("when",)), which is
the entire reason the coercer hands the original value back instead of
raising itself; reporting.py's docstring now explains why the
now-partially-unreachable missing/unparseable guard is kept rather than
deleted as dead code.
CHANGELOG.md intentionally untouched -- Task 10 owns changelog consolidation.
All present on the item-level GET and reachable via a fields= projection; previously available only as untyped extras. Closes EV-R1.
…laim The docstring and field comment said the default LIST projection omits all ten new fields while the same sentence listed ACTION_NUMBER and DONE_BY_ID among what it carries. The real split is three-way: six fields genuinely absent from the default list row, two already present top-level, and two (ACTION_TYPE_ID, REQUEST_ID) present on a list row only nested inside ACTION_TYPE/REQUEST -- so the declared top-level-aliased field reads None off a list row despite the data being present. Also notes that only one of a fresh ticket's ~12 auto-spawned actions is human-authored and that generated actions carry an undeclared STATUS_ID_ON_CREATE. Parametrized the empty-string-sentinel test over all eight OptionalInt fields so its name matches what it actually checks. No field, alias or type changed.
The actions list honours fields= and grants every scalar requested, so a caller can read all timestamps and authors for a ticket in one request instead of one item fetch per action. Closes EV-R3.
Review found the "*" isn't-a-wildcard and dotted-path-is-dropped caveats only lived in the private builder comment, unreachable from help()/IDE tooltips. Adds one sentence to the client-facing docstring, mirrored verbatim into the sync tree. No behavior change.
Both live-verified by re-reading the record, not by status code. Uses the
top-level actions/{id} and nested requests/{rfc}/documents/{id} paths; the
alternatives return 403. Closes EV-R4.
Each verified writable by re-reading the ticket. severity_id and urgency_id deliberately excluded: one is refused, the other returns 590 while still applying (tracked as O-590-PARTIAL). Closes EV-R9, EV-R10.
Adds a differential change-window characterization (a single count cannot prove a range filter works on this API) and corrects three tilde tests whose assertions were right but whose stated conclusion over-generalized. Also settles live whether '%' is a wildcard for '~' (it is, matching '*' exactly), verifies ActionUpdate's lowercase-cased body actually lands through update_action, rewords ActionUpdate's docstring to name the field rather than quote a body it no longer sends, and corrects this module's own inherited assumption that every LAST_UPDATE comparison-operator rendering is silently dropped -- two of the three instead raise a hard type-mismatch error (590), only the colon-free rendering is structurally unparseable enough to be dropped.
Round-1 review found three Important defects in the change-window characterization: split_instants leaked a raw datetime into the comparison-operator f-strings (space separator, 6-digit fraction) despite its str-literal contract; that same test asserted two 590s with no control isolating them to the embedded operator rather than the column rejecting FIELD:"value" outright; and the closed-interval test was a single count that could not distinguish a real upper bound from one silently dropped down to the open-ended form. Also fixes RECENT_TICKETS_SORT's guard, which computed an unsorted baseline but never compared against it (monotonicity-only, same fate EV-R6's sibling test was written to avoid), a strict assertion in the percent-wildcard probe that could redden on a data-availability gap instead of skipping, two compound is-not-None asserts that risked printing a live instant on failure, and a stale ticket/action/update count in conftest.py's mutation-footprint docstring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'~' is a pattern operator requiring an explicit wildcard (* or %), not exact-match-only -- degenerates to equality without one. Corrects the claim in the user guide, the search-syntax and asset-workflow skills, and the changelog; adds ev_contains_filter/ev_starts_with_filter examples in their place. Documents the change-window builders (ev_since_filter/ev_between_filter), the timestamp helpers (parse_ev_datetime/format_ev_datetime), and the two-fate comparison-operator behaviour (silent-drop vs. HTTP 590 type-mismatch, depending on whether FIELD: syntax survives). Closes the O-DIR-1 sort-token hedges in both the reporting-and-context and search-syntax skills now that FIELD DESC is live-confirmed. Consolidates the CHANGELOG's Unreleased section: merges the duplicate Changed headings, strikes the now-false "no datetime parsing is claimed" line, adds the scope note that only Employee.last_update is a true break relative to 0.1.0, and fills in the entries this branch was still missing (the four filter builders, list_actions(fields=...), update_action/ delete_document/ActionUpdate, Action's new fields, RequestUpdate's widening, and the RECENT_TICKETS_SORT fix).
Same failure mode as the earlier easyvista-asset-workflow catch: skill and changelog content outside the brief's file list still described a pre-task-9 API. delete_document, update_action and list_actions(fields=) now exist in easyvista-document-workflow and easyvista-ticket-actions; RequestUpdate's impact_id/owner_id/external_reference widening is documented in easyvista-ticket-workflow; skills/README.md's index no longer contradicts the skills it indexes. Rewrites the CHANGELOG's BREAKING scope note: the 0.1.0 git tag resolves to a commit ~150 commits after the 0.1.0 release commit the changelog documents, and at the tag commit all seven timestamp fields -- not just Employee.last_update -- were already str. Verified against both commits before rewriting. Also fixes a genuine dangling reference (ChangedRef, which never existed) in a test docstring to the real field it was describing (Action.updated_at).
…l map Add ActionUpdate to the _WRITE_MODELS dict so its snippets in easyvista-ticket-actions are now validated for keyword correctness. Add a new test_write_models_map_is_complete() test that asserts every EasyvistaWriteModel subclass exported from the package appears in the map. This prevents silent validation skips when a new write model is added without updating the map. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-2 live probing found that EasyVista *accepts* an offset-less timestamp literal and reads it in a different zone. Measured against one instance, the same wall-clock text enumerated 13 rows with its offset and 11 without: the offset-less form moves the bound later and skips records, with no error of any kind. A watermark that silently skips is the worst outcome this grammar has. `format_ev_datetime` already refused a naive `datetime` on exactly this reasoning, and said so in its docstring. The string path was unguarded, so the identical hazard reached the wire by the other route. Both paths now refuse. A bare date stays legal -- day granularity has no time to misplace, and it is a form measured live as honoured. Also withdraws a claim this package shipped: `RequestUpdate`'s docstring said `DESCRIPTION` is empty on every ticket of the verified instance. A pooled 77-row sample across four orderings found `COMMENT` on 57 rows, `DESCRIPTION` on 27 and both on 24, the proportions flipping by slice. The earlier 0/15 reading was drawn from probe-authored tickets. The load-bearing claim is untouched and still verified -- `RequestUpdate.description` writes the `COMMENT` memo -- but the generalisation is gone, and with it any hope of auto-detecting an instance's body memo by sampling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`download_document` returns `bytes`, so a consumer mirroring attachments between systems has to hold the whole file. With the base64 inflation an upload applies, a 32 MB attachment peaked near 76 MB of worker memory for what is conceptually a pass-through. `stream_document` hands the body over in 64 KiB chunks (`chunk_size=` to change it) so the file never has to exist in memory whole. It accepts exactly what `download_document` accepts and resolves the URL through the same `resolve_url`, so the same-origin refusal, `follow_redirects=True` and the error mapping are shared rather than re-derived. Only the download direction can stream. EasyVista takes an attachment as base64 inside a JSON body, so `add_document` must materialise the whole payload before it can send anything; the asymmetry is the API's. The retry decision, which is the part worth questioning: a streamed response cannot be restarted once bytes have reached the caller, because restarting would deliver them twice, and this method will not silently duplicate data. But refusing to retry at all would make streaming strictly less reliable than `download_document`, which retries. So the retried unit is "open the download AND take its first chunk" -- everything inside `_open_stream`, which produces nothing the caller has seen yet and is therefore safe to repeat under the same policy `get_bytes` uses (same attempt count, same backoff, 590 still not retried). From the first chunk onwards nothing is retried: a transport failure surfaces as `EasyvistaConnectionError` and a partly consumed stream is never resumed, which is stated in the docstring because it is the caller's problem to handle. A test asserts the request count on a mid-body failure, so making this resumable fails loudly. Two consequences of streaming forced small decisions. `_raise_for_response` reads `.content`, which a streaming response refuses until the body has been read, so the error path reads it first -- that is what keeps a 403 on the streaming path identical to a 403 on the buffered one, asserted as an equality between the two rather than against a hardcoded type. And httpx spells its streaming methods with a leading `a` rather than the `Async` prefix unasync's convention knows about, so `aread` and `aiter_bytes` join `aclose` in TOKEN_REPLACEMENTS, with the rationale recorded there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of 8ac38fa found seven statements about `stream_document` that nothing backed, plus one connection released later than it reads. What was false and is now true: - The document-workflow skill's sync/async banner carved `async for` out for the `iter_*` methods only, so a reader following it literally wrote `await client.stream_document(doc)` -- a TypeError, because the method is deliberately not named `iter_*`. The carve-out now names it, and says why awaiting it fails. Only this skill's copy of the shared banner changed; it is the only one with a byte-streaming method. - `unasync_build.py`'s rationale for the `aread`/`aiter_bytes` entries claimed an unmapped httpx async name would raise `AttributeError`. It would not: httpx 0.28.1 defines `aread`, `aiter_bytes` AND `aclose` on `httpx.Response` itself, so in sync code the attribute exists and merely misbehaves -- an un-awaited coroutine leaving the body unread (then `ResponseNotRead` from the error mapping instead of the mapped EasyVista exception), or `TypeError: 'async_generator' object is not iterable`. Only `aclose` on the *client* is genuinely absent. The comment now says so, which is a stronger argument for mapping every one of these names than the version it replaces. - CHANGELOG called "a 32 MB attachment peaked near 76 MB of worker memory" a measurement of ours. It is not ours, appeared nowhere else in the repo, and pointed at the wrong half: of that peak only the download buffer is what this change removes, while the base64 payload `add_document` builds is untouched. The bullet now states the motivation without borrowing a number. - `docs/user_guide.rst`'s new streaming subsection showed only the sync loop while the guide tells async readers every method is a coroutine. It now spells out `async for chunk in client.stream_document(...)`, matching the treatment the pagination section already gives `iter_tickets`. - A test comment said 10240 bytes was "not a multiple of the chunk size" at chunk_size=1024. It was exactly ten chunks, and no streaming test anywhere used a ragged body, so a short final chunk was never exercised. Both the transport and the client case are now genuinely off the boundary and assert the tail. Claims that were true but untested, now pinned: - `test_stream_bytes_retries_a_failure_fetching_the_first_chunk`: the design decision three documents assert -- the first chunk is fetched inside the retried unit, so a failure before any byte reaches the caller is still a safe restart. The rejected alternative (retry the open alone) passed all twelve existing `stream_bytes` tests; it fails this one. - `test_stream_bytes_chunks_at_the_documented_default_size`: every other chunk-counting test passed `chunk_size` explicitly, so `DEFAULT_STREAM_CHUNK_SIZE` could change to anything and leave "64 KiB by default" false in three places with a green suite. - `test_stream_document_closes_the_inner_stream_when_stopped_early`, with the fix it needs: `stream_document` iterated the inner `stream_bytes` generator and never closed it, so a caller that stopped early left the response -- and its pooled connection -- checked out until the generator became garbage, which on the async surface means the collector plus the event loop's asyncgen finalizer. An explicit try/finally releases it at once on both surfaces; `contextlib.aclosing` could not be used because the codegen cannot map it to its sync twin. `stream_bytes` is now annotated `AsyncGenerator[bytes, None]`, which is what it always was. Gates: 729 passed (was 723), 99 skills-contract, docs examples 24 passed, mypy clean over 38 files, ruff check + format clean, `unasync_build.py --check` up to date, sphinx -W succeeded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verify pass on the streaming download found six pieces of prose that were wrong rather than merely untidy. Four are the same defect class this branch has been closing all along: a comment or docstring in `_async/` is copied verbatim into `_sync/`, so one that is false there is false twice. - A test helper's comment said its unreachable `yield` stops the function being a coroutine. In the generated sync tree there is no coroutine to avoid; the yield is simply what makes it a generator function. Now says that. - `_ClosableStream`'s docstring named only `aclose()`. unasync rewrites the method to `close()` but not the name inside a docstring, so the sync twin documented a method it does not have. Names both, as the rest of the tree does. - A comment pointed at "the join above" when the assertion is eight lines below. - The new `try/finally` comment claimed it fixes the `break` case. It does not, and the report that introduced it argued so correctly: unwinding this generator is itself deferred to the event loop's finaliser on the async surface, so a bare `break` still defers. What the `finally` removes is the second wait, for the inner generator to become garbage on its own. Says that now, including why the sync surface never needed it. - `skills/README.md` still told readers to `await` every method while its own inventory advertises `stream_document`, which is an async generator. That is the third file to carry this exact defect, after the skill's banner and the user guide. - One `--` inside a CHANGELOG paragraph whose other prose uses an em dash. Markdown applies no smart typography, so both rendered in the published notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Minor, not patch. The release carries two breaking changes, and CHANGELOG.md's own policy line says breaking changes land between minor versions while the package is pre-1.0 — 0.1.1 would have contradicted the file it appears in: - read-model timestamps are timezone-aware `datetime` instead of `str`; - `ev_since_filter` / `ev_between_filter` now refuse a bound whose time carries no UTC offset, which they previously passed to the wire. The version lives in more places than the two the release workflow names, and two of them are enforced: `testing/test_public_api.py` asserts `__version__` outright, and `scripts/tests/test_skills_contract.py` asserts every skill's frontmatter version equals it — with a comment saying a release that bumps `__version__` and forgets the skills fails there. It does. All eight are bumped. The link block is also repaired. `[0.1.0]` pointed at `releases/tag/v0.1.0`, which has never existed: the tag that was pushed is bare `0.1.0`, so that link 404s in the published changelog today. It now points at the real tag. `[0.2.0]` is written v-prefixed to match the convention `.github/workflows/release.yml` documents and validates against; GitHub's compare view accepts the mixed pair. No tag is created here. Tagging is outward-facing and deliberately left to a human — use `v0.2.0`, both to satisfy the workflow's convention and to avoid repeating the bare-tag mistake that broke the 0.1.0 link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…k stamps
Three measured behaviour changes, each closing a gap between what the code
claimed and what the wire does.
1. An interval bound naming a time is now NORMALISED, not passed through.
`_TIMESTAMP_RE` admitted renderings the API rejects: measured live
2026-08-18, `LAST_UPDATE:(2025-11-28T16:14:41+01:00;)` returns HTTP 590, as
do minute precision, `seconds+00:00` and a space separator instead of `T`
(what `str(aware_datetime)` produces). Only a bare date and
millisecond-precision-with-offset are honoured. That mattered because the
offset gate added earlier on this branch makes an offset MANDATORY on a
time, and the obvious way to comply with a stored `"2026-08-17T20:26:40"`
watermark is to append `+02:00` -- which 590s every sweep. An admitted
string bound is now re-rendered through
`format_ev_datetime(parse_ev_datetime(text))`, a bare date passes through
unchanged, and the string and datetime paths finally emit byte-identical
bounds. The comment at filters.py claiming the regex "accepts only the
renderings measured live" was false; it is now the admission gate and says
so. `test_since_emits_the_open_ended_interval` pinned the 590-ing literal as
canonical and no longer does.
Two sub-cases handled with it: the RENDERED bound is validated, so a zone
whose UTC offset is not a whole number of minutes (any pre-1900 zoneinfo
entry) raises locally instead of emitting `+05:53:20` that the string path
would refuse; and lowercase `z` is now accepted, since `parse_ev_datetime`
accepts it on the read path and the gate rejected it with a misleading "not
a timestamp" message.
2. `ev_contains_filter`/`ev_starts_with_filter` now refuse `_` and `[` as well
as `*` and `%`. All four are metacharacters to `~`: measured live,
replacing one character of an RFC that matched 1 row with `_` matched 9, and
`[0-9]` likewise, while `[<realchar>x]` matched 1. No escape exists -- `\_`
matched 0 rows, so the backslash is compared literally. `_` is pervasive in
EasyVista codes, so `ev_contains_filter("ASSET_TAG", "LAPTOP_01")` silently
also matching `LAPTOP-01` with HTTP 200 was a routine input producing wrong
rows. Refusing is the rationale already written there for `*`/`%`.
3. A malformed timestamp column now RAISES instead of falling through to
pydantic. The fallthrough defeated its own purpose: `"20260817"` became
`1970-08-23T12:00:17Z`, 56 years off and silent, and `1755434441610` -- what
an epoch-millis format change looks like -- became a wholly credible
`2025-08-17T12:40:41.610Z`, absorbing the one signal the guard exists to
raise. The docstring already promised a raise; the obsolete epoch-seconds
paragraph is gone. The `""` unset sentinel still becomes `None`.
Also documents the deliberate read/write asymmetry in `timestamps.py`:
`parse_ev_datetime` assumes UTC for an offset-less literal because a read must
never fail a record, while `_interval_bound` refuses the same shape because a
mis-zoned bound skips records silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nk_size `list_actions` sent no `max_rows` and never paginates, so a ticket's action log was truncated at the server's own default (25 on the verified instance) -- the one search-backed call on this client that did not inject `config.default_max_rows`. It now passes it explicitly, so the truncation point is the caller's to see and to raise. Pagination is deliberately NOT added here: it changes behaviour and needs live verification that this endpoint's `@next` behaves like the others'. What this branch newly did was attach completeness claims to that truncation -- `list_actions`'s docstring said "read every action's timestamps and author in one request", `models/action.py` said "every one of these top-level in one request", and the CHANGELOG said "a whole ticket's action metadata". All three are false for any ticket with more actions than one page, and a freshly created ticket already carries about twelve. They now say "a page", and both `list_actions` and `get_ticket_context` state plainly that at most one page is returned, that nothing paginates, and that the excess is dropped with no error -- `get_ticket_context` because it consumes the list, so `TicketContext.to_markdown()` renders a silently truncated log. `Transport.stream_bytes` now rejects a non-positive `chunk_size` locally. Measured: `chunk_size=0` escaped as "ValueError: range() arg 3 must not be zero" and `-8` as "IndexError: list index out of range", both thrown from inside httpx's ByteChunker several frames below this client, so a caller computing a size read a library bug rather than bad input. Prose corrections on the same surfaces, all previously false or absent: - `resolve_url` said "The API is trusted to describe its own instance, not to redirect us off it". Both download paths run `follow_redirects=True` and a `302` to another host IS followed; the credential is dropped, but the foreign bytes are returned as the attachment. The docstring now states what is actually guaranteed. Behaviour unchanged -- signed-location hops need it. - `stream_document` documents that stopping early on the async surface needs an explicit `aclose()`, or the response stays checked out of the pool for a GC cycle. This was written only in a comment inside the method body. - `iter_tickets` documents the accepted sort token: space-separated `FIELD DESC` works, `FIELD:DESC`/`-FIELD`/`DESC(FIELD)` are silently ignored, and the sort is load-bearing on a change-window sweep. - `update_action`'s return value is the API's unverified echo and may be sparse; re-read with `get_action`. - `RECENT_TICKETS_SORT` sorts a varchar, so `get_department_context`'s "newest-first" is now "descending RFC_NUMBER" -- the live test proves a string ordering, and on an instance issuing more than one RFC prefix letter every `R...` ticket outranks every `I...` one regardless of date. - `aggregate_tickets` records that an offset-less `created_since` bound is read as UTC, which silently shortens the window by the instance's offset. - `_fields.py` named `reporting` as a co-consumer; it has never imported the module (`references.resolve_reference` is its path). And "byte-identical to what the API sent" is false for any input whose fraction is not 3 digits -- the repo's own fixture shows it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pdate's writes Adds the guards the prose in the previous two commits now depends on, and repairs four assertions that could report green while asserting nothing. New: - `test_only_some_timestamp_renderings_are_accepted_as_an_interval_bound` walks the matrix normalisation rests on: a bare date, `ms+offset` and `ms+Z` are honoured; `seconds+offset`, `minutes+offset` and a space separator each raise 590. The seconds case is the one that matters -- it is how a caller naturally satisfies the offset rule, and the unit suite used to pin it as canonical. - `test_the_ascending_sort_token_the_docs_recommend_is_honoured`. Round 1 measured bare `LAST_UPDATE` and `LAST_UPDATE ASC` as ascending, but only the DESC form was pinned; the sweep guidance now tells callers to use the ascending one, so it is pinned rather than remembered. Skips when the default page order is already ascending, so it cannot pass for a coincidental reason. - `test_request_update_writes_impact_owner_and_external_reference` (test_live_ticket_identity.py). `RequestUpdate`'s three new fields had no live read-back, while their unit test's docstring read as though one existed. Under this branch's own measured rule -- a 200 on a PUT is not a receipt, a field the API cannot honour is silently dropped -- that is exactly the gap that ships a field which does nothing. One field per PUT so a failure names the field; the impact and owner ids are sampled from the instance rather than hardcoded, because writing back the value `ticket_factory` already set would pass even if the field were dropped. The unit test's docstring no longer credits itself with a verification it does not perform. - The `%`-wildcard characterization is extended to `_` and `[0-9]`, the two metacharacters the builders newly refuse, plus a `\_` probe showing no escape exists. Renamed to say what it now covers. Distinguishes "matched nothing" (compared literally -- the regression) from "matched no more than exact" (a sparse sample -- a skip). - The `update_action` live test now characterizes the PUT's echo, which had never been captured. It asserts the echo never names a DIFFERENT action; asserting it names THIS one would pin a shape nobody has measured, which is why the docstring and skill instead say to re-read with `get_action`. Repaired: - The comparison-operator control asserted `0 <= control <= baseline`, which is unfalsifiable: `_count` never returns a negative and a filtered count cannot exceed the unfiltered one. Its failure message described a state that could not occur. Now `0 < control < baseline`, which additionally proves the literal was honoured rather than merely not rejected -- a strictly stronger licence for attributing the two 590s to the embedded comparison syntax. - The `LAST_UPDATE DESC` monotonicity check guarded the length of the RFC list while checking the timestamp list, so it passed vacuously whenever fewer than two timestamps came back. Guards the list it actually checks. - The tilde test asserted `exact <= by_prefix`, satisfied by `by_prefix == exact == 1` -- the state its own sibling test skips as inconclusive, and in which it proved nothing about `~` being a pattern operator. Now skips there and asserts the strict bound. - `RECENT_TICKETS_SORT`'s failure message said "newest-first" for what is a string ordering on a varchar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…se claims Prose only; no code in this commit. CHANGELOG: - The `Changed` section asserted, as a verified live fact, that 0/15 sampled tickets have a non-empty `DESCRIPTION` -- a figure the `Fixed` section fifty lines below explicitly retracts as a sampling artifact (a pooled 77-row sample found `DESCRIPTION` populated on 27 rows). One release entry must not contradict itself, and the CHANGELOG was the last place in the repo still asserting the withdrawn number. The load-bearing claim -- `RequestUpdate.description` writes `COMMENT` -- is untouched. - The BREAKING retype bullet documented the seven changed types but not the consequence: `json.dumps(ticket.classify_fields().official)` now raises `TypeError`, and `mode="json"` appeared nowhere in the repo. Added to the migration note, the user guide's Timestamps section and the ticket-workflow skill. - "during this same unreleased cycle" -> "during this 0.2.0 cycle": the section is headed `[0.2.0]`, so a released entry described itself as unreleased. - "~150 commits" -> "117 commits" (`git rev-list --count 6df6a75..3216a33`). The note's other three facts are correct; a wrong count invites distrust of them. - New entries for the interval normalisation, the `_`/`[` refusal, the malformed-timestamp raise, the inclusive lower bound and the sweep-sort hazard. The watermark-sweep hazard, documented in three places plus the docstring: an unsorted offset sweep over a change window can skip a record permanently, because the rows the filter selects are by construction the rows that are changing -- a ticket touched between pages can land before the read cursor, and the next sweep starts from a later watermark. Sorting ascending on the filtered column moves such a row toward the tail so it is seen twice; every sweep example now carries `sort="LAST_UPDATE"` and de-duplicates by `rfc_number`. Nothing in the branch had acknowledged pagination stability at all. Release documentation: - `docs/publishing.rst` and `release.yml` both said the repository's existing tags are v-prefixed. `git tag -l` prints one line: `0.1.0`, unprefixed. The workflow's tag-stripping logic is right; the reason given for it was untrue. - `publishing.rst` said bump the version in "both places". Four tracked sites hardcode it, and two of them are gated -- so the documented procedure guaranteed a red CI run on every release. All four are now named. - `twine>=5.1` -> `twine>=7.0`: hatchling stamps `Metadata-Version: 2.5` and twine <=6.2 caps its valid-metadata list at 2.4, so `twine check` fails a perfectly good wheel and sdist (measured: 6.2.0 fails both, 7.0.0 passes). - The coverage comment's "1272 statements / 99.21% exactly" is now 1437 / 99.37%; re-stated as a snapshot rather than a canary, since it moves with every added line. - README: the pre-1.0 paragraph -- the second thing a PyPI visitor reads, on an artifact that cannot be re-uploaded -- had three grammar errors, and three relative links 404 on the project page because PyPI does not rewrite them. Skills and user guide, each a claim that was incomplete or false: - search-syntax: `*` and `%` are not the only `~` metacharacters; a dotted relation path (`REQUEST.RFC_NUMBER`) IS honoured in `search`, which `list_actions` depends on, while the "only top-level scalars" rule is about bare nested sub-keys; the ascending sort tokens are named. - ticket-actions: the one-page cap, the unverified PUT echo, and the `created_at`/`updated_at` divergence from `Request`/`Employee`, which a consumer would otherwise meet as an `AttributeError`. - reporting-and-context: "genuinely sorted newest-first" attached "verified live" to an inference about a varchar sort; and an offset-less `created_since` is read as UTC. - client-setup, the designated async reference: "returns coroutines" is false for `stream_document` and every `iter_*`. - document-workflow: the async early-exit close, the `chunk_size` guard, and that streamed bytes are not proof of instance origin. - user guide: the JSON note, the declared-vs-undeclared date column split, and that a `datetime` in `custom_fields` will not serialise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tightening the timestamp validator so junk raises instead of becoming a bogus epoch instant also made it raise on `None`. That is wrong: a JSON `null` is an ordinary wire absence on a column whose own type is `datetime | None`, and a caller passing the field's default explicitly is not an error either. Only `""` and junk were meant to change behaviour. Regression guard added, because this was introduced by the very change that was meant to make the validator stricter — the two absences it must accept are now named in the docstring and pinned by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two commits ago this branch published the opposite ruling on every surface that documents a watermark sweep: sort ASCENDING, on the reasoning that ascending turns a permanent miss into a duplicate. That reasoning was wrong, and the recommendation with it. Offset pagination over a set sorted by the column being mutated can drop a row in either direction; what differs is where the dropped row's own stamp lands relative to the next watermark. Ascending, the re-touched row moves tail-ward and the row that crosses the cursor is a neighbour whose stamp did NOT change: it falls below the new watermark and no later sweep selects it -- lost. Descending, the row that slips behind the cursor is the re-touched one, whose stamp is now above the watermark, so the next sweep re-selects it -- deferred and self-healing. So `sort="LAST_UPDATE DESC"` plus de-duplication is now the guidance in ev_since_filter, iter_tickets, the user guide, the search-syntax skill and the CHANGELOG, each carrying the real reason instead of the old one. Keyset pagination is named as the fully robust alternative for a caller who cannot tolerate even a deferred miss, with the honest note that iter_tickets cannot express it because it owns its own offset. The live sort characterization now runs with the change window applied: the guidance is exclusively about a FILTERED sweep, and `sort` has the same silent-ignore fate a search condition has, so "honoured alongside a search" was unmeasured. The ascending pin is kept -- both tokens really are honoured, which the docs still state -- but renamed and re-framed so it no longer reads as the recommended sweep form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…re recommended The last round taught the search-syntax skill that `_` and `[` are metacharacters under `~` and that ev_contains_filter / ev_starts_with_filter now raise for them, but it stopped there. The primary published doc still enumerated only `*` and `%` and steered callers to those two builders using an ASSET_TAG example -- the very column whose codes are underscore-pervasive -- as did the README and the asset-workflow skill. A reader following any of the three passed `LAPTOP_01` and got a ValueError none of them warned about. The user guide's grammar bullet, the README snippet, the user guide's asset example and the asset skill (snippet plus its own Gotcha) now all carry the `_` / `[` / no-escape facts. They also carry the exit none of them offered: `:` does not expand a wildcard, so an exact match on a value containing a metacharacter is expressible with ev_equals_filter, and only pattern-matching AROUND a literal metacharacter is impossible. That exit is now stated by the builders' own ValueError message and their docstrings too, so the person who hits it at runtime is not left without a path -- and it is measured rather than remembered: the live characterization now also probes `RFC_NUMBER:"<stem>_"` and requires it to return strictly fewer rows than the `~` probe on the identical pattern, which a wildcard-expanding `:` (or a silently dropped condition) could not do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, add boundary test Review round 1 on the memo parameterisation (Task 7): the round-trip and peak-in-flight docstring numbers were unconditional statements that only held for the default two-memo memo_fields; generalised them to len(memo_fields) + 2 (+ 2N), keeping the async/sync framing and the existing measurement scoped to the default case it was taken against. Tagged the path-segment memo-selector claim tier 2, citing docs/vendor-api-reference.md (declared in the instance's OpenAPI paths). Warned callers off the bare string footgun -- str satisfies Sequence[str], so memo_fields="solution" type-checks and iterates characters -- as a docstring caveat only, per the no-new-refusals constraint. Restored the memo_fields=() test that was verified live then dropped for not being in the brief's literal list: the brief's tests are a floor, not a ceiling, and this is the exact boundary the slicing arithmetic depends on.
…itten against Reads info.description from the instance's own OpenAPI spec. Asserts 201, not 200: a GET to /swagger answers 201, and a habitual == 200 check would skip the version assertion and pass for the wrong reason.
Documents what tasks 1-7 actually landed, not the pre-implementation draft: workflow_start is tier 3 (instance OpenAPI schema only), not vendor-documented like the other eleven new PostRequest fields; origin and impact_id were each widened to int | str for a different reason, not the same one. Also corrects CHANGELOG.md:378 for a present-day reader without rewriting the released 0.2.0 entry: PostRequest.catalog_guid is back, Request.catalog_guid stays gone.
`department_id` and `recipient_id` were typed `int` on the authority of a sentence claiming they were "the fields that are genuinely int-only here", while docs/vendor-api-reference.md types both as strings at tier 1 -- the same evidence that had already widened `location_id` two paragraphs below, with no explanation for the opposite outcome. Both are now `int | str`, and the sentence now says what it actually means: which types this model accepts and why, rather than an API claim the neighbouring "ids may be sent as JSON numbers or as strings" sentence denies. `impact_id` gets `Field(union_mode="left_to_right")` with `int` first, so a quoted value coerces back into the integer the vendor documents. Widening it to `int | str` had silently changed the wire form for a caller passing "28": pydantic's smart union keeps the exact type match where the declared `int` used to coerce, which inverted the stated reason for its `str` branch. A non-numeric string is still accepted and passes through as written, so nothing previously accepted is refused. Also restates the create-body test as tier 4 and renames it. Its docstring still claimed the seven-field body was "the vendor-documented body" and that sending a subset "is what produces the 590" -- the artifact it cites says the opposite (required is catalog_guid OR catalog_code, everything else optional) and PostRequest's own docstring already calls the fuller body a hedge against per-catalog configuration. BREAKING CHANGE: `PostRequest(department_id="9")` and `PostRequest(recipient_id="42")` now send `"9"` / `"42"` where they used to coerce to `9` / `42`. Both forms were measured accepted on one instance (tier 4, 2026-08-25); a deployment that discriminates on JSON type would see a behaviour change. Pass an `int` for the old bytes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`to_api` promised `extra_payload` "wins ... over a declared field" and then
merged by exact key match, while EasyVista's field names are case-insensitive
(tier 1). So `PostRequest(urgency_id=8, extra_payload={"URGENCY_ID": "4"})`
put BOTH on the wire with conflicting values and left the winner undefined --
and the ALL_CAPS spelling is the likely one, not a corner case, because it
mirrors the read side's ALL_CAPS convention, which is where callers copy
names from. The branch's own `extra_payload` test already used that form.
An `extra_payload` key now replaces any declared or `custom_fields`-produced
key it matches when case is ignored, and `extra_payload`'s own spelling and
value are what ship. This is a merge rule, not a validation: a collision is
never an error, and nothing raises that did not raise before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…comment
`to_markdown` rendered only `description`/`comment`, so the headline case for
`memo_fields` -- `get_ticket_context(rfc, memo_fields=("solution",))` on a
deployment whose body memo is neither default -- produced a Markdown export
with no body section and no warning. Fetch was parameterised and render was
not. It now applies the same role-naming rule to `memos`: when neither
default memo has text, a single populated one becomes the body under
"## Description", and several each get a heading derived from the field name
requested.
The comment above that block was also asserting "0/15 sampled tickets had a
non-empty DESCRIPTION, 15/15 had a COMMENT" while models/request.py says an
earlier 15-ticket sample "was not representative; do not rely on that being
true of any instance" and cites a pooled 77-row one. Two shipped files, one
contradicting the other. The comment now carries the 77-row finding, tagged
tier 4 with its date, and keeps its reasoning about why neither memo is
hard-coded as "the body".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The user guide presented `custom_fields` as *the* escape hatch on write models, which leaves a reader concluding there is no route for an undeclared *official* field -- incomplete in exactly the way this branch exists to fix. And `catalog_guid` (the vendor-preferred subject identifier this branch restored), `extra_payload` and `memo_fields`/`TicketContext.memos` appeared in no .rst, no README and no SKILL.md. - user_guide: a "two escape hatches" passage distinguishing `custom_fields` (e_-prefixed custom columns) from `extra_payload` (anything else, un-prefixed, wins on merge ignoring case, bypasses validation); a `catalog_guid` mention where the create body is introduced; a `memo_fields` passage where the context bundle is covered, with the bare-string trap; and the heading note extended to memos. - README: the create note now says a subject is what a create needs, guid preferred, instead of "catalog_code + title work for incident catalogs". - easyvista-ticket-workflow: same subject correction, `extra_payload` beside `custom_fields`, and `status_id` dropped from the RequestUpdate field list -- that field was removed earlier in this branch and now raises at construction, so the skill was advertising a call that cannot be made. - easyvista-reporting-and-context: `memo_fields`/`memos` and what `to_markdown` does with them. - vendor-api-reference: the `~` row glossed "Contains" with no caveat while filters.py records the measured finding that `~` without an explicit wildcard degenerates to exact match. The tier-1 tag is honest -- the vendor does say that -- so the counter-evidence is now noted beside it, pointing at `ev_contains_filter`. - test_source_citations: its header told a maintainer to keep `_UNPUBLISHED` in sync with the sibling tuple while the comment fifteen lines below explains it is narrower "and deliberately so". Obeying the header would fail tracked documentation. It now says: in sync where they overlap. - CHANGELOG: a `### Changed` section recording the wire-form change for numeric-string callers of `origin`, `department_id` and `recipient_id`, the `impact_id` coercion, and the `extra_payload` case rule; plus the to_markdown fix. The claim "Whichever type is passed reaches the wire unchanged" was true when written and is no longer, so it is corrected too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ields
The override rule was illustrated with `extra_payload={"URGENCY_ID": 4}`
beside `urgency_id=8`, directly under a `RequestUpdate` example.
`RequestUpdate` declares neither `urgency_id` nor anything else outside its
seven fields, and write models set `extra="forbid"`, so a reader copying that
line got a ValidationError at construction. Illustrate it with `impact_id`,
which the model shown does declare.
The same bullet justified the case-insensitive match with a flat claim that
"EasyVista field names are case-insensitive". The vendor documents that for
the ticket *create* body only; the other write bodies are an assumption. Scope
the claim to match `EasyvistaWriteModel.to_api`, which already had it right.
The ticket-workflow skill described the create body as it stood before the
twelve vendor-documented fields were declared, so a reader would reach for
`custom_fields` for a field `PostRequest` now declares -- and get it wrongly
`e_`-prefixed. Name them. `workflow_start` stays out: its only source is the
instance OpenAPI schema, not the vendor docs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extra_payload entry justified the case-insensitive merge with a flat "EasyVista's field names are case-insensitive". The vendor documents that for the ticket create body only; the other write bodies are an assumption this package makes deliberately. `EasyvistaWriteModel.to_api` and the user guide both state it that way already -- the changelog was the last place carrying the unscoped version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…laims
Three doc examples were plain SyntaxErrors -- a module-level `async for` in
two user_guide streaming/sweep blocks and a module-level `async with` in the
README quickstart. Two more were NameErrors: `internal_note_type_id` was never
defined, and the error-handling block used `PostRequest` without importing it.
Verified by compiling every Python block in the docs: 3 syntax failures before,
0 after.
Two claims are retracted rather than reworded. The error-handling example said
a create is rejected for a "missing mandatory title"; the measured cause is the
omitted origin/department_id/urgency_id/impact_id, and the fuller body is
accepted with no title at all. And a 590 on create does not mean the ticket was
not created -- it means *possibly created* (measured, one instance, 2026-08-25),
which now carries a warning where the example claimed safety.
`validate_docs_examples.py` carried the same false claim in its own live tier,
where it does real damage: `create_ticket(PostRequest(catalog_code=...))` sends
no external_reference, so the row a 590 leaves behind cannot be reconciled by
marker the way the integration suite reconciles its own. The comment claiming
"no ticket is created, so this is safe" is gone and the leak is documented. The
script's docstring no longer claims to check the docs: it never opens an .rst,
and a green run is evidence about its hand-maintained transcription only.
The id legends in the create example (`4 = total outage`, `28 = test`) read as
an API-wide legend for values that are per-instance configuration; they are now
marked as placeholders and pointed at `reference("URGENCY")`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An action has two independent text memos, DESCRIPTION and COMMENT, each
addressable at actions/{id}/{memo}. `PostAction` declared only the first, so
the second was unreachable at create time except through `extra_payload`.
The omission rested on a bad inference, recorded in the model's own docstring:
that an action's text "lives in DESCRIPTION while COMMENT is empty" on the
verified instance, making `comment` a mirror for deployments configured the
other way round. COMMENT was empty because nothing had ever written to it. A
single create carrying both channels was sent live on 2026-08-28 and each read
back with exactly the text sent, separately addressable. The instance's own
OpenAPI declares both on the create body and its example populates both.
What has NOT changed: there is still no visibility flag. The item-level action
record was re-captured the same day -- 88 columns, far more than the model
suggests -- and none is a public/private boolean. So `comment` is a second
channel, not a private one, and the docs now say exactly that: whether a
portal surfaces one and not the other is portal configuration to confirm with
an administrator, not an API guarantee. The previous docs said this API "has
no private-comment feature" full stop, which overstated a real absence into a
false one and hid the channel that does exist.
Marked breaking only because the create body gains a field when `comment` is
set; an unset `comment` is omitted, so every existing caller's bytes are
unchanged (pinned by test).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Creating an action is half the job. An action is a unit of work: created open
(a task to do), then ended (work reported). Only an ended action appears in the
ticket history with its text visible -- an open one renders as a pending row
with no body, which reads as though the text vanished. It did not; the action
was never ended. Measured live 2026-08-28: a type-95 action stayed invisible as
a message until it was ended, at which point its description appeared in the
history.
Ending sets START_DATE_UT, END_DATE_UT, ELAPSED_TIME and
STATUS_ID_ON_TERMINATE, fills DONE_BY_ID and clears GROUP_ID -- the record moves
from "assigned to a group" to "done by a person". None of those can be set on
create: they return HTTP 200 and are dropped in silence.
The vendor documents ending as PUT actions/{rfc_number} with an `end_action`
wrapper and dates in the instance's DATE_FORMAT (dd/mm/yyyy, NOT ISO 8601). Not
implemented here, and it could not be made to work on the verified instance:
every documented form returned 590 "Action not found", including for a user who
could end the same action through the UI. Recorded as an instance/profile
restriction to raise with an administrator, not as a payload to keep guessing at.
Two retractions, both of claims this package stated in its own voice:
- "This API has no private-comment feature" overstated a real absence (no
per-action visibility flag, confirmed across 88 item-level columns) into a
false one. Visibility is carried by the action TYPE: 94 = Commentaire
[Public] / Customer Comment, 95 = Note Interne [Prive] / Internal Note.
- "The API cannot reveal which type is which" was wrong, and the CHANGELOG
correction that established it deleted a true finding. Two bracket
conventions exist: a whole label bracketed and echoing another language is an
untranslated placeholder, but a bracketed suffix on distinct text with real
sibling translations is a genuine visibility marker. The correction
generalised the first over the second. Type ids are also discoverable despite
GET action-types being 403 -- every action record carries ACTION_TYPE_ID
beside translated ACTION_LABEL_* columns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A task and an action are the same underlying record, created in different
states. An action is born OPEN -- work still to do -- and the UI renders it as
a pending row with its text NOT displayed, which reads as though the note was
lost. A task is born ENDED, so it lands in the ticket history with its text in
a single call.
`POST requests/{rfc}/tasks` differs from the actions create in two ways, both
measured live 2026-08-28: the body is flat at the root rather than wrapped, and
the record comes back with END_DATE_UT and STATUS_ID_ON_TERMINATE already set.
An internal-note task also needs no parent_action_id, where the equivalent
action does. Verified end to end through the new method: type 95, ENDED, text
readable at actions/{id}/description.
Adds `PostTask` (mandatory action_type_id|action_type_name and one of
group_id|group_name|group_mail, refused locally rather than drawing a 590 that
names no field), `build_create_task`, and `create_task` on both clients.
This is what `create_action`'s docstring should have said all along, and the
docs it lands beside were wrong twice over: the previous commit routed callers
through a two-call action-then-end path whose second call cannot be made on the
verified instance, and the commit before that offered `comment` as if a second
text channel were the mechanism. Neither was. The user guide and the actions
skill now lead with a task-vs-action table and send comments to `create_task`.
Also corrects a test that asserted the false generalisation directly:
`test_a_bracketed_action_label_is_an_untranslated_placeholder_not_a_marker`
became two tests, because both bracket conventions are real and mean opposite
things. `_usable_label` already drew the line correctly -- it rejects only a
label that is entirely bracketed -- so the code was right and only the prose
around it was wrong.
Why this was missed: `/requests/{rfc}/tasks` was found in the instance's
OpenAPI early and set aside because its example showed `group_mail` while
GET /groups is 403. The vendor documents group_id, group_name OR group_mail --
an example was read as a requirement, which is exactly the tier-3 trap
docs/vendor-api-reference.md warns about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vendor documents closing as PUT requests/{rfc_number} with a
{"closed": {...}} wrapper -- the exact route this package already sends. So
O-CLOSE resolves in the package's favour: PUT|PATCH requests/{rfc}/close is an
alternate path, not the canonical one, and there is nothing to migrate to. The
open item claimed switching "needs its own live check"; the check needed was
reading the documentation.
https://docs.easyvista.com/docs/rest-api-close-an-incident-request.md
That page also names two body fields this package never declared, so they were
unreachable -- and unreachable with no workaround, since neither close_ticket
nor any write model would carry them:
* end_date -- omitting it stamps now; supplying it back-dates the closure
* catalog_GUID -- requalifies the ticket as it closes
Both are now arguments on close_ticket / build_close_ticket. end_date stays a
string: it takes the instance's own DATE_FORMAT, which is dd/mm/yyyy on the
verified instance and NOT ISO 8601, so formatting a datetime here would be a
guess. delete_actions widens to int | bool -- the vendor types it boolean and
the package accepted only int.
close_ticket also had no docstring at all. It now carries the verification
methodology, because getting this wrong bit during this session's own testing:
a cleanup script assumed status_id 12 meant closed and skipped a ticket that
was still open. On the verified instance 8 is Cloture and 12 is En cours --
adjacent ids, opposite meanings, neither guessable. The docstring directs
callers at end_date_ut instead, which is empty on an open ticket and stamped on
a closed one, and is therefore portable in a way no status id is. Verified live
2026-08-28: end_date None -> 2026-08-28T17:18:45, status 12 -> 8.
Four tests pin the body shape, the all-optional envelope, the route (so nobody
"fixes" it into the subpath), and the bool passthrough.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Agent working notes, not project documentation: they describe the private preprod instance's behaviour and this machine's credential layout, which is the same reason docs/API_Info.md and scripts/probe_*.py are already ignored. Anything a public reader needs belongs in CONTRIBUTING.md, docs/ or a skill instead -- and the citation guard already refuses a tracked file that points at a gitignored path, so this cannot become a dead link for a stranger. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This package wraps roughly ten of the hundred-odd paths an instance advertises in its own OpenAPI document, and every default in it was measured against one French 2025.3 deployment. Anything else needed a fork. These are the seams that make it adaptable by configuration. - client.send(method, path, *, params=, json=, headers=) reaches any api_root-relative route this package does not wrap, sharing the retry policy and the error mapping with every typed method. An absolute URL is never followed: path always joins to api_root, which is what keeps the credential scoped to the configured instance. - params= on the fifteen get/search/iter methods, layered UNDER the resource builder's own parameters, so a caller can add formatDate without being able to replace the ticket filter on list_actions or the offset an iter_* sweep is stepping. - EasyvistaConfig gains extra_headers, user_agent, default_params and additional_download_hosts, and verify_ssl widens to a CA-bundle path or a prepared SSLContext. extra_headers and RequestSpec.headers both refuse an Authorization key, in any casing, at construction. A header bag may override anything this client sets EXCEPT the credential: silently shadowing config.token would send a secret the client cannot see, redact from a repr, or rotate. extra_headers is deliberately not sent to a host allow-listed by additional_download_hosts, which is where a second secret would leak. from_env() reads none of these. They describe how one deployment differs from another, and for a pip-installed library a constructor argument is the right home for that; the environment is a last resort. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every human label this package resolved came back English first, French
second, and a hardcoded scan after that. On a deployment whose primary
language is neither, the answer was a bracketed placeholder echoing the
primary text rather than the text itself.
languages= now threads through resolve_reference, localized_label,
EasyvistaModel.reference, TicketContext.to_markdown, aggregate_tickets,
ticket_statistics and get_department_context, defaulting to
DEFAULT_LANGUAGE_ORDER -- the order the package already used, so nothing
changes for a deployment the old order suited.
BREAKING: a [bracketed] untranslated label no longer wins over a real
sibling translation, and a usable non-English/non-French column now
beats a *_PATH value. Both were silent wrong answers rather than errors,
so a caller relying on the old output was relying on a placeholder.
Two bracket conventions mean opposite things, and the resolver now keeps
them apart. A label wrapped ENTIRELY in brackets echoing another
language is an untranslated placeholder and is skipped; a bracketed
SUFFIX on distinct text with real sibling translations ("Commentaire
[Public]") is genuine content and is kept. Conflating them once deleted
a true finding.
Action.label reads the ACTION_LABEL_<lang> columns on that rule. Prefer
it over action_label_fr, which names one column and yields
"[Customer Comment]" on an English instance rather than None.
_fields._label is gone. Label resolution now lives in references alone,
so the package has one placeholder rule and one language order rather
than three.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vendor documents `~` as plain Contains (tier 1). This package measured it live on 2026-08-17 against one instance as a pattern operator that matches nothing without an explicit wildcard (tier 4, may not generalise), and appends `*` on that reading. Both readings cannot be right, and on a deployment this one is wrong for the appended `*` is compared literally: the filter returns zero rows with HTTP 200 and no hint. That is a silent narrowing, the mirror of the silent widening the metacharacter guard already prevents. ev_contains_filter and ev_starts_with_filter now take a keyword-only wildcard: Literal["*", "%"] | None = "*". Pass None on a deployment that follows the vendor's reading, or "%" for a LIKE-style backend. The default is unchanged, so output against the verified instance is identical. The metacharacter guard now splits by who owns each character. `_` and `[` are metacharacters of `~` itself and stay refused at every setting, which this repository's own wildcard-free live probe settles. `*` and `%` are refused only while a wildcard is being appended, because that is the only time they are ambiguous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A read model that refuses a value fails the whole record, and because a search validates a page in one comprehension, one odd column fails every record on that page -- and, through get_department_context, the whole bundle. Every widening here is a column measured on one instance being allowed to differ on another. - Asset.asset_id and .status_id accept EasyVista's "" sentinel. They were the only read-model ints left on a bare int | None, so a CMDB row with STATUS_ID: "" took the page and the bundle down with it. - Request.time_used_to_solve_request and Document.document_id widen to str | int, left-to-right so neither coerces. - EasyvistaConfig.datetime_input_formats names extra strptime patterns, tried only after EasyVista's own ISO 8601 form fails. Nothing is guessed: an unmatched value still raises, and a real ISO stamp cannot change meaning, because it is tried first. The context is bound at build time, so the parser signature never changes. - get_ticket(fields=) projects the item route, for when one column poisons the record. With the tier-2 caveat in its docstring: the verified instance declares fields on the list route, not the item one. BREAKING: PostAction now requires an action type and a group, on the same tier-1 sentence PostTask has always been guarded by. Sent without them the route answers HTTP 590 and names no field. Both guards now read the body to_api() will actually send, so a field supplied through extra_payload satisfies them. PostAction.action_type_id and .group_id widen from int to int | str, and the model gains action_type_guid, group_mail and parent_action_id. They had diverged from PostTask for no recorded reason, so a non-numeric type or group id worked through create_task and failed through create_action. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Envelope casing is not stable across deployments. The instance OpenAPI
document read 2026-08-27 spells every envelope lowercase in its
examples, yet the live GET requests/{rfc}/documents on the verified
instance answers a capital-D "Documents". Matching stays over the same
fixed candidate list -- never over whatever keys the payload happens to
carry, which would let an arbitrary record column win -- and the
priority order is unchanged.
A matched list must also be empty or hold at least one dict to be
accepted as the envelope. Without that, a payload whose envelope-named
key held scalars returned [] silently, indistinguishably from an empty
page, rather than falling through. {"records": []} still means an empty
page.
The create_action, create_task, close_ticket and add_document parsers
now name their envelope key instead of relying on a hardcoded fallback
tuple that belongs to no resource in particular. A deployment echoing
the created record under a wrapper handed model_validate the wrapper
itself, and extra="allow" accepted it: a well-formed record with every
field None, at HTTP 200.
EasyvistaConfig.document_delete_path_style selects between the two
attachment-delete routes the instance OpenAPI declares. The default
"nested" is the form verified live; set "top_level" on a deployment that
grants that one instead, or when only a document id is in hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_department_context hardcoded the column list, the sort, the memo field and the statistics cap it read a department with. Every one of those is a per-deployment fact, so on any instance but the verified one the bundle came back thin with no way to widen it. recent_tickets_sort, ticket_fields, memo_fields and the sample cap are arguments now, and get_department_comment takes memo_field -- the API models the memo name as a path segment, so a deployment may carry others. TicketContext.to_markdown takes the field table as fields=. Nothing swallowed is silent any more. DepartmentContext.degraded and TicketContext.degraded record which sub-resource fetches were refused, as "<branch>:<http-status>". to_markdown renders a one-line notice for a refused section rather than omitting it, so an export cannot read as "this ticket has no attachments" when the attachment list was denied. TicketStatistics.truncated and .population_total do the same for a capped sample: a total of 1 out of a population of 3 was previously indistinguishable from a population of 1. BREAKING: find_departments with an all-digit name now tries DEPARTMENT_CODE before DEPARTMENT_ID. A department whose code is all digits was looked up as an id, and a DIFFERENT department came back with HTTP 200 and no hint. That costs one extra round trip when the digits really are an id. Pass by= to pin the columns, or an empty sequence to skip the fast path. BREAKING: the fuzzy fallback now folds accents as well as case, spaces and hyphens, so "Systemes" matches the same name written with its accents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Almost every value a write needs -- the catalog, the status GUID, the action type ids, the group ids -- is configured per deployment and is not portable from anyone else's. Until now the only way to find them was to read a ticket and guess, and this package's documentation had to spell that out by hand for each one. Four methods. get_api_spec reads the instance's own OpenAPI document (it answers 201, not 200, so code gating on == 200 skips it), list_reference_table reads one reference table, discover resolves one name by route or by sampling live records, and describe_instance gathers a whole profile. list_reference_table lets a 403 propagate and never returns []. An empty table is a legitimate answer on a lightly configured instance, so collapsing the two would let a caller build a status map from nothing and conclude the instance has no statuses. describe_instance is the layer that swallows, and it names every gap in .unavailable -- read that before the tables, because a total outage otherwise looks exactly like a bare instance. It catches EasyvistaError alone, so a bug in this package still propagates instead of being buried as a fake instance limitation. GenericRecord declares no columns, on purpose. A reference table's response schema is tier 3, and the verified instance's own /status schema is visibly wrong: it describes an SLA-shaped object with DELAY and WORKING_HOURS_ID and no status id at all. A column list written from that would be a guess frozen into the public API. Action types are sampled rather than listed: GET action-types is 403, but every action record carries ACTION_TYPE_ID beside its translated ACTION_LABEL_* columns. The label falls back to those sibling columns for ACTION_TYPE alone -- an action's type label does not live in a nested object, which is the one shape resolve_reference cannot read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The user guide and the actions skill each stated the visibility rule
correctly and then retracted it a dozen lines later. Both halves were
true and neither said so: there is no action-types route at all, so
there is nothing to enumerate and nothing for an administrator to
unblock -- AND the ids are recoverable from the data, which
client.discover() now makes a one-liner.
INTERNAL_NOTE_TYPE_ID = 20 appears in no test, probe or fixture. It was
invented, and it predates the measurement that established 94 and 95, so
it is deleted rather than replaced with another number.
Several docstrings read an HTTP 403 as a permission verdict. This API
answers 403 for an unknown path as well as a denied one, so none of them
ever distinguished "you may not" from "there is no such route". The
instance OpenAPI document read 2026-08-27 settles each case: the nested
requests/{rfc}/actions/{id} route does not exist, and actions/{id}
declares no DELETE.
close_ticket's "omitting status_guid closes to the instance's default
Closed meta-status" is retracted, not corrected. It is asserted in two
docstrings, absent from the citable artifact, and never exercised: every
close_ticket in integration_tests passes an explicit guid. Both
docstrings now hedge, and it is recorded as open item O-CLOSE-DEFAULT.
Writing a tier-1 row from a docstring that cites the vendor page would
launder an unverified claim into the file whose whole purpose is telling
those apart.
A new "First steps on your instance" section is now section 2 of the
user guide. The nearest equivalent sat 86% in, and a reader needs it
before they write anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps found by running the pre-release walkthrough against a live
instance, both of which made the package disagree with the ticket on
screen.
end_action(rfc, action_id=..., ...) finishes what create_action starts.
An action is born OPEN and its text does not render in the ticket history
until it is ended, so without this the package could create an action
nobody could read -- the walkthrough had to reach into client._transport
with a hand-built body to finish one. Addressed by the TICKET: the path
segment is the rfc_number and the action is named in the body, because
actions/{action_id} answers 404 for this verb.
Ending is not bookkeeping. Measured 2026-09-01 (2/2 tickets), ending a
fresh ticket's open type-20 workflow action moved the TICKET from En
cours to Resolu and spawned a new open action; a control (3/3) showed
ending a caller-created type-94 action changed neither status nor action
count. The vendor's id-less "end every open action" form is therefore
behind an explicit end_all=True, and a bare action_id=None is refused --
Action.action_id is legitimately None all over this package (a create
response carries no id, a fields= projection without ACTION_ID drops
it), so forwarding one would otherwise resolve the ticket in silence.
_resolve_action_body now resolves COMMENT when DESCRIPTION is empty. The
UI renders one text field per action -- DESCRIPTION, falling back to
COMMENT -- so resolving DESCRIPTION alone dropped the body of exactly
the actions a human CAN read, and an exported log disagreed with the
screen. Costs a third request only in the fallback case.
Retracts two measured-false claims in PostAction's docstring: ending
does not clear GROUP_ID (it survived on both an ended workflow action
and an ended caller-created one), and "this package does not implement
it" no longer holds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Carries the retractions into the prose. The actions skill still told the reader, in its Examples section -- where an agent copies code from -- that "ending it is 590-blocked on the verified instance", two hundred lines after the same file retracts exactly that claim. The user guide said the package does not implement ending. Both now point at end_action, and both carry the warning that ending a WORKFLOW action advances the workflow while ending your own does not. Also corrected: the guide's tasks-vs-actions table gave the retracted reason for parent_action_id (it is resolved implicitly; an explicit one is needed at 0 or 2+ open actions), its first create_action example taught the invisible write the page now warns about, and the reporting skill described action bodies as merely "pre-resolved" without saying which memo wins. PostTask -- the model for posting a comment, used in six user-guide snippets -- was exported and absent from docs/api_reference.rst entirely, along with two exported constants. Sphinx renders only what that file lists, so the omission was invisible: no warning, nothing for sphinx -W to catch. test_api_reference_coverage.py now fails when an __all__ export has no autodoc entry. The README covered tickets, assets and documents but never actions, which is where this API is easiest to misuse: it now shows create_task for a comment and create_action/end_action for work, with the description-shadows-comment rule. publishing.rst still described the PyPI publisher as *pending*, which is only true before a project's first upload; 0.1.0 has been on PyPI since 2026-08. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges the 0.2.0 section prepared and dated 2026-08-18 into this one. That version was versioned and documented but never tagged or uploaded, so none of its work had reached anyone; leaving it stranded under a version number that does not exist on PyPI would have been the only alternative. 944 content lines carried across unchanged, and the eight duplicate ### Added / ### Changed / ### Fixed headings the section had accumulated over 69 commits are consolidated into four. Five breaking changes ship here, each replacing a silently wrong answer with a correct one or a refusal; the entry opens with them. PyPI carries only 0.1.0 and origin only the 0.1.0 tag, so 0.2.0 is free. Per docs/publishing.rst the tag for this one is v-prefixed: v0.2.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The malformed-timestamp guard was interpreter-dependent, which CI caught on this branch: 3.10 green, 3.11 and 3.12 red on test_a_numeric_shaped_value_raises_instead_of_becoming_an_epoch_instant. From 3.11, datetime.fromisoformat accepts the ISO *basic* forms -- "20260817", "20260817T154041.610", week dates like "2026W331" -- which 3.10 rejects. parse_ev_datetime reached that call for anything it had not already normalised, so the same wire value parsed to an instant on four of the five supported Pythons and raised on the fifth. The package declares requires-python >=3.10 and CI tests 3.10-3.14, so the documented "raise rather than guess" contract held only on the oldest one, and the silent-wrong-instant this guard exists to prevent was live for most users. A value must now start with an extended ISO date (YYYY-MM-DD). Stated positively on purpose: the first attempt at this rejected digit-only strings, which catches "20260817" and misses a basic date-time (it contains a '.') and a week date (it contains a 'W') -- both of which 3.13 duly parsed. EasyVista's format always carries separators, so none of these is one of its timestamps on any interpreter; a deployment that genuinely sends one names it through EasyvistaConfig(datetime_input_formats=...), which is tried after this returns None. Verified on 3.10 and 3.13: 1294 passed on both, where before the fix 3.13 failed the two tests CI failed on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Welcome to Codecov 🎉Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests. Thanks for integrating Codecov - We've got you covered ☂️ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cuts 0.2.0 — the first release since
0.1.0, and 69 commits on top ofmain.Why this is large
A
0.2.0was versioned and dated2026-08-18but never tagged or uploaded, so none of that work ever reached anyone. Rather than strand it under a version number that does not exist on PyPI, this merges it into the release being cut. PyPI carries only0.1.0and origin only the0.1.0tag, so0.2.0is free.What is in it
Portability escape hatches (the branch's original purpose): every per-deployment value — label language order, the
~wildcard, the department context, response-envelope casing — is now a parameter defaulting to the measured behaviour, instead of a constant that only suits one instance.end_action, new here. An action is born open, and an open action's text does not render in the ticket history — so the package could create an action nobody could read, and the pre-release walkthrough had to reach intoclient._transportwith a hand-built body to finish one. It is addressed by the ticket: the path segment is therfc_numberand the action is named in the body, becauseactions/{action_id}answers 404 for this verb.Ending is not bookkeeping. Measured 2026-09-01 (2/2 tickets), ending a fresh ticket's open type-20 workflow action moved the ticket En cours → Résolu and spawned a new open action; a control (3/3) showed ending a caller-created type-94 action changed neither status nor action count. The vendor's id-less "end every open action" form is therefore behind an explicit
end_all=True, and a bareaction_id=Noneis refused —Action.action_idis legitimatelyNoneall over this package (a create response carries no id; afields=projection withoutACTION_IDdrops it), so forwarding one would otherwise resolve the ticket in silence.The export stopped disagreeing with the screen.
_resolve_action_bodynow resolvesCOMMENTwhenDESCRIPTIONis empty, mirroring the UI's own rule. ResolvingDESCRIPTIONalone dropped the body of exactly the actions a human can read.Breaking changes — five
Each replaces a silently wrong answer with a correct one or a refusal. All are marked
**BREAKING**inCHANGELOG.mdwith the reasoning;RequestUpdate.status_idis the removal most likely to bite, since it returned HTTP 200 while dropping the status.Retractions
Several confident claims in this repo turned out to be false and are retracted with the measurement that refuted them: ending an action is not 590-blocked (that message means "no open action matched", e.g. replaying against an already-ended one), ending does not clear
GROUP_ID, andend_date_utis stamped at resolution, not closure — so it answers "stop working this ticket", not "closed".PostTask— the model for posting a comment, used in six user-guide snippets — was exported and missing fromdocs/api_reference.rstentirely. Sphinx renders only what that file lists, so nothing warned.scripts/tests/test_api_reference_coverage.pynow fails when an__all__export has no autodoc entry.Verification
All five CI gates green on the tip, and on each of the three commits individually: 1289 passed, ruff clean,
_sync/up to date, mypy clean on 42 source files,sphinx -Wbuilds.python -m buildplustwine checkpass on both artifacts.Beyond that, the changed behaviour was exercised against a live EasyVista 2025.3 instance: a nine-step walkthrough confirmed step by step in the UI, and
end_actionverified on both the sync and async surfaces. The 28 test tickets that produced these findings are closed.Before merging
docs/publishing.rstwants the tagv0.2.0(v-prefixed;0.1.0is the unprefixed outlier). Publishing a GitHub release with that tag triggers the PyPI upload, so the tag should not be cut until this is merged and CI is green.🤖 Generated with Claude Code