diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae847d4..e4963eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -135,9 +135,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. Tags in this - # repo are v-prefixed (see the CHANGELOG compare links), so the leading v - # is stripped before comparing. + # 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. - name: Validate release tag matches package version if: github.event_name == 'release' shell: bash diff --git a/.gitignore b/.gitignore index 559b660..05d1ae2 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,9 @@ docs/easyvista-field-inventory.md # print real employees' names and e-mail addresses. Kept on disk so they stay # usable locally; never published. scripts/probe_*.py + +# Local agent instructions. Working notes for whoever drives this repo with an +# AI assistant, not project documentation: they reference the private preprod +# instance's behaviour and this machine's credential layout. Anything here that +# a public reader needs belongs in CONTRIBUTING.md, docs/, or a skill instead. +CLAUDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 049457d..9c4909d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,297 @@ a deprecation policy will follow the 1.0 release. ## [Unreleased] +## [0.2.0] - 2026-09-02 + +The first release since `0.1.0`. A `0.2.0` section was prepared and dated +2026-08-18 but never tagged or uploaded, so that work had never reached anyone; +it is merged into this release rather than left stranded, which is why this +entry is large and its date is later than the work it describes. + +**Upgrading.** Five breaking changes, each replacing a silently wrong answer with +a correct one or a refusal — every one is marked `**BREAKING**` in the sections +below, with the reasoning: + +- `PostAction` now requires an action type and a group, refusing locally what the + API rejected with an HTTP 590 naming no field. +- `RequestUpdate.status_id` is gone: there is no flat status update on this API, + and it returned 200 while dropping the status. Use `set_status`/`close_ticket`. +- `get_department_context`'s recent tickets are now projected. +- `find_departments` changed how an all-digit name and the fuzzy fallback + resolve. + +Read the `### Changed` and `### Removed` sections before upgrading; the rest of +the release is additive. + ### Added +- `end_action(rfc_number, *, action_id=, end_date=, start_date=, elapsed_time=, + doneby_mail=)` on both clients — the step `create_action` leaves undone. 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 a person + could not read, and the pre-release walkthrough had to reach into + `client._transport` to finish one. Wraps the vendor's + `PUT actions/{rfc_number}` / `end_action` route. Addressed by the **ticket**, + not the action: the id goes in the body, and `actions/{action_id}` answers + 404 for this verb. `create_task` still needs none of it. + - **Ending a workflow action advances the workflow** (measured 2026-09-01, + 2/2 tickets): the ticket moved *En cours* → *Résolu* and a new open action + appeared. A control showed ending a caller-created action changed neither + the status nor the action count. Omitting `action_id` ends *every* open + action, which on a ticket whose only open one is its workflow step means + resolving it. + - `elapsed_time` is **minutes**, is not derived from the two dates (omitted, + it stays empty), and is stored as sent even when it contradicts them (60 + against a 15-minute window) — except when `start_date` equals `end_date`, + where it stores `0` whatever you send (3/3). + - Dates take the instance's own `DATE_FORMAT` and are passed through as + strings; ISO 8601 is refused with HTTP 590 "Invalid End Date" on the + verified instance. Send `start_date` explicitly — a derived one comes back + early by the instance's UTC offset. + +- `ev_contains_filter` / `ev_starts_with_filter` take a keyword-only + `wildcard: Literal["*", "%"] | None = "*"`. The vendor documents `~` as + plain **Contains** (tier 1); this package measured it live 2026-08-17 on one + instance as a *pattern* operator needing an explicit wildcard (tier 4, may + not generalise) and defaults to that reading. On a deployment that follows + the vendor's, the appended `*` is compared literally and the filter returns + **zero rows with HTTP 200 and no hint** — a silent narrowing, the mirror of + the silent widening the metacharacter guard already prevents. Pass + `wildcard=None` there, or `wildcard="%"` for a LIKE-style backend. The + default is unchanged, so output against the verified instance is identical. + `_` and `[` stay refused at every setting (they are metacharacters of `~` + itself, measured with a wildcard-free probe); `*` and `%` are now refused + only while a wildcard is being appended. +- `languages=` on every label resolver — `resolve_reference`, + `localized_label`, `EasyvistaModel.reference`, `TicketContext.to_markdown`, + `aggregate_tickets`, `ticket_statistics` and `get_department_context` — so a + deployment whose primary language is not English reorders the scan instead of + forking. Defaults to the new `DEFAULT_LANGUAGE_ORDER`, which is the order the + package already used. +- `DEFAULT_LANGUAGE_ORDER` and `localized_label` are now exported from the + package root. `localized_label` was documented but not importable. +- `Action.label` — the best populated `ACTION_LABEL_` column, skipping + untranslated `[placeholder]` values, mirroring `Department.name`. Prefer it + over `action_label_fr`: on a single-language instance the *other* language + columns echo the primary text in brackets, so on an English deployment + `action_label_fr` is `"[Customer Comment]"` rather than `None`. +- `client.send(method, path, *, params=, json=, headers=)` on both clients — the + supported route to any `api_root`-relative path this package does not wrap. + An instance advertises around a hundred paths in its own OpenAPI document and + this package wraps roughly ten of them; reference tables (`status`, `urgency`, + `groups`, `locations`, `slas`), the external-table route and whole families + such as `problems` and `known-errors` were previously unreachable without a + fork. It shares the retry policy and the error mapping with every typed + method, and returns the decoded JSON unchanged. 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 a query parameter (the vendor + lists `formatDate`) without being able to replace the ticket filter on + `list_actions` or the offset an `iter_*` sweep is stepping. +- `EasyvistaConfig.extra_headers` — merged over every header sent to the + instance, for an API gateway's key or a tenant selector. An `Authorization` + key raises at construction, in any casing, rather than silently shadowing + `token`. +- `EasyvistaConfig.user_agent` and `DEFAULT_USER_AGENT`. +- `EasyvistaConfig.default_params` — query parameters on every JSON API + request, under any the call itself sets. Not applied to downloads. +- `EasyvistaConfig.additional_download_hosts` — opt specific **https** hosts in + as attachment sources, for a deployment serving them from a CDN or vanity + hostname. A fetch from one carries no credential and no `extra_headers`: it + goes through a second, credential-free HTTP client, because the token is + attached to the instance client at client level and cannot be removed per + request. +- `RequestSpec.headers`, for a single request needing a content type other than + the client-level `application/json`. +- `PostRequest` gains the six tier-1 asset / configuration-item selectors: + `assetid`, `assettag`, `asset_name`, `ci_id`, `ci_asset_tag`, `ci_name`. The + wire spellings have no underscore, which is what the vendor documents — the + documented case-insensitivity is not underscore-insensitivity. +- `PostAction` gains `action_type_guid`, `group_mail` and `parent_action_id`, + all tier-1 optional fields the model did not declare. +- `EasyvistaConfig.datetime_input_formats` — extra `strptime` patterns tried + only after EasyVista's own ISO-8601 form fails. Empty by default. The read + models refuse a timestamp they cannot parse rather than guessing an instant, + and a search validates a whole page in one comprehension, so on a deployment + whose format differs one column fails every record on the page. Nothing is + guessed: an unlisted format still raises, and a pattern can never change how + a real EasyVista stamp parses because that is tried first. +- `get_ticket(fields=...)` — a projection on the item route, for when one + column poisons the whole record. Note the verified instance's own OpenAPI + declares `fields` on `GET /requests` but not on `GET /requests/{rfc_number}` + (tier 2), so the item route may ignore it. +- `EasyvistaConfig.document_delete_path_style` and + `delete_document(..., path_style=)`. The instance's own OpenAPI declares + DELETE on **both** `requests/{rfc}/documents/{id}` and `documents/{id}`, + marking only the second `deprecated` — so the 403 measured against the + top-level form was a profile denial, not a missing route. The default + `"nested"` is unchanged. `delete_document` also accepts a `Document` in place + of its id, and `rfc_number` may be `None` under `"top_level"`. +- `get_department_comment(..., memo_field=)` and + `get_department_context(..., memo_fields=)`. The last segment of + `GET departments/{id}/{comment}` is a memo-field *selector* in the + instance's own spec, not a literal, so a deployment whose department memo + column is named differently is no longer locked out. `memo_fields` is a + sequence, mirroring `get_ticket_context`: every resolved memo lands in + `DepartmentContext.memos` and `note` is the first with text. +- `get_department_context` gains `recent_tickets_sort`, `ticket_fields`, + `employee_fields`, `asset_fields` and `statistics_max_records`. Every value + it sampled with was a literal buried in a branch; each is now a keyword whose + default is what it sampled with before, `ticket_fields` excepted (see + Changed). `statistics_max_records` in particular was inherited silently from + `ticket_statistics`. +- `find_departments(..., by=)` — the columns the server-side fast path tries, + in order. `"auto"` (the default), a single column name, an explicit sequence, + or `[]` to skip the fast path entirely. +- `TicketStatistics.truncated` and `.population_total`, so a capped + aggregation says it sampled rather than counted. `population_total` is the + server's own count for the search, read off the first page at **no extra + request**; it is counted before any client-side date window, so it is not + comparable with `total` when one is set. `aggregate_tickets` is pure and + offline and leaves both at their defaults. +- `DepartmentContext.degraded` and `TicketContext.degraded` — which branches + were swallowed, as `":"` entries. A 403 that degraded to + `[]` was previously indistinguishable from a genuinely empty result. Split + with `rsplit(":", 1)`: a memo branch is itself named `"memo:"`. +- `DepartmentContext.memos`, keyed by the field name requested. +- `TicketContext.to_markdown(fields=)` and the exported + `DEFAULT_MARKDOWN_FIELDS` — the field table as `(label, column)` pairs. + Extend rather than retype: `fields=[*DEFAULT_MARKDOWN_FIELDS, ("SLA", "SLA_ID")]`. +- **Instance discovery** — four methods that answer "what do I have to pass to + create a ticket *here*", so no id has to be hardcoded from another + deployment: + - `get_api_spec(path="swagger")` reads the instance's own OpenAPI. Note the + route answers **HTTP 201**, not 200: this client is unaffected, but code + written beside it that gates on `status_code == 200` skips the document in + silence. + - `list_reference_table(path, ...)` reads any list route into + `SearchResult[GenericRecord]`. A 403 **propagates** rather than becoming + `[]` — an empty table is a legitimate answer, and collapsing the two would + let a caller conclude the instance has no statuses. + - `discover(name, ...)` resolves one reference to the ids, labels, codes and + GUIDs in use. `STATUS` additionally gets its `STATUS_GUID` from a ticket + sample, which is the only place one is readable and the value `set_status` + and `close_ticket` actually address a status by. + - `describe_instance(...)` profiles the lot into an `InstanceProfile` and + **never raises for one part**: every gap is named in `.unavailable` with a + machine-readable first token (`denied`, `failed`, `no-route`, `empty`, + `truncated`). + `IMPACT`, `SEVERITY`, `ORIGIN` and `ACTION_TYPE` have no route in the spec at + all, so they are sampling-only by construction — what comes back is the ids + *in use*, and a sample count is never a population count. +- `GenericRecord`, `DiscoveredReference`, `InstanceProfile`, `ReferenceSource` + and `DEFAULT_DISCOVERY_NAMES` are exported from the package root. + `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 no status id). +- `references.label_from_record` — the best human label anywhere in a + reference-table row, matched by **suffix**. A reference table does not name + its label column after the table: `groups` returns `GROUP_EN`, `locations` + returns `LOCATION_FR`, `catalog-requests` returns `TITLE_EN` and `slas` + returns `NAME_FR`. `resolve_reference` deliberately does not route through + it — its own scan is bracket-tolerant, and sharing would be a behaviour + change rather than a refactor. + + +- `EasyvistaClient.iter_actions` / `AsyncEasyvistaClient.iter_actions` page a + ticket's **whole** action log. `list_actions` returns one page and truncates a + longer log with no error — and because it discards the envelope's total, + nothing in the result reveals the truncation. That is not a corner case: a + freshly created ticket already carries about a dozen workflow-generated + actions before anyone has commented, so a ticket with real conversation on it + crosses a default cap easily, and the comments lost are the oldest ones. + `iter_actions` follows `@next` until the server runs out, takes the same + `fields=` projection, and re-applies the ticket filter on every page. + **Caveat:** unlike `iter_tickets`, the `offset`/`@next` contract has not been + measured on the `actions` endpoint specifically. If an instance ignores + `offset`, page two repeats page one and the sweep will not terminate — bound + it with `max_records` the first time you use it on a ticket whose action count + you do not know. `scripts/validate_docs_examples.py` now checks this against a + live instance when credentials resolve. +- `Action.action_label_fr` (`ACTION_LABEL_FR`). Already returned on the default + list projection, it was reachable only through `model_extra` — undeclared, and + so named by no docstring, even though `context.py` already relied on it as the + Markdown heading fallback. Note the item-level record carries eleven language + columns; on a single-language instance the unpopulated ones echo the + default-language text wrapped in `[...]`. Prefer `localized_label` there. +- `resources.actions.build_search_actions` — one page of a ticket's actions with + the envelope kept, so `@next` is visible to a pager. `build_list_actions` is + now a thin wrapper over it that drops the envelope; the unsafe/blank + `rfc_number` guard moved into the shared builder so neither entry point can + bypass it. +- `EasyvistaClient.set_status` / `AsyncEasyvistaClient.set_status` set a ticket's + status, addressed by `STATUS_GUID`. This sends the documented + `{"closed": {"status_GUID": ...}}` body — the same request `close_ticket` + sends, under a name that matches what it does. Despite the wire name, the + envelope is **not** limited to closing: handed each of six different status + GUIDs in turn, a fresh ticket landed on exactly the status requested every + time, non-terminal ones included. Status GUIDs are per-instance configuration + and are not portable between deployments; read one off any ticket already in + that status (the nested `STATUS` object carries `STATUS_GUID`). +- `PostRequest.catalog_guid`. The vendor documents it as the **preferred** + subject identifier; it had been removed on the authority of a customer + handover note that the package cited as the vendor specification. A create + body with neither `catalog_guid` nor `catalog_code` now raises locally + instead of drawing an HTTP 590 whose SQL parser error names no field. +- Eleven vendor-documented create fields that `PostRequest` did not declare: + `location_id`, `location_code`, `department_code`, `recipient_name`, + `recipient_identification`, `requestor_identification`, `requestor_mail`, + `requestor_name`, `parentrequest`, `phone`, `submit_date`. Tier 1 — + vendor-documented, not verified live by this package. +- `PostRequest.workflow_start`, a twelfth field reachable the same way but + **not** vendor-documented like the eleven above: its only source is 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, example-derived, not a normative contract. Treat it as + unverified until tested against the deployment you use it on. +- `extra_payload` on every write model: an un-prefixed passthrough merged last, + overriding declared fields and `custom_fields` alike. Every field these models + decline to declare rests on behaviour measured against a single instance; + `extra="forbid"` turned each of those measurements into a hard wall for every + other deployment, and `custom_fields` could not help because it only emits + `e_`-prefixed keys. +- `get_ticket_context(..., memo_fields=...)` and `TicketContext.memos`. The API + models the memo name as a path segment, and the instance's own OpenAPI spec + documents it as a selector ("Memo field type, could be comment, description"; + tier 2 — declared in the instance's OpenAPI `paths`, not vendor-documented), + so which memo carries a ticket's body is per-deployment configuration the + client was hardcoding. Default behaviour is unchanged. `TicketContext.description` + / `.comment` stay dataclass fields rather than becoming properties over + `memos`, a deliberate deviation to avoid breaking positional construction of + a public dataclass. +- `docs/vendor-api-reference.md` — the vendor facts this package depends on, + each tagged with the evidence behind it, plus the routes present in the + instance's OpenAPI that the package does not implement. + + +- `EasyvistaClient.stream_document` / `AsyncEasyvistaClient.stream_document` + yield an attachment's bytes in chunks (64 KiB by default, `chunk_size=` to + change it) instead of returning them whole. Motivation: a consumer mirroring + attachments had no choice but to buffer, because `download_document` + materialises the whole file before it returns anything. What this removes is + that download buffer and only that — one attachment's worth of memory, so a + 32 MB file is held a chunk at a time instead of whole. The upload leg is + unaffected: `add_document` takes `content: bytes` and base64-encodes it, so a + mirror that re-uploads still materialises that payload in full (see below for + why no streaming upload is possible). Accepts exactly what `download_document` + accepts and resolves the URL identically, so the same-origin refusal, the + `follow_redirects` behaviour and the error mapping (a 403 is still + `EasyvistaAuthError`, a 590 is still not retried) are the same on both paths. + **Only the download direction streams, and that is the API's constraint:** + EasyVista takes an attachment as base64 inside a JSON body, so `add_document` + must materialise the whole payload before it can send anything — no streaming + upload is possible, and the asymmetry is not an oversight here. + **A mid-stream failure is not retried.** Opening the download is retried under + the usual policy, and the first chunk is fetched inside that retried unit so a + failure fetching it is still safe to restart; from that chunk onwards the + request is committed and a transport error raises `EasyvistaConnectionError` + rather than starting over, because starting over would re-deliver bytes the + caller already holds. Nothing resumes a partly consumed stream, so a caller + that must survive a mid-stream failure decides for itself whether to discard + what it collected and ask again; `download_document` retries the whole fetch + and stays the simpler choice for a file small enough to buffer. + - Python 3.13 and 3.14 are now tested and declared supported (classifiers, and the CI/release matrices, now span 3.10--3.14). No code changed: the suite passes unmodified on both, with the same statement count and coverage as on @@ -31,6 +320,26 @@ a deprecation policy will follow the 1.0 release. code snippets against the real public API, so a rename fails CI. - Public `filters.py`: `ev_equals_filter`, `ev_in_filter`, `escape_ev_value`, and `is_safe_ev_value` for building EasyVista `search` expressions safely. +- `ev_since_filter` / `ev_between_filter` — the interval grammar + (`FIELD:(a;b)`) that is the only server-side range filter this API honours. + EasyVista has no comparison operator (`>=`, `BETWEEN`, `[a TO b]`…): either + rendering is silently dropped or, if it keeps `FIELD:"value"` syntax while + embedding the operator in the value, raises HTTP 590 as a type mismatch. + Neither ever narrows a result, which is why these builders exist. +- `ev_contains_filter` / `ev_starts_with_filter` — `~` with an explicit + wildcard (`*` or `%`; both work identically). A bare value under `~` + degenerates to exact match, which these builders avoid by construction. + A value containing any of `*`, `%`, `_` or `[` raises `ValueError`: all four + are metacharacters to `~` (`_` matches any single character, `[` opens a + character class — measured live: replacing one character of an RFC that + matched 1 row with `_`, or with `[0-9]`, matched 9), and no escape for them + exists (`\_` is compared literally). Refusing beats silently matching records + the caller did not ask for, which matters because `_` is pervasive in + EasyVista codes: `ev_contains_filter("ASSET_TAG", "LAPTOP_01")` would + otherwise also match `LAPTOP-01` and `LAPTOP001`, with HTTP 200 and no hint. +- `parse_ev_datetime` / `format_ev_datetime` (new `timestamps.py` module) — + parse an EasyVista timestamp to an aware `datetime` and render one back to + the literal the search grammar and the wire format both accept. - `Request` now declares fields that were previously reachable only as untyped `extra="allow"` data — each verified present on live single-ticket GETs: `title`, `request_id`, `external_reference`, `sd_catalog_id`, `urgency_id`, @@ -39,15 +348,20 @@ a deprecation policy will follow the 1.0 release. and `last_update`. - `RequestUpdate.title` — a ticket's title can now be changed after creation (`PUT /requests/{rfc}`), not only set at create time. +- `RequestUpdate` now also carries `impact_id`, `owner_id` and + `external_reference` (capped at 50 characters — bisected live: 50 is + accepted, 51 is rejected). `severity_id`, a writable priority field, and + `urgency_id` are deliberately still absent; see the `O-590-PARTIAL` note. - `EasyvistaClient.download_document` / `AsyncEasyvistaClient.download_document` fetch an attachment's bytes. An absolute download URL is followed only when its scheme and host match the configured `server`: every request carries the instance's Bearer token, so a URL naming another host is refused rather than followed. - `Request` now declares the official time-limit fields as typed attributes: - `creation_date_ut`, `max_resolution_date_ut`, `expected_date_ut`, - `end_date_ut`, `sla_id` and `time_used_to_solve_request`. As with the existing - timestamps, they are verified *returned* and no datetime parsing is claimed. + `creation_date_ut`, `max_resolution_date_ut`, `expected_date_ut` and + `end_date_ut` (timezone-aware `datetime`, parsed the same way as the other + timestamps below), plus `sla_id` (int) and `time_used_to_solve_request` (a + string on every ticket checked, never an int, so no int branch is declared). The instance-specific `E_GTR_*` / `E_GTI_*` family stays undeclared and reachable through `classify_fields().custom`. - `EasyvistaClient.get_action` / `AsyncEasyvistaClient.get_action` fetch a single @@ -62,43 +376,242 @@ a deprecation policy will follow the 1.0 release. tail is an RFC number rather than an id. A created action's id is therefore not recoverable from its create response at all; diff `list_actions` across the create to identify it (verified live). +- `list_actions(fields=...)` — project timestamps and author onto the list and + read a whole **page** of action metadata in one request instead of one item + fetch per action. Three silent footguns come with it: `"*"` is not a wildcard + (it reduces to `ACTION_ID` alone), a dotted path (`DESCRIPTION.HREF`) is + silently dropped, and `list_actions` returns **one page and does not + paginate** — a ticket with more actions than `config.default_max_rows` is + truncated with no error, and the call discards the envelope's total so the + caller cannot detect it. That is not a corner case: a freshly created ticket + already carries about twelve actions, most of them workflow-generated. The + same cap therefore truncates `get_ticket_context`'s action log and + `TicketContext.to_markdown()`'s rendering of it. `list_actions` now sends + `config.default_max_rows` explicitly, the way every sibling search does, so + the cap is the client's and can be raised; real pagination is a follow-up. +- `Action` now declares its timestamps (`created_at`/`CREATION_DATE_UT`, + `updated_at`/`LAST_UPDATE`), author (`done_by_id`) and workflow context + (`action_type_id`, `group_id`, `request_id`, `action_number`, `stage_id`, + `workflow_id`, `parent_action_id`) — verified live 2026-08-17. Availability + on the LIST endpoint is not uniform across these; pass `fields=` to project + the ones a default list row omits. Note the naming diverges from the two + models already shipped: `Action.created_at`/`updated_at` alias the same wire + columns that `Request` and `Employee` expose as + `creation_date_ut`/`last_update`. The wire aliases are identical on all three, + so code spanning record types should reach the value through + `classify_fields()` / `.reference()` rather than a shared attribute name — + `getattr(record, "last_update")` raises `AttributeError` on an `Action`. +- `update_action` and `delete_document`, with `ActionUpdate`. `PUT + actions/{id}` edits an action's note (verified live by re-reading it + afterwards, not by trusting HTTP 200); an action can be edited but not + deleted (`DELETE actions/{id}` is refused with HTTP 403). `DELETE + requests/{rfc}/documents/{document_id}` removes an attachment — the + top-level `DELETE documents/{id}` returns HTTP 403. - `get_ticket_context(..., resolve_action_bodies=True)` resolves each action's note text. Pass `False` to skip it — it costs two extra requests per action. -### Removed +### Changed -- **Breaking:** `PostRequest.catalog_guid` and `Request.catalog_guid` are gone. - `CATALOG_GUID` is absent from every sampled live ticket (0/25 single-ticket - GETs), from the documented create body, and from the vendor field inventory — - it could never populate. `PostRequest(catalog_guid=...)` previously validated - and was sent to the API; it now raises (`extra="forbid"`) instead of being - silently accepted. Use `catalog_code` to name a catalog on create. +- **BREAKING**: `PostAction` now requires an action type and a group, the same + way `PostTask` always has and on the same vendor sentence ("Required: + `action_type_id`, and one of `group_id` / `group_mail` / `group_name`"). + `PostAction()` used to construct fine and ship `{"action": {}}`, drawing an + HTTP 590 that named no field; it now raises at construction with a message + that names what is missing. A body supplying either through `extra_payload` + satisfies the guard. +- `PostAction.action_type_id` and `.group_id` widen from `int | None` to + `int | str | None`, matching `PostTask`. They had diverged for no recorded + reason, so a non-numeric id worked through `create_task` and failed through + `create_action`. Purely permissive. +- `Asset.asset_id` and `.status_id` accept EasyVista's `""` sentinel + (`OptionalInt`), as every other read model already did. A CMDB row with an + unset `STATUS_ID` used to fail the whole page, and inside + `get_department_context` it failed the whole bundle. +- `Request.time_used_to_solve_request` and `Document.document_id` widen to + non-coercing `str | int | None`. Both were typed from a single observation of + a single instance; an instance sending the other form failed the record, and + for documents that meant every attachment on the ticket. +- `PostAsset.catalog_id` and `.status_id` accept `int | str`, coercing a + numeric string to the number the instance's own create example shows and + passing a non-numeric value through as written. +- The required-field guards on `PostRequest`, `PostAction` and `PostTask` now + read the body `to_api()` will actually send rather than the declared + attributes, so a field supplied through `extra_payload` satisfies them. + Previously `PostRequest(extra_payload={"CATALOG_GUID": ...})` was refused + even though it ships exactly what the vendor documents as a complete create. +- An unknown key on a write model now names itself and points at + `extra_payload`, instead of pydantic's bare "Extra inputs are not permitted". + The message stops short of promising the write will work: on this API an + exclusion is usually a measured misbehaviour, and a 200 is not a receipt. +- **BREAKING**: `get_department_context`'s recent tickets are now **projected** + with `RECENT_TICKET_FIELDS`. The previous default sent no `fields=` at all, + and on the verified instance the unprojected list projection returns `TITLE` + present but **empty** (tier 4 — 400 tickets scanned, zero with a populated + title), so `recent_tickets[i].title` was `None` for every caller. Projecting + fixes that but narrows the rest: a caller reading a column outside the seven + — a custom `e_*`, `DEPARTMENT_ID`, `URGENCY_ID` — off a recent ticket loses + it. `ticket_fields=None` restores the exact previous request. +- **BREAKING**: `find_departments` with an all-digit name now tries + `DEPARTMENT_CODE` before `DEPARTMENT_ID`. A department whose code is all + digits was previously looked up as an id, returning a **different department + with HTTP 200 and no hint** — that is the bug being fixed. Where no such code + exists the result is identical and one extra request is spent. + `by="DEPARTMENT_ID"` restores the old lookup exactly. +- **BREAKING**: `find_departments`' fuzzy fallback now folds accents and + compatibility forms (NFKD + `casefold`), so it can return **more** departments + than before. It is strictly more permissive — no name that matched stops + matching — and it is what makes an unaccented search term reach an accented + label, which it could not do on an instance whose department names are French. +- **Envelope matching is now case-insensitive**, over the same fixed candidate + list and in the same priority order — never a scan of whatever keys the + payload carries. Envelope casing is not stable across deployments: the + verified instance answers a capital-D `Documents` where its own OpenAPI + examples spell every envelope lowercase. A matched list must also be empty or + hold at least one dict, so a scalar list under an envelope name + (`{"REQUESTS": ["a", "b"]}`) falls through to the bare-record reading instead + of silently returning `[]`. +- Corrected several docstrings that read an HTTP 403 as a permission verdict. + This API answers 403 for a path that does not exist as well as for one a + profile denies, so the status code alone never distinguished them. There is + no nested `requests/{rfc}/actions/{id}` route and no DELETE verb on + `actions/{id}` — those are topology facts from the instance's own OpenAPI + document, not restrictions. The conclusions were right; the reasons were not. + A route table now records them in `docs/vendor-api-reference.md`. -### Fixed -- `find_departments` and `list_actions` interpolated caller values into a `search` expression - unescaped. Because `,` is an EasyVista combinator, a crafted value could silently widen the - result set (verified live: a department lookup returned 2 records instead of 1). Both now - validate the value. -- `TicketContext.to_markdown` rendered every action with an empty body. It read - the text from `Action.comment`, but `COMMENT` is a distinct field that never - carries it; the note supplied as `PostAction.description` comes back through - the action's `DESCRIPTION` Memo, which is reachable only via an item-level - `GET actions/{id}`. Verified against a live instance. -- **Every mapped exception's message no longer interpolates the raw HTTP response - body.** For a body this client does not recognize (an nginx or WAF HTML page, a - plain-text 503, any unmodelled shape), the message previously ended with that - body's literal text — which then surfaced verbatim wherever the exception was - rendered (`str(exc)`, a traceback, a test runner's failure summary), regardless - of what the body actually contained. The message now reports only the byte - count. **Added:** `EasyvistaError.body` (`bytes | None`) carries the raw - response body, so the content dropped from the message is not lost — it is the - only way left to retrieve an unrecognized body. `.status_code`, `.ev_code` and - `.ev_message` are unaffected: a *recognized* EasyVista error body (one with a - parseable `error`/`error_code` shape) reads exactly as it did before. +- **A `[bracketed]` untranslated label no longer wins over a real sibling + translation.** `references._nested_label` scanned `_EN`/`_FR`/`_PATH` and + accepted any non-empty string, while `localized_label` fifty lines below + already knew six language columns and already rejected placeholders — two + incompatible policies in one module. They are now one. Affects + `TicketContext.to_markdown`'s Status/Department/Location/Catalog rows, + `TicketStatistics.breakdowns` keys, and `record.reference(name).label`. On a + single-language instance this only ever improves the value: the string + rendered before was a bracketed echo of the text now returned. It can never + *delete* output — a record whose every language column is a placeholder still + yields what it always yielded. A caller keying a dashboard on the exact old + string sees that key change. +- A usable non-English/non-French language column now beats `_PATH`, which was + previously the third and last rung. +- `easyvista_python_client._fields._label` is removed (private, unexported). Its + one caller now resolves labels through `references`, so the package has one + placeholder rule and one language order rather than three. +- `EasyvistaConfig.verify_ssl` accepts a CA-bundle path or an `ssl.SSLContext` + as well as a bool, matching what `httpx` has always accepted. A corporate + private CA no longer reads as though disabling verification were the only + option. Runtime behaviour is unchanged; this widens the annotation. +- Requests now carry `User-Agent: easyvista-python-client/{version}` instead of + the underlying HTTP library's default, so the integration can be identified in + an instance's access log and whitelisted by a WAF operator. This is the only + change to the wire. +- `EasyvistaConfig.__hash__` covers the scalar identity fields only. The hash + the dataclass would generate spans every field and raises `TypeError` once a + mapping field is non-empty; `__eq__` still compares every field. + + +- **The wire form changed for a caller who passes an id as a numeric string.** + Widening a `PostRequest` field from `int` to `int | str` is not purely + additive, and the entries above previously implied it was: pydantic's smart + union keeps the exact type match where the old `int` field coerced. So + `PostRequest(origin="7")` used to send `7` and now sends `"7"`, and the same + is true of `department_id` and `recipient_id`, widened in this release for + the same reason. The vendor documents all three columns as **strings** + (tier 1, `docs/vendor-api-reference.md`), so the string form is the + documented one and there is nothing to normalize towards. An id was measured + accepted in either form on one instance (tier 4, 2026-08-25), so on that + deployment this changes the bytes and not the outcome — but a deployment that + discriminates on JSON type would see a behaviour change. Pass an `int` to get + the old bytes. +- **`PostRequest.impact_id` is the exception, and it coerces again.** It is + declared `Field(union_mode="left_to_right")` with `int` first, so + `impact_id="28"` reaches the wire as `28`. The vendor documents this column + as an **integer** (tier 1), which is the form worth normalizing towards; the + `str` branch exists so a caller who quotes the value is not rejected, not so + that the quoted form ships. A non-numeric string is still accepted and passes + through as written, so nothing previously accepted is refused now. (Between + the widening and this entry `impact_id="28"` did ship as `"28"`; both changes + fall in this same unreleased section, so no release carried it.) +- **`extra_payload` overrides a declared field across letter case.** The vendor + documents the ticket *create* body's field names as case-insensitive (tier 1, + `docs/vendor-api-reference.md`); the other write bodies are assumed to match + it, which is the safe assumption in either direction here. Against that, the + exact-key merge broke the escape hatch's own promise: `PostRequest(urgency_id=8, + extra_payload={"URGENCY_ID": "4"})` put **both** spellings on the wire with + conflicting values and left the winner to the server. An `extra_payload` key + now replaces any declared or `custom_fields`-produced key it matches when + case is ignored, and `extra_payload`'s spelling and value are what ship. The + `ALL_CAPS` form is the likely one, not a corner case — it mirrors the read + side, which is where callers copy names from. This is a merge rule: a + collision is never an error. +- **Live-test credential renamed** (contributor-facing; the published package is + unaffected). `EASYVISTA_TEST_USER` / `secrets/easyvista_test_user` are now + `EASYVISTA_TEST_ACCOUNT` / `secrets/easyvista_test_account`. The value never + was a login: it is the EasyVista **account**, the instance identifier that + forms the `{account}` path segment of `https://host/api/{version}/{account}` + (a number such as `50004`), and it feeds `EasyvistaConfig.account`. + Authentication is the Bearer token alone. The old name is **not** accepted as a + fallback — `integration_tests/`, `scripts/validate_docs_examples.py` and + `scripts/validate_live_content_fidelity.py` now abort with a message naming the + replacement if it is still configured, because silently honouring it would + preserve exactly the misreading the rename removes. Rename your local file; + `secrets/` is gitignored as a whole directory, so the new name stays ignored. + Note the value is consulted **only** when the URL is a bare host — a full API + root already carries the account, and most setups never read it at all. -### Changed +- **BREAKING:** Read-model timestamps are now timezone-aware `datetime` + instead of `str`: `Request.submit_date_ut`, `creation_date_ut`, + `max_resolution_date_ut`, `expected_date_ut`, `end_date_ut`, `last_update`, + and `Employee.last_update`. EasyVista returns ISO 8601 with an explicit UTC + offset and millisecond precision (verified live 2026-08-17), so parsing is + no longer left to callers. An unset date (`""` on the wire) is `None`. Write + models are **unchanged** — the accepted write format for a date is still + unverified. Migration: drop your own parsing; to rebuild a search literal + use `format_ev_datetime(value)`, or pass the `datetime` straight to + `ev_since_filter`. One more consequence, easy to miss: a record dump is no + longer directly JSON-serialisable. `model_dump()` and `classify_fields()` + now yield `datetime` objects for these columns, so + `json.dumps(ticket.classify_fields().official)` raises + `TypeError: Object of type datetime is not JSON serializable` where it used to + work — pass `model_dump(mode="json")` on any path that caches, exports or logs + a record as JSON. `classify_fields()` takes **no arguments**, so `mode="json"` + cannot be applied to it: render its values with `format_ev_datetime` before + serialising, or re-key a JSON-mode dump by the bucket's keys + (`dumped = ticket.model_dump(mode="json", by_alias=True)`, then + `{k: dumped[k] for k in ticket.classify_fields().official}`). + **Scope note — the `0.1.0` boundary is ambiguous, read both.** Relative to + the `## [0.1.0] - 2026-07-15` release **commit** (`6df6a75`), only + `Employee.last_update` is a pre-existing field — the six `Request` fields + above were themselves first declared later, during this 0.2.0 cycle + (see `Added`), so under that reading only one field is retyped out + from under a shipped release. But the `0.1.0` **git tag** currently resolves + to a later commit (`3216a33`, 2026-08-04, 117 commits after the release + commit), at which all six `Request` fields and `Employee.last_update` were + already declared as `str | None`. Anyone who installed or pinned against the + `0.1.0` tag therefore sees **all seven** fields change type, not one — check + which commit your `0.1.0` actually resolves to before assuming the narrower + case. +- **Documentation correction:** the `search` operator `~` was documented as + exact-match-only, identical to `:`. Measured live, `~` **is** a pattern + operator — it needs an explicit wildcard (`*` or `%`, both work identically) + to act as one: `~"*260817*"` matched 33 rows and `~"I26081*"` matched 32, + while `:"I26081*"` matched 0, because `:` never expands a wildcard. Without + one, `~` degenerates to exact match, which is exactly what the earlier + tests observed and over-generalised from. Examples implying substring + matching with a bare value (`ASSET_TAG~LAPTOP`) were wrong and have been + replaced with `ev_contains_filter("ASSET_TAG", "LAPTOP")` → + `ASSET_TAG~"*LAPTOP*"`. `*` and `%` are not the only metacharacters either: + under `~`, `_` matches any single character and `[` opens a character class + (both measured live). The unverified `!~` / `!` / `is_null` / + `is_not_null` operators are still not documented as fact. +- **Documentation correction:** the README's and user guide's tutorial examples filtered with + `ev_equals_filter("STATUS_EN", "Open")`. `STATUS_EN` is a sub-key of the nested `STATUS` + object, not a top-level column, so EasyVista silently ignored the condition and every example + returned *all* tickets, not just open ones. This was a documentation defect, not a library bug + — the library does not special-case field names, so nothing in the shipped code was broken. + Replaced with `ev_equals_filter("STATUS_ID", 3)` throughout, and the user guide now documents + which returned fields are actually searchable and the third (HTTP 590 type-mismatch) search + outcome. - `AsyncEasyvistaClient.get_ticket_context` and `get_department_context` now issue their independent sub-requests **concurrently** instead of one after another. The async client previously awaited every call in sequence, so it was no faster than the synchronous one @@ -125,22 +638,12 @@ a deprecation policy will follow the 1.0 release. - **Documentation of observed behaviour, not a code change:** a `description` supplied to `PostRequest` at create time is not readable back through either the `DESCRIPTION` or the `COMMENT` Memo on the verified instance. `RequestUpdate.description` writes the ticket's - `COMMENT` Memo, not `DESCRIPTION` — verified live (0/15 sampled tickets, portal-created - included, have a non-empty `DESCRIPTION`; 15/15 have a non-empty `COMMENT`). Read the body + `COMMENT` Memo, not `DESCRIPTION` — verified live by re-reading the memo after a write, not + by trusting HTTP 200. Nothing is claimed here about how often `DESCRIPTION` is populated on + an instance: an earlier reading of that (`0/15` sampled tickets) is explicitly withdrawn by + the DESCRIPTION-sampling correction under `Fixed` below. Read the body text back with `TicketContext.comment` (or `resolve_memo("requests/{rfc}/comment")` directly), not `Request.description`. Both fields stay as they are; nothing was renamed. -- **Documentation correction:** the `search` operator `~` was documented as "contains". It is - **exact match**, identical to `:` — verified against a live instance. Examples implying - substring matching (`ASSET_TAG~LAPTOP`) were wrong and have been replaced. The unverified - `!~` / `!` / `is_null` / `is_not_null` operators are no longer documented as fact. -- **Documentation correction:** the README's and user guide's tutorial examples filtered with - `ev_equals_filter("STATUS_EN", "Open")`. `STATUS_EN` is a sub-key of the nested `STATUS` - object, not a top-level column, so EasyVista silently ignored the condition and every example - returned *all* tickets, not just open ones. This was a documentation defect, not a library bug - — the library does not special-case field names, so nothing in the shipped code was broken. - Replaced with `ev_equals_filter("STATUS_ID", 3)` throughout, and the user guide now documents - which returned fields are actually searchable and the third (HTTP 590 type-mismatch) search - outcome. - `Request.status_id`, along with the model's other numeric identity/classification fields, now uses an `OptionalInt` type that tolerates the API's `""` for an absent numeric; `status_id` previously raised a validation error on that value. @@ -164,6 +667,337 @@ a deprecation policy will follow the 1.0 release. action bodies, rather than after. The same requests are issued and the result is identical; only their order on the wire changed. +### Removed + +- **BREAKING**: `RequestUpdate.status_id`. There is no flat status update on this + API and this field never worked. Sent alone the PUT is rejected 590/2013; sent + beside any other field the PUT returns **200, applies the other field, and + drops the status in silence** — measured on one ticket, title updated, + `STATUS_ID` unchanged. A write that reports success and stores nothing is worse + than one that fails, so the field is gone; `extra="forbid"` now makes + `RequestUpdate(status_id=...)` raise at construction. Use `set_status`. + + +- **Breaking:** `PostRequest.catalog_guid` and `Request.catalog_guid` are gone. + `CATALOG_GUID` is absent from every sampled live ticket (0/25 single-ticket + GETs), from the documented create body, and from the vendor field inventory — + it could never populate. `PostRequest(catalog_guid=...)` previously validated + and was sent to the API; it now raises (`extra="forbid"`) instead of being + silently accepted. Use `catalog_code` to name a catalog on create. + **Update, recorded here rather than silently edited into the entry above:** + `PostRequest.catalog_guid` was restored in `[Unreleased]` — the vendor + documents it as the **preferred** subject identifier, and the removal above + rested on a customer handover note the package had cited as the vendor + specification, not on the vendor docs themselves. `Request.catalog_guid` + remains gone, correctly: `Request` is a read model, `CATALOG_GUID` was never + returned on any sampled ticket, and `test_request_has_no_catalog_guid_attribute` + now pins its absence. + +### Fixed + +- Two retractions in `PostAction`'s docstring, both measured 2026-09-02 by + re-reading the record after ending rather than trusting the write: + - It said ending **clears** `GROUP_ID` ("the record moves from + assigned-to-a-group to done-by-a-person"). It does not — the group survived + on an ended workflow action (57) and on an ended caller-created one (3, the + value passed at create). + - It said "**This package does not implement it**" of ending. It does now. +- `STATUS_ID_ON_TERMINATE` is documented: it records the status the *ticket* + took when the action ended, and is empty while the action is open, so it + reports what happened rather than predicting it. + + +- **`get_ticket_context` dropped the body of actions a reader can see.** + `_resolve_action_body` resolved only the `DESCRIPTION` memo, leaving + `COMMENT` an unresolved href dict — which made `TicketContext.to_markdown`'s + `isinstance(action.comment, str)` fallback unreachable on every item-level + record. The EasyVista UI renders one text field per action, `DESCRIPTION` + falling back to `COMMENT` when it is empty (measured in the UI 2026-09-01, + one instance, Service Manager 2025.3), so an action whose description was + empty exported as a heading with no body while the ticket on screen showed + the comment. `COMMENT` is now resolved under exactly that condition — one + extra request only when `DESCRIPTION` comes back empty, so a populated + description costs nothing. +- **The docs taught an invisible write.** "Internal vs. customer-facing + comments" in the user guide, and the matching section of the actions skill, + presented `description` and `comment` as two *independent* channels and gave + as their worked example a `PostAction` putting the internal note in `comment` + beside a populated `description` — precisely the shadowed case, where the + text is stored, reads back through the API and is shown to nobody. Both now + state the shadowing rule and write human-facing text to `description`. + `comment` is not the private channel; visibility is the action type. +- **Retracted: ending an action is not blocked.** `PostAction`, both clients' + `create_action`, the actions skill, the user guide and `CLAUDE.md` all said + `PUT actions/{rfc_number}` returned `590 Action not found` for every + documented form and read that as an instance or profile restriction to raise + with an administrator. Measured 2026-09-01: ending an **open** action + succeeds. The 590 is what the route answers when no open action matches — + replaying it against an already-ended one, for instance. The same measurement + fixed the surrounding details: `end_date` takes `dd/mm/yyyy hh:mm:ss` (ISO + 8601 is refused `590 "Invalid End Date"`), `elapsed_time` is minutes, an + explicit `start_date` is stored faithfully while a derived one is early by + the instance's UTC offset, and the path segment is the RFC number — an + action id there answers 404. +- **`create_action`'s refusals were attributed to the wrong cause.** The + docstring called creation "gated by the workflow's current stage". It is + parent resolution: the route needs exactly one **open** action on the ticket + (0 → `Parent action not found or incorrect`, 1 → succeeds, ≥2 → `Ambiguous + query : many parent actions found`), and an explicit `parent_action_id` + naming an open action works at any count. A status change ends the ticket's + open actions, which is why a body accepted earlier is refused later — the + stage is not itself the gate, and the error messages were literal all along. +- **The action-type documentation contradicted itself.** The user guide and the + actions skill each stated the visibility rule correctly and then retracted it + a dozen lines later ("there is no naming convention to pattern-match", "they + carry no visibility meaning"), and both printed `INTERNAL_NOTE_TYPE_ID = 20` + — a number that appears in no test, probe or fixture and predates the + measurement that established 94/95. The `20` is deleted rather than replaced + with another invented one. The corrected text distinguishes the two halves + that are both true: the instance's OpenAPI declares **no `action-types` route + at all**, so there is nothing to enumerate and nothing for an administrator + to unblock — and every action record still carries `ACTION_TYPE_ID` beside + translated `ACTION_LABEL_*` columns, so the ids are recoverable from the + data. `discover("ACTION_TYPE")` now does that sampling. +- **The actions skill taught `create_action` for comments.** Its front matter, + Procedure and first worked example all reached for the verb that creates an + action *open* — a pending row whose text the UI does not display — while the + skill's own headline said to use `create_task`. A Procedure is executed + verbatim by an agent, so this was the highest-value correction in the set. + The guide's end-to-end example was labelled "add a comment" and called + `create_action` too. +- **Nothing documented how to obtain the one mandatory create field.** A new + guide subsection covers `catalog_code` / `catalog_guid`, including the trap + that `reference("CATALOG_REQUEST").id` resolves to `SD_CATALOG_ID`, which + `PostRequest` accepts under no name at all. A 403 on `/catalog-requests` is + now reported as a **grant to ask for** rather than an impossibility: the + route is declared in that same deployment's spec. +- `origin` is taught as the channel **name** the vendor documents (`"Phone"`), + not as an id. It is the one create field with a portable, human-readable + form; the int that was measured accepted stays in a parenthetical, and every + live-hitting caller keeps it. +- A new **"First steps on your instance"** section is now section 2 of the user + guide rather than section 20 of 23, and gives the order to discover a + deployment's values in — a reader needs it before they write anything. +- `close_ticket`'s "omitting `status_guid` closes to the instance's default + *Closed* meta-status" is **retracted to an open question** in the guide and + in both docstrings that carried it. It is not recorded in + `docs/vendor-api-reference.md` and no live test exercises the omitted form — + every one passes an explicit `status_guid`. Recorded as O-CLOSE-DEFAULT. +- `models/action.py` held the last copy of the retracted bracket rule ("the + brackets carry no other meaning"). A new test pins the half that nothing + guarded: a bracketed **suffix** on distinct text survives `localized_label`, + where a wholly-bracketed placeholder is discarded. + + +- **The `create_action`, `create_task` and `close_ticket` parsers now name + their envelope.** All three called `extract_records(data)` with no envelope + key, so a deployment echoing the created record under an `actions` wrapper + handed `model_validate` the wrapper itself — and `extra="allow"` accepted it + silently, yielding a well-formed record with every declared field `None`. + `close_ticket` worked only because `"requests"` happened to sit in a + hardcoded fallback list belonging to no resource in particular. +- **The `add_document` parser read its response by a different rule than + `list_documents`.** It used the case-sensitive `extract_records` while the + list parser was already case-insensitive — for the same resource on the same + instance, the one known to answer a capital-D `Documents`. A create echoed + that way produced an all-`None` `Document` built from the wrapper. + + +- **Documented how a comment actually works, retracting two wrong claims.** This + entry previously said "this API has no private-comment feature" and that the + API "cannot reveal" which action types are internal. Both were wrong, and the + second was a *correction that deleted a true finding*. Measured live + 2026-08-28 on one instance: + - **A comment is an action that has been ENDED.** 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. Ending + sets `START_DATE_UT`, `END_DATE_UT`, `ELAPSED_TIME` and + `STATUS_ID_ON_TERMINATE` and fills `DONE_BY_ID`. None of those can be set on + create — they return HTTP 200 and are dropped. (This bullet also said ending + **clears** `GROUP_ID`; it does not — see the retraction above.) + - **Ending is vendor-documented, and it works.** + `PUT actions/{rfc_number}`, body wrapped in `end_action`, dates in the + instance's own format. This bullet previously said every documented form + returned `590 Action not found` and read that as an instance/profile + restriction. That was wrong: the 590 is what the route answers when no + *open* action matches, such as replaying it against one already ended. It is + implemented as `end_action` — see the entry at the top of this release. + - **Visibility is by action type, and the labels do say which.** Type 94 is + `Commentaire [Public]` / `Customer Comment`; type 95 is + `Note Interne [Privé]` / `Internal Note`. Ids are per-deployment, but they + are **discoverable**: `GET action-types` is 403, yet every action record + carries `ACTION_TYPE_ID` beside translated `ACTION_LABEL_*` columns, so one + `GET actions` recovers the types in use. + - **The bracket "correction" was itself wrong.** Two conventions exist and mean + opposite things. A whole label bracketed and echoing another language + (`EN='[Analyse et résolution]'`) is an untranslated placeholder, which is why + `references.localized_label` discards it. A bracketed *suffix* on distinct + text with genuine sibling translations (`FR='Commentaire [Public]'` beside + `EN='Customer Comment'`) is a real visibility marker. The earlier correction + generalised the first pattern over the second and removed an accurate claim. + No release carried any of the wrong versions. +- `PostRequest`'s docstring claimed a ticket "needs at minimum `catalog_code` + plus `title`". That was wrong in a way that cost real debugging time. **Send + the whole documented create body** — `catalog_code`, `origin`, `title`, + `description`, `department_id`, `urgency_id`, `impact_id`. The full body is + accepted everywhere tried; the same body minus those ids is accepted on some + catalogs and rejected on others with the *identical* remaining bytes. The + rejection's message is a bare **SQL parser error** naming no field + (`=(1,35) expected token:( * + - . IDENTIFIER CASE NOT JOIN ...`), which reads + like a server-side defect and is not one — it is what an under-specified create + looks like here. Every id in that body was verified to persist by reading it + back under an explicit projection (these columns are absent from the default + projection, like `TITLE`, so an unprojected read shows `None` regardless). +- Documented that **a rejected create may still have created the ticket**: 12 + attempts returned 3 `RFC_NUMBER`s and afterwards all 12 tickets existed. A 590 + therefore means *possibly created*, never *not created* — retrying duplicates, + and the caller never learns the id. The `external_reference` marker does + survive the failed insert and is searchable, which is the only way to reconcile + such an orphan. +- `integration_tests/test_live_smoke.py` leaked one ticket per live run. Its + `test_missing_mandatory_field_raises_validation_error` asserted that a create + with a catalog but no title is rejected "(no ticket created), so this stays + read-only-safe by construction" — both halves false: `title` is not the + mandatory field (the full documented body with no title creates fine), and the + rejection does create a row. Replaced by + `test_an_underspecified_create_body_raises_validation_error`, which omits the + ids that really are required and reconciles the leftover ticket by its marker + in a `finally`. Two tests added beside it: one pinning that the documented body + lands every id, one pinning that `set_status` reaches a **non-terminal** + status. +- `PostRequest.origin` and `PostRequest.impact_id` now accept `int | str`, but + they were widened for different reasons, not the same one. `origin` is + vendor-documented as a **string** (tier 1); an `int` was separately measured + accepted on one instance (tier 4, date not recorded). `impact_id` is + vendor-documented as an **integer** (tier 1); its `str` branch exists so a + caller who quotes it is not rejected, not because a string was independently + measured landing here. `origin` sends whichever type is passed, unchanged; + `impact_id` normalizes a numeric string back to the vendor's documented int — + see **Changed** below, where the wire-form consequences of both are set out. +- Three shipped docstrings cited a gitignored, instance-private handover note + as the documented request body. That file is invisible to every reader of the + published repository — a dead link — and was never the vendor documentation + it was cited as; `docs/vendor-api-reference.md` now carries the citable + facts instead. A new guard (`scripts/tests/test_source_citations.py`) fails + the suite if any tracked file cites a gitignored path again — it scans + `easyvista_python_client/**/*.py`, `scripts/**/*.py`, `docs/*.rst`, + `docs/*.md`, `integration_tests/**/*.py`, and the root `.md` files, + including this changelog. +- `PostRequest`'s "send the whole documented set" doctrine is corrected. The + vendor requires only `catalog_guid` or `catalog_code`; the seven-field body + is a hedge against per-catalog configuration measured on one instance, which + the docstring stated as an API requirement. +- `TicketContext.to_markdown` dropped a non-default memo. Fetch was + parameterised by `memo_fields` and rendering was not, so + `get_ticket_context(rfc, memo_fields=("solution",))` — the headline case for + that parameter, a deployment whose body memo is neither default — produced a + Markdown export with no body section and no warning. It now applies the same + role-naming rule to `memos`: when neither `description` nor `comment` has + text, a single populated memo becomes the body under `## Description`, and + several each get a heading derived from the field name requested. + + +- `ev_since_filter` / `ev_between_filter` accepted a **timestamp string with no + UTC offset** and passed it to the wire. EasyVista accepts such a literal and + reads it in a different zone, which moves the bound and **silently skips + records** — measured live 2026-08-18, the same wall-clock text with and without + its offset enumerated 13 rows and 11 rows against one instance. Both builders + now refuse a time that carries no offset (or `Z`), matching the guard + `format_ev_datetime` already applied to a naive `datetime`; a bare date is + still accepted, having no time to misplace. Found by probing, not by review: + the datetime path was guarded and the string path was not, for the identical + hazard. +- `ev_since_filter` / `ev_between_filter` now **normalise** a timestamp string + bound instead of passing it through. The offset gate above made an offset + mandatory, and the obvious way to comply with a stored + `"2026-08-17T20:26:40"` watermark is to append `+02:00` — but measured live + 2026-08-18, `LAST_UPDATE:(2025-11-28T16:14:41+01:00;)` is **HTTP 590**, as are + minute precision, `seconds+00:00` and a space instead of `T` (which is what + `str(aware_datetime)` produces). Only a bare date and + millisecond-precision-with-offset (or `Z`) are honoured. An admitted string + bound is therefore re-rendered through + `format_ev_datetime(parse_ev_datetime(text))`, so the string and datetime + paths now emit byte-identical bounds and both emit a rendering the wire + accepts; a bare date is still passed through unchanged. Lowercase `z` is now + accepted too — `parse_ev_datetime` already accepted it on the read path, so + refusing it here rejected a value this package itself produces. The rendered + bound is validated as well, so a `datetime` in a zone whose UTC offset is not + a whole number of minutes (every pre-1900 `zoneinfo` entry) raises locally + instead of emitting `+05:53:20`. +- **Documented, not changed:** the interval's lower bound is **inclusive** and + milliseconds are honoured (verified live on three independent boundaries), so + a watermark set to `max(t.last_update)` re-reads that boundary record on the + next sweep. And an offset-pagination sweep over a change window must be sorted + **descending** (`sort="LAST_UPDATE DESC"`) and de-duplicated: the rows the + filter selects are by construction the rows that are changing, so a ticket + touched between two pages moves within the set being paged and can slip past + the read cursor. Descending, the row that slips is the re-touched one, whose + stamp is now *above* the watermark, so the next sweep picks it up — the miss is + deferred. Ascending, the row that slips is a neighbour whose stamp did *not* + change, so it falls *below* the watermark and is lost. A caller who cannot + tolerate even a deferred miss must page `search_tickets` with keyset + pagination (advance the window to the last row's stamp instead of an offset), + which `iter_tickets` cannot express. The sweep examples in `ev_since_filter`, + the user guide and the search-syntax skill all carry the sort and the + de-duplication. **Undocumented until now:** because descending yields the + newest row first, the watermark reaches its final value on page 1 of any + given sweep, so a sweep that is interrupted or capped with `max_records` + still ends up holding the newest stamp — advancing the watermark from it + permanently excludes every row the incomplete sweep never read. The four + sites above now say so: advance the watermark only after a sweep runs to + completion, and checkpoint a mid-sweep caller with keyset pagination instead. +- `Request`/`Action`/`Employee` timestamp columns now **raise** on a malformed + value instead of falling through to pydantic's own datetime parser, which is + far more permissive than EasyVista's format and invented plausible-looking + instants: `"20260817"` became `1970-08-23T12:00:17Z` (56 years off) and + `1755434441610` — what an epoch-millis format change would look like — became + a wholly credible `2025-08-17T12:40:41.610Z`. Absorbing a format change is the + opposite of what the guard exists for, and the docstring already promised a + raise. The `""` unset sentinel still becomes `None`, unchanged. +- **Documentation correction:** `RequestUpdate`'s docstring claimed `DESCRIPTION` + is empty on every ticket of the verified instance. It is not. A pooled 77-row + sample across four orderings found `COMMENT` populated on 57 rows, + `DESCRIPTION` on 27 and *both* on 24, with the proportions flipping by slice + (measured 2026-08-18). The earlier 0/15 reading was a sampling artifact drawn + from probe-authored tickets. The load-bearing claim is unchanged and still + verified: `RequestUpdate.description` writes the `COMMENT` memo. What is + withdrawn is the generalisation about `DESCRIPTION` being universally empty — + which also means an instance's body memo cannot be auto-detected by sampling. +- `find_departments` and `list_actions` interpolated caller values into a `search` expression + unescaped. Because `,` is an EasyVista combinator, a crafted value could silently widen the + result set (verified live: a department lookup returned 2 records instead of 1). Both now + validate the value. +- `TicketContext.to_markdown` rendered every action with an empty body. It read + the text from `Action.comment`, but `COMMENT` is a distinct field that never + carries it; the note supplied as `PostAction.description` comes back through + the action's `DESCRIPTION` Memo, which is reachable only via an item-level + `GET actions/{id}`. Verified against a live instance. +- **Every mapped exception's message no longer interpolates the raw HTTP response + body.** For a body this client does not recognize (an nginx or WAF HTML page, a + plain-text 503, any unmodelled shape), the message previously ended with that + body's literal text — which then surfaced verbatim wherever the exception was + rendered (`str(exc)`, a traceback, a test runner's failure summary), regardless + of what the body actually contained. The message now reports only the byte + count. **Added:** `EasyvistaError.body` (`bytes | None`) carries the raw + response body, so the content dropped from the message is not lost — it is the + only way left to retrieve an unrecognized body. `.status_code`, `.ev_code` and + `.ev_message` are unaffected: a *recognized* EasyVista error body (one with a + parseable `error`/`error_code` shape) reads exactly as it did before. +- `RECENT_TICKETS_SORT` used a colon-separated token (`RFC_NUMBER:DESC`) that + EasyVista silently ignores, so `get_department_context(recent_tickets=...)` + returned tickets in the API's default order rather than newest-first. The + descending token must be space-separated (`RFC_NUMBER DESC`) — verified live + 2026-08-17 by `integration_tests/test_live_change_window.py`. Closes O-DIR-1. + +### Notes + +- Open item **O-590-PARTIAL**: `PUT requests/{rfc}` with `URGENCY_ID` returned + HTTP 590 (code 2013) while nevertheless changing the stored value. A rejected + update may therefore have partially applied — re-read before retrying. Needs a + focused live probe (set each id from `GET /urgencies` in turn and re-read) + before `urgency_id` can be added to `RequestUpdate`. + ## [0.1.0] - 2026-07-15 Initial public release. @@ -188,5 +1022,6 @@ Initial public release. status/error code, with non-retryable validation errors (HTTP 590, code 2013). - `py.typed` marker — the package ships inline type information. -[Unreleased]: https://github.com/baraline/easyvista_python_client/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/baraline/easyvista_python_client/releases/tag/v0.1.0 +[Unreleased]: https://github.com/baraline/easyvista_python_client/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/baraline/easyvista_python_client/compare/0.1.0...v0.2.0 +[0.1.0]: https://github.com/baraline/easyvista_python_client/releases/tag/0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae27b0a..a3f47a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,9 +68,29 @@ python -m sphinx -W --keep-going -b html docs docs/_build/html inside the package, because it calls a **real EasyVista instance that you supply**. It never runs in CI — CI runs `pytest -m "not integration"`. -Credentials come from `EASYVISTA_TEST_*` environment variables, falling back to -files under `secrets/` (both gitignored). With none configured the suite skips -cleanly, so `pytest` on a fresh checkout is offline and green. +Credentials resolve from an environment variable first, then a lowercase file +under `secrets/` (both gitignored): + +| Environment variable | Fallback file | What it is | +| ------------------------- | -------------------------------- | ---------- | +| `EASYVISTA_TEST_URL` | `secrets/easyvista_test_url` | The instance URL. Normally the full API root, `https://host/api/v1/{account}`. | +| `EASYVISTA_TEST_TOKEN` | `secrets/easyvista_test_token` | The Bearer token. **The only credential that authenticates anything.** | +| `EASYVISTA_TEST_ACCOUNT` | `secrets/easyvista_test_account` | The account id — see below. **Not a login.** | + +`EASYVISTA_TEST_ACCOUNT` is the EasyVista *instance identifier* that forms the +`{account}` path segment of `https://host/api/{version}/{account}` — a number +such as `50004` — and it feeds `EasyvistaConfig.account`. Nothing authenticates +with it. It is read **only** when the URL is a bare host: a full API root already +carries the account, in which case the value is never consulted at all. + +> This variable was spelled `EASYVISTA_TEST_USER` (and `secrets/easyvista_test_user`) +> before 2026-08-25, which read as a username and never was one. The old name is +> now **refused with an error naming its replacement** rather than silently +> accepted, so a leftover copy cannot quietly reintroduce the confusion. If you +> have one, rename it. + +With none configured the suite skips cleanly, so `pytest` on a fresh checkout is +offline and green. > **These tests are not read-only.** They create tickets and close them in > teardown. Once your credentials are present they run as part of a plain diff --git a/README.md b/README.md index cda7682..aba3a7b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/baraline/easyvista_python_client/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/baraline/easyvista_python_client/actions/workflows/ci.yml) [![Coverage](https://codecov.io/gh/baraline/easyvista_python_client/branch/main/graph/badge.svg)](https://codecov.io/gh/baraline/easyvista_python_client) -[![License](https://img.shields.io/github/license/baraline/easyvista_python_client)](LICENSE) +[![License](https://img.shields.io/github/license/baraline/easyvista_python_client)](https://github.com/baraline/easyvista_python_client/blob/main/LICENSE) [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://github.com/baraline/easyvista_python_client) [![Docs](https://readthedocs.org/projects/easyvista-python-client/badge/?version=latest)](https://easyvista-python-client.readthedocs.io/en/latest/) @@ -10,7 +10,8 @@ Typed Python client for the EasyVista Service Manager REST API. Sync + async, Pydantic models, Bearer or Basic auth. -While the package is preparing for 1.0, alot of potential breaking change might happen between versions. A deprecation policy will be put in place once 1.0 is out and the package have been stabilized. +While the package is preparing for 1.0, breaking changes may land between +minor versions; a deprecation policy will follow the 1.0 release. ## Documentation @@ -35,18 +36,26 @@ from easyvista_python_client import ( ev_equals_filter, ) +# `account` is the instance id in the API root (https://host/api/v1/12345), not a username. config = EasyvistaConfig(server="https://my.easyvista.com", account="12345", token="...") with EasyvistaClient(config) as client: - # catalog_code, the *_id values and the close status_guid are instance-specific. + # catalog_code, the *_id values and the close status_guid are + # instance-specific -- `client.describe_instance()` finds them for you. + # `external_reference` is your own marker, and it is what lets you + # reconcile a create that failed: see the note below this block. ticket = client.create_ticket( PostRequest( catalog_code="INC_STANDARD", title="Printer down", description="The 3rd-floor printer is offline", - origin=7, + # The vendor documents `origin` as a channel NAME, not an id -- + # the one create field with a portable form. An int is also + # accepted (measured on one instance) and passes through as sent. + origin="Phone", department_id=9, urgency_id=8, impact_id=28, + external_reference="MYAPP-0001", # your own marker; set it always ) ) fetched = client.get_ticket(ticket.rfc_number) @@ -57,7 +66,10 @@ with EasyvistaClient(config) as client: for t in client.iter_tickets(search=open_status, page_size=100, max_records=1000): ... # async: `async for t in client.iter_tickets(...)` - # close it with your instance's "closed" status GUID + # close it with your instance's "closed" status GUID. Every argument is + # optional -- `client.close_ticket(ticket.rfc_number)` sends the close with + # no status of its own, but where that lands the ticket is not established + # by this package; see the user guide before relying on it. client.close_ticket( ticket.rfc_number, status_guid="{00000000-0000-0000-0000-000000000000}", @@ -66,9 +78,58 @@ with EasyvistaClient(config) as client: ) ``` -> Minimum fields for a create are catalog-specific (server-side). `catalog_code` + `title` -> work for incident catalogs; a missing mandatory field raises `EasyvistaValidationError` -> (HTTP 590, code 2013) — it is not retried. +> A create needs a subject: `catalog_guid` (the vendor's preferred identifier) or +> `catalog_code`. Anything beyond that is catalog-specific and enforced server-side, so a +> field a given catalog insists on raises `EasyvistaValidationError` (HTTP 590, code 2013) +> — it is not retried, and the message names no field. +> +> **Do not retry that 590 blindly.** Measured on one instance (2026-08-25), a rejected +> create may still have created the ticket: 12 attempts returned 3 `RFC_NUMBER`s and +> afterwards all 12 tickets existed. A 590 means *possibly created*, never *not created*. +> Set `external_reference` on every create and reconcile by that marker — it survives the +> failed insert and is searchable. + +## Comments and actions + +An action is a unit of work, and it is born **open** — an open action shows in the +UI as a pending row with its text *not* displayed, which reads as though the note +was lost. A comment is an action that has been **ended**. + +```python +from easyvista_python_client import PostAction, PostTask + +# A COMMENT: `create_task` posts the same record already ended, in one call. +# Put the text in `description` -- the UI renders one field per action and +# `description` shadows `comment`, so text in `comment` beside a populated +# `description` is stored, readable through the API, and displayed to nobody. +client.create_task( + rfc, + PostTask(action_type_id=94, group_id=3, description="Investigating now."), +) + +# WORK SOMEONE MUST STILL DO: create it open, then end it when it is done. +client.create_action( + rfc, + PostAction(action_type_id=94, group_id=3, description="Chase the supplier."), +) +client.end_action( + rfc, + action_id=1234, # not recoverable from the create response + start_date="01/09/2026 17:00:00", # your instance's format, not ISO 8601 + end_date="01/09/2026 17:15:00", + elapsed_time=15, # MINUTES +) +``` + +> **There is no private-comment flag.** Visibility is carried by the action +> *type*, which is per-deployment — read the ids off existing actions with +> `client.discover("ACTION_TYPE")` rather than hardcoding one. +> +> **`end_action` on a workflow action changes the ticket.** Ending your own +> action only ends it; ending the ticket's open workflow step advances the +> workflow and moves the ticket's status. Naming `action_id` is therefore +> required — the vendor's id-less "end every open action" form is behind an +> explicit `end_all=True`. ## Assets and documents @@ -78,6 +139,7 @@ from easyvista_python_client import ( EasyvistaClient, EasyvistaConfig, PostAsset, + ev_contains_filter, ev_equals_filter, ) @@ -86,6 +148,16 @@ with EasyvistaClient(EasyvistaConfig.from_env()) as client: tag_filter = ev_equals_filter("ASSET_TAG", "LAPTOP-001") found = client.search_assets(search=tag_filter, max_rows=50) + # On the instance this package was characterized against, `~` needs an + # explicit wildcard to mean "contains" -- a bare value is exact match, + # identical to `:`. ev_contains_filter appends it for you; the vendor + # documents `~` as plain Contains, so pass wildcard=None if that is your + # deployment. It raises ValueError if the value carries `_` or `[` (both + # are metacharacters to `~` itself, with no escape) or `*`/`%` while a + # wildcard is being appended. For an exact match on a tag like + # "LAPTOP_01", use ev_equals_filter: `:` does not expand a wildcard. + partial = client.search_assets(search=ev_contains_filter("ASSET_TAG", "LAPTOP")) + # attach a file to a ticket (uploaded as base64 inside the JSON body) pdf = Path("report.pdf") client.add_document("I240101_0001", filename=pdf.name, content=pdf.read_bytes()) @@ -95,10 +167,18 @@ with EasyvistaClient(EasyvistaConfig.from_env()) as client: ## Usage (async) ```python +import asyncio + from easyvista_python_client import AsyncEasyvistaClient, EasyvistaConfig -async with AsyncEasyvistaClient(EasyvistaConfig.from_env()) as client: - ticket = await client.get_ticket("I240101_0001") + +async def main(): + async with AsyncEasyvistaClient(EasyvistaConfig.from_env()) as client: + ticket = await client.get_ticket("I240101_0001") + print(ticket.rfc_number) + + +asyncio.run(main()) ``` ## Configuration via environment @@ -112,7 +192,7 @@ then call `EasyvistaConfig.from_env()`. `skills/` holds Agent Skills for driving this client from an AI agent — one per domain (client setup, search syntax, tickets, actions, documents, assets, directory, reporting and context). Each is a directory with a `SKILL.md` -following the Agent Skills specification; see [skills/README.md](skills/README.md) +following the Agent Skills specification; see [skills/README.md](https://github.com/baraline/easyvista_python_client/blob/main/skills/README.md) for the index. They are source-tree material: present in the git repository and the source @@ -120,11 +200,11 @@ distribution, absent from the installed wheel. ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and quality checks. +See [CONTRIBUTING.md](https://github.com/baraline/easyvista_python_client/blob/main/CONTRIBUTING.md) for development setup and quality checks. ## License -MIT — see [LICENSE](LICENSE). +MIT — see [LICENSE](https://github.com/baraline/easyvista_python_client/blob/main/LICENSE). ## Sponsoring diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 1ae3600..f756070 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -13,6 +13,8 @@ Configuration .. autoclass:: easyvista_python_client.config.EasyvistaConfig +.. autodata:: easyvista_python_client.DEFAULT_USER_AGENT + Models ------ @@ -26,6 +28,10 @@ Models .. autoclass:: easyvista_python_client.models.action.PostAction +.. autoclass:: easyvista_python_client.models.action.PostTask + +.. autoclass:: easyvista_python_client.models.action.ActionUpdate + .. autoclass:: easyvista_python_client.models.asset.Asset .. autoclass:: easyvista_python_client.models.asset.PostAsset @@ -48,6 +54,8 @@ Models .. autoclass:: easyvista_python_client.context.TicketContext +.. autodata:: easyvista_python_client.DEFAULT_MARKDOWN_FIELDS + .. autoclass:: easyvista_python_client.directory.DepartmentContext Reporting @@ -61,23 +69,58 @@ Filters ------- Build ``search`` expressions with these rather than f-strings: EasyVista ignores a filter it cannot -parse and returns every record, and ``,`` combines conditions — so an unescaped value fails silently -or widens the result rather than raising. +parse and returns every record, ``,`` combines conditions so an unescaped value can silently widen +the result, and there is no comparison operator — a range must be expressed as an interval. .. autofunction:: easyvista_python_client.filters.ev_equals_filter .. autofunction:: easyvista_python_client.filters.ev_in_filter +.. autofunction:: easyvista_python_client.filters.ev_contains_filter + +.. autofunction:: easyvista_python_client.filters.ev_starts_with_filter + +.. autofunction:: easyvista_python_client.filters.ev_since_filter + +.. autofunction:: easyvista_python_client.filters.ev_between_filter + .. autofunction:: easyvista_python_client.filters.escape_ev_value .. autofunction:: easyvista_python_client.filters.is_safe_ev_value +Timestamps +---------- + +EasyVista's timestamp format, parsed and rendered in one place — see +:ref:`timestamps` for how the read models use these. + +.. autofunction:: easyvista_python_client.timestamps.parse_ev_datetime + +.. autofunction:: easyvista_python_client.timestamps.format_ev_datetime + References ---------- .. autoclass:: easyvista_python_client.references.Reference -.. autofunction:: easyvista_python_client.references.localized_label +.. autodata:: easyvista_python_client.DEFAULT_LANGUAGE_ORDER + +.. autofunction:: easyvista_python_client.localized_label + +.. autofunction:: easyvista_python_client.references.label_from_record + +Instance discovery +------------------ + +.. autoclass:: easyvista_python_client.DiscoveredReference + +.. autoclass:: easyvista_python_client.InstanceProfile + +.. autoclass:: easyvista_python_client.ReferenceSource + +.. autoclass:: easyvista_python_client.GenericRecord + +.. autodata:: easyvista_python_client.DEFAULT_DISCOVERY_NAMES Every read model exposes ``.reference(name)`` returning a :class:`~easyvista_python_client.references.Reference` for any field, including custom ``e_*`` fields. diff --git a/docs/development.rst b/docs/development.rst index 03371d3..adbf45a 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -68,9 +68,29 @@ Integration tests calls a **real EasyVista instance** that you supply. It never runs in CI — CI runs ``pytest -m "not integration"``. -Credentials resolve from ``EASYVISTA_TEST_URL`` / ``EASYVISTA_TEST_USER`` / ``EASYVISTA_TEST_TOKEN``, -falling back to files under ``secrets/`` (both gitignored). With no credentials configured the suite -**skips cleanly**, so a plain ``pytest`` on a fresh checkout is offline and green. +Credentials resolve from an environment variable first, then a lowercase file under ``secrets/`` +(both gitignored):: + + url <- EASYVISTA_TEST_URL | secrets/easyvista_test_url + account <- EASYVISTA_TEST_ACCOUNT | secrets/easyvista_test_account + token <- EASYVISTA_TEST_TOKEN | secrets/easyvista_test_token + +``EASYVISTA_TEST_TOKEN`` is the Bearer token, and the only credential that authenticates anything. +``EASYVISTA_TEST_ACCOUNT`` is **not a login**: it is the instance identifier forming the +``{account}`` path segment of ``https://host/api/{version}/{account}`` -- a number such as +``50004`` -- and it feeds ``EasyvistaConfig.account``. It is read only when ``EASYVISTA_TEST_URL`` +is a bare host; a full API root already carries the account, and then the value is never consulted +at all. + +.. note:: + + ``EASYVISTA_TEST_ACCOUNT`` was spelled ``EASYVISTA_TEST_USER`` (and ``secrets/easyvista_test_user``) + before 2026-08-25, which read as a username and never was one. The old name is now **refused with + an error naming its replacement**, not silently accepted, so a leftover copy cannot quietly + reintroduce the confusion. + +With no credentials configured the suite **skips cleanly**, so a plain ``pytest`` on a fresh checkout +is offline and green. .. warning:: diff --git a/docs/publishing.rst b/docs/publishing.rst index eb394b3..eaf4770 100644 --- a/docs/publishing.rst +++ b/docs/publishing.rst @@ -12,15 +12,26 @@ the workflow's OIDC identity. Cutting a release ----------------- -#. Bump the version in **both** places -- ``pyproject.toml`` (``project.version``) and - ``easyvista_python_client.__version__``. The release workflow refuses to build if they - disagree, or if they disagree with the tag. +#. Bump the version in **all four** places, or CI goes red on an otherwise correct + bump: + + * ``pyproject.toml`` (``project.version``); + * ``easyvista_python_client.__version__``; + * the hardcoded literal in ``easyvista_python_client/testing/test_public_api.py`` + (asserted by the unit suite); + * every ``skills/*/SKILL.md`` ``metadata.version`` (asserted by + ``scripts/tests/test_skills_contract.py``). + + The release workflow additionally refuses to build if the first two disagree with + each other or with the tag. #. Move the ``CHANGELOG.md`` ``[Unreleased]`` entries under the new version and update the compare links at the bottom of the file. #. Merge to ``main`` and let CI go green. #. Publish a GitHub release whose tag is the version, ``v``-prefixed -- ``v0.2.0`` for version ``0.2.0``. (The workflow strips a leading ``v`` before comparing, - so an unprefixed tag also passes; the repository's existing tags are prefixed.) + so an unprefixed tag also passes. The only tag that exists today, ``0.1.0``, is + **unprefixed** -- ``v``-prefixing starts at ``v0.2.0``, which is why the + ``CHANGELOG.md`` link for ``0.1.0`` points at the bare tag.) The workflow then runs the test matrix (3.10--3.14) and the quality gates -- Ruff, mypy, the generated-``_sync``-tree check, the hand-written-twin lint and a warnings-as-errors @@ -41,9 +52,10 @@ One-time setup Trusted Publishing Configure a publisher on PyPI for the project pointing at owner ``baraline``, repository ``easyvista_python_client``, workflow ``release.yml``, environment ``pypi``. - Until the project's first upload exists, this is registered as a *pending* publisher. - The ``pypi`` GitHub environment is also where a required-reviewer gate on the upload - step belongs, if the project wants one. + ``0.1.0`` is already on PyPI, so the project is past the *pending* publisher stage + this section used to describe -- a pending publisher is only needed before a project's + first upload exists. The ``pypi`` GitHub environment is also where a required-reviewer + gate on the upload step belongs, if the project wants one. Read the Docs (optional) The docs job self-skips when unconfigured. To enable it, set the ``READTHEDOCS_PROJECT`` diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 6d3ec8c..68f89ec 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -7,9 +7,16 @@ equivalents, which mirror every method name. .. note:: - Values such as the server host, ``account``, ``catalog_code``, the close ``status_guid``, and - ``group_id`` are **instance-specific**. The values below are illustrative; replace them with the - ones from your EasyVista instance. + Values such as the server host, ``account``, ``catalog_code``, the close ``status_guid``, + ``group_id``, ``action_type_id``, ``department_id``, ``urgency_id`` and + ``impact_id`` are **instance-specific**. The values below are illustrative; replace them with + the ones from your EasyVista instance — + :meth:`~easyvista_python_client.EasyvistaClient.describe_instance` finds them + in one call. + + ``origin`` is the exception: the vendor documents it as a string naming the + channel (``"Phone"``, ``"Email"``), so it is the one create field with a + portable, human-readable form and needs no discovery. Creating a client ------------------ @@ -24,12 +31,21 @@ underlying HTTP connection on exit. config = EasyvistaConfig( server="https://my.easyvista.com", - account="12345", + account="12345", # the instance id in the API root -- NOT a username token="...", # static Bearer access token ) with EasyvistaClient(config) as client: ticket = client.get_ticket("I240101_0001") +.. important:: + + ``account`` is **not a user account**. It is the EasyVista *instance* + identifier -- a number -- that forms the final path segment of the API root, + ``https://host/api/{version}/{account}``. Nothing authenticates with it; that + is the job of ``token``, or ``login`` + ``password``. If your instance URL + already reads ``https://my.easyvista.com/api/v1/12345``, then ``12345`` is + your ``account``. + Authentication ~~~~~~~~~~~~~~~ @@ -42,7 +58,7 @@ the token wins. # Bearer token EasyvistaConfig(server="https://my.easyvista.com", account="12345", token="...") - # HTTP Basic + # HTTP Basic -- note that ``login`` and ``account`` are unrelated values EasyvistaConfig(server="https://my.easyvista.com", account="12345", login="rest.user", password="...") @@ -63,6 +79,115 @@ variables, so credentials stay out of source. It reads, in order: ``EASYVISTA_UR search = ev_equals_filter("STATUS_ID", 3) results = client.search_tickets(search=search, max_rows=50) +.. _first-steps: + +First steps on your instance +---------------------------- + +Almost every value a write needs — the catalog, the status GUID, the action +type ids, the group ids — is configured on your EasyVista deployment and is not +portable from anyone else's. This section is the order to discover them in. +Steps 1 to 6 are reads and create nothing. + +The short version is one call: + +.. code-block:: python + + from easyvista_python_client import EasyvistaClient + + with EasyvistaClient.from_env() as client: + profile = client.describe_instance() + print(profile.version, len(profile.spec_paths)) + + # Read the gaps FIRST. A total outage looks exactly like a bare + # instance except that every gap is named here. + for gap, reason in profile.unavailable.items(): + print("gap:", gap, reason) + + for status in profile.references["STATUS"]: + # .guid is what close_ticket and set_status address a status by. + print(status.id, status.label, status.guid) + +That is :meth:`~easyvista_python_client.EasyvistaClient.describe_instance`; see +the ``easyvista-instance-discovery`` skill for the whole surface. The long +version, and what it is doing under the covers: + +1. **Prove the connection** and get one real record. +2. **Read the ids off it.** ``reference(name)`` for id + label, + ``classify_fields()`` for the instance's own ``e_*`` columns and its + href-only memo links. +3. **Find a catalog you can create against** — see + `Finding your catalog_code or catalog_guid`_. This is the step that most + often needs an administrator. +4. **Find the action type ids**, and confirm which means "internal" with that + administrator; the ids are discoverable, the meaning is not. +5. **Find the close status GUID.** It is a sub-key of the nested ``STATUS`` + object and is not searchable. +6. **Pin what you found, in your own configuration.** +7. **Do the first write on a throwaway, then re-read.** + +Keep the reads in steps 1–2 **unprojected**. Passing ``fields=`` narrows a +record to the columns you name and drops the nested ``STATUS`` / +``DEPARTMENT`` / ``CATALOG_REQUEST`` objects that carry the labels and the GUID +(measured on one instance; it may not generalise). Projection is worth reaching +for later, when you know which columns you want — the default search projection +returns ``TITLE`` empty, for instance, so a listing wants +``fields=["RFC_NUMBER", "TITLE"]``. + +.. code-block:: python + + from easyvista_python_client import EasyvistaClient + + with EasyvistaClient.from_env() as client: + # 1. Prove the connection, and get one real record. + probe = client.search_tickets(max_rows=1) + sample = client.get_ticket(probe.records[0].rfc_number) + + # 2. The ids this instance uses, with their human labels. + for name in ("STATUS", "DEPARTMENT", "URGENCY", "IMPACT", "CATALOG_REQUEST"): + ref = sample.reference(name) + print(name, "->", ref.id, ref.display) + + buckets = sample.classify_fields() + print("instance columns:", sorted(buckets.custom)) + print("memo links:", sorted(buckets.links)) + + # 5. The close GUID -- a sub-key of the nested STATUS object, present + # only on an unprojected read. Read it off a ticket already in the + # state you want to reach. + status = (sample.model_extra or {}).get("STATUS") or {} + print("status:", status.get("STATUS_ID"), status.get("STATUS_GUID")) + +.. warning:: + + ``reference("CATALOG_REQUEST").id`` is the catalog's ``SD_CATALOG_ID``, and + :class:`~easyvista_python_client.PostRequest` accepts ``catalog_guid`` or + ``catalog_code`` and **no id field at all**. Step 2 tells you which catalog + a ticket used; it does not give you a value you can send. See + `Finding your catalog_code or catalog_guid`_. + + **Never infer "closed" from a status id.** They are per-instance: on the + verified instance ``8`` is *Clôturé* and ``12`` is *En cours* — adjacent + numbers, opposite meanings. ``end_date_ut`` is the portable signal: empty on + an open ticket, stamped on a closed one. + +Step 6 — **pin what you found in your own configuration.** This package holds +no registry of instance values and never will: they belong to your deployment, +not to the library. Pass them the way you pass any other application setting, +preferring, in order, a method or constructor keyword, a field on your own +configuration object, a module constant a caller can override, and an +environment variable only as a last resort. +:meth:`~easyvista_python_client.EasyvistaConfig.from_env` is a convenience for +credentials in a script, not a configuration mechanism for a library you have +installed into an application. + +Step 7 — **do the first write against a throwaway ticket, and re-read it.** Set +``external_reference`` on the create: a rejected create may still have created +the row, and that marker survives the failed insert and is searchable, so it is +what lets you reconcile instead of retrying and duplicating. And re-read after +every write: this API answers HTTP 200 and drops fields in silence, so a 200 is +not a receipt. + .. _sync-vs-async: Synchronous vs asynchronous @@ -108,9 +233,14 @@ Working with tickets --------------------- Tickets are EasyVista *requests*. Create one with a -:class:`~easyvista_python_client.PostRequest`. The minimum fields are catalog-specific and enforced -server-side; ``catalog_code`` + ``title`` work for incident catalogs. A missing mandatory field -raises :class:`~easyvista_python_client.EasyvistaValidationError` (see :ref:`error-handling`). +:class:`~easyvista_python_client.PostRequest`. The subject is the only part the vendor documents as +required, and it is named either by ``catalog_guid`` — the form the vendor documents as +**preferred** — or by ``catalog_code``; a body carrying neither is refused locally, before any +request goes out. Beyond the subject, which fields a catalog insists on is per-catalog +configuration enforced server-side, and a missing one raises +:class:`~easyvista_python_client.EasyvistaValidationError` (see :ref:`error-handling`) with a +message that names no field. The fuller body below is a hedge against that: it was accepted on +every catalog tried on one instance, which makes it a safe default rather than an API requirement. .. code-block:: python @@ -118,18 +248,93 @@ raises :class:`~easyvista_python_client.EasyvistaValidationError` (see :ref:`err ticket = client.create_ticket( PostRequest( + # Or catalog_guid="{...}", the vendor's preferred subject identifier. + # GET /catalog-requests is 403 on a restricted profile -- that is a + # grant to ask for, not a limit. See "Finding your catalog" below. catalog_code="INC_STANDARD", # instance-specific catalog title="Printer down", description="The 3rd-floor printer is offline", - origin=7, + # The vendor documents `origin` as a STRING naming the channel + # ("Phone", "Email") -- the one create field with a portable, + # human-readable form, so it needs no per-instance discovery. An + # int id is also accepted (measured on one instance; it may not + # generalise) and passes through unchanged if you prefer one. + origin="Phone", department_id=9, # instance-specific; see "Departments and employees" - urgency_id=8, # 4 = total outage, 7 = penalizing, 8 = invisible - impact_id=28, # 17 = critical-prod, 21 = non-critical, 28 = test + urgency_id=8, # instance-specific placeholder -- see the note below + impact_id=28, # instance-specific placeholder -- see the note below recipient_mail="user@example.com", ) ) print(ticket.rfc_number) +.. note:: + + ``department_id``, ``urgency_id`` and ``impact_id`` are ids, and what each id + *means* is per-instance configuration: nothing above is a portable legend, and an id copied + from this page is not guaranteed to name anything on your deployment. Read yours off a ticket + that already carries the value you want — ``ticket.reference("URGENCY")`` and + ``ticket.reference("IMPACT")`` yield the id, plus the human label when the instance projects + one and ``None`` when it does not (``.display`` falls back to the id) — or ask your EasyVista + administrator. + +Finding your catalog_code or catalog_guid +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The subject is the one part of a create the vendor documents as required, and +it is the one field you cannot read off a ticket you already have. +``ticket.reference("CATALOG_REQUEST")`` resolves to the catalog's +``SD_CATALOG_ID`` and its title — useful for display, useless for a create, +because :class:`~easyvista_python_client.PostRequest` accepts ``catalog_guid`` +and ``catalog_code`` and **no id field at all**. Reading a ticket tells you +*which* catalog it used; it does not give you a value you can send. + +:meth:`~easyvista_python_client.EasyvistaClient.discover` reads the catalog +table for you and puts the code where you need it: + +.. code-block:: python + + for catalog in client.discover("CATALOG_REQUEST"): + # .code is what PostRequest(catalog_code=...) takes. + print(catalog.code, catalog.label, catalog.path) + +Three routes carry the catalog, and all three are declared in the instance's +own OpenAPI (read from ``GET {api_root}/swagger``, 2026-08-27, EasyVista 2025.3 +— authoritative for that deployment): ``GET /catalog-requests`` (the list, which +``discover`` uses), ``GET /catalog-requests-paths`` (the same table addressed by +catalog path) and ``GET /catalog-requests/{catalog_id}`` (the item). + +On the restricted profile this package was verified against, all three answer +**403** (measured on one instance; it may not generalise). That is a profile +denial, not a missing route — the route is declared in that same deployment's +spec. **If you cannot read your catalogs, ask your EasyVista administrator to +authorize the REST profile for ``catalog-requests``**; it is a grant, not a +limitation of the API. ``discover`` degrades to sampling meanwhile, which +returns only the catalogs already used by a ticket you can see; and +``describe_instance()`` records the denial in ``.unavailable`` rather than +returning a silently empty list. + +.. note:: + + **``catalog_guid`` is not discoverable at all.** No route returns one — the + ``/catalog-requests`` response schema declares ``CODE``, ``SD_CATALOG_ID``, + ``TITLE_EN`` and ``CATALOG_REQUEST_PATH``, and no ``CATALOG_GUID``. The + vendor documents ``catalog_guid`` as the *preferred* identifier and + ``close_ticket`` accepts one; you simply cannot read one back, so build with + ``catalog_code``. Those column names come from the instance's OpenAPI + *response schemas*, which are example-derived and illustrative only (see + ``docs/vendor-api-reference.md``) — a different deployment may name them + otherwise. + + ``SD_CATALOG_PATH_EN`` is a top-level column *of the catalog-requests-paths + table*, so it filters there. The similarly named ``SD_CATALOG_PATH`` on a + **ticket** is a denormalized display column and is silently ignored as a + search condition. + +With no route access at all, the remaining option is to ask the administrator +for the code or GUID of each catalog you must create against, and pin those in +your configuration the way you pin any other instance-specific value. + Create several tickets in one call with :meth:`~easyvista_python_client.EasyvistaClient.create_tickets`: .. code-block:: python @@ -156,6 +361,25 @@ Fetch, update, and close a ticket by its RFC number: comment="Resolved", ) + # Every argument is optional -- this sends the close with no status of its + # own, letting the instance decide where the ticket lands. + client.close_ticket(ticket.rfc_number) + + # Verify by re-reading, not by the return value: end_date_ut is empty on an + # open ticket and stamped on a closed one, and is more portable than any + # status id (on the verified instance 8 is "Clôturé" and 12 is "En cours"). + assert client.get_ticket(ticket.rfc_number).end_date_ut is not None + +.. warning:: + + Where a ticket lands when ``status_guid`` is omitted is **not established by + this package**. The client simply omits the key; what the server does with a + status-less ``closed`` body has never been measured against a live instance + here, and the behaviour is not recorded in ``docs/vendor-api-reference.md``. + Try it on a throwaway ticket and re-read before you build on it. Passing + your instance's closed ``status_guid`` explicitly is the form this package's + live suite actually exercises. + .. note:: A ``description`` supplied at create time was not readable back through either @@ -174,6 +398,33 @@ any write model; keys are serialized to their ``e_*`` API names automatically. PostRequest(catalog_code="INC_STANDARD", title="...", custom_fields={"e_location": "Paris"}) +There are **two** escape hatches, and they are not interchangeable. ``custom_fields`` only ever +emits ``e_``-prefixed keys, so it cannot reach an *official* column this package declines to +declare. ``extra_payload`` — also on every write model — is the un-prefixed one: whatever you put +in it reaches the wire exactly as written. + +.. code-block:: python + + from easyvista_python_client import RequestUpdate + + # An official column this model does not declare, sent anyway. + RequestUpdate(title="New title", extra_payload={"URGENCY_ID": 4}) + +Three properties are worth knowing before you reach for it: + +* It is merged **last and wins**. A key that matches a declared field, or a key ``custom_fields`` + produced, replaces it — matched **ignoring case**. So + ``RequestUpdate(impact_id=8, extra_payload={"IMPACT_ID": 4})`` sends ``IMPACT_ID`` alone, not + both. The case-insensitive match is what the vendor documents for the ticket *create* body; the + other write bodies are assumed to match it, which is the safe assumption in either direction. +* It **bypasses this model's validation entirely**. Nothing checks the name, the type or a length + cap; a typo reaches the server as a typo. +* Every field these models decline to declare rests on behaviour measured against a single + instance. ``extra_payload`` is the supported way past those measurements on a deployment that + behaves differently — including for the fields :class:`~easyvista_python_client.RequestUpdate` + deliberately omits. Re-read the record afterwards: on this API a write can return HTTP 200, + apply one field and drop another in silence. + Actions (comments / followups) ------------------------------- @@ -185,12 +436,22 @@ Actions are EasyVista's followup/comment analog. Add one with a from easyvista_python_client import PostAction + # An action is born OPEN, and an open action's text is not displayed -- + # so create and end are a pair. For a comment, use create_task instead. + before = {a.action_id for a in client.list_actions(ticket.rfc_number)} client.create_action( ticket.rfc_number, PostAction(action_type_id=94, group_id=3, description="Triaged: on it"), ) - for action in client.list_actions(ticket.rfc_number): - print(action.action_id) + after = client.list_actions(ticket.rfc_number) + created = [a for a in after if a.action_id not in before] + client.end_action( + ticket.rfc_number, + action_id=created[0].action_id, + start_date="01/09/2026 17:00:00", + end_date="01/09/2026 17:15:00", + elapsed_time=15, + ) .. note:: @@ -203,17 +464,284 @@ Actions are EasyVista's followup/comment analog. Add one with a :meth:`~easyvista_python_client.EasyvistaClient.get_ticket_context` resolve it for you onto ``Action.description``). +Reading a whole action log +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:meth:`~easyvista_python_client.EasyvistaClient.list_actions` returns **one +page**. A ticket carrying more actions than ``default_max_rows`` is truncated +with no error, and the call discards the envelope's total, so nothing in the +result reveals it. That cap is easier to hit than it looks: a freshly created +ticket already carries about a dozen workflow-generated actions before anyone +has commented. Use +:meth:`~easyvista_python_client.EasyvistaClient.iter_actions` when the complete +log matters — a comment sync, an export, an audit — and keep ``list_actions`` +for the cheap "show me the recent ones" read. + +.. code-block:: python + + for action in client.iter_actions(ticket.rfc_number): + print(action.action_id, action.action_label_fr) + +.. warning:: + + Unlike :meth:`~easyvista_python_client.EasyvistaClient.iter_tickets`, the + offset pagination behind ``iter_actions`` has **not** been measured against a + live instance — it assumes the ``offset``/``@next`` contract every other + search on this API follows. If your instance's ``actions`` endpoint ignores + ``offset``, page two repeats page one and the sweep will not end on its own. + Bound it with ``max_records`` the first time you run it against a ticket + whose action count you do not already know. + +Two stored text fields, but only one is displayed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An action stores **two separate text fields**, ``description`` and +``comment``, each addressable afterwards as its own memo +(``actions/{id}/description``, ``actions/{id}/comment``). +:class:`~easyvista_python_client.PostAction` writes both, and both persist from +a single create — verified live on 2026-08-28, each reading back with exactly +the text sent. + +They are independent in storage, not in visibility. + +.. warning:: + + **A non-empty** ``description`` **hides** ``comment`` **from every reader.** + The UI shows one text field per action, under a header reading "comment or + description": it renders ``DESCRIPTION`` when that memo has text, and falls + back to ``COMMENT`` only when it is empty. Measured in the UI on + 2026-09-01 against one instance (Service Manager 2025.3) — one instance, + one date, so it may not generalise. + + Text written to ``comment`` beside a populated ``description`` is stored, + reads back cleanly through the API, and is never shown to anyone. There is + no error and no dropped field, so nothing signals the loss. ``comment`` is + not the private channel; it is the unread one. Visibility is carried by the + action **type** instead — see :ref:`tasks-vs-actions`. + +.. code-block:: python + + from easyvista_python_client import PostAction + + PostAction( + action_type_id=94, # instance-specific + # The field the history renders. Anything a person must read goes here. + description="The text a reader will actually see.", + # `comment` is a second memo on the same record. Set it only when you + # deliberately leave `description` empty, or mean it as API-only + # metadata -- with a description present, nobody reads it. + ) + +To fix or extend text that is already posted, write +:class:`~easyvista_python_client.ActionUpdate` with ``description``: it applies +to an action that has already ended, and the new text renders (measured in the +UI on 2026-09-01, one instance). + +.. _tasks-vs-actions: + +Tasks vs. actions: use a task for a comment +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A task and an action are the **same underlying record**. They differ only in +the state they are born in, and that difference decides whether a reader ever +sees the text: + +.. list-table:: + :header-rows: 1 + :widths: 26 37 37 + + * - .. + - :meth:`~easyvista_python_client.EasyvistaClient.create_action` + - :meth:`~easyvista_python_client.EasyvistaClient.create_task` + * - endpoint + - ``POST requests/{rfc}/actions`` + - ``POST requests/{rfc}/tasks`` + * - body shape + - wrapped + - flat at the root + * - born + - **open** — work still to do + - **ended** — work reported + * - in the UI + - a pending row, text **not** shown + - a history entry **with** its text + * - needs ending after + - yes + - no + * - ``parent_action_id`` + - resolved implicitly; needed when 0 or 2+ actions are open + - not needed + * - use it for + - work someone must still do + - **comments** + +.. code-block:: python + + from easyvista_python_client import PostTask + + client.create_task( + ticket.rfc_number, + PostTask( + action_type_id=95, # instance-specific: the internal type + group_id=3, # instance-specific + description="Internal working note.", + ), + ) + +Verified live on 2026-08-28: tasks came back with ``END_DATE_UT`` and +``STATUS_ID_ON_TERMINATE`` already set, and their text appeared in the ticket +history. + +.. warning:: + + **Creating an action and stopping there loses nothing but shows nothing.** + The text is stored; the row simply renders without it until the action is + ended. Finish it with :meth:`~easyvista_python_client.EasyvistaClient.end_action`. + An earlier revision of this guide said every documented form returned + ``590 Action not found`` and suggested raising it with your administrator. + That was wrong: the 590 is what the route answers when no *open* action + matches, such as replaying it against one already ended. For comments, use a + task and the question does not arise. + +.. code-block:: python + + client.end_action( + "YOUR_RFC_NUMBER", + action_id=YOUR_ACTION_ID, + start_date="01/09/2026 17:00:00", + end_date="01/09/2026 17:15:00", + elapsed_time=15, + ) + +Measured 2026-09-01 on one instance (one instance, one date, so it may not +generalise): ``end_date`` takes your instance's ``DATE_FORMAT`` -- +``dd/mm/yyyy hh:mm:ss`` there, and ISO 8601 is refused -- ``elapsed_time`` is +minutes, and you should send ``start_date`` explicitly because a derived one +comes back early by your instance's UTC offset. + +.. warning:: + + **Ending a workflow action advances the workflow.** On the same instance and + date, ending a fresh ticket's open type-20 *Traitement Operation* action + moved the **ticket** from *En cours* to *Résolu* and spawned a new open + type-1 *Validation Self Service* action (2 tickets, 2/2); a control showed + ending a type-94 action the caller had created changed neither the status + nor the action count. Ending your own action is inert, ending a workflow + step is not. Omitting ``action_id`` ends **every** open action, which on a + ticket whose only open one is its workflow step means resolving it. + +.. warning:: + + **Neither text channel is inherently private.** The item-level action record + carries 88 columns and none of them is a public/private boolean, so the API + enforces no visibility distinction and there is no flag to set or read + (measured on one instance, 2026-08-28 — it may not generalise). + + Visibility is a property of the action **type**. On the verified instance + type 94 is ``Commentaire [Public]`` / ``Customer Comment`` and type 95 is + ``Note Interne [Privé]`` / ``Internal Note`` (measured on one instance, + 2026-08-28). Those ids are per-deployment: yours will differ. + +**There is no reference table, and the ids are still discoverable.** Both +halves matter, and an earlier revision of this guide asserted the first and +then denied the second a dozen lines later: + +* The instance's own OpenAPI declares **no** ``action-types`` route at all + (read from ``GET {api_root}/swagger``, 2026-08-27, EasyVista 2025.3 — + authoritative for that deployment). ``GET action-types`` answers **403**, but + on this API a forbidden path and an unknown one both answer 403, so that + response never told you which it was. There is nothing to enumerate and + nothing for an administrator to unblock here. +* Every action record nevertheless carries its own ``ACTION_TYPE_ID`` beside + translated ``ACTION_LABEL_*`` columns, so the types an instance actually uses + are recoverable from the data: + +.. code-block:: python + + for found in client.discover("ACTION_TYPE"): + print(found.id, found.label, found.count) + +That is :meth:`~easyvista_python_client.EasyvistaClient.discover`, which samples +records for you; ``client.describe_instance()`` does every reference at once. +Sampling by hand is the same thing spelled out:: + + for action in client.iter_actions(ticket.rfc_number): + print(action.action_type_id, action.label, action.done_by_id) + +Most of what comes back is workflow-generated steps rather than human notes; +those carry an empty ``DONE_BY_ID``. + +.. note:: + + **Two bracket conventions appear in ``ACTION_LABEL_*`` and they mean + opposite things.** + + * A label wrapped **entirely** in brackets, echoing another language's text + (``ACTION_LABEL_EN='[Analyse et résolution]'``), is an *untranslated + placeholder*: on a single-language instance the unpopulated language + columns echo the default-language text in brackets. It carries no + visibility meaning, and + :func:`~easyvista_python_client.localized_label` discards it. + * A bracketed **suffix** on otherwise distinct text, with genuine + translations in the sibling columns — ``ACTION_LABEL_FR='Commentaire + [Public]'`` beside ``ACTION_LABEL_EN='Customer Comment'`` — is a real + marker, written by whoever configured the instance. + + The test is whether the siblings are real translations or brackets, not + whether brackets are present. Conflating the two once deleted a true finding + from this documentation. + + A marker is still a **convention on one deployment**, not an API feature. + Treat it as a strong hint while matching ids to meanings, and confirm the + mapping with whoever administers the instance before relying on it for + anything that must not leak. + +So: discover the ids, confirm them with your EasyVista administrator, pin them +in your own configuration — a module constant, a settings field, whatever your +application already uses — and pass the one you want. For a comment, pass it to +``create_task`` rather than ``create_action``; see :ref:`tasks-vs-actions` for +why. + +.. code-block:: python + + from easyvista_python_client import PostTask + + # Read off THIS instance and confirmed with its administrator. 94/95 are + # what the verified instance uses; they are not portable. + PUBLIC_COMMENT_TYPE_ID = 94 + INTERNAL_NOTE_TYPE_ID = 95 + + client.create_task( + ticket.rfc_number, + PostTask( + action_type_id=INTERNAL_NOTE_TYPE_ID, + group_id=3, # instance-specific + description="Internal note", + ), + ) + Assets ------ .. code-block:: python - from easyvista_python_client import PostAsset, ev_equals_filter + from easyvista_python_client import PostAsset, ev_contains_filter, ev_equals_filter asset = client.create_asset(PostAsset(catalog_id=3153, asset_tag="LAPTOP-001")) one = client.get_asset(str(asset.asset_id)) found = client.search_assets(search=ev_equals_filter("ASSET_TAG", "LAPTOP-001"), max_rows=50) + # On the instance this package was characterized against, a bare '~' is exact + # match, identical to ':' -- substring search needs an explicit wildcard, which + # ev_contains_filter appends for you: ASSET_TAG~"*LAPTOP*". The vendor documents + # '~' as plain Contains, so pass wildcard=None if that is your deployment. + # The value itself must carry no '_' or '[' (metacharacters to '~' itself, at + # every wildcard= setting) and no '*'/'%' while a wildcard is being appended; + # ev_contains_filter raises ValueError rather than widening the match silently. + # So "LAPTOP_01" raises; use ev_equals_filter for an exact match on a tag + # containing '_'. See "Searching and pagination" below. + laptops = client.search_assets(search=ev_contains_filter("ASSET_TAG", "LAPTOP"), max_rows=50) + Documents --------- @@ -237,6 +765,67 @@ Attach a file to a ticket (uploaded as base64 inside the JSON body) and list a t another host is refused rather than followed. Multipart upload is still not implemented; uploads go as base64 inside the JSON body. +Streaming a large attachment +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:meth:`~easyvista_python_client.EasyvistaClient.stream_document` yields the same bytes in +chunks instead of returning them in one object, so a large attachment never has to exist +in memory whole. It accepts exactly what ``download_document`` accepts and resolves the +URL the same way, including the same refusal of a URL outside the configured instance. + +.. code-block:: python + + from pathlib import Path + + with Path("downloaded.pdf").open("wb") as sink: + for chunk in client.stream_document(attachments[0], chunk_size=1024 * 1024): + sink.write(chunk) + +The name is ``stream_`` rather than ``iter_`` because every ``iter_*`` method on the +client iterates *records*; this one iterates the bytes of a single document. + +The async client streams with ``async for`` — like the ``iter_*`` methods and unlike +every other method on it, ``stream_document`` is not awaited. ``aclient`` below is an +:class:`~easyvista_python_client.AsyncEasyvistaClient`; the two surfaces are not +interchangeable behind one name, because the synchronous ``stream_document`` returns a +plain iterator: + +.. code-block:: python + + async def save(aclient, document): + with Path("downloaded.pdf").open("wb") as sink: + async for chunk in aclient.stream_document(document): + sink.write(chunk) + +.. note:: + + **Stopping early on the async surface needs an explicit close.** If you + ``break`` out of the ``async for`` — sniffing a magic number, hashing the + first block, aborting on a size check — the response stays checked out of the + connection pool until the event loop's async-generator finalizer runs, which + is a garbage-collection cycle away (measured). Use + ``contextlib.aclosing(client.stream_document(doc))``, or call ``aclose()`` + yourself, so the connection is released at the ``break``. The synchronous + surface releases it immediately by refcounting and needs nothing. + +.. note:: + + **Only the download streams.** There is no streaming upload, and it is not an + oversight: EasyVista takes an attachment as base64 inside a JSON body, so + ``add_document`` has to materialise the whole payload before it can send anything. + The asymmetry belongs to the API, not to this client. + +.. warning:: + + **A mid-stream failure is not retried.** Opening the download is retried under the + usual policy, but from the first chunk onwards the request is committed: a transport + failure raises :class:`~easyvista_python_client.EasyvistaConnectionError` rather than + starting over, because starting over would hand you bytes you already have. Nothing + resumes a partly consumed stream, so if you must survive a mid-stream failure, decide + for yourself whether to discard what you collected and stream the document again. + ``download_document`` retries the whole fetch and is the simpler choice when the file + is small enough to buffer. + Exporting a ticket to Markdown ------------------------------ @@ -255,6 +844,23 @@ bundle as Markdown containing only content and human labels (no API URLs). context.actions # list[Action] context.documents # list[Document] (rendered as filenames) +By default the bundle resolves the two Memo fields EasyVista populates out of the box, +``description`` and ``comment``. Which memo actually carries a ticket's body is per-deployment +configuration, so ``memo_fields`` lets you name the ones your instance uses; every resolved memo +lands in ``context.memos``, keyed by the name you asked for, and a memo requested this way is +rendered by ``to_markdown`` like any other body text. + +.. code-block:: python + + context = client.get_ticket_context(ticket.rfc_number, memo_fields=("description", "solution")) + context.memos["solution"] # the resolved text, or None if the instance has no such memo + +.. warning:: + + Pass a tuple or list, never a bare string. ``str`` satisfies ``Sequence[str]``, so + ``memo_fields="solution"`` type-checks and then iterates its letters, issuing one nonsense + request per character instead of the one you meant. + .. note:: By default ``get_ticket_context`` also resolves each action's note text @@ -271,14 +877,18 @@ concurrently — see :ref:`sync-vs-async`. **Which heading a ticket's body gets.** ``to_markdown`` titles a block by the role it plays, not by the EasyVista field it came from. A ticket's body does - not always arrive in ``DESCRIPTION``: on many deployments that memo is unused - and ``COMMENT`` carries the text, and ``RequestUpdate.description`` writes - ``COMMENT`` on any instance. So when only one of the two memos has text, it is + not always arrive in ``DESCRIPTION``: which memo carries it is per-deployment + configuration and is not reliably detectable at runtime, and + ``RequestUpdate.description`` writes ``COMMENT`` on any instance. So when only + one of the two memos has text, it is the body and is rendered under ``## Description`` whichever field it came from; when both have text the distinction is real and each keeps its own - ``## Description`` / ``## Comment`` heading. The ``context.description`` and - ``context.comment`` attributes are unaffected and still name their source - memo. + ``## Description`` / ``## Comment`` heading. The same rule covers a memo asked + for through ``memo_fields``: when neither default memo has text, a single + populated entry in ``context.memos`` becomes the body under ``## Description``, + and several each get a heading derived from the name you requested + (``## Solution``). The ``context.description`` and ``context.comment`` + attributes are unaffected and still name their source memo. Searching and pagination ------------------------- @@ -288,9 +898,41 @@ Searching and pagination The verified search grammar is: -- ``FIELD:"value"`` — exact match. ``~`` is a synonym: despite its appearance it is **exact match**, - not "contains" — identical to ``:``. No substring operator has been identified; ``%`` inside a - value is a literal character, not a wildcard. +- ``FIELD:"value"`` — exact match. +- ``~`` — the vendor documents it as plain **Contains** (Oxygen 1.7+), one word, no example, no + wildcard named. **On the instance this package was characterized against it is a pattern + operator instead** (measured live 2026-08-17; one deployment, may not generalise): it acts as + one only with an *explicit* wildcard in the value, and ``*`` and ``%`` both expand there + (``~"I26081*"`` matched 32 rows, ``~"*260817*"`` matched 33, ``~"*0001"`` matched 432, and + ``~"%"`` reproduced the same count as the ``*`` equivalent, so ``%`` is a wildcard too). + Given a **bare** value with no wildcard, ``~`` degenerates to exact match — identical to ``:`` — + which is why this package once documented it as exact-match-only; that conclusion held only for + the wildcard-free inputs it was tested with. ``:`` never expands a wildcard even when one is + present in the value: ``:"I26081*"`` matched **0** rows on the same data. Build the pattern with + :func:`~easyvista_python_client.ev_contains_filter` (``FIELD~"*value*"``) or + :func:`~easyvista_python_client.ev_starts_with_filter` (``FIELD~"value*"``) rather than by hand. + Both append ``*`` by default; on a deployment that follows the vendor's reading and compares + ``*`` literally, that default returns **zero rows with HTTP 200 and no hint**, so pass + ``wildcard=None`` there (or ``wildcard="%"`` for a LIKE-style backend). The two settings fail in + opposite directions and neither failure is visible in the response — confirm which reading your + deployment follows once, by comparing a filtered count against the unfiltered baseline. On + ``ev_starts_with_filter``, ``wildcard=None`` removes the *anchor* rather than swapping a token: + it is a substring match on a vendor-conformant deployment, not a prefix. +- ``*`` and ``%`` are **not** the only metacharacters under ``~``, and the other two belong to the + **operator** rather than to the wildcard the builders append. ``_`` matches any **single** + character and ``[`` opens a character class — measured live 2026-08-18 with a *wildcard-free* + pattern: replacing one character of an RFC that matched 1 row with ``_``, or with ``[0-9]``, + matched 9, while ``[x]`` still matched 1. There is **no escape**: ``\_`` + matched 0 rows, i.e. the backslash is compared literally. Both builders above therefore raise + ``ValueError`` for ``_`` or ``[`` in the value at **every** ``wildcard=`` setting, ``None`` + included, and additionally for ``*`` or ``%`` while a wildcard is being appended (a second one + would compose with it); with ``wildcard=None`` those two pass through, which is how to + hand-build a pattern. This bites on ordinary input: ``_`` is pervasive in EasyVista codes, and + ``ev_contains_filter("ASSET_TAG", "LAPTOP_01")`` raises for that reason — unhandled, it would + also have matched ``LAPTOP-01`` and ``LAPTOP001`` with HTTP 200 and no hint. For an **exact** + match on such a value use :func:`~easyvista_python_client.ev_equals_filter`, since ``:`` does not + expand a wildcard; to pattern-match *around* one, filter server-side on a wider condition and + compare exactly in Python. - ``,`` — combines conditions: **OR** when every condition names the same field, **AND** across different fields. ``;`` is *not* a combinator. @@ -305,6 +947,20 @@ The verified search grammar is: ``ev_equals_filter("STATUS_ID", "Open")`` sends a status *name* to an integer column and fails loudly. That is the friendlier failure; the silent ones above are the dangerous ones. + There is **no comparison operator** (``>=``, ``BETWEEN``, ``[a TO b]``…), and writing one has + *two* different fates depending on its exact shape, not one: + + - drop the ``FIELD:`` colon entirely (e.g. ``LAST_UPDATE>="2026-01-01"``) and the expression is + structurally unparseable, so it takes the **silent-drop** path above — the whole table comes + back; + - keep ``FIELD:"value"`` syntax but embed the operator *inside* the quoted value + (``LAST_UPDATE:">=2026-01-01"`` or ``LAST_UPDATE:"[2026-01-01 TO *]"``) and the quoted text must + still parse as the column's type — a date, here — so it instead trips the **type-mismatch** + fate and raises ``EasyvistaValidationError`` (HTTP 590). + + Either way, no comparison operator ever narrows the result — see :ref:`change-window-filtering` + for the interval grammar that does. + Build filters with the helpers, not f-strings: .. code-block:: python @@ -368,16 +1024,196 @@ follow the API's offset pagination until ``@next`` is exhausted or ``max_records for asset in client.iter_assets(search=tag_filter, page_size=100): print(asset.asset_tag) -The async client paginates with ``async for``: +The async client paginates with ``async for``. ``aclient`` is an +:class:`~easyvista_python_client.AsyncEasyvistaClient`: the synchronous ``iter_tickets`` +returns a plain iterator, so the two cannot share one name. .. code-block:: python from easyvista_python_client import ev_equals_filter - async for ticket in client.iter_tickets( - search=ev_equals_filter("STATUS_ID", 3), page_size=100 - ): - print(ticket.rfc_number) + async def sweep(aclient): + async for ticket in aclient.iter_tickets( + search=ev_equals_filter("STATUS_ID", 3), page_size=100 + ): + print(ticket.rfc_number) + +.. _change-window-filtering: + +Filtering by a change window +----------------------------- + +EasyVista has **no** comparison operator. ``LAST_UPDATE >= x`` in any spelling is +either structurally unparseable (silently dropped, every record comes back) or, +if it keeps ``FIELD:"value"`` syntax while embedding the operator inside the +quoted value, a type mismatch that raises HTTP 590 — see the warning above. A +range is instead an interval in the *value position*: + +.. code-block:: python + + from easyvista_python_client import ev_since_filter + + search = ev_since_filter("LAST_UPDATE", watermark) # LAST_UPDATE:(...;) + if search is not None: + seen = set() + for ticket in client.iter_tickets(search=search, sort="LAST_UPDATE DESC"): + if ticket.rfc_number in seen: + continue + seen.add(ticket.rfc_number) + ... + +``watermark`` may be a :class:`datetime.datetime` (preferred) or a timestamp +string. Pass a ``datetime`` and the bound cannot be malformed; ``Request`` +timestamps are already aware datetimes (see :ref:`timestamps`), so a value read +from one ticket can be fed straight back in. A string naming a time is +re-rendered into the one form the wire honours (millisecond precision with an +offset), so a stored watermark string and the ``datetime`` it came from produce +byte-identical bounds. + +The bound is **inclusive**, and milliseconds are honoured (verified live on +three independent boundaries). A watermark set to ``max(t.last_update)`` +therefore re-reads that boundary record on the next sweep — hence the +de-duplication above. + +.. warning:: + + **Sort the sweep descending, and de-duplicate.** ``iter_tickets`` walks the + result set by *offset*, and the rows a change window selects are by + construction the rows that are changing, so a ticket touched between page N + and page N+1 moves *within the very set being paged*. An unsorted sweep can + drop such a row with no way to tell — and so can either sort direction. What + differs is where the dropped row's own timestamp lands relative to the + watermark this sweep records: + + - **Descending** (``LAST_UPDATE DESC``): the re-touched row jumps to the head, + behind the read cursor, so this sweep misses it — but its ``LAST_UPDATE`` is + now *above* the watermark, so the next sweep selects it again. The miss is + **deferred and self-healing**. + - **Ascending** (bare ``LAST_UPDATE``, or ``LAST_UPDATE ASC``): the re-touched + row moves to the tail and everything behind it shifts one place head-ward, + so the row that crosses the cursor is one whose own stamp did **not** + change. It falls *below* the new watermark and no later sweep selects it. + The miss is **permanent**. + + Both tokens are honoured (measured live); descending is chosen for the reason + above, not for availability. De-duplicate by ``rfc_number``: the duplicates + are the deferred rows arriving on a later sweep, plus the inclusive-boundary + re-read described above. + + **A sweep that does not run to completion is a separate trap.** Descending + yields the newest row first, so the watermark reaches its *final* value on + page 1. A sweep that is interrupted, or capped with ``max_records`` (as the + pagination examples above do), still ends up holding the newest stamp — + advance the watermark from that and the next window's ``(newest;)`` bound + permanently excludes every row the incomplete sweep never read. Only advance + the watermark after a sweep runs to completion. + + Descending is the safe direction, not a guarantee. If even a deferred miss is + unacceptable, page + :meth:`~easyvista_python_client.EasyvistaClient.search_tickets` yourself with + **keyset** pagination: sort ascending and, after each page, advance the + *window* — ``ev_since_filter("LAST_UPDATE", max(stamps on the page))`` read + again at ``offset=0`` — instead of incrementing an offset. With no offset there + is no cursor for a row to shift past. ``iter_tickets`` cannot express this, + because it owns its own offset. + + An earlier release of this guide recommended sorting *ascending* here, on the + reasoning that it turns a permanent miss into a duplicate. That was wrong: the + row an ascending sweep drops is not the re-touched one. + + The sort token must stay space-separated. ``LAST_UPDATE:DESC``, + ``-LAST_UPDATE`` and ``DESC(LAST_UPDATE)`` are each **silently ignored** + (measured live) and degrade to the server's default order with no error, so a + sweep written with one of those forms is an unsorted sweep that looks sorted. + +.. warning:: + + **A bound that names a time must carry its UTC offset.** EasyVista accepts an + offset-less literal and reads it in a different zone: measured live, the same + wall-clock text with and without its offset returned 13 rows and 11 rows + against one instance — the offset-less form moves the bound *later* and skips + records, with no error of any kind. Both builders therefore refuse a naive + time, whether it arrives as a ``datetime`` or as a string. A bare date + (``"2026-01-31"``) stays accepted: day granularity has no time to misplace. + +Use :func:`~easyvista_python_client.ev_between_filter` for a closed interval. +Both refuse a bound that is not a timestamp: the bound is interpolated +*unquoted*, so a ``;`` or ``)`` inside it would silently change the query. + +.. code-block:: python + + from datetime import datetime, timezone + from easyvista_python_client import ev_between_filter + + window = ev_between_filter( + "LAST_UPDATE", + datetime(2026, 1, 1, tzinfo=timezone.utc), + datetime(2026, 2, 1, tzinfo=timezone.utc), + ) + recent = client.search_tickets(search=window, max_rows=100) + +.. _timestamps: + +Timestamps +~~~~~~~~~~ + +``Request``'s timestamp fields (``submit_date_ut``, ``creation_date_ut``, +``max_resolution_date_ut``, ``expected_date_ut``, ``end_date_ut``, +``last_update``), ``Employee.last_update``, and ``Action.created_at`` / +``Action.updated_at`` are timezone-aware +:class:`datetime.datetime`, parsed from EasyVista's ISO-8601-with-offset wire +format (``2026-08-17T15:40:41.610+02:00``, millisecond precision — verified +live 2026-08-17). An unset date is ``None``. The ``_UT`` suffix is a naming +convention, **not** a promise of UTC normalization: these columns carry the +same local offset as ``LAST_UPDATE``. ``Action`` is easy to miss here: it names +the identical ``CREATION_DATE_UT`` / ``LAST_UPDATE`` wire columns +``created_at`` / ``updated_at``, so an action export is retyped exactly like a +ticket export and hits the JSON note below the same way. + +Only the *read* path is parsed. The accepted *write* format is still +unverified, so no write model accepts a ``datetime`` — set a date-typed field +with a raw request if you need to. That includes ``custom_fields``: a +``datetime`` placed there is not serialisable and fails inside the HTTP layer +with a bare ``TypeError``, so render it yourself first. + +Only the *declared* columns are parsed. An instance-specific date column reached +through ``classify_fields().custom`` or plain ``extra="allow"`` attribute access +is still the raw wire string, so within one record dump +``official["CREATION_DATE_UT"]`` is a ``datetime`` while +``official["EXPECTED_START_DATE_UT"]`` is a ``str`` — comparing the two raises +``TypeError``. Pass the undeclared one through +:func:`~easyvista_python_client.parse_ev_datetime` before comparing them. + +.. note:: + + **A record dump is no longer directly JSON-serialisable.** This is true of + *any* record carrying one of the columns above — a ``Request``, an + ``Employee`` or an ``Action`` alike. ``model_dump()`` and + ``classify_fields()`` yield ``datetime`` objects, so both + ``json.dumps(record.model_dump(by_alias=True))`` and + ``json.dumps(record.classify_fields().official)`` raise + ``TypeError: Object of type datetime is not JSON serializable``. For a dump, + pass ``model_dump(mode="json")``. ``classify_fields()`` takes **no arguments**, + so there is nowhere to put that keyword: render the ``datetime`` values with + :func:`~easyvista_python_client.format_ev_datetime` before serialising the + bucket, or classify the JSON-mode dump yourself — the buckets are keyed by + wire column name, so ``{k: dumped[k] for k in record.classify_fields().official}`` + over ``dumped = record.model_dump(mode="json", by_alias=True)`` gives the same + split with serialisable values. + +Use :func:`~easyvista_python_client.format_ev_datetime` to render a +``datetime`` back into the literal EasyVista's grammar accepts (e.g. as an +interval bound above), and :func:`~easyvista_python_client.parse_ev_datetime` +to parse a raw string yourself. + +.. code-block:: python + + from easyvista_python_client import format_ev_datetime, parse_ev_datetime + + ticket = client.get_ticket(ticket.rfc_number) + watermark = ticket.last_update # already an aware datetime + literal = format_ev_datetime(watermark) # "2026-08-17T15:40:41.610+02:00" + assert parse_ev_datetime(literal) == watermark Counting and statistics ----------------------- @@ -421,9 +1257,10 @@ ones give human labels (``STATUS``, ``DEPARTMENT``, ``CATALOG_REQUEST``), and id Classifying and resolving fields ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -:meth:`~easyvista_python_client.EasyvistaModel.classify_fields` partitions a record's fields into -four buckets: ``official``, ``custom`` (``e_*`` fields not declared by the model), ``available`` -(``AVAILABLE_FIELD_n`` slots), and ``links`` (href-only sub-resource references). Use +``classify_fields()`` — available on every read model — partitions a record's fields into the +four buckets of a :class:`~easyvista_python_client.field_model.FieldClassification`: ``official``, +``custom`` (``e_*`` fields not declared by the model), ``available`` (``AVAILABLE_FIELD_n`` +slots), and ``links`` (href-only sub-resource references). Use :meth:`~easyvista_python_client.EasyvistaClient.resolve_memo` to fetch a link field's text. .. code-block:: python @@ -495,18 +1332,36 @@ plain-text 503, or any other shape this client does not recognize. EasyvistaError, EasyvistaNotFound, EasyvistaValidationError, + PostRequest, ) try: - client.create_ticket(PostRequest(catalog_code="INC_STANDARD")) # missing mandatory title + # Under-specified: a subject but no origin/department_id/urgency_id/impact_id. + # `title` is not what makes a create complete -- the fuller body above was + # accepted with no title. + client.create_ticket(PostRequest(catalog_code="INC_STANDARD")) except EasyvistaValidationError as exc: - # HTTP 590, EasyVista error_code 2013 — a rejected create. Deterministic, not retried. + # HTTP 590, EasyVista error_code 2013 — a rejected create. This client does + # not retry it; read the warning below before you retry it yourself. print("Validation failed:", exc.ev_message) except EasyvistaNotFound: print("No such record") except EasyvistaError as exc: print("EasyVista error:", exc.status_code, exc) +.. warning:: + + **A rejected create may still have created the ticket.** Measured on one instance + (2026-08-25): 12 under-specified attempts returned 3 ``RFC_NUMBER``\ s, and afterwards all 12 + tickets existed — 9 of the 9 failures had written a row, with the ids they were missing left + null. So an :class:`~easyvista_python_client.EasyvistaValidationError` from ``create_ticket`` + means *possibly created*, never *not created*: wrapping this ``except`` in a retry duplicates + tickets, and the caller never learns the id of the one it just made. Set + ``external_reference`` on every create — it survives the failed insert and is searchable — and + reconcile by that marker rather than trusting the error. This is a single-instance measurement, + not a vendor-documented behaviour; treat it as the floor, not the ceiling, of what a 590 can + leave behind. + The hierarchy is: :class:`~easyvista_python_client.EasyvistaAuthError` (401/403), :class:`~easyvista_python_client.EasyvistaNotFound` (404), :class:`~easyvista_python_client.EasyvistaValidationError` (400 / HTTP 590 rejected create), @@ -521,7 +1376,7 @@ Create a ticket, add a comment, close it, and read it back: .. code-block:: python - from easyvista_python_client import EasyvistaClient, PostAction, PostRequest + from easyvista_python_client import EasyvistaClient, PostRequest, PostTask with EasyvistaClient.from_env() as client: ticket = client.create_ticket( @@ -529,15 +1384,23 @@ Create a ticket, add a comment, close it, and read it back: catalog_code="INC_STANDARD", title="VPN drops", description="Daily VPN drops at 11:00", - origin=7, + # The vendor documents `origin` as a STRING naming the channel + # ("Phone", "Email") -- the one create field with a portable, + # human-readable form. An int id is also accepted (measured on + # one instance) and passes through unchanged. + origin="Phone", department_id=9, urgency_id=7, impact_id=21, + external_reference="MYAPP-0002", # your marker; set it always ) ) - client.create_action( + # A COMMENT is a task, not an action: a task is born already ended, so + # its text shows in the ticket history. An action is born open, and an + # open action renders as a pending row with its text NOT shown. + client.create_task( ticket.rfc_number, - PostAction(action_type_id=94, group_id=3, description="Investigating"), + PostTask(action_type_id=94, group_id=3, description="Investigating"), ) client.close_ticket( ticket.rfc_number, diff --git a/docs/vendor-api-reference.md b/docs/vendor-api-reference.md new file mode 100644 index 0000000..c14a509 --- /dev/null +++ b/docs/vendor-api-reference.md @@ -0,0 +1,224 @@ +# EasyVista REST API — vendor reference + +The facts this package depends on, each tagged with the kind of evidence +behind it, so a reader can tell "the vendor says so" from "we saw it once". + +**Baseline: EasyVista 2025.3.** Measured 2026-08-27 against the development +instance: `GET {api_root}/swagger` returns `info.description = "Easyvista +Service Manager REST API - 2025.3"` (OpenAPI 3.1.0, spec version 1.9.4, +100 paths). + +Two traps, both of which cost time to rediscover: + +* The route is `{api_root}/swagger` — that is `/api/v1/{account}/swagger`. + The bare-host `{host}/swagger` returns 403. +* **A GET to it answers HTTP 201**, not 200. Code gating on `== 200` skips + it in silence. + +## Claim tiers + +| Tier | Meaning | Trust | +|------|---------|-------| +| 1 — Vendor-documented | docs.easyvista.com states it | Portable across deployments | +| 2 — Spec path | Declared in the instance's OpenAPI `paths` | Authoritative for this deployment | +| 3 — Spec schema | Declared in the instance's OpenAPI `components.schemas` | **Illustrative only** | +| 4 — Measured | Observed live, one instance, one date | May not generalise | + +Tier 3 is the subtle one. The instance's own `POST /requests` schema declares +`required: []` and lists `E_TEST_REST` / `E_TEST_REST_2` — that deployment's +private custom columns. Those schemas are generated from examples, not from a +normative contract. They look authoritative; they are not. + +## Create a ticket — `POST /requests` (tier 1) + +Source: +(read 2026-08-27). Envelope key `requests`, an array. Field names are +case-insensitive. Success is HTTP 201 with an `HREF` to the created resource. + +**Required: `catalog_guid` OR `catalog_code`.** `catalog_guid` is documented +as the preferred subject identifier. Every other field is optional. + +| Field | Type | Note | +|-------|------|------| +| `catalog_guid` / `catalog_code` | string | Subject; guid preferred | +| `assetid` / `assettag` / `asset_name` | string | Asset, in priority order | +| `ci_id` / `ci_asset_tag` / `ci_name` | string | Configuration item, in priority order | +| `department_id` / `department_code` | string | Requestor department | +| `location_id` / `location_code` | string | Requestor location | +| `description` | string | | +| `title` | string | 2018.1.183.0+ | +| `impact_id` | integer | 2020.2.122.2+ | +| `urgency_id` | integer | | +| `severity_id` | integer | | +| `origin` | string | e.g. Phone, Email | +| `external_reference` | string | | +| `parentrequest` | string | | +| `phone` | string | | +| `recipient_id` / `recipient_identification` / `recipient_mail` / `recipient_name` | string | Priority order | +| `requestor_identification` / `requestor_mail` / `requestor_name` | string | Priority order | +| `submit_date` | string | Respects the employee location's format | +| `e_*` | various | Custom fields, 2018.1.183.0+ | + +**Not in the table above, and not vendor-documented at all: `workflow_start`** +(tier 3, illustrative only). It appears only in the instance's own OpenAPI +schema for this route (`components.schemas`, read 2026-08-27): boolean, +"Optional. If true, starts the workflow for the created incident." Per the +tier table above, that schema is example-derived and not a normative +contract, so treat this field as unverified until tested against the +deployment you use it on. + +## Create an action — `POST /requests/{rfc_number}/actions` (tier 1) + +Source: +(read 2026-08-27). + +Required: `action_type_id`, and one of `group_id` / `group_mail` / +`group_name`. Optional includes `comment`, `description`, `creation_date_ut`, +`contact_*`, `done_by_*`, `expected_start_date_ut`, `expected_end_date_ut`, +`max_intervention_date_ut`, `parent_action_id`, `action_type_guid` (2023.4+). +Action status is set to "In progress" automatically. + +`PostAction` declares `action_type_id`, `action_type_name`, `action_type_guid`, +`group_id`, `group_name`, `group_mail`, `parent_action_id`, `description` and +`comment`, and enforces the required rule above at construction. The `contact_*` +/ `done_by_*` / date fields are deliberately **not** declared — nothing in this +package exercises them and `extra_payload` reaches them today. + +## Create a task — `POST /requests/{rfc_number}/tasks` + +**The vendor page has NOT been transcribed here.** It exists +(, +cited in `PostTask`'s docstring) but nobody has read its field table into this +file, so `PostTask`'s eleven declared fields cannot be diffed against tier 1 +from inside the repository. That is a gap, not a finding. + +What the instance's own OpenAPI declares for this route — **tier 3, +illustrative only** (read 2026-08-31): `action_type_id` (string), `group_mail`, +`Elapsed_Time`, `time_cost`, `contractual_cost`, `description`, +`creation_date_ut`, `start_date_ut`, `end_date_ut`, `available_field_1`, +`available_field_6`, with `required: ["action_type_id", "group_mail"]`. Three +notes on reading that: it is the only body schema in this instance's spec that +declares a non-empty `required`, which is corroboration for `PostTask`'s guard +and not proof of it; it omits `group_id`, `group_name` and `comment`, which +`PostTask` declares and which an example-derived schema would omit anyway; and +it lists `available_field_1`/`_6`, which `PostTask` does not declare and which +`extra_payload` reaches. + +Also worth recording without acting on it: the instance's `POST /assets` schema +(tier 3) titles its array `asset` while its own example uses `assets`, which is +what this package sends and what works. That is an inconsistency inside one +spec; the descriptor is not changed on it. + +## Query grammar (tier 1) + +Source: +(read 2026-08-27). + +| Parameter | Syntax / note | +|-----------|---------------| +| `max_rows` | Integer. Default 100. | +| `offset` | Paging offset. Envelope carries `@previous` / `@next`. | +| `sort` | `field1[+asc\|+desc],field2[+asc\|+desc]` | +| `fields` | Comma-separated projection | +| `search` | Field-based filter | +| `~` / `!~` / `!` | Contains / not-contains / not-equals (Oxygen 1.7+). Counter-evidence, tier 4 — measured live 2026-08-17: `~` behaves as a *pattern* operator and needs an explicit `*`, so `FIELD~"value"` degenerates to an exact match and quietly returns the wrong rows. `ev_contains_filter` supplies the wildcards by default; on a deployment that follows the tier-1 reading and compares `*` literally, that default returns zero rows with HTTP 200 — pass `wildcard=None` (or `wildcard="%"`). Neither failure is visible in the response. See its docstring in `easyvista_python_client/filters.py`. | +| `is_null` / `is_not_null` | Oxygen 2.1.2+ | +| `formatDate` | Oxygen 1.7+ | + +`+` in a query string decodes to a space, so the documented `RFC_NUMBER+desc` +and this package's measured `"RFC_NUMBER DESC"` are the same token. +Dotted sub-field access works in both `sort` and `search` +(`employee.last_name+desc`, `search=employee.e_mail:...`), and relative date +tokens exist (`search=field:last_week`). Neither is exposed by this package. + +Envelope: `HREF`, `record_count`, `total_record_count`, `records`, `@next`. + +## Route topology (tier 2) — `GET {api_root}/swagger`, read 2026-08-27 + +**A 403 does not discriminate.** This API answers 403 for a path that does not +exist as well as for one a profile denies (measured; date not recorded). Every +"blocked" conclusion drawn from a status code alone is therefore unsound; the +spec's `paths` is what settles whether a route exists. + +| Path | Verbs | Note | +| --- | --- | --- | +| `/requests/{rfc_number}/actions` | POST | create-only; no nested list, item or update | +| `/actions` | GET | the only action list | +| `/actions/{id}` | GET, PATCH, PUT | the only action item; **no DELETE** | +| `/requests/{RFC_NUMBER}/documents` | GET, POST | | +| `/requests/{RFC_NUMBER}/documents/{id}` | GET, DELETE | what this package sends by default | +| `/documents/{id}` | GET, DELETE | marked `deprecated`; opt in with `document_delete_path_style="top_level"` | +| `/departments/{id}/{comment}` | GET | `{comment}` is a memo-field *selector*, not a literal | + +Reference tables that exist: `/status`, `/urgency` and `/urgency/{id}` +(**singular**; `/urgencies` is not declared), `/catalog-requests`, +`/catalog-requests-paths`, `/groups` (GET, POST), `/locations`, `/slas`, +`/domains`, `/suppliers`, `/departments`, `/employees`. + +No route is declared for action-types, impact, severity, origin or priority: +those values are discoverable only by sampling records that carry them. + +The package wraps roughly 10 of the spec's 100 paths. + +## Routes present in the spec, not implemented here (tier 2) + +Read from `GET {api_root}/swagger`, 2026-08-27. + +* `PUT|PATCH /requests/{rfc_number}/close` — a dedicated close route exists + (tier 2) taking a **flat** body (tier 3, illustrative only: + `STATUS_GUID`, `END_DATE`, `CATALOG_GUID`, `DELETE_ACTIONS`, `COMMENT`). + **O-CLOSE is CLOSED, in this package's favour.** The vendor documents closing + as `PUT /requests/{rfc_number}` with a `{"closed": {...}}` wrapper — the + route this package already sends — so the subpath is an alternate, not the + canonical one, and there is nothing to switch to. Tier 1: + https://docs.easyvista.com/docs/rest-api-close-an-incident-request.md + The same page supplied two body fields the package had never declared + (`end_date`, `catalog_GUID`), both now exposed on `close_ticket`. +* `PUT|PATCH /requests/{rfc_number}/suspend`, `/restart`. +* `GET /requests/{rfc_number}/{comment}` — the final segment is a **memo-field + selector**, documented in the spec's own parameter description as "Memo + field type, could be comment, description". Same shape on + `GET /actions/{id}/{comment}`. +* `GET /problems`, `GET /configuration-items`, `GET /questionnaires`, + `POST /tokens`, and the external-table routes `GET|POST /{E_Your_Table}`. + These have no typed wrapper; `send()` and `list_reference_table(path)` reach + every read-only one of them. +* The reference tables — `GET /status`, `/urgency`, `/locations`, `/groups`, + `/slas`, `/suppliers`, `/domains`, `/catalog-requests` — are now reachable + through `list_reference_table(path)` and `discover(name)`, which map each + name to the route this deployment declares. + +### `GET /catalog-requests` response columns — **tier 3, illustrative only** + +`CODE`, `SD_CATALOG_ID`, `TITLE_EN`, `CATALOG_REQUEST_PATH`, plus nested +`MANAGER` and nested `SLA`. `CODE` is what `PostRequest.catalog_code` accepts +and `SD_CATALOG_ID` is what reads back as `Request.sd_catalog_id`. **There is +no `CATALOG_GUID` column** in the schema and none was observed live, so a +catalog GUID cannot be discovered from this route — build with `catalog_code`. +The vendor documents `catalog_guid` as the *preferred* identifier (tier 1) and +`close_ticket` accepts one; you simply cannot read one back. + +## Open items + +* **O-URG** — `PUT /requests/{rfc_number}` declares `Urgency_ID` as a + **string** (tier 3). This package sent an **int** when it measured the 590 + that caused `RequestUpdate.urgency_id` to be removed. The exclusion may be a + type mismatch we authored rather than an API limitation. Unresolved: settling + it needs a live write. Same question for `severity_id`. +* **O-CLOSE** — should the close route move to `PUT /requests/{rfc}/close`? +* **O-URGPATH** — the vendor documents `GET /urgencies`; the instance spec + declares `GET /urgency`. Both return 200 live. Which is canonical is unknown. +* **O-CLOSE-DEFAULT** — `close_ticket` omits `status_GUID` from the body when + the caller omits it, and two docstrings previously stated that this closes + the ticket to the instance's default *Closed* meta-status, attributing it to + the vendor close page. **That sentence is not recorded anywhere in this file + and the behaviour is not exercised by the live suite** — every `close_ticket` + call in `integration_tests/` passes an explicit `status_guid`. Both + docstrings now hedge. Until someone either re-reads the vendor page and adds + the row here, or measures the omitted form live and dates it, the + documentation must not assert it. +* **O-TASKDOC** — transcribe the vendor's create-a-task field table into the + section above, so `PostTask` can be diffed against tier 1. Until then + `action_type_guid` is declared on `PostAction` (tier 1, 2023.4+) and **not** + on `PostTask`, and `PostTask`'s guard accepts the key without the model + asserting the field exists on that route. diff --git a/easyvista_python_client/__init__.py b/easyvista_python_client/__init__.py index eb98465..a573444 100644 --- a/easyvista_python_client/__init__.py +++ b/easyvista_python_client/__init__.py @@ -3,9 +3,16 @@ from easyvista_python_client._async import AsyncEasyvistaClient from easyvista_python_client._sync import EasyvistaClient -from .config import EasyvistaConfig -from .context import TicketContext +from ._version import __version__ +from .config import DEFAULT_USER_AGENT, EasyvistaConfig +from .context import DEFAULT_MARKDOWN_FIELDS, TicketContext from .directory import DepartmentContext +from .discovery import ( + DEFAULT_DISCOVERY_NAMES, + DiscoveredReference, + InstanceProfile, + ReferenceSource, +) from .exceptions import ( EasyvistaAuthError, EasyvistaConnectionError, @@ -18,29 +25,39 @@ from .field_model import FieldClassification from .filters import ( escape_ev_value, + ev_between_filter, + ev_contains_filter, ev_equals_filter, ev_in_filter, + ev_since_filter, + ev_starts_with_filter, is_safe_ev_value, ) -from .models.action import Action, PostAction +from .models.action import Action, ActionUpdate, PostAction, PostTask from .models.asset import Asset, PostAsset from .models.department import Department, DepartmentUpdate, PostDepartment from .models.document import Document from .models.employee import Employee, EmployeeUpdate, PostEmployee +from .models.generic import GenericRecord from .models.request import PostRequest, Request, RequestUpdate from .pagination import SearchResult -from .references import Reference +from .references import DEFAULT_LANGUAGE_ORDER, Reference, localized_label from .reporting import TicketStatistics, aggregate_tickets - -__version__ = "0.1.0" +from .timestamps import format_ev_datetime, parse_ev_datetime __all__ = [ + "DEFAULT_DISCOVERY_NAMES", + "DEFAULT_LANGUAGE_ORDER", + "DEFAULT_MARKDOWN_FIELDS", + "DEFAULT_USER_AGENT", "Action", + "ActionUpdate", "Asset", "AsyncEasyvistaClient", "Department", "DepartmentContext", "DepartmentUpdate", + "DiscoveredReference", "Document", "EasyvistaAuthError", "EasyvistaClient", @@ -54,12 +71,16 @@ "Employee", "EmployeeUpdate", "FieldClassification", + "GenericRecord", + "InstanceProfile", "PostAction", "PostAsset", "PostDepartment", "PostEmployee", "PostRequest", + "PostTask", "Reference", + "ReferenceSource", "Request", "RequestUpdate", "SearchResult", @@ -68,7 +89,14 @@ "__version__", "aggregate_tickets", "escape_ev_value", + "ev_between_filter", + "ev_contains_filter", "ev_equals_filter", "ev_in_filter", + "ev_since_filter", + "ev_starts_with_filter", + "format_ev_datetime", "is_safe_ev_value", + "localized_label", + "parse_ev_datetime", ] diff --git a/easyvista_python_client/_async/_transport.py b/easyvista_python_client/_async/_transport.py index 407708c..6dd7bbc 100644 --- a/easyvista_python_client/_async/_transport.py +++ b/easyvista_python_client/_async/_transport.py @@ -16,6 +16,7 @@ from __future__ import annotations import json +from collections.abc import AsyncGenerator, AsyncIterator, Mapping from typing import Any, NoReturn from urllib.parse import urlsplit @@ -28,7 +29,7 @@ ) from easyvista_python_client._transport import RequestSpec -from easyvista_python_client.config import EasyvistaConfig +from easyvista_python_client.config import DEFAULT_USER_AGENT, EasyvistaConfig from easyvista_python_client.exceptions import ( EasyvistaAuthError, EasyvistaConnectionError, @@ -39,6 +40,17 @@ EasyvistaValidationError, ) +#: Default chunk size, in bytes, for :meth:`Transport.stream_bytes`. +#: +#: 64 KiB is the ceiling this default is chosen to set: a caller streams an +#: attachment precisely so the whole file never sits in memory, and the chunk +#: size is what one step of that costs. Large enough that a 32 MB attachment is +#: ~512 iterations rather than tens of thousands, small enough that the resident +#: peak stays negligible beside the file. Deliberately not a config field -- +#: nobody has asked for an instance-wide value, and the one caller who cares +#: about a specific payload can pass ``chunk_size`` per call. +DEFAULT_STREAM_CHUNK_SIZE = 64 * 1024 + class BaseTransport: """Pure transport logic, independent of how a request is executed (no I/O).""" @@ -53,14 +65,28 @@ def resolve_url(self, path_or_url: str) -> str: """Return an absolute URL for a resource path or an API-supplied URL. Relative paths join to ``api_root`` exactly as :meth:`build_url` does. - An absolute URL is passed through **only when its scheme and host match - ``config.server``**, and raises otherwise. + An absolute URL is passed through when its scheme and host match + ``config.server``, or -- opt-in only -- when it is ``https`` and its host + is listed in ``config.additional_download_hosts``. Anything else raises. That check is load-bearing, not decoration. Every request this transport makes carries the instance's Bearer token, so following an absolute URL taken out of a response body (an attachment's ``DDL_HREF``, say) would - hand that credential to whatever host the body named. The API is trusted - to describe its own instance, not to redirect us off it. + hand that credential to whatever host the body named. The allow-list does + not reopen that: a URL admitted by it is fetched through a SEPARATE + client carrying no credential and no ``config.extra_headers`` -- see + :meth:`is_offsite` and :meth:`download_headers`. Nothing but a download + ever consults the allow-list; :meth:`Transport.send` refuses an absolute + URL outright, so the JSON API is unaffected by it. + + What is guaranteed is exactly that and no more: a foreign URL in a + response **body** is refused. An HTTP **redirect** off the instance is + still *followed* -- both download paths run with + ``follow_redirects=True``, which signed-location hops depend on -- and it + merely loses the credential (verified: no ``authorization`` header on the + foreign request, for Bearer and for Basic; a same-host redirect keeps + it). So streamed or downloaded bytes are not proof of instance origin, + and a caller must not treat them as such. """ parsed = urlsplit(path_or_url) if not parsed.scheme and not parsed.netloc: @@ -71,23 +97,106 @@ def resolve_url(self, path_or_url: str) -> str: # scheme on DDL_HREF). Compare the RAW netloc, not .hostname: that # keeps "https://attacker.test@ev.test/x" rejected, because its netloc # is "attacker.test@ev.test", not "ev.test". - if (parsed.scheme, parsed.netloc.lower()) != ( + if (parsed.scheme, parsed.netloc.lower()) == ( server.scheme, server.netloc.lower(), ): - raise EasyvistaError( - f"refusing to fetch {parsed.scheme}://{parsed.netloc} — it is " - f"outside the configured instance " - f"({server.scheme}://{server.netloc})" - ) - return path_or_url + return path_or_url + # https only, so opting a host in can never downgrade a fetch to + # cleartext. The raw-netloc comparison carries over unchanged, so a + # userinfo prefix still fails to match an allow-listed host. + if ( + parsed.scheme == "https" + and parsed.netloc.lower() in self.config.additional_download_hosts + ): + return path_or_url + raise EasyvistaError( + f"refusing to fetch {parsed.scheme}://{parsed.netloc} — it is " + f"outside the configured instance " + f"({server.scheme}://{server.netloc}); add its host to " + f"config.additional_download_hosts to allow an UNAUTHENTICATED " + f"download from it" + ) + + def is_offsite(self, url: str) -> bool: + """True when ``url`` is on an allow-listed host rather than the instance. + + Splits URLs :meth:`resolve_url` has already admitted into the two that + need different clients; it is not a second admission check. The + instance's own origin answers ``False`` even when it is also listed, so a + redundant entry cannot strip the credential from an instance request. + """ + parsed = urlsplit(url) + server = urlsplit(self.config.server) + if (parsed.scheme, parsed.netloc.lower()) == ( + server.scheme, + server.netloc.lower(), + ): + return False + return parsed.netloc.lower() in self.config.additional_download_hosts def headers(self) -> dict[str, str]: - base = {"Accept": "application/json", "Content-Type": "application/json"} + """Every header a request to the configured instance carries. + + Layered lowest to highest: the JSON defaults, the User-Agent + (``config.user_agent``, or :data:`DEFAULT_USER_AGENT` when that is + unset), the credential, and finally ``config.extra_headers`` -- which + therefore overrides all three. It cannot override the credential: + :class:`EasyvistaConfig` refuses an ``Authorization`` key at + construction, in any casing, rather than letting one silently shadow + ``config.token``. + + ``extra_headers`` winning is the opposite of how ``default_params`` + loses to a per-call parameter, and the asymmetry is deliberate. The + client sets ``max_rows`` and ``offset`` itself, so a query parameter the + caller could override would break a paging sweep; no header set here is + load-bearing once the credential is out of reach. + + ``Content-Type: application/json`` is set client-wide, so it rides even + on a GET or a DELETE with no body. That is redundant rather than wrong -- + httpx sets it per request from ``json=`` anyway -- and it is kept because + it is what the verified instance has always been sent. A request needing + another content type overrides it through ``RequestSpec.headers``. + """ + base = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": self.config.user_agent or DEFAULT_USER_AGENT, + } if self.config.token: base["Authorization"] = f"Bearer {self.config.token}" + base.update(self.config.extra_headers) return base + def download_headers(self) -> dict[str, str]: + """Headers for a fetch from a host that is NOT the configured instance. + + Identification and nothing else. No credential, because the whole point + of allowing a foreign download host is that reaching it must not hand + that host this instance's token; and no ``config.extra_headers``, which + is where a second secret (an API key, a proxy credential) would sit. No + ``Accept`` either: what comes back is bytes, not JSON. + """ + return {"User-Agent": self.config.user_agent or DEFAULT_USER_AGENT} + + def merge_params( + self, call: Mapping[str, Any] | None, spec: Mapping[str, Any] | None + ) -> dict[str, Any] | None: + """Layer the three query-parameter sources, most specific last. + + ``config.default_params`` first, then what the caller passed to the + method, then what the resource builder put on the spec. The builder wins + on purpose: it is what sets ``search``, ``max_rows`` and ``offset``, so a + caller cannot replace the ticket filter on ``list_actions`` or the offset + an ``iter_*`` sweep is stepping. + + Returns ``None`` rather than an empty dict when every layer is empty, so + a request that took no parameters before still takes none. + """ + if not (self.config.default_params or call or spec): + return None + return {**self.config.default_params, **(call or {}), **(spec or {})} + def auth(self) -> httpx.Auth | None: if self.config.uses_basic_auth: return httpx.BasicAuth(self.config.login or "", self.config.password or "") @@ -197,6 +306,27 @@ def __init__(self, config: EasyvistaConfig) -> None: timeout=config.timeout, verify=config.verify_ssl, ) + # A second client, for the hosts config.additional_download_hosts opts + # in to. It exists to carry NO credential: the token is attached to + # self._client at CLIENT level, both as a header and (for Basic) as an + # httpx.Auth, and neither can be removed per request -- a per-request + # header can only replace Authorization, never delete it, and a + # per-request auth=None means "use the client default". Built here + # rather than on first use because two concurrent downloads would + # otherwise both construct one and leak the loser. + self._download_client: httpx.AsyncClient | None = None + if config.additional_download_hosts: + self._download_client = httpx.AsyncClient( + headers=self.download_headers(), + timeout=config.timeout, + verify=config.verify_ssl, + ) + + def _client_for(self, url: str) -> httpx.AsyncClient: + """The instance client, or the credential-free one for a foreign host.""" + if self._download_client is not None and self.is_offsite(url): + return self._download_client + return self._client async def __aenter__(self) -> Transport: return self @@ -205,17 +335,34 @@ async def __aexit__(self, *exc_info: object) -> None: await self.aclose() async def aclose(self) -> None: - await self._client.aclose() - - async def _do_send(self, spec: RequestSpec) -> Any: + try: + await self._client.aclose() + finally: + if self._download_client is not None: + await self._download_client.aclose() + + async def _do_send( + self, spec: RequestSpec, params: Mapping[str, Any] | None + ) -> Any: response = await self._client.request( - spec.method, self.build_url(spec.path), params=spec.params, json=spec.json + spec.method, + self.build_url(spec.path), + params=self.merge_params(params, spec.params), + json=spec.json, + headers=dict(spec.headers) if spec.headers else None, ) if self.is_retryable_status(response.status_code): raise _RetryableResponse(response) return self.finish(response) - async def send(self, spec: RequestSpec) -> Any: + async def send( + self, spec: RequestSpec, *, params: Mapping[str, Any] | None = None + ) -> Any: + """Execute ``spec``, with ``params`` layered under the spec's own. + + ``config.default_params`` sits under both -- see :meth:`merge_params` + for the full ordering. + """ retryer = AsyncRetrying( stop=stop_after_attempt(self.config.max_retries + 1), wait=wait_exponential(multiplier=0.5, max=10), @@ -223,16 +370,15 @@ async def send(self, spec: RequestSpec) -> Any: reraise=True, ) try: - return await retryer(self._do_send, spec) + return await retryer(self._do_send, spec, params) except _RetryableResponse as exc: return self.finish(exc.response) except httpx.TransportError as exc: raise EasyvistaConnectionError(f"connection failed: {exc}") from exc async def _do_get_bytes(self, path_or_url: str) -> bytes: - response = await self._client.get( - self.resolve_url(path_or_url), follow_redirects=True - ) + url = self.resolve_url(path_or_url) + response = await self._client_for(url).get(url, follow_redirects=True) if self.is_retryable_status(response.status_code): raise _RetryableResponse(response) if not response.is_success: @@ -245,7 +391,11 @@ async def get_bytes(self, path_or_url: str) -> bytes: :meth:`BaseTransport.finish` always calls ``response.json()``, so binary responses need their own path. This one reuses the same retry policy and the same error mapping, so a 403 on an attachment still surfaces as - :class:`EasyvistaAuthError`. ``follow_redirects`` is on because a + :class:`EasyvistaAuthError`. ``config.default_params`` is deliberately + NOT applied: appending a query parameter to a signed download location is + a plausible way to invalidate it, and a date-format parameter is + meaningless on a fetch that returns bytes. ``follow_redirects`` is on + because a download URL commonly redirects to a signed location; httpx strips the ``Authorization`` header on a cross-origin redirect, so a foreign redirect degrades to an unauthenticated fetch rather than leaking the @@ -264,3 +414,117 @@ async def get_bytes(self, path_or_url: str) -> bytes: self._raise_for_response(exc.response) except httpx.TransportError as exc: raise EasyvistaConnectionError(f"connection failed: {exc}") from exc + + async def _open_stream( + self, path_or_url: str, chunk_size: int + ) -> tuple[httpx.Response, AsyncIterator[bytes], list[bytes]]: + """Open a streaming GET and take its first chunk, as one retryable unit. + + Returns the still-open response, its chunk iterator, and the first chunk + wrapped in a list -- empty for an empty body, which is how "the body is + over" is distinguished from "there is a chunk" without a sentinel. + + Taking the first chunk *here* rather than in :meth:`stream_bytes` is the + whole point of this helper: everything inside it can be retried safely + because nothing it produces has reached the caller yet, so restarting + the request cannot deliver a byte twice. See :meth:`stream_bytes` for + the policy that rests on it. + + Two details are forced by streaming. The response must be closed on + every failure path, because an unread streaming response holds its + connection open. And :meth:`BaseTransport._raise_for_response` reads + ``.content``, which on a streaming response raises until the body has + actually been read -- hence the read before each raise, which is what + makes the error mapping identical to :meth:`get_bytes`. + """ + url = self.resolve_url(path_or_url) + # One client for both calls: build_request is what stamps the + # client-level headers onto the request, so building with the instance + # client and sending with the download one would put the credential back + # on a foreign fetch. + client = self._client_for(url) + response = await client.send( + client.build_request("GET", url), + stream=True, + follow_redirects=True, + ) + try: + if self.is_retryable_status(response.status_code): + await response.aread() + raise _RetryableResponse(response) + if not response.is_success: + await response.aread() + self._raise_for_response(response) + chunks = response.aiter_bytes(chunk_size) + first: list[bytes] = [] + async for chunk in chunks: + first.append(chunk) + break + except BaseException: + await response.aclose() + raise + return response, chunks, first + + async def stream_bytes( + self, path_or_url: str, *, chunk_size: int = DEFAULT_STREAM_CHUNK_SIZE + ) -> AsyncGenerator[bytes, None]: + """GET raw bytes (an attachment) in chunks, never as one object. + + The streaming twin of :meth:`get_bytes`, and deliberately identical to + it everywhere it can be: the same URL resolution through + :meth:`BaseTransport.resolve_url` (so a URL outside the configured + instance is refused here too), the same ``follow_redirects=True`` for + the signed-location hop, the same attempt count and backoff, and the + same error mapping -- a 403 on an attachment still raises + :class:`EasyvistaAuthError`, and a 590 is still not retried. What + differs is that the body is handed over in ``chunk_size`` pieces as it + arrives, so a large attachment never has to exist in memory whole. + + **Retrying stops as soon as a byte reaches the caller.** A retryable + status or a transport error while opening the download is retried like + any other request, and the first chunk is fetched inside that retried + unit so that a failure fetching it is still safe to restart. From that + chunk onwards the request is committed: a transport failure raises + :class:`EasyvistaConnectionError` instead of starting over, because + starting over would re-deliver bytes the caller already has. Nothing + resumes a partly consumed stream -- a caller that must survive a + mid-stream failure has to decide for itself whether to discard what it + collected and ask again, and this method will not make that choice by + silently duplicating data. + + No request is made until iteration begins. This is a generator, so a + refused URL -- and a non-positive ``chunk_size`` -- raises on the first + step rather than at the call. + """ + if chunk_size <= 0: + # Guarded here rather than left to httpx, which raises from inside + # its own ByteChunker: `chunk_size=0` surfaces as + # "ValueError: range() arg 3 must not be zero" and a negative one as + # "IndexError: list index out of range" -- both several frames below + # this client, so a caller computing a size (`total // n`, a config + # value that defaulted to 0) reads it as a library bug rather than + # bad input. + raise ValueError(f"chunk_size must be positive, got {chunk_size}") + retryer = AsyncRetrying( + stop=stop_after_attempt(self.config.max_retries + 1), + wait=wait_exponential(multiplier=0.5, max=10), + retry=retry_if_exception_type((_RetryableResponse, httpx.TransportError)), + reraise=True, + ) + opened: tuple[httpx.Response, AsyncIterator[bytes], list[bytes]] + try: + opened = await retryer(self._open_stream, path_or_url, chunk_size) + except _RetryableResponse as exc: + self._raise_for_response(exc.response) + except httpx.TransportError as exc: + raise EasyvistaConnectionError(f"connection failed: {exc}") from exc + response, chunks, first = opened + try: + for chunk in first: + yield chunk + async for chunk in chunks: + yield chunk + except httpx.TransportError as exc: + raise EasyvistaConnectionError(f"connection failed: {exc}") from exc + finally: + await response.aclose() diff --git a/easyvista_python_client/_async/client.py b/easyvista_python_client/_async/client.py index 14db9d6..a2a9d92 100644 --- a/easyvista_python_client/_async/client.py +++ b/easyvista_python_client/_async/client.py @@ -10,24 +10,54 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Sequence +from collections.abc import AsyncIterator, Iterable, Mapping, Sequence from datetime import datetime +from typing import Any from easyvista_python_client._async._concurrency import Semaphore, settle -from easyvista_python_client._async._transport import Transport +from easyvista_python_client._async._transport import ( + DEFAULT_STREAM_CHUNK_SIZE, + Transport, +) from easyvista_python_client._transport import RequestSpec -from easyvista_python_client.config import EasyvistaConfig -from easyvista_python_client.context import TicketContext +from easyvista_python_client.config import DocumentDeletePathStyle, EasyvistaConfig +from easyvista_python_client.context import TicketContext, _degraded_entry from easyvista_python_client.directory import ( + DEPARTMENT_MEMO_FIELD, + DEPARTMENT_NAME_COLUMNS, + DEPARTMENT_NOTE_FIELDS, + RECENT_TICKET_FIELDS, RECENT_TICKETS_SORT, DepartmentContext, + _as_fields, _department_matches, _normalize_name, ) -from easyvista_python_client.exceptions import EasyvistaAuthError, EasyvistaNotFound +from easyvista_python_client.discovery import ( + DEFAULT_DISCOVERY_NAMES, + DiscoveredReference, + InstanceProfile, + ReferenceSource, + guids_from_sample, + merge_guids, + reference_from_table_row, + references_from_sample, + resolve_source, + sample_fields, +) +from easyvista_python_client.exceptions import ( + EasyvistaAuthError, + EasyvistaError, + EasyvistaNotFound, +) from easyvista_python_client.field_model import parse_memo from easyvista_python_client.filters import ev_equals_filter, is_safe_ev_value -from easyvista_python_client.models.action import Action, PostAction +from easyvista_python_client.models.action import ( + Action, + ActionUpdate, + PostAction, + PostTask, +) from easyvista_python_client.models.asset import Asset, PostAsset from easyvista_python_client.models.department import ( Department, @@ -40,8 +70,10 @@ EmployeeUpdate, PostEmployee, ) +from easyvista_python_client.models.generic import GenericRecord from easyvista_python_client.models.request import PostRequest, Request, RequestUpdate from easyvista_python_client.pagination import SearchResult +from easyvista_python_client.references import DEFAULT_LANGUAGE_ORDER from easyvista_python_client.reporting import ( DEFAULT_DIMENSIONS, TicketStatistics, @@ -51,9 +83,11 @@ from easyvista_python_client.resources import actions as actions_res from easyvista_python_client.resources import assets as assets_res from easyvista_python_client.resources import departments as departments_res +from easyvista_python_client.resources import discovery as discovery_res from easyvista_python_client.resources import documents as documents_res from easyvista_python_client.resources import employees as employees_res from easyvista_python_client.resources import requests as requests_res +from easyvista_python_client.resources.discovery import SWAGGER_PATH # Width of the action-body fan-out: a ceiling on requests in flight at once on # the async surface, inert on the sync one. This is the one fan-out here whose @@ -65,6 +99,27 @@ _ACTION_FANOUT = 8 +def _unavailable_reason(exc: EasyvistaError) -> str: + """One ``InstanceProfile.unavailable`` value, first token machine-readable. + + ``denied`` for 401/403, ``failed`` for everything else. The rest of the + string is for a human; split on the first space to branch on it. + + A 403 here does NOT prove the route is denied: this API answers 403 for a + path that does not exist as well as for one a profile blocks, so the reason + says "denied or absent" rather than asserting which. + """ + status = getattr(exc, "status_code", None) + if status in (401, 403): + return ( + f"denied HTTP {status} -- the profile may lack read access, or the " + "route may not exist on this deployment; this API answers 403 for " + "both. Check get_api_spec()['paths']." + ) + detail = f"HTTP {status}" if isinstance(status, int) else type(exc).__name__ + return f"failed {detail}" + + class AsyncEasyvistaClient: """Client for the EasyVista Service Manager REST API. @@ -75,6 +130,15 @@ class AsyncEasyvistaClient: def __init__(self, config: EasyvistaConfig) -> None: self.config = config self._transport = Transport(config) + # Built once and passed to every resource builder. ``None`` unless the + # caller named extra timestamp formats, so the default path calls + # ``model_validate(record, context=None)`` -- exactly what it always + # called. + self._validation_context: dict[str, Any] | None = ( + {"datetime_input_formats": config.datetime_input_formats} + if config.datetime_input_formats + else None + ) @classmethod def from_env(cls) -> AsyncEasyvistaClient: @@ -89,9 +153,68 @@ async def __aexit__(self, *exc_info: object) -> None: async def aclose(self) -> None: await self._transport.aclose() + # --- escape hatch -------------------------------------------------------- + async def send( + self, + method: str, + path: str, + *, + params: Mapping[str, Any] | None = None, + json: Any = None, + headers: Mapping[str, str] | None = None, + ) -> Any: + """Issue an arbitrary request against this instance's API root. + + The escape hatch. This package wraps roughly ten of the paths the + instance's own OpenAPI document advertises -- about a hundred of them on + the verified 2025.3 instance, read from ``GET {api_root}/swagger`` (tier + 2: authoritative for that deployment, and another deployment may + advertise a different set). This reaches the rest without forking the + package: reference tables such as ``status``, ``urgency``, ``groups``, + ``locations`` and ``slas``, the external-table route, and whole families + like ``problems`` and ``known-errors``. + + ``path`` joins to ``config.api_root`` exactly as every built-in method's + path does; a leading ``/`` is stripped, so ``"status"`` and ``"/status"`` + address the same route. An absolute URL is **not** accepted, which is + what keeps the credential scoped to the configured instance by + construction. To fetch a URL the API handed back, use + :meth:`download_document` or :meth:`stream_document`. + + Everything else is shared with the typed methods: ``config.max_retries`` + attempts with the same backoff, and the same exception mapping -- 401 and + 403 to :class:`~easyvista_python_client.EasyvistaAuthError`, 404 to + :class:`~easyvista_python_client.EasyvistaNotFound`, 400 and 590 to + :class:`~easyvista_python_client.EasyvistaValidationError`, with 590 never + retried because it is a rejected request rather than a transient one. + ``config.default_params`` is merged under ``params``; ``headers`` is + merged over the client-level ones and may not carry ``Authorization``. + + Returns the decoded JSON body, or ``{}`` when the response has none. + Nothing is validated into a model and no envelope is unwrapped: the + caller owns the shape, which is the point -- there is no model for a + route this package does not wrap. + + Two cautions that apply to every route reached this way. A 590 on a + create may still have created the row, so retrying can duplicate it. And + this API answers a write with HTTP 200 while silently dropping fields it + did not accept, so a 200 is not a receipt -- re-read. + """ + return await self._transport.send( + RequestSpec( + method.upper(), + path, + json=json, + headers=dict(headers) if headers else None, + ), + params=params, + ) + # --- tickets ------------------------------------------------------------- async def create_ticket(self, ticket: PostRequest) -> Request: - spec, parse = requests_res.build_create_ticket(ticket) + spec, parse = requests_res.build_create_ticket( + ticket, context=self._validation_context + ) return parse(await self._transport.send(spec)) async def create_tickets(self, tickets: Sequence[PostRequest]) -> list[Request]: @@ -108,9 +231,42 @@ async def create_tickets(self, tickets: Sequence[PostRequest]) -> list[Request]: # this into a fan-out. return [await self.create_ticket(ticket) for ticket in tickets] - async def get_ticket(self, rfc_number: str) -> Request: - spec, parse = requests_res.build_get_ticket(rfc_number) - return parse(await self._transport.send(spec)) + async def get_ticket( + self, + rfc_number: str, + *, + fields: str | list[str] | None = None, + params: Mapping[str, Any] | None = None, + ) -> Request: + """Fetch one ticket by RFC number. + + ``fields`` is a projection -- the same comma-separated column list + :meth:`search_tickets` takes. Left ``None`` it sends no ``fields`` + parameter at all, which is every request this method has ever sent. + + Pass it when one column poisons the whole record. A value the read + model refuses -- a timestamp in an unexpected format, say -- fails the + entire :class:`Request`, and there is otherwise no way to read the rest + of the ticket. + + One caveat, and it cuts against this parameter. The verified instance's + own OpenAPI declares ``fields`` on ``GET /requests`` (the list) but + **not** on ``GET /requests/{rfc_number}`` -- tier 2, read 2026-08-31 -- + so the item route may ignore it and return the full record anyway. It + costs one request to find out on your deployment. The route that *is* + declared to take a projection is the list one, and it reaches the same + ticket:: + + search_tickets( + search=ev_equals_filter("RFC_NUMBER", rfc), + fields=["RFC_NUMBER", "TITLE"], + max_rows=1, + ) + """ + spec, parse = requests_res.build_get_ticket( + rfc_number, fields=fields, context=self._validation_context + ) + return parse(await self._transport.send(spec, params=params)) async def search_tickets( self, @@ -120,13 +276,19 @@ async def search_tickets( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + params: Mapping[str, Any] | None = None, ) -> SearchResult[Request]: if max_rows is None: max_rows = self.config.default_max_rows spec, parse = requests_res.build_search_tickets( - search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset + search=search, + fields=fields, + sort=sort, + max_rows=max_rows, + offset=offset, + context=self._validation_context, ) - return parse(await self._transport.send(spec)) + return parse(await self._transport.send(spec, params=params)) async def iter_tickets( self, @@ -136,11 +298,27 @@ async def iter_tickets( sort: str | None = None, page_size: int | None = None, max_records: int | None = None, + params: Mapping[str, Any] | None = None, ) -> AsyncIterator[Request]: """Yield tickets across pages, following the API's offset pagination. Pages of ``page_size`` (default ``config.default_max_rows``) until the server reports no further page (``@next``) or ``max_records`` is reached. + + ``sort`` is forwarded to the wire and its token must be + **space-separated** — ``"LAST_UPDATE"`` or ``"LAST_UPDATE DESC"``. + ``"LAST_UPDATE:DESC"``, ``"-LAST_UPDATE"`` and ``"DESC(LAST_UPDATE)"`` + are each **silently ignored** (measured live): the server returns its + default order with no error, so an unsorted result looks sorted. This is + not validated locally, so the token is the caller's to get right. + + Sorting is not cosmetic when the filter selects rows that are changing: + an unsorted offset sweep over a change window can skip a record + permanently, and the two sort DIRECTIONS do not fail the same way -- + descending defers such a miss to the next sweep, ascending loses it. See + :func:`~easyvista_python_client.ev_since_filter`, which rules on the + direction and names the keyset alternative for a caller who cannot + tolerate even a deferred miss. """ if page_size is None: page_size = self.config.default_max_rows @@ -153,6 +331,7 @@ async def iter_tickets( sort=sort, max_rows=page_size, offset=offset, + params=params, ) if not result.records: return @@ -174,6 +353,47 @@ async def count_tickets(self, search: str | None = None) -> int: result = await self.search_tickets(search=search, max_rows=1) return result.total_record_count + async def _collect_tickets( + self, + *, + search: str | None, + fields: str | list[str] | None, + max_records: int | None, + ) -> tuple[list[Request], int | None]: + """Page tickets, returning them plus the first page's reported total. + + Exists because :meth:`iter_tickets` discards the envelope: it yields + records one at a time and has no way to also hand back + ``total_record_count``, which is what tells a capped aggregation how + large the population it sampled actually was. The paging is the same + offset walk :meth:`iter_tickets` performs and issues the same requests + for the same cap; it collects a whole page and trims at the end rather + than stopping mid-page, which changes nothing on the wire. + + The total is the server's count for ``search`` alone. Any client-side + date window is applied later, so it is not comparable with the + aggregated total when one is set. + """ + page_size = self.config.default_max_rows + offset = 0 + population_total: int | None = None + records: list[Request] = [] + while max_records is None or len(records) < max_records: + result = await self.search_tickets( + search=search, fields=fields, max_rows=page_size, offset=offset + ) + if population_total is None: + population_total = result.total_record_count + if not result.records: + break + records.extend(result.records) + if result.next_url is None: + break + offset += len(result.records) + if max_records is not None: + del records[max_records:] + return records, population_total + async def ticket_statistics( self, *, @@ -182,6 +402,7 @@ async def ticket_statistics( created_since: datetime | str | None = None, created_until: datetime | str | None = None, max_records: int | None = 100, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, ) -> TicketStatistics: """Aggregate matching tickets into a total plus per-dimension breakdowns. @@ -189,32 +410,79 @@ async def ticket_statistics( pass ``None`` to aggregate all) and groups them by each name in ``dimensions`` (default: all of ``DEFAULT_DIMENSIONS``). ``created_since`` / ``created_until`` apply an inclusive client-side window on the ticket's - creation date. When the cap truncates, the result describes the fetched - subset — use :meth:`count_tickets` for the true total. + creation date. + + When the cap truncates, the result describes the fetched subset and + ``TicketStatistics.truncated`` is ``True``; ``population_total`` carries + the server's own count for ``search``, read off the first page at no + extra request. ``truncated`` reports "the cap was reached", so it is + ``True`` for a population whose size is exactly the cap; compare it + against ``population_total`` when that distinction matters. + ``population_total`` is counted before any client-side + ``created_since``/``created_until`` window, so it is not comparable with + ``total`` when one is set. Delegates to the same pure :func:`aggregate_tickets` on both surfaces. The page is collected into a list first because that function consumes - a plain iterable, and the async surface's ``iter_tickets`` is an async - generator it cannot take directly. + a plain iterable. """ dims = DEFAULT_DIMENSIONS if dimensions is None else dimensions has_date_filter = created_since is not None or created_until is not None fields = fields_for_references(dims, include_creation_date=has_date_filter) - tickets = [ - t - async for t in self.iter_tickets( - search=search, fields=fields, max_records=max_records - ) - ] - return aggregate_tickets( + tickets, population_total = await self._collect_tickets( + search=search, fields=fields, max_records=max_records + ) + stats = aggregate_tickets( tickets, dimensions=dims, created_since=created_since, created_until=created_until, + languages=languages, ) + # aggregate_tickets is offline and knows nothing about pages or caps, + # so these two are stamped here rather than computed in there. + stats.truncated = max_records is not None and len(tickets) >= max_records + stats.population_total = population_total + return stats async def update_ticket(self, rfc_number: str, update: RequestUpdate) -> Request: - spec, parse = requests_res.build_update_ticket(rfc_number, update) + """Update a ticket's writable fields. + + Cannot set a status: there is no flat status update on this API. See + :meth:`set_status`, and :class:`RequestUpdate` for the measurements. + """ + spec, parse = requests_res.build_update_ticket( + rfc_number, update, context=self._validation_context + ) + return parse(await self._transport.send(spec)) + + async def set_status( + self, rfc_number: str, *, status_guid: str, comment: str | None = None + ) -> Request: + """Set a ticket's status, addressed by ``STATUS_GUID``. + + This is the API's only working status write, and it reaches **every** + status rather than only terminal ones: given six different status GUIDs + in turn, a fresh ticket landed on exactly the status requested every + time, non-terminal ones included. + + It sends the documented ``{"closed": {"status_GUID": ...}}`` body -- the + same request :meth:`close_ticket` sends, under a name that matches what + it does, because "close" is what the wire calls it and not what it is + limited to. + + Note the addressing. A ``STATUS_GUID`` is not a ``STATUS_ID``; the two + are different columns, and only the GUID works here. Read a status's GUID + off any ticket in that status (the nested ``STATUS`` object carries + ``STATUS_GUID``) -- they are stable per instance but are **not** + portable between instances. + """ + spec, parse = requests_res.build_set_status( + rfc_number, + status_guid=status_guid, + comment=comment, + context=self._validation_context, + ) return parse(await self._transport.send(spec)) async def close_ticket( @@ -222,14 +490,68 @@ async def close_ticket( rfc_number: str, *, status_guid: str | None = None, - delete_actions: int | None = None, + delete_actions: int | bool | None = None, comment: str | None = None, + end_date: str | None = None, + catalog_guid: str | None = None, ) -> Request: + """Close a ticket, via the vendor's documented close route. + + Sends ``PUT requests/{rfc}`` with a ``closed`` wrapper -- + https://docs.easyvista.com/docs/rest-api-close-an-incident-request.md. + Every argument is optional. With no ``end_date`` the server stamps now. + With no ``status_guid`` this client sends no status of its own -- but + **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, every one of them passing an explicit + ``status_guid``. Try it on a throwaway ticket and re-read before + relying on it (open item O-CLOSE-DEFAULT). + + **Verify the close by re-reading the status, not by the return value.** + A status id is per-instance configuration and nothing about it is + guessable: on the verified instance ``8`` is *Cloturé* and ``12`` is + *En cours* -- adjacent numbers, opposite meanings. Code that infers + "closed" from an id it did not read off that instance will eventually + skip a ticket it believed was already closed. Read + ``get_ticket(rfc).status_id`` (or ``.reference("STATUS")`` for the + label) afterwards, and compare against a status you resolved from the + instance rather than a constant:: + + client.close_ticket(rfc, status_guid=CLOSED_GUID) + after = client.get_ticket(rfc) + assert after.end_date_ut is not None # the close actually landed + + ``end_date_ut`` is the more portable signal than any status id: it is + empty while a ticket is being worked and stamped once it is finished. + Note the boundary is **resolution, not closure** — measured 2026-09-02 + on one instance (one instance, one date, so it may not generalise), a + ticket that reached *Résolu* already carried an ``end_date_ut``, and + closing it afterwards left that original stamp untouched rather than + re-stamping it. So a populated ``end_date_ut`` means "resolved or + closed", which is the right test for "stop working this ticket" but + **not** a test for "closed" specifically. Nothing in this package + distinguishes the two without resolving the status against the + instance. + + ``status_guid`` reaches **any** status, not only terminal ones -- see + :meth:`set_status`, which is this same request under a name that says + so. ``catalog_guid`` requalifies the ticket as it closes. + ``delete_actions`` drops its actions. + + ``end_date`` takes the instance's own date format, which is not ISO 8601 + everywhere (``dd/mm/yyyy`` on the verified instance -- read + ``DATE_FORMAT`` off any employee record), so it is a string this client + passes through rather than a ``datetime`` it would have to format on a + guess. + """ spec, parse = requests_res.build_close_ticket( rfc_number, status_guid=status_guid, delete_actions=delete_actions, comment=comment, + end_date=end_date, + catalog_guid=catalog_guid, + context=self._validation_context, ) return parse(await self._transport.send(spec)) @@ -242,29 +564,368 @@ async def create_action(self, rfc_number: str, action: PostAction) -> Action: ``ACTION_ID`` (verified live). To address the action you just created, diff :meth:`list_actions` across the call — see ``integration_tests/test_live_ticket_history.py`` for the pattern. + + **For a comment, use :meth:`create_task` instead.** An action is created + **open** — work still to do — and an open action shows in the UI as a + pending row with its text NOT displayed, which reads as though the note + was lost. Only an *ended* action becomes a readable history entry. + :meth:`create_task` posts the same record already ended, in one call. + Reach for ``create_action`` only when you genuinely mean "someone must + still do this", and finish it with :meth:`end_action` — which also + advances the ticket's workflow when the action is a workflow step, so + read that method before calling it. + + Public versus internal is the ``action_type_id``, not a flag on the + body — see :class:`~easyvista_python_client.PostAction`. Put the text a + person must read in ``description``: it shadows ``comment`` in the UI. + + **This route resolves an implicit PARENT action, and that is what most + rejections are about.** Measured 2026-09-01 on one instance (one + instance, one date, may not generalise), the outcome tracks how many + actions are currently **open** on the ticket: + + ================= ========================================== + open actions result + ================= ========================================== + 0 ``590 Parent action not found or incorrect`` + exactly 1 succeeds + 2 or more ``590 Ambiguous query : many parent actions found`` + ================= ========================================== + + Passing ``parent_action_id`` for an **open** action succeeds at any + count; naming an **ended** one is refused. A fresh ticket carries + exactly one open workflow action, and every status change ends the + ticket's open actions — which is why an otherwise valid payload can be + refused on a ticket that accepted the same body earlier. The messages + are literal, not a stage gate. :meth:`create_task` is not + parent-resolved and is unaffected. """ - spec, parse = actions_res.build_create_action(rfc_number, action) + spec, parse = actions_res.build_create_action( + rfc_number, action, context=self._validation_context + ) return parse(await self._transport.send(spec)) - async def list_actions(self, rfc_number: str) -> list[Action]: - spec, parse = actions_res.build_list_actions(rfc_number) + async def create_task(self, rfc_number: str, task: PostTask) -> Action: + """Create a task on a ticket — an action that arrives already ENDED. + + **This is how you post a comment.** A task and an action are the same + underlying record, created in different states: an action starts open + (a pending row whose text the UI does not display), a task starts + ended, so it lands in the ticket's history with its text visible. One + call, no termination step. Verified live 2026-08-28: tasks came back + with ``END_DATE_UT`` and ``STATUS_ID_ON_TERMINATE`` already set. + + **Put that text in ``description``, not ``comment``**, despite the + name. :class:`~easyvista_python_client.PostTask` accepts both, but the + UI renders one field per action — ``description``, falling back to + ``comment`` only when the description memo is empty (measured in the UI + 2026-09-01 on one instance; one instance, one date, may not + generalise). A task carrying both shows only the description, so a note + split across the two loses half of itself with no error. + + Public versus internal is carried by ``action_type_id`` — the type's + own ``ACTION_LABEL_*`` columns say which it is. Unlike + :meth:`create_action`, this route is **not** parent-resolved: it needs + no ``parent_action_id`` and works on a ticket at any stage, including + one whose open actions a status change has already drained. + + Like :meth:`create_action`, the returned :class:`Action` carries **no + usable ``action_id``** — the create response is an HREF naming the + parent request. Diff :meth:`list_actions` across the call to address + what you just created. + """ + spec, parse = actions_res.build_create_task( + rfc_number, task, context=self._validation_context + ) return parse(await self._transport.send(spec)) - async def get_action(self, action_id: str | int) -> Action: + async def list_actions( + self, + rfc_number: str, + *, + fields: Iterable[str] | str | None = None, + params: Mapping[str, Any] | None = None, + ) -> list[Action]: + """List a ticket's actions. + + The default projection is slim: it carries ``ACTION_ID``, + ``ACTION_LABEL_FR``, ``ACTION_NUMBER``, ``DONE_BY_ID`` and + ``EXPECTED_START_DATE_UT`` but **no** ``CREATION_DATE_UT`` or + ``LAST_UPDATE``. Pass ``fields`` to project them onto the list and read + a page of actions' timestamps and authors in one request rather than one + item fetch each:: + + actions = client.list_actions( + rfc, + fields=["ACTION_ID", "ACTION_TYPE_ID", "CREATION_DATE_UT", + "LAST_UPDATE", "DONE_BY_ID"], + ) + + **Returns at most ONE page and does not paginate.** The cap is + ``config.default_max_rows``; a ticket with more actions than that is + **truncated with no error**, and this method discards the envelope's + total, so a caller cannot detect the truncation from the result. This is + not hypothetical: a freshly created ticket already carries about twelve + actions, most of them workflow-generated. Use + :meth:`iter_actions` to page the whole log, or raise + ``EasyvistaConfig.default_max_rows`` to widen this single page. + + The note text is never projectable — ``DESCRIPTION`` and ``COMMENT`` + are Memo sub-resources and come back as HREF objects under every + projection, so a body still costs one :meth:`resolve_memo` per action. + + ``fields`` has two more silent footguns: ``"*"`` is not a wildcard — + it silently reduces to ``ACTION_ID`` alone — and a dotted path such as + ``DESCRIPTION.HREF`` is silently dropped. + """ + spec, parse = actions_res.build_list_actions( + rfc_number, + fields=fields, + max_rows=self.config.default_max_rows, + context=self._validation_context, + ) + return parse(await self._transport.send(spec, params=params)) + + async def iter_actions( + self, + rfc_number: str, + *, + fields: Iterable[str] | str | None = None, + page_size: int | None = None, + max_records: int | None = None, + params: Mapping[str, Any] | None = None, + ) -> AsyncIterator[Action]: + """Yield a ticket's actions across pages, following offset pagination. + + Pages of ``page_size`` (default ``config.default_max_rows``) until the + server reports no further page (``@next``) or ``max_records`` is + reached. ``fields`` and the ticket filter apply to every page. See + :meth:`list_actions` for what a projection can and cannot reach. + + A blank or unsafe ``rfc_number`` raises ``ValueError`` on the first + iteration rather than at the call, since this is a generator. + + .. warning:: + + The ``offset``/``@next`` contract is unverified on the ``actions`` + endpoint. If an instance ignores ``offset``, page two repeats page + one and the sweep never ends; bound it with ``max_records``. + """ + if page_size is None: + page_size = self.config.default_max_rows + offset = 0 + yielded = 0 + while max_records is None or yielded < max_records: + spec, parse = actions_res.build_search_actions( + rfc_number, + fields=fields, + max_rows=page_size, + offset=offset, + context=self._validation_context, + ) + result = parse(await self._transport.send(spec, params=params)) + if not result.records: + return + for record in result.records: + yield record + yielded += 1 + if max_records is not None and yielded >= max_records: + return + if result.next_url is None: + return + offset += len(result.records) + + async def get_action( + self, action_id: str | int, *, params: Mapping[str, Any] | None = None + ) -> Action: """Fetch one action, including the Memo links ``list_actions`` omits. The note text lives behind :attr:`Action.description`'s href on this - record; :meth:`get_ticket_context` resolves it for you. + record — but only while that memo has text. ``comment`` is a second + href beside it, and it matters when ``description`` resolves empty: + the UI renders one field per action, falling back to ``comment`` + exactly then (measured in the UI 2026-09-01 on one instance; one + instance, one date, may not generalise). To reproduce what a person + saw, resolve ``description`` first and ``comment`` only if it comes + back empty. :meth:`get_ticket_context` applies that rule for you. + """ + spec, parse = actions_res.build_get_action( + action_id, context=self._validation_context + ) + return parse(await self._transport.send(spec, params=params)) + + async def update_action(self, action_id: str | int, update: ActionUpdate) -> Action: + """Edit an existing action's note text. + + ``ActionUpdate`` carries two fields, ``description`` and ``comment``, + and they are **not** interchangeable to a reader. The UI renders one + text field per action — ``description``, falling back to ``comment`` + only when the description memo is empty (measured in the UI 2026-09-01 + on one instance; one instance, one date, may not generalise). So + ``ActionUpdate(comment=...)`` on an action that already has a + description returns 200, re-reads cleanly through the API, and changes + nothing anyone sees. **Write ``description`` to change what a person + reads.** + + Live-verified 2026-08-17 by re-reading the memo afterwards, not by the + status code, and again 2026-09-01 on an action that had already been + **ended**, where the new ``description`` rendered in the history — an + ended action is not frozen, and this is how to correct or extend a + resolution visibly. Note that an action can be edited but **not + deleted**: the + instance OpenAPI document (``GET {api_root}/swagger``, read 2026-08-27) + declares only GET, PUT and PATCH on ``actions/{id}``, no DELETE, so + there is deliberately no ``delete_action``. The 403 an earlier note + recorded for that verb is what this API answers for an absent route as + well as a denied one, so it did not distinguish them. + + The returned :class:`Action` is the API's own echo and is **not + verified**: the PUT's response body has never been captured, and if it + answers empty or href-only the parser yields an ``Action`` whose fields + are all ``None``. Re-read with :meth:`get_action` rather than reading + fields off the return value. """ - spec, parse = actions_res.build_get_action(action_id) + spec, parse = actions_res.build_update_action( + action_id, update, context=self._validation_context + ) return parse(await self._transport.send(spec)) - async def _resolve_action_body(self, action: Action) -> Action: - """Return ``action`` with its note text resolved onto ``description``. + async def end_action( + self, + rfc_number: str, + *, + action_id: str | int | None = None, + end_all: bool = False, + end_date: str | None = None, + start_date: str | None = None, + elapsed_time: int | str | None = None, + doneby_mail: str | None = None, + ) -> Action: + """Report an action as done — the step :meth:`create_action` leaves open. + + An action is born **open** and its text does not render in the ticket + history until it is ended, so this is what turns a ``create_action`` + into something a person can read. A comment needs neither call: + :meth:`create_task` posts an already-ended record in one request. + + Addressed by the **ticket**, not the action: the path segment is + ``rfc_number`` and the action is named in the body. Passing an action + id as the path answers 404 even with the id also in the body (measured + 2026-09-01). + + .. warning:: + + **Ending the ticket's own workflow action advances the workflow.** + This call is not bookkeeping. Measured 2026-09-01 on one instance + (Service Manager 2025.3 — one instance, one date, so it may not + generalise), ending a fresh ticket's open type-20 *Traitement + Operation* action moved the **ticket** from *En cours* to *Résolu* + and spawned a new open type-1 *Validation Self Service* action + (2 tickets, 2/2). Controls the same day and the next showed the + opposite for an action the caller had created: ending a type-94 + action left the ticket's status and its action count untouched + (3 tickets, 3/3). So ending your own action changes that action + only — it still ends it, which is the whole point, and its text + then renders — while ending a workflow step also changes the + ticket. Status ids are per-instance, so treat 12 and 2 as this + deployment's, not as values to hardcode. + + **This is why ``action_id`` is required.** The vendor documents + the id-less form as ending *every open action on the ticket*, which + on a ticket whose only open action is its workflow step means + resolving it. That form is reachable only through ``end_all=True``; + a bare ``action_id=None`` raises ``ValueError`` before any request. + The guard exists because ``Action.action_id`` is legitimately + ``None`` all over this package — :meth:`create_action`'s response + carries no id, and a ``fields=`` projection without ``ACTION_ID`` + drops it — so an id a caller thought they had would otherwise + select the bulk form in silence. Only ``end_all=True`` was measured + with a single open action, so *how* it behaves against several open + at once is vendor-documented, not measured here. + + Both dates are passed through as **strings**, because the accepted + format follows the instance rather than a standard: it is not ISO 8601 + on every deployment, and accepting a ``datetime`` would mean this + package formatting one on a guess. The date part is the instance's own + ``DATE_FORMAT`` (readable off any employee record); the time part is + not covered by that column, so the whole accepted spelling has to be + measured per deployment. On the verified instance it is + ``dd/mm/yyyy hh:mm:ss`` — ``dd/mm/yyyy hh:mm`` also works, a bare + ``dd/mm/yyyy`` lands at midnight, and **ISO 8601 is refused** with HTTP + 590 "Invalid End Date" (measured 2026-09-01/02 on one instance, so it + may not generalise; ``close_ticket``'s ``end_date`` documents the + date-only spelling from an earlier measurement of the same instance). + + **Send ``start_date`` explicitly.** Left out, the server derives it as + ``end_date`` minus ``elapsed_time`` and then minus the instance's UTC + offset, so the stored start is early by that offset — one hour or two + depending on DST (measured 2026-09-01 against a +02:00 instance and + against a February date at +01:00; confirmed to the second 2026-09-02, + where ``end_date`` 08:14:35 with ``elapsed_time`` 15 stored a start of + 05:59:35). An explicit ``start_date`` is stored faithfully. + + ``elapsed_time`` is a number of **minutes**, and it is neither derived + nor cross-checked. Measured 2026-09-02 on one instance (one instance, + one date, so it may not generalise): + + * Omitted entirely, it stays **empty** — the server does not compute it + from your two dates, so send it if you want one. + * Sent, it is stored **as given, even when it contradicts the dates**: + 60 was stored against a 15-minute window. Both ``60`` and ``"7"`` + were honoured, so the type does not matter. + * The one exception: when ``start_date`` **equals** ``end_date``, the + stored value is ``0`` whatever you send (3/3). A zero-length window + silently discards it, which is easy to hit by passing the same + timestamp to both. + + Raises :class:`~easyvista_python_client.EasyvistaValidationError` (HTTP + 590, EV code 2013) with ``Action not found`` when **no open action + matches** — which is what replaying this call against an + already-ended action looks like. An earlier version of this package's + documentation read that message as a profile restriction to raise with + an administrator; that was wrong, and ending an open action succeeds. + + ``doneby_mail`` attributes the work to somebody other than the + authenticating account; left out, the API credits that account. + + The returned :class:`Action` carries **only ``href``**: the measured + response is href-only and names the parent *request*, exactly like + :meth:`create_action`'s, so ``action_id`` and every other field are + ``None`` — and because that href's tail is an RFC number rather than a + numeric id, no id is derived from it either. Re-read with + :meth:`get_action` to confirm ``END_DATE_UT``; a 200 is not a receipt + on this API. + """ + spec, parse = actions_res.build_end_action( + rfc_number, + action_id=action_id, + end_all=end_all, + end_date=end_date, + start_date=start_date, + elapsed_time=elapsed_time, + doneby_mail=doneby_mail, + context=self._validation_context, + ) + return parse(await self._transport.send(spec)) - Costs two requests per action (item fetch, then the Memo), so callers - that do not need bodies pass ``resolve_action_bodies=False``. Degrades - to the unresolved record on 403/404 rather than failing the bundle. + async def _resolve_action_body(self, action: Action) -> Action: + """Return ``action`` with its note text resolved onto the memo that shows. + + Resolves ``DESCRIPTION``, and ``COMMENT`` **only when ``DESCRIPTION`` + comes back empty** -- mirroring what a reader sees. The UI renders one + text field per action, under a header reading "comment or description": + ``DESCRIPTION`` when it has text, falling back to ``COMMENT`` when it + does not (measured in the UI 2026-09-01 on one instance, Service + Manager 2025.3 -- one instance, one date, so it may not generalise). + Resolving ``DESCRIPTION`` alone dropped the body of exactly the actions + a human *can* read, so a ticket exported through + :meth:`get_ticket_context` disagreed with the ticket on screen. + + Costs two requests per action (item fetch, then the Memo), and a third + only in that fallback case, so a populated description never pays for + it. Callers that do not need bodies pass ``resolve_action_bodies=False``. + Degrades to the unresolved record on 403/404 rather than failing the + bundle. """ if action.action_id is None: return action @@ -275,16 +936,25 @@ async def _resolve_action_body(self, action: Action) -> Action: if isinstance(full.description, dict): href = full.description.get("HREF") full.description = await self._safe_memo(href) if href else None + if not full.description and isinstance(full.comment, dict): + href = full.comment.get("HREF") + full.comment = await self._safe_memo(href) if href else None return full # --- assets -------------------------------------------------------------- async def create_asset(self, asset: PostAsset) -> Asset: - spec, parse = assets_res.build_create_asset(asset) + spec, parse = assets_res.build_create_asset( + asset, context=self._validation_context + ) return parse(await self._transport.send(spec)) - async def get_asset(self, asset_id: str) -> Asset: - spec, parse = assets_res.build_get_asset(asset_id) - return parse(await self._transport.send(spec)) + async def get_asset( + self, asset_id: str, *, params: Mapping[str, Any] | None = None + ) -> Asset: + spec, parse = assets_res.build_get_asset( + asset_id, context=self._validation_context + ) + return parse(await self._transport.send(spec, params=params)) async def search_assets( self, @@ -294,13 +964,19 @@ async def search_assets( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + params: Mapping[str, Any] | None = None, ) -> SearchResult[Asset]: if max_rows is None: max_rows = self.config.default_max_rows spec, parse = assets_res.build_search_assets( - search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset + search=search, + fields=fields, + sort=sort, + max_rows=max_rows, + offset=offset, + context=self._validation_context, ) - return parse(await self._transport.send(spec)) + return parse(await self._transport.send(spec, params=params)) async def iter_assets( self, @@ -310,6 +986,7 @@ async def iter_assets( sort: str | None = None, page_size: int | None = None, max_records: int | None = None, + params: Mapping[str, Any] | None = None, ) -> AsyncIterator[Asset]: """Yield assets across pages (see :meth:`iter_tickets`).""" if page_size is None: @@ -323,6 +1000,7 @@ async def iter_assets( sort=sort, max_rows=page_size, offset=offset, + params=params, ) if not result.records: return @@ -340,14 +1018,59 @@ async def add_document( self, rfc_number: str, *, filename: str, content: bytes ) -> Document: spec, parse = documents_res.build_add_document( - rfc_number, filename=filename, content=content + rfc_number, + filename=filename, + content=content, + context=self._validation_context, ) return parse(await self._transport.send(spec)) async def list_documents(self, rfc_number: str) -> list[Document]: - spec, parse = documents_res.build_list_documents(rfc_number) + spec, parse = documents_res.build_list_documents( + rfc_number, context=self._validation_context + ) return parse(await self._transport.send(spec)) + async def delete_document( + self, + rfc_number: str | None, + document_id: str | int | Document, + *, + path_style: DocumentDeletePathStyle | None = None, + ) -> None: + """Remove an attachment from a ticket. + + ``document_id`` is the ``DOCUMENT_ID`` from :meth:`list_documents`, or + the :class:`Document` itself, whose ``DOCUMENT_ID`` is read off it -- a + record carrying none raises ``ValueError`` rather than sending a request + that would address the collection. Returns nothing: the API answers with + an empty body, so re-list to confirm. + + Two routes exist for this. The instance OpenAPI document read 2026-08-27 + declares DELETE on both ``requests/{rfc}/documents/{id}`` and + ``documents/{id}``, marking only the second ``deprecated``, so which one + works is a profile question rather than a routing one. ``path_style`` + picks one for this call and defaults to + :attr:`EasyvistaConfig.document_delete_path_style`, itself ``"nested"`` + -- the form verified live 2026-08-17 by re-listing the ticket's + documents afterwards, on one instance, which may not generalise. Under + ``"top_level"`` the id addresses the record on its own and + ``rfc_number`` is unused: pass ``None``. + """ + if isinstance(document_id, Document): + if not document_id.document_id: + raise ValueError( + "this Document carries no DOCUMENT_ID; pass the id directly" + ) + document_id = document_id.document_id + if path_style is None: + path_style = self.config.document_delete_path_style + await self._transport.send( + documents_res.build_delete_document( + rfc_number, document_id, path_style=path_style + ) + ) + async def download_document(self, document: Document | str) -> bytes: """Fetch an attachment's bytes. @@ -359,10 +1082,82 @@ async def download_document(self, document: Document | str) -> bytes: """ return await self._transport.get_bytes(documents_res.download_href(document)) + async def stream_document( + self, document: Document | str, *, chunk_size: int = DEFAULT_STREAM_CHUNK_SIZE + ) -> AsyncIterator[bytes]: + """Fetch an attachment's bytes in chunks, without holding the file whole. + + Accepts exactly what :meth:`download_document` accepts -- a + :class:`Document` from :meth:`list_documents` or a raw href/path -- and + resolves it identically, refusing a URL outside the configured instance + for the same reason. Use this when the bytes are on their way somewhere + else in pieces (a file on disk, a hash, another API) and + :meth:`download_document` when a single ``bytes`` object is what you + wanted anyway. + + Called ``stream_`` rather than ``iter_`` on purpose: every ``iter_*`` + method on this client iterates *records*, and this iterates the bytes of + one document. + + The opposite direction cannot stream at all. :meth:`add_document` sends + base64 inside a JSON body, so an upload has to materialise the whole + payload however it is called; the asymmetry is the API's, not an + oversight here. + + Retrying covers opening the download only. Once the first chunk has been + handed over the request is committed, and a transport failure raises + :class:`~easyvista_python_client.exceptions.EasyvistaConnectionError` + rather than starting again -- starting again would re-deliver bytes the + caller already has. A partly consumed stream is never resumed, so + deciding what to do with a mid-stream failure is the caller's. See + :meth:`~easyvista_python_client._async._transport.Transport.stream_bytes`. + + Nothing is requested until iteration begins: this is a generator, so + :class:`ValueError` for a record carrying no download URL and + :class:`EasyvistaError` for one pointing off the instance both surface + on the first step rather than at the call. + + **Stopping early:** on the async surface a bare ``break`` leaves the + response checked out of the connection pool until the event loop's + async-generator finalizer runs, which is a garbage-collection cycle away + (measured) -- so a caller that reads only a prefix of many attachments + under a bounded ``max_connections`` can stall on connections it appears + to have released. Close the generator instead (``aclose()``, or + ``contextlib.aclosing``). On the sync surface refcounting releases it at + the ``break`` and nothing is needed. + """ + stream = self._transport.stream_bytes( + documents_res.download_href(document), chunk_size=chunk_size + ) + # Close the inner generator in a `finally` rather than leaving it to be + # collected. `stream`'s own `finally` is what releases the response and + # returns its connection to the pool, and unwinding *this* generator + # does not reach it on its own -- the loop below simply exits. + # + # What this buys, stated precisely: one deferral instead of two. Closing + # this generator -- explicitly, or by an exception propagating out of it + # -- now releases the response at once. A bare `break` still defers on + # the async surface, because unwinding this generator is itself left to + # the event loop's async-generator finalizer; what the `finally` removes + # is the *second* wait, for `stream` to become garbage in its own right. + # On the sync surface refcounting closes it promptly either way. + # `contextlib.closing`/`aclosing` would say this in one line, but those + # two names differ by more than a token so the codegen cannot generate + # the pair; `stream.close()`/`stream.aclose()` it can. + try: + async for chunk in stream: + yield chunk + finally: + await stream.aclose() + # --- departments ---------------------------------------------------------- - async def get_department(self, department_id: str | int) -> Department: - spec, parse = departments_res.build_get_department(department_id) - return parse(await self._transport.send(spec)) + async def get_department( + self, department_id: str | int, *, params: Mapping[str, Any] | None = None + ) -> Department: + spec, parse = departments_res.build_get_department( + department_id, context=self._validation_context + ) + return parse(await self._transport.send(spec, params=params)) async def search_departments( self, @@ -372,13 +1167,19 @@ async def search_departments( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + params: Mapping[str, Any] | None = None, ) -> SearchResult[Department]: if max_rows is None: max_rows = self.config.default_max_rows spec, parse = departments_res.build_search_departments( - search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset + search=search, + fields=fields, + sort=sort, + max_rows=max_rows, + offset=offset, + context=self._validation_context, ) - return parse(await self._transport.send(spec)) + return parse(await self._transport.send(spec, params=params)) async def iter_departments( self, @@ -388,6 +1189,7 @@ async def iter_departments( sort: str | None = None, page_size: int | None = None, max_records: int | None = None, + params: Mapping[str, Any] | None = None, ) -> AsyncIterator[Department]: """Yield departments across pages (see :meth:`iter_tickets`).""" if page_size is None: @@ -401,6 +1203,7 @@ async def iter_departments( sort=sort, max_rows=page_size, offset=offset, + params=params, ) if not result.records: return @@ -413,26 +1216,53 @@ async def iter_departments( return offset += len(result.records) - async def get_department_comment(self, department_id: str | int) -> str | None: + async def get_department_comment( + self, + department_id: str | int, + *, + memo_field: str = DEPARTMENT_MEMO_FIELD, + ) -> str | None: """Return the department's note (a Memo). ``""`` for an empty note; propagates transport errors so a 403/404 is distinguishable from an empty note (uses the generic ``resolve_memo``). + + ``memo_field`` is the last path segment of + ``GET departments/{id}/{comment}``. In the instance OpenAPI document + read 2026-08-27 that segment is a path *parameter* named ``comment``, + not a literal -- the sibling ``GET requests/{rfc_number}/{comment}`` + describes the same parameter as "Memo field type, could be comment, + description". So the route selects a memo column, and the default is + only the column the verified instance carries. Same idea as + :meth:`get_ticket_context`'s ``memo_fields``. """ - return await self.resolve_memo( - f"departments/{department_id}/comment_department" - ) + return await self.resolve_memo(f"departments/{department_id}/{memo_field}") async def find_departments( - self, name: str, *, limit: int | None = None + self, + name: str, + *, + limit: int | None = None, + by: str | Sequence[str] = "auto", ) -> list[Department]: """Resolve departments by a fuzzy, language-agnostic ``name``. - Fast path (neutral): an all-digit ``name`` matches ``DEPARTMENT_ID`` exactly, - otherwise ``DEPARTMENT_CODE`` exactly; a hit returns immediately. Fuzzy - fallback: scan every department and match ``name`` — normalized so - ``"Acme Corp" == "ACME-CORP" == "acmecorp"`` — as a substring of any - string field. ``limit`` caps the result count. Returns ``[]`` on no match. + Fast path (server-side, exact): ``by`` names the columns to try, in + order, and the first one that returns records wins. ``"auto"`` tries + ``DEPARTMENT_CODE`` alone for a name containing anything but digits, + and ``DEPARTMENT_CODE`` then ``DEPARTMENT_ID`` for an all-digit one -- + code first, because a department whose code is all digits would + otherwise be looked up as an id and a different department would come + back with no error. That costs one extra round trip when the digits are + an id and not a code. Pass a single column name to pin one lookup + (``by="DEPARTMENT_ID"``), an ordered sequence to choose your own, or an + empty sequence to skip the fast path. + + Fuzzy fallback: scan every department and match ``name`` -- normalized + so ``"Acme Corp" == "ACME-CORP" == "acmecorp"``, and accent- and + case-folded so ``"Systemes"`` matches the same name written with its + accents -- as a substring of any string field. ``limit`` caps the result + count. Returns ``[]`` on no match. A ``name`` that cannot be expressed in EasyVista's search grammar (see :func:`~easyvista_python_client.is_safe_ev_value`) skips the server fast @@ -443,10 +1273,22 @@ async def find_departments( # expressed server-side would otherwise be interpolated raw — where a ',' # silently widens the result set. Such names skip straight to the local # scan below, which handles any characters. - field = "DEPARTMENT_ID" if name.isdigit() else "DEPARTMENT_CODE" + # + # `isinstance(by, str)` is checked BEFORE the sequence branch, or + # `by="DEPARTMENT_ID"` would be read as eleven single-character columns. + if by == "auto": + columns: tuple[str, ...] = ( + DEPARTMENT_NAME_COLUMNS if name.isdigit() else ("DEPARTMENT_CODE",) + ) + elif isinstance(by, str): + columns = (by,) + else: + columns = tuple(by) if is_safe_ev_value(name): - search = ev_equals_filter(field, name) - if search is not None: + for column in columns: + search = ev_equals_filter(column, name) + if search is None: + continue fast = await self.search_departments(search=search) if fast.records: return fast.records if limit is None else fast.records[:limit] @@ -463,20 +1305,28 @@ async def find_departments( async def create_department(self, department: PostDepartment) -> Department: """Create a department (provisional; profile-gated — spec open item O-DIR-2).""" - spec, parse = departments_res.build_create_department(department) + spec, parse = departments_res.build_create_department( + department, context=self._validation_context + ) return parse(await self._transport.send(spec)) async def update_department( self, department_id: str | int, update: DepartmentUpdate ) -> Department: """Update a department via PUT (provisional; profile-gated).""" - spec, parse = departments_res.build_update_department(department_id, update) + spec, parse = departments_res.build_update_department( + department_id, update, context=self._validation_context + ) return parse(await self._transport.send(spec)) # --- employees ------------------------------------------------------------ - async def get_employee(self, employee_id: str | int) -> Employee: - spec, parse = employees_res.build_get_employee(employee_id) - return parse(await self._transport.send(spec)) + async def get_employee( + self, employee_id: str | int, *, params: Mapping[str, Any] | None = None + ) -> Employee: + spec, parse = employees_res.build_get_employee( + employee_id, context=self._validation_context + ) + return parse(await self._transport.send(spec, params=params)) async def search_employees( self, @@ -486,13 +1336,19 @@ async def search_employees( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + params: Mapping[str, Any] | None = None, ) -> SearchResult[Employee]: if max_rows is None: max_rows = self.config.default_max_rows spec, parse = employees_res.build_search_employees( - search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset + search=search, + fields=fields, + sort=sort, + max_rows=max_rows, + offset=offset, + context=self._validation_context, ) - return parse(await self._transport.send(spec)) + return parse(await self._transport.send(spec, params=params)) async def iter_employees( self, @@ -502,6 +1358,7 @@ async def iter_employees( sort: str | None = None, page_size: int | None = None, max_records: int | None = None, + params: Mapping[str, Any] | None = None, ) -> AsyncIterator[Employee]: """Yield employees across pages (see :meth:`iter_tickets`).""" if page_size is None: @@ -515,6 +1372,7 @@ async def iter_employees( sort=sort, max_rows=page_size, offset=offset, + params=params, ) if not result.records: return @@ -529,14 +1387,18 @@ async def iter_employees( async def create_employee(self, employee: PostEmployee) -> Employee: """Create an employee (provisional; profile-gated — spec open item O-DIR-2).""" - spec, parse = employees_res.build_create_employee(employee) + spec, parse = employees_res.build_create_employee( + employee, context=self._validation_context + ) return parse(await self._transport.send(spec)) async def update_employee( self, employee_id: str | int, update: EmployeeUpdate ) -> Employee: """Update an employee via PUT (provisional; profile-gated).""" - spec, parse = employees_res.build_update_employee(employee_id, update) + spec, parse = employees_res.build_update_employee( + employee_id, update, context=self._validation_context + ) return parse(await self._transport.send(spec)) # --- aggregated context -------------------------------------------------- @@ -555,8 +1417,445 @@ async def resolve_memo(self, href: str) -> str | None: field = path.rstrip("/").rsplit("/", 1)[-1] return parse_memo(await self._transport.send(RequestSpec("GET", path)), field) + # --- instance discovery -------------------------------------------------- + async def get_api_spec(self, *, path: str = SWAGGER_PATH) -> dict[str, Any]: + """Fetch the instance's own OpenAPI description. + + Returns the parsed document: ``info`` (``description`` carries the + product version, e.g. ``"Easyvista Service Manager REST API - 2025.3"``), + ``paths`` -- the routes *this* deployment exposes -- and ``components``. + + **Trust the two halves differently.** ``paths`` is tier 2: + authoritative for this deployment, and the reason :meth:`discover` reads + urgencies at ``urgency`` rather than at the vendor-documented + ``urgencies``. ``components.schemas`` is tier 3: example-derived and + illustrative only. It declares ``required: []`` throughout and lists + whichever private ``E_*`` columns the example happened to carry, so a + field appearing in a schema is not a requirement and a field missing + from one is not forbidden. + + .. warning:: + + **A GET to this route answers HTTP 201, not 200** (measured + 2026-08-27 against one instance; it may not generalise). This client + is unaffected -- its transport treats any 2xx as success -- but code + you write beside it that gates on ``response.status_code == 200`` + skips this document in silence and concludes the instance publishes + no spec. The route is ``{api_root}/swagger``, that is + ``/api/{api_version}/{account}/swagger``; the bare-host + ``{server}/swagger`` answers 403. + + ``path`` is a keyword so a deployment that publishes its description + elsewhere is reachable without patching this package; the default is the + route measured on the verified instance. + + Raises like any other read -- a 401/403 becomes ``EasyvistaAuthError`` + rather than an empty document, because an empty document and a denied + one must not look alike. + """ + spec, parse = discovery_res.build_get_api_spec(path) + return parse(await self._transport.send(spec)) + + async def list_reference_table( + self, + path: str, + *, + search: str | None = None, + fields: Iterable[str] | str | None = None, + sort: str | None = None, + max_rows: int | None = None, + offset: int | None = None, + params: Mapping[str, Any] | None = None, + ) -> SearchResult[GenericRecord]: + """Read any of the instance's list routes into column-free records. + + ``path`` is resource-relative: ``"status"``, ``"urgency"``, + ``"catalog-requests"``, ``"locations"``, ``"groups"``, ``"slas"``, + ``"domains"``, ``"suppliers"``. Call :meth:`get_api_spec` and read + ``["paths"]`` to see which of them your deployment actually declares -- + this package wraps about ten of that instance's hundred routes, and this + method is how you reach the rest of the read-only ones. + + Records come back as :class:`GenericRecord`, which declares **no + columns**: nothing here assumes a schema, because the OpenAPI schemas for + these routes are tier 3 and one of them (``/status``) is visibly wrong. + Read a column by its API name from ``record.model_dump(by_alias=True)``, + or let ``record.reference(name)`` and ``record.classify_fields()`` do it + generically. + + The four query parameters the spec declares on these routes are + ``max_rows``, ``sort``, ``fields`` and ``search``; each is sent only when + passed, so the default call is the bare route. ``offset`` is + vendor-documented for the requests list and merely *inferred* here, so it + too is sent only when asked for. ``params`` is merged last and wins, for + anything this signature does not model. + + **A 403 propagates as ``EasyvistaAuthError``; it does not become + ``[]``.** An empty reference table is a legitimate answer on a lightly + configured instance, so collapsing a denial into an empty list would make + "you may not read this" indistinguishable from "there is nothing here" -- + and a caller that builds a status map from an empty list concludes the + instance has no statuses and hardcodes a constant instead. + :meth:`describe_instance` is the layer that swallows the denial, and it + names the gap in ``.unavailable`` so the loss stays visible. + + Note what a 403 does *not* tell you: this API answers **403 rather than + 404 for a route that does not exist**, so a denied table and a misspelled + path are the same exception. ``get_api_spec()["paths"]`` distinguishes + them. + + The result is a :class:`SearchResult`, not a list, so truncation is + detectable: compare ``.record_count`` with ``.total_record_count`` before + treating a page as the whole table. A route that answers with a bare + object and no envelope reports both as the number of records parsed, in + which case truncation cannot be detected from the response at all. + """ + spec, parse = discovery_res.build_list_reference_table( + path, + search=search, + fields=fields, + sort=sort, + max_rows=max_rows, + offset=offset, + params=params, + context=self._validation_context, + ) + return parse(await self._transport.send(spec)) + + async def _sample_records( + self, + source: ReferenceSource, + *, + sample_size: int, + search: str | None, + action_sample_tickets: int, + languages: Sequence[str], + ) -> list[dict[str, Any]]: + """Sampled records carrying ``source``, as by-alias dumps. + + Tickets are one sweep. Actions need a ticket sweep first, because the + actions list is filtered per ticket -- so the first + ``action_sample_tickets`` sampled RFC numbers are swept for their + actions. That sweep is always bounded: the ``offset``/``@next`` contract + is unverified on the actions endpoint (see :meth:`iter_actions`), and an + instance that ignores ``offset`` would otherwise repeat page one forever. + """ + projection = sample_fields(source, languages=languages) + if source.sample_from != "actions": + return [ + t.model_dump(by_alias=True) + async for t in self.iter_tickets( + search=search, fields=projection, max_records=sample_size + ) + ] + rfcs = [ + t.rfc_number + async for t in self.iter_tickets( + search=search, + fields=["RFC_NUMBER"], + max_records=max(action_sample_tickets, 1), + ) + if t.rfc_number + ] + records: list[dict[str, Any]] = [] + for rfc in rfcs: + records.extend( + [ + a.model_dump(by_alias=True) + async for a in self.iter_actions( + rfc, fields=projection, max_records=sample_size + ) + ] + ) + return records + + async def discover( + self, + name: str, + *, + strategy: str = "auto", + reference_path: str | None = None, + sample_size: int = 200, + action_sample_tickets: int = 5, + search: str | None = None, + reference_search: str | None = None, + max_rows: int | None = None, + with_guid: bool = True, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, + ) -> list[DiscoveredReference]: + """Find the ids, labels and codes one reference uses on this instance. + + ``name`` is a reference name: ``"STATUS"``, ``"URGENCY"``, + ``"CATALOG_REQUEST"``, ``"LOCATION"``, ``"DEPARTMENT"``, ``"SLA"``, + ``"GROUP"``, ``"IMPACT"``, ``"SEVERITY"``, ``"ORIGIN"``, + ``"ACTION_TYPE"`` -- or any other column, including a custom ``e_*`` + one, which is read off sampled tickets. + + ``strategy``: + + * ``"auto"`` (default) -- read the reference table when this + deployment's OpenAPI declares a route for the name, and fall back to + sampling if that route is denied. Names with no route go straight to + sampling and cost no wasted request. + * ``"reference"`` -- the table only. A denial raises. A name with no + route and no ``reference_path`` raises ``ValueError`` rather than + quietly sampling. + * ``"sample"`` -- sampling only; no reference route is called. + + **Four names have no route at all** on the verified instance: + ``IMPACT``, ``SEVERITY``, ``ORIGIN`` and ``ACTION_TYPE``. That is a + topology fact read from the spec's ``paths`` (tier 2), not a 403 someone + measured, so no strategy reaches a table for them. What comes back is + the ids *in use* in the sample: an id configured but unused is + invisible, and a ``count`` is a sample count, never a population one. + + ``reference_path`` overrides the route -- the escape hatch for a + deployment that spells a table differently. Urgencies are the live + example: the vendor documents ``GET /urgencies`` while the verified + instance declares ``GET /urgency``, so the default is the singular one + and ``reference_path="urgencies"`` reaches the other. + + ``search`` filters the **sample** (a ticket or action filter -- see the + ``easyvista-search-syntax`` skill, and note that unparseable syntax + returns the whole table rather than erroring). ``reference_search`` + filters the **table**. ``sample_size`` caps records fetched client-side; + ``max_rows`` caps the table page. + + **``STATUS`` also gets its GUID, and only from a sample.** A + ``STATUS_GUID`` is not searchable and no reference read returns one, but + every ticket's nested ``STATUS`` object carries it -- so with + ``with_guid`` on (the default) discovering ``STATUS`` additionally + sweeps tickets, reads ``record["STATUS"]["STATUS_GUID"]``, and merges + each guid onto the matching id. That costs one extra ticket sweep even + under ``strategy="reference"``; pass ``with_guid=False`` to skip it. The + GUID is what :meth:`set_status` and :meth:`close_ticket` address a + status by -- a ``STATUS_ID`` will not work there -- so this is usually + the value you came for. A status no sampled ticket currently holds keeps + ``guid=None``: the sample cannot reach it. + + Everything returned is per-deployment configuration. Ids are not + portable; ``8`` is *Cloture* and ``12`` is *En cours* on the verified + instance -- adjacent numbers, opposite meanings. Resolve at start-up and + fail loudly, do not freeze a constant. + """ + if strategy not in ("auto", "reference", "sample"): + raise ValueError( + f"strategy={strategy!r} is not one of 'auto', 'reference', " + "'sample'" + ) + source = resolve_source(name, reference_path=reference_path) + found: list[DiscoveredReference] = [] + + if strategy == "reference" and source.reference_path is None: + raise ValueError( + f"{source.name} has no reference route in this deployment's " + "OpenAPI paths, so strategy='reference' cannot read one. Pass " + "reference_path= if your deployment declares one, or use " + "strategy='sample' (which is what 'auto' does here)." + ) + + if strategy != "sample" and source.reference_path is not None: + try: + page = await self.list_reference_table( + source.reference_path, search=reference_search, max_rows=max_rows + ) + except EasyvistaAuthError: + if strategy == "reference": + raise + page = None + if page is not None: + found = [ + reference_from_table_row( + row.model_dump(by_alias=True), source, languages=languages + ) + for row in page.records + ] + + needs_sample = not found and strategy != "reference" + wants_guid = with_guid and bool(source.guid_field) + if needs_sample or wants_guid: + records = await self._sample_records( + source, + sample_size=sample_size, + search=search, + action_sample_tickets=action_sample_tickets, + languages=languages, + ) + if needs_sample: + found = references_from_sample( + records, source, languages=languages + ) + if wants_guid: + found = merge_guids(found, guids_from_sample(records, source)) + return found + + async def describe_instance( + self, + *, + names: Sequence[str] = DEFAULT_DISCOVERY_NAMES, + strategy: str = "auto", + reference_paths: Mapping[str, str] | None = None, + sample_size: int = 200, + action_sample_tickets: int = 5, + search: str | None = None, + max_rows: int | None = None, + include_spec: bool = True, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, + ) -> InstanceProfile: + """Profile this deployment: its version, its routes, and its reference ids. + + One call that answers "what do I have to pass to create a ticket + *here*". Returns an :class:`InstanceProfile`; read its docstring for what + discovery cannot reach, which is as important as what it can. + + **No part can fail the whole.** Each fetch is attempted independently; an + EasyVista error is recorded in ``.unavailable`` -- keyed by ``"spec"`` or + by the reference name, with a first token of ``denied``, ``failed``, + ``no-route``, ``empty`` or ``truncated`` -- and the remaining parts still + run. Nothing is invented for a part that failed: its entry in + ``.references`` is an empty list, and the reason is in ``.unavailable``. + Read that dict before concluding an instance has no statuses; a total + outage looks exactly like a bare instance except that every gap is named. + + Only ``EasyvistaError`` and its subclasses are caught. A bug inside this + package therefore still propagates rather than being buried as a fake + instance limitation. + + **It samples once, not once per name.** Every name that needs sampling is + projected into a single ticket sweep of at most ``sample_size`` records, + and the names that live on actions come from one action sweep over the + first ``action_sample_tickets`` of those tickets. So the cost is roughly + one spec read, one read per declared reference table, and two short + sweeps -- all GETs, nothing written. + + ``reference_paths`` overrides individual routes by name (e.g. + ``{"URGENCY": "urgencies"}``); ``names`` narrows the work; + ``include_spec=False`` skips the OpenAPI read, at the cost of leaving + ``version`` and ``spec_paths`` empty. Every default is the value that + works on the verified instance today. + """ + overrides = dict(reference_paths or {}) + unavailable: dict[str, str] = {} + version: str | None = None + spec_paths: tuple[str, ...] = () + + if include_spec: + try: + document = await self.get_api_spec() + except EasyvistaError as exc: + unavailable["spec"] = _unavailable_reason(exc) + else: + info = document.get("info") + if isinstance(info, dict): + description = info.get("description") or info.get("title") + version = description if isinstance(description, str) else None + paths = document.get("paths") + if isinstance(paths, dict): + spec_paths = tuple(str(p) for p in paths) + + sources = [ + resolve_source(name, reference_path=overrides.get(name.strip().upper())) + for name in names + ] + + # One sweep per surface, shared by every name that needs it, rather than + # one sweep per name -- which would be eleven. + ticket_projection: list[str] = [] + action_projection: list[str] = [] + for source in sources: + projection = sample_fields(source, languages=languages) + target = ( + action_projection + if source.sample_from == "actions" + else ticket_projection + ) + target.extend(c for c in projection if c not in target) + + tickets: list[dict[str, Any]] = [] + actions: list[dict[str, Any]] = [] + rfcs: list[str] = [] + try: + tickets = [ + t.model_dump(by_alias=True) + async for t in self.iter_tickets( + search=search, fields=ticket_projection, max_records=sample_size + ) + ] + rfcs = [ + str(t["RFC_NUMBER"]) + for t in tickets[: max(action_sample_tickets, 0)] + if t.get("RFC_NUMBER") + ] + except EasyvistaError as exc: + unavailable["sample:tickets"] = _unavailable_reason(exc) + + if action_projection and rfcs: + try: + for rfc in rfcs: + actions.extend( + [ + a.model_dump(by_alias=True) + async for a in self.iter_actions( + rfc, fields=action_projection, max_records=sample_size + ) + ] + ) + except EasyvistaError as exc: + unavailable["sample:actions"] = _unavailable_reason(exc) + + references: dict[str, list[DiscoveredReference]] = {} + for source in sources: + found: list[DiscoveredReference] = [] + if source.reference_path is None: + unavailable[source.name] = ( + "no-route this deployment's OpenAPI declares no list route " + "for it, so the ids below are only those in use in the " + "sample" + ) + elif strategy != "sample": + try: + page = await self.list_reference_table( + source.reference_path, max_rows=max_rows + ) + except EasyvistaError as exc: + unavailable[source.name] = _unavailable_reason(exc) + else: + found = [ + reference_from_table_row( + row.model_dump(by_alias=True), source, languages=languages + ) + for row in page.records + ] + if page.total_record_count > page.record_count: + unavailable[source.name] = ( + f"truncated {page.record_count} of " + f"{page.total_record_count} rows read" + ) + if not found: + records = actions if source.sample_from == "actions" else tickets + found = references_from_sample(records, source, languages=languages) + if source.guid_field: + found = merge_guids(found, guids_from_sample(tickets, source)) + if not found and source.name not in unavailable: + unavailable[source.name] = ( + "empty the read succeeded and returned nothing" + ) + references[source.name] = found + + return InstanceProfile( + api_root=self.config.api_root, + version=version, + spec_paths=spec_paths, + references=references, + unavailable=unavailable, + ) + async def get_ticket_context( - self, rfc_number: str, *, resolve_action_bodies: bool = True + self, + rfc_number: str, + *, + resolve_action_bodies: bool = True, + memo_fields: Sequence[str] = ("description", "comment"), ) -> TicketContext: """Fetch a ticket plus its resolved narrative content as a bundle. @@ -565,67 +1864,109 @@ async def get_ticket_context( profile-restricted lists (403) degrade to ``None`` / ``[]`` rather than failing the whole call. + ``memo_fields`` names which Memo sub-resources to resolve, defaulting to + the two EasyVista populates by default. The API models the memo name as + a path segment (``GET /requests/{rfc}/{memo}``), so an instance + configured with a different body memo is reached by naming it here + (tier 2 -- ``docs/vendor-api-reference.md``: declared in the instance's + OpenAPI ``paths``). Every resolved memo lands in + :attr:`TicketContext.memos`; ``description`` and ``comment`` + additionally keep their own attributes, and are ``None`` when not + requested. + + Pass a tuple or list, not a bare string: ``str`` itself satisfies + ``Sequence[str]``, so ``memo_fields="solution"`` type-checks and then + iterates individual characters, issuing one nonsense sub-resource + request per letter instead of the one you meant. + ``resolve_action_bodies`` (default on) additionally fetches each action item-level and resolves its note text, because ``list_actions`` does not return it — without this the rendered Markdown has empty action bodies. It costs two extra requests per action; pass ``False`` to skip it when you only need the action list. - On the async surface the independent requests (the two memos plus the - actions and documents lists) are issued concurrently, in up to three - waves; on the sync surface they run one after another in source order, - costing ``4 + 2N`` serial round trips for a ticket with ``N`` actions. - Measured against a live instance on a 19-action ticket: 5.31s - concurrent, 14.65s serial. - - Peak in-flight on the async surface is four sub-resource requests, then - up to ``_ACTION_FANOUT`` action-body resolutions; the sync surface - issues one request at a time throughout. On a hard failure (5xx, a - transport error) siblings already in flight on the async surface run to - completion before the error propagates, so a failing call there can - issue more requests than the sequential surface does. That is the - deliberate trade: settling every sibling is what keeps an orphaned - request from outliving the call that issued it, and what makes the - exception a caller sees the one the sequential surface would have - raised rather than whichever branch happened to fail soonest. + **The action log is capped at one page.** It comes from + :meth:`list_actions`, which returns at most ``config.default_max_rows`` + actions and does not paginate, so on a busy ticket + :attr:`TicketContext.actions` — and therefore + :meth:`TicketContext.to_markdown`'s rendered log — is silently truncated + with no error. Raise ``default_max_rows`` if completeness matters. + + On the async surface the independent requests (the requested memos + plus the actions and documents lists) are issued concurrently, in up + to three waves; on the sync surface they run one after another in + source order, costing ``len(memo_fields) + 2 + 2N`` serial round + trips for a ticket with ``N`` actions. Measured against a live + instance on a 19-action ticket with the default two-memo + ``memo_fields``: 5.31s concurrent, 14.65s serial. + + Peak in-flight on the async surface is ``len(memo_fields) + 2`` + sub-resource requests, then up to ``_ACTION_FANOUT`` action-body + resolutions; the sync surface issues one request at a time + throughout. On a hard failure (5xx, a transport error) siblings + already in flight on the async surface run to completion before the + error propagates, so a failing call there can issue more requests + than the sequential surface does. That is the deliberate trade: + settling every sibling is what keeps an orphaned request from + outliving the call that issued it, and what makes the exception a + caller sees the one the sequential surface would have raised rather + than whichever branch happened to fail soonest. """ # Issued first, and deliberately outside the fan-out: this is the one # call with no fallback, so a wrong RFC number should cost one request, # not five. ticket = await self.get_ticket(rfc_number) + degraded: set[str] = set() + # The asymmetry between these two except clauses is real and # load-bearing: the memos degrade on 404 *and* 403, while the two list # calls catch EasyvistaAuthError ONLY, so a 404 there still fails the - # bundle. Do not tidy them into a shared handler. + # bundle. Do not tidy them into a shared handler. Each records its own + # swallow inside its own clause, which keeps that asymmetry visible + # rather than hiding it behind a helper. async def _actions() -> list[Action]: try: return await self.list_actions(rfc_number) - except EasyvistaAuthError: + except EasyvistaAuthError as exc: + degraded.add(_degraded_entry("actions", exc)) return [] async def _documents() -> list[Document]: try: return await self.list_documents(rfc_number) - except EasyvistaAuthError: + except EasyvistaAuthError as exc: + degraded.add(_degraded_entry("documents", exc)) return [] - description, comment, actions, documents = await settle( - self._safe_memo(f"requests/{rfc_number}/description"), - self._safe_memo(f"requests/{rfc_number}/comment"), + memo_results = await settle( + *( + self._safe_memo( + f"requests/{rfc_number}/{name}", + degraded=degraded, + branch=f"memo:{name}", + ) + for name in memo_fields + ), _actions(), _documents(), ) + memos = dict( + zip(memo_fields, memo_results[: len(memo_fields)], strict=True) + ) + actions, documents = memo_results[len(memo_fields) :] if resolve_action_bodies: actions = await self._resolve_action_bodies(actions) return TicketContext( ticket=ticket, - description=description, - comment=comment, + description=memos.get("description"), + comment=memos.get("comment"), actions=actions, documents=documents, + memos=memos, + degraded=frozenset(degraded), ) async def _resolve_action_bodies(self, actions: list[Action]) -> list[Action]: @@ -660,7 +2001,14 @@ async def get_department_context( department_id: str | int, *, recent_tickets: int = 10, + recent_tickets_sort: str | None = RECENT_TICKETS_SORT, + ticket_fields: str | Sequence[str] | None = RECENT_TICKET_FIELDS, + employee_fields: str | Sequence[str] | None = None, + asset_fields: str | Sequence[str] | None = None, dimensions: Sequence[str] | None = None, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, + statistics_max_records: int | None = 100, + memo_fields: Sequence[str] = DEPARTMENT_NOTE_FIELDS, include_statistics: bool = True, include_assets: bool = True, resolve_manager: bool = True, @@ -668,13 +2016,64 @@ async def get_department_context( ) -> DepartmentContext: """Assemble a department plus its employees, manager, note, tickets and assets. - Only :meth:`get_department` is required; every related part is wrapped so a - 403/404 degrades it to ``[]`` / ``None`` / ``0`` (same pattern as - :meth:`get_ticket_context`). The flags trim the heavier related calls. - Tickets and assets filter on ``DEPARTMENT_ID:""``. ``recent_tickets`` - ordering is best-effort: it relies on the server honoring - ``RECENT_TICKETS_SORT`` (open item O-DIR-1) and silently degrades to the - API's default order otherwise. + Only :meth:`get_department` is required; every related part is wrapped + so a 403/404 degrades it to ``[]`` / ``None`` / ``0``, and records + itself in ``DepartmentContext.degraded`` so the degradation is visible + rather than silent. The flags trim the heavier related calls. Tickets + and assets filter on ``DEPARTMENT_ID:""``. + + Every value this method samples with is a keyword, and every default is + what it sampled with before -- with one exception, ``ticket_fields``. + + ``recent_tickets_sort`` is the sort token for the recent-ticket page, + defaulting to ``RECENT_TICKETS_SORT`` (``"RFC_NUMBER DESC"``). That is + **descending RFC_NUMBER**, which is newest-first only where RFC numbers + are issued monotonically: it is a varchar, so the sort orders by the + request-type prefix letter before the date, and on an instance issuing + more than one prefix every ``R...`` outranks every ``I...``. The default + is deliberately not a date column: an unhonoured sort token is silently + ignored by this API and degrades to the server's default order with no + error (see :meth:`iter_tickets`), so a date default would swap a + disclosed flaw for a hidden one, and ``RFC_NUMBER DESC`` is the one + token this repository has actually measured for this call. Pass + ``recent_tickets_sort="CREATION_DATE_UT DESC"`` on a deployment where + you have checked that a date sort is honoured, or ``None`` to send no + sort at all -- in which case "recent" means whatever order the server + returns. + + ``ticket_fields`` is the ``fields=`` projection for the recent tickets. + Its default, ``RECENT_TICKET_FIELDS``, **projects** -- unlike every + other default here, this is a change from the previous behaviour, and + it is deliberate. Sending no projection is not neutral: on the verified + instance the default list projection returns ``TITLE`` present but empty + (tier 4 -- measured on one instance, 400 tickets scanned, zero with a + populated title; it may not generalise), so ``recent_tickets[i].title`` + was ``None`` for every caller. Projecting also narrows what else comes + back: pass ``ticket_fields=None`` to send no projection, or your own + list to widen it. + + ``employee_fields`` and ``asset_fields`` project the employee and asset + sweeps; both default to ``None``, which sends no projection, as before. + + ``statistics_max_records`` caps the statistics sample and defaults to + 100 -- the cap ``ticket_statistics`` applies, which this call inherited + silently before. The sample is unsorted, so it is not the department's + first hundred tickets by any ordering; when it truncates, + ``ticket_statistics.truncated`` is ``True`` and ``population_total`` + carries the server's own count. ``ticket_count`` remains the true total + and is unaffected by this cap. Pass ``None`` to aggregate every ticket. + + ``memo_fields`` names which Memo sub-resources to resolve, defaulting to + ``("comment_department",)``. The API models a memo name as a path + segment (``GET departments/{id}/{memo}``) (tier 2 -- + ``docs/vendor-api-reference.md``: declared in the instance's OpenAPI + ``paths``), so a deployment carrying its directory note elsewhere is + reached by naming it here. Every resolved memo lands in + ``DepartmentContext.memos``; ``note`` is the first one that came back + with text. ``include_note=False`` skips all of them. Pass a tuple or + list, not a bare string: ``str`` itself satisfies ``Sequence[str]``, so + a bare name would be iterated one character at a time, one nonsense + request per letter. On the async surface the seven independent branches are issued concurrently, costing two waves instead of eight serial steps; on the @@ -692,14 +2091,23 @@ async def get_department_context( if search is None: raise ValueError("department_id is required to build a department context") + degraded: set[str] = set() + ticket_projection = _as_fields(ticket_fields) + # Every branch here degrades on both 403 and 404, unlike the ticket # bundle above, whose two list calls catch EasyvistaAuthError only. The # `include_*` / `resolve_*` flags sit inside the branch so a disabled one # costs no request at all, exactly as a plain `if` around the call would. async def _employees() -> list[Employee]: try: - return [e async for e in self.iter_employees(search=search)] - except (EasyvistaAuthError, EasyvistaNotFound): + return [ + e + async for e in self.iter_employees( + search=search, fields=_as_fields(employee_fields) + ) + ] + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("employees", exc)) return [] async def _manager() -> Employee | None: @@ -707,20 +2115,27 @@ async def _manager() -> Employee | None: return None try: return await self.get_employee(department.manager_id) - except (EasyvistaAuthError, EasyvistaNotFound): + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("manager", exc)) return None - async def _note() -> str | None: + async def _memos() -> dict[str, str | None]: if not include_note: - return None - return await self._safe_memo( - f"departments/{department_id}/comment_department" - ) + return {} + return { + name: await self._safe_memo( + f"departments/{department_id}/{name}", + degraded=degraded, + branch=f"memo:{name}", + ) + for name in memo_fields + } async def _ticket_count() -> int: try: return await self.count_tickets(search=search) - except (EasyvistaAuthError, EasyvistaNotFound): + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("ticket_count", exc)) return 0 async def _recent() -> list[Request]: @@ -729,11 +2144,13 @@ async def _recent() -> list[Request]: t async for t in self.iter_tickets( search=search, - sort=RECENT_TICKETS_SORT, + fields=ticket_projection, + sort=recent_tickets_sort, max_records=recent_tickets, ) ] - except (EasyvistaAuthError, EasyvistaNotFound): + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("recent_tickets", exc)) return [] async def _statistics() -> TicketStatistics | None: @@ -741,23 +2158,33 @@ async def _statistics() -> TicketStatistics | None: return None try: return await self.ticket_statistics( - search=search, dimensions=dimensions + search=search, + dimensions=dimensions, + languages=languages, + max_records=statistics_max_records, ) - except (EasyvistaAuthError, EasyvistaNotFound): + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("statistics", exc)) return None async def _assets() -> list[Asset]: if not include_assets: return [] try: - return [a async for a in self.iter_assets(search=search)] - except (EasyvistaAuthError, EasyvistaNotFound): + return [ + a + async for a in self.iter_assets( + search=search, fields=_as_fields(asset_fields) + ) + ] + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("assets", exc)) return [] ( employees, manager, - note, + memos, ticket_count, recent, statistics, @@ -765,13 +2192,14 @@ async def _assets() -> list[Asset]: ) = await settle( _employees(), _manager(), - _note(), + _memos(), _ticket_count(), _recent(), _statistics(), _assets(), ) + note = next((text for text in memos.values() if text), None) return DepartmentContext( department=department, employees=employees, @@ -781,10 +2209,27 @@ async def _assets() -> list[Asset]: recent_tickets=recent, ticket_statistics=statistics, assets=assets, + memos=memos, + degraded=frozenset(degraded), ) - async def _safe_memo(self, path: str) -> str | None: + async def _safe_memo( + self, + path: str, + *, + degraded: set[str] | None = None, + branch: str = "", + ) -> str | None: + """Resolve a Memo, degrading a 403/404 to ``None``. + + When ``degraded`` is given, a swallowed failure is recorded there as + ``":"`` so the bundle can report it. Left at ``None`` + for the action-body resolution, where a missing note is not a section + the caller could be misled about. + """ try: return await self.resolve_memo(path) - except (EasyvistaNotFound, EasyvistaAuthError): + except (EasyvistaNotFound, EasyvistaAuthError) as exc: + if degraded is not None: + degraded.add(_degraded_entry(branch or path, exc)) return None diff --git a/easyvista_python_client/_async/tests/test_client.py b/easyvista_python_client/_async/tests/test_client.py index 5615403..86fab01 100644 --- a/easyvista_python_client/_async/tests/test_client.py +++ b/easyvista_python_client/_async/tests/test_client.py @@ -10,17 +10,27 @@ which is hand-written on both sides and never generated. """ +import dataclasses import json +from collections.abc import AsyncIterator import httpx +import pydantic import pytest import respx from easyvista_python_client._async import client as client_module from easyvista_python_client._async.client import AsyncEasyvistaClient +from easyvista_python_client.config import EasyvistaConfig from easyvista_python_client.directory import DepartmentContext -from easyvista_python_client.exceptions import EasyvistaError -from easyvista_python_client.models.action import PostAction +from easyvista_python_client.exceptions import ( + EasyvistaAuthError, + EasyvistaError, + EasyvistaNotFound, + EasyvistaServerError, + EasyvistaValidationError, +) +from easyvista_python_client.models.action import ActionUpdate, PostAction from easyvista_python_client.models.asset import PostAsset from easyvista_python_client.models.department import ( Department, @@ -110,7 +120,7 @@ async def test_update_and_close_ticket(config): return_value=httpx.Response(200, json={"records": [{"RFC_NUMBER": "I1"}]}) ) async with AsyncEasyvistaClient(config) as client: - await client.update_ticket("I1", RequestUpdate(status_id=4)) + await client.update_ticket("I1", RequestUpdate(impact_id=4)) await client.close_ticket("I1", comment="resolved") @@ -136,12 +146,46 @@ async def test_create_and_list_actions(config): return_value=httpx.Response(200, json={"records": [{"ACTION_ID": 5}]}) ) async with AsyncEasyvistaClient(config) as client: - action = await client.create_action("I1", PostAction(description="hi")) + action = await client.create_action( + "I1", PostAction(action_type_id=94, group_id=3, description="hi") + ) listed = await client.list_actions("I1") assert action.action_id == 5 assert listed[0].action_id == 5 +@respx.mock +async def test_list_actions_forwards_a_fields_projection(config): + """The client must pass fields= through, not just accept it. + + EV-R3: fields= is what turns comment metadata into one request per ticket + instead of one request per action. + """ + route = respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + async with AsyncEasyvistaClient(config) as client: + await client.list_actions("I240101_0001", fields=["ACTION_ID", "LAST_UPDATE"]) + assert route.calls.last.request.url.params["fields"] == "ACTION_ID,LAST_UPDATE" + + +@respx.mock +async def test_list_actions_sends_the_configured_row_cap(config): + """``list_actions`` returns one page, so the cap must be the client's own. + + Without this the request carried no ``max_rows`` at all and the truncation + point was the server's unstated default -- invisible to the caller and not + raisable by configuration. + """ + route = respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + async with AsyncEasyvistaClient(config) as client: + expected = str(client.config.default_max_rows) + await client.list_actions("I240101_0001") + assert route.calls.last.request.url.params["max_rows"] == expected + + @respx.mock async def test_get_action_fetches_the_item_level_record(config): respx.get(f"{ROOT}/actions/52990").mock( @@ -159,6 +203,18 @@ async def test_get_action_fetches_the_item_level_record(config): assert action.description == {"HREF": f"{ROOT}/actions/52990/description"} +@respx.mock +async def test_update_action_sends_a_put_to_the_top_level_path(config): + """The nested requests/{rfc}/actions/{id} form returns 403 (verified live).""" + route = respx.put(f"{ROOT}/actions/57483").mock( + return_value=httpx.Response(200, json={"ACTION_ID": 57483}) + ) + async with AsyncEasyvistaClient(config) as client: + action = await client.update_action(57483, ActionUpdate(description="edited")) + assert json.loads(route.calls.last.request.content) == {"description": "edited"} + assert action.action_id == 57483 + + @respx.mock async def test_from_env_constructs_working_client(monkeypatch): monkeypatch.setenv("EASYVISTA_URL", "https://ev.test") @@ -257,6 +313,95 @@ async def test_add_and_list_documents(config): assert body["documents"][0]["filename"] == "a.txt" +@respx.mock +async def test_delete_document_sends_a_delete_to_the_nested_path(config): + """Returns None: the API answers a delete with an empty body.""" + route = respx.delete(f"{ROOT}/requests/I240101_0001/documents/12345_abcdef").mock( + return_value=httpx.Response(200) + ) + async with AsyncEasyvistaClient(config) as client: + result = await client.delete_document("I240101_0001", "12345_abcdef") + assert route.call_count == 1 + assert result is None + + +@respx.mock +async def test_delete_document_top_level_path_style_addresses_the_document_by_id( + config, +): + """Both routes are declared in the instance's own OpenAPI document. + + Only the top-level one is marked ``deprecated`` there, so which one works + is a profile question rather than a routing one. ``rfc_number`` has no slot + in this route, hence ``None``. + """ + route = respx.delete(f"{ROOT}/documents/12345_abcdef").mock( + return_value=httpx.Response(200) + ) + async with AsyncEasyvistaClient(config) as client: + await client.delete_document(None, "12345_abcdef", path_style="top_level") + assert route.call_count == 1 + + +@respx.mock +async def test_delete_document_takes_its_path_style_from_the_config(config): + """The keyword defaults to the config field, so a deployment sets it once.""" + route = respx.delete(f"{ROOT}/documents/12345_abcdef").mock( + return_value=httpx.Response(200) + ) + top_level = dataclasses.replace(config, document_delete_path_style="top_level") + async with AsyncEasyvistaClient(top_level) as client: + await client.delete_document(None, "12345_abcdef") + assert route.call_count == 1 + + +@respx.mock +async def test_delete_document_accepts_a_document_record(config): + """The id is read off the record, so a caller need not unpack it.""" + route = respx.delete(f"{ROOT}/requests/I240101_0001/documents/12345_abcdef").mock( + return_value=httpx.Response(200) + ) + document = Document.model_validate({"DOCUMENT_ID": "12345_abcdef"}) + async with AsyncEasyvistaClient(config) as client: + await client.delete_document("I240101_0001", document) + assert route.call_count == 1 + + +@respx.mock +async def test_delete_document_rejects_a_document_without_an_id(config): + """A record carrying no DOCUMENT_ID would address the collection.""" + route = respx.delete(f"{ROOT}/requests/I240101_0001/documents/").mock( + return_value=httpx.Response(200) + ) + document = Document.model_validate({"DOCUMENT": "report.pdf"}) + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(ValueError, match="DOCUMENT_ID"): + await client.delete_document("I240101_0001", document) + assert route.call_count == 0 + + +@respx.mock +async def test_get_department_comment_honours_a_memo_field_override(config): + """The route's last segment is a memo-field selector, not a literal. + + In the instance OpenAPI document read 2026-08-27 it is a path *parameter* + named ``comment``, and the sibling ``GET requests/{rfc_number}/{comment}`` + describes the same parameter as "Memo field type, could be comment, + description". So a deployment whose department memo column is named + differently is not locked out. + """ + default_route = respx.get(f"{ROOT}/departments/60/comment_department").mock( + return_value=httpx.Response(200, json={"COMMENT_DEPARTMENT": "default"}) + ) + override = respx.get(f"{ROOT}/departments/60/comment_service").mock( + return_value=httpx.Response(200, json={"COMMENT_SERVICE": "overridden"}) + ) + async with AsyncEasyvistaClient(config) as client: + await client.get_department_comment(60, memo_field="comment_service") + assert override.call_count == 1 + assert default_route.call_count == 0 + + @respx.mock async def test_download_document_fetches_the_ddl_href(config): route = respx.get("https://ev.test/dl/7").mock( @@ -287,6 +432,130 @@ async def test_download_document_refuses_a_foreign_download_url(config): await client.download_document(doc) +@respx.mock +async def test_stream_document_chunks_reassemble_to_the_download(config): + # 3076 bytes at chunk_size=512: six full chunks and a 4-byte tail. Sized off + # the boundary on purpose -- an exact multiple never exercises a short final + # chunk, and reassembly passes either way. + body = bytes(range(256)) * 12 + b"tail" + respx.get("https://ev.test/dl/7").mock( + return_value=httpx.Response(200, content=body) + ) + doc = Document.model_validate( + {"DOCUMENT": "big.bin", "DDL_HREF": "https://ev.test/dl/7"} + ) + chunks = [] + async with AsyncEasyvistaClient(config) as client: + async for chunk in client.stream_document(doc, chunk_size=512): + chunks.append(chunk) + assert b"".join(chunks) == body + assert len(chunks) == 7, "the body arrived in one piece instead of streaming" + assert len(chunks[-1]) == 4, "the short final chunk was padded or dropped" + + +class _ClosableStream(httpx.AsyncByteStream): + """A response body that records when the transport closed it. + + ``closed`` flips in ``aclose()``/``close()``, which httpx calls when the + response is + released -- which is what ``stream_bytes`` does in its own ``finally``. So + the flag answers "has the connection gone back to the pool yet". + """ + + def __init__(self, body: bytes) -> None: + self._body = body + self.closed = False + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield self._body + + async def aclose(self) -> None: + self.closed = True + + +@respx.mock +async def test_stream_document_closes_the_inner_stream_when_stopped_early(config): + """Stopping early releases the connection there and then, not eventually. + + ``stream_document`` hands out chunks from an inner ``stream_bytes`` + generator, and only *that* generator's ``finally`` closes the response and + returns its connection to the httpx pool. Closing the outer generator + unwinds its loop with ``GeneratorExit`` and does not close the inner one, so + without the explicit close in ``stream_document`` the release waits for the + inner generator to be collected -- and this asserts with no ``gc`` round and + no scheduling hop in between. Until the release happens the connection stays + checked out, which a caller with a small ``max_connections`` feels. + + Closing explicitly is also the only moment both client trees share: a caller + that just stops iterating leaves the outer generator to be collected, and + when that happens is a property of the runtime, not of this method. + """ + stream = _ClosableStream(b"0123456789abcdef") + respx.get("https://ev.test/dl/7").mock( + return_value=httpx.Response(200, stream=stream) + ) + doc = Document.model_validate({"DDL_HREF": "https://ev.test/dl/7"}) + async with AsyncEasyvistaClient(config) as client: + chunks = client.stream_document(doc, chunk_size=8) + assert await chunks.__anext__() == b"01234567" + assert not stream.closed, "closed while the caller was still reading" + await chunks.aclose() + assert stream.closed, "the abandoned stream still holds its connection" + + +@respx.mock +async def test_stream_document_accepts_a_relative_path_like_download_document(config): + """Same accepted inputs as download_document, resolved the same way.""" + respx.get(f"{ROOT}/documents/7/content").mock( + return_value=httpx.Response(200, content=b"bytes") + ) + path = "documents/7/content" + async with AsyncEasyvistaClient(config) as client: + streamed = [chunk async for chunk in client.stream_document(path)] + downloaded = await client.download_document(path) + assert b"".join(streamed) == downloaded == b"bytes" + + +@respx.mock +async def test_stream_document_and_download_document_agree_on_a_403(config): + """One error mapping, not two: the streaming path must not soften a failure. + + Asserted as an equality between the paths rather than against a hardcoded + type, so the two cannot drift apart without this failing -- which is the + actual risk, since a streaming response needs its body read before the + mapping can look at it at all. + """ + respx.get("https://ev.test/dl/7").mock( + return_value=httpx.Response(403, json={"error": "forbidden"}) + ) + doc = Document.model_validate({"DDL_HREF": "https://ev.test/dl/7"}) + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(EasyvistaError) as streamed: + [chunk async for chunk in client.stream_document(doc)] + with pytest.raises(EasyvistaError) as downloaded: + await client.download_document(doc) + assert type(streamed.value) is type(downloaded.value) is EasyvistaAuthError + assert streamed.value.status_code == downloaded.value.status_code == 403 + assert streamed.value.ev_message == downloaded.value.ev_message == "forbidden" + + +async def test_stream_document_refuses_a_foreign_download_url(config): + # The same-origin guard covers the streaming path too: it is a property of + # the download, not of one method. Nothing is requested until iteration + # begins, so the refusal lands on the first step. + doc = Document.model_validate({"DDL_HREF": "https://attacker.test/dl/7"}) + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(EasyvistaError, match="outside the configured instance"): + [chunk async for chunk in client.stream_document(doc)] + + +async def test_stream_document_needs_a_download_url(config): + doc = Document.model_validate({"DOCUMENT": "orphan.txt"}) + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(ValueError, match="no download URL"): + [chunk async for chunk in client.stream_document(doc)] + + # --- pagination -------------------------------------------------------------- @@ -441,6 +710,100 @@ async def test_iter_departments_paginates(config): assert ids == [1, 2] +def _paged_actions_responder(request): + offset = int(request.url.params.get("offset", "0")) + if offset == 0: + return httpx.Response( + 200, + json={ + "records": [{"ACTION_ID": 1}, {"ACTION_ID": 2}], + "record_count": 2, + "total_record_count": 3, + "@next": f"{ROOT}/actions?offset=2&max_rows=2", + }, + ) + return httpx.Response( + 200, + json={ + "records": [{"ACTION_ID": 3}], + "record_count": 1, + "total_record_count": 3, + }, + ) + + +@respx.mock +async def test_iter_actions_crosses_the_page_list_actions_stops_at(config): + """``list_actions`` truncates a busy ticket's log silently; this does not.""" + respx.get(f"{ROOT}/actions").mock(side_effect=_paged_actions_responder) + async with AsyncEasyvistaClient(config) as client: + ids = [a.action_id async for a in client.iter_actions("I1", page_size=2)] + assert ids == [1, 2, 3] + + +@respx.mock +async def test_iter_actions_keeps_the_ticket_filter_on_every_page(config): + """A page-2 request that lost the filter would sweep every ticket's log.""" + route = respx.get(f"{ROOT}/actions").mock(side_effect=_paged_actions_responder) + async with AsyncEasyvistaClient(config) as client: + [a async for a in client.iter_actions("I1", page_size=2)] + assert len(route.calls) == 2 + for call in route.calls: + assert call.request.url.params["search"] == 'REQUEST.RFC_NUMBER:"I1"' + + +@respx.mock +async def test_iter_actions_respects_max_records(config): + route = respx.get(f"{ROOT}/actions").mock(side_effect=_paged_actions_responder) + async with AsyncEasyvistaClient(config) as client: + ids = [ + a.action_id + async for a in client.iter_actions("I1", page_size=2, max_records=1) + ] + assert ids == [1] + # Stops inside the first page once the cap is hit (no second request). + assert len(route.calls) == 1 + + +@respx.mock +async def test_iter_actions_stops_when_the_server_reports_no_next_page(config): + """No ``@next`` ends the sweep even on a page that filled ``page_size``.""" + route = respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response( + 200, + json={ + "records": [{"ACTION_ID": 1}, {"ACTION_ID": 2}], + "record_count": 2, + "total_record_count": 2, + }, + ) + ) + async with AsyncEasyvistaClient(config) as client: + ids = [a.action_id async for a in client.iter_actions("I1", page_size=2)] + assert ids == [1, 2] + assert len(route.calls) == 1 + + +@respx.mock +async def test_iter_actions_forwards_a_fields_projection(config): + route = respx.get(f"{ROOT}/actions").mock(side_effect=_paged_actions_responder) + async with AsyncEasyvistaClient(config) as client: + [ + a + async for a in client.iter_actions( + "I1", fields=["ACTION_ID", "ACTION_LABEL_FR"], page_size=2 + ) + ] + assert route.calls[0].request.url.params["fields"] == "ACTION_ID,ACTION_LABEL_FR" + + +async def test_iter_actions_refuses_a_blank_rfc(config): + """An unfiltered sweep of every ticket's actions is never the intent.""" + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(ValueError): + [a async for a in client.iter_actions("")] + + # --- statistics -------------------------------------------------------------- @@ -471,7 +834,14 @@ def _stats_responder(request): @respx.mock -async def test_ticket_statistics_aggregates_over_iter_tickets(config): +async def test_ticket_statistics_aggregates_across_pages(config): + """No longer routes through ``iter_tickets``. + + ``iter_tickets`` yields records one at a time and discards the envelope, so + it cannot also hand back ``total_record_count`` -- the one number that says + how large the population a capped aggregation sampled actually was. + ``_collect_tickets`` walks the same offsets and issues the same requests. + """ from easyvista_python_client import TicketStatistics respx.get(f"{ROOT}/requests").mock(side_effect=_stats_responder) @@ -480,14 +850,54 @@ async def test_ticket_statistics_aggregates_over_iter_tickets(config): assert isinstance(stats, TicketStatistics) assert stats.total == 3 assert stats.breakdowns["STATUS"] == {"Open": 2, "Closed": 1} + assert stats.truncated is False + assert stats.population_total == 3 + + +@respx.mock +async def test_ticket_statistics_passes_languages_through_to_the_aggregator(config): + # Proves the keyword reaches aggregate_tickets on the real dispatch path, + # not just in the pure function's own tests. + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, + json={ + "records": [ + { + "RFC_NUMBER": "I1", + "STATUS": {"STATUS_EN": "[Open]", "STATUS_FR": "Ouvert"}, + } + ], + "record_count": 1, + "total_record_count": 1, + }, + ) + ) + async with AsyncEasyvistaClient(config) as client: + default = await client.ticket_statistics(dimensions=["STATUS"]) + french = await client.ticket_statistics( + dimensions=["STATUS"], languages=("_FR",) + ) + # The bracketed English echo loses to the real French sibling by default... + assert default.breakdowns["STATUS"] == {"Ouvert": 1} + # ...and asking for French directly reaches the same column. + assert french.breakdowns["STATUS"] == {"Ouvert": 1} @respx.mock async def test_ticket_statistics_respects_max_records(config): + """The cap truncating is now disclosed rather than silent. + + That is the whole point of the two new fields: ``total == 1`` describes a + sample of one out of three, and before this a caller had no way to tell + that from a population of one. + """ respx.get(f"{ROOT}/requests").mock(side_effect=_stats_responder) async with AsyncEasyvistaClient(config) as client: stats = await client.ticket_statistics(dimensions=["STATUS"], max_records=1) assert stats.total == 1 # capped before the second page + assert stats.truncated is True + assert stats.population_total == 3 @respx.mock @@ -596,9 +1006,14 @@ async def test_find_departments_fast_path_by_code(config): @respx.mock -async def test_find_departments_fast_path_by_id(config): - # An all-digit name uses the DEPARTMENT_ID fast path (not DEPARTMENT_CODE) - # and returns on the first hit without ever falling back to iter_departments. +async def test_find_departments_auto_tries_code_before_id(config): + """An all-digit name is a candidate CODE before it is a candidate ID. + + It used to go straight to ``DEPARTMENT_ID``, so 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. Code first fixes that; the id lookup still + happens, one request later, when no such code exists. + """ route = respx.get(f"{ROOT}/departments").mock( return_value=httpx.Response( 200, @@ -611,8 +1026,58 @@ async def test_find_departments_fast_path_by_id(config): async with AsyncEasyvistaClient(config) as client: found = await client.find_departments("60") assert [d.department_id for d in found] == [60] - assert route.calls.last.request.url.params["search"] == 'DEPARTMENT_ID:"60"' + assert route.calls.last.request.url.params["search"] == 'DEPARTMENT_CODE:"60"' + assert route.call_count == 1 + + +@respx.mock +async def test_find_departments_auto_falls_back_to_id_for_an_all_digit_name(config): + """The regression guard for the wrong-record bug. + + When the digits are an id and not a code, the code lookup misses and the id + lookup runs -- one extra round trip, on a path the fast path only ever was + an optimization for. + """ + empty = httpx.Response(200, json={"records": [], "total_record_count": 0}) + hit = httpx.Response( + 200, + json={"records": [{"DEPARTMENT_ID": 60}], "total_record_count": 1}, + ) + route = respx.get(f"{ROOT}/departments").mock(side_effect=[empty, hit]) + async with AsyncEasyvistaClient(config) as client: + found = await client.find_departments("60") + assert [d.department_id for d in found] == [60] + assert route.call_count == 2 + searches = [call.request.url.params["search"] for call in route.calls] + assert searches == ['DEPARTMENT_CODE:"60"', 'DEPARTMENT_ID:"60"'] + + +@respx.mock +async def test_find_departments_by_pins_a_single_column(config): + """``by`` restores the old lookup exactly, or skips the fast path entirely. + + ``by="DEPARTMENT_ID"`` must be read as ONE column, not as eleven + single-character ones -- ``str`` satisfies ``Sequence[str]``, so the string + branch is checked first. + """ + route = respx.get(f"{ROOT}/departments").mock( + return_value=httpx.Response( + 200, + json={"records": [{"DEPARTMENT_ID": 60}], "total_record_count": 1}, + ) + ) + async with AsyncEasyvistaClient(config) as client: + found = await client.find_departments("60", by="DEPARTMENT_ID") + assert [d.department_id for d in found] == [60] assert route.call_count == 1 + assert route.calls.last.request.url.params["search"] == 'DEPARTMENT_ID:"60"' + + # `by=[]` skips the fast path: the only call is the fuzzy scan's own + # unfiltered sweep. + route.reset() + async with AsyncEasyvistaClient(config) as client: + await client.find_departments("60", by=[]) + assert "search" not in route.calls.last.request.url.params @respx.mock @@ -861,6 +1326,40 @@ async def test_get_ticket_context_degrades_on_missing_subresources(config): assert ctx.comment is None assert ctx.actions == [] assert ctx.documents == [] + # Each swallow is now recorded, so `[]` is distinguishable from "forbidden". + assert ctx.degraded == frozenset( + { + "memo:description:404", + "memo:comment:404", + "actions:403", + "documents:403", + } + ) + + +@respx.mock +async def test_get_ticket_context_still_raises_on_a_404_from_a_list_call(config): + """The asymmetry between the two except clauses is load-bearing. + + The memos degrade on 404 *and* 403, while the two list calls catch + ``EasyvistaAuthError`` ONLY -- so a 404 there still fails the bundle. Adding + the degraded-recording line inside each clause must not have widened either + caught tuple. This test reddens if a later tidy-up merges them. + """ + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + for memo in ("description", "comment"): + respx.get(f"{ROOT}/requests/I1/{memo}").mock( + return_value=httpx.Response(404, json={}) + ) + respx.get(f"{ROOT}/actions").mock(return_value=httpx.Response(404, json={})) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(EasyvistaNotFound): + await client.get_ticket_context("I1") @respx.mock @@ -963,54 +1462,139 @@ async def test_ticket_context_tolerates_an_unreadable_action(config): @respx.mock -async def test_ticket_context_tolerates_an_action_that_has_vanished(config): - # The 404 arm of the same except clause -- an action listed but deleted - # before we fetch it item-level. Without this case the clause could be - # narrowed to EasyvistaAuthError alone and the suite would stay green. - # The vanished action keeps its slot: degrading must never shorten a - # ticket's history behind the caller's back. +async def test_ticket_context_resolves_comment_when_description_is_empty(config): + # The EasyVista UI shows ONE text field per action, headed "comment or + # description": it renders DESCRIPTION and falls back to COMMENT only when + # DESCRIPTION is empty (measured in the UI 2026-09-01 on one instance, + # Service Manager 2025.3 -- one instance, one date, may not generalise). + # Resolving DESCRIPTION alone therefore loses the body of exactly the + # actions a human CAN read: this bundle must carry the COMMENT text. respx.get(f"{ROOT}/requests/I1").mock( return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) ) respx.get(f"{ROOT}/requests/I1/description").mock(return_value=httpx.Response(404)) respx.get(f"{ROOT}/requests/I1/comment").mock(return_value=httpx.Response(404)) respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"actions": [{"ACTION_ID": 9}]}) + ) + respx.get(f"{ROOT}/actions/9").mock( return_value=httpx.Response( - 200, json={"actions": [{"ACTION_ID": 7}, {"ACTION_ID": 8}]} + 200, + json={ + "ACTION_ID": 9, + "DESCRIPTION": {"HREF": f"{ROOT}/actions/9/description"}, + "COMMENT": {"HREF": f"{ROOT}/actions/9/comment"}, + }, ) ) - respx.get(f"{ROOT}/actions/7").mock(return_value=httpx.Response(404)) - respx.get(f"{ROOT}/actions/8").mock( - return_value=httpx.Response(200, json={"ACTION_ID": 8}) + # DESCRIPTION resolves EMPTY -- the shadow-fallback case. + respx.get(f"{ROOT}/actions/9/description").mock( + return_value=httpx.Response(200, json={"DESCRIPTION": ""}) + ) + comment = respx.get(f"{ROOT}/actions/9/comment").mock( + return_value=httpx.Response( + 200, json={"COMMENT": "what the user actually reads"} + ) ) respx.get(f"{ROOT}/requests/I1/documents").mock( return_value=httpx.Response(200, json={"Documents": []}) ) async with AsyncEasyvistaClient(config) as client: context = await client.get_ticket_context("I1") - assert [a.action_id for a in context.actions] == [7, 8] - assert context.actions[0].description is None + assert comment.call_count == 1 + assert context.actions[0].comment == "what the user actually reads" + # ...and it must survive into the rendered document, not just the model. + assert "what the user actually reads" in context.to_markdown() @respx.mock -async def test_ticket_context_keeps_an_action_that_has_no_id(config): - # A listed action with neither ACTION_ID nor a numeric HREF tail has - # nothing to fetch item-level, so it passes through untouched. Without the - # short-circuit the client would request `actions/None` (here: an unmocked - # route) instead of degrading. +async def test_ticket_context_does_not_fetch_comment_when_description_has_text(config): + # The third request is conditional: DESCRIPTION wins in the UI, so a + # populated description makes the COMMENT memo dead weight. Resolving it + # anyway would add one request per action on every ticket. respx.get(f"{ROOT}/requests/I1").mock( return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) ) respx.get(f"{ROOT}/requests/I1/description").mock(return_value=httpx.Response(404)) respx.get(f"{ROOT}/requests/I1/comment").mock(return_value=httpx.Response(404)) respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"actions": [{"ACTION_ID": 9}]}) + ) + respx.get(f"{ROOT}/actions/9").mock( return_value=httpx.Response( 200, - json={"actions": [{"HREF": f"{ROOT}/requests/I1"}, {"ACTION_ID": 8}]}, + json={ + "ACTION_ID": 9, + "DESCRIPTION": {"HREF": f"{ROOT}/actions/9/description"}, + "COMMENT": {"HREF": f"{ROOT}/actions/9/comment"}, + }, ) ) - item = respx.get(f"{ROOT}/actions/8").mock( - return_value=httpx.Response(200, json={"ACTION_ID": 8}) + respx.get(f"{ROOT}/actions/9/description").mock( + return_value=httpx.Response(200, json={"DESCRIPTION": "the visible note"}) + ) + comment = respx.get(f"{ROOT}/actions/9/comment").mock( + return_value=httpx.Response(200, json={"COMMENT": "shadowed, never rendered"}) + ) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"Documents": []}) + ) + async with AsyncEasyvistaClient(config) as client: + context = await client.get_ticket_context("I1") + assert comment.call_count == 0 + assert context.actions[0].description == "the visible note" + assert "shadowed, never rendered" not in context.to_markdown() + + +@respx.mock +async def test_ticket_context_tolerates_an_action_that_has_vanished(config): + # The 404 arm of the same except clause -- an action listed but deleted + # before we fetch it item-level. Without this case the clause could be + # narrowed to EasyvistaAuthError alone and the suite would stay green. + # The vanished action keeps its slot: degrading must never shorten a + # ticket's history behind the caller's back. + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + respx.get(f"{ROOT}/requests/I1/description").mock(return_value=httpx.Response(404)) + respx.get(f"{ROOT}/requests/I1/comment").mock(return_value=httpx.Response(404)) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response( + 200, json={"actions": [{"ACTION_ID": 7}, {"ACTION_ID": 8}]} + ) + ) + respx.get(f"{ROOT}/actions/7").mock(return_value=httpx.Response(404)) + respx.get(f"{ROOT}/actions/8").mock( + return_value=httpx.Response(200, json={"ACTION_ID": 8}) + ) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"Documents": []}) + ) + async with AsyncEasyvistaClient(config) as client: + context = await client.get_ticket_context("I1") + assert [a.action_id for a in context.actions] == [7, 8] + assert context.actions[0].description is None + + +@respx.mock +async def test_ticket_context_keeps_an_action_that_has_no_id(config): + # A listed action with neither ACTION_ID nor a numeric HREF tail has + # nothing to fetch item-level, so it passes through untouched. Without the + # short-circuit the client would request `actions/None` (here: an unmocked + # route) instead of degrading. + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + respx.get(f"{ROOT}/requests/I1/description").mock(return_value=httpx.Response(404)) + respx.get(f"{ROOT}/requests/I1/comment").mock(return_value=httpx.Response(404)) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response( + 200, + json={"actions": [{"HREF": f"{ROOT}/requests/I1"}, {"ACTION_ID": 8}]}, + ) + ) + item = respx.get(f"{ROOT}/actions/8").mock( + return_value=httpx.Response(200, json={"ACTION_ID": 8}) ) respx.get(f"{ROOT}/requests/I1/documents").mock( return_value=httpx.Response(200, json={"Documents": []}) @@ -1046,6 +1630,94 @@ def record(request): assert documents < actions_item +@respx.mock +async def test_get_ticket_context_resolves_the_two_default_memos(config): + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + respx.get(f"{ROOT}/requests/I1/description").mock( + return_value=httpx.Response(200, json={"DESCRIPTION": "body"}) + ) + respx.get(f"{ROOT}/requests/I1/comment").mock( + return_value=httpx.Response(200, json={"COMMENT": "note"}) + ) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"actions": []}) + ) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"documents": []}) + ) + + async with AsyncEasyvistaClient(config) as client: + ctx = await client.get_ticket_context("I1", resolve_action_bodies=False) + + assert ctx.memos == {"description": "body", "comment": "note"} + assert ctx.description == "body" + assert ctx.comment == "note" + + +@respx.mock +async def test_get_ticket_context_honours_custom_memo_fields(config): + """An instance whose body memo is neither default is reached by naming it.""" + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + solution = respx.get(f"{ROOT}/requests/I1/solution").mock( + return_value=httpx.Response(200, json={"SOLUTION": "fixed it"}) + ) + description = respx.get(f"{ROOT}/requests/I1/description").mock( + return_value=httpx.Response(200, json={"DESCRIPTION": "unused"}) + ) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"actions": []}) + ) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"documents": []}) + ) + + async with AsyncEasyvistaClient(config) as client: + ctx = await client.get_ticket_context( + "I1", resolve_action_bodies=False, memo_fields=("solution",) + ) + + assert ctx.memos == {"solution": "fixed it"} + assert ctx.description is None + assert ctx.comment is None + assert solution.called + # The default memos are not fetched when they were not asked for. + assert not description.called + + +@respx.mock +async def test_get_ticket_context_empty_memo_fields_skips_memo_resolution(config): + """The empty-tuple boundary: no memo sub-resource is requested at all. + + Pins the ``settle`` slicing arithmetic -- ``memo_results[: len(memo_fields)]`` + and ``memo_results[len(memo_fields) :]`` -- at ``len(memo_fields) == 0``, so a + future rewrite of that slicing cannot silently break this case. + """ + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"actions": []}) + ) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"documents": []}) + ) + + async with AsyncEasyvistaClient(config) as client: + ctx = await client.get_ticket_context( + "I1", resolve_action_bodies=False, memo_fields=() + ) + + assert ctx.memos == {} + assert ctx.description is None + assert ctx.comment is None + assert ctx.actions == [] + assert ctx.documents == [] + + # --- department context ------------------------------------------------------ @@ -1091,6 +1763,175 @@ async def test_get_department_context_full_assembly(config): assert ctx.ticket_statistics is not None +@respx.mock +async def test_get_department_context_honours_memo_fields(config): + """``memo_fields`` threads the same memo selector through the bundle. + + A sequence rather than a single name, mirroring + :meth:`get_ticket_context`'s own ``memo_fields``: every resolved memo lands + in ``memos`` and ``note`` is the first with text. The read stays wrapped in + the bundle's 403/404 degradation, unlike :meth:`get_department_comment`, + which raises -- swapping that would change the bundle's failure semantics, + not just its route. + """ + respx.get(f"{ROOT}/departments/60").mock( + return_value=httpx.Response(200, json={"records": [{"DEPARTMENT_ID": 60}]}) + ) + respx.get(f"{ROOT}/employees").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + respx.get(f"{ROOT}/assets").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + default_route = respx.get(f"{ROOT}/departments/60/comment_department").mock( + return_value=httpx.Response(200, json={"COMMENT_DEPARTMENT": "default"}) + ) + override = respx.get(f"{ROOT}/departments/60/comment_service").mock( + return_value=httpx.Response(200, json={"COMMENT_SERVICE": "overridden"}) + ) + async with AsyncEasyvistaClient(config) as client: + ctx = await client.get_department_context( + 60, resolve_manager=False, memo_fields=("comment_service",) + ) + assert ctx.note == "overridden" + assert ctx.memos == {"comment_service": "overridden"} + assert override.call_count == 1 + assert default_route.call_count == 0 + + +def _department_bundle_mocks(ticket_route_json=None): + """Mock every branch of the department bundle with an empty-but-valid page. + + Returns the ``/requests`` route so a caller can inspect the parameters the + recent-tickets and statistics sweeps sent. + """ + respx.get(f"{ROOT}/departments/60").mock( + return_value=httpx.Response(200, json={"records": [{"DEPARTMENT_ID": 60}]}) + ) + respx.get(f"{ROOT}/employees").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + respx.get(f"{ROOT}/assets").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + respx.get(f"{ROOT}/departments/60/comment_department").mock( + return_value=httpx.Response(200, json={"COMMENT_DEPARTMENT": "note"}) + ) + return respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, + json=ticket_route_json + or {"records": [], "record_count": 0, "total_record_count": 0}, + ) + ) + + +@respx.mock +async def test_get_department_context_projects_and_sorts_recent_tickets(config): + """The one deliberate default change, pinned at the request level. + + Sending no projection is not neutral: on the verified instance the default + list projection returns TITLE present but EMPTY, so every recent ticket's + ``.title`` was ``None`` for every caller. The default now projects. + """ + route = _department_bundle_mocks() + async with AsyncEasyvistaClient(config) as client: + await client.get_department_context(60, resolve_manager=False) + sweeps = [ + call.request.url.params + for call in route.calls + if call.request.url.params.get("sort") + ] + assert len(sweeps) == 1 + assert sweeps[0]["sort"] == "RFC_NUMBER DESC" + assert "TITLE" in sweeps[0]["fields"] + + +@respx.mock +async def test_get_department_context_forwards_a_caller_projection_and_sort(config): + """``ticket_fields=None`` restores the exact previous request.""" + route = _department_bundle_mocks() + async with AsyncEasyvistaClient(config) as client: + await client.get_department_context( + 60, + resolve_manager=False, + include_statistics=False, + ticket_fields=["RFC_NUMBER"], + recent_tickets_sort=None, + ) + params = route.calls.last.request.url.params + assert params["fields"] == "RFC_NUMBER" + assert "sort" not in params + + route.reset() + async with AsyncEasyvistaClient(config) as client: + await client.get_department_context( + 60, + resolve_manager=False, + include_statistics=False, + ticket_fields=None, + ) + assert "fields" not in route.calls.last.request.url.params + + +@respx.mock +async def test_get_department_context_caps_the_statistics_sample(config): + """``statistics_max_records`` was inherited silently from + ``ticket_statistics``; it is a keyword now, and the truncation is + disclosed.""" + _department_bundle_mocks( + { + "records": [{"RFC_NUMBER": "I1"}, {"RFC_NUMBER": "I2"}], + "record_count": 2, + "total_record_count": 2, + } + ) + async with AsyncEasyvistaClient(config) as client: + ctx = await client.get_department_context( + 60, resolve_manager=False, statistics_max_records=1, dimensions=["STATUS"] + ) + assert ctx.ticket_statistics is not None + assert ctx.ticket_statistics.total == 1 + assert ctx.ticket_statistics.truncated is True + assert ctx.ticket_statistics.population_total == 2 + + +@pytest.mark.parametrize("status", [403, 404]) +@respx.mock +async def test_get_department_context_records_degraded_branches(config, status): + """A swallowed 403 used to be indistinguishable from an empty result. + + The department bundle degrades on both 403 and 404, so both are recorded. + Entries are ``":"`` and a memo branch is itself + ``"memo:"``, which is why they must be split with ``rsplit``. + """ + respx.get(f"{ROOT}/departments/60").mock( + return_value=httpx.Response(200, json={"records": [{"DEPARTMENT_ID": 60}]}) + ) + for path in ("employees", "requests", "assets"): + respx.get(f"{ROOT}/{path}").mock(return_value=httpx.Response(status)) + respx.get(f"{ROOT}/departments/60/comment_department").mock( + return_value=httpx.Response(status) + ) + async with AsyncEasyvistaClient(config) as client: + ctx = await client.get_department_context(60, resolve_manager=False) + for branch in ( + "employees", + "recent_tickets", + "assets", + "ticket_count", + "statistics", + "memo:comment_department", + ): + assert f"{branch}:{status}" in ctx.degraded + # The bundle still assembles; degradation is reported, not raised. + assert ctx.department.department_id == 60 + assert ctx.note is None + + @pytest.mark.parametrize("status", [403, 404]) @respx.mock async def test_get_department_context_degrades_on_403_and_404(config, status): @@ -1246,3 +2087,514 @@ async def test_get_department_context_rejects_blank_department_id(config): with pytest.raises(ValueError, match="department_id is required"): await client.get_department_context(department_id) assert employees_route.call_count == 0 + + +# --- the escape hatch -------------------------------------------------------- + + +@respx.mock +async def test_send_reaches_an_unwrapped_route_and_returns_raw_json(config): + # `status` is a reference table this package does not wrap. Nothing is + # validated into a model and no envelope is unwrapped: the caller owns the + # shape, which is the point. + route = respx.get(f"{ROOT}/status").mock( + return_value=httpx.Response(200, json={"records": [{"STATUS_ID": 8}]}) + ) + async with AsyncEasyvistaClient(config) as client: + assert await client.send("GET", "status") == {"records": [{"STATUS_ID": 8}]} + assert route.call_count == 1 + + +@respx.mock +async def test_send_upper_cases_the_method_and_strips_a_leading_slash(config): + route = respx.get(f"{ROOT}/status").mock(return_value=httpx.Response(200, json={})) + async with AsyncEasyvistaClient(config) as client: + await client.send("get", "/status") + assert route.call_count == 1 + + +@respx.mock +async def test_send_shares_the_error_mapping_and_retry_policy(config): + # Proves it rides the one transport path rather than a parallel one: 403 + # maps, 590 maps and is NOT retried, 5xx IS retried. + respx.get(f"{ROOT}/known-errors").mock(return_value=httpx.Response(403)) + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(EasyvistaAuthError): + await client.send("GET", "known-errors") + + rejected = respx.post(f"{ROOT}/problems").mock( + return_value=httpx.Response(590, json={"error": "nope", "error_code": 2013}) + ) + retried = respx.get(f"{ROOT}/licenses").mock(return_value=httpx.Response(503)) + retrying = EasyvistaConfig( + server="https://ev.test", account="acme", token="tok", max_retries=2 + ) + async with AsyncEasyvistaClient(retrying) as client: + with pytest.raises(EasyvistaValidationError): + await client.send("POST", "problems", json={}) + with pytest.raises(EasyvistaServerError): + await client.send("GET", "licenses") + assert rejected.call_count == 1 # 590 is deterministic, never retried + assert retried.call_count == 3 # 1 attempt + 2 retries + + +@respx.mock +async def test_send_puts_a_bare_list_body_on_the_wire(config): + # Pins the RequestSpec.json widening to Any: some unwrapped routes take a + # bare list, and httpx accepts anything JSON-serialisable. + route = respx.post(f"{ROOT}/groups").mock(return_value=httpx.Response(200, json={})) + async with AsyncEasyvistaClient(config) as client: + await client.send("POST", "groups", json=[{"a": 1}]) + assert json.loads(route.calls.last.request.content) == [{"a": 1}] + + +@respx.mock +async def test_send_never_reaches_a_foreign_host(config): + # The credential stays scoped to the configured instance BY CONSTRUCTION: + # `path` is always joined to api_root, never treated as an absolute URL. So + # an absolute URL becomes a nonsense path under the instance rather than a + # request to the host it names -- and the token never leaves the instance. + foreign = respx.get("https://attacker.test/steal").mock( + return_value=httpx.Response(200, json={"stolen": True}) + ) + joined = respx.get( + f"{ROOT}/https://attacker.test/steal", + ).mock(return_value=httpx.Response(404, json={})) + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(EasyvistaError): + await client.send("GET", "https://attacker.test/steal") + assert foreign.call_count == 0 + assert joined.call_count == 1 + + +# --- per-call query parameters ----------------------------------------------- + + +@respx.mock +async def test_search_tickets_sends_params_alongside_the_builders_own(config): + route = respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + async with AsyncEasyvistaClient(config) as client: + await client.search_tickets(params={"formatDate": "iso"}) + url = route.calls.last.request.url + assert url.params["formatDate"] == "iso" + assert url.params["max_rows"] == "100" + + +@respx.mock +async def test_a_caller_param_cannot_replace_the_builders_own(config): + # merge_params layers the spec LAST, so a caller cannot silently change what + # the method is actually asking for. + route = respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + async with AsyncEasyvistaClient(config) as client: + await client.search_tickets(max_rows=10, params={"max_rows": 5}) + assert route.calls.last.request.url.params["max_rows"] == "10" + + +@respx.mock +async def test_iter_tickets_still_advances_the_offset_when_params_override_it(config): + # The pagination hazard: a caller passing offset= must not stall the sweep. + # The builder's offset wins on every page, so this terminates. + respx.get(f"{ROOT}/requests").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "records": [{"RFC_NUMBER": "I1"}, {"RFC_NUMBER": "I2"}], + "@next": f"{ROOT}/requests?offset=2", + }, + ), + httpx.Response(200, json={"records": [{"RFC_NUMBER": "I3"}]}), + ] + ) + async with AsyncEasyvistaClient(config) as client: + seen = [t.rfc_number async for t in client.iter_tickets(params={"offset": 0})] + assert seen == ["I1", "I2", "I3"] + + +@respx.mock +async def test_list_actions_keeps_its_rfc_filter_when_a_caller_passes_search(config): + # ',' is a live combinator in this grammar, so a caller-supplied search that + # replaced the builder's filter could list another ticket's actions. + route = respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + async with AsyncEasyvistaClient(config) as client: + await client.list_actions("I1", params={"search": 'RFC_NUMBER:"other"'}) + assert route.calls.last.request.url.params["search"] == 'REQUEST.RFC_NUMBER:"I1"' + + +@respx.mock +async def test_get_ticket_forwards_a_fields_projection(config): + """A projection on the item route, for when one column poisons the record. + + A value the read model refuses fails the entire ``Request``, and without + this there was no way to read the rest of the ticket. Note the item route + may ignore ``fields`` -- the verified instance's own OpenAPI declares it on + ``GET /requests`` but not on ``GET /requests/{rfc_number}``. + """ + route = respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"records": [{"RFC_NUMBER": "I1"}]}) + ) + async with AsyncEasyvistaClient(config) as client: + await client.get_ticket("I1", fields=["RFC_NUMBER", "TITLE"]) + assert route.calls.last.request.url.params["fields"] == "RFC_NUMBER,TITLE" + + +@respx.mock +async def test_a_configured_datetime_format_is_honoured_end_to_end(config): + """The only test that walks the whole thread: config field to model_validate. + + The read models refuse a timestamp they cannot parse rather than guessing + an instant, and a search validates a whole page in one comprehension -- so + on a deployment whose format differs, one column fails every record on the + page. ``datetime_input_formats`` is the way through that is not a fork. + + Both halves matter. The default config must still refuse the payload (the + guard is not softened), and the configured one must accept it (the context + actually reaches ``model_validate`` through 26 builder signatures). + """ + payload = {"records": [{"RFC_NUMBER": "I1", "LAST_UPDATE": "17/08/2026 15:40:00"}]} + respx.get(f"{ROOT}/requests").mock(return_value=httpx.Response(200, json=payload)) + + async with AsyncEasyvistaClient(config) as client: + with pytest.raises( + pydantic.ValidationError, match="not an EasyVista timestamp" + ): + await client.search_tickets() + + tolerant = dataclasses.replace( + config, datetime_input_formats=("%d/%m/%Y %H:%M:%S",) + ) + async with AsyncEasyvistaClient(tolerant) as client: + result = await client.search_tickets() + assert result.records[0].last_update is not None + assert result.records[0].last_update.year == 2026 + + +# --- instance discovery ------------------------------------------------------ + + +@respx.mock +async def test_get_api_spec_accepts_a_201_response(config): + """The regression guard for the ``== 200`` trap. + + A GET to this route answers **201**, not 200 (measured 2026-08-27 on one + instance). This client's transport gates on ``is_success``, so any 2xx + works -- but code written beside it that checks ``status_code == 200`` + skips the document in silence and concludes the instance publishes no spec. + Asserting on 201 specifically is the whole point; a 200 here would prove + nothing. + """ + respx.get(f"{ROOT}/swagger").mock( + return_value=httpx.Response( + 201, json={"info": {"description": "EV REST API - 2025.3"}, "paths": {}} + ) + ) + async with AsyncEasyvistaClient(config) as client: + document = await client.get_api_spec() + assert document["info"]["description"] == "EV REST API - 2025.3" + + +@respx.mock +async def test_get_api_spec_honours_a_custom_path(config): + """The route is tier 4 -- measured on one instance and NOT declared in that + instance's own paths -- so a deployment publishing elsewhere needs a way + through that is not a fork.""" + route = respx.get(f"{ROOT}/openapi.json").mock( + return_value=httpx.Response(200, json={"paths": {}}) + ) + async with AsyncEasyvistaClient(config) as client: + await client.get_api_spec(path="openapi.json") + assert route.call_count == 1 + + +@respx.mock +async def test_list_reference_table_lets_a_403_propagate(config): + """A denial must not look like an empty table. + + An empty reference table is a legitimate answer on a lightly configured + instance. Collapsing a 403 into ``[]`` would make "you may not read this" + indistinguishable from "there is nothing here" -- and a caller building a + status map from ``[]`` concludes the instance has no statuses and reaches + for a hardcoded constant. ``describe_instance`` is the swallowing layer. + """ + respx.get(f"{ROOT}/catalog-requests").mock(return_value=httpx.Response(403)) + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(EasyvistaAuthError): + await client.list_reference_table("catalog-requests") + + +@respx.mock +async def test_list_reference_table_returns_a_search_result_with_counts(config): + """A SearchResult, not a bare list, so truncation is detectable.""" + respx.get(f"{ROOT}/urgency").mock( + return_value=httpx.Response( + 200, + json={ + "records": [{"URGENCY_ID": 1, "URGENCY_EN": "Low"}], + "record_count": "1", + "total_record_count": "7", + }, + ) + ) + async with AsyncEasyvistaClient(config) as client: + page = await client.list_reference_table("urgency") + assert page.record_count == 1 + assert page.total_record_count == 7 + assert page.records[0].model_dump(by_alias=True)["URGENCY_EN"] == "Low" + + +@respx.mock +async def test_discover_status_populates_the_guid_from_a_ticket_sample(config): + """The STATUS_GUID recipe, asserted end to end. + + A STATUS_GUID is not searchable and no reference read returns one, but + every ticket's nested STATUS object carries it. The GUID is what + ``set_status`` and ``close_ticket`` address a status by -- a STATUS_ID will + not work there -- so this is usually the value the caller came for. + """ + respx.get(f"{ROOT}/status").mock( + return_value=httpx.Response( + 200, + json={ + "records": [ + {"STATUS_ID": 8, "STATUS_FR": "Cloture"}, + {"STATUS_ID": 12, "STATUS_FR": "En cours"}, + ] + }, + ) + ) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, + json={ + "records": [ + { + "RFC_NUMBER": "I1", + "STATUS": { + "STATUS_ID": "8", + "STATUS_GUID": "{ABC}", + "STATUS_FR": "Cloture", + }, + } + ] + }, + ) + ) + async with AsyncEasyvistaClient(config) as client: + found = await client.discover("STATUS", sample_size=5) + by_id = {r.id: r for r in found} + assert by_id["8"].guid == "{ABC}" + # A status present in the table but held by no sampled ticket keeps + # guid=None: the sample cannot reach it, and inventing one would hand back + # a GUID that addresses nothing. + assert by_id["12"].guid is None + + +@respx.mock +async def test_discover_a_routeless_name_never_calls_a_reference_route(config): + """IMPACT has no route in the spec at all, so no request is wasted on one.""" + impact_route = respx.get(f"{ROOT}/impact").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, + json={ + "records": [ + {"RFC_NUMBER": "I1", "IMPACT_ID": "17"}, + {"RFC_NUMBER": "I2", "IMPACT_ID": "17"}, + {"RFC_NUMBER": "I3", "IMPACT_ID": "21"}, + ] + }, + ) + ) + async with AsyncEasyvistaClient(config) as client: + found = await client.discover("IMPACT", sample_size=10) + assert impact_route.call_count == 0 + assert [(r.id, r.count) for r in found] == [("17", 2), ("21", 1)] + assert all(r.source == "sample" for r in found) + + +@respx.mock +async def test_discover_strategy_reference_lets_a_403_raise(config): + respx.get(f"{ROOT}/status").mock(return_value=httpx.Response(403)) + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(EasyvistaAuthError): + await client.discover("STATUS", strategy="reference") + + +@respx.mock +async def test_discover_auto_falls_back_to_the_sample_on_a_denied_route(config): + respx.get(f"{ROOT}/status").mock(return_value=httpx.Response(403)) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, + json={ + "records": [ + { + "RFC_NUMBER": "I1", + "STATUS": {"STATUS_ID": "8", "STATUS_FR": "Cloture"}, + } + ] + }, + ) + ) + async with AsyncEasyvistaClient(config) as client: + found = await client.discover("STATUS", sample_size=5) + assert [(r.id, r.source) for r in found] == [("8", "sample")] + + +async def test_discover_strategy_reference_refuses_a_routeless_name(config): + """Rather than quietly sampling, which would answer a different question.""" + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(ValueError, match="no reference route"): + await client.discover("IMPACT", strategy="reference") + + +async def test_discover_refuses_an_unknown_strategy(config): + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(ValueError, match="strategy="): + await client.discover("STATUS", strategy="guess") + + +@respx.mock +async def test_describe_instance_names_every_gap_and_still_returns_a_profile(config): + """No part can fail the whole. + + ``/catalog-requests`` is denied here and everything else succeeds: the + profile still holds the other names, and the gap is named rather than + silently empty. + """ + respx.get(f"{ROOT}/swagger").mock( + return_value=httpx.Response( + 201, + json={ + "info": {"description": "EV REST API - 2025.3"}, + "paths": {"/requests": {}, "/actions": {}}, + }, + ) + ) + respx.get(f"{ROOT}/catalog-requests").mock(return_value=httpx.Response(403)) + for table in ("status", "urgency", "locations", "departments", "slas", "groups"): + respx.get(f"{ROOT}/{table}").mock( + return_value=httpx.Response(200, json={"records": [{"NAME_EN": "x"}]}) + ) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, json={"records": [{"RFC_NUMBER": "I1", "IMPACT_ID": "17"}]} + ) + ) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response( + 200, json={"records": [{"ACTION_ID": 1, "ACTION_TYPE_ID": 94}]} + ) + ) + async with AsyncEasyvistaClient(config) as client: + profile = await client.describe_instance( + sample_size=5, action_sample_tickets=1 + ) + assert profile.version == "EV REST API - 2025.3" + assert "/requests" in profile.spec_paths + assert profile.unavailable["CATALOG_REQUEST"].startswith("denied") + assert profile.references["STATUS"] + assert profile.references["IMPACT"][0].id == "17" + # The four routeless names say so, rather than looking like empty tables. + for routeless in ("IMPACT", "SEVERITY", "ORIGIN", "ACTION_TYPE"): + assert profile.unavailable[routeless].startswith("no-route") + + +@respx.mock +async def test_describe_instance_records_a_truncated_table(config): + """The rows present are real; they are just not all of them.""" + respx.get(f"{ROOT}/swagger").mock( + return_value=httpx.Response(201, json={"info": {}, "paths": {}}) + ) + respx.get(f"{ROOT}/status").mock( + return_value=httpx.Response( + 200, + json={ + "records": [{"STATUS_ID": 8, "STATUS_FR": "Cloture"}], + "record_count": 1, + "total_record_count": 40, + }, + ) + ) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + async with AsyncEasyvistaClient(config) as client: + profile = await client.describe_instance(names=["STATUS"], sample_size=1) + assert profile.unavailable["STATUS"].startswith("truncated") + assert profile.references["STATUS"] # and the rows still came back + + +@respx.mock +async def test_describe_instance_returns_a_profile_when_everything_is_denied(config): + """A total outage looks like a bare instance EXCEPT that every gap is named. + + Which is exactly why the docstring tells the reader to check + ``.unavailable`` before concluding an instance has no statuses. + """ + respx.route(host="ev.test").mock(return_value=httpx.Response(403)) + async with AsyncEasyvistaClient(config) as client: + profile = await client.describe_instance( + names=["STATUS", "IMPACT"], sample_size=1 + ) + assert profile.spec_paths == () + assert profile.unavailable["spec"].startswith("denied") + assert profile.references["STATUS"] == [] + assert profile.references["IMPACT"] == [] + + +@respx.mock +async def test_end_action_forwards_every_keyword_to_the_body(config): + """Pin the kwarg forwarding, ``start_date`` above all. + + A dropped ``start_date`` is invisible in the response -- the server simply + derives one, and the derived one is early by the instance's UTC offset. So + this asserts the wire body, not the return value. + """ + route = respx.put(f"{ROOT}/actions/I1").mock( + return_value=httpx.Response(200, json={"HREF": f"{ROOT}/requests/I1"}) + ) + async with AsyncEasyvistaClient(config) as client: + await client.end_action( + "I1", + action_id=42, + start_date="01/09/2026 17:00:00", + end_date="01/09/2026 17:15:00", + elapsed_time=15, + doneby_mail="tech@example.invalid", + ) + assert json.loads(route.calls.last.request.content) == { + "end_action": { + "action_id": 42, + "start_date": "01/09/2026 17:00:00", + "end_date": "01/09/2026 17:15:00", + "elapsed_time": 15, + "doneby_mail": "tech@example.invalid", + } + } + + +@respx.mock +async def test_end_action_addresses_the_ticket_not_the_action(config): + """``actions/{action_id}`` answers 404 for this verb; the RFC is the path.""" + route = respx.put(f"{ROOT}/actions/I1").mock( + return_value=httpx.Response(200, json={"HREF": f"{ROOT}/requests/I1"}) + ) + async with AsyncEasyvistaClient(config) as client: + await client.end_action("I1", action_id=42) + assert route.calls.last.request.url.path.endswith("/actions/I1") + + +async def test_end_action_refuses_a_missing_action_id_before_any_request(config): + """No socket is opened: the refusal is local, so respx is not even needed.""" + async with AsyncEasyvistaClient(config) as client: + with pytest.raises(ValueError, match="end_all"): + await client.end_action("I1", end_date="01/09/2026 17:00:00") diff --git a/easyvista_python_client/_async/tests/test_transport.py b/easyvista_python_client/_async/tests/test_transport.py index 9ca4204..64137fa 100644 --- a/easyvista_python_client/_async/tests/test_transport.py +++ b/easyvista_python_client/_async/tests/test_transport.py @@ -7,13 +7,15 @@ only one surface. """ +from collections.abc import AsyncIterator + import httpx import pytest import respx from easyvista_python_client._async._transport import BaseTransport, Transport from easyvista_python_client._transport import RequestSpec -from easyvista_python_client.config import EasyvistaConfig +from easyvista_python_client.config import DEFAULT_USER_AGENT, EasyvistaConfig from easyvista_python_client.exceptions import ( EasyvistaAuthError, EasyvistaConnectionError, @@ -434,3 +436,512 @@ async def test_get_bytes_keeps_the_bearer_token_on_a_same_host_redirect(): content = await transport.get_bytes("https://ev.test/download/42") assert content == b"blob" assert signed.calls.last.request.headers["Authorization"] == "Bearer tok" + + +# --- stream_bytes ------------------------------------------------------------ +# +# The streaming download. Its contract is "identical to get_bytes except that +# the body arrives in pieces", so most of these are the get_bytes assertions +# above re-made against the chunked path -- that duplication is the point, since +# the two implementations share no code past `resolve_url`. The one claim with +# no get_bytes counterpart is the retry boundary: retrying stops once a byte has +# reached the caller, because restarting would deliver it twice. + + +class _StreamThatFailsMidBody(httpx.AsyncByteStream): + """A response body that delivers ``prefix`` and then drops the connection. + + ``respx`` can fail a request before a response exists, which is what the + ``side_effect=httpx.ConnectError`` mocks elsewhere in this module do. It has + no way to fail one *after* the status line, and that is exactly the case the + retry boundary is about -- so the failure is injected into the body stream + itself, which httpx surfaces as a real ``TransportError`` while iterating. + """ + + def __init__(self, prefix: bytes) -> None: + self._prefix = prefix + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield self._prefix + raise httpx.ReadError("connection dropped mid-body") + + +class _StreamThatFailsBeforeTheFirstByte(httpx.AsyncByteStream): + """A response body that drops the connection without yielding anything. + + The sibling of :class:`_StreamThatFailsMidBody`, for the *other* side of the + retry boundary: the status line arrived, so this is past the point respx can + fail a request, but no byte has reached the caller yet, so restarting is + still safe and must happen. + """ + + async def __aiter__(self) -> AsyncIterator[bytes]: + raise httpx.ReadError("dropped before the first byte") + yield b"" # unreachable; the yield is what makes this a generator function + + +async def _collect(chunks: AsyncIterator[bytes]) -> list[bytes]: + """Every chunk a stream yields, kept separate rather than joined.""" + return [chunk async for chunk in chunks] + + +@respx.mock +async def test_stream_bytes_reassembles_to_the_whole_body(): + # 10244 bytes at chunk_size=1024: deliberately NOT a multiple of it, so the + # last chunk is a short one. A body sized to an exact multiple never + # exercises the ragged tail, and the reassembly assertion below would + # pass either way. + body = bytes(range(256)) * 40 + b"tail" + respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(200, content=body) + ) + async with Transport(_cfg()) as transport: + chunks = await _collect( + transport.stream_bytes("documents/1/content", chunk_size=1024) + ) + assert b"".join(chunks) == body + # More than one chunk, and each bounded: proves the body is delivered + # progressively rather than read whole and handed over in a single piece. + assert len(chunks) == 11 + assert max(len(chunk) for chunk in chunks) <= 1024 + assert len(chunks[-1]) == 4, "the short final chunk was padded or dropped" + + +@respx.mock +async def test_stream_bytes_chunks_at_the_documented_default_size(): + """The default chunk size is 64 KiB, and this is what says so. + + ``DEFAULT_STREAM_CHUNK_SIZE`` is quoted as "64 KiB" in the CHANGELOG, in the + document-workflow skill and in the constant's own comment (whose "a 32 MB + attachment is ~512 iterations" arithmetic only holds at that value). Every + other chunk-counting test passes ``chunk_size`` explicitly, so without this + one the constant could change to anything and leave all three false with a + green suite. 160 KiB of body -> three chunks, the last a short one. + """ + body = bytes(range(256)) * 640 # 163840 bytes == 2.5 * 64 KiB + respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(200, content=body) + ) + async with Transport(_cfg()) as transport: + chunks = await _collect(transport.stream_bytes("documents/1/content")) + assert b"".join(chunks) == body + assert [len(chunk) for chunk in chunks] == [65536, 65536, 32768] + + +@pytest.mark.parametrize("bad", [0, -8]) +async def test_stream_bytes_refuses_a_non_positive_chunk_size(bad): + """The newest public parameter must fail as bad input, not as a library bug. + + Left unguarded, httpx raises from inside its own ``ByteChunker`` several + frames below this client: ``chunk_size=0`` surfaces as "range() arg 3 must + not be zero" and a negative one as "list index out of range". A caller + computing a chunk size reads either as our bug. No request is made, so this + needs no mock -- and because ``stream_bytes`` is a generator, the raise lands + on the first iteration step, matching the deferred ``ValueError`` for a + record with no download URL. + """ + async with Transport(_cfg()) as transport: + with pytest.raises(ValueError, match="chunk_size must be positive"): + await _collect( + transport.stream_bytes("documents/1/content", chunk_size=bad) + ) + + +@respx.mock +async def test_stream_bytes_yields_nothing_for_an_empty_body(): + respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(200, content=b"") + ) + async with Transport(_cfg()) as transport: + assert await _collect(transport.stream_bytes("documents/1/content")) == [] + + +@respx.mock +async def test_stream_bytes_sends_the_bearer_header(): + route = respx.get("https://ev.test/download/42").mock( + return_value=httpx.Response(200, content=b"ok") + ) + async with Transport(_cfg()) as transport: + await _collect(transport.stream_bytes("https://ev.test/download/42")) + assert route.calls.last.request.headers["Authorization"] == "Bearer tok" + + +@respx.mock +async def test_stream_bytes_maps_403_to_auth_error(): + # The status is on an unread streaming response, whose `.content` raises + # until the body is read -- so the error mapping cannot simply be reused, it + # has to read the body first. This asserts the mapped type AND that the + # parsed EasyVista fields survived that detour. + respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(403, json={"error": "forbidden", "code": "9"}) + ) + async with Transport(_cfg()) as transport: + with pytest.raises(EasyvistaAuthError) as ei: + await _collect(transport.stream_bytes("documents/1/content")) + assert ei.value.status_code == 403 + assert ei.value.ev_message == "forbidden" + assert ei.value.ev_code == "9" + + +@respx.mock +async def test_stream_bytes_does_not_retry_a_590(): + route = respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(590, json={"error": "rejected"}) + ) + async with Transport(_cfg(max_retries=3)) as transport: + with pytest.raises(EasyvistaValidationError): + await _collect(transport.stream_bytes("documents/1/content")) + assert route.call_count == 1 + + +@respx.mock +async def test_stream_bytes_retries_a_retryable_status_on_the_open(): + route = respx.get(f"{ROOT}/documents/1/content").mock( + side_effect=[httpx.Response(503), httpx.Response(200, content=b"bytes")] + ) + async with Transport(_cfg(max_retries=2)) as transport: + chunks = await _collect(transport.stream_bytes("documents/1/content")) + assert b"".join(chunks) == b"bytes" + assert route.call_count == 2 + + +@respx.mock +async def test_stream_bytes_exhausts_retries_raises_server_error(): + route = respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(503) + ) + async with Transport(_cfg(max_retries=1)) as transport: + with pytest.raises(EasyvistaServerError): + await _collect(transport.stream_bytes("documents/1/content")) + assert route.call_count == 2 + + +@respx.mock +async def test_stream_bytes_transport_error_on_the_open_raises_connection_error(): + respx.get(f"{ROOT}/documents/1/content").mock( + side_effect=httpx.ConnectError("boom") + ) + async with Transport(_cfg()) as transport: + with pytest.raises(EasyvistaConnectionError): + await _collect(transport.stream_bytes("documents/1/content")) + + +@respx.mock +async def test_stream_bytes_retries_a_failure_fetching_the_first_chunk(): + """The first chunk is fetched INSIDE the retried unit. This is what pins it. + + ``_open_stream`` takes the first chunk itself, so a body that dies before + yielding a byte is still a safe restart -- nothing has reached the caller, so + replaying the request cannot deliver anything twice. Move that fetch out of + the retried unit (open there, iterate the whole body here) and the failure + below escapes as ``EasyvistaConnectionError`` on the first attempt instead, + with ``call_count == 1``. Every other ``stream_bytes`` test passes under both + arrangements, including the mid-body one just after this: the two differ only + on a first-chunk failure, which is only this test. + """ + route = respx.get(f"{ROOT}/documents/1/content").mock( + side_effect=[ + httpx.Response(200, stream=_StreamThatFailsBeforeTheFirstByte()), + httpx.Response(200, content=b"0123456789abcdef"), + ] + ) + async with Transport(_cfg(max_retries=2)) as transport: + chunks = await _collect( + transport.stream_bytes("documents/1/content", chunk_size=8) + ) + assert b"".join(chunks) == b"0123456789abcdef" + assert route.call_count == 2, "a pre-first-byte failure was not retried" + + +@respx.mock +async def test_stream_bytes_does_not_retry_after_a_chunk_has_been_yielded(): + """A mid-body failure is the caller's to handle, never silently restarted. + + Retrying here would hand the caller the opening bytes a second time, so the + request is committed the moment a chunk is delivered. The delivered prefix + stays visible -- the caller keeps what it already collected -- and the + failure arrives as a mapped ``EasyvistaConnectionError``, not as a raw httpx + error. A "helpful" change making this resumable fails on the call count. + """ + route = respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response( + 200, stream=_StreamThatFailsMidBody(b"0123456789abcdef") + ) + ) + collected: list[bytes] = [] + async with Transport(_cfg(max_retries=3)) as transport: + with pytest.raises(EasyvistaConnectionError): + async for chunk in transport.stream_bytes( + "documents/1/content", chunk_size=8 + ): + collected.append(chunk) + assert b"".join(collected) == b"0123456789abcdef" + assert route.call_count == 1, "a mid-stream failure was retried" + + +async def test_stream_bytes_rejects_a_foreign_origin(): + # The same-origin guard is a security property of the download path, not of + # one method on it: every request carries the instance Bearer token. Nothing + # is requested until iteration starts, so the refusal surfaces there. + async with Transport(_cfg()) as transport: + with pytest.raises(EasyvistaError, match="outside the configured instance"): + await _collect(transport.stream_bytes("https://attacker.test/download/42")) + + +@respx.mock +async def test_stream_bytes_drops_the_bearer_token_on_a_cross_host_redirect(): + # `follow_redirects=True` is as deliberate here as on get_bytes (a download + # URL commonly redirects to a signed location), and so is the reason it is + # safe: httpx strips Authorization when a redirect leaves the origin. Pinned + # on this path too, because the streaming send() call passes the flag itself + # rather than inheriting anything from the non-streaming one. + respx.get("https://ev.test/download/42").mock( + return_value=httpx.Response( + 302, headers={"Location": "https://cdn.attacker.test/blob/42"} + ) + ) + foreign = respx.get("https://cdn.attacker.test/blob/42").mock( + return_value=httpx.Response(200, content=b"blob") + ) + async with Transport(_cfg()) as transport: + chunks = await _collect(transport.stream_bytes("https://ev.test/download/42")) + assert b"".join(chunks) == b"blob" + leaked = "authorization" in foreign.calls.last.request.headers + assert not leaked, "the instance token followed a redirect off the instance" + + +@respx.mock +async def test_stream_bytes_keeps_the_bearer_token_on_a_same_host_redirect(): + # Control for the test above, exactly as on get_bytes: without it, a path + # that never sent Authorization at all would look like a pass. + respx.get("https://ev.test/download/42").mock( + return_value=httpx.Response( + 302, headers={"Location": "https://ev.test/download/42/signed"} + ) + ) + signed = respx.get("https://ev.test/download/42/signed").mock( + return_value=httpx.Response(200, content=b"blob") + ) + async with Transport(_cfg()) as transport: + chunks = await _collect(transport.stream_bytes("https://ev.test/download/42")) + assert b"".join(chunks) == b"blob" + assert signed.calls.last.request.headers["Authorization"] == "Bearer tok" + + +# --- per-deployment adaptation: headers, params, download hosts --------------- + + +def test_headers_carry_the_package_user_agent_by_default(): + assert _base().headers()["User-Agent"] == DEFAULT_USER_AGENT + + +def test_user_agent_config_replaces_the_default(): + assert BaseTransport(_cfg(user_agent="my-app/1.4")).headers()["User-Agent"] == ( + "my-app/1.4" + ) + + +def test_extra_headers_override_every_default_but_not_the_credential(): + # extra_headers is merged LAST, so it wins over Accept, Content-Type and the + # User-Agent alike. It cannot reach the credential: EasyvistaConfig refuses + # an Authorization key at construction, so by the time headers() runs there + # is nothing left to guard against. + transport = BaseTransport( + _cfg( + extra_headers={ + "Accept": "text/csv", + "Content-Type": "text/csv", + "User-Agent": "override/9", + "Ocp-Apim-Subscription-Key": "gateway-key", + } + ) + ) + headers = transport.headers() + assert headers["Accept"] == "text/csv" + assert headers["Content-Type"] == "text/csv" + assert headers["User-Agent"] == "override/9" + assert headers["Ocp-Apim-Subscription-Key"] == "gateway-key" + assert headers["Authorization"] == "Bearer tok" + + +def test_merge_params_layers_config_then_call_then_spec(): + # All three empty returns None, so a request that took no parameters before + # still takes none -- that is what keeps today's wire unchanged. + assert BaseTransport(_cfg()).merge_params(None, None) is None + transport = BaseTransport(_cfg(default_params={"formatDate": "iso", "shared": "c"})) + assert transport.merge_params(None, None) == {"formatDate": "iso", "shared": "c"} + # The caller beats config, and the spec beats both: the spec is what sets + # search/max_rows/offset, so a caller must not be able to replace them. + assert transport.merge_params({"shared": "call"}, None)["shared"] == "call" + assert transport.merge_params({"shared": "call"}, {"shared": "spec"})["shared"] == ( + "spec" + ) + + +def test_download_headers_carry_no_credential_and_no_extra_headers(): + # The whole point of allowing a foreign download host is that reaching it + # must not hand that host this instance's token -- nor a second secret + # sitting in extra_headers. + bearer = EasyvistaConfig( + server="https://ev.test", + account="acme", + token="tok", + extra_headers={"X-Api-Key": "SECRET"}, + ) + basic = EasyvistaConfig( + server="https://ev.test", + account="acme", + login="u", + password="p", + extra_headers={"X-Api-Key": "SECRET"}, + ) + for cfg in (bearer, basic): + assert BaseTransport(cfg).download_headers() == { + "User-Agent": DEFAULT_USER_AGENT + } + + +def _allowing(*hosts): + return BaseTransport(_cfg(additional_download_hosts=set(hosts))) + + +def test_resolve_url_admits_an_allow_listed_https_host(): + url = "https://cdn.allowed.test/blob/1" + assert _allowing("cdn.allowed.test").resolve_url(url) == url + + +def test_resolve_url_allow_list_is_https_only(): + # Opting a host in must never be usable to downgrade a fetch to cleartext. + with pytest.raises(EasyvistaError): + _allowing("cdn.allowed.test").resolve_url("http://cdn.allowed.test/blob/1") + + +def test_resolve_url_still_rejects_a_host_that_is_not_listed(): + with pytest.raises(EasyvistaError, match="additional_download_hosts"): + _allowing("cdn.allowed.test").resolve_url("https://attacker.test/blob/1") + + +def test_resolve_url_allow_list_still_rejects_a_userinfo_prefix(): + # The raw-netloc comparison carries over to the new branch, so + # "attacker.test@cdn.allowed.test" matches no allow-listed host. + with pytest.raises(EasyvistaError): + _allowing("cdn.allowed.test").resolve_url( + "https://attacker.test@cdn.allowed.test/blob/1" + ) + + +def test_is_offsite_answers_false_for_the_instance_even_when_listed(): + # A caller who redundantly lists their own instance host must not thereby + # strip their own credential from every download. + transport = _allowing("ev.test", "cdn.allowed.test") + assert transport.is_offsite(f"{ROOT}/documents/1/content") is False + assert transport.is_offsite("https://cdn.allowed.test/blob/1") is True + + +@respx.mock +async def test_get_bytes_sends_no_credential_to_an_allow_listed_host(): + # THE LOAD-BEARING TEST. The token is attached to the instance client at + # CLIENT level -- as a header for Bearer, as an httpx.Auth for Basic -- and + # httpx can remove neither per request. A second, credential-free client is + # what makes this assertion hold, so both auth mechanisms are checked here: + # one does not cover the other. + bearer = EasyvistaConfig( + server="https://ev.test", + account="acme", + token="tok", + additional_download_hosts={"cdn.allowed.test"}, + ) + basic = EasyvistaConfig( + server="https://ev.test", + account="acme", + login="u", + password="p", + additional_download_hosts={"cdn.allowed.test"}, + ) + for cfg in (bearer, basic): + route = respx.get("https://cdn.allowed.test/blob/1").mock( + return_value=httpx.Response(200, content=b"payload") + ) + async with Transport(cfg) as transport: + fetched = await transport.get_bytes("https://cdn.allowed.test/blob/1") + assert fetched == b"payload" + assert "authorization" not in route.calls.last.request.headers + + +@respx.mock +async def test_get_bytes_still_authenticates_against_the_instance(): + # The other half of the pair: opting a foreign host in must not disturb the + # instance's own downloads. + route = respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(200, content=b"payload") + ) + async with Transport(_cfg(additional_download_hosts={"cdn.allowed.test"})) as t: + assert await t.get_bytes("documents/1/content") == b"payload" + assert route.calls.last.request.headers["Authorization"] == "Bearer tok" + + +@respx.mock +async def test_stream_bytes_sends_no_credential_to_an_allow_listed_host(): + # _open_stream selects its client separately and shares no code with + # _do_get_bytes past resolve_url, so the guarantee is asserted twice. + route = respx.get("https://cdn.allowed.test/blob/1").mock( + return_value=httpx.Response(200, content=b"payload") + ) + async with Transport(_cfg(additional_download_hosts={"cdn.allowed.test"})) as t: + chunks = await _collect(t.stream_bytes("https://cdn.allowed.test/blob/1")) + assert b"".join(chunks) == b"payload" + assert "authorization" not in route.calls.last.request.headers + + +@respx.mock +async def test_stream_bytes_still_authenticates_against_the_instance(): + route = respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(200, content=b"payload") + ) + async with Transport(_cfg(additional_download_hosts={"cdn.allowed.test"})) as t: + chunks = await _collect(t.stream_bytes("documents/1/content")) + assert b"".join(chunks) == b"payload" + assert route.calls.last.request.headers["Authorization"] == "Bearer tok" + + +async def test_the_download_client_exists_only_when_opted_in_and_is_closed(): + plain = Transport(_cfg()) + assert plain._download_client is None + await plain.aclose() + + opted = Transport(_cfg(additional_download_hosts={"cdn.allowed.test"})) + assert opted._download_client is not None + await opted.aclose() + assert opted._client.is_closed + assert opted._download_client.is_closed + + +@respx.mock +async def test_send_layers_the_three_parameter_sources_on_the_wire(): + route = respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + cfg = _cfg(default_params={"max_rows": 1, "formatDate": "config"}) + async with Transport(cfg) as transport: + await transport.send( + RequestSpec("GET", "requests", params={"max_rows": 10}), + params={"max_rows": 5, "formatDate": "iso"}, + ) + url = route.calls.last.request.url + assert url.params["max_rows"] == "10" # the spec wins + assert url.params["formatDate"] == "iso" # the call beats config + + +@respx.mock +async def test_request_spec_headers_override_the_client_level_ones(): + route = respx.post(f"{ROOT}/upload").mock(return_value=httpx.Response(200, json={})) + async with Transport(_cfg()) as transport: + await transport.send( + RequestSpec("POST", "upload", headers={"Content-Type": "text/csv"}) + ) + assert route.calls.last.request.headers["Content-Type"] == "text/csv" + + +def test_request_spec_refuses_the_credential_in_its_headers(): + with pytest.raises(ValueError, match="must not set"): + RequestSpec("GET", "requests", headers={"authorization": "Bearer other"}) diff --git a/easyvista_python_client/_fields.py b/easyvista_python_client/_fields.py index 12a1d9e..4aa3a08 100644 --- a/easyvista_python_client/_fields.py +++ b/easyvista_python_client/_fields.py @@ -1,26 +1,50 @@ -"""Shared helpers for extracting human labels from EasyVista field objects. +"""Shared helper for extracting plain text from EasyVista field values. -EasyVista nests label+href objects (``STATUS``, ``DEPARTMENT``, ``CATALOG_REQUEST``, -``URGENCY``, ``IMPACT``). Both the Markdown renderer (:mod:`context`) and the -statistics aggregator (:mod:`reporting`) need to pick the human label and never an -href, so the logic lives here once. +Human *labels* -- the nested ``STATUS`` / ``DEPARTMENT`` / ``ACTION_TYPE`` +objects and the ``*_EN`` / ``*_FR`` / … language columns -- are resolved in +:mod:`~easyvista_python_client.references` alone +(:func:`~easyvista_python_client.references.resolve_reference` and +:func:`~easyvista_python_client.localized_label`), so the package has one +placeholder rule and one language order rather than three. This module is left +with :func:`_text`, the scalar-to-string extractor. + +Its consumer reads a ``model_dump(by_alias=True)`` dict, so a timestamp column +(``LAST_UPDATE``, ``CREATION_DATE_UT``, …) arrives here as a ``datetime`` since +the 2026-08-17 read-path retype +(:class:`~easyvista_python_client.models.common.OptionalDateTime`), not a +``str``. :func:`_text` renders it rather than discarding it, which is why this +leaf now imports :mod:`~easyvista_python_client.timestamps`. """ from __future__ import annotations +from datetime import datetime from typing import Any +from .timestamps import format_ev_datetime -def _text(value: Any) -> str: - """First stripped string form of ``value``; ``""`` if it is not a string.""" - return value.strip() if isinstance(value, str) else "" +def _text(value: Any) -> str: + """First stripped string form of ``value``; ``""`` if it doesn't render as text. -def _label(obj: Any, keys: tuple[str, ...]) -> str: - """First non-empty string among ``keys`` of a nested object; never an href.""" - if isinstance(obj, dict): - for key in keys: - value = obj.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() + A ``datetime`` renders as EasyVista's own wire format (:func:`format_ev_datetime`) + so the extracted text is byte-identical to EasyVista's own + millisecond-precision-with-offset rendering, and to what ``ev_since_filter`` + accepts. Not necessarily byte-identical to the *input* bytes: the fraction is + always 3 digits, so a source string with a different precision round-trips to + 3 digits here. ``format_ev_datetime`` raises on a *naive* + datetime, which should not occur for a value that came through + ``OptionalDateTime`` (it always normalizes to aware) -- but this is an + extractor, which must never raise, so a naive value still falls back to + plain ``.isoformat()`` instead. + """ + if isinstance(value, str): + return value.strip() + if isinstance(value, datetime): + try: + return format_ev_datetime(value) + except ValueError: + return value.isoformat() return "" + + diff --git a/easyvista_python_client/_sync/_transport.py b/easyvista_python_client/_sync/_transport.py index f4495a1..52d0bfe 100644 --- a/easyvista_python_client/_sync/_transport.py +++ b/easyvista_python_client/_sync/_transport.py @@ -16,6 +16,7 @@ from __future__ import annotations import json +from collections.abc import Generator, Iterator, Mapping from typing import Any, NoReturn from urllib.parse import urlsplit @@ -28,7 +29,7 @@ ) from easyvista_python_client._transport import RequestSpec -from easyvista_python_client.config import EasyvistaConfig +from easyvista_python_client.config import DEFAULT_USER_AGENT, EasyvistaConfig from easyvista_python_client.exceptions import ( EasyvistaAuthError, EasyvistaConnectionError, @@ -39,6 +40,17 @@ EasyvistaValidationError, ) +#: Default chunk size, in bytes, for :meth:`Transport.stream_bytes`. +#: +#: 64 KiB is the ceiling this default is chosen to set: a caller streams an +#: attachment precisely so the whole file never sits in memory, and the chunk +#: size is what one step of that costs. Large enough that a 32 MB attachment is +#: ~512 iterations rather than tens of thousands, small enough that the resident +#: peak stays negligible beside the file. Deliberately not a config field -- +#: nobody has asked for an instance-wide value, and the one caller who cares +#: about a specific payload can pass ``chunk_size`` per call. +DEFAULT_STREAM_CHUNK_SIZE = 64 * 1024 + class BaseTransport: """Pure transport logic, independent of how a request is executed (no I/O).""" @@ -53,14 +65,28 @@ def resolve_url(self, path_or_url: str) -> str: """Return an absolute URL for a resource path or an API-supplied URL. Relative paths join to ``api_root`` exactly as :meth:`build_url` does. - An absolute URL is passed through **only when its scheme and host match - ``config.server``**, and raises otherwise. + An absolute URL is passed through when its scheme and host match + ``config.server``, or -- opt-in only -- when it is ``https`` and its host + is listed in ``config.additional_download_hosts``. Anything else raises. That check is load-bearing, not decoration. Every request this transport makes carries the instance's Bearer token, so following an absolute URL taken out of a response body (an attachment's ``DDL_HREF``, say) would - hand that credential to whatever host the body named. The API is trusted - to describe its own instance, not to redirect us off it. + hand that credential to whatever host the body named. The allow-list does + not reopen that: a URL admitted by it is fetched through a SEPARATE + client carrying no credential and no ``config.extra_headers`` -- see + :meth:`is_offsite` and :meth:`download_headers`. Nothing but a download + ever consults the allow-list; :meth:`Transport.send` refuses an absolute + URL outright, so the JSON API is unaffected by it. + + What is guaranteed is exactly that and no more: a foreign URL in a + response **body** is refused. An HTTP **redirect** off the instance is + still *followed* -- both download paths run with + ``follow_redirects=True``, which signed-location hops depend on -- and it + merely loses the credential (verified: no ``authorization`` header on the + foreign request, for Bearer and for Basic; a same-host redirect keeps + it). So streamed or downloaded bytes are not proof of instance origin, + and a caller must not treat them as such. """ parsed = urlsplit(path_or_url) if not parsed.scheme and not parsed.netloc: @@ -71,23 +97,106 @@ def resolve_url(self, path_or_url: str) -> str: # scheme on DDL_HREF). Compare the RAW netloc, not .hostname: that # keeps "https://attacker.test@ev.test/x" rejected, because its netloc # is "attacker.test@ev.test", not "ev.test". - if (parsed.scheme, parsed.netloc.lower()) != ( + if (parsed.scheme, parsed.netloc.lower()) == ( server.scheme, server.netloc.lower(), ): - raise EasyvistaError( - f"refusing to fetch {parsed.scheme}://{parsed.netloc} — it is " - f"outside the configured instance " - f"({server.scheme}://{server.netloc})" - ) - return path_or_url + return path_or_url + # https only, so opting a host in can never downgrade a fetch to + # cleartext. The raw-netloc comparison carries over unchanged, so a + # userinfo prefix still fails to match an allow-listed host. + if ( + parsed.scheme == "https" + and parsed.netloc.lower() in self.config.additional_download_hosts + ): + return path_or_url + raise EasyvistaError( + f"refusing to fetch {parsed.scheme}://{parsed.netloc} — it is " + f"outside the configured instance " + f"({server.scheme}://{server.netloc}); add its host to " + f"config.additional_download_hosts to allow an UNAUTHENTICATED " + f"download from it" + ) + + def is_offsite(self, url: str) -> bool: + """True when ``url`` is on an allow-listed host rather than the instance. + + Splits URLs :meth:`resolve_url` has already admitted into the two that + need different clients; it is not a second admission check. The + instance's own origin answers ``False`` even when it is also listed, so a + redundant entry cannot strip the credential from an instance request. + """ + parsed = urlsplit(url) + server = urlsplit(self.config.server) + if (parsed.scheme, parsed.netloc.lower()) == ( + server.scheme, + server.netloc.lower(), + ): + return False + return parsed.netloc.lower() in self.config.additional_download_hosts def headers(self) -> dict[str, str]: - base = {"Accept": "application/json", "Content-Type": "application/json"} + """Every header a request to the configured instance carries. + + Layered lowest to highest: the JSON defaults, the User-Agent + (``config.user_agent``, or :data:`DEFAULT_USER_AGENT` when that is + unset), the credential, and finally ``config.extra_headers`` -- which + therefore overrides all three. It cannot override the credential: + :class:`EasyvistaConfig` refuses an ``Authorization`` key at + construction, in any casing, rather than letting one silently shadow + ``config.token``. + + ``extra_headers`` winning is the opposite of how ``default_params`` + loses to a per-call parameter, and the asymmetry is deliberate. The + client sets ``max_rows`` and ``offset`` itself, so a query parameter the + caller could override would break a paging sweep; no header set here is + load-bearing once the credential is out of reach. + + ``Content-Type: application/json`` is set client-wide, so it rides even + on a GET or a DELETE with no body. That is redundant rather than wrong -- + httpx sets it per request from ``json=`` anyway -- and it is kept because + it is what the verified instance has always been sent. A request needing + another content type overrides it through ``RequestSpec.headers``. + """ + base = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": self.config.user_agent or DEFAULT_USER_AGENT, + } if self.config.token: base["Authorization"] = f"Bearer {self.config.token}" + base.update(self.config.extra_headers) return base + def download_headers(self) -> dict[str, str]: + """Headers for a fetch from a host that is NOT the configured instance. + + Identification and nothing else. No credential, because the whole point + of allowing a foreign download host is that reaching it must not hand + that host this instance's token; and no ``config.extra_headers``, which + is where a second secret (an API key, a proxy credential) would sit. No + ``Accept`` either: what comes back is bytes, not JSON. + """ + return {"User-Agent": self.config.user_agent or DEFAULT_USER_AGENT} + + def merge_params( + self, call: Mapping[str, Any] | None, spec: Mapping[str, Any] | None + ) -> dict[str, Any] | None: + """Layer the three query-parameter sources, most specific last. + + ``config.default_params`` first, then what the caller passed to the + method, then what the resource builder put on the spec. The builder wins + on purpose: it is what sets ``search``, ``max_rows`` and ``offset``, so a + caller cannot replace the ticket filter on ``list_actions`` or the offset + an ``iter_*`` sweep is stepping. + + Returns ``None`` rather than an empty dict when every layer is empty, so + a request that took no parameters before still takes none. + """ + if not (self.config.default_params or call or spec): + return None + return {**self.config.default_params, **(call or {}), **(spec or {})} + def auth(self) -> httpx.Auth | None: if self.config.uses_basic_auth: return httpx.BasicAuth(self.config.login or "", self.config.password or "") @@ -197,6 +306,27 @@ def __init__(self, config: EasyvistaConfig) -> None: timeout=config.timeout, verify=config.verify_ssl, ) + # A second client, for the hosts config.additional_download_hosts opts + # in to. It exists to carry NO credential: the token is attached to + # self._client at CLIENT level, both as a header and (for Basic) as an + # httpx.Auth, and neither can be removed per request -- a per-request + # header can only replace Authorization, never delete it, and a + # per-request auth=None means "use the client default". Built here + # rather than on first use because two concurrent downloads would + # otherwise both construct one and leak the loser. + self._download_client: httpx.Client | None = None + if config.additional_download_hosts: + self._download_client = httpx.Client( + headers=self.download_headers(), + timeout=config.timeout, + verify=config.verify_ssl, + ) + + def _client_for(self, url: str) -> httpx.Client: + """The instance client, or the credential-free one for a foreign host.""" + if self._download_client is not None and self.is_offsite(url): + return self._download_client + return self._client def __enter__(self) -> Transport: return self @@ -205,17 +335,34 @@ def __exit__(self, *exc_info: object) -> None: self.close() def close(self) -> None: - self._client.close() - - def _do_send(self, spec: RequestSpec) -> Any: + try: + self._client.close() + finally: + if self._download_client is not None: + self._download_client.close() + + def _do_send( + self, spec: RequestSpec, params: Mapping[str, Any] | None + ) -> Any: response = self._client.request( - spec.method, self.build_url(spec.path), params=spec.params, json=spec.json + spec.method, + self.build_url(spec.path), + params=self.merge_params(params, spec.params), + json=spec.json, + headers=dict(spec.headers) if spec.headers else None, ) if self.is_retryable_status(response.status_code): raise _RetryableResponse(response) return self.finish(response) - def send(self, spec: RequestSpec) -> Any: + def send( + self, spec: RequestSpec, *, params: Mapping[str, Any] | None = None + ) -> Any: + """Execute ``spec``, with ``params`` layered under the spec's own. + + ``config.default_params`` sits under both -- see :meth:`merge_params` + for the full ordering. + """ retryer = Retrying( stop=stop_after_attempt(self.config.max_retries + 1), wait=wait_exponential(multiplier=0.5, max=10), @@ -223,16 +370,15 @@ def send(self, spec: RequestSpec) -> Any: reraise=True, ) try: - return retryer(self._do_send, spec) + return retryer(self._do_send, spec, params) except _RetryableResponse as exc: return self.finish(exc.response) except httpx.TransportError as exc: raise EasyvistaConnectionError(f"connection failed: {exc}") from exc def _do_get_bytes(self, path_or_url: str) -> bytes: - response = self._client.get( - self.resolve_url(path_or_url), follow_redirects=True - ) + url = self.resolve_url(path_or_url) + response = self._client_for(url).get(url, follow_redirects=True) if self.is_retryable_status(response.status_code): raise _RetryableResponse(response) if not response.is_success: @@ -245,7 +391,11 @@ def get_bytes(self, path_or_url: str) -> bytes: :meth:`BaseTransport.finish` always calls ``response.json()``, so binary responses need their own path. This one reuses the same retry policy and the same error mapping, so a 403 on an attachment still surfaces as - :class:`EasyvistaAuthError`. ``follow_redirects`` is on because a + :class:`EasyvistaAuthError`. ``config.default_params`` is deliberately + NOT applied: appending a query parameter to a signed download location is + a plausible way to invalidate it, and a date-format parameter is + meaningless on a fetch that returns bytes. ``follow_redirects`` is on + because a download URL commonly redirects to a signed location; httpx strips the ``Authorization`` header on a cross-origin redirect, so a foreign redirect degrades to an unauthenticated fetch rather than leaking the @@ -264,3 +414,117 @@ def get_bytes(self, path_or_url: str) -> bytes: self._raise_for_response(exc.response) except httpx.TransportError as exc: raise EasyvistaConnectionError(f"connection failed: {exc}") from exc + + def _open_stream( + self, path_or_url: str, chunk_size: int + ) -> tuple[httpx.Response, Iterator[bytes], list[bytes]]: + """Open a streaming GET and take its first chunk, as one retryable unit. + + Returns the still-open response, its chunk iterator, and the first chunk + wrapped in a list -- empty for an empty body, which is how "the body is + over" is distinguished from "there is a chunk" without a sentinel. + + Taking the first chunk *here* rather than in :meth:`stream_bytes` is the + whole point of this helper: everything inside it can be retried safely + because nothing it produces has reached the caller yet, so restarting + the request cannot deliver a byte twice. See :meth:`stream_bytes` for + the policy that rests on it. + + Two details are forced by streaming. The response must be closed on + every failure path, because an unread streaming response holds its + connection open. And :meth:`BaseTransport._raise_for_response` reads + ``.content``, which on a streaming response raises until the body has + actually been read -- hence the read before each raise, which is what + makes the error mapping identical to :meth:`get_bytes`. + """ + url = self.resolve_url(path_or_url) + # One client for both calls: build_request is what stamps the + # client-level headers onto the request, so building with the instance + # client and sending with the download one would put the credential back + # on a foreign fetch. + client = self._client_for(url) + response = client.send( + client.build_request("GET", url), + stream=True, + follow_redirects=True, + ) + try: + if self.is_retryable_status(response.status_code): + response.read() + raise _RetryableResponse(response) + if not response.is_success: + response.read() + self._raise_for_response(response) + chunks = response.iter_bytes(chunk_size) + first: list[bytes] = [] + for chunk in chunks: + first.append(chunk) + break + except BaseException: + response.close() + raise + return response, chunks, first + + def stream_bytes( + self, path_or_url: str, *, chunk_size: int = DEFAULT_STREAM_CHUNK_SIZE + ) -> Generator[bytes, None]: + """GET raw bytes (an attachment) in chunks, never as one object. + + The streaming twin of :meth:`get_bytes`, and deliberately identical to + it everywhere it can be: the same URL resolution through + :meth:`BaseTransport.resolve_url` (so a URL outside the configured + instance is refused here too), the same ``follow_redirects=True`` for + the signed-location hop, the same attempt count and backoff, and the + same error mapping -- a 403 on an attachment still raises + :class:`EasyvistaAuthError`, and a 590 is still not retried. What + differs is that the body is handed over in ``chunk_size`` pieces as it + arrives, so a large attachment never has to exist in memory whole. + + **Retrying stops as soon as a byte reaches the caller.** A retryable + status or a transport error while opening the download is retried like + any other request, and the first chunk is fetched inside that retried + unit so that a failure fetching it is still safe to restart. From that + chunk onwards the request is committed: a transport failure raises + :class:`EasyvistaConnectionError` instead of starting over, because + starting over would re-deliver bytes the caller already has. Nothing + resumes a partly consumed stream -- a caller that must survive a + mid-stream failure has to decide for itself whether to discard what it + collected and ask again, and this method will not make that choice by + silently duplicating data. + + No request is made until iteration begins. This is a generator, so a + refused URL -- and a non-positive ``chunk_size`` -- raises on the first + step rather than at the call. + """ + if chunk_size <= 0: + # Guarded here rather than left to httpx, which raises from inside + # its own ByteChunker: `chunk_size=0` surfaces as + # "ValueError: range() arg 3 must not be zero" and a negative one as + # "IndexError: list index out of range" -- both several frames below + # this client, so a caller computing a size (`total // n`, a config + # value that defaulted to 0) reads it as a library bug rather than + # bad input. + raise ValueError(f"chunk_size must be positive, got {chunk_size}") + retryer = Retrying( + stop=stop_after_attempt(self.config.max_retries + 1), + wait=wait_exponential(multiplier=0.5, max=10), + retry=retry_if_exception_type((_RetryableResponse, httpx.TransportError)), + reraise=True, + ) + opened: tuple[httpx.Response, Iterator[bytes], list[bytes]] + try: + opened = retryer(self._open_stream, path_or_url, chunk_size) + except _RetryableResponse as exc: + self._raise_for_response(exc.response) + except httpx.TransportError as exc: + raise EasyvistaConnectionError(f"connection failed: {exc}") from exc + response, chunks, first = opened + try: + for chunk in first: + yield chunk + for chunk in chunks: + yield chunk + except httpx.TransportError as exc: + raise EasyvistaConnectionError(f"connection failed: {exc}") from exc + finally: + response.close() diff --git a/easyvista_python_client/_sync/client.py b/easyvista_python_client/_sync/client.py index 737b273..7aaace4 100644 --- a/easyvista_python_client/_sync/client.py +++ b/easyvista_python_client/_sync/client.py @@ -10,24 +10,54 @@ from __future__ import annotations -from collections.abc import Iterator, Sequence +from collections.abc import Iterator, Iterable, Mapping, Sequence from datetime import datetime +from typing import Any from easyvista_python_client._sync._concurrency import Semaphore, settle -from easyvista_python_client._sync._transport import Transport +from easyvista_python_client._sync._transport import ( + DEFAULT_STREAM_CHUNK_SIZE, + Transport, +) from easyvista_python_client._transport import RequestSpec -from easyvista_python_client.config import EasyvistaConfig -from easyvista_python_client.context import TicketContext +from easyvista_python_client.config import DocumentDeletePathStyle, EasyvistaConfig +from easyvista_python_client.context import TicketContext, _degraded_entry from easyvista_python_client.directory import ( + DEPARTMENT_MEMO_FIELD, + DEPARTMENT_NAME_COLUMNS, + DEPARTMENT_NOTE_FIELDS, + RECENT_TICKET_FIELDS, RECENT_TICKETS_SORT, DepartmentContext, + _as_fields, _department_matches, _normalize_name, ) -from easyvista_python_client.exceptions import EasyvistaAuthError, EasyvistaNotFound +from easyvista_python_client.discovery import ( + DEFAULT_DISCOVERY_NAMES, + DiscoveredReference, + InstanceProfile, + ReferenceSource, + guids_from_sample, + merge_guids, + reference_from_table_row, + references_from_sample, + resolve_source, + sample_fields, +) +from easyvista_python_client.exceptions import ( + EasyvistaAuthError, + EasyvistaError, + EasyvistaNotFound, +) from easyvista_python_client.field_model import parse_memo from easyvista_python_client.filters import ev_equals_filter, is_safe_ev_value -from easyvista_python_client.models.action import Action, PostAction +from easyvista_python_client.models.action import ( + Action, + ActionUpdate, + PostAction, + PostTask, +) from easyvista_python_client.models.asset import Asset, PostAsset from easyvista_python_client.models.department import ( Department, @@ -40,8 +70,10 @@ EmployeeUpdate, PostEmployee, ) +from easyvista_python_client.models.generic import GenericRecord from easyvista_python_client.models.request import PostRequest, Request, RequestUpdate from easyvista_python_client.pagination import SearchResult +from easyvista_python_client.references import DEFAULT_LANGUAGE_ORDER from easyvista_python_client.reporting import ( DEFAULT_DIMENSIONS, TicketStatistics, @@ -51,9 +83,11 @@ from easyvista_python_client.resources import actions as actions_res from easyvista_python_client.resources import assets as assets_res from easyvista_python_client.resources import departments as departments_res +from easyvista_python_client.resources import discovery as discovery_res from easyvista_python_client.resources import documents as documents_res from easyvista_python_client.resources import employees as employees_res from easyvista_python_client.resources import requests as requests_res +from easyvista_python_client.resources.discovery import SWAGGER_PATH # Width of the action-body fan-out: a ceiling on requests in flight at once on # the async surface, inert on the sync one. This is the one fan-out here whose @@ -65,6 +99,27 @@ _ACTION_FANOUT = 8 +def _unavailable_reason(exc: EasyvistaError) -> str: + """One ``InstanceProfile.unavailable`` value, first token machine-readable. + + ``denied`` for 401/403, ``failed`` for everything else. The rest of the + string is for a human; split on the first space to branch on it. + + A 403 here does NOT prove the route is denied: this API answers 403 for a + path that does not exist as well as for one a profile blocks, so the reason + says "denied or absent" rather than asserting which. + """ + status = getattr(exc, "status_code", None) + if status in (401, 403): + return ( + f"denied HTTP {status} -- the profile may lack read access, or the " + "route may not exist on this deployment; this API answers 403 for " + "both. Check get_api_spec()['paths']." + ) + detail = f"HTTP {status}" if isinstance(status, int) else type(exc).__name__ + return f"failed {detail}" + + class EasyvistaClient: """Client for the EasyVista Service Manager REST API. @@ -75,6 +130,15 @@ class EasyvistaClient: def __init__(self, config: EasyvistaConfig) -> None: self.config = config self._transport = Transport(config) + # Built once and passed to every resource builder. ``None`` unless the + # caller named extra timestamp formats, so the default path calls + # ``model_validate(record, context=None)`` -- exactly what it always + # called. + self._validation_context: dict[str, Any] | None = ( + {"datetime_input_formats": config.datetime_input_formats} + if config.datetime_input_formats + else None + ) @classmethod def from_env(cls) -> EasyvistaClient: @@ -89,9 +153,68 @@ def __exit__(self, *exc_info: object) -> None: def close(self) -> None: self._transport.close() + # --- escape hatch -------------------------------------------------------- + def send( + self, + method: str, + path: str, + *, + params: Mapping[str, Any] | None = None, + json: Any = None, + headers: Mapping[str, str] | None = None, + ) -> Any: + """Issue an arbitrary request against this instance's API root. + + The escape hatch. This package wraps roughly ten of the paths the + instance's own OpenAPI document advertises -- about a hundred of them on + the verified 2025.3 instance, read from ``GET {api_root}/swagger`` (tier + 2: authoritative for that deployment, and another deployment may + advertise a different set). This reaches the rest without forking the + package: reference tables such as ``status``, ``urgency``, ``groups``, + ``locations`` and ``slas``, the external-table route, and whole families + like ``problems`` and ``known-errors``. + + ``path`` joins to ``config.api_root`` exactly as every built-in method's + path does; a leading ``/`` is stripped, so ``"status"`` and ``"/status"`` + address the same route. An absolute URL is **not** accepted, which is + what keeps the credential scoped to the configured instance by + construction. To fetch a URL the API handed back, use + :meth:`download_document` or :meth:`stream_document`. + + Everything else is shared with the typed methods: ``config.max_retries`` + attempts with the same backoff, and the same exception mapping -- 401 and + 403 to :class:`~easyvista_python_client.EasyvistaAuthError`, 404 to + :class:`~easyvista_python_client.EasyvistaNotFound`, 400 and 590 to + :class:`~easyvista_python_client.EasyvistaValidationError`, with 590 never + retried because it is a rejected request rather than a transient one. + ``config.default_params`` is merged under ``params``; ``headers`` is + merged over the client-level ones and may not carry ``Authorization``. + + Returns the decoded JSON body, or ``{}`` when the response has none. + Nothing is validated into a model and no envelope is unwrapped: the + caller owns the shape, which is the point -- there is no model for a + route this package does not wrap. + + Two cautions that apply to every route reached this way. A 590 on a + create may still have created the row, so retrying can duplicate it. And + this API answers a write with HTTP 200 while silently dropping fields it + did not accept, so a 200 is not a receipt -- re-read. + """ + return self._transport.send( + RequestSpec( + method.upper(), + path, + json=json, + headers=dict(headers) if headers else None, + ), + params=params, + ) + # --- tickets ------------------------------------------------------------- def create_ticket(self, ticket: PostRequest) -> Request: - spec, parse = requests_res.build_create_ticket(ticket) + spec, parse = requests_res.build_create_ticket( + ticket, context=self._validation_context + ) return parse(self._transport.send(spec)) def create_tickets(self, tickets: Sequence[PostRequest]) -> list[Request]: @@ -108,9 +231,42 @@ def create_tickets(self, tickets: Sequence[PostRequest]) -> list[Request]: # this into a fan-out. return [self.create_ticket(ticket) for ticket in tickets] - def get_ticket(self, rfc_number: str) -> Request: - spec, parse = requests_res.build_get_ticket(rfc_number) - return parse(self._transport.send(spec)) + def get_ticket( + self, + rfc_number: str, + *, + fields: str | list[str] | None = None, + params: Mapping[str, Any] | None = None, + ) -> Request: + """Fetch one ticket by RFC number. + + ``fields`` is a projection -- the same comma-separated column list + :meth:`search_tickets` takes. Left ``None`` it sends no ``fields`` + parameter at all, which is every request this method has ever sent. + + Pass it when one column poisons the whole record. A value the read + model refuses -- a timestamp in an unexpected format, say -- fails the + entire :class:`Request`, and there is otherwise no way to read the rest + of the ticket. + + One caveat, and it cuts against this parameter. The verified instance's + own OpenAPI declares ``fields`` on ``GET /requests`` (the list) but + **not** on ``GET /requests/{rfc_number}`` -- tier 2, read 2026-08-31 -- + so the item route may ignore it and return the full record anyway. It + costs one request to find out on your deployment. The route that *is* + declared to take a projection is the list one, and it reaches the same + ticket:: + + search_tickets( + search=ev_equals_filter("RFC_NUMBER", rfc), + fields=["RFC_NUMBER", "TITLE"], + max_rows=1, + ) + """ + spec, parse = requests_res.build_get_ticket( + rfc_number, fields=fields, context=self._validation_context + ) + return parse(self._transport.send(spec, params=params)) def search_tickets( self, @@ -120,13 +276,19 @@ def search_tickets( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + params: Mapping[str, Any] | None = None, ) -> SearchResult[Request]: if max_rows is None: max_rows = self.config.default_max_rows spec, parse = requests_res.build_search_tickets( - search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset + search=search, + fields=fields, + sort=sort, + max_rows=max_rows, + offset=offset, + context=self._validation_context, ) - return parse(self._transport.send(spec)) + return parse(self._transport.send(spec, params=params)) def iter_tickets( self, @@ -136,11 +298,27 @@ def iter_tickets( sort: str | None = None, page_size: int | None = None, max_records: int | None = None, + params: Mapping[str, Any] | None = None, ) -> Iterator[Request]: """Yield tickets across pages, following the API's offset pagination. Pages of ``page_size`` (default ``config.default_max_rows``) until the server reports no further page (``@next``) or ``max_records`` is reached. + + ``sort`` is forwarded to the wire and its token must be + **space-separated** — ``"LAST_UPDATE"`` or ``"LAST_UPDATE DESC"``. + ``"LAST_UPDATE:DESC"``, ``"-LAST_UPDATE"`` and ``"DESC(LAST_UPDATE)"`` + are each **silently ignored** (measured live): the server returns its + default order with no error, so an unsorted result looks sorted. This is + not validated locally, so the token is the caller's to get right. + + Sorting is not cosmetic when the filter selects rows that are changing: + an unsorted offset sweep over a change window can skip a record + permanently, and the two sort DIRECTIONS do not fail the same way -- + descending defers such a miss to the next sweep, ascending loses it. See + :func:`~easyvista_python_client.ev_since_filter`, which rules on the + direction and names the keyset alternative for a caller who cannot + tolerate even a deferred miss. """ if page_size is None: page_size = self.config.default_max_rows @@ -153,6 +331,7 @@ def iter_tickets( sort=sort, max_rows=page_size, offset=offset, + params=params, ) if not result.records: return @@ -174,6 +353,47 @@ def count_tickets(self, search: str | None = None) -> int: result = self.search_tickets(search=search, max_rows=1) return result.total_record_count + def _collect_tickets( + self, + *, + search: str | None, + fields: str | list[str] | None, + max_records: int | None, + ) -> tuple[list[Request], int | None]: + """Page tickets, returning them plus the first page's reported total. + + Exists because :meth:`iter_tickets` discards the envelope: it yields + records one at a time and has no way to also hand back + ``total_record_count``, which is what tells a capped aggregation how + large the population it sampled actually was. The paging is the same + offset walk :meth:`iter_tickets` performs and issues the same requests + for the same cap; it collects a whole page and trims at the end rather + than stopping mid-page, which changes nothing on the wire. + + The total is the server's count for ``search`` alone. Any client-side + date window is applied later, so it is not comparable with the + aggregated total when one is set. + """ + page_size = self.config.default_max_rows + offset = 0 + population_total: int | None = None + records: list[Request] = [] + while max_records is None or len(records) < max_records: + result = self.search_tickets( + search=search, fields=fields, max_rows=page_size, offset=offset + ) + if population_total is None: + population_total = result.total_record_count + if not result.records: + break + records.extend(result.records) + if result.next_url is None: + break + offset += len(result.records) + if max_records is not None: + del records[max_records:] + return records, population_total + def ticket_statistics( self, *, @@ -182,6 +402,7 @@ def ticket_statistics( created_since: datetime | str | None = None, created_until: datetime | str | None = None, max_records: int | None = 100, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, ) -> TicketStatistics: """Aggregate matching tickets into a total plus per-dimension breakdowns. @@ -189,32 +410,79 @@ def ticket_statistics( pass ``None`` to aggregate all) and groups them by each name in ``dimensions`` (default: all of ``DEFAULT_DIMENSIONS``). ``created_since`` / ``created_until`` apply an inclusive client-side window on the ticket's - creation date. When the cap truncates, the result describes the fetched - subset — use :meth:`count_tickets` for the true total. + creation date. + + When the cap truncates, the result describes the fetched subset and + ``TicketStatistics.truncated`` is ``True``; ``population_total`` carries + the server's own count for ``search``, read off the first page at no + extra request. ``truncated`` reports "the cap was reached", so it is + ``True`` for a population whose size is exactly the cap; compare it + against ``population_total`` when that distinction matters. + ``population_total`` is counted before any client-side + ``created_since``/``created_until`` window, so it is not comparable with + ``total`` when one is set. Delegates to the same pure :func:`aggregate_tickets` on both surfaces. The page is collected into a list first because that function consumes - a plain iterable, and the async surface's ``iter_tickets`` is an async - generator it cannot take directly. + a plain iterable. """ dims = DEFAULT_DIMENSIONS if dimensions is None else dimensions has_date_filter = created_since is not None or created_until is not None fields = fields_for_references(dims, include_creation_date=has_date_filter) - tickets = [ - t - for t in self.iter_tickets( - search=search, fields=fields, max_records=max_records - ) - ] - return aggregate_tickets( + tickets, population_total = self._collect_tickets( + search=search, fields=fields, max_records=max_records + ) + stats = aggregate_tickets( tickets, dimensions=dims, created_since=created_since, created_until=created_until, + languages=languages, ) + # aggregate_tickets is offline and knows nothing about pages or caps, + # so these two are stamped here rather than computed in there. + stats.truncated = max_records is not None and len(tickets) >= max_records + stats.population_total = population_total + return stats def update_ticket(self, rfc_number: str, update: RequestUpdate) -> Request: - spec, parse = requests_res.build_update_ticket(rfc_number, update) + """Update a ticket's writable fields. + + Cannot set a status: there is no flat status update on this API. See + :meth:`set_status`, and :class:`RequestUpdate` for the measurements. + """ + spec, parse = requests_res.build_update_ticket( + rfc_number, update, context=self._validation_context + ) + return parse(self._transport.send(spec)) + + def set_status( + self, rfc_number: str, *, status_guid: str, comment: str | None = None + ) -> Request: + """Set a ticket's status, addressed by ``STATUS_GUID``. + + This is the API's only working status write, and it reaches **every** + status rather than only terminal ones: given six different status GUIDs + in turn, a fresh ticket landed on exactly the status requested every + time, non-terminal ones included. + + It sends the documented ``{"closed": {"status_GUID": ...}}`` body -- the + same request :meth:`close_ticket` sends, under a name that matches what + it does, because "close" is what the wire calls it and not what it is + limited to. + + Note the addressing. A ``STATUS_GUID`` is not a ``STATUS_ID``; the two + are different columns, and only the GUID works here. Read a status's GUID + off any ticket in that status (the nested ``STATUS`` object carries + ``STATUS_GUID``) -- they are stable per instance but are **not** + portable between instances. + """ + spec, parse = requests_res.build_set_status( + rfc_number, + status_guid=status_guid, + comment=comment, + context=self._validation_context, + ) return parse(self._transport.send(spec)) def close_ticket( @@ -222,14 +490,68 @@ def close_ticket( rfc_number: str, *, status_guid: str | None = None, - delete_actions: int | None = None, + delete_actions: int | bool | None = None, comment: str | None = None, + end_date: str | None = None, + catalog_guid: str | None = None, ) -> Request: + """Close a ticket, via the vendor's documented close route. + + Sends ``PUT requests/{rfc}`` with a ``closed`` wrapper -- + https://docs.easyvista.com/docs/rest-api-close-an-incident-request.md. + Every argument is optional. With no ``end_date`` the server stamps now. + With no ``status_guid`` this client sends no status of its own -- but + **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, every one of them passing an explicit + ``status_guid``. Try it on a throwaway ticket and re-read before + relying on it (open item O-CLOSE-DEFAULT). + + **Verify the close by re-reading the status, not by the return value.** + A status id is per-instance configuration and nothing about it is + guessable: on the verified instance ``8`` is *Cloturé* and ``12`` is + *En cours* -- adjacent numbers, opposite meanings. Code that infers + "closed" from an id it did not read off that instance will eventually + skip a ticket it believed was already closed. Read + ``get_ticket(rfc).status_id`` (or ``.reference("STATUS")`` for the + label) afterwards, and compare against a status you resolved from the + instance rather than a constant:: + + client.close_ticket(rfc, status_guid=CLOSED_GUID) + after = client.get_ticket(rfc) + assert after.end_date_ut is not None # the close actually landed + + ``end_date_ut`` is the more portable signal than any status id: it is + empty while a ticket is being worked and stamped once it is finished. + Note the boundary is **resolution, not closure** — measured 2026-09-02 + on one instance (one instance, one date, so it may not generalise), a + ticket that reached *Résolu* already carried an ``end_date_ut``, and + closing it afterwards left that original stamp untouched rather than + re-stamping it. So a populated ``end_date_ut`` means "resolved or + closed", which is the right test for "stop working this ticket" but + **not** a test for "closed" specifically. Nothing in this package + distinguishes the two without resolving the status against the + instance. + + ``status_guid`` reaches **any** status, not only terminal ones -- see + :meth:`set_status`, which is this same request under a name that says + so. ``catalog_guid`` requalifies the ticket as it closes. + ``delete_actions`` drops its actions. + + ``end_date`` takes the instance's own date format, which is not ISO 8601 + everywhere (``dd/mm/yyyy`` on the verified instance -- read + ``DATE_FORMAT`` off any employee record), so it is a string this client + passes through rather than a ``datetime`` it would have to format on a + guess. + """ spec, parse = requests_res.build_close_ticket( rfc_number, status_guid=status_guid, delete_actions=delete_actions, comment=comment, + end_date=end_date, + catalog_guid=catalog_guid, + context=self._validation_context, ) return parse(self._transport.send(spec)) @@ -242,29 +564,368 @@ def create_action(self, rfc_number: str, action: PostAction) -> Action: ``ACTION_ID`` (verified live). To address the action you just created, diff :meth:`list_actions` across the call — see ``integration_tests/test_live_ticket_history.py`` for the pattern. + + **For a comment, use :meth:`create_task` instead.** An action is created + **open** — work still to do — and an open action shows in the UI as a + pending row with its text NOT displayed, which reads as though the note + was lost. Only an *ended* action becomes a readable history entry. + :meth:`create_task` posts the same record already ended, in one call. + Reach for ``create_action`` only when you genuinely mean "someone must + still do this", and finish it with :meth:`end_action` — which also + advances the ticket's workflow when the action is a workflow step, so + read that method before calling it. + + Public versus internal is the ``action_type_id``, not a flag on the + body — see :class:`~easyvista_python_client.PostAction`. Put the text a + person must read in ``description``: it shadows ``comment`` in the UI. + + **This route resolves an implicit PARENT action, and that is what most + rejections are about.** Measured 2026-09-01 on one instance (one + instance, one date, may not generalise), the outcome tracks how many + actions are currently **open** on the ticket: + + ================= ========================================== + open actions result + ================= ========================================== + 0 ``590 Parent action not found or incorrect`` + exactly 1 succeeds + 2 or more ``590 Ambiguous query : many parent actions found`` + ================= ========================================== + + Passing ``parent_action_id`` for an **open** action succeeds at any + count; naming an **ended** one is refused. A fresh ticket carries + exactly one open workflow action, and every status change ends the + ticket's open actions — which is why an otherwise valid payload can be + refused on a ticket that accepted the same body earlier. The messages + are literal, not a stage gate. :meth:`create_task` is not + parent-resolved and is unaffected. """ - spec, parse = actions_res.build_create_action(rfc_number, action) + spec, parse = actions_res.build_create_action( + rfc_number, action, context=self._validation_context + ) return parse(self._transport.send(spec)) - def list_actions(self, rfc_number: str) -> list[Action]: - spec, parse = actions_res.build_list_actions(rfc_number) + def create_task(self, rfc_number: str, task: PostTask) -> Action: + """Create a task on a ticket — an action that arrives already ENDED. + + **This is how you post a comment.** A task and an action are the same + underlying record, created in different states: an action starts open + (a pending row whose text the UI does not display), a task starts + ended, so it lands in the ticket's history with its text visible. One + call, no termination step. Verified live 2026-08-28: tasks came back + with ``END_DATE_UT`` and ``STATUS_ID_ON_TERMINATE`` already set. + + **Put that text in ``description``, not ``comment``**, despite the + name. :class:`~easyvista_python_client.PostTask` accepts both, but the + UI renders one field per action — ``description``, falling back to + ``comment`` only when the description memo is empty (measured in the UI + 2026-09-01 on one instance; one instance, one date, may not + generalise). A task carrying both shows only the description, so a note + split across the two loses half of itself with no error. + + Public versus internal is carried by ``action_type_id`` — the type's + own ``ACTION_LABEL_*`` columns say which it is. Unlike + :meth:`create_action`, this route is **not** parent-resolved: it needs + no ``parent_action_id`` and works on a ticket at any stage, including + one whose open actions a status change has already drained. + + Like :meth:`create_action`, the returned :class:`Action` carries **no + usable ``action_id``** — the create response is an HREF naming the + parent request. Diff :meth:`list_actions` across the call to address + what you just created. + """ + spec, parse = actions_res.build_create_task( + rfc_number, task, context=self._validation_context + ) return parse(self._transport.send(spec)) - def get_action(self, action_id: str | int) -> Action: + def list_actions( + self, + rfc_number: str, + *, + fields: Iterable[str] | str | None = None, + params: Mapping[str, Any] | None = None, + ) -> list[Action]: + """List a ticket's actions. + + The default projection is slim: it carries ``ACTION_ID``, + ``ACTION_LABEL_FR``, ``ACTION_NUMBER``, ``DONE_BY_ID`` and + ``EXPECTED_START_DATE_UT`` but **no** ``CREATION_DATE_UT`` or + ``LAST_UPDATE``. Pass ``fields`` to project them onto the list and read + a page of actions' timestamps and authors in one request rather than one + item fetch each:: + + actions = client.list_actions( + rfc, + fields=["ACTION_ID", "ACTION_TYPE_ID", "CREATION_DATE_UT", + "LAST_UPDATE", "DONE_BY_ID"], + ) + + **Returns at most ONE page and does not paginate.** The cap is + ``config.default_max_rows``; a ticket with more actions than that is + **truncated with no error**, and this method discards the envelope's + total, so a caller cannot detect the truncation from the result. This is + not hypothetical: a freshly created ticket already carries about twelve + actions, most of them workflow-generated. Use + :meth:`iter_actions` to page the whole log, or raise + ``EasyvistaConfig.default_max_rows`` to widen this single page. + + The note text is never projectable — ``DESCRIPTION`` and ``COMMENT`` + are Memo sub-resources and come back as HREF objects under every + projection, so a body still costs one :meth:`resolve_memo` per action. + + ``fields`` has two more silent footguns: ``"*"`` is not a wildcard — + it silently reduces to ``ACTION_ID`` alone — and a dotted path such as + ``DESCRIPTION.HREF`` is silently dropped. + """ + spec, parse = actions_res.build_list_actions( + rfc_number, + fields=fields, + max_rows=self.config.default_max_rows, + context=self._validation_context, + ) + return parse(self._transport.send(spec, params=params)) + + def iter_actions( + self, + rfc_number: str, + *, + fields: Iterable[str] | str | None = None, + page_size: int | None = None, + max_records: int | None = None, + params: Mapping[str, Any] | None = None, + ) -> Iterator[Action]: + """Yield a ticket's actions across pages, following offset pagination. + + Pages of ``page_size`` (default ``config.default_max_rows``) until the + server reports no further page (``@next``) or ``max_records`` is + reached. ``fields`` and the ticket filter apply to every page. See + :meth:`list_actions` for what a projection can and cannot reach. + + A blank or unsafe ``rfc_number`` raises ``ValueError`` on the first + iteration rather than at the call, since this is a generator. + + .. warning:: + + The ``offset``/``@next`` contract is unverified on the ``actions`` + endpoint. If an instance ignores ``offset``, page two repeats page + one and the sweep never ends; bound it with ``max_records``. + """ + if page_size is None: + page_size = self.config.default_max_rows + offset = 0 + yielded = 0 + while max_records is None or yielded < max_records: + spec, parse = actions_res.build_search_actions( + rfc_number, + fields=fields, + max_rows=page_size, + offset=offset, + context=self._validation_context, + ) + result = parse(self._transport.send(spec, params=params)) + if not result.records: + return + for record in result.records: + yield record + yielded += 1 + if max_records is not None and yielded >= max_records: + return + if result.next_url is None: + return + offset += len(result.records) + + def get_action( + self, action_id: str | int, *, params: Mapping[str, Any] | None = None + ) -> Action: """Fetch one action, including the Memo links ``list_actions`` omits. The note text lives behind :attr:`Action.description`'s href on this - record; :meth:`get_ticket_context` resolves it for you. + record — but only while that memo has text. ``comment`` is a second + href beside it, and it matters when ``description`` resolves empty: + the UI renders one field per action, falling back to ``comment`` + exactly then (measured in the UI 2026-09-01 on one instance; one + instance, one date, may not generalise). To reproduce what a person + saw, resolve ``description`` first and ``comment`` only if it comes + back empty. :meth:`get_ticket_context` applies that rule for you. + """ + spec, parse = actions_res.build_get_action( + action_id, context=self._validation_context + ) + return parse(self._transport.send(spec, params=params)) + + def update_action(self, action_id: str | int, update: ActionUpdate) -> Action: + """Edit an existing action's note text. + + ``ActionUpdate`` carries two fields, ``description`` and ``comment``, + and they are **not** interchangeable to a reader. The UI renders one + text field per action — ``description``, falling back to ``comment`` + only when the description memo is empty (measured in the UI 2026-09-01 + on one instance; one instance, one date, may not generalise). So + ``ActionUpdate(comment=...)`` on an action that already has a + description returns 200, re-reads cleanly through the API, and changes + nothing anyone sees. **Write ``description`` to change what a person + reads.** + + Live-verified 2026-08-17 by re-reading the memo afterwards, not by the + status code, and again 2026-09-01 on an action that had already been + **ended**, where the new ``description`` rendered in the history — an + ended action is not frozen, and this is how to correct or extend a + resolution visibly. Note that an action can be edited but **not + deleted**: the + instance OpenAPI document (``GET {api_root}/swagger``, read 2026-08-27) + declares only GET, PUT and PATCH on ``actions/{id}``, no DELETE, so + there is deliberately no ``delete_action``. The 403 an earlier note + recorded for that verb is what this API answers for an absent route as + well as a denied one, so it did not distinguish them. + + The returned :class:`Action` is the API's own echo and is **not + verified**: the PUT's response body has never been captured, and if it + answers empty or href-only the parser yields an ``Action`` whose fields + are all ``None``. Re-read with :meth:`get_action` rather than reading + fields off the return value. """ - spec, parse = actions_res.build_get_action(action_id) + spec, parse = actions_res.build_update_action( + action_id, update, context=self._validation_context + ) return parse(self._transport.send(spec)) - def _resolve_action_body(self, action: Action) -> Action: - """Return ``action`` with its note text resolved onto ``description``. + def end_action( + self, + rfc_number: str, + *, + action_id: str | int | None = None, + end_all: bool = False, + end_date: str | None = None, + start_date: str | None = None, + elapsed_time: int | str | None = None, + doneby_mail: str | None = None, + ) -> Action: + """Report an action as done — the step :meth:`create_action` leaves open. + + An action is born **open** and its text does not render in the ticket + history until it is ended, so this is what turns a ``create_action`` + into something a person can read. A comment needs neither call: + :meth:`create_task` posts an already-ended record in one request. + + Addressed by the **ticket**, not the action: the path segment is + ``rfc_number`` and the action is named in the body. Passing an action + id as the path answers 404 even with the id also in the body (measured + 2026-09-01). + + .. warning:: + + **Ending the ticket's own workflow action advances the workflow.** + This call is not bookkeeping. Measured 2026-09-01 on one instance + (Service Manager 2025.3 — one instance, one date, so it may not + generalise), ending a fresh ticket's open type-20 *Traitement + Operation* action moved the **ticket** from *En cours* to *Résolu* + and spawned a new open type-1 *Validation Self Service* action + (2 tickets, 2/2). Controls the same day and the next showed the + opposite for an action the caller had created: ending a type-94 + action left the ticket's status and its action count untouched + (3 tickets, 3/3). So ending your own action changes that action + only — it still ends it, which is the whole point, and its text + then renders — while ending a workflow step also changes the + ticket. Status ids are per-instance, so treat 12 and 2 as this + deployment's, not as values to hardcode. + + **This is why ``action_id`` is required.** The vendor documents + the id-less form as ending *every open action on the ticket*, which + on a ticket whose only open action is its workflow step means + resolving it. That form is reachable only through ``end_all=True``; + a bare ``action_id=None`` raises ``ValueError`` before any request. + The guard exists because ``Action.action_id`` is legitimately + ``None`` all over this package — :meth:`create_action`'s response + carries no id, and a ``fields=`` projection without ``ACTION_ID`` + drops it — so an id a caller thought they had would otherwise + select the bulk form in silence. Only ``end_all=True`` was measured + with a single open action, so *how* it behaves against several open + at once is vendor-documented, not measured here. + + Both dates are passed through as **strings**, because the accepted + format follows the instance rather than a standard: it is not ISO 8601 + on every deployment, and accepting a ``datetime`` would mean this + package formatting one on a guess. The date part is the instance's own + ``DATE_FORMAT`` (readable off any employee record); the time part is + not covered by that column, so the whole accepted spelling has to be + measured per deployment. On the verified instance it is + ``dd/mm/yyyy hh:mm:ss`` — ``dd/mm/yyyy hh:mm`` also works, a bare + ``dd/mm/yyyy`` lands at midnight, and **ISO 8601 is refused** with HTTP + 590 "Invalid End Date" (measured 2026-09-01/02 on one instance, so it + may not generalise; ``close_ticket``'s ``end_date`` documents the + date-only spelling from an earlier measurement of the same instance). + + **Send ``start_date`` explicitly.** Left out, the server derives it as + ``end_date`` minus ``elapsed_time`` and then minus the instance's UTC + offset, so the stored start is early by that offset — one hour or two + depending on DST (measured 2026-09-01 against a +02:00 instance and + against a February date at +01:00; confirmed to the second 2026-09-02, + where ``end_date`` 08:14:35 with ``elapsed_time`` 15 stored a start of + 05:59:35). An explicit ``start_date`` is stored faithfully. + + ``elapsed_time`` is a number of **minutes**, and it is neither derived + nor cross-checked. Measured 2026-09-02 on one instance (one instance, + one date, so it may not generalise): + + * Omitted entirely, it stays **empty** — the server does not compute it + from your two dates, so send it if you want one. + * Sent, it is stored **as given, even when it contradicts the dates**: + 60 was stored against a 15-minute window. Both ``60`` and ``"7"`` + were honoured, so the type does not matter. + * The one exception: when ``start_date`` **equals** ``end_date``, the + stored value is ``0`` whatever you send (3/3). A zero-length window + silently discards it, which is easy to hit by passing the same + timestamp to both. + + Raises :class:`~easyvista_python_client.EasyvistaValidationError` (HTTP + 590, EV code 2013) with ``Action not found`` when **no open action + matches** — which is what replaying this call against an + already-ended action looks like. An earlier version of this package's + documentation read that message as a profile restriction to raise with + an administrator; that was wrong, and ending an open action succeeds. + + ``doneby_mail`` attributes the work to somebody other than the + authenticating account; left out, the API credits that account. + + The returned :class:`Action` carries **only ``href``**: the measured + response is href-only and names the parent *request*, exactly like + :meth:`create_action`'s, so ``action_id`` and every other field are + ``None`` — and because that href's tail is an RFC number rather than a + numeric id, no id is derived from it either. Re-read with + :meth:`get_action` to confirm ``END_DATE_UT``; a 200 is not a receipt + on this API. + """ + spec, parse = actions_res.build_end_action( + rfc_number, + action_id=action_id, + end_all=end_all, + end_date=end_date, + start_date=start_date, + elapsed_time=elapsed_time, + doneby_mail=doneby_mail, + context=self._validation_context, + ) + return parse(self._transport.send(spec)) - Costs two requests per action (item fetch, then the Memo), so callers - that do not need bodies pass ``resolve_action_bodies=False``. Degrades - to the unresolved record on 403/404 rather than failing the bundle. + def _resolve_action_body(self, action: Action) -> Action: + """Return ``action`` with its note text resolved onto the memo that shows. + + Resolves ``DESCRIPTION``, and ``COMMENT`` **only when ``DESCRIPTION`` + comes back empty** -- mirroring what a reader sees. The UI renders one + text field per action, under a header reading "comment or description": + ``DESCRIPTION`` when it has text, falling back to ``COMMENT`` when it + does not (measured in the UI 2026-09-01 on one instance, Service + Manager 2025.3 -- one instance, one date, so it may not generalise). + Resolving ``DESCRIPTION`` alone dropped the body of exactly the actions + a human *can* read, so a ticket exported through + :meth:`get_ticket_context` disagreed with the ticket on screen. + + Costs two requests per action (item fetch, then the Memo), and a third + only in that fallback case, so a populated description never pays for + it. Callers that do not need bodies pass ``resolve_action_bodies=False``. + Degrades to the unresolved record on 403/404 rather than failing the + bundle. """ if action.action_id is None: return action @@ -275,16 +936,25 @@ def _resolve_action_body(self, action: Action) -> Action: if isinstance(full.description, dict): href = full.description.get("HREF") full.description = self._safe_memo(href) if href else None + if not full.description and isinstance(full.comment, dict): + href = full.comment.get("HREF") + full.comment = self._safe_memo(href) if href else None return full # --- assets -------------------------------------------------------------- def create_asset(self, asset: PostAsset) -> Asset: - spec, parse = assets_res.build_create_asset(asset) + spec, parse = assets_res.build_create_asset( + asset, context=self._validation_context + ) return parse(self._transport.send(spec)) - def get_asset(self, asset_id: str) -> Asset: - spec, parse = assets_res.build_get_asset(asset_id) - return parse(self._transport.send(spec)) + def get_asset( + self, asset_id: str, *, params: Mapping[str, Any] | None = None + ) -> Asset: + spec, parse = assets_res.build_get_asset( + asset_id, context=self._validation_context + ) + return parse(self._transport.send(spec, params=params)) def search_assets( self, @@ -294,13 +964,19 @@ def search_assets( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + params: Mapping[str, Any] | None = None, ) -> SearchResult[Asset]: if max_rows is None: max_rows = self.config.default_max_rows spec, parse = assets_res.build_search_assets( - search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset + search=search, + fields=fields, + sort=sort, + max_rows=max_rows, + offset=offset, + context=self._validation_context, ) - return parse(self._transport.send(spec)) + return parse(self._transport.send(spec, params=params)) def iter_assets( self, @@ -310,6 +986,7 @@ def iter_assets( sort: str | None = None, page_size: int | None = None, max_records: int | None = None, + params: Mapping[str, Any] | None = None, ) -> Iterator[Asset]: """Yield assets across pages (see :meth:`iter_tickets`).""" if page_size is None: @@ -323,6 +1000,7 @@ def iter_assets( sort=sort, max_rows=page_size, offset=offset, + params=params, ) if not result.records: return @@ -340,14 +1018,59 @@ def add_document( self, rfc_number: str, *, filename: str, content: bytes ) -> Document: spec, parse = documents_res.build_add_document( - rfc_number, filename=filename, content=content + rfc_number, + filename=filename, + content=content, + context=self._validation_context, ) return parse(self._transport.send(spec)) def list_documents(self, rfc_number: str) -> list[Document]: - spec, parse = documents_res.build_list_documents(rfc_number) + spec, parse = documents_res.build_list_documents( + rfc_number, context=self._validation_context + ) return parse(self._transport.send(spec)) + def delete_document( + self, + rfc_number: str | None, + document_id: str | int | Document, + *, + path_style: DocumentDeletePathStyle | None = None, + ) -> None: + """Remove an attachment from a ticket. + + ``document_id`` is the ``DOCUMENT_ID`` from :meth:`list_documents`, or + the :class:`Document` itself, whose ``DOCUMENT_ID`` is read off it -- a + record carrying none raises ``ValueError`` rather than sending a request + that would address the collection. Returns nothing: the API answers with + an empty body, so re-list to confirm. + + Two routes exist for this. The instance OpenAPI document read 2026-08-27 + declares DELETE on both ``requests/{rfc}/documents/{id}`` and + ``documents/{id}``, marking only the second ``deprecated``, so which one + works is a profile question rather than a routing one. ``path_style`` + picks one for this call and defaults to + :attr:`EasyvistaConfig.document_delete_path_style`, itself ``"nested"`` + -- the form verified live 2026-08-17 by re-listing the ticket's + documents afterwards, on one instance, which may not generalise. Under + ``"top_level"`` the id addresses the record on its own and + ``rfc_number`` is unused: pass ``None``. + """ + if isinstance(document_id, Document): + if not document_id.document_id: + raise ValueError( + "this Document carries no DOCUMENT_ID; pass the id directly" + ) + document_id = document_id.document_id + if path_style is None: + path_style = self.config.document_delete_path_style + self._transport.send( + documents_res.build_delete_document( + rfc_number, document_id, path_style=path_style + ) + ) + def download_document(self, document: Document | str) -> bytes: """Fetch an attachment's bytes. @@ -359,10 +1082,82 @@ def download_document(self, document: Document | str) -> bytes: """ return self._transport.get_bytes(documents_res.download_href(document)) + def stream_document( + self, document: Document | str, *, chunk_size: int = DEFAULT_STREAM_CHUNK_SIZE + ) -> Iterator[bytes]: + """Fetch an attachment's bytes in chunks, without holding the file whole. + + Accepts exactly what :meth:`download_document` accepts -- a + :class:`Document` from :meth:`list_documents` or a raw href/path -- and + resolves it identically, refusing a URL outside the configured instance + for the same reason. Use this when the bytes are on their way somewhere + else in pieces (a file on disk, a hash, another API) and + :meth:`download_document` when a single ``bytes`` object is what you + wanted anyway. + + Called ``stream_`` rather than ``iter_`` on purpose: every ``iter_*`` + method on this client iterates *records*, and this iterates the bytes of + one document. + + The opposite direction cannot stream at all. :meth:`add_document` sends + base64 inside a JSON body, so an upload has to materialise the whole + payload however it is called; the asymmetry is the API's, not an + oversight here. + + Retrying covers opening the download only. Once the first chunk has been + handed over the request is committed, and a transport failure raises + :class:`~easyvista_python_client.exceptions.EasyvistaConnectionError` + rather than starting again -- starting again would re-deliver bytes the + caller already has. A partly consumed stream is never resumed, so + deciding what to do with a mid-stream failure is the caller's. See + :meth:`~easyvista_python_client._sync._transport.Transport.stream_bytes`. + + Nothing is requested until iteration begins: this is a generator, so + :class:`ValueError` for a record carrying no download URL and + :class:`EasyvistaError` for one pointing off the instance both surface + on the first step rather than at the call. + + **Stopping early:** on the async surface a bare ``break`` leaves the + response checked out of the connection pool until the event loop's + async-generator finalizer runs, which is a garbage-collection cycle away + (measured) -- so a caller that reads only a prefix of many attachments + under a bounded ``max_connections`` can stall on connections it appears + to have released. Close the generator instead (``aclose()``, or + ``contextlib.aclosing``). On the sync surface refcounting releases it at + the ``break`` and nothing is needed. + """ + stream = self._transport.stream_bytes( + documents_res.download_href(document), chunk_size=chunk_size + ) + # Close the inner generator in a `finally` rather than leaving it to be + # collected. `stream`'s own `finally` is what releases the response and + # returns its connection to the pool, and unwinding *this* generator + # does not reach it on its own -- the loop below simply exits. + # + # What this buys, stated precisely: one deferral instead of two. Closing + # this generator -- explicitly, or by an exception propagating out of it + # -- now releases the response at once. A bare `break` still defers on + # the async surface, because unwinding this generator is itself left to + # the event loop's async-generator finalizer; what the `finally` removes + # is the *second* wait, for `stream` to become garbage in its own right. + # On the sync surface refcounting closes it promptly either way. + # `contextlib.closing`/`aclosing` would say this in one line, but those + # two names differ by more than a token so the codegen cannot generate + # the pair; `stream.close()`/`stream.aclose()` it can. + try: + for chunk in stream: + yield chunk + finally: + stream.close() + # --- departments ---------------------------------------------------------- - def get_department(self, department_id: str | int) -> Department: - spec, parse = departments_res.build_get_department(department_id) - return parse(self._transport.send(spec)) + def get_department( + self, department_id: str | int, *, params: Mapping[str, Any] | None = None + ) -> Department: + spec, parse = departments_res.build_get_department( + department_id, context=self._validation_context + ) + return parse(self._transport.send(spec, params=params)) def search_departments( self, @@ -372,13 +1167,19 @@ def search_departments( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + params: Mapping[str, Any] | None = None, ) -> SearchResult[Department]: if max_rows is None: max_rows = self.config.default_max_rows spec, parse = departments_res.build_search_departments( - search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset + search=search, + fields=fields, + sort=sort, + max_rows=max_rows, + offset=offset, + context=self._validation_context, ) - return parse(self._transport.send(spec)) + return parse(self._transport.send(spec, params=params)) def iter_departments( self, @@ -388,6 +1189,7 @@ def iter_departments( sort: str | None = None, page_size: int | None = None, max_records: int | None = None, + params: Mapping[str, Any] | None = None, ) -> Iterator[Department]: """Yield departments across pages (see :meth:`iter_tickets`).""" if page_size is None: @@ -401,6 +1203,7 @@ def iter_departments( sort=sort, max_rows=page_size, offset=offset, + params=params, ) if not result.records: return @@ -413,26 +1216,53 @@ def iter_departments( return offset += len(result.records) - def get_department_comment(self, department_id: str | int) -> str | None: + def get_department_comment( + self, + department_id: str | int, + *, + memo_field: str = DEPARTMENT_MEMO_FIELD, + ) -> str | None: """Return the department's note (a Memo). ``""`` for an empty note; propagates transport errors so a 403/404 is distinguishable from an empty note (uses the generic ``resolve_memo``). + + ``memo_field`` is the last path segment of + ``GET departments/{id}/{comment}``. In the instance OpenAPI document + read 2026-08-27 that segment is a path *parameter* named ``comment``, + not a literal -- the sibling ``GET requests/{rfc_number}/{comment}`` + describes the same parameter as "Memo field type, could be comment, + description". So the route selects a memo column, and the default is + only the column the verified instance carries. Same idea as + :meth:`get_ticket_context`'s ``memo_fields``. """ - return self.resolve_memo( - f"departments/{department_id}/comment_department" - ) + return self.resolve_memo(f"departments/{department_id}/{memo_field}") def find_departments( - self, name: str, *, limit: int | None = None + self, + name: str, + *, + limit: int | None = None, + by: str | Sequence[str] = "auto", ) -> list[Department]: """Resolve departments by a fuzzy, language-agnostic ``name``. - Fast path (neutral): an all-digit ``name`` matches ``DEPARTMENT_ID`` exactly, - otherwise ``DEPARTMENT_CODE`` exactly; a hit returns immediately. Fuzzy - fallback: scan every department and match ``name`` — normalized so - ``"Acme Corp" == "ACME-CORP" == "acmecorp"`` — as a substring of any - string field. ``limit`` caps the result count. Returns ``[]`` on no match. + Fast path (server-side, exact): ``by`` names the columns to try, in + order, and the first one that returns records wins. ``"auto"`` tries + ``DEPARTMENT_CODE`` alone for a name containing anything but digits, + and ``DEPARTMENT_CODE`` then ``DEPARTMENT_ID`` for an all-digit one -- + code first, because a department whose code is all digits would + otherwise be looked up as an id and a different department would come + back with no error. That costs one extra round trip when the digits are + an id and not a code. Pass a single column name to pin one lookup + (``by="DEPARTMENT_ID"``), an ordered sequence to choose your own, or an + empty sequence to skip the fast path. + + Fuzzy fallback: scan every department and match ``name`` -- normalized + so ``"Acme Corp" == "ACME-CORP" == "acmecorp"``, and accent- and + case-folded so ``"Systemes"`` matches the same name written with its + accents -- as a substring of any string field. ``limit`` caps the result + count. Returns ``[]`` on no match. A ``name`` that cannot be expressed in EasyVista's search grammar (see :func:`~easyvista_python_client.is_safe_ev_value`) skips the server fast @@ -443,10 +1273,22 @@ def find_departments( # expressed server-side would otherwise be interpolated raw — where a ',' # silently widens the result set. Such names skip straight to the local # scan below, which handles any characters. - field = "DEPARTMENT_ID" if name.isdigit() else "DEPARTMENT_CODE" + # + # `isinstance(by, str)` is checked BEFORE the sequence branch, or + # `by="DEPARTMENT_ID"` would be read as eleven single-character columns. + if by == "auto": + columns: tuple[str, ...] = ( + DEPARTMENT_NAME_COLUMNS if name.isdigit() else ("DEPARTMENT_CODE",) + ) + elif isinstance(by, str): + columns = (by,) + else: + columns = tuple(by) if is_safe_ev_value(name): - search = ev_equals_filter(field, name) - if search is not None: + for column in columns: + search = ev_equals_filter(column, name) + if search is None: + continue fast = self.search_departments(search=search) if fast.records: return fast.records if limit is None else fast.records[:limit] @@ -463,20 +1305,28 @@ def find_departments( def create_department(self, department: PostDepartment) -> Department: """Create a department (provisional; profile-gated — spec open item O-DIR-2).""" - spec, parse = departments_res.build_create_department(department) + spec, parse = departments_res.build_create_department( + department, context=self._validation_context + ) return parse(self._transport.send(spec)) def update_department( self, department_id: str | int, update: DepartmentUpdate ) -> Department: """Update a department via PUT (provisional; profile-gated).""" - spec, parse = departments_res.build_update_department(department_id, update) + spec, parse = departments_res.build_update_department( + department_id, update, context=self._validation_context + ) return parse(self._transport.send(spec)) # --- employees ------------------------------------------------------------ - def get_employee(self, employee_id: str | int) -> Employee: - spec, parse = employees_res.build_get_employee(employee_id) - return parse(self._transport.send(spec)) + def get_employee( + self, employee_id: str | int, *, params: Mapping[str, Any] | None = None + ) -> Employee: + spec, parse = employees_res.build_get_employee( + employee_id, context=self._validation_context + ) + return parse(self._transport.send(spec, params=params)) def search_employees( self, @@ -486,13 +1336,19 @@ def search_employees( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + params: Mapping[str, Any] | None = None, ) -> SearchResult[Employee]: if max_rows is None: max_rows = self.config.default_max_rows spec, parse = employees_res.build_search_employees( - search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset + search=search, + fields=fields, + sort=sort, + max_rows=max_rows, + offset=offset, + context=self._validation_context, ) - return parse(self._transport.send(spec)) + return parse(self._transport.send(spec, params=params)) def iter_employees( self, @@ -502,6 +1358,7 @@ def iter_employees( sort: str | None = None, page_size: int | None = None, max_records: int | None = None, + params: Mapping[str, Any] | None = None, ) -> Iterator[Employee]: """Yield employees across pages (see :meth:`iter_tickets`).""" if page_size is None: @@ -515,6 +1372,7 @@ def iter_employees( sort=sort, max_rows=page_size, offset=offset, + params=params, ) if not result.records: return @@ -529,14 +1387,18 @@ def iter_employees( def create_employee(self, employee: PostEmployee) -> Employee: """Create an employee (provisional; profile-gated — spec open item O-DIR-2).""" - spec, parse = employees_res.build_create_employee(employee) + spec, parse = employees_res.build_create_employee( + employee, context=self._validation_context + ) return parse(self._transport.send(spec)) def update_employee( self, employee_id: str | int, update: EmployeeUpdate ) -> Employee: """Update an employee via PUT (provisional; profile-gated).""" - spec, parse = employees_res.build_update_employee(employee_id, update) + spec, parse = employees_res.build_update_employee( + employee_id, update, context=self._validation_context + ) return parse(self._transport.send(spec)) # --- aggregated context -------------------------------------------------- @@ -555,8 +1417,445 @@ def resolve_memo(self, href: str) -> str | None: field = path.rstrip("/").rsplit("/", 1)[-1] return parse_memo(self._transport.send(RequestSpec("GET", path)), field) + # --- instance discovery -------------------------------------------------- + def get_api_spec(self, *, path: str = SWAGGER_PATH) -> dict[str, Any]: + """Fetch the instance's own OpenAPI description. + + Returns the parsed document: ``info`` (``description`` carries the + product version, e.g. ``"Easyvista Service Manager REST API - 2025.3"``), + ``paths`` -- the routes *this* deployment exposes -- and ``components``. + + **Trust the two halves differently.** ``paths`` is tier 2: + authoritative for this deployment, and the reason :meth:`discover` reads + urgencies at ``urgency`` rather than at the vendor-documented + ``urgencies``. ``components.schemas`` is tier 3: example-derived and + illustrative only. It declares ``required: []`` throughout and lists + whichever private ``E_*`` columns the example happened to carry, so a + field appearing in a schema is not a requirement and a field missing + from one is not forbidden. + + .. warning:: + + **A GET to this route answers HTTP 201, not 200** (measured + 2026-08-27 against one instance; it may not generalise). This client + is unaffected -- its transport treats any 2xx as success -- but code + you write beside it that gates on ``response.status_code == 200`` + skips this document in silence and concludes the instance publishes + no spec. The route is ``{api_root}/swagger``, that is + ``/api/{api_version}/{account}/swagger``; the bare-host + ``{server}/swagger`` answers 403. + + ``path`` is a keyword so a deployment that publishes its description + elsewhere is reachable without patching this package; the default is the + route measured on the verified instance. + + Raises like any other read -- a 401/403 becomes ``EasyvistaAuthError`` + rather than an empty document, because an empty document and a denied + one must not look alike. + """ + spec, parse = discovery_res.build_get_api_spec(path) + return parse(self._transport.send(spec)) + + def list_reference_table( + self, + path: str, + *, + search: str | None = None, + fields: Iterable[str] | str | None = None, + sort: str | None = None, + max_rows: int | None = None, + offset: int | None = None, + params: Mapping[str, Any] | None = None, + ) -> SearchResult[GenericRecord]: + """Read any of the instance's list routes into column-free records. + + ``path`` is resource-relative: ``"status"``, ``"urgency"``, + ``"catalog-requests"``, ``"locations"``, ``"groups"``, ``"slas"``, + ``"domains"``, ``"suppliers"``. Call :meth:`get_api_spec` and read + ``["paths"]`` to see which of them your deployment actually declares -- + this package wraps about ten of that instance's hundred routes, and this + method is how you reach the rest of the read-only ones. + + Records come back as :class:`GenericRecord`, which declares **no + columns**: nothing here assumes a schema, because the OpenAPI schemas for + these routes are tier 3 and one of them (``/status``) is visibly wrong. + Read a column by its API name from ``record.model_dump(by_alias=True)``, + or let ``record.reference(name)`` and ``record.classify_fields()`` do it + generically. + + The four query parameters the spec declares on these routes are + ``max_rows``, ``sort``, ``fields`` and ``search``; each is sent only when + passed, so the default call is the bare route. ``offset`` is + vendor-documented for the requests list and merely *inferred* here, so it + too is sent only when asked for. ``params`` is merged last and wins, for + anything this signature does not model. + + **A 403 propagates as ``EasyvistaAuthError``; it does not become + ``[]``.** An empty reference table is a legitimate answer on a lightly + configured instance, so collapsing a denial into an empty list would make + "you may not read this" indistinguishable from "there is nothing here" -- + and a caller that builds a status map from an empty list concludes the + instance has no statuses and hardcodes a constant instead. + :meth:`describe_instance` is the layer that swallows the denial, and it + names the gap in ``.unavailable`` so the loss stays visible. + + Note what a 403 does *not* tell you: this API answers **403 rather than + 404 for a route that does not exist**, so a denied table and a misspelled + path are the same exception. ``get_api_spec()["paths"]`` distinguishes + them. + + The result is a :class:`SearchResult`, not a list, so truncation is + detectable: compare ``.record_count`` with ``.total_record_count`` before + treating a page as the whole table. A route that answers with a bare + object and no envelope reports both as the number of records parsed, in + which case truncation cannot be detected from the response at all. + """ + spec, parse = discovery_res.build_list_reference_table( + path, + search=search, + fields=fields, + sort=sort, + max_rows=max_rows, + offset=offset, + params=params, + context=self._validation_context, + ) + return parse(self._transport.send(spec)) + + def _sample_records( + self, + source: ReferenceSource, + *, + sample_size: int, + search: str | None, + action_sample_tickets: int, + languages: Sequence[str], + ) -> list[dict[str, Any]]: + """Sampled records carrying ``source``, as by-alias dumps. + + Tickets are one sweep. Actions need a ticket sweep first, because the + actions list is filtered per ticket -- so the first + ``action_sample_tickets`` sampled RFC numbers are swept for their + actions. That sweep is always bounded: the ``offset``/``@next`` contract + is unverified on the actions endpoint (see :meth:`iter_actions`), and an + instance that ignores ``offset`` would otherwise repeat page one forever. + """ + projection = sample_fields(source, languages=languages) + if source.sample_from != "actions": + return [ + t.model_dump(by_alias=True) + for t in self.iter_tickets( + search=search, fields=projection, max_records=sample_size + ) + ] + rfcs = [ + t.rfc_number + for t in self.iter_tickets( + search=search, + fields=["RFC_NUMBER"], + max_records=max(action_sample_tickets, 1), + ) + if t.rfc_number + ] + records: list[dict[str, Any]] = [] + for rfc in rfcs: + records.extend( + [ + a.model_dump(by_alias=True) + for a in self.iter_actions( + rfc, fields=projection, max_records=sample_size + ) + ] + ) + return records + + def discover( + self, + name: str, + *, + strategy: str = "auto", + reference_path: str | None = None, + sample_size: int = 200, + action_sample_tickets: int = 5, + search: str | None = None, + reference_search: str | None = None, + max_rows: int | None = None, + with_guid: bool = True, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, + ) -> list[DiscoveredReference]: + """Find the ids, labels and codes one reference uses on this instance. + + ``name`` is a reference name: ``"STATUS"``, ``"URGENCY"``, + ``"CATALOG_REQUEST"``, ``"LOCATION"``, ``"DEPARTMENT"``, ``"SLA"``, + ``"GROUP"``, ``"IMPACT"``, ``"SEVERITY"``, ``"ORIGIN"``, + ``"ACTION_TYPE"`` -- or any other column, including a custom ``e_*`` + one, which is read off sampled tickets. + + ``strategy``: + + * ``"auto"`` (default) -- read the reference table when this + deployment's OpenAPI declares a route for the name, and fall back to + sampling if that route is denied. Names with no route go straight to + sampling and cost no wasted request. + * ``"reference"`` -- the table only. A denial raises. A name with no + route and no ``reference_path`` raises ``ValueError`` rather than + quietly sampling. + * ``"sample"`` -- sampling only; no reference route is called. + + **Four names have no route at all** on the verified instance: + ``IMPACT``, ``SEVERITY``, ``ORIGIN`` and ``ACTION_TYPE``. That is a + topology fact read from the spec's ``paths`` (tier 2), not a 403 someone + measured, so no strategy reaches a table for them. What comes back is + the ids *in use* in the sample: an id configured but unused is + invisible, and a ``count`` is a sample count, never a population one. + + ``reference_path`` overrides the route -- the escape hatch for a + deployment that spells a table differently. Urgencies are the live + example: the vendor documents ``GET /urgencies`` while the verified + instance declares ``GET /urgency``, so the default is the singular one + and ``reference_path="urgencies"`` reaches the other. + + ``search`` filters the **sample** (a ticket or action filter -- see the + ``easyvista-search-syntax`` skill, and note that unparseable syntax + returns the whole table rather than erroring). ``reference_search`` + filters the **table**. ``sample_size`` caps records fetched client-side; + ``max_rows`` caps the table page. + + **``STATUS`` also gets its GUID, and only from a sample.** A + ``STATUS_GUID`` is not searchable and no reference read returns one, but + every ticket's nested ``STATUS`` object carries it -- so with + ``with_guid`` on (the default) discovering ``STATUS`` additionally + sweeps tickets, reads ``record["STATUS"]["STATUS_GUID"]``, and merges + each guid onto the matching id. That costs one extra ticket sweep even + under ``strategy="reference"``; pass ``with_guid=False`` to skip it. The + GUID is what :meth:`set_status` and :meth:`close_ticket` address a + status by -- a ``STATUS_ID`` will not work there -- so this is usually + the value you came for. A status no sampled ticket currently holds keeps + ``guid=None``: the sample cannot reach it. + + Everything returned is per-deployment configuration. Ids are not + portable; ``8`` is *Cloture* and ``12`` is *En cours* on the verified + instance -- adjacent numbers, opposite meanings. Resolve at start-up and + fail loudly, do not freeze a constant. + """ + if strategy not in ("auto", "reference", "sample"): + raise ValueError( + f"strategy={strategy!r} is not one of 'auto', 'reference', " + "'sample'" + ) + source = resolve_source(name, reference_path=reference_path) + found: list[DiscoveredReference] = [] + + if strategy == "reference" and source.reference_path is None: + raise ValueError( + f"{source.name} has no reference route in this deployment's " + "OpenAPI paths, so strategy='reference' cannot read one. Pass " + "reference_path= if your deployment declares one, or use " + "strategy='sample' (which is what 'auto' does here)." + ) + + if strategy != "sample" and source.reference_path is not None: + try: + page = self.list_reference_table( + source.reference_path, search=reference_search, max_rows=max_rows + ) + except EasyvistaAuthError: + if strategy == "reference": + raise + page = None + if page is not None: + found = [ + reference_from_table_row( + row.model_dump(by_alias=True), source, languages=languages + ) + for row in page.records + ] + + needs_sample = not found and strategy != "reference" + wants_guid = with_guid and bool(source.guid_field) + if needs_sample or wants_guid: + records = self._sample_records( + source, + sample_size=sample_size, + search=search, + action_sample_tickets=action_sample_tickets, + languages=languages, + ) + if needs_sample: + found = references_from_sample( + records, source, languages=languages + ) + if wants_guid: + found = merge_guids(found, guids_from_sample(records, source)) + return found + + def describe_instance( + self, + *, + names: Sequence[str] = DEFAULT_DISCOVERY_NAMES, + strategy: str = "auto", + reference_paths: Mapping[str, str] | None = None, + sample_size: int = 200, + action_sample_tickets: int = 5, + search: str | None = None, + max_rows: int | None = None, + include_spec: bool = True, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, + ) -> InstanceProfile: + """Profile this deployment: its version, its routes, and its reference ids. + + One call that answers "what do I have to pass to create a ticket + *here*". Returns an :class:`InstanceProfile`; read its docstring for what + discovery cannot reach, which is as important as what it can. + + **No part can fail the whole.** Each fetch is attempted independently; an + EasyVista error is recorded in ``.unavailable`` -- keyed by ``"spec"`` or + by the reference name, with a first token of ``denied``, ``failed``, + ``no-route``, ``empty`` or ``truncated`` -- and the remaining parts still + run. Nothing is invented for a part that failed: its entry in + ``.references`` is an empty list, and the reason is in ``.unavailable``. + Read that dict before concluding an instance has no statuses; a total + outage looks exactly like a bare instance except that every gap is named. + + Only ``EasyvistaError`` and its subclasses are caught. A bug inside this + package therefore still propagates rather than being buried as a fake + instance limitation. + + **It samples once, not once per name.** Every name that needs sampling is + projected into a single ticket sweep of at most ``sample_size`` records, + and the names that live on actions come from one action sweep over the + first ``action_sample_tickets`` of those tickets. So the cost is roughly + one spec read, one read per declared reference table, and two short + sweeps -- all GETs, nothing written. + + ``reference_paths`` overrides individual routes by name (e.g. + ``{"URGENCY": "urgencies"}``); ``names`` narrows the work; + ``include_spec=False`` skips the OpenAPI read, at the cost of leaving + ``version`` and ``spec_paths`` empty. Every default is the value that + works on the verified instance today. + """ + overrides = dict(reference_paths or {}) + unavailable: dict[str, str] = {} + version: str | None = None + spec_paths: tuple[str, ...] = () + + if include_spec: + try: + document = self.get_api_spec() + except EasyvistaError as exc: + unavailable["spec"] = _unavailable_reason(exc) + else: + info = document.get("info") + if isinstance(info, dict): + description = info.get("description") or info.get("title") + version = description if isinstance(description, str) else None + paths = document.get("paths") + if isinstance(paths, dict): + spec_paths = tuple(str(p) for p in paths) + + sources = [ + resolve_source(name, reference_path=overrides.get(name.strip().upper())) + for name in names + ] + + # One sweep per surface, shared by every name that needs it, rather than + # one sweep per name -- which would be eleven. + ticket_projection: list[str] = [] + action_projection: list[str] = [] + for source in sources: + projection = sample_fields(source, languages=languages) + target = ( + action_projection + if source.sample_from == "actions" + else ticket_projection + ) + target.extend(c for c in projection if c not in target) + + tickets: list[dict[str, Any]] = [] + actions: list[dict[str, Any]] = [] + rfcs: list[str] = [] + try: + tickets = [ + t.model_dump(by_alias=True) + for t in self.iter_tickets( + search=search, fields=ticket_projection, max_records=sample_size + ) + ] + rfcs = [ + str(t["RFC_NUMBER"]) + for t in tickets[: max(action_sample_tickets, 0)] + if t.get("RFC_NUMBER") + ] + except EasyvistaError as exc: + unavailable["sample:tickets"] = _unavailable_reason(exc) + + if action_projection and rfcs: + try: + for rfc in rfcs: + actions.extend( + [ + a.model_dump(by_alias=True) + for a in self.iter_actions( + rfc, fields=action_projection, max_records=sample_size + ) + ] + ) + except EasyvistaError as exc: + unavailable["sample:actions"] = _unavailable_reason(exc) + + references: dict[str, list[DiscoveredReference]] = {} + for source in sources: + found: list[DiscoveredReference] = [] + if source.reference_path is None: + unavailable[source.name] = ( + "no-route this deployment's OpenAPI declares no list route " + "for it, so the ids below are only those in use in the " + "sample" + ) + elif strategy != "sample": + try: + page = self.list_reference_table( + source.reference_path, max_rows=max_rows + ) + except EasyvistaError as exc: + unavailable[source.name] = _unavailable_reason(exc) + else: + found = [ + reference_from_table_row( + row.model_dump(by_alias=True), source, languages=languages + ) + for row in page.records + ] + if page.total_record_count > page.record_count: + unavailable[source.name] = ( + f"truncated {page.record_count} of " + f"{page.total_record_count} rows read" + ) + if not found: + records = actions if source.sample_from == "actions" else tickets + found = references_from_sample(records, source, languages=languages) + if source.guid_field: + found = merge_guids(found, guids_from_sample(tickets, source)) + if not found and source.name not in unavailable: + unavailable[source.name] = ( + "empty the read succeeded and returned nothing" + ) + references[source.name] = found + + return InstanceProfile( + api_root=self.config.api_root, + version=version, + spec_paths=spec_paths, + references=references, + unavailable=unavailable, + ) + def get_ticket_context( - self, rfc_number: str, *, resolve_action_bodies: bool = True + self, + rfc_number: str, + *, + resolve_action_bodies: bool = True, + memo_fields: Sequence[str] = ("description", "comment"), ) -> TicketContext: """Fetch a ticket plus its resolved narrative content as a bundle. @@ -565,67 +1864,109 @@ def get_ticket_context( profile-restricted lists (403) degrade to ``None`` / ``[]`` rather than failing the whole call. + ``memo_fields`` names which Memo sub-resources to resolve, defaulting to + the two EasyVista populates by default. The API models the memo name as + a path segment (``GET /requests/{rfc}/{memo}``), so an instance + configured with a different body memo is reached by naming it here + (tier 2 -- ``docs/vendor-api-reference.md``: declared in the instance's + OpenAPI ``paths``). Every resolved memo lands in + :attr:`TicketContext.memos`; ``description`` and ``comment`` + additionally keep their own attributes, and are ``None`` when not + requested. + + Pass a tuple or list, not a bare string: ``str`` itself satisfies + ``Sequence[str]``, so ``memo_fields="solution"`` type-checks and then + iterates individual characters, issuing one nonsense sub-resource + request per letter instead of the one you meant. + ``resolve_action_bodies`` (default on) additionally fetches each action item-level and resolves its note text, because ``list_actions`` does not return it — without this the rendered Markdown has empty action bodies. It costs two extra requests per action; pass ``False`` to skip it when you only need the action list. - On the async surface the independent requests (the two memos plus the - actions and documents lists) are issued concurrently, in up to three - waves; on the sync surface they run one after another in source order, - costing ``4 + 2N`` serial round trips for a ticket with ``N`` actions. - Measured against a live instance on a 19-action ticket: 5.31s - concurrent, 14.65s serial. - - Peak in-flight on the async surface is four sub-resource requests, then - up to ``_ACTION_FANOUT`` action-body resolutions; the sync surface - issues one request at a time throughout. On a hard failure (5xx, a - transport error) siblings already in flight on the async surface run to - completion before the error propagates, so a failing call there can - issue more requests than the sequential surface does. That is the - deliberate trade: settling every sibling is what keeps an orphaned - request from outliving the call that issued it, and what makes the - exception a caller sees the one the sequential surface would have - raised rather than whichever branch happened to fail soonest. + **The action log is capped at one page.** It comes from + :meth:`list_actions`, which returns at most ``config.default_max_rows`` + actions and does not paginate, so on a busy ticket + :attr:`TicketContext.actions` — and therefore + :meth:`TicketContext.to_markdown`'s rendered log — is silently truncated + with no error. Raise ``default_max_rows`` if completeness matters. + + On the async surface the independent requests (the requested memos + plus the actions and documents lists) are issued concurrently, in up + to three waves; on the sync surface they run one after another in + source order, costing ``len(memo_fields) + 2 + 2N`` serial round + trips for a ticket with ``N`` actions. Measured against a live + instance on a 19-action ticket with the default two-memo + ``memo_fields``: 5.31s concurrent, 14.65s serial. + + Peak in-flight on the async surface is ``len(memo_fields) + 2`` + sub-resource requests, then up to ``_ACTION_FANOUT`` action-body + resolutions; the sync surface issues one request at a time + throughout. On a hard failure (5xx, a transport error) siblings + already in flight on the async surface run to completion before the + error propagates, so a failing call there can issue more requests + than the sequential surface does. That is the deliberate trade: + settling every sibling is what keeps an orphaned request from + outliving the call that issued it, and what makes the exception a + caller sees the one the sequential surface would have raised rather + than whichever branch happened to fail soonest. """ # Issued first, and deliberately outside the fan-out: this is the one # call with no fallback, so a wrong RFC number should cost one request, # not five. ticket = self.get_ticket(rfc_number) + degraded: set[str] = set() + # The asymmetry between these two except clauses is real and # load-bearing: the memos degrade on 404 *and* 403, while the two list # calls catch EasyvistaAuthError ONLY, so a 404 there still fails the - # bundle. Do not tidy them into a shared handler. + # bundle. Do not tidy them into a shared handler. Each records its own + # swallow inside its own clause, which keeps that asymmetry visible + # rather than hiding it behind a helper. def _actions() -> list[Action]: try: return self.list_actions(rfc_number) - except EasyvistaAuthError: + except EasyvistaAuthError as exc: + degraded.add(_degraded_entry("actions", exc)) return [] def _documents() -> list[Document]: try: return self.list_documents(rfc_number) - except EasyvistaAuthError: + except EasyvistaAuthError as exc: + degraded.add(_degraded_entry("documents", exc)) return [] - description, comment, actions, documents = settle( - self._safe_memo(f"requests/{rfc_number}/description"), - self._safe_memo(f"requests/{rfc_number}/comment"), + memo_results = settle( + *( + self._safe_memo( + f"requests/{rfc_number}/{name}", + degraded=degraded, + branch=f"memo:{name}", + ) + for name in memo_fields + ), _actions(), _documents(), ) + memos = dict( + zip(memo_fields, memo_results[: len(memo_fields)], strict=True) + ) + actions, documents = memo_results[len(memo_fields) :] if resolve_action_bodies: actions = self._resolve_action_bodies(actions) return TicketContext( ticket=ticket, - description=description, - comment=comment, + description=memos.get("description"), + comment=memos.get("comment"), actions=actions, documents=documents, + memos=memos, + degraded=frozenset(degraded), ) def _resolve_action_bodies(self, actions: list[Action]) -> list[Action]: @@ -660,7 +2001,14 @@ def get_department_context( department_id: str | int, *, recent_tickets: int = 10, + recent_tickets_sort: str | None = RECENT_TICKETS_SORT, + ticket_fields: str | Sequence[str] | None = RECENT_TICKET_FIELDS, + employee_fields: str | Sequence[str] | None = None, + asset_fields: str | Sequence[str] | None = None, dimensions: Sequence[str] | None = None, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, + statistics_max_records: int | None = 100, + memo_fields: Sequence[str] = DEPARTMENT_NOTE_FIELDS, include_statistics: bool = True, include_assets: bool = True, resolve_manager: bool = True, @@ -668,13 +2016,64 @@ def get_department_context( ) -> DepartmentContext: """Assemble a department plus its employees, manager, note, tickets and assets. - Only :meth:`get_department` is required; every related part is wrapped so a - 403/404 degrades it to ``[]`` / ``None`` / ``0`` (same pattern as - :meth:`get_ticket_context`). The flags trim the heavier related calls. - Tickets and assets filter on ``DEPARTMENT_ID:""``. ``recent_tickets`` - ordering is best-effort: it relies on the server honoring - ``RECENT_TICKETS_SORT`` (open item O-DIR-1) and silently degrades to the - API's default order otherwise. + Only :meth:`get_department` is required; every related part is wrapped + so a 403/404 degrades it to ``[]`` / ``None`` / ``0``, and records + itself in ``DepartmentContext.degraded`` so the degradation is visible + rather than silent. The flags trim the heavier related calls. Tickets + and assets filter on ``DEPARTMENT_ID:""``. + + Every value this method samples with is a keyword, and every default is + what it sampled with before -- with one exception, ``ticket_fields``. + + ``recent_tickets_sort`` is the sort token for the recent-ticket page, + defaulting to ``RECENT_TICKETS_SORT`` (``"RFC_NUMBER DESC"``). That is + **descending RFC_NUMBER**, which is newest-first only where RFC numbers + are issued monotonically: it is a varchar, so the sort orders by the + request-type prefix letter before the date, and on an instance issuing + more than one prefix every ``R...`` outranks every ``I...``. The default + is deliberately not a date column: an unhonoured sort token is silently + ignored by this API and degrades to the server's default order with no + error (see :meth:`iter_tickets`), so a date default would swap a + disclosed flaw for a hidden one, and ``RFC_NUMBER DESC`` is the one + token this repository has actually measured for this call. Pass + ``recent_tickets_sort="CREATION_DATE_UT DESC"`` on a deployment where + you have checked that a date sort is honoured, or ``None`` to send no + sort at all -- in which case "recent" means whatever order the server + returns. + + ``ticket_fields`` is the ``fields=`` projection for the recent tickets. + Its default, ``RECENT_TICKET_FIELDS``, **projects** -- unlike every + other default here, this is a change from the previous behaviour, and + it is deliberate. Sending no projection is not neutral: on the verified + instance the default list projection returns ``TITLE`` present but empty + (tier 4 -- measured on one instance, 400 tickets scanned, zero with a + populated title; it may not generalise), so ``recent_tickets[i].title`` + was ``None`` for every caller. Projecting also narrows what else comes + back: pass ``ticket_fields=None`` to send no projection, or your own + list to widen it. + + ``employee_fields`` and ``asset_fields`` project the employee and asset + sweeps; both default to ``None``, which sends no projection, as before. + + ``statistics_max_records`` caps the statistics sample and defaults to + 100 -- the cap ``ticket_statistics`` applies, which this call inherited + silently before. The sample is unsorted, so it is not the department's + first hundred tickets by any ordering; when it truncates, + ``ticket_statistics.truncated`` is ``True`` and ``population_total`` + carries the server's own count. ``ticket_count`` remains the true total + and is unaffected by this cap. Pass ``None`` to aggregate every ticket. + + ``memo_fields`` names which Memo sub-resources to resolve, defaulting to + ``("comment_department",)``. The API models a memo name as a path + segment (``GET departments/{id}/{memo}``) (tier 2 -- + ``docs/vendor-api-reference.md``: declared in the instance's OpenAPI + ``paths``), so a deployment carrying its directory note elsewhere is + reached by naming it here. Every resolved memo lands in + ``DepartmentContext.memos``; ``note`` is the first one that came back + with text. ``include_note=False`` skips all of them. Pass a tuple or + list, not a bare string: ``str`` itself satisfies ``Sequence[str]``, so + a bare name would be iterated one character at a time, one nonsense + request per letter. On the async surface the seven independent branches are issued concurrently, costing two waves instead of eight serial steps; on the @@ -692,14 +2091,23 @@ def get_department_context( if search is None: raise ValueError("department_id is required to build a department context") + degraded: set[str] = set() + ticket_projection = _as_fields(ticket_fields) + # Every branch here degrades on both 403 and 404, unlike the ticket # bundle above, whose two list calls catch EasyvistaAuthError only. The # `include_*` / `resolve_*` flags sit inside the branch so a disabled one # costs no request at all, exactly as a plain `if` around the call would. def _employees() -> list[Employee]: try: - return [e for e in self.iter_employees(search=search)] - except (EasyvistaAuthError, EasyvistaNotFound): + return [ + e + for e in self.iter_employees( + search=search, fields=_as_fields(employee_fields) + ) + ] + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("employees", exc)) return [] def _manager() -> Employee | None: @@ -707,20 +2115,27 @@ def _manager() -> Employee | None: return None try: return self.get_employee(department.manager_id) - except (EasyvistaAuthError, EasyvistaNotFound): + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("manager", exc)) return None - def _note() -> str | None: + def _memos() -> dict[str, str | None]: if not include_note: - return None - return self._safe_memo( - f"departments/{department_id}/comment_department" - ) + return {} + return { + name: self._safe_memo( + f"departments/{department_id}/{name}", + degraded=degraded, + branch=f"memo:{name}", + ) + for name in memo_fields + } def _ticket_count() -> int: try: return self.count_tickets(search=search) - except (EasyvistaAuthError, EasyvistaNotFound): + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("ticket_count", exc)) return 0 def _recent() -> list[Request]: @@ -729,11 +2144,13 @@ def _recent() -> list[Request]: t for t in self.iter_tickets( search=search, - sort=RECENT_TICKETS_SORT, + fields=ticket_projection, + sort=recent_tickets_sort, max_records=recent_tickets, ) ] - except (EasyvistaAuthError, EasyvistaNotFound): + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("recent_tickets", exc)) return [] def _statistics() -> TicketStatistics | None: @@ -741,23 +2158,33 @@ def _statistics() -> TicketStatistics | None: return None try: return self.ticket_statistics( - search=search, dimensions=dimensions + search=search, + dimensions=dimensions, + languages=languages, + max_records=statistics_max_records, ) - except (EasyvistaAuthError, EasyvistaNotFound): + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("statistics", exc)) return None def _assets() -> list[Asset]: if not include_assets: return [] try: - return [a for a in self.iter_assets(search=search)] - except (EasyvistaAuthError, EasyvistaNotFound): + return [ + a + for a in self.iter_assets( + search=search, fields=_as_fields(asset_fields) + ) + ] + except (EasyvistaAuthError, EasyvistaNotFound) as exc: + degraded.add(_degraded_entry("assets", exc)) return [] ( employees, manager, - note, + memos, ticket_count, recent, statistics, @@ -765,13 +2192,14 @@ def _assets() -> list[Asset]: ) = settle( _employees(), _manager(), - _note(), + _memos(), _ticket_count(), _recent(), _statistics(), _assets(), ) + note = next((text for text in memos.values() if text), None) return DepartmentContext( department=department, employees=employees, @@ -781,10 +2209,27 @@ def _assets() -> list[Asset]: recent_tickets=recent, ticket_statistics=statistics, assets=assets, + memos=memos, + degraded=frozenset(degraded), ) - def _safe_memo(self, path: str) -> str | None: + def _safe_memo( + self, + path: str, + *, + degraded: set[str] | None = None, + branch: str = "", + ) -> str | None: + """Resolve a Memo, degrading a 403/404 to ``None``. + + When ``degraded`` is given, a swallowed failure is recorded there as + ``":"`` so the bundle can report it. Left at ``None`` + for the action-body resolution, where a missing note is not a section + the caller could be misled about. + """ try: return self.resolve_memo(path) - except (EasyvistaNotFound, EasyvistaAuthError): + except (EasyvistaNotFound, EasyvistaAuthError) as exc: + if degraded is not None: + degraded.add(_degraded_entry(branch or path, exc)) return None diff --git a/easyvista_python_client/_sync/tests/test_client.py b/easyvista_python_client/_sync/tests/test_client.py index a7d0296..e492cb9 100644 --- a/easyvista_python_client/_sync/tests/test_client.py +++ b/easyvista_python_client/_sync/tests/test_client.py @@ -10,17 +10,27 @@ which is hand-written on both sides and never generated. """ +import dataclasses import json +from collections.abc import Iterator import httpx +import pydantic import pytest import respx from easyvista_python_client._sync import client as client_module from easyvista_python_client._sync.client import EasyvistaClient +from easyvista_python_client.config import EasyvistaConfig from easyvista_python_client.directory import DepartmentContext -from easyvista_python_client.exceptions import EasyvistaError -from easyvista_python_client.models.action import PostAction +from easyvista_python_client.exceptions import ( + EasyvistaAuthError, + EasyvistaError, + EasyvistaNotFound, + EasyvistaServerError, + EasyvistaValidationError, +) +from easyvista_python_client.models.action import ActionUpdate, PostAction from easyvista_python_client.models.asset import PostAsset from easyvista_python_client.models.department import ( Department, @@ -110,7 +120,7 @@ def test_update_and_close_ticket(config): return_value=httpx.Response(200, json={"records": [{"RFC_NUMBER": "I1"}]}) ) with EasyvistaClient(config) as client: - client.update_ticket("I1", RequestUpdate(status_id=4)) + client.update_ticket("I1", RequestUpdate(impact_id=4)) client.close_ticket("I1", comment="resolved") @@ -136,12 +146,46 @@ def test_create_and_list_actions(config): return_value=httpx.Response(200, json={"records": [{"ACTION_ID": 5}]}) ) with EasyvistaClient(config) as client: - action = client.create_action("I1", PostAction(description="hi")) + action = client.create_action( + "I1", PostAction(action_type_id=94, group_id=3, description="hi") + ) listed = client.list_actions("I1") assert action.action_id == 5 assert listed[0].action_id == 5 +@respx.mock +def test_list_actions_forwards_a_fields_projection(config): + """The client must pass fields= through, not just accept it. + + EV-R3: fields= is what turns comment metadata into one request per ticket + instead of one request per action. + """ + route = respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + with EasyvistaClient(config) as client: + client.list_actions("I240101_0001", fields=["ACTION_ID", "LAST_UPDATE"]) + assert route.calls.last.request.url.params["fields"] == "ACTION_ID,LAST_UPDATE" + + +@respx.mock +def test_list_actions_sends_the_configured_row_cap(config): + """``list_actions`` returns one page, so the cap must be the client's own. + + Without this the request carried no ``max_rows`` at all and the truncation + point was the server's unstated default -- invisible to the caller and not + raisable by configuration. + """ + route = respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + with EasyvistaClient(config) as client: + expected = str(client.config.default_max_rows) + client.list_actions("I240101_0001") + assert route.calls.last.request.url.params["max_rows"] == expected + + @respx.mock def test_get_action_fetches_the_item_level_record(config): respx.get(f"{ROOT}/actions/52990").mock( @@ -159,6 +203,18 @@ def test_get_action_fetches_the_item_level_record(config): assert action.description == {"HREF": f"{ROOT}/actions/52990/description"} +@respx.mock +def test_update_action_sends_a_put_to_the_top_level_path(config): + """The nested requests/{rfc}/actions/{id} form returns 403 (verified live).""" + route = respx.put(f"{ROOT}/actions/57483").mock( + return_value=httpx.Response(200, json={"ACTION_ID": 57483}) + ) + with EasyvistaClient(config) as client: + action = client.update_action(57483, ActionUpdate(description="edited")) + assert json.loads(route.calls.last.request.content) == {"description": "edited"} + assert action.action_id == 57483 + + @respx.mock def test_from_env_constructs_working_client(monkeypatch): monkeypatch.setenv("EASYVISTA_URL", "https://ev.test") @@ -257,6 +313,95 @@ def test_add_and_list_documents(config): assert body["documents"][0]["filename"] == "a.txt" +@respx.mock +def test_delete_document_sends_a_delete_to_the_nested_path(config): + """Returns None: the API answers a delete with an empty body.""" + route = respx.delete(f"{ROOT}/requests/I240101_0001/documents/12345_abcdef").mock( + return_value=httpx.Response(200) + ) + with EasyvistaClient(config) as client: + result = client.delete_document("I240101_0001", "12345_abcdef") + assert route.call_count == 1 + assert result is None + + +@respx.mock +def test_delete_document_top_level_path_style_addresses_the_document_by_id( + config, +): + """Both routes are declared in the instance's own OpenAPI document. + + Only the top-level one is marked ``deprecated`` there, so which one works + is a profile question rather than a routing one. ``rfc_number`` has no slot + in this route, hence ``None``. + """ + route = respx.delete(f"{ROOT}/documents/12345_abcdef").mock( + return_value=httpx.Response(200) + ) + with EasyvistaClient(config) as client: + client.delete_document(None, "12345_abcdef", path_style="top_level") + assert route.call_count == 1 + + +@respx.mock +def test_delete_document_takes_its_path_style_from_the_config(config): + """The keyword defaults to the config field, so a deployment sets it once.""" + route = respx.delete(f"{ROOT}/documents/12345_abcdef").mock( + return_value=httpx.Response(200) + ) + top_level = dataclasses.replace(config, document_delete_path_style="top_level") + with EasyvistaClient(top_level) as client: + client.delete_document(None, "12345_abcdef") + assert route.call_count == 1 + + +@respx.mock +def test_delete_document_accepts_a_document_record(config): + """The id is read off the record, so a caller need not unpack it.""" + route = respx.delete(f"{ROOT}/requests/I240101_0001/documents/12345_abcdef").mock( + return_value=httpx.Response(200) + ) + document = Document.model_validate({"DOCUMENT_ID": "12345_abcdef"}) + with EasyvistaClient(config) as client: + client.delete_document("I240101_0001", document) + assert route.call_count == 1 + + +@respx.mock +def test_delete_document_rejects_a_document_without_an_id(config): + """A record carrying no DOCUMENT_ID would address the collection.""" + route = respx.delete(f"{ROOT}/requests/I240101_0001/documents/").mock( + return_value=httpx.Response(200) + ) + document = Document.model_validate({"DOCUMENT": "report.pdf"}) + with EasyvistaClient(config) as client: + with pytest.raises(ValueError, match="DOCUMENT_ID"): + client.delete_document("I240101_0001", document) + assert route.call_count == 0 + + +@respx.mock +def test_get_department_comment_honours_a_memo_field_override(config): + """The route's last segment is a memo-field selector, not a literal. + + In the instance OpenAPI document read 2026-08-27 it is a path *parameter* + named ``comment``, and the sibling ``GET requests/{rfc_number}/{comment}`` + describes the same parameter as "Memo field type, could be comment, + description". So a deployment whose department memo column is named + differently is not locked out. + """ + default_route = respx.get(f"{ROOT}/departments/60/comment_department").mock( + return_value=httpx.Response(200, json={"COMMENT_DEPARTMENT": "default"}) + ) + override = respx.get(f"{ROOT}/departments/60/comment_service").mock( + return_value=httpx.Response(200, json={"COMMENT_SERVICE": "overridden"}) + ) + with EasyvistaClient(config) as client: + client.get_department_comment(60, memo_field="comment_service") + assert override.call_count == 1 + assert default_route.call_count == 0 + + @respx.mock def test_download_document_fetches_the_ddl_href(config): route = respx.get("https://ev.test/dl/7").mock( @@ -287,6 +432,130 @@ def test_download_document_refuses_a_foreign_download_url(config): client.download_document(doc) +@respx.mock +def test_stream_document_chunks_reassemble_to_the_download(config): + # 3076 bytes at chunk_size=512: six full chunks and a 4-byte tail. Sized off + # the boundary on purpose -- an exact multiple never exercises a short final + # chunk, and reassembly passes either way. + body = bytes(range(256)) * 12 + b"tail" + respx.get("https://ev.test/dl/7").mock( + return_value=httpx.Response(200, content=body) + ) + doc = Document.model_validate( + {"DOCUMENT": "big.bin", "DDL_HREF": "https://ev.test/dl/7"} + ) + chunks = [] + with EasyvistaClient(config) as client: + for chunk in client.stream_document(doc, chunk_size=512): + chunks.append(chunk) + assert b"".join(chunks) == body + assert len(chunks) == 7, "the body arrived in one piece instead of streaming" + assert len(chunks[-1]) == 4, "the short final chunk was padded or dropped" + + +class _ClosableStream(httpx.SyncByteStream): + """A response body that records when the transport closed it. + + ``closed`` flips in ``aclose()``/``close()``, which httpx calls when the + response is + released -- which is what ``stream_bytes`` does in its own ``finally``. So + the flag answers "has the connection gone back to the pool yet". + """ + + def __init__(self, body: bytes) -> None: + self._body = body + self.closed = False + + def __iter__(self) -> Iterator[bytes]: + yield self._body + + def close(self) -> None: + self.closed = True + + +@respx.mock +def test_stream_document_closes_the_inner_stream_when_stopped_early(config): + """Stopping early releases the connection there and then, not eventually. + + ``stream_document`` hands out chunks from an inner ``stream_bytes`` + generator, and only *that* generator's ``finally`` closes the response and + returns its connection to the httpx pool. Closing the outer generator + unwinds its loop with ``GeneratorExit`` and does not close the inner one, so + without the explicit close in ``stream_document`` the release waits for the + inner generator to be collected -- and this asserts with no ``gc`` round and + no scheduling hop in between. Until the release happens the connection stays + checked out, which a caller with a small ``max_connections`` feels. + + Closing explicitly is also the only moment both client trees share: a caller + that just stops iterating leaves the outer generator to be collected, and + when that happens is a property of the runtime, not of this method. + """ + stream = _ClosableStream(b"0123456789abcdef") + respx.get("https://ev.test/dl/7").mock( + return_value=httpx.Response(200, stream=stream) + ) + doc = Document.model_validate({"DDL_HREF": "https://ev.test/dl/7"}) + with EasyvistaClient(config) as client: + chunks = client.stream_document(doc, chunk_size=8) + assert chunks.__next__() == b"01234567" + assert not stream.closed, "closed while the caller was still reading" + chunks.close() + assert stream.closed, "the abandoned stream still holds its connection" + + +@respx.mock +def test_stream_document_accepts_a_relative_path_like_download_document(config): + """Same accepted inputs as download_document, resolved the same way.""" + respx.get(f"{ROOT}/documents/7/content").mock( + return_value=httpx.Response(200, content=b"bytes") + ) + path = "documents/7/content" + with EasyvistaClient(config) as client: + streamed = [chunk for chunk in client.stream_document(path)] + downloaded = client.download_document(path) + assert b"".join(streamed) == downloaded == b"bytes" + + +@respx.mock +def test_stream_document_and_download_document_agree_on_a_403(config): + """One error mapping, not two: the streaming path must not soften a failure. + + Asserted as an equality between the paths rather than against a hardcoded + type, so the two cannot drift apart without this failing -- which is the + actual risk, since a streaming response needs its body read before the + mapping can look at it at all. + """ + respx.get("https://ev.test/dl/7").mock( + return_value=httpx.Response(403, json={"error": "forbidden"}) + ) + doc = Document.model_validate({"DDL_HREF": "https://ev.test/dl/7"}) + with EasyvistaClient(config) as client: + with pytest.raises(EasyvistaError) as streamed: + [chunk for chunk in client.stream_document(doc)] + with pytest.raises(EasyvistaError) as downloaded: + client.download_document(doc) + assert type(streamed.value) is type(downloaded.value) is EasyvistaAuthError + assert streamed.value.status_code == downloaded.value.status_code == 403 + assert streamed.value.ev_message == downloaded.value.ev_message == "forbidden" + + +def test_stream_document_refuses_a_foreign_download_url(config): + # The same-origin guard covers the streaming path too: it is a property of + # the download, not of one method. Nothing is requested until iteration + # begins, so the refusal lands on the first step. + doc = Document.model_validate({"DDL_HREF": "https://attacker.test/dl/7"}) + with EasyvistaClient(config) as client: + with pytest.raises(EasyvistaError, match="outside the configured instance"): + [chunk for chunk in client.stream_document(doc)] + + +def test_stream_document_needs_a_download_url(config): + doc = Document.model_validate({"DOCUMENT": "orphan.txt"}) + with EasyvistaClient(config) as client: + with pytest.raises(ValueError, match="no download URL"): + [chunk for chunk in client.stream_document(doc)] + + # --- pagination -------------------------------------------------------------- @@ -441,6 +710,100 @@ def test_iter_departments_paginates(config): assert ids == [1, 2] +def _paged_actions_responder(request): + offset = int(request.url.params.get("offset", "0")) + if offset == 0: + return httpx.Response( + 200, + json={ + "records": [{"ACTION_ID": 1}, {"ACTION_ID": 2}], + "record_count": 2, + "total_record_count": 3, + "@next": f"{ROOT}/actions?offset=2&max_rows=2", + }, + ) + return httpx.Response( + 200, + json={ + "records": [{"ACTION_ID": 3}], + "record_count": 1, + "total_record_count": 3, + }, + ) + + +@respx.mock +def test_iter_actions_crosses_the_page_list_actions_stops_at(config): + """``list_actions`` truncates a busy ticket's log silently; this does not.""" + respx.get(f"{ROOT}/actions").mock(side_effect=_paged_actions_responder) + with EasyvistaClient(config) as client: + ids = [a.action_id for a in client.iter_actions("I1", page_size=2)] + assert ids == [1, 2, 3] + + +@respx.mock +def test_iter_actions_keeps_the_ticket_filter_on_every_page(config): + """A page-2 request that lost the filter would sweep every ticket's log.""" + route = respx.get(f"{ROOT}/actions").mock(side_effect=_paged_actions_responder) + with EasyvistaClient(config) as client: + [a for a in client.iter_actions("I1", page_size=2)] + assert len(route.calls) == 2 + for call in route.calls: + assert call.request.url.params["search"] == 'REQUEST.RFC_NUMBER:"I1"' + + +@respx.mock +def test_iter_actions_respects_max_records(config): + route = respx.get(f"{ROOT}/actions").mock(side_effect=_paged_actions_responder) + with EasyvistaClient(config) as client: + ids = [ + a.action_id + for a in client.iter_actions("I1", page_size=2, max_records=1) + ] + assert ids == [1] + # Stops inside the first page once the cap is hit (no second request). + assert len(route.calls) == 1 + + +@respx.mock +def test_iter_actions_stops_when_the_server_reports_no_next_page(config): + """No ``@next`` ends the sweep even on a page that filled ``page_size``.""" + route = respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response( + 200, + json={ + "records": [{"ACTION_ID": 1}, {"ACTION_ID": 2}], + "record_count": 2, + "total_record_count": 2, + }, + ) + ) + with EasyvistaClient(config) as client: + ids = [a.action_id for a in client.iter_actions("I1", page_size=2)] + assert ids == [1, 2] + assert len(route.calls) == 1 + + +@respx.mock +def test_iter_actions_forwards_a_fields_projection(config): + route = respx.get(f"{ROOT}/actions").mock(side_effect=_paged_actions_responder) + with EasyvistaClient(config) as client: + [ + a + for a in client.iter_actions( + "I1", fields=["ACTION_ID", "ACTION_LABEL_FR"], page_size=2 + ) + ] + assert route.calls[0].request.url.params["fields"] == "ACTION_ID,ACTION_LABEL_FR" + + +def test_iter_actions_refuses_a_blank_rfc(config): + """An unfiltered sweep of every ticket's actions is never the intent.""" + with EasyvistaClient(config) as client: + with pytest.raises(ValueError): + [a for a in client.iter_actions("")] + + # --- statistics -------------------------------------------------------------- @@ -471,7 +834,14 @@ def _stats_responder(request): @respx.mock -def test_ticket_statistics_aggregates_over_iter_tickets(config): +def test_ticket_statistics_aggregates_across_pages(config): + """No longer routes through ``iter_tickets``. + + ``iter_tickets`` yields records one at a time and discards the envelope, so + it cannot also hand back ``total_record_count`` -- the one number that says + how large the population a capped aggregation sampled actually was. + ``_collect_tickets`` walks the same offsets and issues the same requests. + """ from easyvista_python_client import TicketStatistics respx.get(f"{ROOT}/requests").mock(side_effect=_stats_responder) @@ -480,14 +850,54 @@ def test_ticket_statistics_aggregates_over_iter_tickets(config): assert isinstance(stats, TicketStatistics) assert stats.total == 3 assert stats.breakdowns["STATUS"] == {"Open": 2, "Closed": 1} + assert stats.truncated is False + assert stats.population_total == 3 + + +@respx.mock +def test_ticket_statistics_passes_languages_through_to_the_aggregator(config): + # Proves the keyword reaches aggregate_tickets on the real dispatch path, + # not just in the pure function's own tests. + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, + json={ + "records": [ + { + "RFC_NUMBER": "I1", + "STATUS": {"STATUS_EN": "[Open]", "STATUS_FR": "Ouvert"}, + } + ], + "record_count": 1, + "total_record_count": 1, + }, + ) + ) + with EasyvistaClient(config) as client: + default = client.ticket_statistics(dimensions=["STATUS"]) + french = client.ticket_statistics( + dimensions=["STATUS"], languages=("_FR",) + ) + # The bracketed English echo loses to the real French sibling by default... + assert default.breakdowns["STATUS"] == {"Ouvert": 1} + # ...and asking for French directly reaches the same column. + assert french.breakdowns["STATUS"] == {"Ouvert": 1} @respx.mock def test_ticket_statistics_respects_max_records(config): + """The cap truncating is now disclosed rather than silent. + + That is the whole point of the two new fields: ``total == 1`` describes a + sample of one out of three, and before this a caller had no way to tell + that from a population of one. + """ respx.get(f"{ROOT}/requests").mock(side_effect=_stats_responder) with EasyvistaClient(config) as client: stats = client.ticket_statistics(dimensions=["STATUS"], max_records=1) assert stats.total == 1 # capped before the second page + assert stats.truncated is True + assert stats.population_total == 3 @respx.mock @@ -596,9 +1006,14 @@ def test_find_departments_fast_path_by_code(config): @respx.mock -def test_find_departments_fast_path_by_id(config): - # An all-digit name uses the DEPARTMENT_ID fast path (not DEPARTMENT_CODE) - # and returns on the first hit without ever falling back to iter_departments. +def test_find_departments_auto_tries_code_before_id(config): + """An all-digit name is a candidate CODE before it is a candidate ID. + + It used to go straight to ``DEPARTMENT_ID``, so 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. Code first fixes that; the id lookup still + happens, one request later, when no such code exists. + """ route = respx.get(f"{ROOT}/departments").mock( return_value=httpx.Response( 200, @@ -611,8 +1026,58 @@ def test_find_departments_fast_path_by_id(config): with EasyvistaClient(config) as client: found = client.find_departments("60") assert [d.department_id for d in found] == [60] - assert route.calls.last.request.url.params["search"] == 'DEPARTMENT_ID:"60"' + assert route.calls.last.request.url.params["search"] == 'DEPARTMENT_CODE:"60"' + assert route.call_count == 1 + + +@respx.mock +def test_find_departments_auto_falls_back_to_id_for_an_all_digit_name(config): + """The regression guard for the wrong-record bug. + + When the digits are an id and not a code, the code lookup misses and the id + lookup runs -- one extra round trip, on a path the fast path only ever was + an optimization for. + """ + empty = httpx.Response(200, json={"records": [], "total_record_count": 0}) + hit = httpx.Response( + 200, + json={"records": [{"DEPARTMENT_ID": 60}], "total_record_count": 1}, + ) + route = respx.get(f"{ROOT}/departments").mock(side_effect=[empty, hit]) + with EasyvistaClient(config) as client: + found = client.find_departments("60") + assert [d.department_id for d in found] == [60] + assert route.call_count == 2 + searches = [call.request.url.params["search"] for call in route.calls] + assert searches == ['DEPARTMENT_CODE:"60"', 'DEPARTMENT_ID:"60"'] + + +@respx.mock +def test_find_departments_by_pins_a_single_column(config): + """``by`` restores the old lookup exactly, or skips the fast path entirely. + + ``by="DEPARTMENT_ID"`` must be read as ONE column, not as eleven + single-character ones -- ``str`` satisfies ``Sequence[str]``, so the string + branch is checked first. + """ + route = respx.get(f"{ROOT}/departments").mock( + return_value=httpx.Response( + 200, + json={"records": [{"DEPARTMENT_ID": 60}], "total_record_count": 1}, + ) + ) + with EasyvistaClient(config) as client: + found = client.find_departments("60", by="DEPARTMENT_ID") + assert [d.department_id for d in found] == [60] assert route.call_count == 1 + assert route.calls.last.request.url.params["search"] == 'DEPARTMENT_ID:"60"' + + # `by=[]` skips the fast path: the only call is the fuzzy scan's own + # unfiltered sweep. + route.reset() + with EasyvistaClient(config) as client: + client.find_departments("60", by=[]) + assert "search" not in route.calls.last.request.url.params @respx.mock @@ -861,6 +1326,40 @@ def test_get_ticket_context_degrades_on_missing_subresources(config): assert ctx.comment is None assert ctx.actions == [] assert ctx.documents == [] + # Each swallow is now recorded, so `[]` is distinguishable from "forbidden". + assert ctx.degraded == frozenset( + { + "memo:description:404", + "memo:comment:404", + "actions:403", + "documents:403", + } + ) + + +@respx.mock +def test_get_ticket_context_still_raises_on_a_404_from_a_list_call(config): + """The asymmetry between the two except clauses is load-bearing. + + The memos degrade on 404 *and* 403, while the two list calls catch + ``EasyvistaAuthError`` ONLY -- so a 404 there still fails the bundle. Adding + the degraded-recording line inside each clause must not have widened either + caught tuple. This test reddens if a later tidy-up merges them. + """ + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + for memo in ("description", "comment"): + respx.get(f"{ROOT}/requests/I1/{memo}").mock( + return_value=httpx.Response(404, json={}) + ) + respx.get(f"{ROOT}/actions").mock(return_value=httpx.Response(404, json={})) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + with EasyvistaClient(config) as client: + with pytest.raises(EasyvistaNotFound): + client.get_ticket_context("I1") @respx.mock @@ -963,54 +1462,139 @@ def test_ticket_context_tolerates_an_unreadable_action(config): @respx.mock -def test_ticket_context_tolerates_an_action_that_has_vanished(config): - # The 404 arm of the same except clause -- an action listed but deleted - # before we fetch it item-level. Without this case the clause could be - # narrowed to EasyvistaAuthError alone and the suite would stay green. - # The vanished action keeps its slot: degrading must never shorten a - # ticket's history behind the caller's back. +def test_ticket_context_resolves_comment_when_description_is_empty(config): + # The EasyVista UI shows ONE text field per action, headed "comment or + # description": it renders DESCRIPTION and falls back to COMMENT only when + # DESCRIPTION is empty (measured in the UI 2026-09-01 on one instance, + # Service Manager 2025.3 -- one instance, one date, may not generalise). + # Resolving DESCRIPTION alone therefore loses the body of exactly the + # actions a human CAN read: this bundle must carry the COMMENT text. respx.get(f"{ROOT}/requests/I1").mock( return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) ) respx.get(f"{ROOT}/requests/I1/description").mock(return_value=httpx.Response(404)) respx.get(f"{ROOT}/requests/I1/comment").mock(return_value=httpx.Response(404)) respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"actions": [{"ACTION_ID": 9}]}) + ) + respx.get(f"{ROOT}/actions/9").mock( return_value=httpx.Response( - 200, json={"actions": [{"ACTION_ID": 7}, {"ACTION_ID": 8}]} + 200, + json={ + "ACTION_ID": 9, + "DESCRIPTION": {"HREF": f"{ROOT}/actions/9/description"}, + "COMMENT": {"HREF": f"{ROOT}/actions/9/comment"}, + }, ) ) - respx.get(f"{ROOT}/actions/7").mock(return_value=httpx.Response(404)) - respx.get(f"{ROOT}/actions/8").mock( - return_value=httpx.Response(200, json={"ACTION_ID": 8}) + # DESCRIPTION resolves EMPTY -- the shadow-fallback case. + respx.get(f"{ROOT}/actions/9/description").mock( + return_value=httpx.Response(200, json={"DESCRIPTION": ""}) + ) + comment = respx.get(f"{ROOT}/actions/9/comment").mock( + return_value=httpx.Response( + 200, json={"COMMENT": "what the user actually reads"} + ) ) respx.get(f"{ROOT}/requests/I1/documents").mock( return_value=httpx.Response(200, json={"Documents": []}) ) with EasyvistaClient(config) as client: context = client.get_ticket_context("I1") - assert [a.action_id for a in context.actions] == [7, 8] - assert context.actions[0].description is None + assert comment.call_count == 1 + assert context.actions[0].comment == "what the user actually reads" + # ...and it must survive into the rendered document, not just the model. + assert "what the user actually reads" in context.to_markdown() @respx.mock -def test_ticket_context_keeps_an_action_that_has_no_id(config): - # A listed action with neither ACTION_ID nor a numeric HREF tail has - # nothing to fetch item-level, so it passes through untouched. Without the - # short-circuit the client would request `actions/None` (here: an unmocked - # route) instead of degrading. +def test_ticket_context_does_not_fetch_comment_when_description_has_text(config): + # The third request is conditional: DESCRIPTION wins in the UI, so a + # populated description makes the COMMENT memo dead weight. Resolving it + # anyway would add one request per action on every ticket. respx.get(f"{ROOT}/requests/I1").mock( return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) ) respx.get(f"{ROOT}/requests/I1/description").mock(return_value=httpx.Response(404)) respx.get(f"{ROOT}/requests/I1/comment").mock(return_value=httpx.Response(404)) respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"actions": [{"ACTION_ID": 9}]}) + ) + respx.get(f"{ROOT}/actions/9").mock( return_value=httpx.Response( 200, - json={"actions": [{"HREF": f"{ROOT}/requests/I1"}, {"ACTION_ID": 8}]}, + json={ + "ACTION_ID": 9, + "DESCRIPTION": {"HREF": f"{ROOT}/actions/9/description"}, + "COMMENT": {"HREF": f"{ROOT}/actions/9/comment"}, + }, ) ) - item = respx.get(f"{ROOT}/actions/8").mock( - return_value=httpx.Response(200, json={"ACTION_ID": 8}) + respx.get(f"{ROOT}/actions/9/description").mock( + return_value=httpx.Response(200, json={"DESCRIPTION": "the visible note"}) + ) + comment = respx.get(f"{ROOT}/actions/9/comment").mock( + return_value=httpx.Response(200, json={"COMMENT": "shadowed, never rendered"}) + ) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"Documents": []}) + ) + with EasyvistaClient(config) as client: + context = client.get_ticket_context("I1") + assert comment.call_count == 0 + assert context.actions[0].description == "the visible note" + assert "shadowed, never rendered" not in context.to_markdown() + + +@respx.mock +def test_ticket_context_tolerates_an_action_that_has_vanished(config): + # The 404 arm of the same except clause -- an action listed but deleted + # before we fetch it item-level. Without this case the clause could be + # narrowed to EasyvistaAuthError alone and the suite would stay green. + # The vanished action keeps its slot: degrading must never shorten a + # ticket's history behind the caller's back. + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + respx.get(f"{ROOT}/requests/I1/description").mock(return_value=httpx.Response(404)) + respx.get(f"{ROOT}/requests/I1/comment").mock(return_value=httpx.Response(404)) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response( + 200, json={"actions": [{"ACTION_ID": 7}, {"ACTION_ID": 8}]} + ) + ) + respx.get(f"{ROOT}/actions/7").mock(return_value=httpx.Response(404)) + respx.get(f"{ROOT}/actions/8").mock( + return_value=httpx.Response(200, json={"ACTION_ID": 8}) + ) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"Documents": []}) + ) + with EasyvistaClient(config) as client: + context = client.get_ticket_context("I1") + assert [a.action_id for a in context.actions] == [7, 8] + assert context.actions[0].description is None + + +@respx.mock +def test_ticket_context_keeps_an_action_that_has_no_id(config): + # A listed action with neither ACTION_ID nor a numeric HREF tail has + # nothing to fetch item-level, so it passes through untouched. Without the + # short-circuit the client would request `actions/None` (here: an unmocked + # route) instead of degrading. + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + respx.get(f"{ROOT}/requests/I1/description").mock(return_value=httpx.Response(404)) + respx.get(f"{ROOT}/requests/I1/comment").mock(return_value=httpx.Response(404)) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response( + 200, + json={"actions": [{"HREF": f"{ROOT}/requests/I1"}, {"ACTION_ID": 8}]}, + ) + ) + item = respx.get(f"{ROOT}/actions/8").mock( + return_value=httpx.Response(200, json={"ACTION_ID": 8}) ) respx.get(f"{ROOT}/requests/I1/documents").mock( return_value=httpx.Response(200, json={"Documents": []}) @@ -1046,6 +1630,94 @@ def record(request): assert documents < actions_item +@respx.mock +def test_get_ticket_context_resolves_the_two_default_memos(config): + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + respx.get(f"{ROOT}/requests/I1/description").mock( + return_value=httpx.Response(200, json={"DESCRIPTION": "body"}) + ) + respx.get(f"{ROOT}/requests/I1/comment").mock( + return_value=httpx.Response(200, json={"COMMENT": "note"}) + ) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"actions": []}) + ) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"documents": []}) + ) + + with EasyvistaClient(config) as client: + ctx = client.get_ticket_context("I1", resolve_action_bodies=False) + + assert ctx.memos == {"description": "body", "comment": "note"} + assert ctx.description == "body" + assert ctx.comment == "note" + + +@respx.mock +def test_get_ticket_context_honours_custom_memo_fields(config): + """An instance whose body memo is neither default is reached by naming it.""" + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + solution = respx.get(f"{ROOT}/requests/I1/solution").mock( + return_value=httpx.Response(200, json={"SOLUTION": "fixed it"}) + ) + description = respx.get(f"{ROOT}/requests/I1/description").mock( + return_value=httpx.Response(200, json={"DESCRIPTION": "unused"}) + ) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"actions": []}) + ) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"documents": []}) + ) + + with EasyvistaClient(config) as client: + ctx = client.get_ticket_context( + "I1", resolve_action_bodies=False, memo_fields=("solution",) + ) + + assert ctx.memos == {"solution": "fixed it"} + assert ctx.description is None + assert ctx.comment is None + assert solution.called + # The default memos are not fetched when they were not asked for. + assert not description.called + + +@respx.mock +def test_get_ticket_context_empty_memo_fields_skips_memo_resolution(config): + """The empty-tuple boundary: no memo sub-resource is requested at all. + + Pins the ``settle`` slicing arithmetic -- ``memo_results[: len(memo_fields)]`` + and ``memo_results[len(memo_fields) :]`` -- at ``len(memo_fields) == 0``, so a + future rewrite of that slicing cannot silently break this case. + """ + respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"RFC_NUMBER": "I1"}) + ) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"actions": []}) + ) + respx.get(f"{ROOT}/requests/I1/documents").mock( + return_value=httpx.Response(200, json={"documents": []}) + ) + + with EasyvistaClient(config) as client: + ctx = client.get_ticket_context( + "I1", resolve_action_bodies=False, memo_fields=() + ) + + assert ctx.memos == {} + assert ctx.description is None + assert ctx.comment is None + assert ctx.actions == [] + assert ctx.documents == [] + + # --- department context ------------------------------------------------------ @@ -1091,6 +1763,175 @@ def test_get_department_context_full_assembly(config): assert ctx.ticket_statistics is not None +@respx.mock +def test_get_department_context_honours_memo_fields(config): + """``memo_fields`` threads the same memo selector through the bundle. + + A sequence rather than a single name, mirroring + :meth:`get_ticket_context`'s own ``memo_fields``: every resolved memo lands + in ``memos`` and ``note`` is the first with text. The read stays wrapped in + the bundle's 403/404 degradation, unlike :meth:`get_department_comment`, + which raises -- swapping that would change the bundle's failure semantics, + not just its route. + """ + respx.get(f"{ROOT}/departments/60").mock( + return_value=httpx.Response(200, json={"records": [{"DEPARTMENT_ID": 60}]}) + ) + respx.get(f"{ROOT}/employees").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + respx.get(f"{ROOT}/assets").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + default_route = respx.get(f"{ROOT}/departments/60/comment_department").mock( + return_value=httpx.Response(200, json={"COMMENT_DEPARTMENT": "default"}) + ) + override = respx.get(f"{ROOT}/departments/60/comment_service").mock( + return_value=httpx.Response(200, json={"COMMENT_SERVICE": "overridden"}) + ) + with EasyvistaClient(config) as client: + ctx = client.get_department_context( + 60, resolve_manager=False, memo_fields=("comment_service",) + ) + assert ctx.note == "overridden" + assert ctx.memos == {"comment_service": "overridden"} + assert override.call_count == 1 + assert default_route.call_count == 0 + + +def _department_bundle_mocks(ticket_route_json=None): + """Mock every branch of the department bundle with an empty-but-valid page. + + Returns the ``/requests`` route so a caller can inspect the parameters the + recent-tickets and statistics sweeps sent. + """ + respx.get(f"{ROOT}/departments/60").mock( + return_value=httpx.Response(200, json={"records": [{"DEPARTMENT_ID": 60}]}) + ) + respx.get(f"{ROOT}/employees").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + respx.get(f"{ROOT}/assets").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + respx.get(f"{ROOT}/departments/60/comment_department").mock( + return_value=httpx.Response(200, json={"COMMENT_DEPARTMENT": "note"}) + ) + return respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, + json=ticket_route_json + or {"records": [], "record_count": 0, "total_record_count": 0}, + ) + ) + + +@respx.mock +def test_get_department_context_projects_and_sorts_recent_tickets(config): + """The one deliberate default change, pinned at the request level. + + Sending no projection is not neutral: on the verified instance the default + list projection returns TITLE present but EMPTY, so every recent ticket's + ``.title`` was ``None`` for every caller. The default now projects. + """ + route = _department_bundle_mocks() + with EasyvistaClient(config) as client: + client.get_department_context(60, resolve_manager=False) + sweeps = [ + call.request.url.params + for call in route.calls + if call.request.url.params.get("sort") + ] + assert len(sweeps) == 1 + assert sweeps[0]["sort"] == "RFC_NUMBER DESC" + assert "TITLE" in sweeps[0]["fields"] + + +@respx.mock +def test_get_department_context_forwards_a_caller_projection_and_sort(config): + """``ticket_fields=None`` restores the exact previous request.""" + route = _department_bundle_mocks() + with EasyvistaClient(config) as client: + client.get_department_context( + 60, + resolve_manager=False, + include_statistics=False, + ticket_fields=["RFC_NUMBER"], + recent_tickets_sort=None, + ) + params = route.calls.last.request.url.params + assert params["fields"] == "RFC_NUMBER" + assert "sort" not in params + + route.reset() + with EasyvistaClient(config) as client: + client.get_department_context( + 60, + resolve_manager=False, + include_statistics=False, + ticket_fields=None, + ) + assert "fields" not in route.calls.last.request.url.params + + +@respx.mock +def test_get_department_context_caps_the_statistics_sample(config): + """``statistics_max_records`` was inherited silently from + ``ticket_statistics``; it is a keyword now, and the truncation is + disclosed.""" + _department_bundle_mocks( + { + "records": [{"RFC_NUMBER": "I1"}, {"RFC_NUMBER": "I2"}], + "record_count": 2, + "total_record_count": 2, + } + ) + with EasyvistaClient(config) as client: + ctx = client.get_department_context( + 60, resolve_manager=False, statistics_max_records=1, dimensions=["STATUS"] + ) + assert ctx.ticket_statistics is not None + assert ctx.ticket_statistics.total == 1 + assert ctx.ticket_statistics.truncated is True + assert ctx.ticket_statistics.population_total == 2 + + +@pytest.mark.parametrize("status", [403, 404]) +@respx.mock +def test_get_department_context_records_degraded_branches(config, status): + """A swallowed 403 used to be indistinguishable from an empty result. + + The department bundle degrades on both 403 and 404, so both are recorded. + Entries are ``":"`` and a memo branch is itself + ``"memo:"``, which is why they must be split with ``rsplit``. + """ + respx.get(f"{ROOT}/departments/60").mock( + return_value=httpx.Response(200, json={"records": [{"DEPARTMENT_ID": 60}]}) + ) + for path in ("employees", "requests", "assets"): + respx.get(f"{ROOT}/{path}").mock(return_value=httpx.Response(status)) + respx.get(f"{ROOT}/departments/60/comment_department").mock( + return_value=httpx.Response(status) + ) + with EasyvistaClient(config) as client: + ctx = client.get_department_context(60, resolve_manager=False) + for branch in ( + "employees", + "recent_tickets", + "assets", + "ticket_count", + "statistics", + "memo:comment_department", + ): + assert f"{branch}:{status}" in ctx.degraded + # The bundle still assembles; degradation is reported, not raised. + assert ctx.department.department_id == 60 + assert ctx.note is None + + @pytest.mark.parametrize("status", [403, 404]) @respx.mock def test_get_department_context_degrades_on_403_and_404(config, status): @@ -1246,3 +2087,514 @@ def test_get_department_context_rejects_blank_department_id(config): with pytest.raises(ValueError, match="department_id is required"): client.get_department_context(department_id) assert employees_route.call_count == 0 + + +# --- the escape hatch -------------------------------------------------------- + + +@respx.mock +def test_send_reaches_an_unwrapped_route_and_returns_raw_json(config): + # `status` is a reference table this package does not wrap. Nothing is + # validated into a model and no envelope is unwrapped: the caller owns the + # shape, which is the point. + route = respx.get(f"{ROOT}/status").mock( + return_value=httpx.Response(200, json={"records": [{"STATUS_ID": 8}]}) + ) + with EasyvistaClient(config) as client: + assert client.send("GET", "status") == {"records": [{"STATUS_ID": 8}]} + assert route.call_count == 1 + + +@respx.mock +def test_send_upper_cases_the_method_and_strips_a_leading_slash(config): + route = respx.get(f"{ROOT}/status").mock(return_value=httpx.Response(200, json={})) + with EasyvistaClient(config) as client: + client.send("get", "/status") + assert route.call_count == 1 + + +@respx.mock +def test_send_shares_the_error_mapping_and_retry_policy(config): + # Proves it rides the one transport path rather than a parallel one: 403 + # maps, 590 maps and is NOT retried, 5xx IS retried. + respx.get(f"{ROOT}/known-errors").mock(return_value=httpx.Response(403)) + with EasyvistaClient(config) as client: + with pytest.raises(EasyvistaAuthError): + client.send("GET", "known-errors") + + rejected = respx.post(f"{ROOT}/problems").mock( + return_value=httpx.Response(590, json={"error": "nope", "error_code": 2013}) + ) + retried = respx.get(f"{ROOT}/licenses").mock(return_value=httpx.Response(503)) + retrying = EasyvistaConfig( + server="https://ev.test", account="acme", token="tok", max_retries=2 + ) + with EasyvistaClient(retrying) as client: + with pytest.raises(EasyvistaValidationError): + client.send("POST", "problems", json={}) + with pytest.raises(EasyvistaServerError): + client.send("GET", "licenses") + assert rejected.call_count == 1 # 590 is deterministic, never retried + assert retried.call_count == 3 # 1 attempt + 2 retries + + +@respx.mock +def test_send_puts_a_bare_list_body_on_the_wire(config): + # Pins the RequestSpec.json widening to Any: some unwrapped routes take a + # bare list, and httpx accepts anything JSON-serialisable. + route = respx.post(f"{ROOT}/groups").mock(return_value=httpx.Response(200, json={})) + with EasyvistaClient(config) as client: + client.send("POST", "groups", json=[{"a": 1}]) + assert json.loads(route.calls.last.request.content) == [{"a": 1}] + + +@respx.mock +def test_send_never_reaches_a_foreign_host(config): + # The credential stays scoped to the configured instance BY CONSTRUCTION: + # `path` is always joined to api_root, never treated as an absolute URL. So + # an absolute URL becomes a nonsense path under the instance rather than a + # request to the host it names -- and the token never leaves the instance. + foreign = respx.get("https://attacker.test/steal").mock( + return_value=httpx.Response(200, json={"stolen": True}) + ) + joined = respx.get( + f"{ROOT}/https://attacker.test/steal", + ).mock(return_value=httpx.Response(404, json={})) + with EasyvistaClient(config) as client: + with pytest.raises(EasyvistaError): + client.send("GET", "https://attacker.test/steal") + assert foreign.call_count == 0 + assert joined.call_count == 1 + + +# --- per-call query parameters ----------------------------------------------- + + +@respx.mock +def test_search_tickets_sends_params_alongside_the_builders_own(config): + route = respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + with EasyvistaClient(config) as client: + client.search_tickets(params={"formatDate": "iso"}) + url = route.calls.last.request.url + assert url.params["formatDate"] == "iso" + assert url.params["max_rows"] == "100" + + +@respx.mock +def test_a_caller_param_cannot_replace_the_builders_own(config): + # merge_params layers the spec LAST, so a caller cannot silently change what + # the method is actually asking for. + route = respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + with EasyvistaClient(config) as client: + client.search_tickets(max_rows=10, params={"max_rows": 5}) + assert route.calls.last.request.url.params["max_rows"] == "10" + + +@respx.mock +def test_iter_tickets_still_advances_the_offset_when_params_override_it(config): + # The pagination hazard: a caller passing offset= must not stall the sweep. + # The builder's offset wins on every page, so this terminates. + respx.get(f"{ROOT}/requests").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "records": [{"RFC_NUMBER": "I1"}, {"RFC_NUMBER": "I2"}], + "@next": f"{ROOT}/requests?offset=2", + }, + ), + httpx.Response(200, json={"records": [{"RFC_NUMBER": "I3"}]}), + ] + ) + with EasyvistaClient(config) as client: + seen = [t.rfc_number for t in client.iter_tickets(params={"offset": 0})] + assert seen == ["I1", "I2", "I3"] + + +@respx.mock +def test_list_actions_keeps_its_rfc_filter_when_a_caller_passes_search(config): + # ',' is a live combinator in this grammar, so a caller-supplied search that + # replaced the builder's filter could list another ticket's actions. + route = respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + with EasyvistaClient(config) as client: + client.list_actions("I1", params={"search": 'RFC_NUMBER:"other"'}) + assert route.calls.last.request.url.params["search"] == 'REQUEST.RFC_NUMBER:"I1"' + + +@respx.mock +def test_get_ticket_forwards_a_fields_projection(config): + """A projection on the item route, for when one column poisons the record. + + A value the read model refuses fails the entire ``Request``, and without + this there was no way to read the rest of the ticket. Note the item route + may ignore ``fields`` -- the verified instance's own OpenAPI declares it on + ``GET /requests`` but not on ``GET /requests/{rfc_number}``. + """ + route = respx.get(f"{ROOT}/requests/I1").mock( + return_value=httpx.Response(200, json={"records": [{"RFC_NUMBER": "I1"}]}) + ) + with EasyvistaClient(config) as client: + client.get_ticket("I1", fields=["RFC_NUMBER", "TITLE"]) + assert route.calls.last.request.url.params["fields"] == "RFC_NUMBER,TITLE" + + +@respx.mock +def test_a_configured_datetime_format_is_honoured_end_to_end(config): + """The only test that walks the whole thread: config field to model_validate. + + The read models refuse a timestamp they cannot parse rather than guessing + an instant, and a search validates a whole page in one comprehension -- so + on a deployment whose format differs, one column fails every record on the + page. ``datetime_input_formats`` is the way through that is not a fork. + + Both halves matter. The default config must still refuse the payload (the + guard is not softened), and the configured one must accept it (the context + actually reaches ``model_validate`` through 26 builder signatures). + """ + payload = {"records": [{"RFC_NUMBER": "I1", "LAST_UPDATE": "17/08/2026 15:40:00"}]} + respx.get(f"{ROOT}/requests").mock(return_value=httpx.Response(200, json=payload)) + + with EasyvistaClient(config) as client: + with pytest.raises( + pydantic.ValidationError, match="not an EasyVista timestamp" + ): + client.search_tickets() + + tolerant = dataclasses.replace( + config, datetime_input_formats=("%d/%m/%Y %H:%M:%S",) + ) + with EasyvistaClient(tolerant) as client: + result = client.search_tickets() + assert result.records[0].last_update is not None + assert result.records[0].last_update.year == 2026 + + +# --- instance discovery ------------------------------------------------------ + + +@respx.mock +def test_get_api_spec_accepts_a_201_response(config): + """The regression guard for the ``== 200`` trap. + + A GET to this route answers **201**, not 200 (measured 2026-08-27 on one + instance). This client's transport gates on ``is_success``, so any 2xx + works -- but code written beside it that checks ``status_code == 200`` + skips the document in silence and concludes the instance publishes no spec. + Asserting on 201 specifically is the whole point; a 200 here would prove + nothing. + """ + respx.get(f"{ROOT}/swagger").mock( + return_value=httpx.Response( + 201, json={"info": {"description": "EV REST API - 2025.3"}, "paths": {}} + ) + ) + with EasyvistaClient(config) as client: + document = client.get_api_spec() + assert document["info"]["description"] == "EV REST API - 2025.3" + + +@respx.mock +def test_get_api_spec_honours_a_custom_path(config): + """The route is tier 4 -- measured on one instance and NOT declared in that + instance's own paths -- so a deployment publishing elsewhere needs a way + through that is not a fork.""" + route = respx.get(f"{ROOT}/openapi.json").mock( + return_value=httpx.Response(200, json={"paths": {}}) + ) + with EasyvistaClient(config) as client: + client.get_api_spec(path="openapi.json") + assert route.call_count == 1 + + +@respx.mock +def test_list_reference_table_lets_a_403_propagate(config): + """A denial must not look like an empty table. + + An empty reference table is a legitimate answer on a lightly configured + instance. Collapsing a 403 into ``[]`` would make "you may not read this" + indistinguishable from "there is nothing here" -- and a caller building a + status map from ``[]`` concludes the instance has no statuses and reaches + for a hardcoded constant. ``describe_instance`` is the swallowing layer. + """ + respx.get(f"{ROOT}/catalog-requests").mock(return_value=httpx.Response(403)) + with EasyvistaClient(config) as client: + with pytest.raises(EasyvistaAuthError): + client.list_reference_table("catalog-requests") + + +@respx.mock +def test_list_reference_table_returns_a_search_result_with_counts(config): + """A SearchResult, not a bare list, so truncation is detectable.""" + respx.get(f"{ROOT}/urgency").mock( + return_value=httpx.Response( + 200, + json={ + "records": [{"URGENCY_ID": 1, "URGENCY_EN": "Low"}], + "record_count": "1", + "total_record_count": "7", + }, + ) + ) + with EasyvistaClient(config) as client: + page = client.list_reference_table("urgency") + assert page.record_count == 1 + assert page.total_record_count == 7 + assert page.records[0].model_dump(by_alias=True)["URGENCY_EN"] == "Low" + + +@respx.mock +def test_discover_status_populates_the_guid_from_a_ticket_sample(config): + """The STATUS_GUID recipe, asserted end to end. + + A STATUS_GUID is not searchable and no reference read returns one, but + every ticket's nested STATUS object carries it. The GUID is what + ``set_status`` and ``close_ticket`` address a status by -- a STATUS_ID will + not work there -- so this is usually the value the caller came for. + """ + respx.get(f"{ROOT}/status").mock( + return_value=httpx.Response( + 200, + json={ + "records": [ + {"STATUS_ID": 8, "STATUS_FR": "Cloture"}, + {"STATUS_ID": 12, "STATUS_FR": "En cours"}, + ] + }, + ) + ) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, + json={ + "records": [ + { + "RFC_NUMBER": "I1", + "STATUS": { + "STATUS_ID": "8", + "STATUS_GUID": "{ABC}", + "STATUS_FR": "Cloture", + }, + } + ] + }, + ) + ) + with EasyvistaClient(config) as client: + found = client.discover("STATUS", sample_size=5) + by_id = {r.id: r for r in found} + assert by_id["8"].guid == "{ABC}" + # A status present in the table but held by no sampled ticket keeps + # guid=None: the sample cannot reach it, and inventing one would hand back + # a GUID that addresses nothing. + assert by_id["12"].guid is None + + +@respx.mock +def test_discover_a_routeless_name_never_calls_a_reference_route(config): + """IMPACT has no route in the spec at all, so no request is wasted on one.""" + impact_route = respx.get(f"{ROOT}/impact").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, + json={ + "records": [ + {"RFC_NUMBER": "I1", "IMPACT_ID": "17"}, + {"RFC_NUMBER": "I2", "IMPACT_ID": "17"}, + {"RFC_NUMBER": "I3", "IMPACT_ID": "21"}, + ] + }, + ) + ) + with EasyvistaClient(config) as client: + found = client.discover("IMPACT", sample_size=10) + assert impact_route.call_count == 0 + assert [(r.id, r.count) for r in found] == [("17", 2), ("21", 1)] + assert all(r.source == "sample" for r in found) + + +@respx.mock +def test_discover_strategy_reference_lets_a_403_raise(config): + respx.get(f"{ROOT}/status").mock(return_value=httpx.Response(403)) + with EasyvistaClient(config) as client: + with pytest.raises(EasyvistaAuthError): + client.discover("STATUS", strategy="reference") + + +@respx.mock +def test_discover_auto_falls_back_to_the_sample_on_a_denied_route(config): + respx.get(f"{ROOT}/status").mock(return_value=httpx.Response(403)) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, + json={ + "records": [ + { + "RFC_NUMBER": "I1", + "STATUS": {"STATUS_ID": "8", "STATUS_FR": "Cloture"}, + } + ] + }, + ) + ) + with EasyvistaClient(config) as client: + found = client.discover("STATUS", sample_size=5) + assert [(r.id, r.source) for r in found] == [("8", "sample")] + + +def test_discover_strategy_reference_refuses_a_routeless_name(config): + """Rather than quietly sampling, which would answer a different question.""" + with EasyvistaClient(config) as client: + with pytest.raises(ValueError, match="no reference route"): + client.discover("IMPACT", strategy="reference") + + +def test_discover_refuses_an_unknown_strategy(config): + with EasyvistaClient(config) as client: + with pytest.raises(ValueError, match="strategy="): + client.discover("STATUS", strategy="guess") + + +@respx.mock +def test_describe_instance_names_every_gap_and_still_returns_a_profile(config): + """No part can fail the whole. + + ``/catalog-requests`` is denied here and everything else succeeds: the + profile still holds the other names, and the gap is named rather than + silently empty. + """ + respx.get(f"{ROOT}/swagger").mock( + return_value=httpx.Response( + 201, + json={ + "info": {"description": "EV REST API - 2025.3"}, + "paths": {"/requests": {}, "/actions": {}}, + }, + ) + ) + respx.get(f"{ROOT}/catalog-requests").mock(return_value=httpx.Response(403)) + for table in ("status", "urgency", "locations", "departments", "slas", "groups"): + respx.get(f"{ROOT}/{table}").mock( + return_value=httpx.Response(200, json={"records": [{"NAME_EN": "x"}]}) + ) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response( + 200, json={"records": [{"RFC_NUMBER": "I1", "IMPACT_ID": "17"}]} + ) + ) + respx.get(f"{ROOT}/actions").mock( + return_value=httpx.Response( + 200, json={"records": [{"ACTION_ID": 1, "ACTION_TYPE_ID": 94}]} + ) + ) + with EasyvistaClient(config) as client: + profile = client.describe_instance( + sample_size=5, action_sample_tickets=1 + ) + assert profile.version == "EV REST API - 2025.3" + assert "/requests" in profile.spec_paths + assert profile.unavailable["CATALOG_REQUEST"].startswith("denied") + assert profile.references["STATUS"] + assert profile.references["IMPACT"][0].id == "17" + # The four routeless names say so, rather than looking like empty tables. + for routeless in ("IMPACT", "SEVERITY", "ORIGIN", "ACTION_TYPE"): + assert profile.unavailable[routeless].startswith("no-route") + + +@respx.mock +def test_describe_instance_records_a_truncated_table(config): + """The rows present are real; they are just not all of them.""" + respx.get(f"{ROOT}/swagger").mock( + return_value=httpx.Response(201, json={"info": {}, "paths": {}}) + ) + respx.get(f"{ROOT}/status").mock( + return_value=httpx.Response( + 200, + json={ + "records": [{"STATUS_ID": 8, "STATUS_FR": "Cloture"}], + "record_count": 1, + "total_record_count": 40, + }, + ) + ) + respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response(200, json={"records": []}) + ) + with EasyvistaClient(config) as client: + profile = client.describe_instance(names=["STATUS"], sample_size=1) + assert profile.unavailable["STATUS"].startswith("truncated") + assert profile.references["STATUS"] # and the rows still came back + + +@respx.mock +def test_describe_instance_returns_a_profile_when_everything_is_denied(config): + """A total outage looks like a bare instance EXCEPT that every gap is named. + + Which is exactly why the docstring tells the reader to check + ``.unavailable`` before concluding an instance has no statuses. + """ + respx.route(host="ev.test").mock(return_value=httpx.Response(403)) + with EasyvistaClient(config) as client: + profile = client.describe_instance( + names=["STATUS", "IMPACT"], sample_size=1 + ) + assert profile.spec_paths == () + assert profile.unavailable["spec"].startswith("denied") + assert profile.references["STATUS"] == [] + assert profile.references["IMPACT"] == [] + + +@respx.mock +def test_end_action_forwards_every_keyword_to_the_body(config): + """Pin the kwarg forwarding, ``start_date`` above all. + + A dropped ``start_date`` is invisible in the response -- the server simply + derives one, and the derived one is early by the instance's UTC offset. So + this asserts the wire body, not the return value. + """ + route = respx.put(f"{ROOT}/actions/I1").mock( + return_value=httpx.Response(200, json={"HREF": f"{ROOT}/requests/I1"}) + ) + with EasyvistaClient(config) as client: + client.end_action( + "I1", + action_id=42, + start_date="01/09/2026 17:00:00", + end_date="01/09/2026 17:15:00", + elapsed_time=15, + doneby_mail="tech@example.invalid", + ) + assert json.loads(route.calls.last.request.content) == { + "end_action": { + "action_id": 42, + "start_date": "01/09/2026 17:00:00", + "end_date": "01/09/2026 17:15:00", + "elapsed_time": 15, + "doneby_mail": "tech@example.invalid", + } + } + + +@respx.mock +def test_end_action_addresses_the_ticket_not_the_action(config): + """``actions/{action_id}`` answers 404 for this verb; the RFC is the path.""" + route = respx.put(f"{ROOT}/actions/I1").mock( + return_value=httpx.Response(200, json={"HREF": f"{ROOT}/requests/I1"}) + ) + with EasyvistaClient(config) as client: + client.end_action("I1", action_id=42) + assert route.calls.last.request.url.path.endswith("/actions/I1") + + +def test_end_action_refuses_a_missing_action_id_before_any_request(config): + """No socket is opened: the refusal is local, so respx is not even needed.""" + with EasyvistaClient(config) as client: + with pytest.raises(ValueError, match="end_all"): + client.end_action("I1", end_date="01/09/2026 17:00:00") diff --git a/easyvista_python_client/_sync/tests/test_transport.py b/easyvista_python_client/_sync/tests/test_transport.py index 5dc5ec4..ed5de63 100644 --- a/easyvista_python_client/_sync/tests/test_transport.py +++ b/easyvista_python_client/_sync/tests/test_transport.py @@ -7,13 +7,15 @@ only one surface. """ +from collections.abc import Iterator + import httpx import pytest import respx from easyvista_python_client._sync._transport import BaseTransport, Transport from easyvista_python_client._transport import RequestSpec -from easyvista_python_client.config import EasyvistaConfig +from easyvista_python_client.config import DEFAULT_USER_AGENT, EasyvistaConfig from easyvista_python_client.exceptions import ( EasyvistaAuthError, EasyvistaConnectionError, @@ -434,3 +436,512 @@ def test_get_bytes_keeps_the_bearer_token_on_a_same_host_redirect(): content = transport.get_bytes("https://ev.test/download/42") assert content == b"blob" assert signed.calls.last.request.headers["Authorization"] == "Bearer tok" + + +# --- stream_bytes ------------------------------------------------------------ +# +# The streaming download. Its contract is "identical to get_bytes except that +# the body arrives in pieces", so most of these are the get_bytes assertions +# above re-made against the chunked path -- that duplication is the point, since +# the two implementations share no code past `resolve_url`. The one claim with +# no get_bytes counterpart is the retry boundary: retrying stops once a byte has +# reached the caller, because restarting would deliver it twice. + + +class _StreamThatFailsMidBody(httpx.SyncByteStream): + """A response body that delivers ``prefix`` and then drops the connection. + + ``respx`` can fail a request before a response exists, which is what the + ``side_effect=httpx.ConnectError`` mocks elsewhere in this module do. It has + no way to fail one *after* the status line, and that is exactly the case the + retry boundary is about -- so the failure is injected into the body stream + itself, which httpx surfaces as a real ``TransportError`` while iterating. + """ + + def __init__(self, prefix: bytes) -> None: + self._prefix = prefix + + def __iter__(self) -> Iterator[bytes]: + yield self._prefix + raise httpx.ReadError("connection dropped mid-body") + + +class _StreamThatFailsBeforeTheFirstByte(httpx.SyncByteStream): + """A response body that drops the connection without yielding anything. + + The sibling of :class:`_StreamThatFailsMidBody`, for the *other* side of the + retry boundary: the status line arrived, so this is past the point respx can + fail a request, but no byte has reached the caller yet, so restarting is + still safe and must happen. + """ + + def __iter__(self) -> Iterator[bytes]: + raise httpx.ReadError("dropped before the first byte") + yield b"" # unreachable; the yield is what makes this a generator function + + +def _collect(chunks: Iterator[bytes]) -> list[bytes]: + """Every chunk a stream yields, kept separate rather than joined.""" + return [chunk for chunk in chunks] + + +@respx.mock +def test_stream_bytes_reassembles_to_the_whole_body(): + # 10244 bytes at chunk_size=1024: deliberately NOT a multiple of it, so the + # last chunk is a short one. A body sized to an exact multiple never + # exercises the ragged tail, and the reassembly assertion below would + # pass either way. + body = bytes(range(256)) * 40 + b"tail" + respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(200, content=body) + ) + with Transport(_cfg()) as transport: + chunks = _collect( + transport.stream_bytes("documents/1/content", chunk_size=1024) + ) + assert b"".join(chunks) == body + # More than one chunk, and each bounded: proves the body is delivered + # progressively rather than read whole and handed over in a single piece. + assert len(chunks) == 11 + assert max(len(chunk) for chunk in chunks) <= 1024 + assert len(chunks[-1]) == 4, "the short final chunk was padded or dropped" + + +@respx.mock +def test_stream_bytes_chunks_at_the_documented_default_size(): + """The default chunk size is 64 KiB, and this is what says so. + + ``DEFAULT_STREAM_CHUNK_SIZE`` is quoted as "64 KiB" in the CHANGELOG, in the + document-workflow skill and in the constant's own comment (whose "a 32 MB + attachment is ~512 iterations" arithmetic only holds at that value). Every + other chunk-counting test passes ``chunk_size`` explicitly, so without this + one the constant could change to anything and leave all three false with a + green suite. 160 KiB of body -> three chunks, the last a short one. + """ + body = bytes(range(256)) * 640 # 163840 bytes == 2.5 * 64 KiB + respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(200, content=body) + ) + with Transport(_cfg()) as transport: + chunks = _collect(transport.stream_bytes("documents/1/content")) + assert b"".join(chunks) == body + assert [len(chunk) for chunk in chunks] == [65536, 65536, 32768] + + +@pytest.mark.parametrize("bad", [0, -8]) +def test_stream_bytes_refuses_a_non_positive_chunk_size(bad): + """The newest public parameter must fail as bad input, not as a library bug. + + Left unguarded, httpx raises from inside its own ``ByteChunker`` several + frames below this client: ``chunk_size=0`` surfaces as "range() arg 3 must + not be zero" and a negative one as "list index out of range". A caller + computing a chunk size reads either as our bug. No request is made, so this + needs no mock -- and because ``stream_bytes`` is a generator, the raise lands + on the first iteration step, matching the deferred ``ValueError`` for a + record with no download URL. + """ + with Transport(_cfg()) as transport: + with pytest.raises(ValueError, match="chunk_size must be positive"): + _collect( + transport.stream_bytes("documents/1/content", chunk_size=bad) + ) + + +@respx.mock +def test_stream_bytes_yields_nothing_for_an_empty_body(): + respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(200, content=b"") + ) + with Transport(_cfg()) as transport: + assert _collect(transport.stream_bytes("documents/1/content")) == [] + + +@respx.mock +def test_stream_bytes_sends_the_bearer_header(): + route = respx.get("https://ev.test/download/42").mock( + return_value=httpx.Response(200, content=b"ok") + ) + with Transport(_cfg()) as transport: + _collect(transport.stream_bytes("https://ev.test/download/42")) + assert route.calls.last.request.headers["Authorization"] == "Bearer tok" + + +@respx.mock +def test_stream_bytes_maps_403_to_auth_error(): + # The status is on an unread streaming response, whose `.content` raises + # until the body is read -- so the error mapping cannot simply be reused, it + # has to read the body first. This asserts the mapped type AND that the + # parsed EasyVista fields survived that detour. + respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(403, json={"error": "forbidden", "code": "9"}) + ) + with Transport(_cfg()) as transport: + with pytest.raises(EasyvistaAuthError) as ei: + _collect(transport.stream_bytes("documents/1/content")) + assert ei.value.status_code == 403 + assert ei.value.ev_message == "forbidden" + assert ei.value.ev_code == "9" + + +@respx.mock +def test_stream_bytes_does_not_retry_a_590(): + route = respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(590, json={"error": "rejected"}) + ) + with Transport(_cfg(max_retries=3)) as transport: + with pytest.raises(EasyvistaValidationError): + _collect(transport.stream_bytes("documents/1/content")) + assert route.call_count == 1 + + +@respx.mock +def test_stream_bytes_retries_a_retryable_status_on_the_open(): + route = respx.get(f"{ROOT}/documents/1/content").mock( + side_effect=[httpx.Response(503), httpx.Response(200, content=b"bytes")] + ) + with Transport(_cfg(max_retries=2)) as transport: + chunks = _collect(transport.stream_bytes("documents/1/content")) + assert b"".join(chunks) == b"bytes" + assert route.call_count == 2 + + +@respx.mock +def test_stream_bytes_exhausts_retries_raises_server_error(): + route = respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(503) + ) + with Transport(_cfg(max_retries=1)) as transport: + with pytest.raises(EasyvistaServerError): + _collect(transport.stream_bytes("documents/1/content")) + assert route.call_count == 2 + + +@respx.mock +def test_stream_bytes_transport_error_on_the_open_raises_connection_error(): + respx.get(f"{ROOT}/documents/1/content").mock( + side_effect=httpx.ConnectError("boom") + ) + with Transport(_cfg()) as transport: + with pytest.raises(EasyvistaConnectionError): + _collect(transport.stream_bytes("documents/1/content")) + + +@respx.mock +def test_stream_bytes_retries_a_failure_fetching_the_first_chunk(): + """The first chunk is fetched INSIDE the retried unit. This is what pins it. + + ``_open_stream`` takes the first chunk itself, so a body that dies before + yielding a byte is still a safe restart -- nothing has reached the caller, so + replaying the request cannot deliver anything twice. Move that fetch out of + the retried unit (open there, iterate the whole body here) and the failure + below escapes as ``EasyvistaConnectionError`` on the first attempt instead, + with ``call_count == 1``. Every other ``stream_bytes`` test passes under both + arrangements, including the mid-body one just after this: the two differ only + on a first-chunk failure, which is only this test. + """ + route = respx.get(f"{ROOT}/documents/1/content").mock( + side_effect=[ + httpx.Response(200, stream=_StreamThatFailsBeforeTheFirstByte()), + httpx.Response(200, content=b"0123456789abcdef"), + ] + ) + with Transport(_cfg(max_retries=2)) as transport: + chunks = _collect( + transport.stream_bytes("documents/1/content", chunk_size=8) + ) + assert b"".join(chunks) == b"0123456789abcdef" + assert route.call_count == 2, "a pre-first-byte failure was not retried" + + +@respx.mock +def test_stream_bytes_does_not_retry_after_a_chunk_has_been_yielded(): + """A mid-body failure is the caller's to handle, never silently restarted. + + Retrying here would hand the caller the opening bytes a second time, so the + request is committed the moment a chunk is delivered. The delivered prefix + stays visible -- the caller keeps what it already collected -- and the + failure arrives as a mapped ``EasyvistaConnectionError``, not as a raw httpx + error. A "helpful" change making this resumable fails on the call count. + """ + route = respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response( + 200, stream=_StreamThatFailsMidBody(b"0123456789abcdef") + ) + ) + collected: list[bytes] = [] + with Transport(_cfg(max_retries=3)) as transport: + with pytest.raises(EasyvistaConnectionError): + for chunk in transport.stream_bytes( + "documents/1/content", chunk_size=8 + ): + collected.append(chunk) + assert b"".join(collected) == b"0123456789abcdef" + assert route.call_count == 1, "a mid-stream failure was retried" + + +def test_stream_bytes_rejects_a_foreign_origin(): + # The same-origin guard is a security property of the download path, not of + # one method on it: every request carries the instance Bearer token. Nothing + # is requested until iteration starts, so the refusal surfaces there. + with Transport(_cfg()) as transport: + with pytest.raises(EasyvistaError, match="outside the configured instance"): + _collect(transport.stream_bytes("https://attacker.test/download/42")) + + +@respx.mock +def test_stream_bytes_drops_the_bearer_token_on_a_cross_host_redirect(): + # `follow_redirects=True` is as deliberate here as on get_bytes (a download + # URL commonly redirects to a signed location), and so is the reason it is + # safe: httpx strips Authorization when a redirect leaves the origin. Pinned + # on this path too, because the streaming send() call passes the flag itself + # rather than inheriting anything from the non-streaming one. + respx.get("https://ev.test/download/42").mock( + return_value=httpx.Response( + 302, headers={"Location": "https://cdn.attacker.test/blob/42"} + ) + ) + foreign = respx.get("https://cdn.attacker.test/blob/42").mock( + return_value=httpx.Response(200, content=b"blob") + ) + with Transport(_cfg()) as transport: + chunks = _collect(transport.stream_bytes("https://ev.test/download/42")) + assert b"".join(chunks) == b"blob" + leaked = "authorization" in foreign.calls.last.request.headers + assert not leaked, "the instance token followed a redirect off the instance" + + +@respx.mock +def test_stream_bytes_keeps_the_bearer_token_on_a_same_host_redirect(): + # Control for the test above, exactly as on get_bytes: without it, a path + # that never sent Authorization at all would look like a pass. + respx.get("https://ev.test/download/42").mock( + return_value=httpx.Response( + 302, headers={"Location": "https://ev.test/download/42/signed"} + ) + ) + signed = respx.get("https://ev.test/download/42/signed").mock( + return_value=httpx.Response(200, content=b"blob") + ) + with Transport(_cfg()) as transport: + chunks = _collect(transport.stream_bytes("https://ev.test/download/42")) + assert b"".join(chunks) == b"blob" + assert signed.calls.last.request.headers["Authorization"] == "Bearer tok" + + +# --- per-deployment adaptation: headers, params, download hosts --------------- + + +def test_headers_carry_the_package_user_agent_by_default(): + assert _base().headers()["User-Agent"] == DEFAULT_USER_AGENT + + +def test_user_agent_config_replaces_the_default(): + assert BaseTransport(_cfg(user_agent="my-app/1.4")).headers()["User-Agent"] == ( + "my-app/1.4" + ) + + +def test_extra_headers_override_every_default_but_not_the_credential(): + # extra_headers is merged LAST, so it wins over Accept, Content-Type and the + # User-Agent alike. It cannot reach the credential: EasyvistaConfig refuses + # an Authorization key at construction, so by the time headers() runs there + # is nothing left to guard against. + transport = BaseTransport( + _cfg( + extra_headers={ + "Accept": "text/csv", + "Content-Type": "text/csv", + "User-Agent": "override/9", + "Ocp-Apim-Subscription-Key": "gateway-key", + } + ) + ) + headers = transport.headers() + assert headers["Accept"] == "text/csv" + assert headers["Content-Type"] == "text/csv" + assert headers["User-Agent"] == "override/9" + assert headers["Ocp-Apim-Subscription-Key"] == "gateway-key" + assert headers["Authorization"] == "Bearer tok" + + +def test_merge_params_layers_config_then_call_then_spec(): + # All three empty returns None, so a request that took no parameters before + # still takes none -- that is what keeps today's wire unchanged. + assert BaseTransport(_cfg()).merge_params(None, None) is None + transport = BaseTransport(_cfg(default_params={"formatDate": "iso", "shared": "c"})) + assert transport.merge_params(None, None) == {"formatDate": "iso", "shared": "c"} + # The caller beats config, and the spec beats both: the spec is what sets + # search/max_rows/offset, so a caller must not be able to replace them. + assert transport.merge_params({"shared": "call"}, None)["shared"] == "call" + assert transport.merge_params({"shared": "call"}, {"shared": "spec"})["shared"] == ( + "spec" + ) + + +def test_download_headers_carry_no_credential_and_no_extra_headers(): + # The whole point of allowing a foreign download host is that reaching it + # must not hand that host this instance's token -- nor a second secret + # sitting in extra_headers. + bearer = EasyvistaConfig( + server="https://ev.test", + account="acme", + token="tok", + extra_headers={"X-Api-Key": "SECRET"}, + ) + basic = EasyvistaConfig( + server="https://ev.test", + account="acme", + login="u", + password="p", + extra_headers={"X-Api-Key": "SECRET"}, + ) + for cfg in (bearer, basic): + assert BaseTransport(cfg).download_headers() == { + "User-Agent": DEFAULT_USER_AGENT + } + + +def _allowing(*hosts): + return BaseTransport(_cfg(additional_download_hosts=set(hosts))) + + +def test_resolve_url_admits_an_allow_listed_https_host(): + url = "https://cdn.allowed.test/blob/1" + assert _allowing("cdn.allowed.test").resolve_url(url) == url + + +def test_resolve_url_allow_list_is_https_only(): + # Opting a host in must never be usable to downgrade a fetch to cleartext. + with pytest.raises(EasyvistaError): + _allowing("cdn.allowed.test").resolve_url("http://cdn.allowed.test/blob/1") + + +def test_resolve_url_still_rejects_a_host_that_is_not_listed(): + with pytest.raises(EasyvistaError, match="additional_download_hosts"): + _allowing("cdn.allowed.test").resolve_url("https://attacker.test/blob/1") + + +def test_resolve_url_allow_list_still_rejects_a_userinfo_prefix(): + # The raw-netloc comparison carries over to the new branch, so + # "attacker.test@cdn.allowed.test" matches no allow-listed host. + with pytest.raises(EasyvistaError): + _allowing("cdn.allowed.test").resolve_url( + "https://attacker.test@cdn.allowed.test/blob/1" + ) + + +def test_is_offsite_answers_false_for_the_instance_even_when_listed(): + # A caller who redundantly lists their own instance host must not thereby + # strip their own credential from every download. + transport = _allowing("ev.test", "cdn.allowed.test") + assert transport.is_offsite(f"{ROOT}/documents/1/content") is False + assert transport.is_offsite("https://cdn.allowed.test/blob/1") is True + + +@respx.mock +def test_get_bytes_sends_no_credential_to_an_allow_listed_host(): + # THE LOAD-BEARING TEST. The token is attached to the instance client at + # CLIENT level -- as a header for Bearer, as an httpx.Auth for Basic -- and + # httpx can remove neither per request. A second, credential-free client is + # what makes this assertion hold, so both auth mechanisms are checked here: + # one does not cover the other. + bearer = EasyvistaConfig( + server="https://ev.test", + account="acme", + token="tok", + additional_download_hosts={"cdn.allowed.test"}, + ) + basic = EasyvistaConfig( + server="https://ev.test", + account="acme", + login="u", + password="p", + additional_download_hosts={"cdn.allowed.test"}, + ) + for cfg in (bearer, basic): + route = respx.get("https://cdn.allowed.test/blob/1").mock( + return_value=httpx.Response(200, content=b"payload") + ) + with Transport(cfg) as transport: + fetched = transport.get_bytes("https://cdn.allowed.test/blob/1") + assert fetched == b"payload" + assert "authorization" not in route.calls.last.request.headers + + +@respx.mock +def test_get_bytes_still_authenticates_against_the_instance(): + # The other half of the pair: opting a foreign host in must not disturb the + # instance's own downloads. + route = respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(200, content=b"payload") + ) + with Transport(_cfg(additional_download_hosts={"cdn.allowed.test"})) as t: + assert t.get_bytes("documents/1/content") == b"payload" + assert route.calls.last.request.headers["Authorization"] == "Bearer tok" + + +@respx.mock +def test_stream_bytes_sends_no_credential_to_an_allow_listed_host(): + # _open_stream selects its client separately and shares no code with + # _do_get_bytes past resolve_url, so the guarantee is asserted twice. + route = respx.get("https://cdn.allowed.test/blob/1").mock( + return_value=httpx.Response(200, content=b"payload") + ) + with Transport(_cfg(additional_download_hosts={"cdn.allowed.test"})) as t: + chunks = _collect(t.stream_bytes("https://cdn.allowed.test/blob/1")) + assert b"".join(chunks) == b"payload" + assert "authorization" not in route.calls.last.request.headers + + +@respx.mock +def test_stream_bytes_still_authenticates_against_the_instance(): + route = respx.get(f"{ROOT}/documents/1/content").mock( + return_value=httpx.Response(200, content=b"payload") + ) + with Transport(_cfg(additional_download_hosts={"cdn.allowed.test"})) as t: + chunks = _collect(t.stream_bytes("documents/1/content")) + assert b"".join(chunks) == b"payload" + assert route.calls.last.request.headers["Authorization"] == "Bearer tok" + + +def test_the_download_client_exists_only_when_opted_in_and_is_closed(): + plain = Transport(_cfg()) + assert plain._download_client is None + plain.close() + + opted = Transport(_cfg(additional_download_hosts={"cdn.allowed.test"})) + assert opted._download_client is not None + opted.close() + assert opted._client.is_closed + assert opted._download_client.is_closed + + +@respx.mock +def test_send_layers_the_three_parameter_sources_on_the_wire(): + route = respx.get(f"{ROOT}/requests").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + cfg = _cfg(default_params={"max_rows": 1, "formatDate": "config"}) + with Transport(cfg) as transport: + transport.send( + RequestSpec("GET", "requests", params={"max_rows": 10}), + params={"max_rows": 5, "formatDate": "iso"}, + ) + url = route.calls.last.request.url + assert url.params["max_rows"] == "10" # the spec wins + assert url.params["formatDate"] == "iso" # the call beats config + + +@respx.mock +def test_request_spec_headers_override_the_client_level_ones(): + route = respx.post(f"{ROOT}/upload").mock(return_value=httpx.Response(200, json={})) + with Transport(_cfg()) as transport: + transport.send( + RequestSpec("POST", "upload", headers={"Content-Type": "text/csv"}) + ) + assert route.calls.last.request.headers["Content-Type"] == "text/csv" + + +def test_request_spec_refuses_the_credential_in_its_headers(): + with pytest.raises(ValueError, match="must not set"): + RequestSpec("GET", "requests", headers={"authorization": "Bearer other"}) diff --git a/easyvista_python_client/_transport.py b/easyvista_python_client/_transport.py index 799a1ef..7dee8fa 100644 --- a/easyvista_python_client/_transport.py +++ b/easyvista_python_client/_transport.py @@ -15,15 +15,35 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from typing import Any +from .config import reject_authorization + @dataclass(frozen=True) class RequestSpec: - """A resource-relative HTTP request, independent of sync/async execution.""" + """A resource-relative HTTP request, independent of sync/async execution. + + ``headers`` are merged OVER the transport's client-level ones for this + request alone, and may not carry ``Authorization``: the credential is + ``config.token`` or ``config.login`` / ``password``, and nothing else. They + exist because the client-level ``Content-Type: application/json`` is wrong + for a route that takes another body type, and a single request must be able + to say so without disturbing the client. + + ``json`` is typed ``Any`` rather than ``dict``: some routes this package does + not wrap take a bare list body, and ``httpx`` accepts anything + JSON-serialisable. + """ method: str path: str params: dict[str, Any] | None = None - json: dict[str, Any] | None = None + json: Any = None + headers: Mapping[str, str] | None = None + + def __post_init__(self) -> None: + if self.headers is not None: + reject_authorization(self.headers, "RequestSpec.headers") diff --git a/easyvista_python_client/_version.py b/easyvista_python_client/_version.py new file mode 100644 index 0000000..b465765 --- /dev/null +++ b/easyvista_python_client/_version.py @@ -0,0 +1,8 @@ +"""The package version, in a leaf module both ``__init__`` and ``config`` import. + +``config.py`` builds the default ``User-Agent`` from it and is imported by +``__init__.py`` before ``__init__`` finishes executing, so the version cannot +live only in ``__init__``. +""" + +__version__ = "0.2.0" diff --git a/easyvista_python_client/config.py b/easyvista_python_client/config.py index a65acba..bf2f7f6 100644 --- a/easyvista_python_client/config.py +++ b/easyvista_python_client/config.py @@ -3,7 +3,54 @@ from __future__ import annotations import os +import ssl +from collections.abc import Mapping from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any, Literal + +from ._version import __version__ as _package_version + +DocumentDeletePathStyle = Literal["nested", "top_level"] + +#: The two routes an EasyVista deployment may expose for deleting a ticket +#: attachment. Both are declared in the instance OpenAPI document read +#: 2026-08-27 (authoritative for that deployment's routes): +#: ``DELETE requests/{RFC_NUMBER}/documents/{id}`` and ``DELETE documents/{id}``, +#: the latter marked ``deprecated`` there. Exposed as a module constant so a +#: caller can validate a value without importing a private name. +DOCUMENT_DELETE_PATH_STYLES: tuple[DocumentDeletePathStyle, ...] = ( + "nested", + "top_level", +) +DEFAULT_DOCUMENT_DELETE_PATH_STYLE: DocumentDeletePathStyle = "nested" + +#: ``User-Agent`` sent when :attr:`EasyvistaConfig.user_agent` is unset. +#: +#: No vendor documentation requires or constrains a User-Agent, so this +#: identifies the client in the instance's access log and nothing more. A caller +#: extending rather than replacing it can build on this value: +#: ``user_agent=f"{DEFAULT_USER_AGENT} my-app/1.4"``. +DEFAULT_USER_AGENT = f"easyvista-python-client/{_package_version}" + + +def reject_authorization(headers: Mapping[str, str], source: str) -> None: + """Raise :class:`ValueError` if ``headers`` carries an ``Authorization`` key. + + Case-insensitively, because HTTP header names are. Shared by + :class:`EasyvistaConfig` and + :class:`~easyvista_python_client._transport.RequestSpec` so the rule is + stated once: 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. + """ + for key in headers: + if key.lower() == "authorization": + raise ValueError( + f"{source} must not set {key!r}. The credential comes from " + "config.token or config.login/password; setting it here would " + "silently shadow it." + ) @dataclass(frozen=True) @@ -11,6 +58,66 @@ class EasyvistaConfig: """Immutable connection settings. Provide either ``token`` (Bearer) or ``login`` + ``password`` (Basic). + + ``account`` is **not a user account**, despite sitting next to ``login`` and + ``password``. It is the EasyVista *instance* identifier -- a number such as + ``"50004"`` -- that forms the final path segment of :attr:`api_root`, + ``https://host/api/{version}/{account}``. Every request is routed through it, + but nothing authenticates with it: authentication is ``token``, or ``login`` + plus ``password``. The two are unrelated values and normally differ. + + ``server`` is the bare instance host, e.g. ``"https://my.easyvista.com"``. A + trailing slash is stripped; do not append the ``/api/...`` path, which + :attr:`api_root` builds from ``api_version`` and ``account``. + + ``document_delete_path_style`` selects which of the two attachment-delete + routes :meth:`delete_document` sends. Both are declared in the instance + OpenAPI document read 2026-08-27 -- ``DELETE requests/{rfc}/documents/{id}`` + and ``DELETE documents/{id}``, the latter marked ``deprecated`` there -- so + which one a deployment actually grants is a profile question, not a routing + one. The default ``"nested"`` is the form verified live 2026-08-17 on one + instance, where the top-level form answered 403; that measurement may not + generalise. Set ``"top_level"`` on a deployment that grants the top-level + route instead, or when only a document id is in hand. + + Several settings exist so a deployment that differs from the verified one + needs no fork. None is a vendor-documented knob; they are client-side + plumbing. + + ``extra_headers`` is merged over every header this client sends to the + instance, so it overrides the JSON defaults and the User-Agent alike. It may + **not** carry an ``Authorization`` key, in any casing -- that raises here, at + construction, rather than silently shadowing ``token``. It is the insertion + point for an API gateway's key (``Ocp-Apim-Subscription-Key``, ``X-Api-Key``) + or a tenant selector. It is deliberately **not** sent to a host allow-listed + by ``additional_download_hosts``, which is where a second secret would leak. + + ``user_agent`` replaces :data:`DEFAULT_USER_AGENT`. Some corporate WAFs in + front of an ITSM tool throttle or block a generic client string, and a vendor + asked to whitelist an integration needs something to whitelist. + + ``default_params`` are query parameters added to every JSON API request, + **under** any the call itself sets. They are not applied to + ``download_document`` / ``stream_document``: appending a query parameter to a + signed download location is a plausible way to invalidate it, and it would be + meaningless on a fetch that returns bytes. The motivating case is + ``formatDate``, which the vendor lists as a query parameter (tier 1) without + documenting its values -- so this config can send it, and makes no claim + about what it does. + + ``additional_download_hosts`` opts specific **https** hosts in as attachment + sources, for a deployment that serves attachments from a CDN or a vanity + hostname rather than the instance itself. A fetch from one carries no + credential and no ``extra_headers`` -- see + :meth:`~easyvista_python_client._async._transport.BaseTransport.download_headers`. + Hosts are normalised to lower case, and the instance's own origin is never + treated as foreign, so listing it redundantly is inert. + + ``verify_ssl`` is passed to ``httpx`` unchanged, so besides ``True`` / + ``False`` it accepts a CA-bundle path or a prepared :class:`ssl.SSLContext` -- + which is what a corporate private CA or a client certificate needs. Disabling + verification is not the only answer to a private CA, and should not be + reached for as though it were. """ server: str @@ -20,9 +127,29 @@ class EasyvistaConfig: password: str | None = field(default=None, repr=False) timeout: float = 30.0 max_retries: int = 0 - verify_ssl: bool = True + verify_ssl: bool | str | ssl.SSLContext = True default_max_rows: int = 100 api_version: str = "v1" + document_delete_path_style: DocumentDeletePathStyle = ( + DEFAULT_DOCUMENT_DELETE_PATH_STYLE + ) + extra_headers: Mapping[str, str] = field(default_factory=dict, repr=False) + user_agent: str | None = None + default_params: Mapping[str, Any] = field(default_factory=dict) + additional_download_hosts: frozenset[str] = frozenset() + #: Extra ``datetime.strptime`` patterns to accept when reading a timestamp + #: column, tried only after EasyVista's own ISO-8601 form fails. Empty by + #: default, which is exactly today's behaviour. + #: + #: This exists because the read models refuse a timestamp they cannot parse + #: rather than guessing an instant, and a search validates a whole page at + #: once -- so on a deployment whose format differs, one column fails every + #: record on the page. Naming the format is the way through that is not a + #: fork. Nothing is guessed: a value matching none of the listed patterns + #: still raises, and a pattern can never change how a real ISO-8601 stamp + #: parses, because that is tried first. A pattern that yields a naive + #: datetime is read as UTC. + datetime_input_formats: tuple[str, ...] = () _server_normalized: str = field(init=False, repr=False) def __post_init__(self) -> None: @@ -32,6 +159,52 @@ def __post_init__(self) -> None: "An EasyVista credential is required: pass token=... or " "login=... and password=..." ) + if self.document_delete_path_style not in DOCUMENT_DELETE_PATH_STYLES: + raise ValueError( + "document_delete_path_style must be one of " + f"{DOCUMENT_DELETE_PATH_STYLES!r}, got " + f"{self.document_delete_path_style!r}" + ) + reject_authorization(self.extra_headers, "EasyvistaConfig.extra_headers") + # Copy, then freeze. A frozen dataclass holding a live dict is not + # frozen: the caller's dict stays aliased, and the attribute stays + # writable through it. + object.__setattr__( + self, "extra_headers", MappingProxyType(dict(self.extra_headers)) + ) + object.__setattr__( + self, "default_params", MappingProxyType(dict(self.default_params)) + ) + object.__setattr__( + self, + "additional_download_hosts", + frozenset( + host.strip().lower() + for host in self.additional_download_hosts + if host.strip() + ), + ) + + def __hash__(self) -> int: + """Hash the scalar identity only, so a config stays usable as a key. + + ``extra_headers`` and ``default_params`` are mappings, and the hash + ``@dataclass`` would generate over every field raises ``TypeError`` the + moment either is non-empty. Two configs differing only in those mappings + therefore collide; that is allowed -- the contract is that equal objects + hash equal, not that unequal ones differ -- and ``__eq__``, which does + compare every field, still tells them apart. + """ + return hash( + ( + self.server, + self.account, + self.token, + self.login, + self.password, + self.api_version, + ) + ) @property def api_root(self) -> str: @@ -45,9 +218,26 @@ def uses_basic_auth(self) -> bool: def from_env(cls) -> EasyvistaConfig: """Build config from environment variables. + A convenience for a 12-factor deployment, not the primary way to + configure this package: **every** setting is a constructor argument, and + for a pip-installed library that is the better route. Reach for this when + the process is already configured through the environment. + Reads ``EASYVISTA_URL`` (or ``EASYVISTA_SERVER``), ``EASYVISTA_ACCOUNT``, then ``EASYVISTA_TOKEN`` or ``EASYVISTA_TOKEN_FILE``, else ``EASYVISTA_LOGIN`` / ``EASYVISTA_PASSWORD``. + + ``EASYVISTA_ACCOUNT`` is the instance identifier path segment, not a + username -- see the class docstring. ``EASYVISTA_LOGIN`` is the username. + + It deliberately reads none of the per-deployment adaptation settings -- + ``extra_headers``, ``user_agent``, ``default_params``, + ``additional_download_hosts``, or a non-boolean ``verify_ssl``. Those + describe how one deployment differs from another, and an environment + variable is the wrong home for them; pass them to the constructor. The + variable names are fixed, so two instances in one process (production and + preproduction, say) want two explicit ``EasyvistaConfig`` objects rather + than two environments. """ server = os.environ.get("EASYVISTA_URL") or os.environ.get("EASYVISTA_SERVER") account = os.environ.get("EASYVISTA_ACCOUNT") diff --git a/easyvista_python_client/context.py b/easyvista_python_client/context.py index 066305d..1ce0c2b 100644 --- a/easyvista_python_client/context.py +++ b/easyvista_python_client/context.py @@ -2,13 +2,28 @@ from __future__ import annotations -from dataclasses import dataclass +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field -from ._fields import _label, _text +from ._fields import _text from ._html import html_to_text +from .exceptions import EasyvistaError from .models.action import Action from .models.document import Document from .models.request import Request +from .references import DEFAULT_LANGUAGE_ORDER, localized_label + +#: Default rows of ``TicketContext.to_markdown``'s field table, as +#: ``(label, field_name)`` pairs. Extend rather than retype: +#: ``fields=[*DEFAULT_MARKDOWN_FIELDS, ("SLA", "SLA_ID")]``. +DEFAULT_MARKDOWN_FIELDS: tuple[tuple[str, str], ...] = ( + ("Status", "STATUS"), + ("Department", "DEPARTMENT"), + ("Location", "LOCATION"), + ("Catalog", "CATALOG_REQUEST"), + ("Created", "CREATION_DATE_UT"), + ("Updated", "LAST_UPDATE"), +) def _cell(value: str) -> str: @@ -16,12 +31,59 @@ def _cell(value: str) -> str: return value.replace("|", "\\|").replace("\n", " ").strip() +def _degraded_entry(branch: str, exc: EasyvistaError) -> str: + """Build a ``degraded`` entry: ``":"``. + + ``"?"`` stands in for an error carrying no status code, which the transport + always sets but a hand-constructed exception need not. + """ + status = getattr(exc, "status_code", None) + return f"{branch}:{status}" if isinstance(status, int) else f"{branch}:?" + + +def _degraded_status(degraded: Iterable[str], branch: str) -> str | None: + """The HTTP status recorded for ``branch``, or ``None`` if it is not degraded. + + ``rpartition``, not ``split``: a memo branch is itself named + ``"memo:"``, so only the LAST colon separates the status. + """ + for entry in degraded: + name, _, status = entry.rpartition(":") + if name == branch: + return status + return None + + +def _memo_heading(name: str) -> str: + """Title a memo block from the field name the caller asked for. + + Only the first letter of each word is touched, so an already-capitalized + name (``COMMENT``) keeps its shape instead of being flattened to + ``Comment`` the way :meth:`str.title` would. + """ + words = name.replace("_", " ").replace("-", " ").split() + return " ".join(word[:1].upper() + word[1:] for word in words) or name + + @dataclass class TicketContext: """A ticket plus its resolved narrative content. Holds the *raw* resolved text (``description``/``comment`` may still be HTML); :meth:`to_markdown` does the plain-text reduction and formatting. + + ``description`` and ``comment`` are the two memos EasyVista populates by + default. ``memos`` carries every memo that was actually resolved, keyed by + the field name requested -- the API models the memo name as a path segment + (``GET /requests/{rfc}/{memo}``) (tier 2 -- ``docs/vendor-api-reference.md``: + declared in the instance's OpenAPI ``paths``), so a deployment may carry + others. + + ``degraded`` records which sub-resource fetches were swallowed, as + ``":"`` entries -- split with ``rsplit(":", 1)``, + because a memo branch is itself named ``"memo:"``. Branch names are + ``actions``, ``documents`` and ``memo:``. An empty set means nothing + was swallowed; it does not mean everything was populated. """ ticket: Request @@ -29,23 +91,67 @@ class TicketContext: comment: str | None actions: list[Action] documents: list[Document] + memos: dict[str, str | None] = field(default_factory=dict) + degraded: frozenset[str] = frozenset() + + def to_markdown( + self, + *, + fields: Sequence[tuple[str, str]] | None = None, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, + ) -> str: + """Render an href-free Markdown document for this ticket. - def to_markdown(self) -> str: - """Render an href-free Markdown document for this ticket.""" + ``fields`` is the field table, as ``(label, field_name)`` pairs; it + defaults to :data:`DEFAULT_MARKDOWN_FIELDS`. Each field is resolved with + ``Request.reference``, so a nested object yields its localized label, a + bare id yields the id, and a timestamp column yields EasyVista's own + rendering. A pair whose field resolves to nothing is dropped, so naming + a column this instance does not return costs an absent row, not an + error. Pass a list of pairs, never a flat list of names. + + A section recorded in ``degraded`` renders a one-line notice naming the + HTTP status instead of being omitted, so an export cannot read as "this + ticket has no attachments" when the attachment list was refused. Only + the actions and documents sections do this: a memo that was refused and + a memo that is genuinely empty both render as no block, and injecting a + heading for the refused one would break the role-based rule below. It is + recorded in ``degraded`` either way. + + Headings name the *role* a block plays, not the field it came from: + when only one memo has text it is the body and is titled + ``## Description`` whichever field carried it; when both defaults have + text the distinction is real and each keeps its own heading. A memo + requested through ``memo_fields`` is rendered on the same rule -- a + single non-empty one becomes the body, several each get a heading + derived from the field name asked for -- so a deployment whose body + memo is neither ``description`` nor ``comment`` still exports one. + + ``languages`` orders the language columns tried when resolving every + human label -- the table's Status/Department/Location/Catalog values and + each action's heading (default: + :data:`~easyvista_python_client.DEFAULT_LANGUAGE_ORDER`). It changes the + *content* only: the structural headings (``## Description``, + ``## Actions``, ``## Attachments``, the ``| Field | Value |`` table) are + fixed English and are part of this method's output contract, so a RAG + chunker splitting on ``##`` headings behaves the same against every + deployment. + """ data = self.ticket.model_dump(by_alias=True) rfc = self.ticket.rfc_number or "(unknown)" title = _text(data.get("TITLE")) lines: list[str] = [f"# Ticket {rfc}" + (f" — {title}" if title else ""), ""] rows: list[tuple[str, str]] = [] - for label, value in ( - ("Status", self.ticket.reference("STATUS").display), - ("Department", self.ticket.reference("DEPARTMENT").display), - ("Location", self.ticket.reference("LOCATION").display), - ("Catalog", self.ticket.reference("CATALOG_REQUEST").display), - ("Created", _text(data.get("CREATION_DATE_UT"))), - ("Updated", _text(data.get("LAST_UPDATE"))), - ): + # One extraction for every row, where the two date rows used to take a + # separate `_text(data.get(...))` path. Equivalent, not a behaviour + # change: `resolve_reference` renders a datetime through + # `format_ev_datetime`, which is byte-identical to what `_text` + # produced for these columns, naive-datetime fallback included. It is a + # strict superset for anything else -- an int-valued column now renders + # instead of yielding "". + for label, column in DEFAULT_MARKDOWN_FIELDS if fields is None else fields: + value = self.ticket.reference(column, languages=languages).display if value: rows.append((label, value)) if rows: @@ -57,13 +163,19 @@ def to_markdown(self) -> str: # Headings name the ROLE a block plays in this ticket, decided from the # data in hand -- not the EasyVista field it came from. # - # Which memo carries a ticket's body is a per-deployment fact. On the - # verified instance DESCRIPTION is unused and the body arrives in - # COMMENT (0/15 sampled tickets had a non-empty DESCRIPTION, 15/15 had a - # COMMENT), and `RequestUpdate.description` writes COMMENT too -- so - # this library's own tickets export that way on any instance. Titling - # that block "Comment" mislabels the single most important part of the - # document for an LLM, or for a RAG chunker splitting on "## ". + # Which memo carries a ticket's body is a per-deployment fact, and it + # is not reliably detectable at runtime. Tier 4 -- measured on one + # instance, 2026-08-18, and it may not generalise: a pooled 77-row + # sample across four different orderings found COMMENT populated on 57 + # rows, DESCRIPTION on 27, both on 24 and neither on 17, with the + # proportions flipping depending on the slice sampled. (An earlier + # 15-ticket sample that found DESCRIPTION empty everywhere was not + # representative; `models/request.py` treats the 77-row figure as the + # authoritative one.) What is fixed is this library's own writes: + # `RequestUpdate.description` writes COMMENT, so tickets this library + # has written export that way. Titling that block "Comment" mislabels + # the single most important part of the document for an LLM, or for a + # RAG chunker splitting on "## ". # # But the opposite hard-coding is just as wrong: an instance that # populates DESCRIPTION properly uses COMMENT for a genuine follow-up @@ -73,6 +185,13 @@ def to_markdown(self) -> str: # "Description"; when both do, the distinction is real and each keeps # its own heading. An instance where DESCRIPTION works renders exactly # as it did before. + # + # And when NEITHER default memo has text, the same rule runs over + # `memos`. `get_ticket_context(rfc, memo_fields=("solution",))` is the + # whole point of that parameter -- a deployment whose body memo is + # neither default -- and rendering only the two defaults would drop + # that body from the export with no heading and no warning. Fetch is + # parameterised, so render is too. description = html_to_text(self.description) comment = html_to_text(self.comment) if description and comment: @@ -80,22 +199,54 @@ def to_markdown(self) -> str: lines.extend(["## Comment", "", comment, ""]) elif description or comment: lines.extend(["## Description", "", description or comment, ""]) + else: + resolved = [ + (memo_name, memo_text) + for memo_name, raw in self.memos.items() + if (memo_text := html_to_text(raw)) + ] + if len(resolved) == 1: + lines.extend(["## Description", "", resolved[0][1], ""]) + else: + for memo_name, memo_text in resolved: + lines.extend( + [f"## {_memo_heading(memo_name)}", "", memo_text, ""] + ) if self.actions: lines.extend(["## Actions", ""]) for action in self.actions: adata = action.model_dump(by_alias=True) - type_label = _label(adata.get("ACTION_TYPE"), ("NAME_EN", "NAME_FR")) - type_label = ( - type_label or _text(adata.get("ACTION_LABEL_FR")) or "Action" + # The nested ACTION_TYPE's own NAME_ columns first, then + # the record's ACTION_LABEL_ columns, then a literal. Both + # rungs honour `languages`, and both reject a fully bracketed + # untranslated echo -- reading one named column would return the + # placeholder on any instance whose primary language is not that + # column's. + nested_type = adata.get("ACTION_TYPE") + type_label = localized_label( + nested_type if isinstance(nested_type, dict) else {}, + "NAME", + languages=languages, + ) + heading_label = ( + type_label + or localized_label(adata, "ACTION_LABEL", languages=languages) + or "Action" ) author = _text(adata.get("DONE_BY")) - heading = type_label + (f" — {author}" if author else "") + heading = heading_label + (f" — {author}" if author else "") lines.append(f"### {heading}") - # DESCRIPTION carries the note text once get_ticket_context has - # resolved it; COMMENT is a separate field that never does - # (verified live). Fall back to it only for records that - # predate resolution. + # One body per action, DESCRIPTION first. This mirrors what the + # UI does with the single text field it renders per action, + # headed literally "comment or description": DESCRIPTION when + # non-empty, COMMENT only when DESCRIPTION is empty (measured + # in the UI 2026-09-01 on one instance, Service Manager 2025.3 + # -- one instance, one date, may not generalise). The COMMENT + # arm is NOT legacy compatibility: it renders exactly the + # actions a reader does see, and deleting it would drop their + # bodies. ``_resolve_action_body`` resolves that memo under the + # same condition, which is what makes this branch reachable. body = html_to_text( action.description if isinstance(action.description, str) else None ) or html_to_text( @@ -104,6 +255,12 @@ def to_markdown(self) -> str: if body: lines.extend(["", body]) lines.append("") + elif ( + actions_status := _degraded_status(self.degraded, "actions") + ) is not None: + lines.extend( + ["## Actions", "", f"_Not available (HTTP {actions_status})._", ""] + ) if self.documents: lines.extend(["## Attachments", ""]) @@ -112,5 +269,16 @@ def to_markdown(self) -> str: if name: lines.append(f"- {name}") lines.append("") + elif ( + documents_status := _degraded_status(self.degraded, "documents") + ) is not None: + lines.extend( + [ + "## Attachments", + "", + f"_Not available (HTTP {documents_status})._", + "", + ] + ) return "\n".join(lines).rstrip() + "\n" diff --git a/easyvista_python_client/directory.py b/easyvista_python_client/directory.py index 93d9080..73e8b16 100644 --- a/easyvista_python_client/directory.py +++ b/easyvista_python_client/directory.py @@ -2,7 +2,9 @@ from __future__ import annotations -from dataclasses import dataclass +import unicodedata +from collections.abc import Sequence +from dataclasses import dataclass, field from .models.asset import Asset from .models.department import Department @@ -10,10 +12,73 @@ from .models.request import Request from .reporting import TicketStatistics -# O-DIR-1: the descending-sort token for "most recent" is not yet live-confirmed. -# EasyVista ignores an unknown ``sort`` param (falls back to default order) rather -# than erroring, so this is safe; adjust once confirmed against the live instance. -RECENT_TICKETS_SORT = "RFC_NUMBER:DESC" +# O-DIR-1: the descending-sort token must be SPACE-separated. Measured live +# 2026-08-17 on a date column: `FIELD DESC` and `FIELD desc` genuinely reorder, +# while `FIELD:DESC`, `-FIELD` and `DESC(FIELD)` are silently ignored — they +# return the API's default order, byte-identical to an unsorted page, with no +# error. This constant previously used the ignored colon form, so +# `recent_tickets` was never actually sorted. The rule is syntactic rather than +# field-specific, so it is applied to RFC_NUMBER here by inference; +# integration_tests/test_live_change_window.py pins this exact token live. +# +# What is measured is the DESCENDING-ness, not recency. RFC_NUMBER is a varchar +# (`I240101_0001`), so a descending string sort orders by the request-type +# prefix FIRST and the date second: on an instance that issues more than one +# prefix letter, every `R...` ticket outranks every `I...` ticket regardless of +# date. Hence the docstrings say "descending RFC_NUMBER" rather than +# "newest-first". Switching to a date column (`CREATION_DATE_UT DESC`) would +# make recency literal and is a candidate follow-up; it is a behaviour change +# and wants its own live check first. +RECENT_TICKETS_SORT = "RFC_NUMBER DESC" + +# The final path segment of ``GET departments/{id}/{comment}``. In the instance +# OpenAPI document read 2026-08-27 that segment is a path *parameter* named +# ``comment``, not a literal: the sibling ``GET requests/{rfc_number}/{comment}`` +# describes the same parameter as "Memo field type, could be comment, +# description". The route therefore selects a memo column, and this value is +# only the column the verified instance carries -- a deployment that names its +# department memo differently passes its own. +DEPARTMENT_MEMO_FIELD = "comment_department" + +#: Memo sub-resources ``get_department_context`` resolves for a department. +#: The API models a memo name as a path segment (``GET departments/{id}/{memo}``), +#: so a deployment carrying its directory note under another column is reached +#: by naming it, not by editing this module. Derived from +#: :data:`DEPARTMENT_MEMO_FIELD` so the single-memo and multi-memo defaults +#: cannot drift apart. +DEPARTMENT_NOTE_FIELDS: tuple[str, ...] = (DEPARTMENT_MEMO_FIELD,) + +#: Default ``fields=`` projection for ``get_department_context``'s recent +#: tickets. Passing *no* projection is not a neutral default here: on the +#: verified instance the default list projection returns ``TITLE`` present but +#: EMPTY (tier 4 -- measured on one instance, 400 tickets scanned via a plain +#: search, zero with a populated title; it may not generalise). See +#: ``integration_tests/conftest.py`` (``_adopt_by_title``) and +#: ``test_title_search_requires_the_fields_projection_to_return_a_value`` in +#: ``integration_tests/test_live_search_syntax.py``. So an unprojected recent +#: ticket has ``title is None`` on that instance, always. +#: +#: ``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 +#: ``STATUS_ID`` are both requested so ``.reference("STATUS")`` resolves a label +#: where the instance returns one and an id where it does not. +#: +#: Pass ``ticket_fields=None`` to restore the unprojected request. +RECENT_TICKET_FIELDS: tuple[str, ...] = ( + "RFC_NUMBER", + "TITLE", + "STATUS", + "STATUS_ID", + "CREATION_DATE_UT", + "LAST_UPDATE", + "END_DATE_UT", +) + +#: Columns ``find_departments(by="auto")`` tries, in order, for an all-digit +#: name. Code first: a department whose CODE is all digits is otherwise +#: resolved as an ID and the wrong record comes back with no error. +DEPARTMENT_NAME_COLUMNS: tuple[str, ...] = ("DEPARTMENT_CODE", "DEPARTMENT_ID") @dataclass @@ -22,6 +87,18 @@ class DepartmentContext: Only ``department`` is guaranteed; every related part degrades to ``[]`` / ``None`` / ``0`` when a profile restriction (403) or a missing record (404) blocks it. + + ``memos`` carries every memo resolved, keyed by the field name requested + through ``memo_fields``; ``note`` is the first of them that came back with + text, which on a default call is ``comment_department``. + + ``degraded`` records which branches were swallowed, so a caller can tell + "no employees" from "employees were forbidden". Each entry is + ``":"`` -- split it with ``rsplit(":", 1)``, because a + memo branch is itself named ``"memo:"``. Branch names are + ``employees``, ``manager``, ``ticket_count``, ``recent_tickets``, + ``statistics``, ``assets`` and ``memo:``. An empty set means nothing + was swallowed; it does not mean everything was populated. """ department: Department @@ -32,11 +109,43 @@ class DepartmentContext: recent_tickets: list[Request] ticket_statistics: TicketStatistics | None assets: list[Asset] + memos: dict[str, str | None] = field(default_factory=dict) + degraded: frozenset[str] = frozenset() + + +def _as_fields(value: str | Sequence[str] | None) -> str | list[str] | None: + """Normalize a projection argument to what the search builders accept. + + A bare ``str`` passes through unchanged -- the wire format is a + comma-separated list, so ``"RFC_NUMBER,TITLE"`` is a legal single argument + and must not be exploded into one character per field. + """ + if value is None or isinstance(value, str): + return value + return list(value) def _normalize_name(value: str) -> str: - """Case-, space- and hyphen-insensitive key for fuzzy name matching.""" - return value.replace("-", "").replace(" ", "").lower() + """Accent-, case-, space- and hyphen-insensitive key for fuzzy name matching. + + NFKD-decomposes, drops every combining mark, case-folds, then removes ASCII + hyphens and spaces -- in that order. The order is load-bearing: NFKD can + itself produce a space (NO-BREAK SPACE and the U+2000..U+200A family all + decompose to U+0020), so a removal done first would leave one behind. + + Strictly more permissive than the plain ``lower()`` this replaced: it is a + pure function of its argument, so every pair of names that matched before + still matches, and ``"Systemes"`` now also matches ``"Systemes"`` written + with its accents -- which it did not, on an instance whose department + labels are French. + + ``unicodedata.combining`` rather than a ``category(ch) == "Mn"`` test: that + would also strip spacing and enclosing marks, which are part of the word in + several scripts. + """ + decomposed = unicodedata.normalize("NFKD", value) + unmarked = "".join(ch for ch in decomposed if not unicodedata.combining(ch)) + return unmarked.casefold().replace("-", "").replace(" ", "") def _department_matches(dept: Department, needle: str) -> bool: diff --git a/easyvista_python_client/discovery.py b/easyvista_python_client/discovery.py new file mode 100644 index 0000000..938ca0b --- /dev/null +++ b/easyvista_python_client/discovery.py @@ -0,0 +1,489 @@ +"""Instance discovery: where each reference lives, and how to read it. + +Pure and offline. Holds the name-to-route map, the two result dataclasses and +the extractors the clients delegate to; nothing here performs I/O or imports a +client. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any + +from .references import ( + DEFAULT_LANGUAGE_ORDER, + Reference, + label_from_record, + localized_label, + resolve_reference, +) +from .reporting import fields_for_references + + +@dataclass(frozen=True) +class ReferenceSource: + """Where one reference name can be read on an EasyVista instance. + + ``reference_path`` is the list route, or ``None`` when this deployment's + OpenAPI declares no route for it at all -- in which case the only way to + learn the ids is to sample records that carry them. + """ + + name: str + reference_path: str | None + sample_from: str # "tickets" | "actions" + sample_field: str + id_field: str | None = None + guid_field: str | None = None + + +#: Name -> where to read it. Every path here is declared in the verified +#: instance's OpenAPI ``paths`` (tier 2, read 2026-08-27, EasyVista 2025.3); +#: every ``None`` is a route that is **absent** from those 100 paths. +#: +#: ``urgency`` is SINGULAR. The vendor documents ``GET /urgencies`` (tier 1) +#: and the instance declares ``GET /urgency`` (tier 2); which is canonical is +#: unresolved -- open item O-URGPATH in ``docs/vendor-api-reference.md``. The +#: default is the spelling this deployment declares, and +#: ``discover(reference_path=...)`` reaches the other without a fork. +#: +#: ``IMPACT``, ``SEVERITY``, ``ORIGIN`` and ``ACTION_TYPE`` have no route in +#: the spec, so they are sampling-only by construction rather than by a 403 +#: someone measured. Priority has neither a route nor a column: EasyVista +#: derives it from urgency x impact, so there is nothing to discover. +REFERENCE_SOURCES: dict[str, ReferenceSource] = { + "STATUS": ReferenceSource( + "STATUS", "status", "tickets", "STATUS", guid_field="STATUS_GUID" + ), + "URGENCY": ReferenceSource("URGENCY", "urgency", "tickets", "URGENCY"), + "CATALOG_REQUEST": ReferenceSource( + "CATALOG_REQUEST", + "catalog-requests", + "tickets", + "CATALOG_REQUEST", + id_field="SD_CATALOG_ID", + ), + "LOCATION": ReferenceSource("LOCATION", "locations", "tickets", "LOCATION"), + "DEPARTMENT": ReferenceSource( + "DEPARTMENT", "departments", "tickets", "DEPARTMENT" + ), + "SLA": ReferenceSource("SLA", "slas", "tickets", "SLA"), + # No ticket column carries a group; an action does (ACTION.GROUP_ID). + "GROUP": ReferenceSource("GROUP", "groups", "actions", "GROUP"), + "IMPACT": ReferenceSource("IMPACT", None, "tickets", "IMPACT"), + "SEVERITY": ReferenceSource("SEVERITY", None, "tickets", "SEVERITY"), + # PostRequest.origin reads back as REQUEST_ORIGIN_ID; ORIGIN is not + # returned at all, so that is the column to project. + "ORIGIN": ReferenceSource("ORIGIN", None, "tickets", "REQUEST_ORIGIN"), + "ACTION_TYPE": ReferenceSource("ACTION_TYPE", None, "actions", "ACTION_TYPE"), +} + +#: The names ``describe_instance`` profiles when none are given. +DEFAULT_DISCOVERY_NAMES: tuple[str, ...] = tuple(REFERENCE_SOURCES) + + +@dataclass(frozen=True) +class DiscoveredReference: + """One value of one reference, as found on this instance. + + ``id`` is what a write model takes (``urgency_id``, ``impact_id``, + ``department_id``, ...). ``guid`` is populated only where a GUID is + reachable -- today that is ``STATUS``, read off sampled tickets. ``code`` + is the instance's own short code, and for ``CATALOG_REQUEST`` it is what + ``PostRequest.catalog_code`` accepts. ``count`` is how many sampled records + carried this value, and is ``None`` for a value read from a reference + table. ``source`` is ``"reference"`` or ``"sample"``. ``record`` is the raw + row, unmodelled, so an instance-specific column is still reachable. + + Every value here is **per-deployment configuration**, not an API constant. + Do not hardcode one; rediscover it, or resolve it at start-up and fail + loudly when it is gone. + """ + + name: str + id: str | None + label: str | None = None + guid: str | None = None + code: str | None = None + path: str | None = None + count: int | None = None + source: str = "reference" + record: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class InstanceProfile: + """What one EasyVista deployment could tell this client about itself. + + ``references`` maps each requested name to what was found. ``unavailable`` + maps a part that is missing or incomplete to a reason whose first token is + machine-readable: ``denied`` (401/403), ``failed`` (any other transport or + server error), ``no-route`` (the spec declares none, so sampling was the + only option), ``empty`` (the read succeeded and returned nothing) or + ``truncated`` (a page cap cut the table short -- the rows present are real, + they are just not all of them). A name can appear in **both** dicts; + ``truncated`` is exactly that case. + + **This never raises for one part.** Every fetch is attempted; a failure is + recorded and the rest continues. An empty ``spec_paths`` together with a + ``references`` dict of empty lists means the instance was unreachable, not + that it has no statuses -- read ``unavailable`` before believing a gap. + + What discovery **cannot** reach, whatever the profile: + + * **``catalog_guid``.** No route returns it. ``GET /catalog-requests`` + exists on this deployment (tier 2) and its response schema declares + ``CODE``, ``SD_CATALOG_ID``, ``TITLE_EN``, ``CATALOG_REQUEST_PATH``, + nested ``MANAGER`` and nested ``SLA`` (tier 3 -- example-derived, + illustrative only). ``CODE`` is what ``PostRequest.catalog_code`` accepts + and ``SD_CATALOG_ID`` is what reads back as ``Request.sd_catalog_id``; + there is no ``CATALOG_GUID`` column in the schema and none was observed + live. So build with ``catalog_code``. The vendor documents + ``catalog_guid`` as the *preferred* identifier (tier 1) and + ``close_ticket`` accepts one -- you just cannot read one from here. + * **``catalog_code`` when the route is denied.** A 403 on + ``/catalog-requests`` is a **profile restriction on a route that + exists**, not an API limitation: ask your EasyVista administrator to + grant the profile read access to the request catalog. Until then the + sampling fallback returns only catalogs already used by a ticket you can + see. + * **Which ``ACTION_TYPE_ID`` means "internal note" and which means + "customer comment".** The ids are discoverable -- every action row + carries ``ACTION_TYPE_ID`` beside translated ``ACTION_LABEL_*`` columns, + and there is no route for the type table (tier 2: none is declared). The + *meaning* is a label a human has to read. On the verified instance type + 94 is ``Commentaire [Public]`` / Customer Comment and 95 is + ``Note Interne [Prive]`` / Internal Note (tier 4, one instance, date not + recorded); ids are per-deployment and those two are not portable. There + is no public/private boolean on an action record to fall back on. + * **``group_id`` when ``/groups`` is denied.** Creating an action requires + one of ``group_id`` / ``group_mail`` / ``group_name`` (tier 1). With the + route denied, the sampled fallback reads ``GROUP_ID`` off existing + actions -- ids only, no labels -- and reaches only groups already in use. + * **A full enumeration of IMPACT, SEVERITY or ORIGIN.** No route exists for + any of them, so what you get is "the ids in use in the sample". An id + that is configured but unused is invisible, and a count from a sample is + not a population count. Priority is not discoverable at all: EasyVista + derives it from urgency x impact. + * **A ``STATUS_GUID`` for a status no sampled ticket currently holds.** + """ + + api_root: str + version: str | None + spec_paths: tuple[str, ...] + references: dict[str, list[DiscoveredReference]] + unavailable: dict[str, str] + + +def resolve_source( + name: str, + *, + reference_path: str | None = None, + sources: Mapping[str, ReferenceSource] = REFERENCE_SOURCES, +) -> ReferenceSource: + """The :class:`ReferenceSource` for ``name``, case-insensitively. + + An unknown name is not an error: it yields a sampling-only source that + reads ``name`` off a ticket, which is exactly right for a custom ``e_*`` + column. ``reference_path`` overrides the mapped route (or supplies one for + a name that has none); ``sources`` replaces the whole map for a deployment + that routes a table somewhere else. Both are arguments rather than + configuration a caller has to patch into the package. + """ + key = name.strip().upper() + source = sources.get(key) + if source is None: + source = ReferenceSource(key, None, "tickets", key) + if reference_path is not None: + source = ReferenceSource( + source.name, + reference_path, + source.sample_from, + source.sample_field, + source.id_field, + source.guid_field, + ) + return source + + +def _upper_items(row: Mapping[str, Any]) -> list[tuple[str, Any]]: + return [(k.upper(), v) for k, v in row.items() if isinstance(k, str)] + + +def _scalar_text(value: Any) -> str | None: + """A non-empty scalar rendered as text, else ``None``. + + Nested objects and lists are never ids or codes, so they are refused rather + than stringified into something that looks like one. + """ + if value is None or isinstance(value, dict | list): + return None + text = str(value).strip() + return text or None + + +def _table_row_id(row: Mapping[str, Any], source: ReferenceSource) -> str | None: + """The id of a reference-table row, by precedence. + + Only TOP-LEVEL keys are scanned, so a nested ``MANAGER.EMPLOYEE_ID`` or + ``SLA.SLA_ID`` on a catalog row cannot win. + """ + items = _upper_items(row) + lookup = dict(items) + candidates = [] + if source.id_field: + candidates.append(source.id_field.upper()) + candidates.append(f"{source.name}_ID") + for candidate in candidates: + got = _scalar_text(lookup.get(candidate)) + if got is not None: + return got + for key, value in items: + if key.endswith(("_ID", "_GUID")): + got = _scalar_text(value) + if got is not None: + return got + href = _scalar_text(lookup.get("HREF")) + if href: + tail = href.rstrip("/").rsplit("/", 1)[-1] + return tail or None + return None + + +def _by_suffix( + items: Sequence[tuple[str, Any]], preferred: str, suffix: str +) -> str | None: + """The value of ``preferred``, else the first key ending in ``suffix``. + + A bare key equal to the suffix without its leading underscore also counts: + ``catalog-requests`` names its short code plain ``CODE``. + """ + lookup = dict(items) + for candidate in (preferred, suffix.lstrip("_")): + got = _scalar_text(lookup.get(candidate)) + if got is not None: + return got + for key, value in items: + if key.endswith(suffix): + got = _scalar_text(value) + if got is not None: + return got + return None + + +def reference_from_table_row( + row: Mapping[str, Any], + source: ReferenceSource, + *, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, +) -> DiscoveredReference: + """Turn one reference-table row into a :class:`DiscoveredReference`. + + Column naming is not consistent across the tables, so each part is resolved + by a precedence chain rather than a fixed name: + + * **id** -- ``source.id_field`` when the map names one (``SD_CATALOG_ID`` + for ``catalog-requests``), else ``_ID`` (``LOCATION_ID``, + ``GROUP_ID``, ``SLA_ID``), else the first top-level ``*_ID`` / ``*_GUID`` + key in document order, else the trailing segment of ``HREF``. Only + TOP-LEVEL keys are scanned, so a nested ``MANAGER.EMPLOYEE_ID`` or + ``SLA.SLA_ID`` on a catalog row cannot win. + * **label** -- :func:`~easyvista_python_client.references.label_from_record`. + * **code** -- ``_CODE``, else a bare ``CODE`` (which is what + ``catalog-requests`` uses), else the first ``*_CODE``. + * **path** -- ``_PATH``, else the first ``*_PATH``. + + ``.record`` keeps the row verbatim, so an instance-specific column this + function knows nothing about is still one dict lookup away. + """ + items = _upper_items(row) + return DiscoveredReference( + name=source.name, + id=_table_row_id(row, source), + label=label_from_record(dict(row), languages=languages), + code=_by_suffix(items, f"{source.name}_CODE", "_CODE"), + path=_by_suffix(items, f"{source.name}_PATH", "_PATH"), + source="reference", + record=dict(row), + ) + + +def references_from_sample( + records: Iterable[Mapping[str, Any]], + source: ReferenceSource, + *, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, +) -> list[DiscoveredReference]: + """Distinct values of ``source`` across sampled records, with occurrence counts. + + Each record goes through + :func:`~easyvista_python_client.references.resolve_reference`, which already + handles both shapes EasyVista uses -- a nested object carrying ``*_EN`` / + ``*_FR`` labels, and a bare top-level ``_ID``. Grouped by id (or by + label when there is no id), ordered by descending ``count`` then by id, with + ``source="sample"``. ``.code`` and ``.path`` stay ``None``: those are + reference-table columns, and a sampled record carries neither. + + An action record is the one shape ``resolve_reference`` cannot read on its + own: its type label lives in SIBLING ``ACTION_LABEL_`` columns rather + than in a nested ``ACTION_TYPE`` object, so those are consulted as a second + rung. :func:`sample_fields` projects them for exactly this reason -- without + the fallback, that projection would be requested and then ignored, and + every discovered action type would come back with ``label=None``. + + This can only ever see values *in use*. An id configured on the instance but + absent from every sampled record is invisible here, and no count from a + sample is a population count. + """ + seen: dict[str, DiscoveredReference] = {} + counts: dict[str, int] = {} + label_prefix = ( + "ACTION_LABEL" + if source.sample_from == "actions" and source.sample_field == "ACTION_TYPE" + else None + ) + for record in records: + ref = resolve_reference(dict(record), source.sample_field, languages=languages) + sibling_label = ( + localized_label(dict(record), label_prefix, languages=languages) + if label_prefix + else None + ) + if sibling_label is not None and ref.label is None: + ref = Reference(id=ref.id, label=sibling_label) + if ref.id is None and ref.label is None: + continue + key = ref.id if ref.id is not None else f"label:{ref.label}" + counts[key] = counts.get(key, 0) + 1 + if key not in seen: + seen[key] = DiscoveredReference( + name=source.name, + id=ref.id, + label=ref.label, + source="sample", + record=dict(record), + ) + elif seen[key].label is None and ref.label is not None: + # A later record carried the label an earlier one lacked. Keep it: + # a projection can return the id on one row and the nested object + # on another. + seen[key] = DiscoveredReference( + name=source.name, + id=ref.id, + label=ref.label, + source="sample", + record=dict(record), + ) + found = [ + DiscoveredReference( + name=ref.name, + id=ref.id, + label=ref.label, + guid=ref.guid, + code=ref.code, + path=ref.path, + count=counts[key], + source="sample", + record=ref.record, + ) + for key, ref in seen.items() + ] + found.sort(key=lambda r: (-(r.count or 0), r.id or "")) + return found + + +def guids_from_sample( + records: Iterable[Mapping[str, Any]], source: ReferenceSource +) -> dict[str, str]: + """``{id: guid}`` read out of sampled records' nested reference objects. + + Empty unless ``source.guid_field`` is set -- today that is ``STATUS`` + alone. See ``discover`` on either client for why this exists and what it + costs. + """ + if not source.guid_field: + return {} + guid_key = source.guid_field.upper() + id_key = f"{source.sample_field.upper()}_ID" + out: dict[str, str] = {} + for record in records: + nested = next( + ( + value + for key, value in _upper_items(record) + if key == source.sample_field.upper() and isinstance(value, dict) + ), + None, + ) + if not nested: + continue + nested_items = dict(_upper_items(nested)) + guid = _scalar_text(nested_items.get(guid_key)) + ident = _scalar_text(nested_items.get(id_key)) or _scalar_text( + dict(_upper_items(record)).get(id_key) + ) + if guid and ident and ident not in out: + out[ident] = guid + return out + + +def merge_guids( + discovered: Sequence[DiscoveredReference], guids: Mapping[str, str] +) -> list[DiscoveredReference]: + """Copy of ``discovered`` with ``.guid`` filled in where ``guids`` has the id. + + Entries with no matching id keep ``guid=None``; nothing is dropped and + nothing is invented. + """ + out: list[DiscoveredReference] = [] + for ref in discovered: + guid = guids.get(ref.id) if ref.id is not None else None + if guid is None or ref.guid is not None: + out.append(ref) + continue + out.append( + DiscoveredReference( + name=ref.name, + id=ref.id, + label=ref.label, + guid=guid, + code=ref.code, + path=ref.path, + count=ref.count, + source=ref.source, + record=ref.record, + ) + ) + return out + + +def sample_fields( + source: ReferenceSource, + *, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, +) -> list[str]: + """The ``fields=`` projection that makes ``source`` resolvable on a sample row. + + Tickets ride + :func:`~easyvista_python_client.reporting.fields_for_references` unchanged: + it already asks for the nested object plus ``_ID`` and ``_GUID``, and the + API ignores a projected column it does not have. + + Actions need their own list, because the actions list returns a deliberately + slim default row -- ``ACTION_ID``, ``ACTION_LABEL_FR``, ``ACTION_NUMBER``, + ``DONE_BY_ID``, ``EXPECTED_START_DATE_UT`` -- so the translated + ``ACTION_LABEL_`` columns must be asked for by name. They are added + only for ``ACTION_TYPE``: an action carries ``GROUP_ID`` but no group label, + so discovering ``GROUP`` by sampling yields ids with ``label=None``. That is + stated rather than papered over with a fabricated label. + """ + if source.sample_from == "actions": + fields = ["ACTION_ID", source.sample_field, f"{source.sample_field}_ID"] + if source.sample_field == "ACTION_TYPE": + fields += [ + f"ACTION_LABEL{suffix}" + for suffix in ("_" + s.strip().lstrip("_").upper() for s in languages) + ] + return fields + return fields_for_references([source.sample_field], include_creation_date=False) diff --git a/easyvista_python_client/filters.py b/easyvista_python_client/filters.py index 511e2fb..acb160e 100644 --- a/easyvista_python_client/filters.py +++ b/easyvista_python_client/filters.py @@ -1,6 +1,6 @@ """Safe builders for EasyVista ``search`` expressions. -EasyVista's search grammar has two traps a caller cannot see, both verified +EasyVista's search grammar has three traps a caller cannot see, all verified against a live instance: 1. An expression it cannot parse is **silently ignored** and every record is @@ -8,8 +8,12 @@ 2. ``,`` is a live combinator (OR within one field, AND across fields), so an unescaped value that closes its quote can append conditions and silently widen the result set. +3. A comparison operator does not exist. A change window is an *interval in the + value position* — ``FIELD:(a;b)`` — and its bound is UNQUOTED, so + ``ev_since_filter``/``ev_between_filter`` validate the bound's shape rather + than escaping it. -These builders exist so neither can happen. Filters return ``None`` for blank +These builders exist so none of them can happen. Filters return ``None`` for blank input so callers compose without conditionals:: search = ev_equals_filter("DEPARTMENT_CODE", code) @@ -23,7 +27,12 @@ from __future__ import annotations +import re from collections.abc import Iterable +from datetime import datetime +from typing import Literal + +from .timestamps import format_ev_datetime, parse_ev_datetime # A double quote terminates the quoted value, letting a caller reach the ',' # combinator; no escape for it is known (verified live). ',' itself is NOT @@ -74,9 +83,464 @@ def ev_in_filter(field: str, values: Iterable[str | int | None]) -> str | None: return ",".join(parts) +# An interval bound is rendered UNQUOTED inside `(...)`, so the quote-based +# defence the other builders rely on does not apply here: a ';' would append a +# second bound and a ')' would close the interval early. Validating the shape is +# therefore the guard, not escaping. +# +# This regex is the ADMISSION gate — which strings a caller may hand in — and it +# is deliberately WIDER than the set the wire honours, because +# :func:`_interval_bound` re-renders every admitted time through +# :func:`~easyvista_python_client.format_ev_datetime` before emitting it. +# Measured live 2026-08-18, a *time* bound is honoured only at millisecond +# precision with an explicit offset (or ``Z``); second precision with an offset +# (``2025-11-28T16:14:41+01:00``), minute precision, and a space instead of +# ``T`` are all HTTP 590. A bare date is honoured as written. Admitting the +# wider set and normalising is what lets a caller pass a stored watermark string +# at all instead of having to pre-render it in exactly one shape. +# +# This is a SHAPE gate only, not a validity gate: `[0-9]` (not `\d`, which is +# Unicode-aware) keeps non-ASCII digits out, and `fullmatch` anchors both ends +# unconditionally rather than relying on a trailing `$` — which would also +# match just before a newline — so the guard does not silently depend on the +# `.strip()` above having already removed one. Calendar/time validity (e.g. +# ``9999-99-99``, ``25:61:61``) is not this regex's job; :func:`_interval_bound` +# checks that separately via :func:`~easyvista_python_client.parse_ev_datetime`. +# ``z`` is accepted lowercase because ``parse_ev_datetime`` accepts it on the +# read path; refusing it here would reject a value this package itself produced. +_TIMESTAMP_RE = re.compile( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}" # YYYY-MM-DD + r"(?:[T ][0-9]{2}:[0-9]{2}:[0-9]{2}" # optional T HH:MM:SS + r"(?:\.[0-9]{1,6})?" # optional fractional seconds + r"(?:[Zz]|[+-][0-9]{2}:[0-9]{2})?)?" # optional offset +) + +# A bare calendar date, which is passed through UNCHANGED rather than +# normalised: it has day granularity, the API honours it as written, and +# re-rendering it would invent a midnight instant in some zone. +_DATE_ONLY_RE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}") + +# A datetime carrying NO ``Z`` and no ``+-HH:MM``. The wire accepts these, and +# reads them in another zone -- measured live 2026-08-18, the same wall-clock +# text with and without its offset enumerated 13 rows and 11 rows against one +# instance, the offset-less form moving the bound *later* and skipping records +# with no error. A bare date deliberately does NOT match: it has day +# granularity and no time to misplace, and round 1 measured it honoured. +_OFFSETLESS_TIME_RE = re.compile( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,6})?" +) + +# A time whose UTC offset carries SECONDS (``+05:53:20``), which +# :data:`_TIMESTAMP_RE` refuses. Matched separately only to DIAGNOSE it: the +# value is perfectly good ISO 8601 -- it is what ``isoformat()`` produces for +# any pre-1900 ``zoneinfo`` instant -- so the generic "not an EasyVista +# timestamp ... pass a datetime to be certain" message would be wrong twice +# over, since parsing it back to a datetime and passing that raises for the +# same underlying reason (see :func:`_render_interval_bound`). +_SUB_MINUTE_OFFSET_RE = re.compile( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9]{2}:[0-9]{2}:[0-9]{2}" + r"(?:\.[0-9]{1,6})?[+-][0-9]{2}:[0-9]{2}:[0-9]{2}" +) + + +def _render_interval_bound(moment: datetime) -> str: + """Render one time bound in the single form measured live as honoured. + + :func:`~easyvista_python_client.format_ev_datetime` emits millisecond + precision with an explicit offset, which is the ONLY time rendering the + interval grammar accepts: second precision with an offset, minute precision + and a space separator instead of ``T`` are each HTTP 590 (measured live + 2026-08-18). + + The rendering is re-checked against :data:`_TIMESTAMP_RE` rather than + trusted, because a zone whose UTC offset is not a whole number of minutes -- + every pre-1900 ``zoneinfo`` entry has one, e.g. ``Asia/Kolkata`` at + ``+05:53:20`` -- renders as ``+05:53:20``, which the string path refuses and + which the wire has no reason to honour either. Without this check the + datetime path could emit a bound the string path would reject, which is the + asymmetry this function exists to remove. + """ + rendered = format_ev_datetime(moment) + if not _TIMESTAMP_RE.fullmatch(rendered): + raise ValueError( + f"{moment!r} renders as {rendered!r}, which EasyVista's interval " + "grammar cannot express: its UTC offset is not a whole number of " + "minutes (historical zoneinfo zones carry such offsets). Convert " + "the datetime to UTC, or to a zone with a whole-minute offset." + ) + return rendered + + +def _interval_bound(value: str | datetime | None) -> str: + """Render one interval bound, or ``""`` for an open end. + + Raises ``ValueError`` for anything that is not a timestamp, because the + bound is interpolated unquoted (see :data:`_TIMESTAMP_RE`). The regex is a + shape gate; :func:`~easyvista_python_client.parse_ev_datetime` is the + validity gate behind it, so a well-shaped but impossible timestamp (e.g. + ``9999-99-99`` or ``25:61:61``) is also refused rather than reaching the + wire, where a dropped condition returns the whole table rather than an + error. + + A third gate refuses a **time without a UTC offset**, even though the wire + accepts one. An offset-less literal is read in another zone, which moves the + bound and skips records silently -- the one failure mode a watermark must + never have. :func:`~easyvista_python_client.format_ev_datetime` already + refuses a naive ``datetime`` on this reasoning; this keeps the string path + consistent with it. A bare date stays legal. + + Past those gates an admitted **time** is NORMALISED rather than passed + through: it is re-rendered by :func:`_render_interval_bound`, so the string + and datetime paths emit byte-identical bounds and both emit the one + rendering measured live as honoured. This is not cosmetic -- + ``"2025-11-28T16:14:41+01:00"``, the most natural way to comply with the + offset gate, is HTTP 590 on the wire as written and becomes + ``2025-11-28T16:14:41.000+01:00`` here. Sub-millisecond precision is + truncated to milliseconds, EasyVista's own precision -- truncated **down**, + never rounded (``.133999`` becomes ``.133``). That direction is not + symmetric between the two ends of an interval: on the **inclusive lower** + bound it widens the window, so the worst case is re-reading a record, while + on an **upper** bound it moves the bound up to 999 microseconds *earlier* + and NARROWS the window, excluding anything stamped inside the truncated + remainder. A caller who needs an exact upper bound should hand in a value + already at millisecond precision. A bare **date** is passed through + unchanged (see :data:`_DATE_ONLY_RE`). + """ + if value is None: + return "" + if isinstance(value, datetime): + return _render_interval_bound(value) + text = str(value).strip() + if not text: + return "" + parsed = parse_ev_datetime(text) if _TIMESTAMP_RE.fullmatch(text) else None + if parsed is None: + if _SUB_MINUTE_OFFSET_RE.fullmatch(text): + raise ValueError( + f"{value!r} carries a UTC offset that is not a whole number of " + "minutes, which EasyVista's interval grammar cannot express -- " + "the value itself is valid ISO 8601 (every pre-1900 zoneinfo " + "zone renders like this). Convert it to UTC, or to a zone with " + "a whole-minute offset. Parsing it back to a datetime and " + "passing that does NOT help: the datetime path refuses the same " + "offset for the same reason." + ) + raise ValueError( + f"{value!r} is not an EasyVista timestamp. An interval bound is " + "interpolated unquoted, so only a date or an ISO-8601 timestamp is " + "accepted; pass a datetime to be certain." + ) + if _OFFSETLESS_TIME_RE.fullmatch(text): + raise ValueError( + f"{value!r} names a time with no UTC offset, which EasyVista reads " + "in another zone: that silently moves the bound and skips records " + "with no error at all. Append the offset (or 'Z'), or pass an aware " + "datetime and let format_ev_datetime render it. A date alone is " + "accepted -- it has no time to misplace." + ) + if _DATE_ONLY_RE.fullmatch(text): + return text + return _render_interval_bound(parsed) + + +def ev_since_filter(field: str, start: str | datetime | None) -> str | None: + """Build an open-ended lower bound: ``FIELD:(start;)``. + + This is the change-window filter. EasyVista has **no** comparison operator — + ``>=``, ``>``, ``BETWEEN``, ``[a TO b]`` and ``a..b`` are all silently + dropped, which returns the whole table (256 live trials, zero honoured). + A range is instead an *interval in the value position*, and the open-ended + form is exactly a watermark:: + + search = ev_since_filter("LAST_UPDATE", watermark) + if search is not None: + seen = set() + for ticket in client.iter_tickets( + search=search, sort="LAST_UPDATE DESC" + ): + if ticket.rfc_number in seen: + continue + seen.add(ticket.rfc_number) + ... + + **The sort is load-bearing, and its DIRECTION decides whether a dropped row + ever comes back.** ``iter_tickets`` walks the result set by offset, and the + rows this filter selects are by construction the rows that are changing, so + a row touched between page N and page N+1 moves *within the very set being + paged*. Either direction can drop a row from the current sweep; what differs + is where the dropped row's own timestamp ends up relative to the watermark + this sweep will record: + + * **Descending** (``LAST_UPDATE DESC``) — the re-touched row jumps to the + *head*, behind the read cursor, so this sweep misses it. But its + ``LAST_UPDATE`` is now *above* the watermark, so the next sweep selects it + again: the miss is **deferred and self-healing**. + * **Ascending** (bare ``LAST_UPDATE``, or ``LAST_UPDATE ASC``) — the + re-touched row moves to the tail, and every row between its old place and + the tail shifts one position head-ward. The row that crosses the cursor is + therefore one whose own stamp did **not** change; it falls *below* the new + watermark, so no later sweep selects it either. The miss is **permanent**. + + So sweep **descending** and de-duplicate by ``rfc_number`` as above. Both + directions are honoured tokens (measured live); the direction is chosen for + this reason, not for availability. An earlier docstring in this package + recommended ascending, on the reasoning that it turns a permanent miss into a + duplicate — that was wrong: the row an ascending sweep drops is not the + re-touched one. + + **A sweep that does not run to completion is a separate trap under this + sort.** Because ``DESC`` yields the newest row first, the watermark reaches + its *final* value on page 1. A sweep interrupted partway through, or capped + with ``max_records``, still ends up holding the newest stamp — so advancing + the watermark from it makes the next window's ``(newest;)`` bound + permanently exclude every row the incomplete sweep never reached. Advance the + watermark only after a sweep runs to completion. + + Descending is the safe direction, not a guarantee: ``iter_tickets`` owns its + offset. A caller who cannot tolerate even a deferred miss should page + :meth:`~easyvista_python_client.EasyvistaClient.search_tickets` directly + with **keyset** pagination: sort ascending and, after each page, advance the + *window* — ``ev_since_filter(field, max(stamps on the page))`` read again at + ``offset=0`` — instead of incrementing an offset. With no offset there is no + cursor for a row to shift past. ``iter_tickets`` cannot express this. + + The lower bound is **INCLUSIVE** and milliseconds are honoured (verified + live on three independent boundaries), so a watermark taken as + ``max(t.last_update)`` re-reads that boundary record on the next sweep — + another duplicate the same de-duplication absorbs. + + ``start`` may be a ``datetime`` (the preferred input) or a timestamp + string; a string naming a time is re-rendered to the one form the wire + honours (see :func:`_interval_bound`). Blank or ``None`` returns ``None``, + matching the other builders so callers compose without conditionals. + """ + bound = _interval_bound(start) + if not bound: + return None + return f"{field}:({bound};)" + + +def ev_between_filter( + field: str, start: str | datetime | None, end: str | datetime | None +) -> str | None: + """Build a closed interval: ``FIELD:(start;end)``. + + Either bound may be omitted for a half-open interval. With both omitted the + result is ``None`` rather than ``FIELD:(;)``, which would match everything. + + Note ``,`` is **not** the separator — ``FIELD:(a,b)`` raises HTTP 590 live. + + Both bounds are normalised exactly as :func:`ev_since_filter`'s is (see + :func:`_interval_bound`): a bare date passes through, and a value naming a + time is re-rendered at millisecond precision with an explicit offset. The + sub-millisecond truncation that involves runs **downward**, which is the + safe direction for the lower bound but not for the upper one — + ``"...41.133999Z"`` as ``end`` becomes ``"...41.133Z"``, up to 999 + microseconds early. Pass an ``end`` already at millisecond precision when + the exact instant matters. (EasyVista itself returns millisecond precision, + so a bound read back from a record cannot fall inside that remainder. The + *upper* bound's inclusivity is unmeasured — only the lower bound's was + verified live.) + """ + low, high = _interval_bound(start), _interval_bound(end) + if not low and not high: + return None + return f"{field}:({low};{high})" + + +# The wildcard tokens this builder can APPEND. `*` is the one measured live +# 2026-08-17 on the verified instance; `%` reproduced its exact match count +# there (32 of 4317), and is the token a LIKE-backed deployment may use +# instead. Not exported: see `wildcard=` on the two public builders. +_WILDCARD_TOKENS = ("*", "%") + +# Metacharacters of `~` ITSELF, evaluated whether or not this builder appends a +# wildcard. Measured live 2026-08-18 against one instance, and it may not +# generalise: the probes in integration_tests/test_live_change_window.py are +# raw `FIELD~"_"` and `FIELD~"[0-9]"` -- no wildcard added at all -- +# and each widened a one-row exact match to nine, while `[x]` still +# matched the one row, so the class is genuinely evaluated. There is no escape: +# a backslash before `_` returned 0 rows, i.e. it is compared literally. These +# stay refused at EVERY `wildcard=` setting, because the refusal was never about +# what this builder adds. `_` is not exotic in EasyVista -- it is pervasive in +# asset tags, catalog codes and `e_*` column values. +_OPERATOR_METACHARS = ("_", "[") + +# Everything refused while a wildcard IS being appended: the operator's own +# metacharacters plus the wildcard tokens, a second one of which inside the +# value would compose with the appended one. +_PATTERN_METACHARS = _WILDCARD_TOKENS + _OPERATOR_METACHARS + + +def _wildcard_filter( + field: str, + value: str | None, + pattern: str, + wildcard: Literal["*", "%"] | None, +) -> str | None: + """Shared body for the ``~`` pattern builders. + + ``pattern`` is a format string over ``{w}`` (the wildcard token, or ``""`` + when none is appended) and ``{v}`` (the escaped value). + + ``wildcard`` is validated before anything else, so an unsupported token is + refused even on a call whose blank ``value`` would return ``None`` -- it is + a fault in the caller's code, not in their data, and it must not depend on + what happens to be in ``value`` that day. + """ + if wildcard is not None and wildcard not in _WILDCARD_TOKENS: + raise ValueError( + f"wildcard={wildcard!r} is not a token these builders emit. Pass " + "'*' (the default, and the token measured live on the verified " + "instance), '%' (interchangeable with it there, and the token a " + "LIKE-backed deployment may use instead), or None to append " + "nothing -- the vendor's plain Contains reading of '~'. The token " + "is interpolated outside the value escaping, so it comes from a " + "closed set on purpose; for any other pattern, build the " + "expression yourself and pass it as a raw search= string." + ) + if value is None: + return None + text = str(value).strip() + if not text: + # With a wildcard appended a blank value renders `FIELD~"**"`, which + # matches every row -- the silent-widening failure these builders exist + # to prevent. With none appended it renders `FIELD~""`, which asks + # nothing. Both return None, so callers compose without conditionals + # either way. + return None + if wildcard is None: + if any(char in text for char in _OPERATOR_METACHARS): + raise ValueError( + f"{value!r} contains a pattern metacharacter (one of _ [). " + "These are metacharacters of '~' ITSELF, not of the wildcard " + "this builder appends, so wildcard=None does not make them " + "literal: measured live 2026-08-18 against one instance (it " + 'may not generalise), FIELD~"_" with no wildcard ' + "appended widened a one-row exact match to nine, and [0-9] in " + "the same position did the same. EasyVista provides no escape " + "-- a backslash is compared literally. For an EXACT match use " + "ev_equals_filter: ':' does not expand a wildcard. If your " + "deployment compares '_' literally under '~' -- the vendor " + "documents '~' as plain Contains and names no metacharacters " + "-- pass the expression as a raw search= string." + ) + elif any(char in text for char in _PATTERN_METACHARS): + raise ValueError( + f"{value!r} contains a pattern metacharacter (one of * % _ [). " + "These builders add the wildcards themselves; a metacharacter " + "inside the value would change which records match rather than " + "being compared literally, and EasyVista provides no escape for it " + "-- a backslash is taken literally (verified live). For an EXACT " + "match on a value containing one, use ev_equals_filter: ':' does " + "not expand a wildcard. To pattern-match around one, filter " + "server-side on a wider condition and compare exactly in Python. " + "To place '*' or '%' yourself, pass wildcard=None and put them in " + "the value." + ) + rendered = pattern.format( + w="" if wildcard is None else wildcard, v=escape_ev_value(text) + ) + return f'{field}~"{rendered}"' + + +def ev_contains_filter( + field: str, + value: str | None, + *, + wildcard: Literal["*", "%"] | None = "*", +) -> str | None: + """Build a substring match: ``FIELD~"*value*"``. + + Two readings of ``~`` are on record, and this builder resolves them in + favour of the measured one: + + * **Tier 1, vendor documentation** -- ``~`` is *Contains* (Oxygen 1.7+). + The vendor's grammar table gives it one word, no example, and names + neither a wildcard nor any metacharacter. On a deployment that behaves + that way, ``FIELD~"value"`` is already the substring match. + * **Tier 4, measured live 2026-08-17 against one instance, which may not + generalise** -- ``~`` behaved as a *pattern* operator needing an explicit + wildcard. ``RFC_NUMBER~"*260817*"`` matched 33 rows while a bare value + matched only the one exact row, and ``RFC_NUMBER:"I26081*"`` matched 0, + because ``:`` never expands a wildcard. + + ``wildcard`` chooses between them. It defaults to ``"*"`` -- the tier-4 + reading -- because that is the only behaviour anyone has measured. Pass + ``wildcard="%"`` for a deployment whose wildcard is the LIKE one (``%`` + reproduced ``*``'s exact match count on the verified instance, so the two + are interchangeable there), or ``wildcard=None`` to emit ``FIELD~"value"`` + with nothing appended, which is the vendor's plain Contains. + + **The two settings fail in opposite directions, and neither failure is + visible in the response.** Appending a wildcard on a deployment that + compares ``*`` literally returns zero rows with HTTP 200 and no hint; + ``wildcard=None`` on a deployment like the verified one degenerates to an + exact match. Confirm once which reading your deployment follows -- compare + a filtered count against the unfiltered baseline -- rather than guessing + per call. + + A value containing ``_`` or ``[`` raises ``ValueError`` **at every** + ``wildcard`` setting, including ``None``: both are metacharacters of ``~`` + itself rather than of the wildcard this builder appends. Measured live + 2026-08-18 against one instance, and it may not generalise -- + ``FIELD~"_"``, with no wildcard appended at all, widened a one-row + exact match to nine; ``[0-9]`` in the same position did the same; and there + is no escape, a backslash before the character being compared literally. + ``*`` and ``%`` are refused **only** while a wildcard is being appended, + where a second one in the value would compose with it; with + ``wildcard=None`` they pass through, which is how to hand-build a pattern + through this builder. + + Refusing beats silently matching records the caller did not ask for -- + ``ev_contains_filter("ASSET_TAG", "LAPTOP_01")`` would otherwise also match + ``LAPTOP-01`` and ``LAPTOP001`` with HTTP 200 and no hint. For an **exact** + match on such a value use :func:`ev_equals_filter`, whose ``:`` does not + expand a wildcard. Only pattern-matching *around* a literal metacharacter + is impossible here: that needs a wider server-side condition plus an exact + comparison in Python, or an expression built by hand and passed to the + caller's own ``search=`` argument, which every search method accepts as a + raw unvalidated string. + """ + return _wildcard_filter(field, value, "{w}{v}{w}", wildcard) + + +def ev_starts_with_filter( + field: str, + value: str | None, + *, + wildcard: Literal["*", "%"] | None = "*", +) -> str | None: + """Build a prefix match: ``FIELD~"value*"`` (verified live 2026-08-17: 32 rows). + + ``wildcard`` means what it means in :func:`ev_contains_filter`, including + its default of ``"*"`` and the two readings of ``~`` documented there -- + the vendor's tier-1 *Contains*, and this package's tier-4 measurement of a + pattern operator, taken 2026-08-17 against one instance and not necessarily + general. + + **``wildcard=None`` does not express a prefix on either kind of + deployment.** It emits ``FIELD~"value"``, which is an exact match on the + verified instance and an *unanchored substring* match on a deployment that + follows the vendor's reading. So on this builder ``None`` removes the + anchor rather than swapping a token: use it only once you have confirmed + which reading you are on, and expect a wider result set than the function + name promises if that reading is the vendor's. + + Refuses ``_`` and ``[`` in ``value`` at every ``wildcard`` setting, and + ``*``/``%`` while a wildcard is being appended, for the reasons and with + the exits given in :func:`ev_contains_filter`. + """ + return _wildcard_filter(field, value, "{v}{w}", wildcard) + + __all__ = [ "escape_ev_value", + "ev_between_filter", + "ev_contains_filter", "ev_equals_filter", "ev_in_filter", + "ev_since_filter", + "ev_starts_with_filter", "is_safe_ev_value", ] diff --git a/easyvista_python_client/models/action.py b/easyvista_python_client/models/action.py index 142c2a3..069cd66 100644 --- a/easyvista_python_client/models/action.py +++ b/easyvista_python_client/models/action.py @@ -6,7 +6,14 @@ from pydantic import Field, model_validator -from .common import EasyvistaModel, EasyvistaWriteModel, OptionalInt +from ..references import localized_label +from .common import ( + EasyvistaModel, + EasyvistaWriteModel, + OptionalDateTime, + OptionalInt, + _shipped_keys, +) class Action(EasyvistaModel): @@ -18,6 +25,34 @@ class Action(EasyvistaModel): caller supplied as ``PostAction.description`` comes back through ``DESCRIPTION`` — **not** ``COMMENT``, and not on the list endpoint at all (verified live). ``extra="allow"`` preserves everything else. + + Item-level reads additionally carry timestamps (``CREATION_DATE_UT``, + ``LAST_UPDATE``), the author (``DONE_BY_ID`` plus a nested ``DONE_BY`` + employee object) and workflow context (``STAGE_ID``, ``WORKFLOW_ID``) — + verified live 2026-08-17. Their availability on the LIST endpoint is not + uniform: ``CREATION_DATE_UT``, ``LAST_UPDATE``, ``GROUP_ID``, ``STAGE_ID``, + ``WORKFLOW_ID`` and ``PARENT_ACTION_ID`` are genuinely absent from the + default list projection; ``DONE_BY_ID`` and ``ACTION_NUMBER`` are already + present there as top-level scalars; ``ACTION_TYPE_ID`` and ``REQUEST_ID`` + are present on a list row too, but only *nested* (inside ``ACTION_TYPE`` / + ``REQUEST``) — since the declared fields alias the top-level key, + ``action_type_id``/``request_id`` read ``None`` off a default list row + even though the API did return the data. Pass ``fields=`` to + ``list_actions`` to get these top-level for a whole PAGE of actions in one + request instead of an item fetch per action — ``list_actions`` returns one + page and does not paginate, so it is a page's worth, not a ticket's. + + Naming: this model calls its two timestamps ``created_at``/``updated_at`` + where :class:`~easyvista_python_client.models.request.Request` and + :class:`~easyvista_python_client.models.employee.Employee` mirror the wire + and call the identical columns ``creation_date_ut``/``last_update``. The + divergence is only in the Python surface; the aliases + (``CREATION_DATE_UT``/``LAST_UPDATE``) are the same on all three. Code that + spans record types should therefore reach for the wire name via + :meth:`~easyvista_python_client.models.common.EasyvistaModel.classify_fields` + or ``.reference()`` rather than a shared attribute name, because + ``getattr(record, "last_update")`` raises ``AttributeError`` on an + ``Action``. """ action_id: OptionalInt = Field(default=None, alias="ACTION_ID") @@ -28,6 +63,46 @@ class Action(EasyvistaModel): # The live API returns ACTION_TYPE as a nested object (id/name/...), not a # bare string, so accept either (same polymorphism as Request.description). action_type: str | dict[str, Any] | None = Field(default=None, alias="ACTION_TYPE") + # The action's human label, present on the default list row. On a + # single-language instance the other language columns (``_EN``, ``_GE``, + # ``_IT``, ``_PO``, ``_SP``, ``_L1``..``_L6``) echo this text wrapped in + # brackets to mark it untranslated; ``localized_label`` skips those. A + # bracketed *suffix* on distinct text is a different thing entirely and + # does carry meaning -- see :class:`PostAction`. + action_label_fr: str | None = Field(default=None, alias="ACTION_LABEL_FR") + # --- item-level fields (EV-R1, verified live 2026-08-17) ------------------ + # All ten are present on ``GET actions/{id}``. On the LIST endpoint: + # CREATION_DATE_UT, LAST_UPDATE, GROUP_ID, STAGE_ID, WORKFLOW_ID and + # PARENT_ACTION_ID are genuinely ABSENT from the default projection (the + # default list row carries only ACTION_ID, ACTION_LABEL_FR, ACTION_NUMBER, + # DONE_BY_ID and EXPECTED_START_DATE_UT) -- use a ``fields=`` projection + # (see ``list_actions(fields=...)``) or an item fetch to get them. + # ACTION_TYPE_ID and REQUEST_ID are NOT list-absent -- the default list row + # already returns them, nested inside ACTION_TYPE / REQUEST respectively -- + # but because these fields alias the top-level key, they still read + # ``None`` off a default list row; a ``fields=`` projection or the item GET + # returns them top-level instead. + # + # Named ``created_at``/``updated_at`` rather than mirroring the API's + # ``CREATION_DATE_UT``/``LAST_UPDATE`` because these are the two timestamps + # a caller reaches for; the aliases keep the wire names authoritative. + created_at: OptionalDateTime = Field(default=None, alias="CREATION_DATE_UT") + updated_at: OptionalDateTime = Field(default=None, alias="LAST_UPDATE") + done_by_id: OptionalInt = Field(default=None, alias="DONE_BY_ID") + action_type_id: OptionalInt = Field(default=None, alias="ACTION_TYPE_ID") + group_id: OptionalInt = Field(default=None, alias="GROUP_ID") + request_id: OptionalInt = Field(default=None, alias="REQUEST_ID") + action_number: OptionalInt = Field(default=None, alias="ACTION_NUMBER") + # Workflow context. A freshly created ticket auto-spawns ~12 actions from the + # catalog's workflow -- on one live ticket, only ONE of the twelve was + # human-authored; the rest were the workflow's own generated steps. Those + # generated actions carry these fields, an EMPTY ``DONE_BY_ID``, and also a + # ``STATUS_ID_ON_CREATE`` (deliberately not declared here) -- together how a + # caller tells a generated step from a human note. Filter on + # ``action_type_id`` — the comment-like type ids are per-deployment config. + stage_id: OptionalInt = Field(default=None, alias="STAGE_ID") + workflow_id: OptionalInt = Field(default=None, alias="WORKFLOW_ID") + parent_action_id: OptionalInt = Field(default=None, alias="PARENT_ACTION_ID") @model_validator(mode="after") def _derive_action_id_from_href(self) -> Action: @@ -48,18 +123,345 @@ def _derive_action_id_from_href(self) -> Action: self.action_id = int(tail) return self + @property + def label(self) -> str | None: + """Best localized action label, whichever language column carries it. + + Reads the ``ACTION_LABEL_`` columns in + :data:`~easyvista_python_client.DEFAULT_LANGUAGE_ORDER` and returns the + first that is populated and not an untranslated ``[placeholder]``. + Prefer this over ``action_label_fr``, which names one column: on a + single-language instance the *other* language columns echo the primary + text wrapped in brackets, so on an English deployment + ``action_label_fr`` is ``"[Customer Comment]"`` -- not ``None`` -- and + reading it directly yields the placeholder. A bracketed *suffix* on + otherwise distinct text (``"Commentaire [Public]"``) is real content and + is kept. + + A plain property, not a serialized field, so it never recurses through + ``model_dump`` and never appears in ``classify_fields``. ``None`` when no + ``ACTION_LABEL_*`` column is populated, and also in the pathological case + where every one of them is a bracketed placeholder; a caller that must + always render something supplies its own fallback, as + :meth:`~easyvista_python_client.TicketContext.to_markdown` does with the + literal ``"Action"``. For a different language order call + :func:`~easyvista_python_client.localized_label` on + ``model_dump(by_alias=True)`` directly. + """ + return localized_label(self.model_dump(by_alias=True), "ACTION_LABEL") + class PostAction(EasyvistaWriteModel): """Payload for creating an action on a ticket. Field set follows the documented (and live-verified) create-action body: identify the action type via ``action_type_id`` (or ``action_type_name``) and - the assigned group via ``group_id`` (or ``group_name``); ``description`` holds - the note text. Inherits ``custom_fields``/``to_api()`` from EasyvistaWriteModel. + the assigned group via ``group_id`` (or ``group_name``). Inherits + ``custom_fields``/``to_api()`` from EasyvistaWriteModel. + + **An action stores two separate text fields**, ``description`` and + ``comment``, each addressable afterwards as its own memo sub-resource + (``GET actions/{id}/description`` and ``GET actions/{id}/comment``). Both + persist when sent together on create -- verified live 2026-08-28 on one + instance: a single create carrying both read back with exactly the text + sent in each. The instance's own OpenAPI declares both on the create body + and its example populates both (tier 2). + + **Independent in storage, not in visibility.** Measured in the UI + 2026-09-01 on one instance (Service Manager 2025.3 -- one instance, one + date, so it may not generalise), the ticket history shows ONE text field + per action, under a header reading literally "comment or description": it + renders ``DESCRIPTION`` when that memo has text, and falls back to + ``COMMENT`` only when it is empty. So **``description`` shadows + ``comment``**. A create carrying both stores both, and the comment is + readable through the API but never reaches a human -- no error, no dropped + field, nothing to signal the loss. Put the text a person must read in + ``description``; use ``comment`` only when you deliberately leave + ``description`` empty, or when you mean it as API-only metadata. + + ``comment`` was previously absent from this model, on the reasoning that an + action's text lives in ``DESCRIPTION`` while ``COMMENT`` "is empty" on the + verified instance. That *reasoning* was wrong -- the column accepts writes + and reads back. But the instinct behind the omission was half right: an + action's **visible** text does live in ``DESCRIPTION``, because the UI + reaches ``COMMENT`` only when the description memo is empty. + + **A comment is an action that has been ENDED.** This is the single most + important thing to know before posting one, and it is why a newly created + action looks wrong in the UI. An action is a unit of work: created open + (a task still to do), then *ended* (work reported). Only an ended action + renders in the ticket's history with its text visible -- an open one shows + as a pending action row with no body. Verified live 2026-08-28: a type-95 + action created through this model 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``, and fills ``DONE_BY_ID`` with the person who + ended it. None of those fields can be set through ``POST + requests/{rfc}/actions``: sending them returns HTTP 200 and drops them + silently. + + ``STATUS_ID_ON_TERMINATE`` records the status the **ticket** took when the + action ended, which is what makes a workflow action's ending visible in the + record: measured 2026-09-01/02 on one instance (one instance, two dates, so + it may not generalise), a workflow action stored ``2`` (the status the + ticket moved to) while a caller-created action on a ticket that did not + move stored ``12`` (where it stayed). Those ids are this deployment's -- + status ids are per-instance and must never be hardcoded from another. It is + empty on an action that is still open, so it reports what happened rather + than predicting it. + + .. warning:: + + **Retracted 2026-09-02.** This paragraph previously said ending + **clears** ``GROUP_ID`` -- "the record moves from assigned-to-a-group to + done-by-a-person". It does not. Re-read after ending, the group survived + on both an ended workflow action (``GROUP_ID`` 57) and an ended + caller-created one (``GROUP_ID`` 3, the value passed at create). + + Ending is ``PUT actions/{rfc_number}`` with the body wrapped in + ``end_action`` (``doneby_mail``, ``start_date``, ``end_date``, + ``elapsed_time``; omit ``action_id`` to end every open action at once), and + dates in the instance's own ``DATE_FORMAT``. See + https://docs.easyvista.com/docs/rest-api-finish-an-action-attached-to-an-incident-request.md + **This package implements it as** ``EasyvistaClient.end_action`` -- read + that method before calling it, because ending the ticket's own workflow + action advances the workflow and moves the ticket's status. + + .. warning:: + + **Retracted 2026-09-01.** This docstring previously said every + documented form returned ``590 Action not found`` and read that as an + instance- or profile-level restriction to raise with an administrator. + That was wrong. The 590 is what the route answers when **no OPEN action + matches** -- replaying it against an action that is already ended, for + instance. Ending an open action succeeds. + + Measured 2026-09-01 on one instance (Service Manager 2025.3 -- one + instance, one date, so it may not generalise): + + * ``end_date`` accepts ``dd/mm/yyyy hh:mm:ss`` and ``dd/mm/yyyy hh:mm`` and + honours the time; a bare ``dd/mm/yyyy`` lands at midnight. **ISO 8601 is + rejected** with ``590 "Invalid End Date"``. + * ``elapsed_time`` is in **minutes**. + * Send ``start_date`` explicitly. Left to derive it, the server returns a + ``START_DATE_UT`` early by the instance's UTC offset -- confirmed with a + DST control (120 minutes in September at +02:00, 60 in February at + +01:00), so it tracks the offset rather than a fixed constant. An + explicit ``start_date`` is stored faithfully. + * The path segment is the **RFC number**, not an action id, despite the + route living under ``/actions``: ``PUT actions/{action_id}`` answers 404 + even with the id also in the body. + + **Visibility is by action TYPE, and the labels say which is which.** There + is no per-action visibility flag -- the item-level record was captured on + 2026-08-28 (88 columns) and holds no public/private boolean. The + distinction lives in the action type. On the verified instance: + type 94 is ``Commentaire [Public]`` / ``Customer Comment``, type 95 is + ``Note Interne [Prive]`` / ``Internal Note``. Type ids are per-deployment + and are not portable, but they are **discoverable**: ``GET action-types`` + is 403 on a standard profile, yet every action record carries its own + ``ACTION_TYPE_ID`` alongside translated ``ACTION_LABEL_*`` columns, so one + ``GET actions`` recovers the types in use. + + Two bracket conventions appear in ``ACTION_LABEL_*`` and they mean + different things. A whole label wrapped in brackets that echoes another + language (``EN='[Analyse et resolution]'``) is an *untranslated + placeholder* and carries no meaning -- ``references.localized_label`` + already skips those. A bracketed *suffix* on distinct text, with genuine + translations in the sibling columns (``FR='Commentaire [Public]'`` beside + ``EN='Customer Comment'``), is a real visibility marker. An earlier + revision of this package conflated the two and deleted the true finding. + + **Mandatory (tier 1):** ``action_type_id`` (or ``action_type_name`` / + ``action_type_guid``), and one of ``group_id`` / ``group_name`` / + ``group_mail``. A body missing either is refused here rather than drawing + an HTTP 590 that names no field. Sent empty, this route answers 590 with + nothing a caller can act on -- the same failure :class:`PostTask` has + always guarded against, on the same vendor sentence. + """ + + # Types mirror PostTask exactly. They diverged for no recorded reason -- + # ``int | None`` here against ``int | str | None`` there -- which made a + # non-numeric type or group id work through ``create_task`` and fail + # through ``create_action``. The instance's own OpenAPI declares + # ``action_type_id`` on this route as a *string* (tier 3, illustrative + # only), which argues for accepting one, not for coercing to one: whichever + # type is passed serializes unchanged. + action_type_id: int | str | None = None + action_type_name: str | None = None + # Tier 1, 2023.4+ (docs/vendor-api-reference.md). Declared here and NOT on + # PostTask -- see that class for why. + action_type_guid: str | None = None + group_id: int | str | None = None + group_name: str | None = None + # Tier 1, and the third way to name the group. PostTask has always had it; + # this model's omission was an oversight, not a finding. + group_mail: str | None = None + # Tier 1, optional. An action of a child type hangs off its parent. + parent_action_id: int | str | None = None + description: str | None = None + comment: str | None = None + + # Deliberately still undeclared, all tier 1 and all optional: contact_*, + # done_by_*, creation_date_ut, expected_start_date_ut, expected_end_date_ut, + # max_intervention_date_ut. Nothing in this package exercises any of them, + # and extra_payload reaches them today. Declaring six fields nobody has sent + # would put this model's word behind bodies it has never seen work. + + @model_validator(mode="after") + def _require_a_type_and_a_group(self) -> PostAction: + """Refuse a body the API would reject with an unattributable 590. + + Same rule and same tier-1 source as :class:`PostTask`'s guard. The + check reads the body ``to_api()`` will actually send, so a field + supplied through ``extra_payload`` satisfies it. + """ + shipped = _shipped_keys(self) + if not shipped & {"action_type_id", "action_type_name", "action_type_guid"}: + raise ValueError( + "an action needs an action type: pass action_type_id " + "(preferred), action_type_name or action_type_guid. The type " + "also carries the public/internal distinction -- read the ids " + "off ACTION_LABEL_* on existing actions." + ) + if not shipped & {"group_id", "group_name", "group_mail"}: + raise ValueError( + "an action needs an assigned group: pass group_id, group_name " + "or group_mail. Tier 1 lists it as required; omitting it on " + "the sibling tasks route drew HTTP 590 'Le groupe (Group_...) " + "est invalide' (measured 2026-08-28)." + ) + return self + + +class ActionUpdate(EasyvistaWriteModel): + """Payload for editing an existing action's note. + + ``PUT actions/{id}`` is live-verified (2026-08-17): writing the action's + ``DESCRIPTION`` memo really changed it, confirmed by re-reading it rather + than by trusting HTTP 200. There is no **nested** + ``requests/{rfc}/actions/{id}`` route at all, and no DELETE verb on the + top-level one -- the instance OpenAPI document read 2026-08-27 declares + only GET, PUT and PATCH there. So an action can be edited but not deleted, + and the 403 an earlier note recorded against both is what this API answers + for an absent route as well as a denied one. + + ``description`` and ``comment`` are the action's two stored text fields, + the same pair :class:`PostAction` writes on create -- both were verified + live on 2026-08-28 to persist and read back separately from a single + create. Editing either targets that memo alone -- but **the two are not + interchangeable to a reader**. Measured in the UI 2026-09-01 on one + instance (Service Manager 2025.3 -- one instance, one date, may not + generalise), the history renders ``DESCRIPTION`` and falls back to + ``COMMENT`` only when the description memo is empty, so an + ``ActionUpdate(comment=...)`` on an action that already has a description + returns 200, re-reads cleanly, and changes nothing anyone sees. + + **Write ``description`` to change what a person reads.** The same + measurement confirmed a ``description`` edit applied to an action that had + already been **ended** renders in the history, which is how to correct or + extend a resolution after the fact. + + Neither memo is private in the API's sense (see :class:`PostAction`: + visibility is carried by the action type). An unrendered ``comment`` is + invisible by accident, not by permission. + """ + + description: str | None = None + comment: str | None = None + + +class PostTask(EasyvistaWriteModel): + """Payload for creating a **task** on a ticket -- an action that arrives ended. + + **This is the model to use for a comment.** A task and an action are the + same underlying record; they differ in the state they are born in: + + ================== ================================ ======================== + .. ``POST requests/{rfc}/actions`` ``POST requests/{rfc}/tasks`` + ================== ================================ ======================== + body shape wrapped in ``action`` flat at the root + resulting state **open** -- work still to do **ended** -- work reported + in the UI a pending row, body NOT shown shows its ``description`` + needs ending after yes no + ================== ================================ ======================== + + So an action models work someone still has to do, and only becomes a + readable history entry once it is ended. A task is created already ended, + which is why one call is enough to post a comment. Verified live + 2026-08-28: two tasks (types 94 and 95) came back with ``END_DATE_UT`` and + ``STATUS_ID_ON_TERMINATE`` already set, attributed to the API account. + Vendor documentation: + https://docs.easyvista.com/docs/rest-api-create-a-task-for-an-incident-request.md + + **Put the comment text in ``description``.** ``comment`` is a second memo + on the same record, and the UI renders only one: ``description``, falling + back to ``comment`` when the description memo is empty (measured in the UI + 2026-09-01 on one instance -- one instance, one date, may not generalise). + A task sending both therefore shows only the description, and a note split + across the two loses half of itself with no error. + + Prefer this over :class:`PostAction` unless you genuinely mean "someone + must still do this". An action is born open, so its text does not render + until it is ended; ending is a second call + (``EasyvistaClient.end_action``, which also advances the workflow when the + action is a workflow step), and a task skips it entirely. + + **Public vs internal is the action type**, not a flag on the body. On the + verified instance type 94 is ``Commentaire [Public]`` / ``Customer + Comment`` and type 95 is ``Note Interne [Prive]`` / ``Internal Note``. + Those ids are per-deployment; recover yours from the labels on existing + actions (see :class:`PostAction`). Unlike an action of type 95, a task + needs no ``parent_action_id``. + + Mandatory (tier 1): ``action_type_id`` **or** ``action_type_name``, and one + of ``group_id`` / ``group_name`` / ``group_mail``. A body missing either is + refused locally rather than drawing an HTTP 590 that names no field. """ - action_type_id: int | None = None + action_type_id: int | str | None = None action_type_name: str | None = None - group_id: int | None = None + group_id: int | str | None = None group_name: str | None = None + group_mail: str | None = None description: str | None = None + comment: str | None = None + # The vendor example spells this ``Elapsed_Time``; JSON object names are + # case-insensitive on this API (tier 1), so the snake_case spelling ships + # and no alias is needed -- which also keeps ``to_api()``'s plain + # ``model_dump()`` correct. Left unset, EasyVista computes it. + elapsed_time: int | str | None = None + time_cost: int | str | None = None + contractual_cost: int | str | None = None + creation_date_ut: str | None = None + start_date_ut: str | None = None + end_date_ut: str | None = None + + @model_validator(mode="after") + def _require_a_type_and_a_group(self) -> PostTask: + """Refuse a body the API would reject with an unattributable 590. + + Reads the body ``to_api()`` will send, not the declared attributes, so + a field passed through ``extra_payload`` satisfies it. + ``action_type_guid`` counts even though this model does not declare it: + nothing in the repository documents the task body's field list against + tier 1 (see O-TASKDOC in ``docs/vendor-api-reference.md``), so the + guard accepts the key without the model asserting the field exists on + this route. + """ + shipped = _shipped_keys(self) + if not shipped & {"action_type_id", "action_type_name", "action_type_guid"}: + raise ValueError( + "a task needs an action type: pass action_type_id (preferred) or " + "action_type_name, on the model or through extra_payload. The " + "type also carries the public/internal distinction -- read the " + "ids off ACTION_LABEL_* on existing actions." + ) + if not shipped & {"group_id", "group_name", "group_mail"}: + raise ValueError( + "a task needs an assigned group: pass group_id, group_name or " + "group_mail. Omitting it draws HTTP 590 'Le groupe (Group_...) " + "est invalide' (measured 2026-08-28)." + ) + return self diff --git a/easyvista_python_client/models/asset.py b/easyvista_python_client/models/asset.py index f241b6e..792eda1 100644 --- a/easyvista_python_client/models/asset.py +++ b/easyvista_python_client/models/asset.py @@ -1,36 +1,50 @@ """Models for the EasyVista ``assets`` resource. Field sets are the documented/common AM_ASSET fields; ``extra="allow"`` on the -read model preserves any others. Exact field names pending live validation. +read model preserves any others. """ from __future__ import annotations from pydantic import Field -from .common import EasyvistaModel, EasyvistaWriteModel +from .common import EasyvistaModel, EasyvistaWriteModel, OptionalInt class Asset(EasyvistaModel): - """An asset as returned by the API.""" + """An asset as returned by the API. - asset_id: int | None = Field(default=None, alias="ASSET_ID") + The two id columns use :data:`OptionalInt`, not a bare ``int | None``: + EasyVista returns ``""`` for a numeric column that carries no value, and a + CMDB row with an unset ``STATUS_ID`` is ordinary data, not corruption. + Typed ``int`` alone such a row failed the whole record -- and because a + search validates a page in one comprehension, the whole page with it. + """ + + asset_id: OptionalInt = Field(default=None, alias="ASSET_ID") asset_tag: str | None = Field(default=None, alias="ASSET_TAG") serial_number: str | None = Field(default=None, alias="SERIAL_NUMBER") - status_id: int | None = Field(default=None, alias="STATUS_ID") + status_id: OptionalInt = Field(default=None, alias="STATUS_ID") href: str | None = Field(default=None, alias="HREF") class PostAsset(EasyvistaWriteModel): """Payload for creating an asset. - ``catalog_id`` is required by EasyVista (identifies the equipment model). - ``custom_fields`` are serialized with an ``e_`` prefix (see EasyvistaWriteModel). + ``catalog_id`` identifies the equipment model and is required. + + ``catalog_id`` and ``status_id`` are ``int | str`` with + ``union_mode="left_to_right"``: a numeric string is coerced to the number + the instance's own create example shows (tier 3, illustrative only -- + ``{"assets": [{"catalog_id": 2666, ..., "status_id": 1}]}``), while a + non-numeric value passes through as written rather than being refused by a + type this package cannot vendor-document. ``custom_fields`` are serialized + with an ``e_`` prefix (see :class:`EasyvistaWriteModel`). """ - catalog_id: int + catalog_id: int | str = Field(union_mode="left_to_right") asset_tag: str | None = None serial_number: str | None = None - status_id: int | None = None + status_id: int | str | None = Field(default=None, union_mode="left_to_right") comment_asset: str | None = None installation_date: str | None = None diff --git a/easyvista_python_client/models/common.py b/easyvista_python_client/models/common.py index b0529b8..2eceaba 100644 --- a/easyvista_python_client/models/common.py +++ b/easyvista_python_client/models/common.py @@ -2,12 +2,22 @@ from __future__ import annotations +from collections.abc import Sequence +from datetime import datetime, timezone from typing import Annotated, Any -from pydantic import BaseModel, BeforeValidator, ConfigDict, Field +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + ValidationInfo, + model_validator, +) from ..field_model import FieldClassification, classify -from ..references import Reference, resolve_reference +from ..references import DEFAULT_LANGUAGE_ORDER, Reference, resolve_reference +from ..timestamps import parse_ev_datetime def _empty_str_to_none(value: Any) -> Any: @@ -27,6 +37,98 @@ def _empty_str_to_none(value: Any) -> Any: """An ``int | None`` field that treats the API's ``""`` sentinel as ``None``.""" +def _parse_with_context_formats(value: Any, info: ValidationInfo) -> datetime | None: + """Try the caller's own timestamp formats, if it supplied any. + + Opt-in and empty by default: with no context this returns ``None`` + immediately and the guard below raises exactly as it always has. The + patterns are :meth:`datetime.datetime.strptime` format strings, so nothing + is ever guessed -- a value matching none of them still raises. A pattern + that parses to a naive datetime is stamped UTC, the same assumption + :func:`~easyvista_python_client.parse_ev_datetime` documents for an + offset-less literal on the read path. + """ + context = info.context + if not isinstance(context, dict) or not isinstance(value, str): + return None + for pattern in context.get("datetime_input_formats") or (): + try: + parsed = datetime.strptime(value.strip(), pattern) + except (TypeError, ValueError): + continue + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + return None + + +def _empty_str_to_none_datetime(value: Any, info: ValidationInfo) -> Any: + """Coerce EasyVista's ``""`` sentinel for an absent date to ``None``. + + Distinct from :func:`_empty_str_to_none`: a *malformed* timestamp must still + raise, so this maps only the two documented absences -- ``None`` (a JSON + ``null``, and the field's own default) and EasyVista's ``""`` sentinel. Every + other value -- including a ``datetime`` handed in directly, not just a string -- + routes through :func:`~easyvista_python_client.timestamps.parse_ev_datetime`, + which normalizes a naive ``datetime`` to UTC and returns ``None`` for + anything it cannot parse. + + When it returns ``None`` this raises ``ValueError``, which pydantic wraps + into a ``ValidationError`` naming the field. It deliberately does **not** + fall through to pydantic's own datetime parser, which is far more permissive + than EasyVista's format and would invent a plausible-but-wrong instant + instead of reporting the mismatch: ``"20260817"`` (ISO-basic, no separators) + becomes ``1970-08-23T12:00:17Z``, 56 years off, and ``1755434441610`` -- + what an epoch-millis format change would look like -- becomes a wholly + credible ``2025-08-17T12:40:41.610Z``. Absorbing the one format change this + guard exists to surface is the opposite of the intended behaviour, so junk + raises here instead. + + **The cost of raising is a whole record, and on a search a whole PAGE.** + ``resources/descriptor.py`` validates a page in a list comprehension, so one + unparseable timestamp on one row fails the entire ``search_tickets`` call, + not just that row. That is the deliberate trade -- a wrong instant is worse + than a loud failure -- but it is the reason for the escape hatch below. + + A deployment whose timestamps are genuinely a different format can name that + format instead of forking: pass + ``EasyvistaConfig(datetime_input_formats=("%d/%m/%Y %H:%M:%S",))`` and every + read through the client validates under it. The native ISO-8601 form is + tried first, so a listed pattern can never change how a real EasyVista stamp + parses, and an unlisted format still raises here. + """ + if value is None: + return None + if isinstance(value, str) and not value.strip(): + return None + parsed = parse_ev_datetime(value) + if parsed is None: + parsed = _parse_with_context_formats(value, info) + if parsed is None: + raise ValueError( + f"{value!r} is not an EasyVista timestamp. EasyVista sends ISO 8601 " + "with an explicit UTC offset (or '' for an unset date); anything " + "else is reported rather than guessed at, because pydantic's own " + "parser would turn a numeric or ISO-basic value into a plausible " + "but wrong instant and hide the format change." + ) + return parsed + + +OptionalDateTime = Annotated[ + datetime | None, BeforeValidator(_empty_str_to_none_datetime) +] +"""An aware ``datetime | None`` for an EasyVista timestamp column. + +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 +:func:`~easyvista_python_client.parse_ev_datetime` rather than letting pydantic +parse the string itself. 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. +""" + + class EasyvistaModel(BaseModel): """Base for read models. @@ -36,10 +138,18 @@ class EasyvistaModel(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="allow") - def reference(self, name: str) -> Reference: + def reference( + self, name: str, *, languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER + ) -> Reference: """Resolve a reference attribute (``STATUS``, ``URGENCY``, custom ``e_*``…) - to a normalized :class:`~easyvista_python_client.references.Reference`.""" - return resolve_reference(self.model_dump(by_alias=True), name) + to a normalized :class:`~easyvista_python_client.references.Reference`. + + ``languages`` orders the language columns tried for the human label + (default: :data:`~easyvista_python_client.DEFAULT_LANGUAGE_ORDER`). + """ + return resolve_reference( + self.model_dump(by_alias=True), name, languages=languages + ) def classify_fields(self) -> FieldClassification: """Partition this record's fields into official / custom (``e_*``) / @@ -57,22 +167,112 @@ class EasyvistaWriteModel(BaseModel): """Base for write payloads (create/update). Rejects unknown fields (``extra="forbid"``) to catch caller typos, and - serializes ``custom_fields`` with an ``e_`` prefix (unless already prefixed). + serializes ``custom_fields`` with an ``e_`` prefix (unless already + prefixed). ``extra_payload`` is an un-prefixed passthrough for anything the + model does not declare -- see :meth:`to_api`. """ model_config = ConfigDict(extra="forbid") + @model_validator(mode="before") + @classmethod + def _point_unknown_keys_at_extra_payload(cls, data: Any) -> Any: + """Name the unknown keys, and the supported way past them. + + ``extra="forbid"`` catches a caller typo, which is what it is for, but + its own message ("Extra inputs are not permitted") never mentions + ``extra_payload`` -- the model's documented route for a field it + declines to declare. Someone who has just read that a field was + excluded has no way to learn from the error that there is a way + through. + + The message stops short of promising the write will work. On this + package an exclusion is usually a *measured misbehaviour*, not a gap in + the documentation: ``RequestUpdate.status_id`` returned HTTP 200, + applied its companion field and dropped the status in silence. + ``extra_payload`` gets the field onto the wire; it cannot make the + server store it. + """ + if not isinstance(data, dict): + return data + unknown = sorted(str(key) for key in data if key not in cls.model_fields) + if not unknown: + return data + raise ValueError( + f"{cls.__name__} does not declare {', '.join(unknown)}. Check the " + "spelling first. If the field is real on your deployment, send it " + "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." + ) + custom_fields: dict[str, Any] = Field(default_factory=dict) + extra_payload: dict[str, Any] = Field(default_factory=dict) def to_api(self) -> dict[str, Any]: - """Return the API body: known fields (``None`` dropped) plus ``e_``-prefixed - custom fields. + """Return the API body: known fields (``None`` dropped), ``e_``-prefixed + custom fields, then ``extra_payload`` verbatim. Booleans may be sent as native JSON ``true``/``false`` (EasyVista also accepts ``0``/``1`` and the strings ``"true"``/``"false"``). + + ``extra_payload`` is merged **last and wins**, over a declared field and + over ``custom_fields`` alike. It is the supported route for a field this + model declines to declare -- every such exclusion rests on behaviour + 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. + + **The merge is case-insensitive.** The vendor documents the ticket + create body's field names as case-insensitive (tier 1 -- + ``docs/vendor-api-reference.md``); the other write bodies are assumed + to match it, which is the safe assumption in either direction here. An + ``extra_payload`` key that matches a declared field's key or a + ``custom_fields``-produced key when case is ignored *replaces* it: the + model's entry is dropped, and ``extra_payload``'s own spelling and + value are what ship. Without + that, ``PostRequest(urgency_id=8, extra_payload={"URGENCY_ID": "4"})`` + would put **both** on the wire with conflicting values and leave which + one the server honours undefined -- and the ``ALL_CAPS`` spelling is + the one callers reach for, since it mirrors the read side. This is a + merge rule only; a collision is never an error. """ - data = self.model_dump(exclude_none=True, exclude={"custom_fields"}) + data = self.model_dump( + exclude_none=True, exclude={"custom_fields", "extra_payload"} + ) for key, value in self.custom_fields.items(): api_key = key if key.startswith("e_") else f"e_{key}" data[api_key] = value + if self.extra_payload: + overridden = {str(key).casefold() for key in self.extra_payload} + data = { + key: value + for key, value in data.items() + if key.casefold() not in overridden + } + data.update(self.extra_payload) return data + + +def _shipped_keys(model: EasyvistaWriteModel) -> set[str]: + """The case-folded keys of the body :meth:`EasyvistaWriteModel.to_api` will + actually send. + + Derived from ``to_api()`` rather than from the declared attributes, so a + required field supplied through ``extra_payload`` -- this package's + documented route past a field the model declines to declare -- satisfies a + guard instead of being refused for a body the API would have accepted. + Deriving it here also means a guard cannot drift from ``to_api``'s + case-insensitive merge rule, because it *is* that rule. + + A ``None`` value is dropped. ``to_api`` already drops ``None`` for declared + fields; a ``None`` arriving through ``extra_payload`` is an absent value, + not a supplied one. + """ + return { + str(key).casefold() + for key, value in model.to_api().items() + if value is not None + } diff --git a/easyvista_python_client/models/department.py b/easyvista_python_client/models/department.py index 29f5399..f0e84b0 100644 --- a/easyvista_python_client/models/department.py +++ b/easyvista_python_client/models/department.py @@ -5,7 +5,8 @@ by ``name``), the ``COMMENT_DEPARTMENT`` Memo link (surfaced by ``classify_fields().links`` and read via ``client.get_department_comment``), and any instance-specific columns. Field aliases are grounded in the live inventory -(``docs/easyvista-field-inventory.md``). Writes are **provisional** pending an +(tier 4 -- a field inventory generated from one instance, 2026-07-07; it may +not generalise to another deployment). Writes are **provisional** pending an authorised profile (spec open item O-DIR-2). """ diff --git a/easyvista_python_client/models/document.py b/easyvista_python_client/models/document.py index 7941ea5..58d1f56 100644 --- a/easyvista_python_client/models/document.py +++ b/easyvista_python_client/models/document.py @@ -21,7 +21,15 @@ class Document(EasyvistaModel): filename: str | None = Field(default=None, alias="FILE_NAME") name: str | None = Field(default=None, alias="NAME") document: str | None = Field(default=None, alias="DOCUMENT") - document_id: str | None = Field(default=None, alias="DOCUMENT_ID") + # Tier 4 and non-coercing, for the same reason as + # ``Request.time_used_to_solve_request``: this column's type was observed on + # one instance, never vendor-documented. Declared ``str`` alone, an instance + # returning a JSON number for DOCUMENT_ID failed the record -- and because + # the list parser validates a whole page in one comprehension, every + # attachment on the ticket with it. + document_id: str | int | None = Field( + default=None, alias="DOCUMENT_ID", union_mode="left_to_right" + ) download_href: str | None = Field(default=None, alias="DDL_HREF") @model_validator(mode="after") diff --git a/easyvista_python_client/models/employee.py b/easyvista_python_client/models/employee.py index 19f17c8..0d394b8 100644 --- a/easyvista_python_client/models/employee.py +++ b/easyvista_python_client/models/employee.py @@ -4,15 +4,17 @@ (``GET employees/{id}``). ``E_MAIL`` is a **declared official** field, so the generic field model never misclassifies it as a custom ``e_*`` column. ``extra="allow"`` preserves the ``COMMENT_EMPLOYEE`` Memo link and any other columns. Aliases are -grounded in the live inventory (``docs/easyvista-field-inventory.md``). Writes are -**provisional** pending an authorised profile (spec open item O-DIR-2). +grounded in the live inventory (tier 4 -- a field inventory generated from +one instance, 2026-07-07; it may not generalise to another deployment). +Writes are **provisional** pending an authorised profile (spec open item +O-DIR-2). """ from __future__ import annotations from pydantic import Field -from .common import EasyvistaModel, EasyvistaWriteModel, OptionalInt +from .common import EasyvistaModel, EasyvistaWriteModel, OptionalDateTime, OptionalInt class Employee(EasyvistaModel): @@ -33,7 +35,7 @@ class Employee(EasyvistaModel): login: str | None = Field(default=None, alias="LOGIN") function_id: OptionalInt = Field(default=None, alias="FUNCTION_ID") language_id: OptionalInt = Field(default=None, alias="LANGUAGE_ID") - last_update: str | None = Field(default=None, alias="LAST_UPDATE") + last_update: OptionalDateTime = Field(default=None, alias="LAST_UPDATE") href: str | None = Field(default=None, alias="HREF") diff --git a/easyvista_python_client/models/generic.py b/easyvista_python_client/models/generic.py new file mode 100644 index 0000000..693d283 --- /dev/null +++ b/easyvista_python_client/models/generic.py @@ -0,0 +1,29 @@ +"""A read model for endpoints this package does not model.""" + +from __future__ import annotations + +from .common import EasyvistaModel + + +class GenericRecord(EasyvistaModel): + """One record from an endpoint this package does not model. + + Declares **no fields**. :class:`EasyvistaModel` + already sets ``extra="allow"``, so every column the instance sends is kept + verbatim and reachable by its API name through ``model_dump(by_alias=True)``, + ``reference()`` and ``classify_fields()``. + + Declaring nothing is the point. A reference table's response schema in an + instance's OpenAPI is tier 3 -- example-derived, illustrative only -- and + the verified instance's ``GET /status`` schema is visibly wrong: it + describes an SLA-shaped object (``DELAY``, ``SLA_ID``, ``NAME_*``, + ``WORKING_HOURS_ID``) with no ``records`` envelope and no status id at all. + A column list written from those schemas would be a guess frozen into this + package's public API, and a different guess on the next deployment. So the + columns stay data. + + One consequence to know: because nothing is declared, ``classify_fields()`` + sees an empty ``declared`` set, so every ``E_``-prefixed column lands in the + ``custom`` bucket -- including an official one such as ``E_MAIL``. That is + the right trade for a model that knows nothing about its table. + """ diff --git a/easyvista_python_client/models/request.py b/easyvista_python_client/models/request.py index a2dbf4e..3a3f02b 100644 --- a/easyvista_python_client/models/request.py +++ b/easyvista_python_client/models/request.py @@ -13,7 +13,13 @@ from pydantic import Field, model_validator -from .common import EasyvistaModel, EasyvistaWriteModel, OptionalInt +from .common import ( + EasyvistaModel, + EasyvistaWriteModel, + OptionalDateTime, + OptionalInt, + _shipped_keys, +) class Request(EasyvistaModel): @@ -71,28 +77,40 @@ class Request(EasyvistaModel): recipient_id: OptionalInt = Field(default=None, alias="RECIPIENT_ID") owner_id: OptionalInt = Field(default=None, alias="OWNER_ID") - # timestamps and time limits — verified *returned*; their accepted write - # format is NOT verified (both a string and an int probe return HTTP 590), - # so no datetime parsing is claimed here. See spec open item O-590-DATE. + # timestamps — ISO 8601 with an EXPLICIT UTC OFFSET and millisecond + # precision, verified live 2026-08-17 against our own UTC clock, so these + # parse to aware datetimes. Their accepted WRITE format is still NOT + # verified (both a string and an int probe return HTTP 590), which is why + # no write model carries a datetime. An unset date is ``""``, handled by + # OptionalDateTime. Note ``_UT`` does NOT mean UTC-normalized: those columns + # carry the same local offset as LAST_UPDATE. # # These are the OFFICIAL time fields, portable across EasyVista # deployments. The instance-specific GTR/GTI family (``E_GTR_STATUS``, # ``E_GTI_UT``, ``E_DELAI_PEC``…) is deliberately NOT declared: it does not # exist on another deployment, so it belongs in the custom bucket of # :meth:`classify_fields`, reached by name at the call site. - submit_date_ut: str | None = Field(default=None, alias="SUBMIT_DATE_UT") - creation_date_ut: str | None = Field(default=None, alias="CREATION_DATE_UT") - max_resolution_date_ut: str | None = Field( + submit_date_ut: OptionalDateTime = Field(default=None, alias="SUBMIT_DATE_UT") + creation_date_ut: OptionalDateTime = Field(default=None, alias="CREATION_DATE_UT") + max_resolution_date_ut: OptionalDateTime = Field( default=None, alias="MAX_RESOLUTION_DATE_UT" ) - expected_date_ut: str | None = Field(default=None, alias="EXPECTED_DATE_UT") - end_date_ut: str | None = Field(default=None, alias="END_DATE_UT") - last_update: str | None = Field(default=None, alias="LAST_UPDATE") + expected_date_ut: OptionalDateTime = Field(default=None, alias="EXPECTED_DATE_UT") + end_date_ut: OptionalDateTime = Field(default=None, alias="END_DATE_UT") + last_update: OptionalDateTime = Field(default=None, alias="LAST_UPDATE") sla_id: OptionalInt = Field(default=None, alias="SLA_ID") - # Verified live (2026-07-28 Phase 0 probe, U6) as a string on every ticket - # checked -- never an int -- so no int branch is declared here. - time_used_to_solve_request: str | None = Field( - default=None, alias="TIME_USED_TO_SOLVE_REQUEST" + # Type, tier 4: measured as a string on every ticket checked in one + # 2026-07-28 probe of one instance -- which is evidence about that instance + # on that day, not about the column. Nothing vendor-documents it. So the + # declaration accepts both forms and coerces neither + # (``union_mode="left_to_right"`` tries ``str`` first, and pydantic's + # ``str`` does not absorb an int). Declared ``str`` alone, a deployment + # returning ``3600`` failed the whole record -- and on a search, the whole + # page. + time_used_to_solve_request: str | int | None = Field( + default=None, + alias="TIME_USED_TO_SOLVE_REQUEST", + union_mode="left_to_right", ) @model_validator(mode="after") @@ -115,58 +133,267 @@ def _derive_rfc_from_href(self) -> Request: class PostRequest(EasyvistaWriteModel): """Payload for creating a ticket. - Field set matches the documented create body (``docs/API_Info.md``), - verified against a live instance: a ticket needs at minimum ``catalog_code`` - plus ``title`` (and typically ``origin`` / ``department_id``). The exact - mandatory fields are configured **per catalog on the EasyVista side**, so the - client cannot know them statically; a missing one is rejected server-side and - surfaces as :class:`EasyvistaValidationError` (HTTP 590, code 2013), not a - retried server error. ``custom_fields`` values are serialized with an ``e_`` + **Vendor-required is one field: ``catalog_guid`` or ``catalog_code``** (tier + 1). Everything else the vendor lists is optional. + + That is not the whole story, and the gap between it and practice is + expensive. Measured on one instance (tier 4, 2026-08-18): the full + seven-field body -- catalog, origin, title, description, department_id, + urgency_id, impact_id -- creates successfully on every catalog tried, while + the same body minus the four ids creates on some catalogs and is rejected on + others. Two catalogs of one instance, identical remaining bytes, one + accepted and one refused. + + The rejection is HTTP 590 / code 2013 carrying a **SQL parser error** + (``=(1,35) expected token:( * + - . IDENTIFIER CASE NOT JOIN ...``) that + names no field at all. It reads like a server defect. It is not: it is what + an under-specified create body looks like here. + + Which fields a catalog can do without is configured per catalog on the + EasyVista side, so no client can know it statically. Sending the fuller body + is therefore the safer default in practice -- but it is a hedge against a + per-catalog configuration, not an API requirement, and an instance that + needs less is not misbehaving. + + Ids may be sent as JSON numbers or as strings; both are accepted (tier 4: + measured on one instance, 2026-08-25). The documented examples quote them. + + What follows is **this model's** accept-and-serialize rule for each id, not + a claim about what the API will take -- per the finding just above, it + takes either form. The declared types follow the vendor's documented column + types (tier 1, ``docs/vendor-api-reference.md``), so a caller holding a + value in the documented form never has to convert it: + + * ``urgency_id`` and ``severity_id`` are typed ``int``: the vendor + documents both as **integer** (tier 1) and nothing measured contradicts + that, so a quoted value is coerced to a number on the way out. + * ``origin`` is ``int | str``. The vendor documents it as a **string** + (tier 1); an int was separately measured accepted on one instance (tier + 4: measured on one instance; date not recorded). Whichever type is + passed serializes unchanged, with no coercion between them. + * ``department_id``, ``location_id`` and ``recipient_id`` are ``int | str`` + for one shared reason: the vendor documents all three as **strings** + (tier 1). An int is what the measured seven-field create body above sent + for ``department_id`` (tier 4, 2026-08-18); nothing independently + confirms a string in any of the three, nor an int in ``location_id`` or + ``recipient_id``, so the wider type is a precaution rather than a tier-4 + finding. Whichever type is passed serializes unchanged. + * ``impact_id`` is ``int | str`` and is the one exception to "serializes + unchanged". The vendor documents it as an **integer** (tier 1), so a + numeric string is coerced into that documented form -- ``"28"`` ships as + ``28`` -- via ``union_mode="left_to_right"``. The ``str`` branch stays so + that a caller with a non-numeric value is not rejected; such a value + passes through as written. + + **A rejected create may still have created the ticket.** Measured: 12 + attempts returned 3 ``RFC_NUMBER``s and afterwards all 12 tickets existed -- + 9 of 9 failures had written a row, with the ids they were missing left null + (tier 4: measured on one instance, 2026-08-25). So a 590 here means + *possibly created*, never *not created*: retrying duplicates, and the + caller never learns the id. Reconcile by ``external_reference`` rather than + trusting the error. + + The recipient and requestor families each offer several ways to name the + same thing, and the vendor documents a priority order within each -- + ``recipient`` id, then identification, then mail, then name; ``requestor`` + has no id variant here, so identification, then mail, then name. The + department and location families are narrower: the vendor gives each only + an id-or-code choice (``department_id``/``department_code``, + ``location_id``/``location_code``), with no identification, mail or name + variant. Tier 1 for all of these fields: they are vendor-documented and are + **not** verified live by this package's test suite, so a deployment may + reject one the vendor lists. + + ``submit_date`` is a string whose accepted format follows the employee's + location settings, so no ``datetime`` is accepted here -- this package has + never established a write format for an EasyVista date (both a string and + an int probe returned HTTP 590; tier 4: measured on one instance, + 2026-07-16), which is why no write model carries one. + + ``custom_fields`` values are serialized with an ``e_`` prefix unless they already start with ``e_`` (see :class:`EasyvistaWriteModel`). - ``catalog_code`` is the only verified way to name a catalog here. An earlier - ``catalog_guid`` field was removed: it is absent from the documented create - body, and it cannot be verified on a profile where ``GET /catalog-requests`` - returns 403 (no way to obtain a real catalog GUID). + ``catalog_guid`` and ``catalog_code`` both name the ticket's subject, and one + of them is required -- tier 1, and the vendor documents the **guid** as the + preferred form. An earlier version of this model dropped ``catalog_guid`` + entirely on the grounds that it was "absent from the documented create + body"; the document consulted was a customer handover note rather than the + vendor specification, and the vendor documents it plainly. Note that + ``GET /catalog-requests`` is 403 on a restricted profile, so an instance may + give you no way to *read* a catalog GUID even though the create accepts one. ``description`` supplied at create time was **not** readable back through - either Memo on the verified instance -- neither ``DESCRIPTION`` nor - ``COMMENT``. To set body text you can read again, follow the create with + either Memo on the verified instance (tier 4: measured on one instance, + 2026-07-28) -- neither ``DESCRIPTION`` nor ``COMMENT``. To set body text + 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. """ + catalog_guid: str | None = None catalog_code: str | None = None + # Asset and configuration-item selectors, each family in the vendor's + # documented priority order (tier 1, docs/vendor-api-reference.md). + # + # The wire spellings have NO underscore -- ``assetid``, ``assettag``. + # ``to_api()`` serializes by ATTRIBUTE NAME (``model_dump()`` without + # ``by_alias``), so an alias would never reach the body and renaming these + # to ``asset_id``/``asset_tag`` would ship a key the vendor does not + # document. The documented case-insensitivity is not underscore- + # insensitivity. + # + # The vendor types all six as **string**. ``assetid`` and ``ci_id`` are + # widened to ``int | str`` for the same reason ``recipient_id`` is: a caller + # holding ``Asset.asset_id`` holds an int. Whichever type is passed + # serializes unchanged, with no coercion between them. Tier 1, and NOT + # verified live by this package's suite -- a deployment may reject one the + # vendor lists. + assetid: int | str | None = None + assettag: str | None = None + asset_name: str | None = None + ci_id: int | str | None = None + ci_asset_tag: str | None = None + ci_name: str | None = None title: str | None = None description: str | None = None - origin: int | None = None - department_id: int | None = None + origin: int | str | None = None + department_id: int | str | None = None + department_code: str | None = None + location_id: int | str | None = None + location_code: str | None = None urgency_id: int | None = None - impact_id: int | None = None + impact_id: int | str | None = Field(default=None, union_mode="left_to_right") severity_id: int | None = None - recipient_id: int | None = None + recipient_id: int | str | None = None recipient_mail: str | None = None + recipient_name: str | None = None + recipient_identification: str | None = None + requestor_identification: str | None = None + requestor_mail: str | None = None + requestor_name: str | None = None + parentrequest: str | None = None + phone: str | None = None + submit_date: str | None = None + workflow_start: bool | None = None external_reference: str | None = None + @model_validator(mode="after") + def _require_a_catalog_identifier(self) -> PostRequest: + """Refuse a create body with no subject. + + Tier 1: the vendor documents ``catalog_guid`` OR ``catalog_code`` as the + only required part of a create body. Sent without either, the server + answers HTTP 590 with a SQL parser error naming no field at all, which + is easy to misread as a server defect -- so this is refused here, where + the message can say what is missing. + + The check reads the body ``to_api()`` will actually send, not the + attributes declared on this model, so + ``extra_payload={"CATALOG_GUID": ...}`` satisfies it. That is not a + courtesy: ``extra_payload`` is the documented route past a declared + field, and a guard that could not see it would refuse bodies the API + accepts. Deriving from ``to_api()`` also means the check cannot drift + from that method's documented case-insensitive merge rule. + """ + if not _shipped_keys(self) & {"catalog_guid", "catalog_code"}: + raise ValueError( + "a create body needs a subject: pass catalog_guid (preferred) " + "or catalog_code, on the model or through extra_payload" + ) + return self + class RequestUpdate(EasyvistaWriteModel): """Payload for updating a ticket via PUT. - ``docs/API_Info.md`` documents only the create, comment and close bodies, so - the update body is not vendor-documented. Every field here is one verified - accepted against a live instance -- ``title`` by the Phase 0 probe and by - ``integration_tests/test_live_ticket_identity.py``. Nothing is added - speculatively: an unaccepted field would silently no-op or raise HTTP 590. + The vendor documents only the create, comment and close bodies + (``docs/vendor-api-reference.md``), so the update body is not + vendor-documented. Every field here is one verified accepted against a + live instance **by re-reading the ticket afterwards**, not by trusting + HTTP 200 (tier 4: measured on one instance across several sessions; date + not recorded) — that distinction matters on this API, where a write can + return 200 and change nothing. ``description`` writes the ticket's **COMMENT** Memo, not ``DESCRIPTION`` -- - verified live. EasyVista models ``COMMENT`` as the request's justification - and ``DESCRIPTION`` as a separate Memo; which one a deployment actually - populates is a per-instance configuration choice. On the instance this - client was verified against, ``DESCRIPTION`` is empty on every ticket and - ``COMMENT`` carries the body text. Read it back with - ``resolve_memo("requests/{rfc}/comment")``, or take - ``TicketContext.comment``, which resolves it for you. + verified live by reading the text back, and pinned by + ``integration_tests/test_live_ticket_metadata.py``. + + EasyVista models ``COMMENT`` as the request's justification and + ``DESCRIPTION`` as a separate Memo. Which one a deployment populates is a + per-instance configuration choice, and it is **not** reliably detectable at + runtime. A pooled 77-row sample of one instance across four different + orderings found ``COMMENT`` populated on 57 rows, ``DESCRIPTION`` on 27, + *both* on 24 and neither on 17 -- and the proportions flipped depending on + which slice was sampled, so a majority vote over a sample answers whichever + way the sort happened to fall (measured 2026-08-18). An earlier 15-ticket + sample that found ``DESCRIPTION`` empty everywhere was not representative; + do not rely on that being true of any instance. Treat the body memo as + operator configuration, not as something to infer. + + Read ``COMMENT`` back with ``resolve_memo("requests/{rfc}/comment")``, or + take ``TicketContext.comment``, which resolves it for you. + + **Deliberately absent** (verified 2026-08-17): + + * ``status_id`` — there is **no flat status update on this API**. It was a + field here until 2026-08-25 and it never worked: sent alone the PUT is + rejected 590/2013, and sent beside any other field the PUT returns **200, + applies the other field, and drops the status in silence** (measured: + title updated, ``STATUS_ID`` unchanged). A write that reports success and + stores nothing is worse than one that fails, so the field is gone and + ``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``. + * ``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 + changed*, so the API's behaviour there is not one this model can express + honestly. Tier 4, measured 2026-08-17, and with counter-evidence: the + instance's own OpenAPI declares ``Urgency_ID`` on + ``PUT /requests/{rfc_number}`` as a **string**, while the probe that + measured the 590 sent an **int**. The exclusion may therefore be a type + mismatch of our own making rather than an API limit -- unresolved, + tracked as O-URG in ``docs/vendor-api-reference.md``. Note the + counter-evidence is tier 3 (a spec *schema*, which on this API is + example-derived and not normative), so it is a reason to test, not a + reason to assume. + + Either way this is an **update**-path finding only: on the CREATE path + ``urgency_id`` is vendor-documented and lands cleanly (see + :class:`PostRequest`). + * a priority field — EasyVista derives priority from urgency x impact rather + than exposing a writable column. + + 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. + ``extra_payload`` does **not** help with priority: there is no writable + column for it to reach. + + ``external_reference`` is capped at 50 characters: 50 accepted, 51 rejected + server-side (tier 4 -- bisected live on one instance, 2026-08-17). The cap + is enforced here so the round trip is saved; over-length is rejected rather + than truncated either way. A deployment with a wider column can bypass the + cap with ``extra_payload``. """ - status_id: int | None = None title: str | None = None description: str | None = None + impact_id: int | None = None + owner_id: int | None = None + external_reference: str | None = Field(default=None, max_length=50) diff --git a/easyvista_python_client/models/tests/test_action.py b/easyvista_python_client/models/tests/test_action.py index 767c32f..d38a4d8 100644 --- a/easyvista_python_client/models/tests/test_action.py +++ b/easyvista_python_client/models/tests/test_action.py @@ -1,4 +1,135 @@ -from easyvista_python_client.models.action import Action, PostAction +from datetime import datetime, timedelta, timezone + +import pytest +from pydantic import ValidationError + +from easyvista_python_client.models.action import Action, PostAction, PostTask + +_CEST = timezone(timedelta(hours=2)) + +# Trimmed from a real item-level GET (see the spec's Appendix A-2); values are +# synthetic, the KEY NAMES are what this test pins. +_ITEM_PAYLOAD = { + "ACTION_ID": "57483", + "ACTION_NUMBER": "0", + "ACTION_TYPE_ID": "20", + "CREATION_DATE_UT": "2026-08-17T15:40:36.000+02:00", + "LAST_UPDATE": "2026-08-17T15:40:37.653+02:00", + "DONE_BY_ID": "6117", + "GROUP_ID": "57", + "REQUEST_ID": "7743", + "STAGE_ID": "10", + "WORKFLOW_ID": "37", + "PARENT_ACTION_ID": "", + "DONE_BY": {"EMPLOYEE_ID": "6117", "LAST_NAME": "Doe"}, + "DESCRIPTION": {"HREF": "https://ev.test/api/v1/12345/actions/57483/description"}, +} + + +def test_item_level_action_exposes_timestamps_and_author(): + """EV-R1: the fields a Comment model needs all exist on the item GET.""" + action = Action.model_validate(_ITEM_PAYLOAD) + assert action.created_at == datetime(2026, 8, 17, 15, 40, 36, tzinfo=_CEST) + assert action.updated_at == datetime(2026, 8, 17, 15, 40, 37, 653000, tzinfo=_CEST) + assert action.done_by_id == 6117 + assert action.action_type_id == 20 + assert action.group_id == 57 + assert action.request_id == 7743 + + +def test_workflow_context_is_declared_so_generated_actions_are_identifiable(): + """A fresh ticket auto-spawns ~12 workflow actions; these tell them apart.""" + action = Action.model_validate(_ITEM_PAYLOAD) + assert action.stage_id == 10 + assert action.workflow_id == 37 + assert action.parent_action_id is None # "" sentinel -> None + + +@pytest.mark.parametrize( + ("alias", "attr"), + [ + ("DONE_BY_ID", "done_by_id"), + ("ACTION_TYPE_ID", "action_type_id"), + ("GROUP_ID", "group_id"), + ("REQUEST_ID", "request_id"), + ("ACTION_NUMBER", "action_number"), + ("STAGE_ID", "stage_id"), + ("WORKFLOW_ID", "workflow_id"), + ("PARENT_ACTION_ID", "parent_action_id"), + ], +) +def test_the_empty_string_sentinel_maps_to_none_on_every_new_int_field(alias, attr): + """Workflow-generated actions have an EMPTY DONE_BY_ID (measured live).""" + action = Action.model_validate({"ACTION_ID": "1", alias: ""}) + assert getattr(action, attr) is None + + +def test_absent_timestamps_are_none_not_an_error(): + """The list projection omits both date fields entirely.""" + action = Action.model_validate({"ACTION_ID": "1"}) + assert action.created_at is None + assert action.updated_at is None + + +def test_action_label_is_declared_not_left_in_model_extra(): + """It rides the default list projection, and ``context.py`` reads it.""" + action = Action.model_validate( + {"ACTION_ID": "1", "ACTION_LABEL_FR": "Analyse de Resolution"} + ) + assert action.action_label_fr == "Analyse de Resolution" + + +def test_a_whole_bracketed_label_echoing_another_language_is_a_placeholder(): + """Brackets around the WHOLE label, echoing another column, mean "untranslated". + + A single-language instance echoes the default-language text wrapped in + ``[...]`` on every other language column; ``localized_label`` discards them. + See the sibling test below for the bracket convention that DOES carry + meaning -- conflating the two once cost this package a true finding. + """ + from easyvista_python_client.references import localized_label + + item = { + "ACTION_ID": "1", + "ACTION_LABEL_FR": "Analyse de Resolution", + "ACTION_LABEL_EN": "[Analyse de Resolution]", + } + assert Action.model_validate(item).action_label_fr == "Analyse de Resolution" + assert localized_label(item, "ACTION_LABEL") == "Analyse de Resolution" + + +def test_a_bracketed_suffix_beside_real_translations_is_a_visibility_marker(): + """``Commentaire [Public]`` is a real marker, not a placeholder. + + Measured live 2026-08-28: type 94's sibling columns carry genuine + translations (``Customer Comment``, ``Kommentar des Kunden``), so the + French label's ``[Public]`` suffix is content -- the opposite of the + placeholder above, where the whole label is bracketed and duplicates + another language. + + ``_usable_label`` already draws this line correctly: it rejects only a + label that is *entirely* bracketed, so a bracketed SUFFIX survives. The + code was right; the prose that called every bracket a placeholder was not. + """ + from easyvista_python_client.references import localized_label + + item = { + "ACTION_ID": "1", + "ACTION_LABEL_FR": "Commentaire [Public]", + "ACTION_LABEL_EN": "Customer Comment", + } + assert Action.model_validate(item).action_label_fr == "Commentaire [Public]" + # _EN is preferred when populated, so the English translation wins here. + assert localized_label(item, "ACTION_LABEL") == "Customer Comment" + # The point: with only the French column, the marker is KEPT, not discarded + # the way a fully-bracketed placeholder would be. + fr_only = {"ACTION_ID": "1", "ACTION_LABEL_FR": "Note Interne [Prive]"} + assert localized_label(fr_only, "ACTION_LABEL") == "Note Interne [Prive]" + + +def test_done_by_reference_resolves_through_the_shared_resolver(): + action = Action.model_validate(_ITEM_PAYLOAD) + assert action.reference("DONE_BY").id == "6117" def test_action_reads_the_item_level_description_memo(): @@ -93,3 +224,187 @@ def test_post_action_serializes_description(): "group_id": 3, "description": "hi", } + + +def test_post_action_carries_both_text_channels(): + """An action has two independent memos and a create can populate both. + + Verified live 2026-08-28: a single create carrying ``description`` and + ``comment`` read back with exactly the text sent in each, addressable + separately at ``actions/{id}/description`` and ``actions/{id}/comment``. + ``comment`` was absent from this model until then, which made the second + channel unreachable at create time without ``extra_payload``. + """ + assert PostAction( + action_type_id=94, group_id=3, description="public", comment="internal" + ).to_api() == { + "action_type_id": 94, + "group_id": 3, + "description": "public", + "comment": "internal", + } + + +def test_post_action_omits_an_unset_comment(): + """The new field must not widen the body every caller already sends.""" + assert ( + "comment" + not in PostAction(action_type_id=94, group_id=3, description="hi").to_api() + ) + + +def test_post_task_serializes_flat_for_the_tasks_endpoint(): + """The task body is FLAT at the root; the action body is wrapped. + + Verified live 2026-08-28 -- POST requests/{rfc}/tasks with this shape + returned 201 and a record already carrying END_DATE_UT. + """ + assert PostTask( + action_type_id=95, group_id=3, description="internal note" + ).to_api() == { + "action_type_id": 95, + "group_id": 3, + "description": "internal note", + } + + +def test_post_task_refuses_a_body_with_no_action_type(): + """The type is mandatory AND carries the public/internal distinction.""" + with pytest.raises(ValidationError, match="needs an action type"): + PostTask(group_id=3, description="orphan") + + +def test_post_task_refuses_a_body_with_no_group(): + """Omitting the group draws a 590 naming a field the caller never sent.""" + with pytest.raises(ValidationError, match="needs an assigned group"): + PostTask(action_type_id=94, description="orphan") + + +def test_post_task_accepts_any_of_the_three_group_spellings(): + """group_id / group_name / group_mail are documented alternatives. + + The instance OpenAPI's example shows only group_mail, which is what led an + earlier pass to believe a 403 on GET /groups made this endpoint unusable. + """ + for kwargs in ({"group_id": 3}, {"group_name": "N1"}, {"group_mail": "a@b.fr"}): + assert PostTask(action_type_id=94, **kwargs).to_api()["action_type_id"] == 94 + + +def test_post_task_omits_unset_optional_fields(): + """An unset elapsed_time is computed by EasyVista, not sent as null.""" + body = PostTask(action_type_id=94, group_id=3).to_api() + for absent in ("elapsed_time", "time_cost", "end_date_ut", "comment"): + assert absent not in body + + +def test_action_label_property_returns_the_real_text_on_an_english_instance(): + # Why the property exists. On a single-language instance the OTHER language + # columns echo the primary text wrapped in brackets, so reading the one + # named column yields the placeholder rather than None -- asserted on both + # attributes so the difference is visible. + action = Action.model_validate( + {"ACTION_LABEL_EN": "Customer Comment", "ACTION_LABEL_FR": "[Customer Comment]"} + ) + assert action.action_label_fr == "[Customer Comment]" + assert action.label == "Customer Comment" + + +def test_action_label_keeps_a_bracketed_suffix_which_is_real_content(): + # A label wrapped ENTIRELY in brackets is an untranslated placeholder; a + # bracketed SUFFIX on otherwise distinct text is a genuine marker and must + # survive. Conflating the two once deleted a true finding. + action = Action.model_validate({"ACTION_LABEL_FR": "Commentaire [Public]"}) + assert action.label == "Commentaire [Public]" + + +def test_action_label_on_a_non_french_instance_skips_the_bracketed_echo(): + """The exact behaviour the guide's `label` prose claims. + + On an English deployment ``action_label_fr`` is not ``None`` -- it holds + the default-language text wrapped in brackets, ``'[Customer Comment]'`` -- + so reading that column directly gives bracket noise rather than an absence, + which is the failure that is easy to miss. + """ + action = Action.model_validate( + { + "ACTION_ID": 1, + "ACTION_LABEL_FR": "[Customer Comment]", + "ACTION_LABEL_EN": "Customer Comment", + } + ) + assert action.label == "Customer Comment" + assert action.action_label_fr == "[Customer Comment]" # the trap + + +def test_action_label_is_none_when_no_label_column_is_populated(): + assert Action.model_validate({"ACTION_ID": 1}).label is None + # And in the pathological case where every column is a placeholder: the + # caller supplies its own last-resort text. + assert Action.model_validate({"ACTION_LABEL_FR": "[X]"}).label is None + + +# --- PostAction gains PostTask's guard, on the same tier-1 sentence ---------- +# +# `docs/vendor-api-reference.md` quotes the vendor's create-an-ACTION page as +# "Required: action_type_id, and one of group_id / group_mail / group_name" -- +# so this is if anything better evidence here than on the task route. Before +# this, `PostAction()` constructed fine and shipped `{"action": {}}`, drawing an +# HTTP 590 that named no field at all. + + +def test_post_action_requires_a_type_and_a_group(): + with pytest.raises(ValidationError, match="needs an action type"): + PostAction(group_id=3, description="orphan") + with pytest.raises(ValidationError, match="needs an assigned group"): + PostAction(action_type_id=94, description="orphan") + + +def test_post_action_accepts_a_string_type_id_like_post_task_does(): + """The two models' id types diverged for no recorded reason. + + ``int | None`` here against ``int | str | None`` on ``PostTask`` made a + non-numeric type or group id work through ``create_task`` and fail through + ``create_action`` -- an inconsistency inside the package with no evidence + behind it. The instance's own OpenAPI declares ``action_type_id`` on this + route as a *string* (tier 3, illustrative), which argues for accepting one, + not for coercing to one: whichever type is passed serializes unchanged. + """ + body = PostAction(action_type_id="94", group_id="GRP-1").to_api() + assert body["action_type_id"] == "94" + assert body["group_id"] == "GRP-1" + + +def test_post_action_ships_group_mail_and_parent_action_id_and_guid(): + """Three tier-1 optional fields the model did not declare.""" + body = PostAction( + action_type_guid="{TYPE}", group_mail="n1@example.invalid", parent_action_id=7 + ).to_api() + assert body["action_type_guid"] == "{TYPE}" + assert body["group_mail"] == "n1@example.invalid" + assert body["parent_action_id"] == 7 + + +def test_extra_payload_satisfies_the_action_guards(): + """A guard reading declared attributes would refuse a body the API accepts.""" + payload = PostAction( + extra_payload={"action_type_id": 94, "group_mail": "n1@example.invalid"} + ) + assert payload.to_api() == { + "action_type_id": 94, + "group_mail": "n1@example.invalid", + } + + +def test_an_extra_payload_action_type_guid_satisfies_the_task_guard(): + """``PostTask`` deliberately does NOT declare ``action_type_guid``. + + On the action route the field is tier 1 (2023.4+) and the instance's own + OpenAPI declares it; on the TASK route neither holds -- the vendor's task + page has never been transcribed into this repo (O-TASKDOC) and the + instance's schema for that route lists eleven properties without it. + Declaring it would put the model's word behind a field nothing supports + there. So the guard accepts the key without the model asserting the field + exists, and ``extra_payload`` is how it arrives. + """ + body = PostTask(group_id=3, extra_payload={"action_type_guid": "{TYPE}"}).to_api() + assert body["action_type_guid"] == "{TYPE}" diff --git a/easyvista_python_client/models/tests/test_asset.py b/easyvista_python_client/models/tests/test_asset.py index de1fc44..decc572 100644 --- a/easyvista_python_client/models/tests/test_asset.py +++ b/easyvista_python_client/models/tests/test_asset.py @@ -10,6 +10,27 @@ def test_asset_reads_aliased_fields(): assert asset.href.endswith("/assets/9504") +def test_asset_accepts_the_empty_string_sentinel_on_every_id(): + """A CMDB row with no status is ordinary data, and used to fail a whole page. + + ``Asset`` was the only read model on a bare ``int | None`` while every other + used ``OptionalInt``. EasyVista sends ``""`` for a numeric column carrying + no value, so such a row raised -- and because ``build_search``'s parser + validates a page in one list comprehension, it failed every OTHER asset on + the page with it. Worse, ``get_department_context``'s assets branch catches + only ``EasyvistaAuthError``/``EasyvistaNotFound``, so the ``ValidationError`` + escaped and took the whole bundle down. + """ + asset = Asset.model_validate({"ASSET_ID": "", "STATUS_ID": ""}) + assert asset.asset_id is None + assert asset.status_id is None + + +def test_asset_accepts_a_numeric_string_id(): + """``OptionalInt`` coerces the string form the API sometimes sends.""" + assert Asset.model_validate({"ASSET_ID": "9504"}).asset_id == 9504 + + def test_post_asset_to_api_uses_known_fields(): payload = PostAsset(catalog_id=3153, asset_tag="ZGCSS_732", status_id=1) assert payload.to_api() == { @@ -19,6 +40,20 @@ def test_post_asset_to_api_uses_known_fields(): } +def test_post_asset_coerces_a_quoted_catalog_id(): + """``union_mode="left_to_right"`` tries ``int`` first, so a numeric string + becomes the number the instance's own create example shows.""" + assert PostAsset(catalog_id="3153").to_api()["catalog_id"] == 3153 + + +def test_post_asset_passes_a_non_numeric_status_through(): + """The point of the ``str`` branch: a value this package cannot + vendor-document is sent as written rather than refused.""" + assert PostAsset(catalog_id=1, status_id="IN_STOCK").to_api()["status_id"] == ( + "IN_STOCK" + ) + + def test_post_asset_custom_fields_get_e_prefix(): payload = PostAsset(catalog_id=1, custom_fields={"last_date_update": "12/09/2025"}) body = payload.to_api() diff --git a/easyvista_python_client/models/tests/test_common.py b/easyvista_python_client/models/tests/test_common.py index 925e6dc..413da6d 100644 --- a/easyvista_python_client/models/tests/test_common.py +++ b/easyvista_python_client/models/tests/test_common.py @@ -1,11 +1,17 @@ +from datetime import datetime, timedelta, timezone + +import pydantic +import pytest + from easyvista_python_client import FieldClassification from easyvista_python_client.models.common import ( EasyvistaModel, EasyvistaWriteModel, + OptionalDateTime, OptionalInt, _empty_str_to_none, ) -from easyvista_python_client.models.request import Request +from easyvista_python_client.models.request import PostRequest, Request def test_classify_fields_declared_alias_is_official_custom_is_custom(): @@ -64,3 +70,240 @@ def test_base_model_keeps_unknown_fields(): dumped = model.model_dump(by_alias=True) assert dumped["RFC_NUMBER"] == "I123" assert dumped["e_custom1"] == "x" + + +class _Probe(EasyvistaModel): + when: OptionalDateTime = None + + +def test_parses_the_live_easyvista_format(): + got = _Probe.model_validate({"when": "2026-08-17T15:40:41.610+02:00"}).when + assert got == datetime( + 2026, 8, 17, 15, 40, 41, 610000, tzinfo=timezone(timedelta(hours=2)) + ) + + +def test_the_empty_string_sentinel_becomes_none(): + """EasyVista returns "" for every unset date — not null. Measured live.""" + assert _Probe.model_validate({"when": ""}).when is None + assert _Probe.model_validate({"when": " "}).when is None + + +def test_a_missing_key_is_none(): + assert _Probe.model_validate({}).when is None + + +def test_an_explicit_none_is_none_not_an_error(): + """A JSON ``null`` is an ordinary absence on a ``datetime | None`` column. + + Regression guard. When the validator was tightened so a malformed value + raises instead of becoming a bogus epoch instant, it began raising on + ``None`` too -- so a wire payload carrying ``"LAST_UPDATE": null``, or a + caller passing the field's own default explicitly, failed validation. The + tightening is about *junk*; an absence is not junk, and this column's own + type says ``None`` is legal. + """ + assert _Probe.model_validate({"when": None}).when is None + assert _Probe(when=None).when is None + + +def test_an_unparseable_timestamp_raises_rather_than_silently_becoming_none(): + """A malformed date is a real signal; swallowing it would hide a format change. + + Contrast the "" sentinel above, which is EasyVista's documented way of + saying "unset" and is therefore not an error. + """ + with pytest.raises(pydantic.ValidationError) as exc_info: + _Probe.model_validate({"when": "not-a-date"}) + # _empty_str_to_none_datetime raises ValueError, which pydantic wraps into a + # ValidationError naming the field -- confirm it actually does, not just + # that *some* error was raised. + assert exc_info.value.errors()[0]["loc"] == ("when",) + + +@pytest.mark.parametrize( + "junk", + [ + # ISO-basic, no separators. Pydantic's own parser reads this as epoch + # seconds -> 1970-08-23T12:00:17Z, an instant 56 years off, SILENTLY. + "20260817", + # What an epoch-millis format change would look like on the wire. + # Pydantic reads it as 2025-08-17T12:40:41.610Z -- entirely plausible, + # which is exactly why absorbing it would defeat this guard. + 1755434441610, + "1755434441610", + # A plausible alternative "unset" sentinel; pydantic reads it as the + # epoch rather than reporting that EasyVista's sentinel is "". + 0, + "0", + ], +) +def test_a_numeric_shaped_value_raises_instead_of_becoming_an_epoch_instant(junk): + """The guard must not fall through to pydantic's much broader parser. + + Every value here is one pydantic accepts with a credible-looking result, so + a fallthrough would turn the one signal this guard exists to raise -- a + change in EasyVista's timestamp format -- into wrong data with no error. + """ + with pytest.raises(pydantic.ValidationError) as exc_info: + _Probe.model_validate({"when": junk}) + assert exc_info.value.errors()[0]["loc"] == ("when",) + + +def test_a_naive_datetime_input_comes_back_aware(): + """A datetime handed in directly (not a wire string) must still end up + 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) + + +# --- the caller's own timestamp formats, opt-in and empty by default --------- +# +# The raise-rather-than-guess default above stays exactly as it is; the tests in +# this block are about the escape hatch beside it, for a deployment whose +# timestamps are genuinely a different format. Nothing here softens the guard -- +# an unlisted format still raises. + + +def test_no_context_behaves_exactly_as_before(): + """The default path is untouched: an explicit ``context=None`` still raises.""" + with pytest.raises(pydantic.ValidationError): + _Probe.model_validate({"when": "20260817"}, context=None) + + +def test_an_unlisted_format_still_raises_under_a_context(): + """Naming a format is not a licence to guess at every other one.""" + with pytest.raises(pydantic.ValidationError) as exc_info: + _Probe.model_validate( + {"when": "not-a-date"}, + context={"datetime_input_formats": ["%d/%m/%Y"]}, + ) + assert exc_info.value.errors()[0]["loc"] == ("when",) + + +def test_a_named_format_is_accepted_and_stamped_utc(): + """A pattern yielding a naive datetime is read as UTC. + + The same assumption ``parse_ev_datetime`` already documents for an + offset-less literal on the read path, so the two paths agree. + """ + got = _Probe.model_validate( + {"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) + + +def test_a_context_format_never_shadows_the_native_iso_form(): + """Order is load-bearing: ``parse_ev_datetime`` runs FIRST. + + ``"%Y%m%d"`` would happily consume the leading ``20260817`` of an ISO + stamp if strptime were tried first, silently discarding the time and the + offset. Because the native form is tried first, adding a pattern can never + change how a real EasyVista timestamp parses. + """ + got = _Probe.model_validate( + {"when": "2026-08-17T15:40:41.610+02:00"}, + context={"datetime_input_formats": ["%Y%m%d"]}, + ).when + assert got == datetime( + 2026, 8, 17, 15, 40, 41, 610000, tzinfo=timezone(timedelta(hours=2)) + ) + + +# --- an unknown key names itself, and names extra_payload ------------------- + + +def test_an_unknown_field_names_itself_and_extra_payload(): + """``extra="forbid"``'s own message never mentions the way through. + + Someone who has just read that a field was excluded from a model has no way + to learn from "Extra inputs are not permitted" that ``extra_payload`` + exists. The message must also stop short of promising the write works: on + this API an exclusion is usually a measured misbehaviour, and a 200 is not + a receipt. + """ + with pytest.raises(pydantic.ValidationError) as exc_info: + PostRequest(catalog_code="X", ctalog_guid="typo") + message = str(exc_info.value) + assert "ctalog_guid" in message + assert "extra_payload" in message + assert "a 200 is not a receipt on this API." in message + + +def test_a_known_field_is_not_intercepted(): + """The validator returns the input untouched when nothing is unknown.""" + assert PostRequest(catalog_code="X", title="t").to_api()["title"] == "t" + + +def test_a_non_mapping_input_falls_through(): + """A non-mapping must reach pydantic's own error, not this validator's.""" + with pytest.raises(pydantic.ValidationError) as exc_info: + PostRequest.model_validate("nonsense") + assert "extra_payload" not in str(exc_info.value) + + +def test_extra_payload_serializes_verbatim_without_prefix() -> None: + """extra_payload keys reach the wire exactly as written.""" + payload = PostRequest(catalog_code="X", extra_payload={"URGENCY_ID": "4"}) + body = payload.to_api() + assert body["URGENCY_ID"] == "4" + assert "e_URGENCY_ID" not in body + + +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"}) + assert payload.to_api()["title"] == "override" + + +def test_extra_payload_overrides_custom_fields() -> None: + payload = PostRequest( + catalog_code="X", + custom_fields={"thing": "from_custom"}, + extra_payload={"e_thing": "from_extra"}, + ) + assert payload.to_api()["e_thing"] == "from_extra" + + +def test_extra_payload_defaults_empty_and_adds_nothing() -> None: + assert "extra_payload" not in PostRequest(catalog_code="X").to_api() + + +def test_extra_payload_overrides_a_declared_field_across_case() -> None: + """An ALL_CAPS override must REPLACE the declared lower-case field. + + EasyVista's field names are case-insensitive, so an exact-key merge put + both spellings on the wire with conflicting values and left the winner to + the server. The ALL_CAPS spelling is the likely one, not a corner case: + it mirrors the read side's ``ALL_CAPS`` convention, which is where callers + copy names from. + """ + body = PostRequest( + catalog_code="X", urgency_id=8, extra_payload={"URGENCY_ID": "4"} + ).to_api() + assert body["URGENCY_ID"] == "4" + assert "urgency_id" not in body + + +def test_extra_payload_overrides_custom_fields_across_case() -> None: + """The same rule covers a ``custom_fields``-produced key.""" + body = PostRequest( + catalog_code="X", + custom_fields={"thing": "from_custom"}, + extra_payload={"E_THING": "from_extra"}, + ).to_api() + assert body["E_THING"] == "from_extra" + assert "e_thing" not in body + + +def test_a_case_insensitive_collision_never_raises() -> None: + """The rule is a merge, not a validation: a collision is not an error.""" + payload = PostRequest( + catalog_code="X", title="declared", extra_payload={"TITLE": "override"} + ) + body = payload.to_api() + assert body["TITLE"] == "override" + assert body["catalog_code"] == "X" diff --git a/easyvista_python_client/models/tests/test_document.py b/easyvista_python_client/models/tests/test_document.py new file mode 100644 index 0000000..32e3b5b --- /dev/null +++ b/easyvista_python_client/models/tests/test_document.py @@ -0,0 +1,26 @@ +from easyvista_python_client.models.document import Document + + +def test_document_id_accepts_a_json_number(): + """``DOCUMENT_ID``'s type was observed on one instance, never documented. + + Declared ``str`` alone, an instance returning it as a JSON number failed + the record -- and because ``_all_documents`` validates the whole list in one + comprehension, every attachment on the ticket with it. The union coerces + neither direction, so a number stays a number. + """ + assert Document.model_validate({"DOCUMENT_ID": 42}).document_id == 42 + + +def test_document_id_keeps_a_string_as_a_string(): + """``union_mode="left_to_right"`` tries ``str`` first; pydantic's ``str`` + does not absorb an int, so both forms survive as sent.""" + assert Document.model_validate({"DOCUMENT_ID": "42"}).document_id == "42" + + +def test_filename_falls_back_across_the_list_and_item_shapes(): + """The live list exposes the name as ``DOCUMENT``; other shapes use + ``FILE_NAME`` or ``NAME``.""" + assert Document.model_validate({"DOCUMENT": "report.pdf"}).filename == "report.pdf" + assert Document.model_validate({"NAME": "report.pdf"}).filename == "report.pdf" + assert Document.model_validate({"FILE_NAME": "kept.pdf"}).filename == "kept.pdf" diff --git a/easyvista_python_client/models/tests/test_employee.py b/easyvista_python_client/models/tests/test_employee.py index 3b376c6..f789c92 100644 --- a/easyvista_python_client/models/tests/test_employee.py +++ b/easyvista_python_client/models/tests/test_employee.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta, timezone + from easyvista_python_client.models.employee import ( Employee, EmployeeUpdate, @@ -65,3 +67,18 @@ def test_post_employee_to_api(): def test_employee_update_is_write_model(): assert EmployeeUpdate(phone_number="0102").to_api() == {"phone_number": "0102"} + + +def test_employee_last_update_parses_to_an_aware_datetime(): + """BREAKING as of 2026-08-17: last_update was str. EV-R7 proved the format. + + One of the seven fields the retype covers -- this is the only one of the + seven that had no failing pre-existing assertion to fix, so it was + otherwise only exercised transitively (never directly asserted). + """ + emp = Employee.model_validate( + {"EMPLOYEE_ID": 1, "LAST_UPDATE": "2026-08-17T15:40:41.610+02:00"} + ) + assert emp.last_update == datetime( + 2026, 8, 17, 15, 40, 41, 610000, tzinfo=timezone(timedelta(hours=2)) + ) diff --git a/easyvista_python_client/models/tests/test_generic.py b/easyvista_python_client/models/tests/test_generic.py new file mode 100644 index 0000000..56d859b --- /dev/null +++ b/easyvista_python_client/models/tests/test_generic.py @@ -0,0 +1,36 @@ +from easyvista_python_client.models.generic import GenericRecord + + +def test_generic_record_keeps_every_unknown_column(): + """It declares nothing, so nothing can be dropped for being unrecognised. + + That is the point: a reference table's response schema in an instance's + OpenAPI is tier 3, and the verified instance's own ``/status`` schema is + visibly wrong (it describes an SLA-shaped object with no status id at all). + A column list written from those schemas would be a guess frozen into the + public API. + """ + row = {"STATUS_ID": 8, "STATUS_FR": "Cloture", "E_WHATEVER": "x", "HREF": "h"} + record = GenericRecord.model_validate(row) + assert record.model_dump(by_alias=True) == row + + +def test_generic_record_resolves_a_nested_reference(): + record = GenericRecord.model_validate( + {"STATUS": {"STATUS_ID": 8, "STATUS_EN": "Closed"}} + ) + assert record.reference("STATUS").label == "Closed" + assert record.reference("STATUS").id == "8" + + +def test_classify_fields_puts_every_e_column_in_custom(): + """A documented consequence of declaring nothing, not an oversight. + + ``classify_fields`` partitions against the model's DECLARED aliases, and + this model declares none -- so an official ``E_MAIL`` lands in ``custom`` + beside a genuinely custom column. That is the right trade for a model that + knows nothing about its table, and it is pinned here so nobody "fixes" it + by declaring a tier-3 column list. + """ + record = GenericRecord.model_validate({"E_MAIL": "a@example.com", "NAME": "n"}) + assert "E_MAIL" in record.classify_fields().custom diff --git a/easyvista_python_client/models/tests/test_request.py b/easyvista_python_client/models/tests/test_request.py index 72fc187..38fffef 100644 --- a/easyvista_python_client/models/tests/test_request.py +++ b/easyvista_python_client/models/tests/test_request.py @@ -1,6 +1,10 @@ +from datetime import datetime, timedelta, timezone + +import pydantic import pytest from pydantic import ValidationError +from easyvista_python_client import ev_since_filter from easyvista_python_client.models.request import PostRequest, Request, RequestUpdate @@ -27,12 +31,28 @@ def test_post_request_to_api_uses_known_fields(): assert body == {"catalog_code": "CODE1", "title": "T", "description": "hi"} -def test_post_request_rejects_catalog_guid(): - """catalog_guid is not a verified create field and cannot be verified on the - probe profile (GET /catalog-requests is 403). extra="forbid" now rejects it - rather than silently sending a field the server may ignore.""" - with pytest.raises(ValidationError): - PostRequest(catalog_code="CODE1", catalog_guid="GUID1") +def test_catalog_guid_is_accepted_and_serialized() -> None: + body = PostRequest(catalog_guid="{ABC-123}").to_api() + assert body["catalog_guid"] == "{ABC-123}" + + +def test_catalog_code_alone_still_works() -> None: + body = PostRequest(catalog_code="EAZ_INC_000").to_api() + assert body["catalog_code"] == "EAZ_INC_000" + + +def test_both_catalog_identifiers_may_be_sent() -> None: + body = PostRequest(catalog_guid="{ABC-123}", catalog_code="EAZ_INC_000").to_api() + assert body["catalog_guid"] == "{ABC-123}" + assert body["catalog_code"] == "EAZ_INC_000" + + +def test_neither_catalog_identifier_is_refused_locally() -> None: + """The server answers this with a 590 naming no field; fail here instead.""" + with pytest.raises(ValidationError) as excinfo: + PostRequest(title="no subject") + assert "catalog_guid" in str(excinfo.value) + assert "catalog_code" in str(excinfo.value) def test_post_request_to_api_includes_documented_create_fields(): @@ -71,8 +91,8 @@ def test_post_request_custom_fields_get_e_prefix(): def test_request_update_to_api_omits_none(): - update = RequestUpdate(status_id=5) - assert update.to_api() == {"status_id": 5} + update = RequestUpdate(impact_id=5) + assert update.to_api() == {"impact_id": 5} def test_request_declares_title_and_core_scalars(): @@ -114,8 +134,9 @@ def test_request_declares_title_and_core_scalars(): assert req.recipient_id == 13 assert req.owner_id == 14 assert req.external_reference == "REF-1" - assert req.submit_date_ut == "2026-01-01 09:00:00" - assert req.last_update == "2026-01-02 10:30:00" + # 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) def test_request_coerces_empty_string_numerics_to_none(): @@ -181,7 +202,7 @@ def test_request_update_serializes_title(): def test_request_update_omits_unset_fields(): - assert RequestUpdate(status_id=3).to_api() == {"status_id": 3} + assert RequestUpdate(impact_id=3).to_api() == {"impact_id": 3} def test_request_declares_the_official_time_fields(): @@ -198,10 +219,17 @@ def test_request_declares_the_official_time_fields(): "TIME_USED_TO_SOLVE_REQUEST": "3600", } ) - assert ticket.creation_date_ut == "2026-07-28 09:00:00" - assert ticket.max_resolution_date_ut == "2026-07-30 09:00:00" - assert ticket.expected_date_ut == "2026-07-29 09:00:00" - assert ticket.end_date_ut == "" + # 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.end_date_ut is None # "" sentinel assert ticket.sla_id == 4 assert ticket.time_used_to_solve_request == "3600" @@ -227,3 +255,315 @@ def test_gtr_custom_fields_stay_out_of_the_official_bucket(): assert set(fc.custom) == {"E_GTR_STATUS", "E_GTI_UT"} assert "SLA_ID" in fc.official assert "MAX_RESOLUTION_DATE_UT" in fc.official + + +def test_request_timestamps_are_aware_datetimes(): + """BREAKING as of 2026-08-17: these were str. EV-R7 proved the format.""" + request = Request.model_validate( + { + "RFC_NUMBER": "I240101_0001", + "LAST_UPDATE": "2026-08-17T15:40:41.610+02:00", + "CREATION_DATE_UT": "2026-08-17T15:40:36.383+02:00", + "END_DATE_UT": "", + } + ) + assert request.last_update.tzinfo is not None + assert request.last_update.utcoffset() == timedelta(hours=2) + assert request.creation_date_ut.year == 2026 + assert request.end_date_ut is None # "" sentinel + + +def test_a_request_timestamp_round_trips_into_a_change_window_filter(): + """The retype must not cost the ability to build an interval (EV-R5).""" + request = Request.model_validate( + {"RFC_NUMBER": "I240101_0001", "LAST_UPDATE": "2026-08-17T15:40:41.610+02:00"} + ) + assert ev_since_filter("LAST_UPDATE", request.last_update) == ( + "LAST_UPDATE:(2026-08-17T15:40:41.610+02:00;)" + ) + + +def test_request_update_carries_the_writable_columns(): + """Pins the emitted body SHAPE only -- the client's own lowercase key names. + + This asserts nothing about the wire. A 200 on a PUT is not a receipt on this + API: a field it cannot honour is silently dropped while the request succeeds. + The read-back that does establish these three land lives in + ``integration_tests/test_live_ticket_identity.py`` + ``::test_request_update_writes_impact_owner_and_external_reference``. + """ + body = RequestUpdate( + title="t", + impact_id=1, + owner_id=42, + external_reference="PEER-abc123", + ).to_api() + assert body == { + "title": "t", + "impact_id": 1, + "owner_id": 42, + "external_reference": "PEER-abc123", + } + + +def test_request_update_still_rejects_an_unverified_field(): + """extra="forbid" is the guard that caught SEVERITY_ID before the wire.""" + with pytest.raises(pydantic.ValidationError): + RequestUpdate(severity_id=2) + + +def test_external_reference_longer_than_fifty_characters_is_refused_locally(): + """Bisected live: 50 accepted, 51 -> HTTP 590. Refuse before the round trip. + + Over-length is REJECTED server-side, not truncated, and the previously + stored value survives — so this guard loses nothing and saves a request. + """ + with pytest.raises(pydantic.ValidationError): + RequestUpdate(external_reference="X" * 51) + assert ( + RequestUpdate(external_reference="X" * 50).to_api()["external_reference"] + == "X" * 50 + ) + + +def test_request_update_refuses_status_id(): + """``RequestUpdate(status_id=...)`` must not be constructible. + + A regression guard with teeth, because the field existed and its failure was + 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. + """ + with pytest.raises(ValidationError) as excinfo: + RequestUpdate(status_id=2) + assert "status_id" in str(excinfo.value) + + +def test_post_request_carries_the_measured_safe_create_body(): + """Every field of the measured-safe create body survives ``to_api``. + + Tier 4, measured on one instance on 2026-08-18, and it may not generalise: + the seven-field body -- catalog + origin + title + description + + department_id + urgency_id + impact_id -- was accepted on every catalog + tried, while the same body minus the four ids was refused on some of them + with an HTTP 590 whose message is a bare SQL parser error naming no field. + + The vendor requires only ``catalog_guid`` or ``catalog_code`` + (``docs/vendor-api-reference.md``, tier 1); the fuller body is a hedge + against per-catalog configuration, not an API requirement. The shape is + pinned here anyway, because a field silently dropped from this model would + take that hedge away from every caller relying on it. + """ + body = PostRequest( + catalog_code="SYNTH_INC_001", + origin=7, + title="t", + description="d", + department_id=9, + urgency_id=7, + impact_id=21, + external_reference="X", + ).to_api() + assert body == { + "catalog_code": "SYNTH_INC_001", + "origin": 7, + "title": "t", + "description": "d", + "department_id": 9, + "urgency_id": 7, + "impact_id": 21, + "external_reference": "X", + } + + +def test_origin_accepts_the_vendor_documented_string() -> None: + body = PostRequest(catalog_code="X", origin="Phone").to_api() + assert body["origin"] == "Phone" + + +def test_origin_still_accepts_an_int() -> None: + body = PostRequest(catalog_code="X", origin=7).to_api() + assert body["origin"] == 7 + + +def test_origin_keeps_a_numeric_string_as_a_string() -> None: + """``origin="7"`` must reach the wire as ``"7"``, not as ``7``. + + The vendor documents ``origin`` as a **string** (tier 1), so the string + form is the documented one and there is nothing to normalize towards. This + pins the smart-union behaviour: before the field was widened to + ``int | str`` the declared ``int`` coerced ``"7"`` to ``7``, so this is the + wire form that changed, and it must not drift back. + """ + assert PostRequest(catalog_code="X", origin="7").to_api()["origin"] == "7" + + +def test_impact_id_coerces_a_numeric_string_to_the_documented_int() -> None: + """``impact_id="28"`` must reach the wire as ``28``. + + The vendor documents ``impact_id`` as an **integer** (tier 1). The ``str`` + branch exists so a caller who quotes the value is not rejected -- not so + that the quoted form ships. ``union_mode="left_to_right"`` puts ``int`` + first, which restores the normalization the declared ``int`` used to do. + """ + assert PostRequest(catalog_code="X", impact_id="28").to_api()["impact_id"] == 28 + assert PostRequest(catalog_code="X", impact_id=17).to_api()["impact_id"] == 17 + + +def test_impact_id_still_accepts_a_non_numeric_string() -> None: + """The ``str`` branch must survive the left-to-right union. + + ``left_to_right`` tries ``int`` first; a value that is not a number must + fall through to ``str`` rather than raise. Nothing a caller could send + before may be refused now. + """ + body = PostRequest(catalog_code="X", impact_id="Critical").to_api() + assert body["impact_id"] == "Critical" + + +def test_department_id_and_recipient_id_accept_the_documented_string() -> None: + """Both are vendor-documented as **strings** (tier 1), so both take one. + + They were typed ``int`` until this change, which accepted the documented + string form and then coerced it to a number on the way out. It is no + 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() + assert body["department_id"] == "9" + assert body["recipient_id"] == "42" + ints = PostRequest(catalog_code="X", department_id=9, recipient_id=42).to_api() + assert ints["department_id"] == 9 + assert ints["recipient_id"] == 42 + + +_VENDOR_CREATE_FIELDS = ( + "location_id", + "location_code", + "department_code", + "recipient_name", + "recipient_identification", + "requestor_identification", + "requestor_mail", + "requestor_name", + "parentrequest", + "phone", + "submit_date", +) + + +@pytest.mark.parametrize("name", _VENDOR_CREATE_FIELDS) +def test_vendor_documented_create_field_is_declared(name: str) -> None: + """Each is tier 1 -- documented by the vendor, not verified live here.""" + body = PostRequest(catalog_code="X", **{name: "value"}).to_api() + assert body[name] == "value" + + +def test_workflow_start_is_a_boolean() -> None: + body = PostRequest(catalog_code="X", workflow_start=True).to_api() + assert body["workflow_start"] is True + + +def test_workflow_start_false_is_sent_not_dropped() -> None: + """exclude_none drops None, not False -- a caller disabling the workflow + must not have that silently discarded.""" + body = PostRequest(catalog_code="X", workflow_start=False).to_api() + assert body["workflow_start"] is False + + +# --- the create guard reads the body that ships, not the attributes declared -- + + +def test_extra_payload_satisfies_the_catalog_guard() -> None: + """``extra_payload`` is the documented route past a declared field. + + A guard reading ``self.catalog_guid`` refused this body even though it is + exactly what the vendor documents as a complete create -- the model's own + escape hatch was unusable for the one field the model requires. Deriving + from ``to_api()`` fixes that and cannot drift from its case-insensitive + merge rule, because it *is* that rule. + """ + payload = PostRequest(extra_payload={"CATALOG_GUID": "{ABC}"}) + assert payload.to_api() == {"CATALOG_GUID": "{ABC}"} + + +def test_an_explicit_none_in_extra_payload_does_not_satisfy_the_guard() -> None: + """A ``None`` is an absent value, not a supplied one. + + ``to_api()`` already drops ``None`` for declared fields; without the + ``value is not None`` filter in ``_shipped_keys``, an explicit + ``{"catalog_code": None}`` would satisfy a guard while shipping nothing. + """ + with pytest.raises(ValidationError, match="needs a subject"): + PostRequest(extra_payload={"catalog_code": None}) + + +def test_a_custom_field_named_catalog_code_does_not_satisfy_the_guard() -> None: + """``custom_fields`` serialize with an ``e_`` prefix, so the key differs. + + ``e_catalog_code`` is not the key the API reads, and the guard correctly + still refuses -- deriving from ``to_api()`` gets this right for free. + """ + with pytest.raises(ValidationError, match="needs a subject"): + PostRequest(custom_fields={"catalog_code": "X"}) + + +# --- the asset / CI selectors ----------------------------------------------- + + +def test_post_request_ships_the_asset_and_ci_selectors() -> None: + """All six are tier 1, and the wire spellings have NO underscore. + + ``to_api()`` serializes by attribute name (``model_dump()`` without + ``by_alias``), so a ``serialization_alias`` would never reach the body and + renaming these to ``asset_id`` / ``asset_tag`` would ship keys the vendor + does not document. The documented case-insensitivity is not + underscore-insensitivity, which is what this test pins. + """ + body = PostRequest( + catalog_code="X", + assetid=9504, + assettag="ZGCSS_732", + asset_name="a laptop", + ci_id=77, + ci_asset_tag="CI-1", + ci_name="a service", + ).to_api() + assert body["assetid"] == 9504 + assert body["assettag"] == "ZGCSS_732" + assert body["asset_name"] == "a laptop" + assert body["ci_id"] == 77 + assert body["ci_asset_tag"] == "CI-1" + assert body["ci_name"] == "a service" + assert "asset_id" not in body + assert "asset_tag" not in body + + +def test_an_int_assetid_serializes_unchanged() -> None: + """The vendor types it a string; the int branch exists because a caller + holding ``Asset.asset_id`` holds an int. Neither branch coerces.""" + assert PostRequest(catalog_code="X", assetid=9504).to_api()["assetid"] == 9504 + assert PostRequest(catalog_code="X", assetid="9504").to_api()["assetid"] == "9504" + + +def test_time_used_to_solve_request_accepts_both_a_string_and_an_int() -> None: + """Tier 4 measured a string on one instance on one day; that is evidence + about the instance, not about the column. A ``str``-only declaration + rejected ``3600`` and failed the whole record -- on a search, the whole + page. Neither branch coerces into the other.""" + assert ( + Request.model_validate( + {"TIME_USED_TO_SOLVE_REQUEST": "3600"} + ).time_used_to_solve_request + == "3600" + ) + assert ( + Request.model_validate( + {"TIME_USED_TO_SOLVE_REQUEST": 3600} + ).time_used_to_solve_request + == 3600 + ) diff --git a/easyvista_python_client/pagination.py b/easyvista_python_client/pagination.py index 191daf9..6595dae 100644 --- a/easyvista_python_client/pagination.py +++ b/easyvista_python_client/pagination.py @@ -67,17 +67,39 @@ def extract_records(data: Any, envelope_key: str | None = None) -> list[dict[str envelopes, and a bare single object. ``envelope_key`` names a resource's own envelope (e.g. ``"departments"``) so a response echoed in that wrapper is unwrapped too; it is checked right after ``records`` and before the legacy - defaults. With ``envelope_key=None`` the behavior is unchanged. + defaults. + + Matching is **case-insensitive**, over this fixed candidate list only -- + never over whatever keys the payload happens to carry, which would let an + arbitrary record column win. 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`` (measured 2026-08-17, one instance, may not + generalise). Priority still runs ``records`` first, then ``envelope_key``, + then the legacy defaults, so a payload carrying several is unwrapped the + same way it was before. + + A matched list must be empty or hold at least one dict to be accepted as + the envelope. Without that check a payload whose envelope-named key holds + scalars (``{"REQUESTS": ["a", "b"]}``) returned ``[]`` -- silently, and + indistinguishably from an empty page -- rather than falling through to + ``[data]``. ``{"records": []}`` stays ``[]``: an empty page is legitimate. """ if isinstance(data, dict): keys = ["records"] if envelope_key and envelope_key not in keys: keys.append(envelope_key) keys.extend(k for k in ("requests", "assets", "documents") if k not in keys) + lowered = {str(k).lower(): v for k, v in data.items()} for key in keys: - value = data.get(key) - if isinstance(value, list): + value = lowered.get(key.lower()) + if isinstance(value, list) and any( + isinstance(r, dict) for r in value + ): return [r for r in value if isinstance(r, dict)] + if isinstance(value, list) and not value: + return [] return [data] if isinstance(data, list): return [r for r in data if isinstance(r, dict)] diff --git a/easyvista_python_client/references.py b/easyvista_python_client/references.py index ead676e..2f260da 100644 --- a/easyvista_python_client/references.py +++ b/easyvista_python_client/references.py @@ -7,14 +7,108 @@ conventions, so any field — including custom ``e_*`` fields on any instance — resolves the same way with no registry or configuration. -Leaf module: stdlib only, no model/client imports. +**This module owns label resolution for the whole package.** There is one +placeholder rule (:func:`_usable_label`) and one language order +(:data:`DEFAULT_LANGUAGE_ORDER`), both reachable by callers through a +``languages=`` keyword, so a deployment whose primary language is not English +reorders them rather than forking. + +Leaf module: only stdlib plus the :mod:`~easyvista_python_client.timestamps` +leaf (no cycle: that module imports nothing from the package either), no +model/client imports. The timestamps import exists so ``.reference()`` on a +timestamp column (``LAST_UPDATE``, …) — now a ``datetime`` since the +2026-08-17 read-path retype — resolves to its rendered value instead of an +empty :class:`Reference`. """ from __future__ import annotations +from collections.abc import Iterator, Sequence from dataclasses import dataclass +from datetime import datetime +from functools import lru_cache from typing import Any +from .timestamps import format_ev_datetime + +DEFAULT_LANGUAGE_ORDER: tuple[str, ...] = ( + "_EN", + "_FR", + "_GE", + "_IT", + "_PO", + "_SP", + "_L1", + "_L2", + "_L3", + "_L4", + "_L5", + "_L6", +) +"""Default order in which EasyVista language columns are tried for a label. + +Every label resolver in this package -- :func:`resolve_reference`, +:func:`localized_label`, +:meth:`~easyvista_python_client.models.common.EasyvistaModel.reference`, +:meth:`~easyvista_python_client.TicketContext.to_markdown`, +:func:`~easyvista_python_client.aggregate_tickets` and both clients' +``ticket_statistics`` -- takes a ``languages=`` argument defaulting to this +tuple, so an English-first or German-first deployment reorders it once and +passes it, rather than forking the package or setting an environment variable. + +Entries are matched as *suffixes* and normalized before use, so ``"en"``, +``"EN"`` and ``"_EN"`` are the same thing. + +``_EN`` .. ``_SP`` are the six columns EasyVista documents and the six this +package has always scanned. ``_L1`` .. ``_L6`` are the six additional +custom-language columns: they are declared on flat record shapes in the +instance's own OpenAPI (tier 3 -- example-derived and illustrative only), and +whether a *nested* reference object such as ``STATUS`` ever carries +``STATUS_L1`` has **not** been tested on any instance. They are listed last and +are harmless where they do not exist: a suffix that matches no key contributes +nothing, and on the verified instance the ``_Lx`` columns hold either an empty +string or a fully ``[bracketed]`` untranslated echo, both of which +:func:`_usable_label` already rejects. So they can only ever supply a label +where every documented column supplied none. +""" + + +@lru_cache(maxsize=32) +def _normalized(languages: tuple[str, ...]) -> tuple[str, ...]: + """Language entries as upper-case ``_XX`` suffixes, blanks dropped. + + Cached because :func:`~easyvista_python_client.aggregate_tickets` calls this + once per ticket per dimension. ``maxsize=32`` rather than ``None`` so a + caller building a fresh tuple inside a loop cannot grow it without bound. + """ + return tuple( + "_" + item.strip().lstrip("_").upper() + for item in languages + if isinstance(item, str) and item.strip().strip("_") + ) + + +def _any_text(value: Any) -> str | None: + """A stripped non-empty string, ``[bracketed]`` placeholders included.""" + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _usable_label(value: Any) -> str | None: + """A stripped non-empty string that is not a ``[bracketed]`` placeholder. + + Rejects a label wrapped *entirely* in brackets -- how a single-language + instance marks an untranslated column. A bracketed *suffix* on otherwise + distinct text (``"Commentaire [Public]"``) is content and is kept; so is + ``"[EXAMPLE] - ticket"``, which only starts with a bracket. Returns ``None`` + otherwise. + """ + text = _any_text(value) + if text is None or (text.startswith("[") and text.endswith("]")): + return None + return text + @dataclass(frozen=True) class Reference: @@ -30,37 +124,80 @@ def display(self) -> str | None: def _scalar(value: Any) -> str | None: - """A non-empty id-like scalar as a string, else ``None`` (bools rejected).""" + """A non-empty id-like scalar as a string, else ``None`` (bools rejected). + + A ``datetime`` renders as EasyVista's own wire format + (:func:`~easyvista_python_client.timestamps.format_ev_datetime`), so + ``.reference("LAST_UPDATE")`` on a retyped timestamp field still resolves + to a populated :class:`Reference` instead of an empty one. That function + raises on a *naive* datetime -- which should not occur for a value that + came through ``OptionalDateTime`` -- but this must never raise, so a naive + value falls back to plain ``.isoformat()`` instead. + """ if isinstance(value, bool): return None + if isinstance(value, datetime): + try: + return format_ev_datetime(value) + except ValueError: + return value.isoformat() if isinstance(value, (str, int)) and str(value).strip(): return str(value).strip() return None -def _nested_label(nested: dict[str, Any] | None) -> str | None: - """First non-empty ``*_EN`` then ``*_FR`` then ``*_PATH`` string; never an href.""" +def _suffix_values(nested: dict[str, Any], suffix: str) -> Iterator[Any]: + """Values of every sub-key whose name ends with ``suffix`` (case-blind).""" + for key, value in nested.items(): + if isinstance(key, str) and key.upper().endswith(suffix): + yield value + + +def _nested_label( + nested: dict[str, Any] | None, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, +) -> str | None: + """Best label under a language suffix, then ``_PATH``; never an href. + + Sub-keys are matched by *suffix*, not by a ``_`` prefix, so a label + under any sub-key resolves -- ``CATALOG_REQUEST``'s human label lives in + ``TITLE_FR`` / ``TITLE_EN``, not in ``CATALOG_REQUEST_FR``. + + Two passes run over the same order (``languages``, then ``"_PATH"``). The + first accepts only a *usable* value (:func:`_usable_label`), so a column + holding a fully ``[bracketed]`` untranslated echo is skipped and a real + sibling translation wins. The second repeats the scan accepting any + non-empty string: a record in which *every* language column is a + placeholder still yields the label it always yielded, because the callers + of this function do not render a fallback -- a missing label removes a + Markdown table row, or collapses a statistics bucket onto a bare id. + """ if not nested: return None - # Suffix-scan (not _* prefix) so labels under any sub-key resolve — - # e.g. CATALOG_REQUEST's human label lives in TITLE_FR / TITLE_EN. - for suffix in ("_EN", "_FR", "_PATH"): - for key, value in nested.items(): - if ( - key.upper().endswith(suffix) - and isinstance(value, str) - and value.strip() - ): - return value.strip() + order = (*_normalized(tuple(languages)), "_PATH") + for accept in (_usable_label, _any_text): + for suffix in order: + for value in _suffix_values(nested, suffix): + got = accept(value) + if got is not None: + return got return None -def resolve_reference(record: dict[str, Any], name: str) -> Reference: +def resolve_reference( + record: dict[str, Any], + name: str, + *, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, +) -> Reference: """Resolve reference ``name`` in a model's by-alias dump to ``(id, label)``. ``name`` is a raw API field name, matched case-insensitively. See the module - docstring for the conventions. Never raises; a non-dict record or a missing - field yields an empty :class:`Reference`. + docstring for the conventions. ``languages`` is the order in which the + nested object's language columns are tried before its ``_PATH`` (default: + :data:`DEFAULT_LANGUAGE_ORDER`); reorder it for a deployment whose primary + language is not English. Never raises; a non-dict record or a missing field + yields an empty :class:`Reference`. """ if not isinstance(record, dict): return Reference(id=None, label=None) @@ -72,7 +209,7 @@ def resolve_reference(record: dict[str, Any], name: str) -> Reference: value = upper.get(key) nested = value if isinstance(value, dict) else None - label = _nested_label(nested) + label = _nested_label(nested, languages) id_ = _resolve_id(upper, key, nested) return Reference(id=id_, label=label) @@ -103,39 +240,36 @@ def _resolve_id( return None -_LANG_SUFFIXES = ("_EN", "_FR", "_GE", "_IT", "_PO", "_SP") - - -def _usable_label(value: Any) -> str | None: - """A stripped non-empty string that is not a ``[bracketed]`` placeholder. - - Returns ``None`` otherwise. - """ - if not isinstance(value, str): - return None - text = value.strip() - if not text or (text.startswith("[") and text.endswith("]")): - return None - return text - - def localized_label( - record: dict[str, Any], prefix: str, *, fallbacks: tuple[str | None, ...] = () + record: dict[str, Any], + prefix: str, + *, + fallbacks: tuple[str | None, ...] = (), + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, ) -> str | None: """Best populated ``"_"`` label, else first usable ``fallbacks``. - Scans the EasyVista language columns ``_EN`` → ``_FR`` → ``_GE`` → ``_IT`` - → ``_PO`` → ``_SP`` and returns the first value that is a non-empty string and not - a ``[bracketed]`` placeholder (unpopulated localized columns on a single-language - instance echo ``"[CODE]"``). Only the language suffixes are considered, so - ``_CODE`` / ``_PATH`` are never mistaken for a label. Falls back to the first - usable value in ``fallbacks`` (e.g. a code then a path). Case-insensitive on keys; - never raises; returns ``None`` when nothing usable is found. + Scans ``"" + suffix`` for each suffix in ``languages`` (default: + :data:`DEFAULT_LANGUAGE_ORDER`, i.e. ``_EN`` first) and returns the first + value that is a non-empty string and not a ``[bracketed]`` placeholder -- + unpopulated localized columns on a single-language instance echo the primary + text in brackets. Pass a reordered ``languages`` on a deployment whose + primary language is not English. Only the language suffixes are considered, + so ``_CODE`` / ``_PATH`` are never mistaken for a label. Falls back to the + first usable value in ``fallbacks`` (e.g. a code then a path). + Case-insensitive on keys; never raises; returns ``None`` when nothing usable + is found -- including when every language column holds a placeholder, so a + caller that must always render something supplies its own last-resort text. + + Deliberately stricter than :func:`_nested_label`, which falls back to a + placeholder rather than returning ``None``. The asymmetry is intentional: + every caller here sits behind an explicit fallback of its own, while + ``_nested_label``'s callers render nothing at all when the label is missing. """ if isinstance(record, dict): upper = {k.upper(): v for k, v in record.items() if isinstance(k, str)} base = prefix.upper() - for suffix in _LANG_SUFFIXES: + for suffix in _normalized(tuple(languages)): got = _usable_label(upper.get(base + suffix)) if got is not None: return got @@ -144,3 +278,55 @@ def localized_label( if got is not None: return got return None + + +def label_from_record( + record: dict[str, Any], + *, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, + fallback_suffixes: Sequence[str] = ("_LABEL", "_PATH", "_CODE"), +) -> str | None: + """The best human label anywhere in a reference-table row, matched by suffix. + + A reference table does not name its label column after the table. On the + verified instance's own OpenAPI response schemas ``groups`` returns + ``GROUP_EN``, ``locations`` returns ``LOCATION_FR``, ``catalog-requests`` + returns ``TITLE_EN`` and ``slas`` returns ``NAME_FR`` -- four different + prefixes for the same job. Those schemas are tier 3 (example-derived, + illustrative only), which is the second reason to match on the *suffix* + rather than on a prefix a caller would have to know in advance. + + Scans for the first key ending in one of ``languages``, in the order + ``languages`` gives, whose value is a usable label; then the first key + ending in one of ``fallback_suffixes``. A ``[bracketed]`` value is skipped + (an unpopulated translation column echoes ``"[CODE]"`` on a single-language + instance) and ``HREF`` is never a label. Case-insensitive on keys; never + raises; returns ``None`` when nothing usable is found. + + :func:`resolve_reference` deliberately does **not** route through this. Its + own nested-label scan runs a bracket-tolerant second pass so a fully + untranslated record still renders something, and it appends ``_PATH`` to + the language order rather than treating it as a separate rung. Routing it + through here would change what ``.reference("STATUS").label`` returns on a + record whose every language column holds a placeholder -- a behaviour + change, not a refactor, and out of scope here. + """ + if not isinstance(record, dict): + return None + items = [(k.upper(), v) for k, v in record.items() if isinstance(k, str)] + for suffix in _normalized(tuple(languages)): + for key, value in items: + if key == "HREF" or not key.endswith(suffix): + continue + got = _usable_label(value) + if got is not None: + return got + for suffix in fallback_suffixes: + upper_suffix = suffix.upper() + for key, value in items: + if key == "HREF" or not key.endswith(upper_suffix): + continue + got = _usable_label(value) + if got is not None: + return got + return None diff --git a/easyvista_python_client/reporting.py b/easyvista_python_client/reporting.py index 55c70b6..c7f91f4 100644 --- a/easyvista_python_client/reporting.py +++ b/easyvista_python_client/reporting.py @@ -7,43 +7,18 @@ from __future__ import annotations -import re from collections.abc import Iterable, Sequence from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime from typing import Any from .models.request import Request -from .references import resolve_reference - -_FRACTION_RE = re.compile(r"\.(\d+)") - - -def _parse_iso_datetime(value: Any) -> datetime | None: - """Parse an EasyVista timestamp to a timezone-aware ``datetime``, or ``None``. - - Accepts a ``datetime`` (returned as-is, naive made 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. A naive result - is treated as UTC. Unparseable input returns ``None``. - """ - if isinstance(value, datetime): - return value if value.tzinfo else value.replace(tzinfo=timezone.utc) - if not isinstance(value, str) or not value.strip(): - return None - text = value.strip() - if text.endswith(("Z", "z")): - text = text[:-1] + "+00:00" - match = _FRACTION_RE.search(text) - if match: - frac6 = (match.group(1) + "000000")[:6] - text = text[: match.start()] + "." + frac6 + text[match.end() :] - try: - parsed = datetime.fromisoformat(text) - except ValueError: - return None - return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) +from .references import DEFAULT_LANGUAGE_ORDER, resolve_reference +from .timestamps import parse_ev_datetime +# Kept as a module-level alias so the eight existing tests in +# tests/test_reporting.py keep importing the name they were written against. +_parse_iso_datetime = parse_ev_datetime DEFAULT_DIMENSIONS: tuple[str, ...] = ( "STATUS", @@ -63,15 +38,33 @@ class TicketStatistics: ``total`` is the number of tickets aggregated (after any date window). ``breakdowns`` maps each requested dimension name to ``{label: count}``; for every dimension ``sum(breakdowns[dim].values()) == total``. + + ``truncated`` is ``True`` when the fetch stopped because it hit its record + cap, so ``total`` describes a sample and not the population. It is a local + fact -- "the cap was reached" -- and over-reports by exactly one case: a + population whose size equals the cap. ``population_total`` resolves that + case: it is the server's reported total for the search, read off the first + page, and it is counted BEFORE any client-side ``created_since`` / + ``created_until`` window, so it is not comparable with ``total`` when a + window is set. It is ``None`` when no total was reported. + + :func:`aggregate_tickets` is pure and offline: it has no page, no envelope + and no cap, so it always leaves these two at their defaults. The client's + ``ticket_statistics`` stamps them from the fetch it performed. Do not move + that computation in here. """ total: int breakdowns: dict[str, dict[str, int]] + truncated: bool = False + population_total: int | None = None -def _dimension_value(data: dict[str, Any], name: str) -> str: +def _dimension_value( + data: dict[str, Any], name: str, languages: Sequence[str] +) -> str: """Group key for one ticket on one dimension: label, else id, else unknown.""" - return resolve_reference(data, name).display or _UNKNOWN + return resolve_reference(data, name, languages=languages).display or _UNKNOWN def fields_for_references( @@ -109,6 +102,7 @@ def aggregate_tickets( dimensions: Sequence[str] = DEFAULT_DIMENSIONS, created_since: datetime | str | None = None, created_until: datetime | str | None = None, + languages: Sequence[str] = DEFAULT_LANGUAGE_ORDER, ) -> TicketStatistics: """Aggregate tickets into a total plus per-dimension breakdowns. @@ -118,6 +112,32 @@ def aggregate_tickets( ``CREATION_DATE_UT`` (a ``datetime`` or ISO string); a ticket with a missing/unparseable date is excluded when a bound is set. Raises ``ValueError`` for a malformed bound string. + + **An offset-less bound is interpreted as UTC**, not as instance-local time, + because it routes through + :func:`~easyvista_python_client.parse_ev_datetime`. On a ``+02:00`` instance + ``created_since="2026-01-01T00:00:00"`` therefore silently excludes every + ticket created between 00:00 and 02:00 local on 1 January -- a two-hour hole + in a bound this docstring calls inclusive. Pass an aware ``datetime``, or an + offset-bearing string, when the boundary matters. Note this filter is + client-side and deliberately more permissive than the *wire* builders, which + refuse an offset-less time outright + (:func:`~easyvista_python_client.ev_since_filter`); making the two agree is a + behaviour change and a candidate follow-up. + + ``languages`` orders the language columns tried when turning each + dimension's nested reference object into a bucket key (default: + :data:`~easyvista_python_client.DEFAULT_LANGUAGE_ORDER`). It affects the + keys of ``breakdowns`` only, never ``total``: a dimension that resolves to + no label still buckets by its id, and then by ``"(unknown)"``. + + The "unparseable" half of that per-ticket guard is unreachable for a + ``Request`` built the normal way: ``Request.model_validate`` itself now + rejects a malformed ``CREATION_DATE_UT`` before this function ever sees the + ticket (see ``OptionalDateTime`` in ``models/common.py``). It stays as + defence-in-depth for a ``Request`` assembled some other way (e.g. + ``model_construct``, which bypasses validation) -- do not delete it as dead + code. """ since = _bound(created_since, "created_since") until = _bound(created_until, "created_until") @@ -137,7 +157,7 @@ def aggregate_tickets( continue total += 1 for dim in dimensions: - key = _dimension_value(data, dim) + key = _dimension_value(data, dim, languages) counts = breakdowns[dim] counts[key] = counts.get(key, 0) + 1 return TicketStatistics(total=total, breakdowns=breakdowns) diff --git a/easyvista_python_client/resources/actions.py b/easyvista_python_client/resources/actions.py index 88bfb67..19a1901 100644 --- a/easyvista_python_client/resources/actions.py +++ b/easyvista_python_client/resources/actions.py @@ -8,14 +8,14 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable from typing import Any from .._transport import RequestSpec from ..filters import ev_equals_filter -from ..models.action import Action, PostAction -from ..pagination import extract_records -from .descriptor import ResourceDescriptor, build_get, build_search +from ..models.action import Action, ActionUpdate, PostAction, PostTask +from ..pagination import SearchResult, extract_records +from .descriptor import ResourceDescriptor, build_get, build_search, build_update ACTIONS: ResourceDescriptor[Action] = ResourceDescriptor( path="actions", envelope_key="actions", model=Action @@ -23,33 +23,113 @@ def build_create_action( - rfc_number: str, payload: PostAction + rfc_number: str, + payload: PostAction, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Action]]: # Create is nested under the request, one action per call, with a bare body # (NOT wrapped in an ``actions`` array) — verified against a live instance. spec = RequestSpec("POST", f"requests/{rfc_number}/actions", json=payload.to_api()) def parse(data: Any) -> Action: - records = extract_records(data) - return Action.model_validate(records[0] if records else data) + # The envelope key is passed explicitly: a deployment echoing the + # created record under an ``actions`` wrapper would otherwise hand + # ``model_validate`` the wrapper itself, and ``extra="allow"`` accepts + # it silently with every declared field ``None``. + records = extract_records(data, ACTIONS.envelope_key) + return Action.model_validate(records[0] if records else data, context=context) return spec, parse -def build_list_actions( +def build_create_task( rfc_number: str, -) -> tuple[RequestSpec, Callable[[Any], list[Action]]]: - # Actions are listed via the TOP-LEVEL /actions resource filtered by the - # request number, not a nested requests/{rfc}/actions path (which the API - # rejects as "Unauthorized Method"). Verified against a live instance. - # An unsafe rfc_number raises rather than degrading: ',' is a live combinator, - # so a raw value could append conditions and list another ticket's actions. A - # blank one must raise too — ev_equals_filter returns None for blank input, and - # search=None would list every action just as surely. + payload: PostTask, + *, + context: dict[str, Any] | None = None, +) -> tuple[RequestSpec, Callable[[Any], Action]]: + """Build ``POST requests/{rfc}/tasks`` -- an action created already ENDED. + + Two differences from :func:`build_create_action`, both measured live + 2026-08-28. The body is **flat** at the root where the action create wraps + its fields, and the record arrives with ``END_DATE_UT`` and + ``STATUS_ID_ON_TERMINATE`` already set, so it renders in the ticket history + with its text instead of as an open row with none. + """ + spec = RequestSpec("POST", f"requests/{rfc_number}/tasks", json=payload.to_api()) + + def parse(data: Any) -> Action: + # Same envelope reasoning as build_create_action's parser. + records = extract_records(data, ACTIONS.envelope_key) + return Action.model_validate(records[0] if records else data, context=context) + + return spec, parse + + +def build_search_actions( + rfc_number: str, + *, + fields: Iterable[str] | str | None = None, + max_rows: int | None = None, + offset: int | None = None, + context: dict[str, Any] | None = None, +) -> tuple[RequestSpec, Callable[[Any], SearchResult[Action]]]: + """One page of a ticket's actions, envelope included. + + Filters the TOP-LEVEL ``/actions`` resource by request number, because + there is no nested list route to prefer. In the instance OpenAPI document + (``GET {api_root}/swagger``, read 2026-08-27 -- authoritative for that + deployment's routes) ``requests/{rfc_number}/actions`` declares **POST + only**, while the list and item operations live on ``/actions`` and + ``/actions/{id}``. That is a topology fact, not a permission verdict, and + the difference matters: an unknown path on this API answers **403**, not + 404 (measured live; date not recorded), so a 403 alone can never say + whether a route is denied or simply absent. An earlier note here read the + nested path's rejection as "Unauthorized Method" and inferred a profile + restriction; the route does not exist. The + filter is re-applied on every page. A blank or unsafe ``rfc_number`` raises: + ``,`` is a live combinator, so a raw value could list another ticket's + actions. + + ``fields`` grants any scalar requested, but never the memo bodies + (``DESCRIPTION`` and ``COMMENT`` stay HREF objects), and ``fields="*"`` + reduces to ``ACTION_ID`` alone. + + The envelope carries ``@next``, which is what makes ``offset`` paging + possible. That contract is unverified on this endpoint -- see + ``EasyvistaClient.iter_actions``. + """ search = ev_equals_filter("REQUEST.RFC_NUMBER", rfc_number) if search is None: raise ValueError("rfc_number is required to list a ticket's actions") - spec, parse_search = build_search(ACTIONS, search=search) + return build_search( + ACTIONS, + search=search, + fields=fields, + max_rows=max_rows, + offset=offset, + context=context, + ) + + +def build_list_actions( + rfc_number: str, + *, + fields: Iterable[str] | str | None = None, + max_rows: int | None = None, + context: dict[str, Any] | None = None, +) -> tuple[RequestSpec, Callable[[Any], list[Action]]]: + """One page of a ticket's actions, as a bare list. + + :func:`build_search_actions` with the envelope dropped. Returns a single + page: a ticket with more actions than ``max_rows`` is truncated silently, + and the dropped envelope leaves the caller no way to detect it. + ``EasyvistaClient.iter_actions`` pages instead. + """ + spec, parse_search = build_search_actions( + rfc_number, fields=fields, max_rows=max_rows, context=context + ) def parse(data: Any) -> list[Action]: return parse_search(data).records @@ -59,14 +139,127 @@ def parse(data: Any) -> list[Action]: def build_get_action( action_id: str | int, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Action]]: """Fetch ONE action by id. The item-level record is far richer than the list endpoint's: the note text a caller passed as ``PostAction.description`` comes back through a ``DESCRIPTION`` Memo sub-resource that ``list_actions`` does not return at - all (verified live). Uses the **top-level** ``actions/{id}`` path — the - nested ``requests/{rfc}/actions/{id}`` is rejected with HTTP 403, the same - way the nested list path is. + all (verified live). Uses the **top-level** ``actions/{id}`` path because + it is the only one: the instance OpenAPI document read 2026-08-27 declares + 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. + """ + return build_get(ACTIONS, action_id, context=context) + + +def build_update_action( + action_id: str | int, + payload: ActionUpdate, + *, + context: dict[str, Any] | None = None, +) -> tuple[RequestSpec, Callable[[Any], Action]]: + """Edit one action, via the TOP-LEVEL ``actions/{id}`` path. + + ``PUT`` and ``PATCH`` are declared on ``actions/{id}`` and nowhere else: + the instance OpenAPI document read 2026-08-27 has no nested + ``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. + """ + return build_update(ACTIONS, action_id, payload, context=context) + + +def build_end_action( + rfc_number: str, + *, + action_id: str | int | None = None, + end_all: bool = False, + end_date: str | None = None, + start_date: str | None = None, + elapsed_time: int | str | None = None, + doneby_mail: str | None = None, + context: dict[str, Any] | None = None, +) -> tuple[RequestSpec, Callable[[Any], Action]]: + """Build the ``{"end_action": {...}}`` PUT spec -- report an action as done. + + Note the addressing, which is the first thing to get wrong: the path + segment is the **ticket's RFC number**, not the action id. The action is + named in the *body*. Sending the id in the path answers 404 even when the + body names it too (measured 2026-09-01). + + The route and the wrapper are the vendor's own: + https://docs.easyvista.com/docs/rest-api-finish-an-action-attached-to-an-incident-request.md + The vendor documents ``action_id`` omitted as ending **every open action on + the ticket**. This builder does not let that happen by omission: it is + reachable only through ``end_all=True``, and a bare ``action_id=None`` is + refused. + + That guard is not defensive styling. ``Action.action_id`` is legitimately + ``None`` across this package -- :func:`build_create_action`'s response + carries no id at all, and any ``fields=`` projection that omits + ``ACTION_ID`` yields rows without one -- so forwarding an id a caller + *thought* they had would otherwise select the bulk form silently. Compare + ``delete_document``, which refuses a document with no id rather than + addressing the collection. See the client's ``end_action`` for what ending + measurably does to a ticket. + + Dates take the instance's own ``DATE_FORMAT``, so they are passed through + as strings rather than accepting a ``datetime`` this package would have to + format on a guess -- the same reasoning as + :func:`~easyvista_python_client.resources.requests.build_close_ticket`'s + ``end_date``. On the verified instance that format is + ``dd/mm/yyyy hh:mm:ss`` and **ISO 8601 is refused** with HTTP 590 "Invalid + 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**. + + A blank ``rfc_number`` is refused rather than allowed to build ``PUT + actions/``, which addresses the collection instead of a ticket. """ - return build_get(ACTIONS, action_id) + if not rfc_number or not rfc_number.strip(): + raise ValueError( + "rfc_number is required to end an action: the path segment is the " + "ticket's RFC number, not the action id. Blank would address " + "'actions/' -- the collection -- instead of a ticket." + ) + if action_id is None and not end_all: + raise ValueError( + "end_action needs an action_id. Omitting it is the vendor's " + "'end every open action on this ticket' form, which on a ticket " + "whose only open action is its workflow step ends that step and " + "moves the ticket's status -- so it must be asked for explicitly " + "with end_all=True. Note that action_id is legitimately None all " + "over this package (create_action's response carries no id, and a " + "fields= projection without ACTION_ID drops it), which is exactly " + "the case this refusal is here to catch: recover the id by " + "diffing list_actions across the create." + ) + if action_id is not None and end_all: + raise ValueError( + "pass either action_id or end_all=True, not both: end_all is the " + "id-less form, so naming an action contradicts it." + ) + end: dict[str, Any] = {} + if action_id is not None: + end["action_id"] = action_id + if start_date is not None: + end["start_date"] = start_date + if end_date is not None: + end["end_date"] = end_date + if elapsed_time is not None: + end["elapsed_time"] = elapsed_time + if doneby_mail is not None: + end["doneby_mail"] = doneby_mail + spec = RequestSpec("PUT", f"actions/{rfc_number}", json={"end_action": end}) + + def parse(data: Any) -> Action: + # Same envelope reasoning as build_create_action's parser. The measured + # response is HREF-only and names the parent REQUEST, so the Action + # this returns is empty by construction -- see the client method. + records = extract_records(data, ACTIONS.envelope_key) + return Action.model_validate(records[0] if records else data, context=context) + + return spec, parse diff --git a/easyvista_python_client/resources/assets.py b/easyvista_python_client/resources/assets.py index 27f7513..fc10837 100644 --- a/easyvista_python_client/resources/assets.py +++ b/easyvista_python_client/resources/assets.py @@ -21,12 +21,18 @@ def build_create_asset( payload: PostAsset, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Asset]]: - return build_create(ASSETS, payload) + return build_create(ASSETS, payload, context=context) -def build_get_asset(asset_id: str) -> tuple[RequestSpec, Callable[[Any], Asset]]: - return build_get(ASSETS, asset_id) +def build_get_asset( + asset_id: str, + *, + context: dict[str, Any] | None = None, +) -> tuple[RequestSpec, Callable[[Any], Asset]]: + return build_get(ASSETS, asset_id, context=context) def build_search_assets( @@ -36,6 +42,7 @@ def build_search_assets( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], SearchResult[Asset]]]: return build_search( ASSETS, @@ -44,4 +51,5 @@ def build_search_assets( sort=sort, max_rows=max_rows, offset=offset, + context=context, ) diff --git a/easyvista_python_client/resources/departments.py b/easyvista_python_client/resources/departments.py index eb99da9..56329e4 100644 --- a/easyvista_python_client/resources/departments.py +++ b/easyvista_python_client/resources/departments.py @@ -23,8 +23,10 @@ def build_get_department( department_id: str | int, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Department]]: - return build_get(DEPARTMENTS, department_id) + return build_get(DEPARTMENTS, department_id, context=context) def build_search_departments( @@ -34,6 +36,7 @@ def build_search_departments( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], SearchResult[Department]]]: return build_search( DEPARTMENTS, @@ -42,16 +45,22 @@ def build_search_departments( sort=sort, max_rows=max_rows, offset=offset, + context=context, ) def build_create_department( payload: PostDepartment, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Department]]: - return build_create(DEPARTMENTS, payload) + return build_create(DEPARTMENTS, payload, context=context) def build_update_department( - department_id: str | int, update: DepartmentUpdate + department_id: str | int, + update: DepartmentUpdate, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Department]]: - return build_update(DEPARTMENTS, department_id, update) + return build_update(DEPARTMENTS, department_id, update, context=context) diff --git a/easyvista_python_client/resources/descriptor.py b/easyvista_python_client/resources/descriptor.py index 74268a3..a93e322 100644 --- a/easyvista_python_client/resources/descriptor.py +++ b/easyvista_python_client/resources/descriptor.py @@ -33,25 +33,48 @@ class ResourceDescriptor(Generic[M]): model: type[M] -def _first_record_parser(desc: ResourceDescriptor[M]) -> Callable[[Any], M]: +def _first_record_parser( + desc: ResourceDescriptor[M], context: dict[str, Any] | None = None +) -> Callable[[Any], M]: """Build a parser that validates the first extracted record (or bare ``data``). Shared by :func:`build_get`, :func:`build_create` and :func:`build_update` — all three parse a single record, either wrapped in the resource's envelope (or ``records``) or returned bare (e.g. a create's ``HREF``-only body). + + ``context`` is the pydantic validation context, bound here at build time + rather than passed to the returned parser: it is fixed for the lifetime of + a client, so binding it early keeps the parser signature + ``Callable[[Any], M]``. ``None`` — the default — makes every + ``model_validate`` call byte-identical to a context-free one. """ def parse(data: Any) -> M: records = extract_records(data, desc.envelope_key) - return desc.model.model_validate(records[0] if records else data) + return desc.model.model_validate( + records[0] if records else data, context=context + ) return parse def build_get( - desc: ResourceDescriptor[M], record_id: Any + desc: ResourceDescriptor[M], + record_id: Any, + *, + fields: Iterable[str] | str | None = None, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], M]]: - return RequestSpec("GET", f"{desc.path}/{record_id}"), _first_record_parser(desc) + params: dict[str, Any] = {} + if fields is not None: + params["fields"] = fields if isinstance(fields, str) else ",".join(fields) + # ``params or None`` rather than a bare ``{}``: with no projection the spec + # must be identical to the one this builder has always produced, and the + # suite asserts on ``spec.params``. + return ( + RequestSpec("GET", f"{desc.path}/{record_id}", params=params or None), + _first_record_parser(desc, context), + ) def build_search( @@ -62,6 +85,7 @@ def build_search( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], SearchResult[M]]]: params: dict[str, Any] = {} if search is not None: @@ -77,7 +101,7 @@ def build_search( def parse(data: Any) -> SearchResult[M]: records = [ - desc.model.model_validate(r) + desc.model.model_validate(r, context=context) for r in extract_records(data, desc.envelope_key) ] return build_search_result(data, records) @@ -86,14 +110,21 @@ def parse(data: Any) -> SearchResult[M]: def build_create( - desc: ResourceDescriptor[M], payload: EasyvistaWriteModel + desc: ResourceDescriptor[M], + payload: EasyvistaWriteModel, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], M]]: spec = RequestSpec("POST", desc.path, json={desc.envelope_key: [payload.to_api()]}) - return spec, _first_record_parser(desc) + return spec, _first_record_parser(desc, context) def build_update( - desc: ResourceDescriptor[M], record_id: Any, payload: EasyvistaWriteModel + desc: ResourceDescriptor[M], + record_id: Any, + payload: EasyvistaWriteModel, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], M]]: spec = RequestSpec("PUT", f"{desc.path}/{record_id}", json=payload.to_api()) - return spec, _first_record_parser(desc) + return spec, _first_record_parser(desc, context) diff --git a/easyvista_python_client/resources/discovery.py b/easyvista_python_client/resources/discovery.py new file mode 100644 index 0000000..c967241 --- /dev/null +++ b/easyvista_python_client/resources/discovery.py @@ -0,0 +1,79 @@ +"""Builders for the instance-discovery reads. + +Both follow the ``(RequestSpec, parser)`` contract every other resource uses. +``build_list_reference_table`` reuses :func:`~.descriptor.build_search`, so the +parser and the envelope handling are the same ones every list endpoint gets. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from dataclasses import replace +from typing import Any + +from .._transport import RequestSpec +from ..models.generic import GenericRecord +from ..pagination import SearchResult +from .descriptor import ResourceDescriptor, build_search + +#: The route that serves the instance's own OpenAPI description. +#: +#: Measured 2026-08-27 on one instance (may not generalise) and NOT declared in +#: that instance's own ``paths``, so this is a tier-4 constant, not a tier-2 +#: one. It is a module constant, and both client methods take a ``path`` +#: keyword, so a deployment that publishes elsewhere needs no fork. +SWAGGER_PATH = "swagger" + + +def build_get_api_spec( + path: str = SWAGGER_PATH, +) -> tuple[RequestSpec, Callable[[Any], dict[str, Any]]]: + """A GET for the instance's OpenAPI document, parsed as a plain dict.""" + + def parse(data: Any) -> dict[str, Any]: + return data if isinstance(data, dict) else {} + + return RequestSpec("GET", path), parse + + +def build_list_reference_table( + path: str, + *, + search: str | None = None, + fields: Iterable[str] | str | None = None, + sort: str | None = None, + max_rows: int | None = None, + offset: int | None = None, + params: Mapping[str, Any] | None = None, + context: dict[str, Any] | None = None, +) -> tuple[RequestSpec, Callable[[Any], SearchResult[GenericRecord]]]: + """A GET over any list route, parsed into column-free records. + + The descriptor is built per call rather than declared as a constant, + because the path is the caller's -- that is the whole point of this builder. + ``envelope_key`` is the last path segment, which is what ``extract_records`` + checks after ``records``; a route that answers with a bare object and no + envelope at all (the instance's ``GET /status`` schema shows exactly that) + still parses, via that function's single-object fallback. + + ``params`` is merged last and wins over every modelled parameter above it, + so a query argument this package does not know about needs no fork. + """ + resource = path.strip("/") + desc: ResourceDescriptor[GenericRecord] = ResourceDescriptor( + path=resource, envelope_key=resource.rsplit("/", 1)[-1], model=GenericRecord + ) + spec, parse = build_search( + desc, + search=search, + fields=fields, + sort=sort, + max_rows=max_rows, + offset=offset, + context=context, + ) + if params: + merged = dict(spec.params or {}) + merged.update(params) + spec = replace(spec, params=merged) # RequestSpec is frozen + return spec, parse diff --git a/easyvista_python_client/resources/documents.py b/easyvista_python_client/resources/documents.py index 3a869f6..8cffa1e 100644 --- a/easyvista_python_client/resources/documents.py +++ b/easyvista_python_client/resources/documents.py @@ -12,21 +12,25 @@ from typing import Any from .._transport import RequestSpec +from ..config import ( + DEFAULT_DOCUMENT_DELETE_PATH_STYLE, + DOCUMENT_DELETE_PATH_STYLES, + DocumentDeletePathStyle, +) from ..models.document import Document from ..pagination import extract_records -def _first_document(data: Any) -> Document: - records = extract_records(data) - return Document.model_validate(records[0] if records else data) - - def _document_records(data: Any) -> list[dict[str, Any]]: - """Find the list of document dicts in a list response. - - The live list wraps items under a capital-D ``Documents`` key (verified live), - which the generic ``extract_records`` (lowercase ``documents``/``records``) does - not match; check any case-insensitive ``documents`` key first, then fall back. + """Find the list of document dicts in a document response. + + The live list wraps items under a capital-D ``Documents`` key (measured + 2026-08-17 on one instance; may not generalise), which the generic + ``extract_records`` also now matches. This stays a separate helper for a + different reason: it checks a ``documents`` key *before* ``records``, the + priority the live list shape was verified against, and ``extract_records`` + keeps ``records`` first. Both the list parser and the create parser go + through it, so one response shape has one reading. """ if isinstance(data, dict): for key, value in data.items(): @@ -35,12 +39,56 @@ def _document_records(data: Any) -> list[dict[str, Any]]: return extract_records(data) -def _all_documents(data: Any) -> list[Document]: - return [Document.model_validate(r) for r in _document_records(data)] +def _first_document( + context: dict[str, Any] | None = None, +) -> Callable[[Any], Document]: + """Build the single-document parser, binding the validation context. + + A factory rather than a bare function so ``context`` is fixed at build time + and the returned parser keeps the ``Callable[[Any], Document]`` shape every + other builder returns. ``Document`` declares no timestamp column today, so + the context reaches nothing -- it is threaded anyway because "every builder + that returns a parser takes a context" is a rule with no exception for a + future field addition to forget. + + Goes through :func:`_document_records`, not ``extract_records``: the create + and list responses are the same resource on the same instance, and reading + them by two different rules is how a capital-D ``Documents`` echo produced + an all-``None`` ``Document`` built from the wrapper. + """ + + def parse(data: Any) -> Document: + records = _document_records(data) + return Document.model_validate( + records[0] if records else data, context=context + ) + + return parse + + +def _all_documents( + context: dict[str, Any] | None = None, +) -> Callable[[Any], list[Document]]: + """Build the document-list parser, binding the validation context. + + A factory for the same reason as :func:`_first_document`. + """ + + def parse(data: Any) -> list[Document]: + return [ + Document.model_validate(r, context=context) + for r in _document_records(data) + ] + + return parse def build_add_document( - rfc_number: str, *, filename: str, content: bytes + rfc_number: str, + *, + filename: str, + content: bytes, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Document]]: filedata = base64.b64encode(content).decode("ascii") spec = RequestSpec( @@ -48,13 +96,62 @@ def build_add_document( f"requests/{rfc_number}/documents", json={"documents": [{"filename": filename, "filedata": filedata}]}, ) - return spec, _first_document + return spec, _first_document(context) def build_list_documents( rfc_number: str, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], list[Document]]]: - return RequestSpec("GET", f"requests/{rfc_number}/documents"), _all_documents + return ( + RequestSpec("GET", f"requests/{rfc_number}/documents"), + _all_documents(context), + ) + + +def build_delete_document( + rfc_number: str | None, + document_id: str | int, + *, + path_style: DocumentDeletePathStyle = DEFAULT_DOCUMENT_DELETE_PATH_STYLE, +) -> RequestSpec: + """Delete one attachment, by either of the two routes that exist for it. + + The instance OpenAPI document read 2026-08-27 declares DELETE on **both** + ``requests/{RFC_NUMBER}/documents/{id}`` and ``documents/{id}``, marking + only the latter ``deprecated``. So the HTTP 403 measured against the + top-level form on the verified instance (2026-08-17, one instance, may not + generalise) was a profile denial, not a missing route -- this API answers + 403 for an unknown path as well as for a denied one, so the status code + alone never said which. + + ``path_style="nested"`` (the default, and the form verified live 2026-08-17: + the document count went 5 -> 4 and the target was absent from a re-listing) + needs both identifiers non-blank. ``path_style="top_level"`` addresses the + document by id alone and ignores ``rfc_number`` entirely, which may be + ``None``. ``document_id`` must be non-blank either way: a blank one + addresses the collection rather than an item, which is a very different + request to send by accident. + """ + if path_style not in DOCUMENT_DELETE_PATH_STYLES: + raise ValueError( + "path_style must be one of " + f"{DOCUMENT_DELETE_PATH_STYLES!r}, got {path_style!r}" + ) + doc = str(document_id).strip() + if not doc: + raise ValueError("document_id is required to delete a document") + if path_style == "top_level": + return RequestSpec("DELETE", f"documents/{doc}") + rfc = str(rfc_number or "").strip() + if not rfc: + raise ValueError( + "rfc_number is required to delete a document with the 'nested' " + "path style; pass path_style='top_level' to address the document " + "by its id alone" + ) + return RequestSpec("DELETE", f"requests/{rfc}/documents/{doc}") def download_href(document: Document | str) -> str: diff --git a/easyvista_python_client/resources/employees.py b/easyvista_python_client/resources/employees.py index dd3ce96..545eb17 100644 --- a/easyvista_python_client/resources/employees.py +++ b/easyvista_python_client/resources/employees.py @@ -23,8 +23,10 @@ def build_get_employee( employee_id: str | int, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Employee]]: - return build_get(EMPLOYEES, employee_id) + return build_get(EMPLOYEES, employee_id, context=context) def build_search_employees( @@ -34,6 +36,7 @@ def build_search_employees( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], SearchResult[Employee]]]: return build_search( EMPLOYEES, @@ -42,16 +45,22 @@ def build_search_employees( sort=sort, max_rows=max_rows, offset=offset, + context=context, ) def build_create_employee( payload: PostEmployee, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Employee]]: - return build_create(EMPLOYEES, payload) + return build_create(EMPLOYEES, payload, context=context) def build_update_employee( - employee_id: str | int, update: EmployeeUpdate + employee_id: str | int, + update: EmployeeUpdate, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Employee]]: - return build_update(EMPLOYEES, employee_id, update) + return build_update(EMPLOYEES, employee_id, update, context=context) diff --git a/easyvista_python_client/resources/requests.py b/easyvista_python_client/resources/requests.py index f7bc45a..992d708 100644 --- a/easyvista_python_client/resources/requests.py +++ b/easyvista_python_client/resources/requests.py @@ -29,12 +29,19 @@ def build_create_ticket( payload: PostRequest, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Request]]: - return build_create(REQUESTS, payload) + return build_create(REQUESTS, payload, context=context) -def build_get_ticket(rfc_number: str) -> tuple[RequestSpec, Callable[[Any], Request]]: - return build_get(REQUESTS, rfc_number) +def build_get_ticket( + rfc_number: str, + *, + fields: Iterable[str] | str | None = None, + context: dict[str, Any] | None = None, +) -> tuple[RequestSpec, Callable[[Any], Request]]: + return build_get(REQUESTS, rfc_number, fields=fields, context=context) def build_search_tickets( @@ -44,6 +51,7 @@ def build_search_tickets( sort: str | None = None, max_rows: int | None = None, offset: int | None = None, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], SearchResult[Request]]]: return build_search( REQUESTS, @@ -52,28 +60,64 @@ def build_search_tickets( sort=sort, max_rows=max_rows, offset=offset, + context=context, ) def build_update_ticket( - rfc_number: str, update: RequestUpdate + rfc_number: str, + update: RequestUpdate, + *, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Request]]: - return build_update(REQUESTS, rfc_number, update) + return build_update(REQUESTS, rfc_number, update, context=context) def build_close_ticket( rfc_number: str, *, status_guid: str | None = None, - delete_actions: int | None = None, + delete_actions: int | bool | None = None, comment: str | None = None, + end_date: str | None = None, + catalog_guid: str | None = None, + context: dict[str, Any] | None = None, ) -> tuple[RequestSpec, Callable[[Any], Request]]: - """Build a close (PUT) spec. - - ``status_guid`` is the instance's "closed" status GUID (EasyVista - ``status_GUID``); without it the API may not actually transition the ticket. - ``delete_actions=1`` drops the ticket's actions on close. Shapes follow the - documented close body (``docs/API_Info.md``), verified live. + """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. + + **The route is the vendor's own.** ``PUT requests/{rfc_number}`` with a + ``closed`` wrapper is what the documentation specifies + (https://docs.easyvista.com/docs/rest-api-close-an-incident-request.md), not + 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). + + ``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, + which is not ISO 8601 on every deployment (``dd/mm/yyyy`` on the verified + one; read ``DATE_FORMAT`` off any employee record) -- so it is passed + through as a string rather than accepting a ``datetime`` this package would + have to format on a guess. """ closed: dict[str, Any] = {} if status_guid is not None: @@ -82,10 +126,38 @@ def build_close_ticket( closed["delete_actions"] = delete_actions if comment is not None: closed["comment"] = comment + if end_date is not None: + closed["end_date"] = end_date + if catalog_guid is not None: + closed["catalog_GUID"] = catalog_guid spec = RequestSpec("PUT", f"requests/{rfc_number}", json={"closed": closed}) def parse(data: Any) -> Request: - records = extract_records(data) - return Request.model_validate(records[0] if records else data) + # Explicit rather than relying on ``"requests"`` happening to sit in + # ``extract_records``' hardcoded fallback tuple, which belongs to no + # resource in particular. + records = extract_records(data, REQUESTS.envelope_key) + return Request.model_validate( + records[0] if records else data, context=context + ) 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 5feab92..942352d 100644 --- a/easyvista_python_client/resources/tests/test_actions.py +++ b/easyvista_python_client/resources/tests/test_actions.py @@ -1,8 +1,17 @@ import pytest -from easyvista_python_client.models.action import Action, PostAction +from easyvista_python_client.models.action import ( + Action, + ActionUpdate, + PostAction, + PostTask, +) from easyvista_python_client.resources import actions as a -from easyvista_python_client.resources.actions import build_get_action +from easyvista_python_client.resources.actions import ( + build_get_action, + build_list_actions, + build_update_action, +) def test_action_accepts_object_action_type(): @@ -30,8 +39,11 @@ def test_build_create_action_bare_body_and_path(): def test_build_create_action_custom_fields_prefix(): - spec, _ = a.build_create_action("I1", PostAction(custom_fields={"team": "L2"})) - assert spec.json == {"e_team": "L2"} + spec, _ = a.build_create_action( + "I1", + PostAction(action_type_id=94, group_id=3, custom_fields={"team": "L2"}), + ) + assert spec.json == {"action_type_id": 94, "group_id": 3, "e_team": "L2"} def test_build_list_actions_uses_top_level_filtered_endpoint(): @@ -69,6 +81,39 @@ def test_build_list_actions_filters_by_rfc(): assert spec.params["search"] == 'REQUEST.RFC_NUMBER:"I240101_0001"' +def test_list_actions_passes_a_fields_projection_through(): + """EV-R3: the projection is what makes comment metadata 1 request, not N.""" + spec, _parse = build_list_actions( + "I240101_0001", fields=["ACTION_ID", "CREATION_DATE_UT", "LAST_UPDATE"] + ) + assert spec.params["fields"] == "ACTION_ID,CREATION_DATE_UT,LAST_UPDATE" + assert spec.params["search"] == 'REQUEST.RFC_NUMBER:"I240101_0001"' + + +def test_list_actions_accepts_a_bare_string_projection(): + spec, _parse = build_list_actions("I240101_0001", fields="ACTION_ID,LAST_UPDATE") + assert spec.params["fields"] == "ACTION_ID,LAST_UPDATE" + + +def test_list_actions_omits_fields_when_not_requested(): + """Absent, not empty: `fields=` with no value is not the same request.""" + spec, _parse = build_list_actions("I240101_0001") + assert "fields" not in spec.params + + +def test_list_actions_sends_the_row_cap_explicitly_when_given_one(): + """The cap must be the CLIENT's, not the server's unstated default. + + This call returns one page and does not paginate, so whoever owns the cap + owns where the action log gets truncated. Every sibling search on the client + injects ``config.default_max_rows``; this one used to be the single search + that deferred to the server (25 on the verified instance), which a caller + could neither see nor raise. + """ + spec, _parse = build_list_actions("I240101_0001", max_rows=200) + assert spec.params["max_rows"] == 200 + + def test_build_get_action_targets_the_top_level_path(): spec, _ = build_get_action(52990) assert spec.method == "GET" @@ -80,3 +125,176 @@ def test_build_get_action_parses_an_enveloped_record(): _, parse = build_get_action(52990) action = parse({"actions": [{"ACTION_ID": 52990}]}) assert action.action_id == 52990 + + +def test_update_action_uses_the_top_level_path(): + """The nested requests/{rfc}/actions/{id} form returns 403 (verified live).""" + spec, _parse = build_update_action(57483, ActionUpdate(description="edited")) + assert spec.method == "PUT" + assert spec.path == "actions/57483" + assert spec.json == {"description": "edited"} + + +def test_update_action_drops_unset_fields(): + spec, _parse = build_update_action(1, ActionUpdate(description="only this")) + assert "comment" not in spec.json + + +def test_build_search_actions_exposes_the_envelope_a_pager_needs(): + """The parser yields the whole ``SearchResult``, so ``@next`` is readable.""" + spec, parse = a.build_search_actions("I240101_0001") + assert spec.path == "actions" + result = parse( + { + "records": [{"ACTION_ID": 1}, {"ACTION_ID": 2}], + "record_count": "2", + "total_record_count": "3", + "@next": "https://ev.test/api/v1/acme/actions?offset=2", + } + ) + assert [x.action_id for x in result.records] == [1, 2] + assert result.total_record_count == 3 + assert result.next_url == "https://ev.test/api/v1/acme/actions?offset=2" + + +def test_build_search_actions_sends_the_offset(): + spec, _parse = a.build_search_actions("I240101_0001", max_rows=25, offset=50) + assert spec.params["offset"] == 50 + assert spec.params["max_rows"] == 25 + + +def test_build_search_actions_keeps_the_rfc_filter_on_every_page(): + """A page-2 request that lost the filter would sweep the whole table.""" + spec, _parse = a.build_search_actions("I240101_0001", offset=25) + assert spec.params["search"] == 'REQUEST.RFC_NUMBER:"I240101_0001"' + + +@pytest.mark.parametrize("rfc", ["", " ", 'x",REQUEST.RFC_NUMBER:"y']) +def test_build_search_actions_refuses_an_unsafe_or_blank_rfc(rfc): + """The guard is shared with ``build_list_actions``.""" + with pytest.raises(ValueError): + a.build_search_actions(rfc) + + +# --- the create parsers name their envelope --------------------------------- +# +# Both passed `extract_records(data)` with no envelope key. A deployment +# echoing the created record under an `actions` wrapper handed +# `model_validate` the wrapper itself, and `extra="allow"` accepted it +# silently -- so the assert below is on `action_id`, not on the type: the old +# code returned a perfectly well-formed `Action` with every field `None`. + + +def test_build_create_action_unwraps_an_actions_envelope(): + _, parser = a.build_create_action( + "I1", PostAction(action_type_id=94, group_id=3) + ) + assert parser({"actions": [{"ACTION_ID": 9}]}).action_id == 9 + + +def test_build_create_action_unwraps_a_capital_a_actions_envelope(): + _, parser = a.build_create_action( + "I1", PostAction(action_type_id=94, group_id=3) + ) + assert parser({"Actions": [{"ACTION_ID": 9}]}).action_id == 9 + + +def test_build_create_task_unwraps_an_actions_envelope(): + _, parser = a.build_create_task("I1", PostTask(action_type_id=94, group_id=3)) + assert parser({"actions": [{"ACTION_ID": 9}]}).action_id == 9 + + +def test_build_end_action_addresses_the_ticket_not_the_action(): + """The path segment is the RFC; the action is named in the body. + + Getting this backwards is the documented failure mode -- ``actions/{id}`` + answers 404 for this verb even when the body names the action too. + """ + spec, _ = a.build_end_action( + "I1", action_id=42, end_date="01/09/2026 17:23:51", elapsed_time=15 + ) + assert spec.method == "PUT" + assert spec.path == "actions/I1" + assert spec.json == { + "end_action": { + "action_id": 42, + "end_date": "01/09/2026 17:23:51", + "elapsed_time": 15, + } + } + + +def test_build_end_action_refuses_a_bare_missing_action_id(): + """The id-less bulk form must be asked for, never arrived at by omission. + + ``Action.action_id`` is legitimately ``None`` across this package -- a + create response carries no id, and a ``fields=`` projection without + ``ACTION_ID`` drops it -- so forwarding one would otherwise select the + vendor's "end every open action" form in silence. + """ + with pytest.raises(ValueError, match="end_all"): + a.build_end_action("I1", end_date="01/09/2026 17:00:00") + + +def test_build_end_action_end_all_reaches_the_wire_without_an_action_id(): + """Asked for explicitly, the bulk form omits the key rather than nulling it.""" + spec, _ = a.build_end_action("I1", end_all=True, elapsed_time=15) + assert spec.json == {"end_action": {"elapsed_time": 15}} + assert "action_id" not in spec.json["end_action"] + + +def test_build_end_action_refuses_action_id_and_end_all_together(): + """They are contradictory: end_all IS the id-less form.""" + with pytest.raises(ValueError): + a.build_end_action("I1", action_id=5, end_all=True) + + +@pytest.mark.parametrize("value", [0, "0"]) +def test_build_end_action_sends_a_falsy_elapsed_time(value): + """``is not None``, not truthiness. + + Zero minutes is a legitimate value the server stores; a truthiness check + would drop it silently and the action would end with an empty + ``ELAPSED_TIME`` instead. + """ + spec, _ = a.build_end_action("I1", action_id=1, 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_passes_start_date_through(): + """``start_date`` is the fix for the server's offset-shifted derived one.""" + spec, _ = a.build_end_action( + "I1", action_id="7", start_date="01/09/2026 17:00:00", + end_date="01/09/2026 17:15:00", elapsed_time="15", doneby_mail="a@b.c", + ) + assert spec.json["end_action"] == { + "action_id": "7", + "start_date": "01/09/2026 17:00:00", + "end_date": "01/09/2026 17:15:00", + "elapsed_time": "15", + "doneby_mail": "a@b.c", + } + + +@pytest.mark.parametrize("rfc", ["", " "]) +def test_build_end_action_refuses_a_blank_rfc(rfc): + """Blank would build ``PUT actions/`` -- the collection, not a ticket.""" + with pytest.raises(ValueError): + a.build_end_action(rfc, action_id=1) + + +def test_build_end_action_parses_the_href_only_response_without_raising(): + """The measured response is href-only and names the parent REQUEST. + + It carries no ACTION_ID, so the parsed Action is empty by construction -- + the point of this test is that it parses rather than that it is useful. + """ + _, 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 diff --git a/easyvista_python_client/resources/tests/test_descriptor.py b/easyvista_python_client/resources/tests/test_descriptor.py index af4221e..0ff49aa 100644 --- a/easyvista_python_client/resources/tests/test_descriptor.py +++ b/easyvista_python_client/resources/tests/test_descriptor.py @@ -1,4 +1,8 @@ +import pydantic +import pytest + from easyvista_python_client.models.asset import Asset, PostAsset +from easyvista_python_client.models.request import Request from easyvista_python_client.pagination import SearchResult, extract_records from easyvista_python_client.resources.descriptor import ( ResourceDescriptor, @@ -9,6 +13,9 @@ ) ASSETS = ResourceDescriptor(path="assets", envelope_key="assets", model=Asset) +# A second descriptor, because ``Asset`` declares no timestamp column and the +# context tests need one that does. +REQUESTS = ResourceDescriptor(path="requests", envelope_key="requests", model=Request) def test_extract_records_default_behavior_unchanged(): @@ -65,3 +72,53 @@ def test_build_update_sends_bare_payload(): assert spec.method == "PUT" assert spec.path == "assets/9504" assert spec.json == {"catalog_id": 3153} + + +# --- a projection on the item route, and the validation context ------------- + + +def test_build_get_sends_no_params_without_a_fields_projection(): + """With no projection the spec must be the one this builder always built. + + ``params or None`` rather than a bare ``{}``: an empty dict would probably + behave the same through httpx, but the suite asserts on ``spec.params`` + elsewhere and "probably" is not the standard here. + """ + spec, _ = build_get(ASSETS, "9504") + assert spec.params is None + + +def test_build_get_sends_a_joined_fields_projection(): + spec, _ = build_get(ASSETS, "9504", fields=["ASSET_ID", "ASSET_TAG"]) + assert spec.params == {"fields": "ASSET_ID,ASSET_TAG"} + # A string is passed through as written, not re-joined character by + # character. + spec, _ = build_get(ASSETS, "9504", fields="ASSET_ID") + assert spec.params == {"fields": "ASSET_ID"} + + +_ODD_FORMAT = {"datetime_input_formats": ["%d/%m/%Y %H:%M:%S"]} + + +def test_a_validation_context_reaches_the_search_parser(): + """The context is bound at build time, so the parser signature never + changes -- but it must actually arrive at ``model_validate``.""" + _, parse = build_search(REQUESTS, context=_ODD_FORMAT) + result = parse( + {"records": [{"RFC_NUMBER": "I1", "LAST_UPDATE": "17/08/2026 15:40:00"}]} + ) + assert result.records[0].last_update is not None + # And without it the same payload is refused, which is what makes the + # assertion above mean something. + _, plain = build_search(REQUESTS) + with pytest.raises(pydantic.ValidationError): + plain({"records": [{"RFC_NUMBER": "I1", "LAST_UPDATE": "17/08/2026 15:40:00"}]}) + + +def test_a_validation_context_reaches_the_single_record_parser(): + """``_first_record_parser`` serves get, create and update alike.""" + _, parse = build_get(REQUESTS, "I1", context=_ODD_FORMAT) + parsed = parse( + {"records": [{"RFC_NUMBER": "I1", "LAST_UPDATE": "17/08/2026 15:40:00"}]} + ) + assert parsed.last_update is not None diff --git a/easyvista_python_client/resources/tests/test_discovery.py b/easyvista_python_client/resources/tests/test_discovery.py new file mode 100644 index 0000000..66d124f --- /dev/null +++ b/easyvista_python_client/resources/tests/test_discovery.py @@ -0,0 +1,83 @@ +import pytest + +from easyvista_python_client.resources.discovery import ( + SWAGGER_PATH, + build_get_api_spec, + build_list_reference_table, +) + + +def test_build_get_api_spec_uses_the_measured_route_and_sends_no_params(): + spec, _ = build_get_api_spec() + assert (spec.method, spec.path) == ("GET", SWAGGER_PATH) + assert spec.params is None + + +def test_build_get_api_spec_honours_a_custom_path(): + """The route is tier 4 -- measured on one instance and NOT declared in that + instance's own paths -- so a deployment publishing elsewhere needs a way + through that is not a fork.""" + spec, _ = build_get_api_spec("openapi.json") + assert spec.path == "openapi.json" + + +def test_build_get_api_spec_parses_a_non_dict_body_to_an_empty_dict(): + _, parse = build_get_api_spec() + assert parse(["not", "a", "document"]) == {} + assert parse({"info": {}})["info"] == {} + + +def test_build_list_reference_table_sends_no_params_by_default(): + """The default call is the bare route. + + Every query parameter is sent only when passed -- ``offset`` included, + which is vendor-documented for the requests list and merely inferred on + these routes. + """ + spec, _ = build_list_reference_table("catalog-requests") + assert (spec.method, spec.path) == ("GET", "catalog-requests") + assert spec.params == {} + + +@pytest.mark.parametrize( + ("kwargs", "expected"), + [ + ({"search": 'CODE:"INC"'}, {"search": 'CODE:"INC"'}), + ({"max_rows": 5}, {"max_rows": 5}), + ({"offset": 10}, {"offset": 10}), + ({"sort": "CODE"}, {"sort": "CODE"}), + ({"fields": ["CODE", "TITLE_EN"]}, {"fields": "CODE,TITLE_EN"}), + ], +) +def test_each_keyword_adds_exactly_its_own_param(kwargs, expected): + spec, _ = build_list_reference_table("status", **kwargs) + assert spec.params == expected + + +def test_params_is_merged_last_and_overrides_a_modelled_one(): + """The escape hatch for a query argument this signature does not model.""" + spec, _ = build_list_reference_table( + "status", max_rows=5, params={"max_rows": 99, "formatDate": "iso"} + ) + assert spec.params == {"max_rows": 99, "formatDate": "iso"} + + +def test_a_slashed_path_is_stripped(): + spec, _ = build_list_reference_table("/catalog-requests/") + assert spec.path == "catalog-requests" + + +def test_the_parser_handles_all_three_envelope_shapes(): + """``records``, the resource-named envelope, and a BARE object. + + The bare case is not hypothetical: the instance's own ``GET /status`` + schema shows an object with no ``records`` envelope at all. + """ + _, parse = build_list_reference_table("status") + + def first_status_id(payload): + return parse(payload).records[0].model_dump(by_alias=True)["STATUS_ID"] + + assert first_status_id({"records": [{"STATUS_ID": 8}]}) == 8 + assert first_status_id({"status": [{"STATUS_ID": 8}]}) == 8 + assert first_status_id({"STATUS_ID": 8, "NAME_FR": "Cloture"}) == 8 diff --git a/easyvista_python_client/resources/tests/test_documents.py b/easyvista_python_client/resources/tests/test_documents.py index 5e599da..7f9a9f2 100644 --- a/easyvista_python_client/resources/tests/test_documents.py +++ b/easyvista_python_client/resources/tests/test_documents.py @@ -4,7 +4,10 @@ from easyvista_python_client.models.document import Document from easyvista_python_client.resources import documents as d -from easyvista_python_client.resources.documents import download_href +from easyvista_python_client.resources.documents import ( + build_delete_document, + download_href, +) def test_build_add_document_base64_envelope_and_path(): @@ -82,3 +85,68 @@ def test_download_href_accepts_a_raw_string(): def test_download_href_raises_when_no_url_is_available(): with pytest.raises(ValueError, match="no download URL"): download_href(Document.model_validate({"DOCUMENT": "report.pdf"})) + + +def test_delete_document_uses_the_nested_per_ticket_path(): + """The top-level documents/{id} form returns 403 (verified live).""" + spec = build_delete_document("I240101_0001", "12345_abcdef") + assert spec.method == "DELETE" + assert spec.path == "requests/I240101_0001/documents/12345_abcdef" + assert spec.json is None + + +def test_delete_document_requires_both_identifiers(): + with pytest.raises(ValueError, match="rfc_number"): + build_delete_document("", "12345_abcdef") + with pytest.raises(ValueError, match="document_id"): + build_delete_document("I240101_0001", "") + + +# --- two routes exist for the delete; a 403 never said which ---------------- +# +# The instance OpenAPI document read 2026-08-27 declares DELETE on BOTH +# `requests/{rfc}/documents/{id}` and `documents/{id}`, marking only the +# latter `deprecated`. So the 403 measured against the top-level form was a +# profile denial, not a missing route -- this API answers 403 for an unknown +# path as well as a denied one. + + +def test_build_delete_document_top_level_style(): + spec = build_delete_document("I240101_0001", "12345_abcdef", path_style="top_level") + assert spec.method == "DELETE" + assert spec.path == "documents/12345_abcdef" + + +def test_build_delete_document_top_level_ignores_a_missing_rfc_number(): + """The top-level route has no slot for an RFC, so refusing a missing one + would be surprising and requiring one would defeat the point.""" + spec = build_delete_document(None, "d1", path_style="top_level") + assert spec.path == "documents/d1" + + +def test_build_delete_document_rejects_a_blank_document_id_in_either_style(): + """``DELETE documents/`` addresses the collection just as + ``DELETE requests/{rfc}/documents/`` does.""" + for style in ("nested", "top_level"): + with pytest.raises(ValueError, match="document_id"): + build_delete_document("I240101_0001", " ", path_style=style) + + +def test_build_delete_document_rejects_an_unknown_path_style(): + with pytest.raises(ValueError, match="path_style"): + build_delete_document("I240101_0001", "d1", path_style="nested_v2") + + +def test_build_add_document_unwraps_a_capital_d_documents_envelope(): + """The create parser read the response by a different rule than the list. + + ``_first_document`` used the case-SENSITIVE ``extract_records`` while + ``_document_records`` fifteen lines below was already case-insensitive -- + for the same resource on the same instance, the one known to answer a + capital-D ``Documents``. A create echoed that way yielded an all-``None`` + ``Document`` built from the wrapper. + """ + _, parser = d.build_add_document("I1", filename="a.txt", content=b"x") + parsed = parser({"Documents": [{"DOCUMENT": "a.txt", "DOCUMENT_ID": "x1"}]}) + assert parsed.filename == "a.txt" + assert parsed.document_id == "x1" diff --git a/easyvista_python_client/resources/tests/test_requests.py b/easyvista_python_client/resources/tests/test_requests.py index 14794ef..c0a5cb7 100644 --- a/easyvista_python_client/resources/tests/test_requests.py +++ b/easyvista_python_client/resources/tests/test_requests.py @@ -79,10 +79,10 @@ def test_build_search_tickets_includes_offset(): def test_build_update_ticket(): - spec, _parser = r.build_update_ticket("I1", RequestUpdate(status_id=3)) + spec, _parser = r.build_update_ticket("I1", RequestUpdate(impact_id=3)) assert spec.method == "PUT" assert spec.path == "requests/I1" - assert spec.json == {"status_id": 3} + assert spec.json == {"impact_id": 3} def test_build_close_ticket_default_and_comment(): @@ -108,3 +108,101 @@ def test_build_close_ticket_full_documented_shape(): "comment": "resolved", } } + + +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_close_ticket_carries_the_two_previously_undeclared_documented_fields(): + """``end_date`` and ``catalog_GUID`` are tier 1 and were unreachable. + + The vendor's close body is status_GUID / end_date / catalog_GUID / + delete_actions / comment; this package declared only three of the five, so + requalifying-on-close and back-dating a closure needed extra_payload -- and + neither field is on a write model, so there was no extra_payload to use. + """ + spec, _ = r.build_close_ticket( + "I1", + status_guid="{G}", + end_date="28/08/2026", + catalog_guid="{C}", + comment="done", + delete_actions=True, + ) + assert spec.json == { + "closed": { + "status_GUID": "{G}", + "delete_actions": True, + "comment": "done", + "end_date": "28/08/2026", + "catalog_GUID": "{C}", + } + } + + +def test_close_ticket_omits_every_unset_field(): + """All five are optional: an empty envelope closes to the default status.""" + spec, _ = r.build_close_ticket("I1") + assert spec.json == {"closed": {}} + + +def test_close_ticket_uses_the_vendor_route_not_the_close_subpath(): + """``PUT requests/{rfc}`` with a wrapper IS the documented route. + + An instance's OpenAPI also declares ``PUT|PATCH requests/{rfc}/close``; + this pins that the package deliberately sends the documented one, so a + later reader does not "fix" it into the subpath. + """ + spec, _ = r.build_close_ticket("I1", status_guid="{G}") + assert spec.method == "PUT" + assert spec.path == "requests/I1" + + +def test_delete_actions_passes_a_bool_through_unchanged(): + """The vendor types it boolean; the package used to type it int only.""" + spec, _ = r.build_close_ticket("I1", delete_actions=False) + assert spec.json["closed"]["delete_actions"] is False + + +def test_build_get_ticket_forwards_a_fields_projection(): + """No projection means no ``fields`` parameter at all -- the request this + builder has always sent.""" + spec, _ = r.build_get_ticket("I1") + assert spec.params is None + spec, _ = r.build_get_ticket("I1", fields=["RFC_NUMBER", "TITLE"]) + assert spec.params == {"fields": "RFC_NUMBER,TITLE"} + + +def test_build_close_ticket_unwraps_a_requests_envelope(): + """Green before and after -- it pins the accident. + + This parser passed no envelope key and worked only because ``"requests"`` + happens to sit in ``extract_records``' hardcoded fallback tuple, a list + that belongs to no resource in particular. The key is now explicit. + """ + _, parser = r.build_close_ticket("I1") + assert parser({"requests": [{"RFC_NUMBER": "I1"}]}).rfc_number == "I1" + + +def test_build_close_ticket_unwraps_a_capital_r_requests_envelope(): + """Red before the case-insensitive match.""" + _, parser = r.build_close_ticket("I1") + assert parser({"Requests": [{"RFC_NUMBER": "I1"}]}).rfc_number == "I1" diff --git a/easyvista_python_client/testing/test_method_invocation.py b/easyvista_python_client/testing/test_method_invocation.py index d7da089..a68ac5b 100644 --- a/easyvista_python_client/testing/test_method_invocation.py +++ b/easyvista_python_client/testing/test_method_invocation.py @@ -24,6 +24,7 @@ import respx from easyvista_python_client import ( + ActionUpdate, AsyncEasyvistaClient, DepartmentUpdate, EasyvistaClient, @@ -33,6 +34,7 @@ PostDepartment, PostEmployee, PostRequest, + PostTask, RequestUpdate, ) @@ -43,7 +45,8 @@ #: ``records[0]``. So a search sees a one-record page with real counts, and a #: single-record get/create/update sees that same record. ``parse_memo`` finds #: no matching field and returns ``None``, which is a legal ``resolve_memo`` -#: result. ``ASSET_ID`` must be an int -- ``Asset`` rejects a string. +#: result. ``ASSET_ID`` is an int here because that is the shape the live list +#: returns; ``Asset`` also accepts the ``""`` sentinel and a numeric string. PAYLOAD = { "records": [ { @@ -69,14 +72,31 @@ #: Positional and keyword arguments for every method that does reach HTTP. ARGS: dict[str, tuple[tuple, dict]] = { "add_document": (("I1",), {"filename": "d.txt", "content": b"x"}), + # 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}"}), "count_tickets": ((), {}), - "create_action": (("I1", PostAction()), {}), + "create_action": (("I1", PostAction(action_type_id=94, group_id=3)), {}), + # action_id named explicitly: omitted, the vendor form ends EVERY open + # action, and on a ticket whose only open one is its workflow step that + # resolves the ticket. The registry should model the safe call. + "end_action": (("I1",), {"action_id": 1, "end_date": "01/01/2026 09:00:00"}), + # sample_size=1 / action_sample_tickets=1 keep the blanket-mocked runs + # cheap: the shared PAYLOAD carries one RFC_NUMBER and one ACTION_ID with no + # `@next`, so every sweep terminates after one page on both surfaces. + "describe_instance": ((), {"sample_size": 1, "action_sample_tickets": 1}), + "discover": (("STATUS",), {"sample_size": 1, "action_sample_tickets": 1}), + "get_api_spec": ((), {}), + "list_reference_table": (("status",), {}), + "create_task": (("I1", PostTask(action_type_id=94, group_id=3)), {}), "create_asset": ((PostAsset(catalog_id=1),), {}), "create_department": ((PostDepartment(),), {}), "create_employee": ((PostEmployee(),), {}), "create_ticket": ((PostRequest(catalog_code="C"),), {}), "create_tickets": (([PostRequest(catalog_code="C")],), {}), + "delete_document": (("I1", "d1"), {}), "download_document": (("requests/I1/documents/1",), {}), "find_departments": (("Acme",), {}), "get_action": ((1,), {}), @@ -87,6 +107,7 @@ "get_employee": ((1,), {}), "get_ticket": (("I1",), {}), "get_ticket_context": (("I1",), {}), + "iter_actions": (("I1",), {"max_records": 1}), "iter_assets": ((), {"max_records": 1}), "iter_departments": ((), {"max_records": 1}), "iter_employees": ((), {"max_records": 1}), @@ -98,7 +119,9 @@ "search_departments": ((), {}), "search_employees": ((), {}), "search_tickets": ((), {}), + "stream_document": (("requests/I1/documents/1",), {}), "ticket_statistics": ((), {"max_records": 1}), + "update_action": ((1, ActionUpdate()), {}), "update_department": ((1, DepartmentUpdate()), {}), "update_employee": ((1, EmployeeUpdate()), {}), "update_ticket": (("I1", RequestUpdate()), {}), diff --git a/easyvista_python_client/testing/test_public_api.py b/easyvista_python_client/testing/test_public_api.py index 0a699b8..58b26ef 100644 --- a/easyvista_python_client/testing/test_public_api.py +++ b/easyvista_python_client/testing/test_public_api.py @@ -2,7 +2,7 @@ def test_package_imports_and_has_version(): - assert easyvista_python_client.__version__ == "0.1.0" + assert easyvista_python_client.__version__ == "0.2.0" def test_public_exports_available(): diff --git a/easyvista_python_client/tests/test_config.py b/easyvista_python_client/tests/test_config.py index b9bbb36..a3cb19e 100644 --- a/easyvista_python_client/tests/test_config.py +++ b/easyvista_python_client/tests/test_config.py @@ -69,3 +69,143 @@ def test_repr_does_not_leak_secrets(): server="https://ev.example.com", account="acme", login="u", password="PW123" ) assert "PW123" not in repr(basic) + + +# --- per-deployment adaptation settings -------------------------------------- +# +# Five settings exist so a deployment differing from the verified one needs no +# fork. Each defaults to the value the verified instance already sees, so these +# first assertions are what keep "adding a knob" from changing anyone's wire. + + +def test_adaptation_settings_default_to_todays_behaviour(): + cfg = EasyvistaConfig(server="https://ev.example.com", account="acme", token="abc") + assert cfg.extra_headers == {} + assert cfg.default_params == {} + assert cfg.user_agent is None + assert cfg.additional_download_hosts == frozenset() + assert cfg.verify_ssl is True + + +@pytest.mark.parametrize("key", ["Authorization", "authorization", "AUTHORIZATION"]) +def test_extra_headers_refuses_the_credential_in_any_casing(key): + # HTTP header names are case-insensitive, so the guard must be too: an + # Authorization key here would silently shadow config.token with a secret + # the client cannot see, redact from a repr, or rotate. + with pytest.raises(ValueError, match="must not set"): + EasyvistaConfig( + server="https://ev.example.com", + account="acme", + token="abc", + extra_headers={key: "Bearer other"}, + ) + + +def test_mapping_settings_are_copied_and_read_only(): + # A frozen dataclass holding a live dict is not frozen. Both directions + # matter: the caller's dict must not stay aliased, and the attribute must + # not be writable through the mapping. + headers = {"X-Api-Key": "k"} + params = {"formatDate": "iso"} + cfg = EasyvistaConfig( + server="https://ev.example.com", + account="acme", + token="abc", + extra_headers=headers, + default_params=params, + ) + headers["X-Injected"] = "nope" + params["injected"] = "nope" + assert cfg.extra_headers == {"X-Api-Key": "k"} + assert cfg.default_params == {"formatDate": "iso"} + with pytest.raises(TypeError): + cfg.extra_headers["X"] = "y" # type: ignore[index] + with pytest.raises(TypeError): + cfg.default_params["x"] = "y" # type: ignore[index] + + +def test_config_stays_hashable_with_non_empty_mappings(): + # The regression the explicit __hash__ exists for: the hash @dataclass would + # generate covers every field, and raises TypeError the moment a mapping + # field is non-empty. + cfg = EasyvistaConfig( + server="https://ev.example.com", + account="acme", + token="abc", + extra_headers={"X-Api-Key": "k"}, + default_params={"formatDate": "iso"}, + ) + assert isinstance(hash(cfg), int) + assert len({cfg, cfg}) == 1 + twin = EasyvistaConfig( + server="https://ev.example.com", + account="acme", + token="abc", + extra_headers={"X-Api-Key": "k"}, + default_params={"formatDate": "iso"}, + ) + assert cfg == twin + assert hash(cfg) == hash(twin) + + +def test_additional_download_hosts_are_normalised(): + cfg = EasyvistaConfig( + server="https://ev.example.com", + account="acme", + token="abc", + additional_download_hosts={"CDN.Example.COM ", " ", "cdn2.example.com"}, + ) + assert cfg.additional_download_hosts == frozenset( + {"cdn.example.com", "cdn2.example.com"} + ) + + +def test_repr_does_not_leak_a_secret_in_extra_headers(): + # Headers are the canonical place for a SECOND secret -- an API gateway key, + # a proxy credential -- so they are redacted for the same reason token and + # password are. + cfg = EasyvistaConfig( + server="https://ev.example.com", + account="acme", + token="tok", + extra_headers={"X-Api-Key": "SECRET"}, + ) + assert "SECRET" not in repr(cfg) + + +def test_dataclasses_replace_composes_with_from_env(monkeypatch): + # The documented idiom for adding adaptation settings to an env-built + # config: from_env deliberately reads none of them, so replace() is how the + # two compose. Nothing else pins it. + import dataclasses + + monkeypatch.setenv("EASYVISTA_URL", "https://ev.example.com") + monkeypatch.setenv("EASYVISTA_ACCOUNT", "acme") + monkeypatch.setenv("EASYVISTA_TOKEN", "tok123") + monkeypatch.delenv("EASYVISTA_TOKEN_FILE", raising=False) + cfg = dataclasses.replace( + EasyvistaConfig.from_env(), extra_headers={"X-Api-Key": "k"} + ) + assert cfg.api_root == "https://ev.example.com/api/v1/acme" + assert cfg.extra_headers == {"X-Api-Key": "k"} + assert cfg.token == "tok123" + + +# --- the two attachment-delete routes ---------------------------------------- + + +def test_document_delete_path_style_defaults_to_nested(): + """The form verified live 2026-08-17, where the top-level one answered 403.""" + cfg = EasyvistaConfig(server="https://ev.test", account="acme", token="t") + assert cfg.document_delete_path_style == "nested" + + +def test_document_delete_path_style_rejects_an_unknown_value(): + """A typo surfaces at config build, not at the first delete.""" + with pytest.raises(ValueError, match="document_delete_path_style"): + EasyvistaConfig( + server="https://ev.test", + account="acme", + token="t", + document_delete_path_style="nested_v2", + ) diff --git a/easyvista_python_client/tests/test_context.py b/easyvista_python_client/tests/test_context.py index 5749ef4..7bf6caa 100644 --- a/easyvista_python_client/tests/test_context.py +++ b/easyvista_python_client/tests/test_context.py @@ -29,6 +29,7 @@ def _ticket() -> Request: "HREF": "https://h/api/v1/12345/catalog-requests/5791", }, "CREATION_DATE_UT": "2025-11-28T11:35:22+01:00", + "LAST_UPDATE": "2025-11-28T16:14:41.133+01:00", } ) @@ -39,6 +40,13 @@ def test_to_markdown_has_title_and_header_labels(): assert "| Status | En cours |" in md assert "| Department | Example Department |" in md assert "| Catalog | [EXAMPLE] - ticket |" in md + # Exact rendered literals, not just "Created"/"Updated" substrings -- a + # presence-only check would still pass on an empty cell (the 2026-08-17 + # retype briefly made these rows vanish entirely: model_dump(by_alias=True) + # yields a datetime for these keys now, and the extractor used to return "" + # for anything that wasn't already a str). + assert "| Created | 2025-11-28T11:35:22.000+01:00 |" in md + assert "| Updated | 2025-11-28T16:14:41.133+01:00 |" in md def test_to_markdown_contains_no_api_url(): @@ -155,3 +163,226 @@ def test_to_markdown_skips_nameless_document(): assert "- \n" not in md assert not md.rstrip().endswith("- ") assert "/api/" not in md + + +def test_ticket_context_memos_defaults_to_empty() -> None: + ctx = TicketContext( + ticket=Request(RFC_NUMBER="I1"), + description=None, + comment=None, + actions=[], + documents=[], + ) + assert ctx.memos == {} + + +def test_ticket_context_carries_arbitrary_memos() -> None: + ctx = TicketContext( + ticket=Request(RFC_NUMBER="I1"), + description="d", + comment="c", + actions=[], + documents=[], + memos={"description": "d", "comment": "c", "solution": "s"}, + ) + assert ctx.memos["solution"] == "s" + + +def test_a_lone_non_default_memo_is_rendered_as_the_body() -> None: + """The headline case for ``memo_fields``: a body memo that is neither + default must still reach the export. + + ``get_ticket_context(rfc, memo_fields=("solution",))`` leaves both + ``description`` and ``comment`` unset by construction. Rendering only + those two produced a document with no body section and no warning, so the + one feature the parameter exists for silently lost its content. + """ + md = TicketContext( + ticket=_ticket(), + description=None, + comment=None, + actions=[], + documents=[], + memos={"solution": "

replaced the fuser

"}, + ).to_markdown() + assert "## Description" in md + assert "replaced the fuser" in md + assert "## Solution" not in md + + +def test_several_non_default_memos_each_keep_their_own_heading() -> None: + """Two populated memos are a real distinction, as with the two defaults. + + The heading comes from the field name the caller asked for, so the export + still says which memo carried which block. + """ + md = TicketContext( + ticket=_ticket(), + description=None, + comment=None, + actions=[], + documents=[], + memos={"solution": "replaced the fuser", "workaround": "print to PDF"}, + ).to_markdown() + assert "## Solution" in md + assert "## Workaround" in md + assert md.index("## Solution") < md.index("## Workaround") + assert "replaced the fuser" in md + assert "print to PDF" in md + + +def test_an_empty_non_default_memo_adds_no_section() -> None: + """A memo that resolved to nothing must not invent a heading.""" + md = TicketContext( + ticket=_ticket(), + description=None, + comment=None, + actions=[], + documents=[], + memos={"solution": None, "workaround": " "}, + ).to_markdown() + assert "## Description" not in md + assert "## Solution" not in md + assert "## Workaround" not in md + + +def test_a_populated_default_memo_still_wins_over_memos() -> None: + """The default path is untouched: no duplicate body when both are present. + + ``get_ticket_context`` puts the defaults in ``memos`` as well as on the + two attributes, so a renderer that appended ``memos`` unconditionally + would emit the body twice. + """ + md = TicketContext( + ticket=_ticket(), + description=None, + comment="the printer is offline", + actions=[], + documents=[], + memos={"description": None, "comment": "the printer is offline"}, + ).to_markdown() + assert md.count("## Description") == 1 + assert "## Comment" not in md + assert md.count("the printer is offline") == 1 + + +# --- label language, and the headings that deliberately do not follow it ------ + + +def test_to_markdown_action_heading_prefers_a_translated_action_type_name(): + # The nested ACTION_TYPE's NAME_ columns are the first rung, and the + # bracketed English echo must lose to the real French text. + action = Action.model_validate( + { + "ACTION_ID": 1, + "ACTION_TYPE": {"NAME_EN": "[Prise d'appel]", "NAME_FR": "Prise d'appel"}, + } + ) + md = TicketContext(_ticket(), None, None, [action], []).to_markdown() + assert "### Prise d'appel" in md + # Asking for French directly reaches the same column. + md_fr = TicketContext(_ticket(), None, None, [action], []).to_markdown( + languages=("_FR",) + ) + assert "### Prise d'appel" in md_fr + + +def test_to_markdown_action_heading_falls_back_to_any_action_label_column(): + # Second rung. Before this, only ACTION_LABEL_FR was read, so an English + # instance produced the literal "### Action" here. + action = Action.model_validate({"ACTION_ID": 1, "ACTION_LABEL_EN": "Call intake"}) + md = TicketContext(_ticket(), None, None, [action], []).to_markdown() + assert "### Call intake" in md + + +def test_to_markdown_language_order_changes_labels_but_not_headings(): + # Pins the deliberate split: the CONTENT follows `languages`, the FRAME is + # this method's output contract. to_markdown is an LLM/RAG-facing export and + # a chunker splits on "## " -- making the frame vary per deployment would + # turn a stable contract into a per-instance one, and fail silently. + ticket = Request.model_validate( + { + "RFC_NUMBER": "I1", + "STATUS": {"STATUS_EN": "Open", "STATUS_FR": "En cours"}, + } + ) + context = TicketContext(ticket, None, None, [], [Document(FILENAME="a.txt")]) + assert "| Status | Open |" in context.to_markdown() + md_fr = context.to_markdown(languages=("_FR",)) + assert "| Status | En cours |" in md_fr + # The frame is identical in both. + for md in (context.to_markdown(), md_fr): + assert "| Field | Value |" in md + assert "## Attachments" in md + + +# --- the field table is a parameter, and a refused section says so ----------- + + +def test_to_markdown_default_fields_are_unchanged(): + """The equivalence check for collapsing the row extraction. + + The two date rows used to take a separate ``_text(data.get(...))`` path + while the four reference rows went through ``reference()``. They are one + path now, which is only safe because ``resolve_reference`` renders a + datetime through ``format_ev_datetime`` -- byte-identically to what + ``_text`` produced. These are the same six literals the header test asserts; + a drift in either direction reddens both. + """ + md = TicketContext(_ticket(), None, None, [], []).to_markdown() + assert "| Status | En cours |" in md + assert "| Department | Example Department |" in md + assert "| Catalog | [EXAMPLE] - ticket |" in md + assert "| Created | 2025-11-28T11:35:22.000+01:00 |" in md + assert "| Updated | 2025-11-28T16:14:41.133+01:00 |" in md + + +def test_to_markdown_honours_custom_fields(): + """``fields`` is (label, column) pairs, not a flat list of column names. + + A pair whose column resolves to nothing is dropped, so naming a column the + instance does not return costs an absent row rather than an error. + """ + ticket = Request.model_validate( + {"RFC_NUMBER": "I1", "EXTERNAL_REFERENCE": "EVCLI-42"} + ) + md = TicketContext(ticket, None, None, [], []).to_markdown( + fields=[("Ref", "EXTERNAL_REFERENCE"), ("Absent", "NOT_A_COLUMN")] + ) + assert "| Ref | EVCLI-42 |" in md + assert "Absent" not in md + assert "| Status |" not in md + + +def test_to_markdown_reports_a_degraded_section(): + """A refused list must not read as an empty one. + + Omitting the section silently makes the export say "this ticket has no + attachments" when the attachment list was actually forbidden -- exactly the + kind of confident wrong statement an LLM reading this document would repeat. + """ + md = TicketContext( + _ticket(), None, None, [], [], degraded=frozenset({"actions:403"}) + ).to_markdown() + assert "## Actions" in md + assert "_Not available (HTTP 403)._" in md + # An undegraded empty bundle still renders no heading at all. + plain = TicketContext(_ticket(), None, None, [], []).to_markdown() + assert "## Actions" not in plain + assert "Not available" not in plain + + +def test_to_markdown_degraded_lookup_survives_a_colon_in_the_branch_name(): + """A memo branch is itself ``"memo:"``, so the split must be on the + LAST colon. A plain ``split(":")`` would read the branch as ``"memo"`` and + never match ``"documents"`` here.""" + md = TicketContext( + _ticket(), + None, + None, + [], + [], + degraded=frozenset({"memo:comment:404", "documents:403"}), + ).to_markdown() + assert "## Attachments" in md + assert "_Not available (HTTP 403)._" in md diff --git a/easyvista_python_client/tests/test_directory.py b/easyvista_python_client/tests/test_directory.py index 1450077..2f48683 100644 --- a/easyvista_python_client/tests/test_directory.py +++ b/easyvista_python_client/tests/test_directory.py @@ -1,4 +1,5 @@ from easyvista_python_client.directory import ( + RECENT_TICKETS_SORT, DepartmentContext, _department_matches, _normalize_name, @@ -15,6 +16,29 @@ def test_normalize_name_is_space_and_hyphen_and_case_insensitive(): ) +def test_normalize_name_folds_accents_and_compatibility_forms(): + """Strictly more permissive than the ``lower()`` this replaced. + + It is a pure function of its argument, so every pair that matched before + still matches (the test above is the guard for that) -- what changes is + that more pairs now match. The accent case is the one that mattered: on an + instance whose department labels are French, an unaccented search term + matched nothing. + + The NO-BREAK-SPACE case pins the ORDER. NFKD itself decomposes U+00A0 (and + the whole U+2000..U+200A family) to a plain space, so removing spaces + before normalising would leave one behind. + """ + assert _normalize_name("Systemes") == _normalize_name("Systèmes") + assert _normalize_name("Straße") == _normalize_name("STRASSE") + # Built with chr(), not a literal: a NO-BREAK SPACE is indistinguishable + # from a plain one on screen, and a reader has to be able to see that + # these are different characters for the assertion to mean anything. + nbsp = "ACME" + chr(0xA0) + "CORP" + assert nbsp != "ACME CORP" + assert _normalize_name(nbsp) == _normalize_name("ACME CORP") + + def test_department_matches_across_localized_fields_and_ignores_href(): dept = Department.model_validate( {"DEPARTMENT_FR": "ACME CORP", "HREF": "https://h/api/v1/12345/departments/60"} @@ -36,3 +60,22 @@ def test_department_context_holds_all_parts(): ) assert ctx.department.department_id == 60 assert ctx.employees[0].employee_id == 1 + # Both new fields are defaulted and appended last, so this fully-keyworded + # construction still works untouched. An empty `degraded` means nothing was + # swallowed -- NOT that everything came back populated. + assert ctx.memos == {} + assert ctx.degraded == frozenset() + + +def test_recent_tickets_sort_uses_the_space_separated_form(): + """The colon form is SILENTLY IGNORED by EasyVista (measured 2026-08-17). + + On a date column, `FIELD:DESC` returned rows in the API's default order — + byte-identical to an unsorted page — so a colon-form constant is the + difference between "most recent" and "arbitrary". The rule is syntactic, so + it applies to RFC_NUMBER too; the live guard for this exact token lives in + integration_tests/test_live_change_window.py. A regression here is invisible + at runtime, which is why it is asserted at all. + """ + assert RECENT_TICKETS_SORT == "RFC_NUMBER DESC" + assert ":" not in RECENT_TICKETS_SORT diff --git a/easyvista_python_client/tests/test_discovery.py b/easyvista_python_client/tests/test_discovery.py new file mode 100644 index 0000000..6707131 --- /dev/null +++ b/easyvista_python_client/tests/test_discovery.py @@ -0,0 +1,319 @@ +"""Offline tests for the discovery extractors. Nothing here touches a network.""" + +import pytest + +from easyvista_python_client.discovery import ( + REFERENCE_SOURCES, + ReferenceSource, + guids_from_sample, + merge_guids, + reference_from_table_row, + references_from_sample, + resolve_source, + sample_fields, +) + +# --- the name -> route map --------------------------------------------------- + + +@pytest.mark.parametrize( + ("name", "path"), + [ + ("STATUS", "status"), + # SINGULAR, and asserted as such on purpose: the vendor documents + # `GET /urgencies` (tier 1) while this deployment declares + # `GET /urgency` (tier 2). A well-meaning "fix" to the plural must fail + # here rather than silently 403 on the instance the package was + # characterized against. O-URGPATH stays open; `reference_path=` is the + # escape hatch, not a resolution. + ("URGENCY", "urgency"), + ("CATALOG_REQUEST", "catalog-requests"), + ("LOCATION", "locations"), + ("DEPARTMENT", "departments"), + ("GROUP", "groups"), + ("SLA", "slas"), + ], +) +def test_resolve_source_maps_a_declared_route(name, path): + assert resolve_source(name).reference_path == path + # Case-insensitively, so a caller may pass the lowercase column name. + assert resolve_source(name.lower()).reference_path == path + + +@pytest.mark.parametrize("name", ["IMPACT", "SEVERITY", "ORIGIN", "ACTION_TYPE"]) +def test_four_names_have_no_reference_route_at_all(name): + """A topology fact from the spec's ``paths``, not a 403 someone measured. + + No strategy can reach a table for these, so what discovery returns is "the + ids in use in the sample" -- an id configured but unused is invisible. + """ + assert resolve_source(name).reference_path is None + + +def test_an_unknown_name_becomes_a_sampling_only_ticket_source(): + """Exactly right for a custom ``e_*`` column, which has no table.""" + source = resolve_source("e_site") + assert source.name == "E_SITE" + assert source.reference_path is None + assert source.sample_from == "tickets" + assert source.sample_field == "E_SITE" + + +def test_reference_path_overrides_a_mapped_route_and_supplies_a_missing_one(): + assert resolve_source("URGENCY", reference_path="urgencies").reference_path == ( + "urgencies" + ) + # And for a name the map gives no route at all. + impact = resolve_source("IMPACT", reference_path="impacts") + assert impact.reference_path == "impacts" + assert impact.sample_field == "IMPACT" # the rest of the source is preserved + + +def test_origin_samples_the_column_that_is_actually_returned(): + """``PostRequest.origin`` reads back as ``REQUEST_ORIGIN_ID``. + + ``ORIGIN`` itself is never returned, so projecting it would sample a column + that is always absent. + """ + assert resolve_source("ORIGIN").sample_field == "REQUEST_ORIGIN" + + +def test_group_samples_from_actions_because_no_ticket_carries_one(): + assert REFERENCE_SOURCES["GROUP"].sample_from == "actions" + assert REFERENCE_SOURCES["STATUS"].sample_from == "tickets" + + +# --- one reference-table row ------------------------------------------------- + + +def test_table_row_id_prefers_the_mapped_id_field(): + """``catalog-requests`` names its id ``SD_CATALOG_ID``, not ``*_ID``.""" + row = {"CODE": "INC", "SD_CATALOG_ID": 5791, "TITLE_EN": "Incident"} + got = reference_from_table_row(row, resolve_source("CATALOG_REQUEST")) + assert got.id == "5791" + assert got.code == "INC" + assert got.label == "Incident" + + +def test_table_row_id_falls_back_to_the_name_prefixed_column(): + row = {"LOCATION_ID": 12, "LOCATION_FR": "Paris", "LOCATION_CODE": "PAR"} + got = reference_from_table_row(row, resolve_source("LOCATION")) + assert (got.id, got.label, got.code) == ("12", "Paris", "PAR") + + +def test_table_row_id_falls_back_to_the_href_tail(): + row = {"HREF": "https://h/api/v1/acme/locations/12/"} + assert reference_from_table_row(row, resolve_source("LOCATION")).id == "12" + + +def test_a_nested_id_never_becomes_the_row_id(): + """Only TOP-LEVEL keys are scanned. + + A ``/catalog-requests`` row carries a nested ``MANAGER`` and a nested + ``SLA``, each with its own ``*_ID``. Scanning recursively would let an + employee id become the catalog id, silently. + """ + row = { + "CODE": "INC", + "MANAGER": {"EMPLOYEE_ID": 42}, + "SLA": {"SLA_ID": 7}, + "SD_CATALOG_ID": 5791, + } + assert reference_from_table_row(row, resolve_source("CATALOG_REQUEST")).id == "5791" + # And with the mapped id absent, the nested ones still cannot win. + del row["SD_CATALOG_ID"] + assert reference_from_table_row(row, resolve_source("CATALOG_REQUEST")).id is None + + +def test_code_precedence_prefers_the_name_prefixed_column(): + row = {"LOCATION_ID": 1, "ZIP_CODE": "75001", "LOCATION_CODE": "PAR"} + assert reference_from_table_row(row, resolve_source("LOCATION")).code == "PAR" + + +def test_path_resolves_from_the_name_prefixed_column(): + row = {"DEPARTMENT_ID": 60, "DEPARTMENT_PATH": "ACME/IT"} + assert reference_from_table_row(row, resolve_source("DEPARTMENT")).path == "ACME/IT" + + +def test_the_raw_row_is_kept_verbatim(): + """So an instance-specific column is still one dict lookup away.""" + row = {"GROUP_ID": 3, "GROUP_EN": "N1", "E_SITE": "Paris"} + got = reference_from_table_row(row, resolve_source("GROUP")) + assert got.record["E_SITE"] == "Paris" + + +def test_label_from_a_table_row_matches_by_suffix_not_by_prefix(): + """Four tables, four different label prefixes, one suffix rule.""" + cases = [ + ({"GROUP_ID": 3, "GROUP_EN": "N1"}, "GROUP", "N1"), + ({"LOCATION_ID": 1, "LOCATION_FR": "Paris"}, "LOCATION", "Paris"), + ({"SD_CATALOG_ID": 1, "TITLE_EN": "Incident"}, "CATALOG_REQUEST", "Incident"), + ({"SLA_ID": 1, "NAME_FR": "Standard"}, "SLA", "Standard"), + ] + for row, name, expected in cases: + assert reference_from_table_row(row, resolve_source(name)).label == expected + + +# --- sampled records --------------------------------------------------------- + + +def test_references_from_sample_groups_counts_and_orders(): + records = [ + {"STATUS": {"STATUS_ID": "2", "STATUS_EN": "Open"}}, + {"STATUS": {"STATUS_ID": "8", "STATUS_EN": "Closed"}}, + {"STATUS": {"STATUS_ID": "2", "STATUS_EN": "Open"}}, + ] + got = references_from_sample(records, resolve_source("STATUS")) + assert [(r.id, r.label, r.count) for r in got] == [ + ("2", "Open", 2), + ("8", "Closed", 1), + ] + assert all(r.source == "sample" for r in got) + + +def test_references_from_sample_reads_a_bare_top_level_id(): + """Not every reference comes back as a nested object.""" + records = [{"URGENCY_ID": "1"}, {"URGENCY_ID": "2"}, {"URGENCY_ID": "1"}] + got = references_from_sample(records, resolve_source("URGENCY")) + assert [(r.id, r.count) for r in got] == [("1", 2), ("2", 1)] + + +def test_a_sampled_group_has_no_label_rather_than_a_fabricated_one(): + """An action carries ``GROUP_ID`` but no group label. + + Inventing one from the id would be worse than saying so: a caller would + render "3" as if it were a group name. + """ + got = references_from_sample( + [{"ACTION_ID": 1, "GROUP_ID": 3}], resolve_source("GROUP") + ) + assert [(r.id, r.label) for r in got] == [("3", None)] + + +def test_a_sampled_action_type_reads_its_label_from_the_sibling_columns(): + """An action's type label is NOT in a nested object. + + It lives in sibling ``ACTION_LABEL_`` columns, which is the one shape + ``resolve_reference`` cannot read on its own. ``sample_fields`` projects + those columns for exactly this reason -- without the fallback the + projection would be requested and then ignored, and every discovered action + type would come back with ``label=None``. + """ + records = [ + { + "ACTION_ID": 1, + "ACTION_TYPE_ID": 94, + "ACTION_LABEL_EN": "Customer Comment", + } + ] + got = references_from_sample(records, resolve_source("ACTION_TYPE")) + assert [(r.id, r.label) for r in got] == [("94", "Customer Comment")] + + +def test_a_bracketed_action_label_is_not_mistaken_for_a_translation(): + """On a single-language instance the other columns echo the primary text + in brackets. ``localized_label`` already skips those, and the fallback must + inherit that rather than reintroducing the placeholder.""" + records = [ + { + "ACTION_ID": 1, + "ACTION_TYPE_ID": 95, + "ACTION_LABEL_EN": "[Note Interne]", + "ACTION_LABEL_FR": "Note Interne", + } + ] + got = references_from_sample(records, resolve_source("ACTION_TYPE")) + assert got[0].label == "Note Interne" + + +def test_a_sampled_group_still_has_no_label_after_the_action_fallback(): + """The fallback is scoped to ACTION_TYPE, not to every action source. + + An action carries ``GROUP_ID`` and an ``ACTION_LABEL_*`` describing the + ACTION, not the group -- borrowing it would label group 3 "Customer + Comment". + """ + records = [ + {"ACTION_ID": 1, "GROUP_ID": 3, "ACTION_LABEL_EN": "Customer Comment"} + ] + got = references_from_sample(records, resolve_source("GROUP")) + assert [(r.id, r.label) for r in got] == [("3", None)] + + +def test_a_sampled_entry_carries_no_code_or_path(): + """Those are reference-table columns; a sampled record has neither.""" + got = references_from_sample( + [{"STATUS": {"STATUS_ID": "2", "STATUS_EN": "Open"}}], + resolve_source("STATUS"), + ) + assert got[0].code is None + assert got[0].path is None + + +# --- the STATUS_GUID recipe -------------------------------------------------- + + +def test_guids_from_sample_reads_the_nested_status_guid(): + records = [ + {"STATUS": {"STATUS_ID": "8", "STATUS_GUID": "{ABC}", "STATUS_FR": "Cloture"}}, + {"STATUS": {"STATUS_ID": "12", "STATUS_GUID": "{DEF}"}}, + ] + assert guids_from_sample(records, resolve_source("STATUS")) == { + "8": "{ABC}", + "12": "{DEF}", + } + + +def test_guids_from_sample_is_empty_for_a_source_with_no_guid_field(): + """Today only STATUS has one, so nothing else pays for the lookup.""" + records = [{"URGENCY": {"URGENCY_ID": "1", "URGENCY_GUID": "{X}"}}] + assert guids_from_sample(records, resolve_source("URGENCY")) == {} + + +def test_merge_guids_fills_only_matching_ids(): + """A status no sampled ticket holds keeps ``guid=None``. + + The sample cannot reach it, and inventing one would hand a caller a GUID + that addresses nothing. + """ + discovered = references_from_sample( + [{"STATUS": {"STATUS_ID": "2"}}, {"STATUS": {"STATUS_ID": "8"}}], + resolve_source("STATUS"), + ) + merged = merge_guids(discovered, {"2": "{ABC}"}) + assert {r.id: r.guid for r in merged} == {"2": "{ABC}", "8": None} + assert len(merged) == len(discovered) # nothing dropped + + +# --- the sample projection --------------------------------------------------- + + +def test_sample_fields_for_a_ticket_source_asks_for_the_nested_object_and_ids(): + fields = sample_fields(resolve_source("STATUS")) + for expected in ("RFC_NUMBER", "STATUS", "STATUS_ID", "STATUS_GUID"): + assert expected in fields + + +def test_sample_fields_adds_action_labels_only_for_action_type(): + """An action's default row is deliberately slim, so the translated + ``ACTION_LABEL_`` columns must be asked for by name -- but only where + they are the label, which is ACTION_TYPE and not GROUP.""" + action_type = sample_fields(resolve_source("ACTION_TYPE")) + assert "ACTION_LABEL_EN" in action_type + assert "ACTION_TYPE_ID" in action_type + + group = sample_fields(resolve_source("GROUP")) + assert "GROUP_ID" in group + assert not any(f.startswith("ACTION_LABEL") for f in group) + + +def test_sample_fields_honours_a_language_reordering(): + fields = sample_fields(resolve_source("ACTION_TYPE"), languages=("_FR",)) + assert "ACTION_LABEL_FR" in fields + assert "ACTION_LABEL_EN" not in fields + + +def test_a_custom_source_map_replaces_the_whole_routing_table(): + """For a deployment that routes a table somewhere else entirely.""" + custom = {"STATUS": ReferenceSource("STATUS", "etats", "tickets", "STATUS")} + assert resolve_source("STATUS", sources=custom).reference_path == "etats" diff --git a/easyvista_python_client/tests/test_fields.py b/easyvista_python_client/tests/test_fields.py index 880fb1c..df946a6 100644 --- a/easyvista_python_client/tests/test_fields.py +++ b/easyvista_python_client/tests/test_fields.py @@ -1,4 +1,6 @@ -from easyvista_python_client._fields import _label, _text +from datetime import datetime, timedelta, timezone + +from easyvista_python_client._fields import _text def test_text_strips_strings_and_ignores_non_strings(): @@ -7,11 +9,16 @@ def test_text_strips_strings_and_ignores_non_strings(): assert _text(123) == "" -def test_label_prefers_first_non_empty_key_and_drops_href(): - obj = {"STATUS_EN": "", "STATUS_FR": "En cours", "HREF": "http://x/api/v1"} - assert _label(obj, ("STATUS_EN", "STATUS_FR")) == "En cours" +def test_text_renders_an_aware_datetime_as_the_ev_wire_format(): + value = datetime( + 2026, 8, 17, 15, 40, 41, 610000, tzinfo=timezone(timedelta(hours=2)) + ) + assert _text(value) == "2026-08-17T15:40:41.610+02:00" + +def test_text_renders_a_naive_datetime_via_isoformat_fallback(): + # format_ev_datetime refuses a naive datetime; _text must never raise, so it + # falls back to plain .isoformat() rather than propagating that ValueError. + value = datetime(2026, 8, 17, 15, 40, 41) + assert _text(value) == "2026-08-17T15:40:41" -def test_label_returns_empty_for_non_dict_or_missing_keys(): - assert _label(None, ("A",)) == "" - assert _label({"B": "x"}, ("A",)) == "" diff --git a/easyvista_python_client/tests/test_filters.py b/easyvista_python_client/tests/test_filters.py index 6d0d542..cef5fb1 100644 --- a/easyvista_python_client/tests/test_filters.py +++ b/easyvista_python_client/tests/test_filters.py @@ -1,12 +1,20 @@ +from datetime import datetime, timedelta, timezone + import pytest from easyvista_python_client import ( escape_ev_value, + ev_between_filter, + ev_contains_filter, ev_equals_filter, ev_in_filter, + ev_since_filter, + ev_starts_with_filter, is_safe_ev_value, ) +_CET = timezone(timedelta(hours=1)) + def test_equals_filter_quotes_the_value(): assert ev_equals_filter("DEPARTMENT_CODE", "ACME") == 'DEPARTMENT_CODE:"ACME"' @@ -55,3 +63,399 @@ def test_equals_filter_rejects_unsafe_value(): def test_is_safe_predicate_never_raises(): assert is_safe_ev_value("ACME") is True assert is_safe_ev_value('X"') is False + + +def test_since_emits_the_open_ended_interval(): + """``FIELD:(a;)`` — the form measured live as a watermark lower bound. + + The bound is NORMALISED, not passed through. The literal below is the most + natural way for a caller to satisfy the offset gate, and measured live + 2026-08-18 it is HTTP 590 exactly as written -- second precision with an + offset is not a rendering the interval grammar accepts. So the builder + re-renders it at millisecond precision, which is. + """ + got = ev_since_filter("LAST_UPDATE", "2025-11-28T16:14:41+01:00") + assert got == "LAST_UPDATE:(2025-11-28T16:14:41.000+01:00;)" + + +def test_since_normalises_a_space_separated_literal_to_the_T_form(): + """``str(aware_datetime)`` uses a space, which is HTTP 590 on the wire. + + Measured live 2026-08-18 (and again in round 1). Normalising is what makes + the most obvious Python rendering of an aware datetime usable at all. + """ + got = ev_since_filter("LAST_UPDATE", "2025-11-28 16:14:41.133+01:00") + assert got == "LAST_UPDATE:(2025-11-28T16:14:41.133+01:00;)" + + +def test_since_accepts_a_lowercase_z_the_read_path_already_accepts(): + """``parse_ev_datetime`` accepts ``z``; the gate must not contradict it. + + Rejecting a value this package's own read path produces -- with a message + reading "is not an EasyVista timestamp" -- would be actively misleading. + """ + got = ev_since_filter("LAST_UPDATE", "2025-11-28T15:14:41.133z") + assert got == "LAST_UPDATE:(2025-11-28T15:14:41.133+00:00;)" + + +def test_the_string_and_datetime_paths_emit_byte_identical_bounds(): + """The last asymmetry between the two input paths, closed by normalisation. + + Before this, the same instant emitted two different literals depending on + whether the caller had already stringified it -- and only one of the two was + a rendering the wire honours. + """ + dt = datetime(2025, 11, 28, 16, 14, 41, 133000, tzinfo=_CET) + assert ev_since_filter("LAST_UPDATE", dt) == ev_since_filter( + "LAST_UPDATE", dt.isoformat() + ) + + +def test_since_accepts_a_datetime_and_formats_the_offset_literal(): + dt = datetime(2025, 11, 28, 16, 14, 41, 133000, tzinfo=_CET) + assert ev_since_filter("LAST_UPDATE", dt) == ( + "LAST_UPDATE:(2025-11-28T16:14:41.133+01:00;)" + ) + + +def test_between_emits_both_bounds(): + got = ev_between_filter("LAST_UPDATE", "2025-11-28", "2099-12-31") + assert got == "LAST_UPDATE:(2025-11-28;2099-12-31)" + + +def test_blank_input_returns_none_so_callers_compose_without_conditionals(): + assert ev_since_filter("LAST_UPDATE", None) is None + assert ev_since_filter("LAST_UPDATE", "") is None + assert ev_between_filter("LAST_UPDATE", None, None) is None + + +def test_between_with_only_an_end_bound_is_open_on_the_left(): + assert ev_between_filter("LAST_UPDATE", None, "2099-12-31") == ( + "LAST_UPDATE:(;2099-12-31)" + ) + + +@pytest.mark.parametrize( + "bad", + [ + '2025-11-28";DEPARTMENT_ID:"9', # quote breakout + "2025-11-28;2099-12-31", # a second bound smuggled in + "2025-11-28)", # closes the interval early + "2025-11-28 or 1=1", + "today", # a real EV token, but not a timestamp + ], +) +def test_interval_refuses_anything_that_is_not_a_timestamp(bad): + """The interval value is UNQUOTED, so ';' and ')' would break out of it. + + ``ev_equals_filter`` can rely on quoting; this one cannot, so it validates + the shape instead. Refuse rather than interpolate. + """ + with pytest.raises(ValueError, match="timestamp"): + ev_since_filter("LAST_UPDATE", bad) + + +@pytest.mark.parametrize( + "bad", + [ + "9999-99-99", # no such month + "2025-02-30", # no such day (February) + "2025-13-45T99:99:99", # no such month/day/time at all + "2025-11-28T25:61:61", # out-of-range time components + "٢٠٢٥-١١-٢٨", # non-ASCII digits # noqa: RUF001 + ], +) +def test_interval_refuses_a_well_shaped_but_impossible_timestamp(bad): + """The regex is a shape gate only; a calendar-invalid value must still be + refused, because a dropped condition returns the whole table rather than + an error (this is what makes a typo'd watermark dangerous). + """ + with pytest.raises(ValueError, match="timestamp"): + ev_since_filter("LAST_UPDATE", bad) + + +@pytest.mark.parametrize( + ("literal", "emitted"), + [ + # A date alone is legal AND is passed through unchanged: day + # granularity, no time to misplace, and measured live as honoured + # (round 1: 4107 rows against a 4316-row table). Re-rendering it would + # invent a midnight instant in some zone. + ("2025-11-28", "2025-11-28"), + # Already the one honoured time rendering: normalisation is a no-op. + ("2025-11-28T16:14:41.133+01:00", "2025-11-28T16:14:41.133+01:00"), + # Microseconds are truncated to EasyVista's own millisecond precision. + ("2025-11-28T16:14:41.133456Z", "2025-11-28T16:14:41.133+00:00"), + ], +) +def test_interval_accepts_every_rendering_measured_live(literal, emitted): + """The guard's acceptance side: a regression here fails CLOSED on real + watermarks, which no rejection test would catch. + + Accepted is not the same as emitted verbatim -- every admitted *time* is + re-rendered into the one form measured live as honoured. See + ``test_since_emits_the_open_ended_interval``. + + The offset-less *time* renderings that round 1 measured as accepted by the + API moved to ``test_interval_refuses_a_time_without_an_offset``: the wire + takes them, but it reads them in another zone. See that test. + """ + assert ev_since_filter("LAST_UPDATE", literal) == f"LAST_UPDATE:({emitted};)" + + +def test_interval_refuses_a_sub_minute_utc_offset_on_either_path(): + """A whole-minute offset is not a given: historical zoneinfo zones break it. + + ``format_ev_datetime`` would render ``+05:53:20``, which no shape this + grammar accepts can express -- and which the string path already refused. + Validating the RENDERED bound is what keeps the datetime path from emitting + something its own sibling path would reject. + + Both paths must give the SAME diagnosis, which is why the string half matches + on "whole number of minutes" rather than merely on "timestamp". The generic + message ("is not an EasyVista timestamp ... pass a datetime to be certain") + would be wrong twice over here: the value IS a valid ISO-8601 timestamp -- it + is what ``isoformat()`` returns for such a zone -- and following the advice + raises on the datetime path for the same underlying reason. + """ + odd = timezone(timedelta(hours=5, minutes=53, seconds=20)) + aware = datetime(2025, 11, 28, 16, 14, 41, tzinfo=odd) + with pytest.raises(ValueError, match="whole number of minutes"): + ev_since_filter("LAST_UPDATE", aware) + with pytest.raises(ValueError, match="whole number of minutes"): + ev_since_filter("LAST_UPDATE", aware.isoformat()) + # The fractional-second variant takes the same branch: `isoformat()` emits + # microseconds whenever they are non-zero, so this is the shape a caller who + # serialised `datetime.now(odd)` would actually hand back. + with pytest.raises(ValueError, match="whole number of minutes"): + ev_since_filter("LAST_UPDATE", aware.replace(microsecond=133999).isoformat()) + # A genuinely unparseable value must keep the generic message -- the new + # branch must not swallow it. + with pytest.raises(ValueError, match="is not an EasyVista timestamp"): + ev_since_filter("LAST_UPDATE", "not-a-timestamp") + + +@pytest.mark.parametrize( + "bad", + [ + "2025-11-28T16:14:41", + "2025-11-28 16:14:41", + "2025-11-28T16:14:41.133", + "2025-11-28T16:14:41.133456", + ], +) +def test_interval_refuses_a_time_without_an_offset(bad): + """An offset-less time SILENTLY SHIFTS the window, so refuse it locally. + + The API *accepts* these -- which is precisely the danger. Measured live + 2026-08-18 against one instance, the same wall-clock text with and without + its offset enumerated 13 rows and 11 rows respectively; the offset-less form + is read in another zone, moving the bound *later* and skipping records with + no error of any kind. A watermark that silently skips is the worst outcome + this grammar can produce. + + ``format_ev_datetime`` already refuses a naive ``datetime`` for exactly this + reason, and its docstring says so. Accepting a naive *string* let the same + hazard reach the wire by the other path, so both paths now refuse. + """ + with pytest.raises(ValueError, match="offset"): + ev_since_filter("LAST_UPDATE", bad) + + +def test_between_refuses_an_offsetless_time_on_either_bound(): + """Both bounds go through the same gate, so neither may be naive.""" + with pytest.raises(ValueError, match="offset"): + ev_between_filter("LAST_UPDATE", "2025-11-28T16:14:41", "2025-12-01") + with pytest.raises(ValueError, match="offset"): + ev_between_filter("LAST_UPDATE", "2025-11-28", "2025-12-01T16:14:41") + + +def test_contains_wraps_the_value_in_wildcards_with_the_tilde_operator(): + """``~`` IS a pattern operator; it needs an explicit ``*`` (measured live).""" + assert ev_contains_filter("ASSET_TAG", "LAPTOP") == 'ASSET_TAG~"*LAPTOP*"' + + +def test_starts_with_anchors_on_the_left_only(): + assert ev_starts_with_filter("RFC_NUMBER", "I26081") == 'RFC_NUMBER~"I26081*"' + + +def test_wildcard_builders_reject_a_double_quote(): + """Same reasoning as escape_ev_value: no escape for '"' exists.""" + with pytest.raises(ValueError): + ev_contains_filter("ASSET_TAG", 'LAP"TOP') + + +@pytest.mark.parametrize( + "bad", + [ + "LAP*TOP", + "LAP%TOP", + # `_` is a SINGLE-character wildcard under `~`, measured live: replacing + # one character of an RFC that matched 1 row gave 9. Underscores are + # pervasive in EasyVista codes, so this is the routine case, not the + # exotic one -- `ASSET_TAG~"*LAPTOP_01*"` also matches `LAPTOP-01`. + "LAP_TOP", + "LAPTOP_01", + # `[` opens a character class; `[0-9]` in the same position also gave 9. + "LAP[0-9]TOP", + # A backslash does NOT escape it (`\\_` returned 0 rows live), so an + # escaped-looking value is refused too rather than silently mismatching. + r"LAP\_TOP", + ], +) +def test_wildcard_builders_reject_a_metacharacter_inside_the_value(bad): + """A metacharacter in the middle silently changes what the caller asked for. + + ``ev_contains_filter("A*B")`` would match "A" then anything then "B" rather + than the literal "A*B", so refuse instead of quietly widening the query. + All four of ``* % _ [`` behave this way under ``~`` (measured live) and none + of them can be escaped, so all four are refused on the identical rationale. + """ + with pytest.raises(ValueError, match="metacharacter"): + ev_contains_filter("ASSET_TAG", bad) + with pytest.raises(ValueError, match="metacharacter"): + ev_starts_with_filter("ASSET_TAG", bad) + + +@pytest.mark.parametrize("blank", [None, "", " "]) +@pytest.mark.parametrize("wildcard", ["*", "%", None]) +def test_blank_wildcard_value_returns_none_not_a_match_everything_pattern( + blank, wildcard +): + """``FIELD~"**"`` would match every row — the exact silent-widening shape. + + ``None`` is included because the signature says ``str | None``: without the + guard, ``str(None)`` would render ``FIELD~"*None*"``, a pattern that both + widens silently and matches on a value no caller ever supplied. + + The ``wildcard`` axis pins that the blank guard sits *ahead* of the render + on every setting. At ``wildcard=None`` the pattern would be ``FIELD~""`` + rather than ``FIELD~"**"`` — it asks nothing instead of asking everything, + which is a different failure, but returning ``None`` uniformly is what lets + callers compose without a per-setting conditional. + """ + assert ev_contains_filter("ASSET_TAG", blank, wildcard=wildcard) is None + assert ev_starts_with_filter("ASSET_TAG", blank, wildcard=wildcard) is None + + +# --- `wildcard=`: which reading of `~` this expression is built for ---------- +# +# The vendor documents `~` as plain Contains (tier 1, no wildcard named); this +# package measured it live 2026-08-17 as a pattern operator needing an explicit +# one (tier 4, one instance). The default stays the measured reading, so every +# test above this line exercises the unchanged path. + + +def test_percent_wildcard_is_emitted_verbatim(): + """``%`` is the token a LIKE-backed deployment may use instead of ``*``.""" + assert ( + ev_contains_filter("ASSET_TAG", "LAPTOP", wildcard="%") + == 'ASSET_TAG~"%LAPTOP%"' + ) + assert ( + ev_starts_with_filter("RFC_NUMBER", "I26081", wildcard="%") + == 'RFC_NUMBER~"I26081%"' + ) + + +def test_wildcard_none_appends_nothing(): + """``wildcard=None`` emits the bare value — the vendor's plain Contains. + + The equality between the two builders is the point, not an accident of the + assertion style: with nothing appended there is no anchor left to + distinguish a prefix from a substring, so ``ev_starts_with_filter`` stops + expressing a prefix at all. That is why its docstring warns that ``None`` + removes the *anchor* rather than swapping a token. + """ + contains = ev_contains_filter("ASSET_TAG", "LAPTOP", wildcard=None) + starts_with = ev_starts_with_filter("ASSET_TAG", "LAPTOP", wildcard=None) + assert contains == 'ASSET_TAG~"LAPTOP"' + assert contains == starts_with + + +@pytest.mark.parametrize( + "bad", ["LAP_TOP", "LAPTOP_01", "LAP[0-9]TOP", r"LAP\_TOP"] +) +def test_wildcard_none_still_refuses_the_operators_own_metacharacters(bad): + """``_`` and ``[`` belong to ``~`` itself, not to the appended wildcard. + + This is the item's central decision, and it is settled by this repo's own + live probe rather than by inference: + ``integration_tests/test_live_change_window.py:659-660`` builds + ``RFC_NUMBER~"{stem}_"`` and ``RFC_NUMBER~"{stem}[0-9]"`` as raw ``search=`` + strings with **no builder-added wildcard**, and each widened a one-row exact + match to nine. So the tempting premise — that with nothing appended a + metacharacter is merely a character — holds for ``*``/``%`` and is false + for these two. Relaxing them under ``wildcard=None`` would trade a loud, + local ``ValueError`` for the silent widening these builders exist to + prevent, on the only deployment anyone has measured. + + ``LAP\\_TOP`` is refused for the ``_`` it contains, not for the backslash: + a backslash is compared literally under ``~`` (``\\_`` matched + nothing live), so it escapes nothing. + """ + with pytest.raises(ValueError, match="metacharacter"): + ev_contains_filter("ASSET_TAG", bad, wildcard=None) + with pytest.raises(ValueError, match="metacharacter"): + ev_starts_with_filter("ASSET_TAG", bad, wildcard=None) + + +def test_wildcard_none_admits_a_caller_placed_wildcard(): + """With nothing appended, ``*``/``%`` in the value are the caller's own. + + This is the supported way to hand-build a pattern through these builders: + the value still goes through ``escape_ev_value``, so the ``"`` defence + holds, but the wildcard placement becomes the caller's. + """ + assert ( + ev_contains_filter("ASSET_TAG", "*LAPTOP*", wildcard=None) + == 'ASSET_TAG~"*LAPTOP*"' + ) + assert ( + ev_contains_filter("ASSET_TAG", "%LAPTOP%", wildcard=None) + == 'ASSET_TAG~"%LAPTOP%"' + ) + # The complement: while a wildcard IS being appended, BOTH tokens are + # refused, not merely the one being appended -- either would compose with + # it. + with pytest.raises(ValueError, match="metacharacter"): + ev_contains_filter("ASSET_TAG", "LAP*TOP", wildcard="%") + with pytest.raises(ValueError, match="metacharacter"): + ev_contains_filter("ASSET_TAG", "LAP%TOP", wildcard="*") + + +@pytest.mark.parametrize("token", ["?", "**", "", '"']) +def test_an_unsupported_wildcard_token_is_refused(token): + """The token bypasses ``escape_ev_value``, so its domain must be closed. + + ``'"'`` is the load-bearing case: the token is interpolated as part of + ``pattern``, outside the value escaping, so without this check it would + terminate the quoted value and reach the ``,`` combinator — reopening the + exact hole ``escape_ev_value`` exists to shut. + """ + with pytest.raises(ValueError, match="wildcard="): + ev_contains_filter("ASSET_TAG", "LAPTOP", wildcard=token) + with pytest.raises(ValueError, match="wildcard="): + ev_starts_with_filter("ASSET_TAG", "LAPTOP", wildcard=token) + + +@pytest.mark.parametrize("blank", [None, " "]) +def test_an_unsupported_wildcard_token_is_refused_even_for_a_blank_value(blank): + """A bad token is a fault in the caller's code, not in their data. + + Validating it after the blank-value early return would make the same wrong + call succeed or raise depending on what happened to be in ``value`` that + day, which is the worst shape a programming error can take. + """ + with pytest.raises(ValueError, match="wildcard="): + ev_contains_filter("ASSET_TAG", blank, wildcard="?") + with pytest.raises(ValueError, match="wildcard="): + ev_starts_with_filter("ASSET_TAG", blank, wildcard="?") + + +def test_a_double_quote_is_still_refused_with_no_wildcard(): + """``escape_ev_value`` runs on every path, including ``wildcard=None``.""" + with pytest.raises(ValueError): + ev_contains_filter("ASSET_TAG", 'LAP"TOP', wildcard=None) + with pytest.raises(ValueError): + ev_starts_with_filter("ASSET_TAG", 'LAP"TOP', wildcard=None) diff --git a/easyvista_python_client/tests/test_pagination.py b/easyvista_python_client/tests/test_pagination.py index fe96de0..cca5277 100644 --- a/easyvista_python_client/tests/test_pagination.py +++ b/easyvista_python_client/tests/test_pagination.py @@ -66,3 +66,46 @@ def test_build_search_result_handles_non_dict_payload(): assert sr.record_count == 3 assert sr.total_record_count == 3 assert sr.href is None + + +# --- envelope casing is not stable across deployments ------------------------ + + +def test_extract_records_matches_an_envelope_key_case_insensitively(): + """The instance already answers a capital-D ``Documents``. + + Measured 2026-08-17 on the verified instance, where the same instance's own + OpenAPI examples spell every envelope lowercase -- so matching the casing + exactly is a coin flip per deployment. + """ + assert extract_records({"Documents": [{"d": 1}]}) == [{"d": 1}] + assert extract_records({"Actions": [{"a": 1}]}, "actions") == [{"a": 1}] + assert extract_records({"REQUESTS": [{"r": 1}]}) == [{"r": 1}] + + +def test_extract_records_still_prefers_records_over_a_resource_envelope(): + """Priority is unchanged: ``records`` first, then the envelope key. + + The case-insensitive rewrite must not disturb the order, or a payload + carrying both would start unwrapping the wrong one. + """ + payload = {"records": [{"r": 1}], "Actions": [{"a": 1}]} + assert extract_records(payload, "actions") == [{"r": 1}] + + +def test_extract_records_ignores_a_scalar_list_under_an_envelope_name(): + """A list of scalars is not an envelope of records. + + Without this check the payload below returned ``[]`` -- silently, and + indistinguishably from an empty page -- rather than falling through to + ``[data]``. The check matters more now that matching is case-insensitive: + it is the guard against a record column that happens to be named like an + envelope and to hold a list. + """ + assert extract_records({"REQUESTS": ["a", "b"]}) == [{"REQUESTS": ["a", "b"]}] + + +def test_extract_records_still_returns_an_empty_page_as_empty(): + """An empty envelope list is a legitimate empty page, not a fallthrough.""" + assert extract_records({"records": []}) == [] + assert extract_records({"Documents": []}) == [] diff --git a/easyvista_python_client/tests/test_references.py b/easyvista_python_client/tests/test_references.py index 319d876..f48a7d3 100644 --- a/easyvista_python_client/tests/test_references.py +++ b/easyvista_python_client/tests/test_references.py @@ -1,6 +1,10 @@ +from datetime import datetime, timedelta, timezone + from easyvista_python_client.models.request import Request from easyvista_python_client.references import ( Reference, + _scalar, + label_from_record, localized_label, resolve_reference, ) @@ -12,11 +16,12 @@ def test_display_prefers_label_then_id_then_none(): assert Reference(id=None, label=None).display is None -def test_nested_label_prefers_en_then_fr_then_path_and_drops_href(): +def test_nested_label_prefers_en_then_fr_then_other_languages_then_path(): rec = { "STATUS": { "STATUS_FR": "En cours", "STATUS_EN": "Open", + "STATUS_GE": "Offen", "HREF": "http://x/api/v1", }, "STATUS_ID": "12", @@ -117,12 +122,97 @@ def test_model_reference_missing_field_is_empty(): assert ticket.reference("DEPARTMENT") == Reference(id=None, label=None) +def test_scalar_renders_an_aware_datetime_as_the_ev_wire_format(): + value = datetime( + 2026, 8, 17, 15, 40, 41, 610000, tzinfo=timezone(timedelta(hours=2)) + ) + assert _scalar(value) == "2026-08-17T15:40:41.610+02:00" + + +def test_scalar_renders_a_naive_datetime_via_isoformat_fallback(): + # format_ev_datetime refuses a naive datetime; _scalar must never raise, so + # it falls back to plain .isoformat() rather than propagating that error. + value = datetime(2026, 8, 17, 15, 40, 41) + assert _scalar(value) == "2026-08-17T15:40:41" + + +def test_model_reference_on_a_retyped_timestamp_field_is_populated(): + """Request.reference("LAST_UPDATE") must not regress to an empty Reference + now that last_update is a datetime rather than a str (2026-08-17 retype).""" + ticket = Request.model_validate( + {"RFC_NUMBER": "I1", "LAST_UPDATE": "2026-08-17T15:40:41.610+02:00"} + ) + ref = ticket.reference("LAST_UPDATE") + assert ref.display == "2026-08-17T15:40:41.610+02:00" + + def test_reference_exported_from_package(): import easyvista_python_client as evc assert evc.Reference is Reference +# --- language order and the placeholder rule --------------------------------- + + +def test_nested_label_skips_a_bracketed_placeholder_so_fr_wins(): + # THE deliberate behaviour change. An unpopulated localized column on a + # single-language instance echoes the primary text in brackets; before this, + # that echo won because it came first in the scan and was rendered verbatim. + rec = {"STATUS": {"STATUS_EN": "[En cours]", "STATUS_FR": "En cours"}} + assert resolve_reference(rec, "STATUS").label == "En cours" + + +def test_nested_label_keeps_a_fully_placeholder_record_rather_than_losing_the_heading(): + # The regression guard for the whole design. This function's callers render + # NOTHING when it returns None -- to_markdown drops the table row entirely, + # and a statistics bucket collapses onto the bare id -- so a record whose + # every language column is a placeholder must still yield what it always + # yielded. Losing a heading is worse than an ugly one. + rec = {"STATUS": {"STATUS_EN": "[X]", "STATUS_FR": "[X]"}} + assert resolve_reference(rec, "STATUS").label == "[X]" + + +def test_nested_label_prefers_a_language_column_over_path(): + # _PATH used to be the third and last rung, so it beat everything after _FR. + # A real language label is a better heading than a path. + rec = { + "LOCATION": { + "LOCATION_EN": "[Site A]", + "LOCATION_GE": "Standort A", + "LOCATION_PATH": "A/B", + } + } + assert resolve_reference(rec, "LOCATION").label == "Standort A" + + +def test_languages_argument_reorders_the_scan(): + rec = {"STATUS": {"STATUS_EN": "Open", "STATUS_FR": "En cours"}} + assert resolve_reference(rec, "STATUS").label == "Open" + assert resolve_reference(rec, "STATUS", languages=("_FR", "_EN")).label == ( + "En cours" + ) + # A bare "FR" normalizes to the "_FR" suffix, so a caller need not know the + # storage form. + assert ( + localized_label( + {"DEPARTMENT_EN": "Dept", "DEPARTMENT_FR": "Service"}, + "DEPARTMENT", + languages=("FR",), + ) + == "Service" + ) + + +def test_default_language_order_is_exported_and_starts_english_first(): + # The gate that keeps a skill snippet importable: the contract test rejects + # any snippet importing a name that is not public. + import easyvista_python_client as evc + + assert evc.DEFAULT_LANGUAGE_ORDER[:6] == ("_EN", "_FR", "_GE", "_IT", "_PO", "_SP") + assert evc.localized_label is localized_label + + def test_localized_label_prefers_en_then_fr(): rec = {"DEPARTMENT_EN": "Dept", "DEPARTMENT_FR": "Service"} assert localized_label(rec, "DEPARTMENT") == "Dept" @@ -134,6 +224,41 @@ def test_localized_label_skips_bracketed_placeholder_so_fr_wins(): assert localized_label(rec, "DEPARTMENT") == "ACME CORP" +def test_localized_label_keeps_a_bracketed_suffix_marker(): + """The other half of the bracket rule, and the one nothing pinned. + + Two bracket conventions appear in ``ACTION_LABEL_*`` and they mean OPPOSITE + things. A label wrapped ENTIRELY in brackets, echoing another language, is + an untranslated placeholder and is discarded -- that is the test above. A + bracketed SUFFIX on otherwise distinct text, with real sibling + translations, is a genuine marker written by whoever configured the + instance, and must survive. + + Only the placeholder case was pinned before, so nothing stopped a + well-meant "strip the brackets" cleanup from re-deleting the finding the + documentation now teaches. An earlier revision of this package did exactly + that. + """ + # Real translations on both sides: the preferred language wins, brackets or + # not, and neither is treated as noise. + assert ( + localized_label( + { + "ACTION_LABEL_EN": "Customer Comment", + "ACTION_LABEL_FR": "Commentaire [Public]", + }, + "ACTION_LABEL", + ) + == "Customer Comment" + ) + # The load-bearing one: a bracketed SUFFIX is the only label there is, and + # it survives. Discarding it would return None and lose the marker. + assert ( + localized_label({"ACTION_LABEL_FR": "Commentaire [Public]"}, "ACTION_LABEL") + == "Commentaire [Public]" + ) + + def test_localized_label_skips_empty_and_does_not_match_code_or_path(): # _CODE / _PATH are not language columns and must never be picked as the label. rec = {"DEPARTMENT_EN": "", "DEPARTMENT_CODE": "UTX", "DEPARTMENT_PATH": "A/B"} @@ -152,3 +277,66 @@ def test_localized_label_none_when_nothing_usable(): localized_label({"DEPARTMENT_FR": "[x]"}, "DEPARTMENT", fallbacks=(None,)) is None ) + + +# --- label_from_record: a reference table names its label after nothing ------ + + +def test_label_from_record_matches_by_suffix_across_four_prefixes(): + """Four tables, four different label prefixes, one suffix rule. + + On the verified instance's own response schemas ``groups`` returns + ``GROUP_EN``, ``locations`` returns ``LOCATION_FR``, ``catalog-requests`` + returns ``TITLE_EN`` and ``slas`` returns ``NAME_FR``. Those schemas are + tier 3, which is the second reason to match on the suffix rather than on a + prefix a caller would have to know in advance. + """ + assert label_from_record({"GROUP_ID": 3, "GROUP_EN": "N1"}) == "N1" + assert label_from_record({"LOCATION_FR": "Paris"}) == "Paris" + assert label_from_record({"SD_CATALOG_ID": 1, "TITLE_EN": "Incident"}) == ( + "Incident" + ) + assert label_from_record({"SLA_ID": 1, "NAME_FR": "Standard"}) == "Standard" + + +def test_label_from_record_skips_a_bracketed_placeholder(): + """An unpopulated translation column echoes ``"[CODE]"``.""" + assert label_from_record({"NAME_EN": "[Standard]", "NAME_FR": "Standard"}) == ( + "Standard" + ) + + +def test_label_from_record_never_returns_an_href(): + assert label_from_record({"HREF": "https://h/api/v1/acme/groups/3"}) is None + + +def test_label_from_record_falls_back_through_label_then_path_then_code(): + assert label_from_record({"SOME_LABEL": "L"}) == "L" + assert label_from_record({"DEPARTMENT_PATH": "ACME/IT"}) == "ACME/IT" + assert label_from_record({"LOCATION_CODE": "PAR"}) == "PAR" + # And the order between them: a real label beats a path beats a code. + assert label_from_record( + {"LOCATION_CODE": "PAR", "SOME_LABEL": "L", "X_PATH": "p"} + ) == "L" + + +def test_label_from_record_honours_a_language_reordering(): + row = {"GROUP_EN": "Level 1", "GROUP_FR": "Niveau 1"} + assert label_from_record(row) == "Level 1" + assert label_from_record(row, languages=("_FR", "_EN")) == "Niveau 1" + + +def test_resolve_reference_is_unchanged_by_the_new_helper(): + """``label_from_record`` deliberately does NOT feed ``resolve_reference``. + + The nested-label scan runs a bracket-TOLERANT second pass so a fully + untranslated record still renders something, and it appends ``_PATH`` to + the language order rather than treating it as a separate rung. Routing it + through the stricter helper would change what ``.reference("STATUS").label`` + returns on such a record -- a behaviour change, not a refactor. + """ + record = {"STATUS": {"STATUS_GE": "[x]", "STATUS_PATH": "Real Text"}} + assert resolve_reference(record, "STATUS").label == "Real Text" + # The stricter helper prefers the language column even when bracketed is + # all that column has -- which is exactly why the two are kept apart. + assert label_from_record(record["STATUS"]) == "Real Text" diff --git a/easyvista_python_client/tests/test_reporting.py b/easyvista_python_client/tests/test_reporting.py index 750d785..0b05674 100644 --- a/easyvista_python_client/tests/test_reporting.py +++ b/easyvista_python_client/tests/test_reporting.py @@ -147,11 +147,21 @@ def test_created_since_until_inclusive_bounds(): assert stats.total == 2 -def test_window_excludes_missing_or_unparseable_dates(): +def test_window_excludes_missing_dates(): + """A ticket with no CREATION_DATE_UT is excluded once a window bound is set. + + This test used to also cover a third, "garbage"-dated ticket to exercise the + unparseable-date branch here in ``aggregate_tickets``. As of the 2026-08-17 + read-path retype, ``Request.model_validate`` itself rejects a malformed + ``CREATION_DATE_UT`` (see + ``test_an_unparseable_timestamp_raises_rather_than_silently_becoming_none`` + in ``models/tests/test_common.py``), so a ``Request`` with an unparseable + creation date can no longer be constructed through the validated path this + helper uses -- that sub-case is gone, not weakened. + """ tickets = [ _ticket(RFC_NUMBER="I1", CREATION_DATE_UT="2025-06-15T12:00:00+00:00"), _ticket(RFC_NUMBER="I2"), # no date - _ticket(RFC_NUMBER="I3", CREATION_DATE_UT="garbage"), ] stats = aggregate_tickets( tickets, dimensions=(), created_since="2025-01-01T00:00:00+00:00" @@ -175,3 +185,45 @@ def test_created_until_excludes_newer_records(): tickets, dimensions=(), created_until="2025-06-15T12:00:00+00:00" ) assert stats.total == 1 # I2 is newer than created_until -> excluded + + +# --- label language reaches the aggregator ----------------------------------- + + +def _localized_ticket() -> Request: + return Request.model_validate( + { + "RFC_NUMBER": "I1", + "STATUS": {"STATUS_EN": "[Open]", "STATUS_FR": "Ouvert"}, + } + ) + + +def test_breakdown_skips_a_bracketed_placeholder_label(): + # A bucket key is user-facing: an untranslated echo must not become one + # while a real sibling translation sits beside it. + stats = aggregate_tickets([_localized_ticket()], dimensions=("STATUS",)) + assert stats.breakdowns["STATUS"] == {"Ouvert": 1} + + +def test_languages_argument_reorders_breakdown_keys(): + # Proves the tolerant second pass reaches the aggregator too: asking only + # for English leaves the placeholder as the single candidate, and it is kept + # rather than collapsing the bucket onto the raw id. + stats = aggregate_tickets( + [_localized_ticket()], dimensions=("STATUS",), languages=("_EN",) + ) + assert stats.breakdowns["STATUS"] == {"[Open]": 1} + + +def test_aggregate_tickets_leaves_truncation_unset(): + """The pure function stays page-unaware. + + It has no page, no envelope and no cap, so it cannot know whether a fetch + truncated or how large the population was. The client's + ``ticket_statistics`` stamps both after the fetch it performed; moving that + computation in here would require handing this function a client. + """ + stats = aggregate_tickets([_ticket(RFC_NUMBER="I1")], dimensions=()) + assert stats.truncated is False + assert stats.population_total is None diff --git a/easyvista_python_client/tests/test_timestamps.py b/easyvista_python_client/tests/test_timestamps.py new file mode 100644 index 0000000..af22e09 --- /dev/null +++ b/easyvista_python_client/tests/test_timestamps.py @@ -0,0 +1,101 @@ +"""Tests for EasyVista's timestamp format. + +The format was established live on 2026-08-17: ISO 8601 with an explicit UTC +offset and millisecond precision, e.g. ``2026-08-17T15:40:41.610+02:00``. An +unset date comes back as the empty string, not ``null``. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from easyvista_python_client import format_ev_datetime, parse_ev_datetime +from easyvista_python_client import timestamps as timestamps_module + + +def test_parses_the_live_format_with_offset_and_milliseconds(): + """The exact shape measured live (YYYY-MM-DDTHH:MM:SS.mmm+HH:MM).""" + dt = parse_ev_datetime("2026-08-17T15:40:41.610+02:00") + assert dt == datetime( + 2026, 8, 17, 15, 40, 41, 610000, tzinfo=timezone(timedelta(hours=2)) + ) + assert dt.utcoffset() == timedelta(hours=2) + + +def test_parsed_value_is_always_aware(): + """Action.updated_at requires an aware datetime; a naive one is a bug.""" + assert parse_ev_datetime("2026-08-17T15:40:41.610+02:00").tzinfo is not None + + +def test_the_empty_string_sentinel_is_none_not_an_error(): + """An unset EasyVista date is ``""``. Measured on every unpopulated column.""" + assert parse_ev_datetime("") is None + assert parse_ev_datetime(" ") is None + assert parse_ev_datetime(None) is None + + +def test_unparseable_input_is_none_rather_than_raising(): + assert parse_ev_datetime("not-a-date") is None + assert parse_ev_datetime(12345) is None + + +def test_formats_back_to_the_literal_the_interval_grammar_accepts(): + """``LAST_UPDATE:(;)`` was honoured live with exactly this rendering.""" + dt = datetime(2025, 11, 28, 16, 14, 41, 133000, tzinfo=timezone(timedelta(hours=1))) + assert format_ev_datetime(dt) == "2025-11-28T16:14:41.133+01:00" + + +def test_format_round_trips_through_parse(): + literal = "2026-08-17T15:40:41.610+02:00" + assert format_ev_datetime(parse_ev_datetime(literal)) == literal + + +def test_format_refuses_a_naive_datetime(): + """Refuse rather than guess a zone: a naive instant cannot name a moment.""" + with pytest.raises(ValueError, match="timezone-aware"): + format_ev_datetime(datetime(2026, 8, 17, 15, 40, 41)) + + +@pytest.mark.parametrize( + "basic", + ["20260817", "20260817T154041", "20260817T154041.610", "2026W331"], +) +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 + 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``. + + 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 + format change through as a plausible-looking instant. + """ + assert parse_ev_datetime(basic) is None + + +def test_the_refusal_does_not_depend_on_fromisoformat(monkeypatch): + """The guard must short-circuit, not lean on ``fromisoformat`` raising. + + Pinned against a stdlib that accepts even more shorthand later: if the + refusal were reached only via a ``ValueError``, a future interpreter would + silently start parsing these again and this module's other guard would go + quiet without any test noticing. + + Substitutes the module's whole ``datetime`` binding rather than setting an + attribute on the real class, which is a C type and refuses one. + """ + + class _NoFromIso: + @staticmethod + def fromisoformat(_value): # pragma: no cover - must never be reached + raise AssertionError("fromisoformat consulted for an ISO-basic value") + + monkeypatch.setattr(timestamps_module, "datetime", _NoFromIso) + assert parse_ev_datetime("20260817") is None diff --git a/easyvista_python_client/timestamps.py b/easyvista_python_client/timestamps.py new file mode 100644 index 0000000..fc5be40 --- /dev/null +++ b/easyvista_python_client/timestamps.py @@ -0,0 +1,119 @@ +"""EasyVista's timestamp format, in one place. + +Established against a live instance on 2026-08-17: every returned timestamp is +**ISO 8601 with an explicit UTC offset** and millisecond precision, e.g. +``2026-08-17T15:40:41.610+02:00``. Verified by arithmetic, not inspection — a +write bracketed by our own UTC clock at ``13:40:40.411Z``/``13:40:40.869Z`` +produced ``15:40:41.610+02:00``, which *is* ``13:40:41.610Z``. + +Two consequences worth stating, because both have bitten callers: + +* The ``_UT`` suffix does **not** mean UTC-normalized. ``CREATION_DATE_UT`` and + ``SUBMIT_DATE_UT`` carry the same local offset as ``LAST_UPDATE``. Treat it as + a naming convention, not a zone promise. +* An **unset** date is the empty string, not ``null``. A parser that only guards + ``None`` raises on real data. + +One deliberate asymmetry, stated here because it looks like an inconsistency +otherwise. On the **read** path :func:`parse_ev_datetime` assumes UTC for a +literal that carries no offset, because an extractor must never fail a record +over one column. On the **write/query** path +:func:`easyvista_python_client.filters._interval_bound` *refuses* the identical +shape, because a mis-zoned interval bound silently moves the window and skips +records — the one failure a watermark must not have, and there a ``ValueError`` +costs the caller nothing but a corrected input. So a naive stamp read back from +an instance becomes a confidently offset-bearing ``datetime``: if a deployment +ever returns offset-less timestamps, that guess is laundered past the filter +guard, and the assumption -- not the guard -- is what to revisit. + +This module is a leaf: it imports nothing from the package, so both ``models/`` +and ``filters.py`` can use it without a cycle. +""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import Any + +_FRACTION_RE = re.compile(r"\.(\d+)") + + +#: EasyVista sends the extended calendar date; the ISO basic forms that 3.11+ +#: also accepts are not its format on any interpreter. See parse_ev_datetime. +_EXTENDED_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}") + + +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. + + **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. + + The rule is stated positively because the reject-list version of it was + wrong: "digits only" catches ``"20260817"`` and misses both a basic + date-time (it has a ``.``) and a week date (it has a ``W``). EasyVista's + format always carries separators, so none of these is one of its timestamps + on any interpreter, and accepting one would let a genuine format change + through as a plausible instant. A deployment that really sends such a form + names it through ``EasyvistaConfig(datetime_input_formats=("%Y%m%d",))``, + which is tried after this returns ``None``. + """ + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + if not isinstance(value, str) or not value.strip(): + return None + text = value.strip() + # Require the extended (separator-bearing) calendar date ISO 8601 mandates + # for EasyVista's own format. Stated positively on purpose: enumerating the + # basic forms to reject misses them -- week dates ("2026W331") and basic + # date-times ("20260817T154041.610") are not digit-only, and 3.11+ parses + # both. See the docstring for why this cannot be left to fromisoformat. + if not _EXTENDED_DATE_RE.match(text): + return None + if text.endswith(("Z", "z")): + text = text[:-1] + "+00:00" + match = _FRACTION_RE.search(text) + if match: + frac6 = (match.group(1) + "000000")[:6] + text = text[: match.start()] + "." + frac6 + text[match.end() :] + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def format_ev_datetime(value: datetime) -> str: + """Render ``value`` as the literal EasyVista's search grammar accepts. + + Millisecond precision with an explicit offset — byte-identical to what the + API itself returns, and verified live as an accepted interval bound + (``LAST_UPDATE:(2025-11-28T16:14:41.133+01:00;…)`` was honoured). + + Raises ``ValueError`` for a naive datetime. ``ValueError``, not an + ``Easyvista*`` error, because nothing reached the API: this is a local input + fault, the same reasoning as :func:`~easyvista_python_client.escape_ev_value`. + Refusing beats guessing a zone — a naive instant does not name a moment, and + silently assuming UTC would shift every bound by the server's offset. + """ + if value.tzinfo is None: + raise ValueError( + "an EasyVista timestamp must be timezone-aware; a naive datetime " + "does not name a unique instant and would silently shift the bound" + ) + return value.isoformat(timespec="milliseconds") + + +__all__ = ["format_ev_datetime", "parse_ev_datetime"] diff --git a/integration_tests/conftest.py b/integration_tests/conftest.py index 9dc3b77..3895a9c 100644 --- a/integration_tests/conftest.py +++ b/integration_tests/conftest.py @@ -7,32 +7,56 @@ and no ``EASYVISTA_TEST_*`` environment simply skips the suite rather than failing it. -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 7 -actions, 5 document uploads and 3 updates; ``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. +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 +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 +candidate values per column and stops at the first the instance accepts, some +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. Credentials resolve from an uppercase env var first, then a lowercase file under ``secrets/``: - url <- EASYVISTA_TEST_URL | secrets/easyvista_test_url - user <- EASYVISTA_TEST_USER (or _ACCOUNT) | secrets/easyvista_test_user - token <- EASYVISTA_TEST_TOKEN | secrets/easyvista_test_token - -Two further per-instance ids resolve the same way (env var, else the matching -lowercase ``secrets/`` file), gating only the tests that need them -(``live_action_config``, see below): - + url <- EASYVISTA_TEST_URL | secrets/easyvista_test_url + account <- EASYVISTA_TEST_ACCOUNT | secrets/easyvista_test_account + token <- EASYVISTA_TEST_TOKEN | secrets/easyvista_test_token + +``account`` is **not a login**. It is the EasyVista instance identifier that +forms the ``{account}`` path segment of ``https://host/api/{version}/{account}`` +-- a number such as ``50004`` -- and it feeds ``EasyvistaConfig.account``. +Nothing authenticates with it. Until 2026-08-25 it was spelled +``EASYVISTA_TEST_USER`` / ``secrets/easyvista_test_user``, which read as a +username and never was one; that name is now **refused** rather than quietly +accepted, so a stale copy cannot resurrect the confusion (see +``_reject_legacy_account_name``). + +Eight further per-instance values resolve the same way (env var, else the +matching lowercase ``secrets/`` file), each gating only the tests that need it. +The first six make up ``live_write_config``, which every ticket-creating test +depends on (``sample_catalog_code`` also takes ``catalog_code`` on its own); the +last two gate ``live_action_config`` alone, so an instance with no action type +configured still runs the write tests: + + catalog_code <- EASYVISTA_TEST_CATALOG_CODE + origin <- EASYVISTA_TEST_ORIGIN + department_id <- EASYVISTA_TEST_DEPARTMENT_ID + urgency_id <- EASYVISTA_TEST_URGENCY_ID + impact_id <- EASYVISTA_TEST_IMPACT_ID + status_guid <- EASYVISTA_TEST_STATUS_GUID action_type_id <- EASYVISTA_TEST_ACTION_TYPE_ID group_id <- EASYVISTA_TEST_GROUP_ID Auth is **Bearer** (the ``token`` value) — confirmed against the live preprod -instance. The ``url`` value is the **full API root** (it already ends in +instance, and it is the *only* credential that authenticates anything. The +``url`` value is normally the **full API root** (it already ends in ``/api/v1/{account}``), so we split it back into ``server`` + ``account`` so -``EasyvistaConfig.api_root`` reconstructs it exactly. If the URL is instead a -bare host (no ``/api/`` segment), the ``user`` value is used as the account. +``EasyvistaConfig.api_root`` reconstructs it exactly — and in that case the +``account`` credential is never read at all. It is consulted only when ``url`` +is a bare host with no ``/api/`` segment. The secret values are loaded by this test process at runtime and never printed. Nor is live instance content: ``_force_short_traceback`` strips the frame- @@ -173,6 +197,34 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: _RECONCILE_DELAY = 15.0 +# Retired 2026-08-25. The value is the API-root ``{account}`` path segment, not a +# login, and the old spelling said otherwise. Accepting it as a silent fallback +# would re-admit the exact confusion the rename removed, so it is refused with a +# message naming its replacement. Only names are printed, never values (P2). +_LEGACY_ACCOUNT_ENV = "EASYVISTA_TEST_USER" +_LEGACY_ACCOUNT_FILE = "easyvista_test_user" + + +def _reject_legacy_account_name() -> None: + """Fail loudly when the pre-rename account credential is still configured.""" + stale: list[str] = [] + value = os.environ.get(_LEGACY_ACCOUNT_ENV) + if value and value.strip(): + stale.append("the " + _LEGACY_ACCOUNT_ENV + " environment variable") + if (_SECRETS_DIR / _LEGACY_ACCOUNT_FILE).is_file(): + stale.append("secrets/" + _LEGACY_ACCOUNT_FILE) + if stale: + verb = "is" if len(stale) == 1 else "are" + pytest.fail( + " and ".join(stale) + " " + verb + " still set. That name was retired on " + "2026-08-25 and is no longer read: the value is the EasyVista " + "account -- the instance id in https://host/api/{version}/{account} " + "-- and never a login. Rename it to EASYVISTA_TEST_ACCOUNT / " + "secrets/easyvista_test_account.", + pytrace=False, + ) + + def _resolve(env_names: tuple[str, ...], filename: str) -> str | None: for name in env_names: value = os.environ.get(name) @@ -189,15 +241,16 @@ def _resolve(env_names: tuple[str, ...], filename: str) -> str | None: @pytest.fixture(scope="session") def live_config() -> EasyvistaConfig: url = _resolve(("EASYVISTA_TEST_URL",), "easyvista_test_url") - user = _resolve( - ("EASYVISTA_TEST_USER", "EASYVISTA_TEST_ACCOUNT"), "easyvista_test_user" - ) token = _resolve(("EASYVISTA_TEST_TOKEN",), "easyvista_test_token") if not url or not token: pytest.skip( "live credentials unavailable (need url + token; set EASYVISTA_TEST_* " "env vars or add secrets/easyvista_test_* files)" ) + # Ordered after the skip so an unconfigured checkout stays offline and green: + # with no url/token there is no live run for a stale name to mislead. + _reject_legacy_account_name() + account = _resolve(("EASYVISTA_TEST_ACCOUNT",), "easyvista_test_account") root = url.rstrip("/") if "/api/" in root: @@ -221,12 +274,16 @@ def live_config() -> EasyvistaConfig: timeout=LIVE_TIMEOUT, max_retries=LIVE_MAX_RETRIES, ) - # url is a bare host; the account comes from the user value. - if not user: - pytest.skip("URL has no /api/ segment, so a user/account value is required") + # url is a bare host, so the account cannot be parsed out of it. + if not account: + pytest.skip( + "URL has no /api/ segment, so EASYVISTA_TEST_ACCOUNT (or " + "secrets/easyvista_test_account) is required -- the instance id in " + "https://host/api/{version}/{account}, not a login" + ) return EasyvistaConfig( server=root, - account=user, + account=account, token=token, timeout=LIVE_TIMEOUT, max_retries=LIVE_MAX_RETRIES, diff --git a/integration_tests/test_fixture_helpers.py b/integration_tests/test_fixture_helpers.py index 2099600..8129657 100644 --- a/integration_tests/test_fixture_helpers.py +++ b/integration_tests/test_fixture_helpers.py @@ -548,3 +548,184 @@ def test_close_tracked_error_text_carries_no_server_prose(): assert "connection failed" not in message assert info.value.__cause__ is None assert info.value.__context__ is None + + +# --- the retired-credential tripwire and account resolution -------------------- +# +# The account credential was named ``EASYVISTA_TEST_USER`` until 2026-08-25, which +# read as a login and never was one. Honouring the old name as a fallback would +# have preserved exactly that misreading, so ``live_config`` refuses it instead. +# That refusal is the only thing standing between a stale file and the confusion +# coming back, and mypy does not watch it: ``integration_tests/`` is excluded from +# type-checking by pyproject and mirrored in the pre-commit hook. These tests are +# the coverage. Like everything else in this module they need no credentials and +# touch no network -- ``live_config`` only assembles a dataclass. + + +def _live_config(): + """``live_config``'s undecorated body. + + ``pytest.fixture`` guards its wrapper against direct calls, so the plain + function has to be reached through ``__wrapped__``. + """ + return conftest_module.live_config.__wrapped__ + + +@pytest.fixture +def _clean_credential_env(monkeypatch, tmp_path): + """Isolate every credential source, so a real ``secrets/`` cannot leak in. + + Points ``_SECRETS_DIR`` at an empty tmp dir and clears the four environment + variables the resolver reads. Without this a developer's own configuration + would decide the outcome of these tests. + """ + monkeypatch.setattr(conftest_module, "_SECRETS_DIR", tmp_path) + for name in ( + "EASYVISTA_TEST_URL", + "EASYVISTA_TEST_TOKEN", + "EASYVISTA_TEST_ACCOUNT", + conftest_module._LEGACY_ACCOUNT_ENV, + ): + monkeypatch.delenv(name, raising=False) + return tmp_path + + +def test_the_retired_account_env_var_is_refused_not_honoured( + monkeypatch, _clean_credential_env +): + """The old name must abort, and must name its replacement while doing so. + + Resolving it as a silent fallback is the failure mode this whole rename + exists to prevent, so "it still works" would be the bug. + """ + monkeypatch.setenv(conftest_module._LEGACY_ACCOUNT_ENV, "50004") + + with pytest.raises(pytest.fail.Exception) as info: + conftest_module._reject_legacy_account_name() + + message = str(info.value) + assert conftest_module._LEGACY_ACCOUNT_ENV in message + assert "EASYVISTA_TEST_ACCOUNT" in message + assert "never a login" in message + # The value is a credential-adjacent secret: name the variable, never print it. + assert "50004" not in message + + +def test_the_retired_account_secrets_file_is_refused(_clean_credential_env): + """A leftover file trips the wire even with a clean environment. + + This is the likely real-world case: the env var was never set, the file was + simply left on disk by a checkout that predates the rename. + """ + (_clean_credential_env / conftest_module._LEGACY_ACCOUNT_FILE).write_text( + "50004", encoding="utf-8" + ) + + with pytest.raises(pytest.fail.Exception) as info: + conftest_module._reject_legacy_account_name() + + message = str(info.value) + assert "secrets/" + conftest_module._LEGACY_ACCOUNT_FILE in message + assert "secrets/easyvista_test_account" in message + assert "50004" not in message + + +def test_both_retired_sources_are_reported_together(monkeypatch, _clean_credential_env): + """Naming only one source would send someone round the loop twice.""" + monkeypatch.setenv(conftest_module._LEGACY_ACCOUNT_ENV, "50004") + (_clean_credential_env / conftest_module._LEGACY_ACCOUNT_FILE).write_text( + "50004", encoding="utf-8" + ) + + with pytest.raises(pytest.fail.Exception) as info: + conftest_module._reject_legacy_account_name() + + message = str(info.value) + assert conftest_module._LEGACY_ACCOUNT_ENV in message + assert "secrets/" + conftest_module._LEGACY_ACCOUNT_FILE in message + assert " are still set" in message # plural agreement, not "is" + + +def test_no_retired_source_is_silent(_clean_credential_env): + """The tripwire must cost nothing when nothing is stale.""" + assert conftest_module._reject_legacy_account_name() is None + + +def test_a_blank_retired_env_var_does_not_trip_the_wire( + monkeypatch, _clean_credential_env +): + """An empty string is not configuration -- the resolver ignores it too. + + Tripping on it would make an exported-but-empty variable an unfixable abort. + """ + monkeypatch.setenv(conftest_module._LEGACY_ACCOUNT_ENV, " ") + + assert conftest_module._reject_legacy_account_name() is None + + +def test_a_bare_host_url_takes_the_account_from_its_own_credential( + monkeypatch, _clean_credential_env +): + """The one case where the account credential is actually read.""" + monkeypatch.setenv("EASYVISTA_TEST_URL", "https://example.invalid") + monkeypatch.setenv("EASYVISTA_TEST_TOKEN", "t0ken") + monkeypatch.setenv("EASYVISTA_TEST_ACCOUNT", "50004") + + config = _live_config()() + + assert config.account == "50004" + assert config.server == "https://example.invalid" + assert config.api_root == "https://example.invalid/api/v1/50004" + + +def test_a_full_api_root_never_consults_the_account_credential( + monkeypatch, _clean_credential_env +): + """A full API root already carries the account, so the credential is dead. + + Pinning this is what makes the docstring's claim checkable: the sentinel + below would win if the resolver ever preferred the credential to the URL. + """ + monkeypatch.setenv("EASYVISTA_TEST_URL", "https://example.invalid/api/v2/12345") + monkeypatch.setenv("EASYVISTA_TEST_TOKEN", "t0ken") + monkeypatch.setenv("EASYVISTA_TEST_ACCOUNT", "SENTINEL-NEVER-USED") + + config = _live_config()() + + assert config.account == "12345" + assert config.api_version == "v2" + assert config.api_root == "https://example.invalid/api/v2/12345" + + +def test_a_bare_host_with_no_account_skips_and_says_which_name_to_set( + monkeypatch, _clean_credential_env +): + """The skip has to hand back the *new* name, or it just relocates the puzzle.""" + monkeypatch.setenv("EASYVISTA_TEST_URL", "https://example.invalid") + monkeypatch.setenv("EASYVISTA_TEST_TOKEN", "t0ken") + + with pytest.raises(pytest.skip.Exception) as info: + _live_config()() + + message = str(info.value) + assert "EASYVISTA_TEST_ACCOUNT" in message + assert "not a login" in message + + +def test_missing_credentials_still_skip_even_with_a_stale_legacy_file( + monkeypatch, _clean_credential_env +): + """A fresh checkout stays offline and green, stray file or not. + + The tripwire is deliberately ordered *after* the url/token skip: with nothing + configured there is no live run for a stale name to mislead, and turning that + case into a hard failure would break the suite's "skips cleanly" contract. + """ + (_clean_credential_env / conftest_module._LEGACY_ACCOUNT_FILE).write_text( + "50004", encoding="utf-8" + ) + + with pytest.raises(pytest.skip.Exception) as info: + _live_config()() + + assert "live credentials unavailable" in str(info.value) diff --git a/integration_tests/test_live_baseline_version.py b/integration_tests/test_live_baseline_version.py new file mode 100644 index 0000000..a0666af --- /dev/null +++ b/integration_tests/test_live_baseline_version.py @@ -0,0 +1,52 @@ +"""Pin the baseline this package is written against. + +Everything in ``docs/vendor-api-reference.md`` is stated for EasyVista 2025.3. +An instance upgrade should surface here as a failing test rather than as +behaviour nobody can account for. + +Credential-gated like the rest of ``integration_tests/`` and never run in CI. +""" + +from __future__ import annotations + +import httpx +import pytest + +from easyvista_python_client import EasyvistaConfig + +BASELINE = "2025.3" + +pytestmark = pytest.mark.integration + + +def test_instance_still_reports_the_baseline_version( + live_config: EasyvistaConfig, +) -> None: + headers = {"Accept": "application/json"} + if live_config.token: + headers["Authorization"] = f"Bearer {live_config.token}" + auth = None + else: + auth = httpx.BasicAuth(live_config.login or "", live_config.password or "") + + response = httpx.get( + f"{live_config.api_root}/swagger", + headers=headers, + auth=auth, + timeout=live_config.timeout, + verify=live_config.verify_ssl, + ) + + # Deliberately not `== 200`. A GET here answers 201, which is odd enough + # that a status check written from habit would skip the assertion below + # and pass for the wrong reason. + assert response.status_code == 201, ( + f"GET {{api_root}}/swagger returned {response.status_code}; it has " + "answered 201 since this baseline was measured" + ) + + info = response.json().get("info", {}) + assert BASELINE in info.get("description", ""), ( + f"instance reports {info.get('description')!r}, but this package's " + f"docs/vendor-api-reference.md is written against {BASELINE}" + ) diff --git a/integration_tests/test_live_change_window.py b/integration_tests/test_live_change_window.py new file mode 100644 index 0000000..9b3634a --- /dev/null +++ b/integration_tests/test_live_change_window.py @@ -0,0 +1,805 @@ +"""Characterization of EasyVista's interval, sort and wildcard grammars. + +Established live 2026-08-17 and guarded here. These tests assert *relationships* +— never fixed counts — so they hold on any instance. + +The central discipline: a condition this API cannot honour is **silently +dropped** and the whole table comes back. So a single count can never prove a +range filter works — if the chosen instant sits before every record, an honoured +lower bound *also* returns everything. Every interval assertion below is +therefore a **differential across two instants**: strictly more rows at the +earlier one, and both strictly inside (0, baseline). + +Skipped automatically without credentials; never runs in CI. +""" + +from __future__ import annotations + +import uuid +from datetime import timedelta, timezone +from itertools import pairwise + +import pytest + +from easyvista_python_client import ( + Action, + ActionUpdate, + EasyvistaClient, + EasyvistaValidationError, + PostAction, + ev_between_filter, + ev_contains_filter, + ev_since_filter, + ev_starts_with_filter, + format_ev_datetime, + parse_ev_datetime, +) +from easyvista_python_client._html import html_to_text + +pytestmark = pytest.mark.integration + + +def _count(client: EasyvistaClient, search: str | None = None) -> int: + return client.search_tickets(search=search, max_rows=1).total_record_count or 0 + + +@pytest.fixture(scope="session") +def tickets_baseline(live_client: EasyvistaClient) -> int: + """Unfiltered ticket count — the "condition was dropped" tell.""" + total = _count(live_client) + if total < 4: + pytest.skip("need at least 4 tickets to characterize a change window") + return total + + +@pytest.fixture(scope="session") +def split_instants(live_client: EasyvistaClient) -> tuple[str, str]: + """Two LAST_UPDATE literals with tickets between them, early then late. + + Sampled across four pages because the default order is not chronological, so + one page of a large instance is a biased slice and its quartiles may not + actually split the data. + + Returns the API's own accepted rendering (:func:`format_ev_datetime`), not + a Python ``repr``: ``last_update`` is a parsed, timezone-aware ``datetime`` + (Task 5), so collecting it via ``.model_dump(by_alias=True)`` in "python" + mode hands back the ``datetime`` object itself, not a string -- despite + this fixture's own ``-> tuple[str, str]`` annotation. Interpolating that + object straight into an f-string (as the comparison-operator test below + does) renders Python's ``str(datetime)`` -- a space separator and 6-digit + microseconds -- which is NOT a literal this API accepts. Sorting is done + on the ``datetime`` values themselves (correct under differing UTC + offsets), then each endpoint is rendered to a literal on the way out. + """ + stamps: list = [] + for page in range(4): + result = live_client.search_tickets( + max_rows=200, offset=page * 200, fields=["RFC_NUMBER", "LAST_UPDATE"] + ) + if not result.records: + break + for row in result.records: + if row.last_update is not None: + stamps.append(row.last_update) + if len(stamps) < 8: + pytest.skip("too few LAST_UPDATE values sampled to derive split instants") + stamps.sort() + early_dt, late_dt = stamps[len(stamps) // 4], stamps[(3 * len(stamps)) // 4] + if early_dt == late_dt: + pytest.skip("sampled LAST_UPDATE values do not span two distinct instants") + return format_ev_datetime(early_dt), format_ev_datetime(late_dt) + + +def test_last_update_parses_to_an_aware_datetime(live_client: EasyvistaClient): + """EV-R7: the model must hand back an aware datetime from real data.""" + result = live_client.search_tickets( + max_rows=1, fields=["RFC_NUMBER", "LAST_UPDATE"] + ) + if not result.records: + pytest.skip("no tickets on the live instance") + value = result.records[0].last_update + if value is None: + pytest.skip("sampled ticket has no LAST_UPDATE") + # Bind before asserting: an inline assert would render the whole record (P2). + has_zone = value.tzinfo is not None and value.utcoffset() is not None + assert has_zone, "LAST_UPDATE parsed without a timezone offset" + + +def test_the_open_ended_interval_is_honoured_and_monotone( + live_client: EasyvistaClient, split_instants, tickets_baseline +): + """EV-R5, the decisive one: ``FIELD:(instant;)`` really bounds the result. + + Judged by a differential, never a single count. Only a genuinely applied + lower bound returns strictly fewer rows as the instant moves later while + both counts stay strictly inside the table. + """ + early, late = split_instants + search_early = ev_since_filter("LAST_UPDATE", parse_ev_datetime(early)) + search_late = ev_since_filter("LAST_UPDATE", parse_ev_datetime(late)) + # Two SEPARATE asserts, not one `and`-joined check: a compound boolean + # that fails on the second operand would have pytest's rewriter print + # both operands to explain it, and the other filter string here embeds a + # live instant (P2). Neither can actually fail (`_interval_bound` returns + # a non-empty string or raises), but the channel is closed either way. + assert search_early is not None + assert search_late is not None + + count_early = _count(live_client, search_early) + count_late = _count(live_client, search_late) + + assert count_early > count_late, ( + "the interval was not applied: a later lower bound returned at least as " + "many rows as an earlier one" + ) + assert 0 < count_late, "the later bound matched nothing — instants unusable" + assert count_early < tickets_baseline, ( + "the earlier bound returned the whole table, i.e. the condition was " + "silently dropped" + ) + + +def test_the_closed_interval_is_honoured( + live_client: EasyvistaClient, split_instants, tickets_baseline +): + """The upper bound must narrow further than the lower bound alone. + + A single count strictly inside ``(0, baseline)`` cannot distinguish a + genuinely closed interval from an upper bound that was silently dropped: + if ``FIELD:(early;late)`` degraded to the open-ended ``FIELD:(early;)``, + ``got`` would just equal ``count_early`` -- a value + ``test_the_open_ended_interval_is_honoured_and_monotone`` already proves + sits strictly inside ``(0, baseline)`` on its own, so that check alone + would pass for the wrong reason. The extra ``count_early`` query below is + what actually establishes the upper bound narrows the result further. + """ + early, late = split_instants + search = ev_between_filter("LAST_UPDATE", early, late) + search_since_early = ev_since_filter("LAST_UPDATE", early) + # Two separate asserts, not one `and`-joined check: see the equivalent + # note on the open-ended interval test above (P2). + assert search is not None + assert search_since_early is not None + + got = _count(live_client, search) + count_early = _count(live_client, search_since_early) + + assert 0 < got < tickets_baseline + assert got < count_early, ( + "the closed interval matched at least as many rows as the open-ended " + "lower bound alone -- the upper bound may have been silently dropped" + ) + + +def test_a_comparison_operator_never_narrows_the_result( + live_client: EasyvistaClient, split_instants, tickets_baseline +): + """The negative half, pinned: no comparison operator exists on this API — + but measured live 2026-08-17, it fails two DIFFERENT ways, not one. + + The brief this test started from assumed all three renderings are + "silently dropped" (whole table, no error). Measured against this + instance, only the colon-free rendering actually is: ``LAST_UPDATE>="…"`` + does not match ``FIELD:"value"`` at all, so it is structurally + unparseable and takes the same silent-ignore path as + ``test_bare_sql_like_is_silently_ignored`` in ``test_live_search_syntax.py``. + + The other two DO use ``FIELD:"value"`` syntax, and ``LAST_UPDATE`` is a + date-typed column, so the quoted value must actually parse as one — + embedding ``>=`` or a ``[a TO b]`` range inside the quotes instead trips + the **type-mismatch** fate: a hard ``EasyvistaValidationError`` (HTTP 590). + A CONTROL below isolates that claim: a bare, valid ``LAST_UPDATE`` literal + is asserted to be ACCEPTED (no raise), which is what licenses attributing + the two 590s to the embedded comparison syntax specifically, rather than + to ``FIELD:"value"`` being unusable on this column at all. Without that + control, a future release that started honouring ``>=`` but still + rejected this exact rendering's date shape could keep the raises green + while the claim they guard went false -- the same "prose outran evidence" + failure this task exists to catch, one level down. + ``test_live_search_syntax.py`` documents the same paired shape (bogus vs. + type-correct value) for an int column; this generalizes it to a date + column. Asserting ``== tickets_baseline`` for the two raising cases, as + the original version of this test did, is wrong: it happened to fail + loudly with a 590 rather than passing for the wrong reason, but it was + still pinning a false claim. + + Whichever fate applies, a comparison operator never narrows the result — + it either raises or returns the whole table — so the filter builders' + reason for existing still holds. If a future EasyVista release starts + honouring one of these forms, this test fails and the interval builders + can be simplified. + """ + early, _late = split_instants + + # Control: a bare, valid LAST_UPDATE literal must be ACCEPTED. Called + # outside `pytest.raises` on purpose -- if this column rejected + # `FIELD:"value"` syntax outright, this call would itself raise and the + # test would error here, honestly, rather than mis-attributing that + # rejection to the comparison operator in the two raises below. + control = _count(live_client, f'LAST_UPDATE:"{early}"') + # Strict on BOTH sides, and deliberately so. `0 <= control <= baseline` was + # unfalsifiable -- `_count` never returns a negative, and a filtered count on + # one table cannot exceed the unfiltered one -- so it read as a gate while + # gating nothing, and its failure message described a state that could not + # occur. An exact-instant equality match returns about one row, so the + # strict upper bound additionally proves the literal was HONOURED rather + # than merely not rejected; without it, a future release that started + # silently dropping the condition (and returning the whole table) would + # leave this control green and the 590s below misattributed. + assert 0 < control < tickets_baseline, ( + "a bare valid LAST_UPDATE literal was not honoured as an equality " + "match -- the 590s below can no longer be attributed to the embedded " + "comparison syntax" + ) + + with pytest.raises(EasyvistaValidationError) as excinfo: + _count(live_client, f'LAST_UPDATE:">={early}"') + # Bound first: `excinfo.value.status_code` renders the ExceptionInfo, and + # with it the server's own error prose (P2). + status_code = excinfo.value.status_code + assert status_code == 590 + + with pytest.raises(EasyvistaValidationError) as excinfo: + _count(live_client, f'LAST_UPDATE:"[{early} TO *]"') + status_code = excinfo.value.status_code + assert status_code == 590 + + # Only this rendering breaks FIELD:"value" structure altogether (no + # colon), so it is the one that actually reaches the silent-ignore path. + got = _count(live_client, f'LAST_UPDATE>="{early}"') + # Re-measured now, not the session-cached `tickets_baseline`: one concurrent + # create on the shared instance between that fixture's capture and this + # assertion makes the live unfiltered count `tickets_baseline + 1`, and a + # strict `== tickets_baseline` would fail claiming the opposite of what + # happened -- "a bare comparison operator was honoured" -- for an ordinary + # write elsewhere on the instance. Comparing against a same-instant + # unfiltered re-read keeps the property under test (an unparseable + # comparison operator does not narrow the result) without assuming the + # instance is quiescent. + current_unfiltered = _count(live_client) + assert got == current_unfiltered, ( + "a bare comparison operator was honoured — the interval builders may " + "no longer be the only option" + ) + + +def test_only_some_timestamp_renderings_are_accepted_as_an_interval_bound( + live_client: EasyvistaClient, split_instants, tickets_baseline +): + """Pins the ACCEPTED and REJECTED rendering sets, which normalisation rests on. + + ``_interval_bound`` re-renders every admitted time bound through + ``format_ev_datetime`` rather than passing the caller's string through. That + is only justified if the wire really is this picky, so the matrix is measured + here instead of remembered: a bare date and millisecond-precision-with-offset + (or ``Z``) are honoured, while second precision *with* an offset, minute + precision, and a space separator instead of ``T`` each raise HTTP 590. + + The second-precision case is the one that matters most. It is the most + natural way for a caller to satisfy the "a time bound must carry its offset" + rule -- append ``+02:00`` to a stored ``"2026-08-17T20:26:40"`` watermark -- + and the package's own unit test used to pin it as the canonical shape. If a + future release starts accepting it, normalisation becomes optional and this + test says so. + + Built from raw ``search=`` strings on purpose: the builders now emit only the + honoured rendering, so they cannot express the rejected ones. + + Each honoured rendering is a **differential across two bounds** in that same + rendering, never a single count: a silently dropped condition returns the + whole table, so ``0 < got <= baseline`` would pass on the very fate this + module exists to detect. The later bound must return strictly fewer rows than + the earlier one, which no dropped condition can do -- dropped, both counts are + the baseline. The pairs are built so the strict inequality is guaranteed by + construction: ``split_instants``' early instant is itself in the result set of + the early bound (the lower bound is inclusive) and below the later bound. + """ + early, late = split_instants + moment = parse_ev_datetime(early) + 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) + # 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. + day_after_late = (later.date() + timedelta(days=1)).isoformat() + + honoured = { + "date only": (moment.date().isoformat(), day_after_late), + "milliseconds with offset": ( + format_ev_datetime(moment), + format_ev_datetime(later), + ), + "milliseconds with Z": ( + format_ev_datetime(as_utc).replace("+00:00", "Z"), + format_ev_datetime(later_utc).replace("+00:00", "Z"), + ), + } + for name, (low, high) in honoured.items(): + got_low = _count(live_client, f"LAST_UPDATE:({low};)") + got_high = _count(live_client, f"LAST_UPDATE:({high};)") + # `name` is authored here. The counts are NOT kept out of pytest's + # output by binding them to a local -- pytest's assertion rewriter + # reprints BOTH operands of a comparison even when the assert carries a + # message, so `assert got_low <= tickets_baseline` would still emit the + # live integers. Only binding the comparison itself to a bool closes + # that channel, which is why it is done below (the same idiom + # `is_non_increasing` and `colon_did_not_expand` use elsewhere in this + # module). Aggregate row counts are the one live-derived value this + # module accepts printing (P2) -- record fields, RFCs and timestamps + # are not -- and even that is avoided here where it costs nothing. + bound_was_honoured = 0 < got_low <= tickets_baseline + assert bound_was_honoured, ( + f"the {name!r} rendering was expected to be honoured as an interval " + "bound and returned nothing -- format_ev_datetime may no longer emit " + "a literal this grammar accepts" + ) + later_bound_narrowed = got_high < got_low + assert later_bound_narrowed, ( + f"a later bound in the {name!r} rendering did not return strictly " + "fewer rows than an earlier one -- the condition is being silently " + "dropped and the whole table returned, which is what this rendering " + "being honoured is supposed to rule out" + ) + + rejected = { + # The trap: this is what appending an offset to a naive watermark gives. + "seconds with offset": moment.isoformat(timespec="seconds"), + "minutes with offset": moment.isoformat(timespec="minutes"), + # What `str(aware_datetime)` produces. + "space instead of T": format_ev_datetime(moment).replace("T", " "), + } + for name, literal in rejected.items(): + with pytest.raises(EasyvistaValidationError) as excinfo: + _count(live_client, f"LAST_UPDATE:({literal};)") + # Bind first: rendering the ExceptionInfo would print the server's own + # error prose (P2). + status_code = excinfo.value.status_code + assert status_code == 590, ( + f"the {name!r} rendering failed with an unexpected status; the " + "accepted-rendering set may have changed" + ) + + +def test_descending_sort_needs_the_space_separated_token( + live_client: EasyvistaClient, split_instants +): + """EV-R6: `FIELD DESC` sorts; `FIELD:DESC` is silently ignored. + + Comparing against the UNSORTED order is what makes this meaningful — a + monotonicity check alone cannot distinguish "sorted descending" from "the + default order happens to be descending". + + Measured with the change **window applied**, because that is the claim the + docs rest on: ``ev_since_filter``, the user guide and the search-syntax skill + all now tell a caller to sweep + ``iter_tickets(search=ev_since_filter("LAST_UPDATE", w), sort="LAST_UPDATE + DESC")`` — sort and filter together. ``sort`` has the same silent-failure mode + a search condition has (three token shapes are ignored with no error), so + "DESC is honoured when a ``search`` is also present" has to be measured, not + inferred from the unfiltered case. If this instance dropped ``sort`` whenever + ``search`` was set, that entire remedy would be inert while every surface + asserted it works. + """ + proj = ["RFC_NUMBER", "LAST_UPDATE"] + early, _late = split_instants + window = ev_since_filter("LAST_UPDATE", early) + assert window is not None, "split_instants did not yield a usable window bound" + + def rfcs(sort: str | None) -> list[str | None]: + page = live_client.search_tickets( + search=window, sort=sort, fields=proj, max_rows=20 + ) + return [r.rfc_number for r in page.records] + + def stamps(sort: str | None) -> list: + page = live_client.search_tickets( + search=window, sort=sort, fields=proj, max_rows=20 + ) + return [r.last_update for r in page.records if r.last_update is not None] + + unsorted_order = rfcs(None) + if len(unsorted_order) < 4: + pytest.skip("need at least 4 tickets to characterize sorting") + + descending = stamps("LAST_UPDATE DESC") + # `all(...)` over pairwise is True for a list of 0 or 1 element, and the only + # length guard in this test measures `unsorted_order` -- a DIFFERENT list + # from a different query. Skip rather than assert nothing, the same idiom + # `split_instants` uses for its own sample. + if len(descending) < 2: + pytest.skip("too few LAST_UPDATE values on the sorted page to check order") + is_non_increasing = all(a >= b for a, b in pairwise(descending)) + assert is_non_increasing, "'LAST_UPDATE DESC' did not return newest-first" + reordered = rfcs("LAST_UPDATE DESC") != unsorted_order + assert reordered, "'LAST_UPDATE DESC' returned the default order unchanged" + + # Re-take the unsorted snapshot immediately before comparing, rather than + # reusing `unsorted_order` from three round trips ago. With a window + # applied, a ticket whose LAST_UPDATE sits just below the bound can be + # touched by anyone on the shared instance in those seconds and ENTER the + # filtered set within the first 20 rows -- membership differs, not order, + # which the stale snapshot would misattribute to 'LAST_UPDATE:DESC' now + # reordering results. Before the window was added, set membership was + # stable and only reordering could break this equality; a window makes it + # a race. One retry absorbs a second unlucky write in the same gap before + # this fails for the wrong reason. + colon_order = rfcs("LAST_UPDATE:DESC") + fresh_unsorted = rfcs(None) + colon_ignored = colon_order == fresh_unsorted + if not colon_ignored: + colon_order = rfcs("LAST_UPDATE:DESC") + fresh_unsorted = rfcs(None) + colon_ignored = colon_order == fresh_unsorted + assert colon_ignored, ( + "'LAST_UPDATE:DESC' now reorders results — it used to be silently " + "ignored, and RECENT_TICKETS_SORT was changed on that basis" + ) + + +def test_the_ascending_sort_tokens_are_honoured_too( + live_client: EasyvistaClient, split_instants +): + """Pins the two ASCENDING tokens: bare ``FIELD`` and ``FIELD ASC``. + + **Not the recommended sweep form.** The change-window guidance is + ``LAST_UPDATE DESC`` (see ``ev_since_filter``): under offset pagination an + ascending sweep drops a row whose own stamp did *not* change, which falls + below the next watermark and is lost, while descending drops the re-touched + row, whose stamp is above the watermark and is re-selected. This test exists + only because both tokens' *availability* is a documented fact -- the docs name + them when explaining why the direction is a choice -- and a fact this package + states should be measured rather than remembered. + + Measured with the change window applied, the same reasoning as the DESC test + above, and deliberately paired against the UNSORTED order of the same + filtered query: monotonicity alone cannot tell "sorted ascending" apart from + "the default order happens to be ascending". + """ + proj = ["RFC_NUMBER", "LAST_UPDATE"] + early, _late = split_instants + window = ev_since_filter("LAST_UPDATE", early) + assert window is not None, "split_instants did not yield a usable window bound" + + def page(sort: str | None) -> tuple[list[str | None], list]: + result = live_client.search_tickets( + search=window, sort=sort, fields=proj, max_rows=20 + ) + return ( + [r.rfc_number for r in result.records], + [r.last_update for r in result.records if r.last_update is not None], + ) + + unsorted_order, unsorted_stamps = page(None) + if len(unsorted_order) < 4: + pytest.skip("need at least 4 tickets to characterize sorting") + if len(unsorted_stamps) >= 2 and all(a <= b for a, b in pairwise(unsorted_stamps)): + pytest.skip( + "the default page order is already LAST_UPDATE-ascending on this " + "instance -- cannot distinguish an honoured ascending token from a " + "coincidence" + ) + + for token in ("LAST_UPDATE", "LAST_UPDATE ASC"): + order, stamps = page(token) + if len(stamps) < 2: + pytest.skip("too few LAST_UPDATE values on the sorted page to check order") + is_non_decreasing = all(a <= b for a, b in pairwise(stamps)) + # Bind the token into a local: it is a literal authored here, not a + # value read from the instance, so it is printable under P2. + assert is_non_decreasing, f"{token!r} did not return oldest-first" + reordered = order != unsorted_order + assert reordered, f"{token!r} returned the default order unchanged" + + +def test_recent_tickets_sort_token_is_honoured(live_client: EasyvistaClient): + """The exact constant `get_department_context` relies on (O-DIR-1). + + Comparing against the UNSORTED order is what makes this meaningful, the + same reasoning ``test_descending_sort_needs_the_space_separated_token`` + documents: monotonicity alone cannot tell "sorted descending" apart from + "the default order happens to be descending". If the default page is + itself already RFC-descending, this instance cannot discriminate the two + and the test skips rather than passing for a coincidental reason. + """ + from easyvista_python_client.directory import RECENT_TICKETS_SORT + + proj = ["RFC_NUMBER"] + + def rfcs(sort: str | None) -> list[str]: + page = live_client.search_tickets(sort=sort, fields=proj, max_rows=20) + # Filtered consistently on BOTH the sorted and unsorted side: an RFC-less + # row would otherwise make `sorted_rfcs` and its own re-sorted copy + # differ in length and fail the monotonicity check for the wrong reason. + return [r.rfc_number for r in page.records if r.rfc_number] + + unsorted_order = rfcs(None) + if len(unsorted_order) < 4: + pytest.skip("need at least 4 tickets") + if unsorted_order == sorted(unsorted_order, reverse=True): + pytest.skip( + "the default page order is already RFC-descending on this " + "instance -- cannot distinguish an honoured sort token from a " + "coincidence" + ) + + sorted_rfcs = rfcs(RECENT_TICKETS_SORT) + is_descending = sorted_rfcs == sorted(sorted_rfcs, reverse=True) + # "descending RFC_NUMBER", not "newest-first": RFC_NUMBER is a varchar, so + # this proves a string ordering and nothing about dates. See the comment on + # RECENT_TICKETS_SORT in directory.py. + assert is_descending, ( + f"{RECENT_TICKETS_SORT!r} did not return descending RFC_NUMBER order" + ) + reordered = sorted_rfcs != unsorted_order + assert reordered, ( + f"{RECENT_TICKETS_SORT!r} returned the default order unchanged -- it " + "may be silently ignored" + ) + + +def test_tilde_is_a_wildcard_operator_when_given_a_wildcard( + live_client: EasyvistaClient, tickets_baseline +): + """Corrects this suite's own earlier conclusion that `~` is exact-match. + + That held only for wildcard-free inputs. With an explicit `*`, `~` matches a + prefix or a substring; `:` never does. Anchored on a real RFC so the prefix + demonstrably exists. + """ + page = live_client.search_tickets(max_rows=1, fields=["RFC_NUMBER"]) + if not page.records or not page.records[0].rfc_number: + pytest.skip("no RFC to build a wildcard probe from") + rfc = page.records[0].rfc_number + if len(rfc) < 8: + pytest.skip("RFC too short to form a strict prefix") + prefix = rfc[:6] + + exact = _count(live_client, f'RFC_NUMBER:"{rfc}"') + assert exact == 1 + + by_prefix = _count(live_client, ev_starts_with_filter("RFC_NUMBER", prefix)) + if by_prefix <= exact: + # `exact <= by_prefix` was satisfied by by_prefix == exact == 1, in which + # state the test passed while asserting nothing about `~` being a pattern + # operator -- its own headline claim. The sibling '%' test already treats + # this state as inconclusive and skips; agree with it rather than + # reporting green on a degenerate sample. + pytest.skip( + "the sampled prefix matches no more than the exact RFC on this " + "instance -- cannot demonstrate wildcard expansion" + ) + assert by_prefix < tickets_baseline, ( + "the prefix pattern matched the whole table — '~' with a wildcard is " + "not behaving as a pattern operator" + ) + + by_contains = _count(live_client, ev_contains_filter("RFC_NUMBER", prefix)) + assert by_prefix <= by_contains < tickets_baseline + + # ':' does NOT expand a wildcard — an honest 0, not the whole table. + colon_literal = _count(live_client, f'RFC_NUMBER:"{prefix}*"') + assert colon_literal == 0 + + +def test_every_refused_metacharacter_really_is_one_under_tilde( + live_client: EasyvistaClient, tickets_baseline +): + """Settles whether ``%`` is really a wildcard for ``~`` — measured, not assumed. + + ``ev_contains_filter``/``ev_starts_with_filter`` (``filters.py``) reject a + caller-supplied ``%`` on the premise that it is a wildcard character like + ``*``, but the original probe behind that rejection only ever measured + ``*``. Measured live 2026-08-17 on this instance: ``RFC_NUMBER~"%"`` + and ``RFC_NUMBER~"*"`` matched the identical, non-trivial count (32 + of 4317 tickets), both strictly more than the 1-row exact match and strictly + fewer than the whole table. ``%`` behaves exactly as a wildcard here, so the + builders' rejection of it is justified and should stay as is. + + Extended 2026-08-18 to the other two refused metacharacters, ``_`` and + ``[``, on the same reasoning: the builders reject them, so the rejection + needs live justification. ``_`` is a SINGLE-character wildcard — replacing + one character of an exact-matching RFC with it widens the match — and + ``[0-9]`` in that position is evaluated as a character class, while + ``[x]`` still matches only the one row. A backslash does + not escape ``_``; ``\\_`` matches nothing, which is what makes + refusing the only honest option. + + Built with raw ``search=`` strings rather than the builders themselves, + since ``ev_contains_filter``/``ev_starts_with_filter`` raise ``ValueError`` + on any of these in the caller's value by design — that rejection is the very + thing this test is checking the justification for. + + The ``_`` and ``[`` probes are built WITHOUT a wildcard on purpose, and that + is what justifies ``filters._OPERATOR_METACHARS`` being refused even at + ``wildcard=None``: these are metacharacters of ``~`` itself, not of the + token the builders append. If either probe ever measures as literal, that + refusal — and only that one — can be relaxed. + """ + page = live_client.search_tickets(max_rows=1, fields=["RFC_NUMBER"]) + if not page.records or not page.records[0].rfc_number: + pytest.skip("no RFC to build a wildcard probe from") + rfc = page.records[0].rfc_number + if len(rfc) < 8: + pytest.skip("RFC too short to form a strict prefix") + prefix = rfc[:6] + + exact = _count(live_client, f'RFC_NUMBER:"{rfc}"') + assert exact == 1 + + by_star = _count(live_client, f'RFC_NUMBER~"{prefix}*"') + if by_star <= exact: + # A data-availability gap (this sampled prefix happens to be unique + # on this instance), not a defect -- skip rather than fail (P1). The + # sibling tilde test's non-strict `exact <= by_prefix` is the + # precedent for treating "no wider than exact" as inconclusive, not + # wrong. + pytest.skip( + "the sampled prefix's '*' match is no wider than the exact RFC " + "on this instance -- cannot use it as the reference point for " + "the '%' comparison" + ) + assert by_star < tickets_baseline, ( + "the '*' prefix pattern matched the whole table -- cannot use it as " + "the reference point for the '%' comparison" + ) + + by_percent = _count(live_client, f'RFC_NUMBER~"{prefix}%"') + assert by_percent == by_star, ( + "'%' no longer matches the same count as '*' under '~' — it may have " + "stopped behaving as a wildcard, which would justify relaxing the " + "builders' rejection of a caller-supplied '%'" + ) + + # `_` and `[` are probed by REPLACING the RFC's final character, so the + # pattern has the same length as the exact value. Three outcomes, all + # distinguishable: 0 means the character was compared LITERALLY (no RFC + # contains it in that position) and is no longer a metacharacter — that is + # the regression this pins; `== exact` means it behaved as a wildcard but + # this sampled stem has no sibling to widen onto, a data gap the module + # skips on elsewhere; `> exact` is the measured behaviour. + stem, last = rfc[:-1], rfc[-1] + widened_by: dict[str, int] = {} + for probe, name in ( + (f'RFC_NUMBER~"{stem}_"', "_"), + (f'RFC_NUMBER~"{stem}[0-9]"', "[0-9]"), + ): + widened = _count(live_client, probe) + widened_by[name] = widened + # `name` is a literal authored here, never a value read from the + # instance, so it is printable under P2. `stem` is NOT printed. + assert widened > 0, ( + f"{name!r} matched nothing where the exact RFC matches one row -- it " + "is being compared literally, i.e. it is no longer a metacharacter " + "under '~', and the builders' refusal of it could be relaxed" + ) + if widened == exact: + pytest.skip( + f"{name!r} behaved as a pattern but this instance has no other " + "record sharing the sampled stem -- cannot demonstrate widening" + ) + assert widened < tickets_baseline, ( + f"{name!r} matched the whole table, which is what a SILENTLY DROPPED " + "condition also looks like -- inconclusive as evidence" + ) + + # The EXIT the builders' error message, the user guide, the README and the + # asset skill now name: `:` does not expand a wildcard, so an exact match on + # a value containing `_` is expressible even though `~` refuses it. Decisive + # because `~` on this very pattern widened above -- if `:` expanded `_` too, + # this count would match that one instead of being far smaller. + by_colon = _count(live_client, f'RFC_NUMBER:"{stem}_"') + colon_did_not_expand = by_colon < widened_by["_"] + assert colon_did_not_expand, ( + "':' expanded '_' as a wildcard (or the condition was dropped and the " + "whole table came back) -- ev_equals_filter is then NOT the exact-match " + "exit that the wildcard builders' error message and the docs point at " + "for a value containing '_'" + ) + + # A one-character class matching only the real final character must behave + # like the exact match: that is what shows the class is evaluated rather + # than `[0-9]` merely being swallowed into some broader match. + single_class = _count(live_client, f'RFC_NUMBER~"{stem}[{last}x]"') + assert single_class == exact, ( + "a one-character class naming only the real final character did not " + "behave like the exact match -- '[' is not being evaluated as a " + "character class the way the wider '[0-9]' probe suggests" + ) + + # No escape exists: the backslash is compared literally, which is why the + # builders refuse a metacharacter rather than escaping it. Decisive only on + # an RFC that really contains an underscore -- then a WORKING escape would + # match that one row, and a literal backslash matches nothing. + if "_" in rfc: + escaped = _count(live_client, 'RFC_NUMBER~"{}"'.format(rfc.replace("_", "\\_"))) + assert escaped == 0, ( + "a backslash now escapes '_' under '~' — the builders could escape a " + "caller-supplied metacharacter instead of refusing it" + ) + + +def test_update_action_writes_the_description_with_model_dump_casing( + live_client: EasyvistaClient, + live_write_client: EasyvistaClient, + ticket_factory, + live_action_config, +): + """``ActionUpdate.to_api()`` ships lowercase keys — verify that lands live. + + The probe behind :meth:`EasyvistaClient.update_action` edited an action by + sending a raw, hand-built ``{"DESCRIPTION": ...}`` body. ``ActionUpdate`` + instead goes through ``EasyvistaWriteModel.to_api()``, which calls + ``model_dump(exclude_none=True)`` with **no aliasing** — so the body this + client actually ships is lowercase ``{"description": ...}``, a casing + nobody had verified live before this test. Creates exactly one action: + an earlier probe found a *second* ``create_action`` on the same ticket can + fail with HTTP 590. + """ + rfc = ticket_factory() + original_marker = f"EVCLI{uuid.uuid4().hex[:10].upper()}ORIGINAL" + updated_marker = f"EVCLI{uuid.uuid4().hex[:10].upper()}UPDATED" + + before = {a.action_id for a in live_client.list_actions(rfc)} + live_write_client.create_action( + rfc, + PostAction( + action_type_id=int(live_action_config["action_type_id"]), + group_id=int(live_action_config["group_id"]), + description=original_marker, + ), + ) + fresh: list[Action] = [ + a for a in live_client.list_actions(rfc) if a.action_id not in before + ] + # Bound first: `assert len(fresh) == 1` would repr the whole list, i.e. + # every live Action record in it (P2). + exactly_one_new = len(fresh) == 1 + assert exactly_one_new, ( + f"expected exactly 1 new action on {rfc} after creating one, got {len(fresh)}" + ) + action_id = fresh[0].action_id + assert action_id is not None, "listed action carries no ACTION_ID" + + returned = live_client.update_action( + action_id, ActionUpdate(description=updated_marker) + ) + # Characterize the PUT's echo, which had never been captured -- the skill + # snippet used to print a field off it. Two shapes are both acceptable and + # both documented: `update_action` parses through `_first_record_parser`, so + # an empty or href-only body yields an Action whose every field is None, + # while a record-bearing body yields this action. What must NEVER happen is + # the third shape -- an echo naming a DIFFERENT action, which would make the + # return value actively misleading rather than merely sparse. Deliberately + # not asserting `== action_id`: that shape is unverified, which is exactly + # why the docstring and the skill now say to re-read with `get_action`. + # `action_id` is already bound and already interpolated in this test's own + # messages, so echoing it is no new P2 exposure. + echo_names_another_action = ( + returned.action_id is not None and returned.action_id != action_id + ) + assert not echo_names_another_action, ( + f"update_action's echo names an action other than {action_id} -- the " + "return value cannot be treated as the edited record at all" + ) + + action = live_client.get_action(action_id) + href = ( + action.description.get("HREF") if isinstance(action.description, dict) else None + ) + assert href, f"action {action_id} carries no DESCRIPTION href after the update" + text = html_to_text(live_client.resolve_memo(href) or "") + + # Both markers are self-authored nonces, so printing them is fine under P2 + # (they name nothing about the live instance), but bind first anyway to + # keep this module's style uniform. + landed = updated_marker in text + stale = original_marker in text + assert landed, ( + "update_action's lowercase-cased body did not change the DESCRIPTION " + "memo -- ActionUpdate.to_api() ships {'description': ...} with no " + "aliasing, and that casing had never been verified live before this" + ) + assert not stale, "the pre-update marker is still present after the edit" diff --git a/integration_tests/test_live_directory.py b/integration_tests/test_live_directory.py index cdde2c9..f2156cb 100644 --- a/integration_tests/test_live_directory.py +++ b/integration_tests/test_live_directory.py @@ -78,9 +78,30 @@ def test_find_departments_returns_list(live_client: EasyvistaClient) -> None: def test_get_department_context( live_client: EasyvistaClient, sample_department_id: int ) -> None: + """Read-only. Also the live guard for the default ticket projection. + + ``recent_tickets`` is projected with ``RECENT_TICKET_FIELDS`` by default, + which is a deliberate change from sending no projection at all. It exists + because the unprojected list projection returns ``TITLE`` present but EMPTY + on this instance -- measured over 400 tickets, zero with a populated title + (see ``_adopt_by_title`` in ``conftest.py`` and + ``test_title_search_requires_the_fields_projection_to_return_a_value`` in + ``test_live_search_syntax.py``). So before this, every recent ticket's + ``.title`` was ``None``. If that assertion ever fails while tickets come + back, the projection has stopped reaching the wire. + """ ctx = live_client.get_department_context(sample_department_id, recent_tickets=3) assert_shape(ctx, DepartmentContext, "get_department_context result") id_round_trips = ctx.department.department_id == sample_department_id assert id_round_trips, "the context is not for the department requested" assert_shape(ctx.employees, list, "DepartmentContext.employees") assert_shape(ctx.ticket_count, int, "DepartmentContext.ticket_count") + if ctx.recent_tickets: + # Bound to a local first: P2 keeps live titles out of failure output, + # and an assert sub-expression would print the whole ticket. + any_titled = any(bool(t.title) for t in ctx.recent_tickets) + assert any_titled, ( + "no recent ticket carried a TITLE -- the default fields= projection " + "is what makes it non-empty on this instance, so this suggests it " + "stopped being sent" + ) diff --git a/integration_tests/test_live_instance_discovery.py b/integration_tests/test_live_instance_discovery.py new file mode 100644 index 0000000..99db717 --- /dev/null +++ b/integration_tests/test_live_instance_discovery.py @@ -0,0 +1,76 @@ +"""Live, READ-ONLY profile of the instance's discovery surface. + +Every call here is a GET: nothing is created, closed, updated or uploaded, so +unlike ``scripts/validate_docs_examples.py`` this cannot leak a ticket. Bounded +deliberately -- ``sample_size=20`` and ``action_sample_tickets=1`` -- because +the ``offset``/``@next`` contract is unverified on the actions endpoint (see +``iter_actions``), so an instance that ignores ``offset`` would otherwise +repeat page one forever. + +Credential-gated like the rest of ``integration_tests/`` and never run in CI. +""" + +from __future__ import annotations + +import pytest + +from easyvista_python_client import EasyvistaClient, InstanceProfile + +pytestmark = pytest.mark.integration + +BASELINE = "2025.3" + +#: The four names the instance's OpenAPI declares no list route for. This is a +#: topology fact read from the spec's ``paths`` (tier 2), not a 403 anyone +#: measured -- so discovery reports them as ``no-route`` rather than as denied. +ROUTELESS = ("IMPACT", "SEVERITY", "ORIGIN", "ACTION_TYPE") + + +def test_describe_instance_profiles_the_live_deployment( + live_client: EasyvistaClient, +) -> None: + profile = live_client.describe_instance( + include_spec=True, sample_size=20, action_sample_tickets=1 + ) + assert isinstance(profile, InstanceProfile) + + assert profile.version is not None + assert BASELINE in profile.version, ( + f"instance reports {profile.version!r}, but this package is written " + f"against {BASELINE}" + ) + assert "/requests" in profile.spec_paths or "requests" in profile.spec_paths + + # Read the gaps before believing any of them. A total outage looks exactly + # like a bare instance EXCEPT that every gap is named here. + for name in ROUTELESS: + reason = profile.unavailable.get(name, "") + assert reason.startswith("no-route"), ( + f"{name} should be reported as routeless, got {reason!r}" + ) + + statuses = profile.references.get("STATUS", []) + assert statuses, ( + "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. + 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" + ) + + +def test_get_api_spec_survives_the_201_that_a_status_check_would_skip( + live_client: EasyvistaClient, +) -> None: + """The client's transport gates on ``is_success``, so a 201 is fine. + + ``test_live_baseline_version`` pins the raw 201 with a bare httpx call -- + it has to, because this method hides the status code. This one proves the + document still arrives through the client. + """ + document = live_client.get_api_spec() + assert BASELINE in document["info"]["description"] + assert document["paths"] diff --git a/integration_tests/test_live_search_syntax.py b/integration_tests/test_live_search_syntax.py index 51ae124..461af64 100644 --- a/integration_tests/test_live_search_syntax.py +++ b/integration_tests/test_live_search_syntax.py @@ -29,9 +29,11 @@ silently *widens* a same-field query. A ``,`` **inside** the quotes is a literal, so escaping the quote is what blocks it. * **``;`` is not a combinator** — it is swallowed into the quoted value. -* **``~`` is exact-match, not "contains"** — identical to ``:``, on code-like - fields (``DEPARTMENT_CODE``, ``ASSET_TAG``) and free-text label fields - (``DEPARTMENT_FR``) alike. The published docs claiming otherwise are wrong. +* **``~`` is a pattern operator, but only with an explicit wildcard.** + ``FIELD~"abc*"`` matches a prefix and ``FIELD~"*abc*"`` a substring (verified + live 2026-08-17); ``%`` works as a wildcard too. Given a *bare* value it is + identical to ``:`` — exact match — which is why this suite once concluded it + was exact-only. ``:`` never expands a wildcard: ``FIELD:"abc*"`` returns 0. * **No escape for an embedded ``"`` was found.** Raw, backslash-escaped, and doubled-quote renderings of a title containing a literal ``"`` all fail to match a ticket verifiably created with that exact title (full table in @@ -378,13 +380,14 @@ def test_semicolon_is_not_a_combinator(live_client, sample_department_row, basel # --- the tilde operator ---------------------------------------------------- -def test_tilde_is_exact_match_not_contains( +def test_tilde_without_a_wildcard_behaves_as_exact_match( live_client, other_department_code, baseline ): - """``FIELD~value`` behaves identically to ``FIELD:"value"``. - - Decisive probe: a strict infix of a code that verifiably exists. A real - "contains" operator must match that code; exact-match cannot. + """``~`` requires an EXPLICIT wildcard to act as a pattern operator. Given a + bare value it is equality, which is what this test pins. The README's + ``ASSET_TAG~LAPTOP`` therefore finds only a tag that *is* ``LAPTOP``; to + mean "contains", write ``ASSET_TAG~"*LAPTOP*"`` — see + ``test_live_change_window.py`` for that behaviour, verified live 2026-08-17. """ code = other_department_code infix = code[1:] @@ -402,16 +405,17 @@ def test_tilde_is_exact_match_not_contains( assert tilde_infix == 0 -def test_tilde_is_exact_match_on_free_text_fields_too( +def test_tilde_without_a_wildcard_is_exact_on_free_text_too( live_client, department_label, baseline ): - """``~`` is not "contains" on free text either — it is exact everywhere. + """``~`` requires an EXPLICIT wildcard to act as a pattern operator. Given a + bare value it is equality, which is what this test pins. The README's + ``ASSET_TAG~LAPTOP`` therefore finds only a tag that *is* ``LAPTOP``; to + mean "contains", write ``ASSET_TAG~"*LAPTOP*"`` — see + ``test_live_change_window.py`` for that behaviour, verified live 2026-08-17. ``DEPARTMENT_FR`` is a human label, not a code, so this rules out the - "``~`` is contains, but only on free-text fields" hypothesis. The published - docs (``user_guide.rst`` calls ``~`` "contains"; the README advertises - ``ASSET_TAG~LAPTOP``) are wrong: ``~LAPTOP`` matches only a tag that *is* - exactly ``LAPTOP``. + "``~`` is contains, but only on free-text fields" hypothesis. """ label = department_label infix = label[1:-1] @@ -446,12 +450,14 @@ def test_comma_inside_a_quoted_value_is_a_literal( assert inside != baseline # and it is not silently ignored either -def test_tilde_on_asset_tag_is_exact_match(live_client): - """The README's advertised ``ASSET_TAG~LAPTOP`` does not do what it implies. +def test_tilde_without_a_wildcard_is_exact_on_asset_tag(live_client): + """``~`` requires an EXPLICIT wildcard to act as a pattern operator. Given a + bare value it is equality, which is what this test pins. The README's + ``ASSET_TAG~LAPTOP`` therefore finds only a tag that *is* ``LAPTOP``; to + mean "contains", write ``ASSET_TAG~"*LAPTOP*"`` — see + ``test_live_change_window.py`` for that behaviour, verified live 2026-08-17. - Probed on the very endpoint and field the README documents. ``~`` is exact - there too, so ``ASSET_TAG~LAPTOP`` finds only an asset tagged exactly - ``LAPTOP`` — not the laptops. + Probed on the very endpoint and field the README documents. """ try: result = live_client.search_assets(max_rows=25) diff --git a/integration_tests/test_live_smoke.py b/integration_tests/test_live_smoke.py index 8186436..09506b4 100644 --- a/integration_tests/test_live_smoke.py +++ b/integration_tests/test_live_smoke.py @@ -4,10 +4,18 @@ env vars or ``secrets/easyvista_test_*`` files. Never runs in CI (which runs ``pytest -m "not integration"``). NEVER point at production. -No ticket persists from this module: it reads, plus issues a single create that -the server is *expected to reject* (``test_missing_mandatory_field_raises_ -validation_error``). The ticket-creating fixture lives in ``conftest.py`` and is -used by ``test_live_search_syntax``. +This module WRITES. It creates up to three 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. + +The ticket-creating fixture lives in ``conftest.py`` and is also used by +``test_live_search_syntax``. 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 @@ -23,6 +31,8 @@ from __future__ import annotations +import uuid + import pytest from easyvista_python_client import ( @@ -30,9 +40,11 @@ Asset, Document, EasyvistaClient, + EasyvistaError, EasyvistaValidationError, PostRequest, Request, + ev_equals_filter, ) from integration_tests._assertions import assert_shape @@ -88,18 +100,203 @@ def test_classify_fields_live_ticket( assert all("AVAILABLE_FIELD_" in k.upper() for k in fc.available) -def test_missing_mandatory_field_raises_validation_error( - live_write_client: EasyvistaClient, sample_catalog_code: str +def test_an_underspecified_create_body_raises_validation_error( + live_client: EasyvistaClient, + live_write_client: EasyvistaClient, + live_write_config: dict[str, str], + sample_catalog_code: str, +) -> None: + """A create missing the documented ids is rejected 590, not retried as 5xx. + + This test used to send ``PostRequest(catalog_code=...)`` and attribute the + 590 to the missing *title*. Both halves of that were wrong, measured: + + * ``title`` is NOT the mandatory field. The full documented body with no + title at all creates successfully. What the old payload actually omitted + was ``origin``/``department_id``/``urgency_id``/``impact_id``, so the + assertion never tested the thing it named. + * it claimed "no ticket created ... read-only-safe by construction". A + rejected create on this API **does** create the row -- 9 of 9 rejections + left one -- so the old test leaked a ticket on every single live run. + + So the omission is now the documented ids (which really are required here), + and the ticket the rejection leaves behind is reconciled and closed. The + ``external_reference`` marker survives the failed insert and is searchable, + which is the only handle available: no ``RFC_NUMBER`` comes back from a + rejected create. + """ + marker = f"EVCLI{uuid.uuid4().hex[:10].upper()}" + try: + with pytest.raises(EasyvistaValidationError) as ei: + live_write_client.create_ticket( + PostRequest( + catalog_code=sample_catalog_code, + title=marker, + description=f"{marker} under-specified create; safe to close", + external_reference=marker, + ) + ) + # Bound first: asserting on `ei.value.status_code` makes the rewriter + # render the ExceptionInfo, and that prints the exception's own message -- + # server prose this suite did not author (P2). + status_code = ei.value.status_code + assert status_code == 590, "an under-specified create did not raise HTTP 590" + finally: + # Runs even when the create unexpectedly SUCCEEDS, because that outcome + # leaves a ticket too and an assertion failure must not also orphan one. + _close_by_marker(live_client, live_write_config, marker) + + +def test_the_documented_create_body_lands_every_id( + live_client: EasyvistaClient, + live_write_client: EasyvistaClient, + live_write_config: dict[str, str], + sample_catalog_code: str, +) -> None: + """The documented create body is accepted and every id it carries persists. + + The counterpart to the test above, and the reason this suite can trust any + create at all. ``origin``/``department_id``/``urgency_id``/``impact_id`` are + read back and compared to what was sent. + + The read is **explicitly projected**. It has to be: these columns are absent + from the default search projection exactly like ``TITLE``, so an unprojected + read returns ``None`` for all of them and would pass this test by comparing + two absences. + """ + cfg = live_write_config + marker = f"EVCLI{uuid.uuid4().hex[:10].upper()}" + try: + created = live_write_client.create_ticket( + PostRequest( + catalog_code=sample_catalog_code, + title=marker, + description=f"{marker} documented create body; safe to close", + origin=int(cfg["origin"]), + department_id=int(cfg["department_id"]), + urgency_id=int(cfg["urgency_id"]), + impact_id=int(cfg["impact_id"]), + external_reference=marker, + ) + ) + rfc = created.rfc_number + assert rfc, "the documented create body returned no RFC_NUMBER" + rows = live_client.search_tickets( + search=ev_equals_filter("RFC_NUMBER", rfc), + fields=[ + "RFC_NUMBER", + "URGENCY_ID", + "IMPACT_ID", + "REQUEST_ORIGIN_ID", + "EXTERNAL_REFERENCE", + ], + max_rows=1, + ) + record = rows.records[0] if rows.records else None + assert record is not None, "the created ticket was not readable back" + # Every comparison binds a bool before asserting, so a mismatch cannot + # print the instance's own values (P2). + urgency_matches = str(record.urgency_id) == str(cfg["urgency_id"]) + impact_matches = str(record.impact_id) == str(cfg["impact_id"]) + marker_matches = record.external_reference == marker + assert urgency_matches, "URGENCY_ID did not survive the documented create" + assert impact_matches, "IMPACT_ID did not survive the documented create" + assert marker_matches, "EXTERNAL_REFERENCE did not survive the create" + finally: + _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: - # Creating with a catalog but no title is rejected by EasyVista (no ticket - # created), so this stays read-only-safe by construction. The catalog code - # must be *valid* on this instance: the missing title has to be the only - # defect in the payload, or the 590 can't be attributed to it -- an unknown - # catalog would raise 590 too, and the assertion would prove nothing. - with pytest.raises(EasyvistaValidationError) as ei: - live_write_client.create_ticket(PostRequest(catalog_code=sample_catalog_code)) - # Bound first: asserting on `ei.value.status_code` makes the rewriter render - # the ExceptionInfo, and that prints the exception's own message -- server - # prose this suite did not author, on a payload naming a real catalog (P2). - status_code = ei.value.status_code - assert status_code == 590, "creating without a title did not raise HTTP 590" + """``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. + + The cleanup a rejected create needs. No ``RFC_NUMBER`` comes back from one, + so the marker is the only handle -- and it does survive the failed insert + (measured). Searching rather than tracking is the point: a tracked list can + only hold ids the caller was given, which is precisely the set that excludes + every orphan. + + Never raises. It runs in a ``finally`` beside the assertion that matters, and + a cleanup failure must not replace a real test result. + """ + try: + found = client.search_tickets( + search=ev_equals_filter("EXTERNAL_REFERENCE", marker), + fields=["RFC_NUMBER"], + max_rows=20, + ) + except EasyvistaError: + return + for record in found.records: + rfc = record.rfc_number + if not rfc: + continue + try: + client.close_ticket( + rfc, 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_ticket_identity.py b/integration_tests/test_live_ticket_identity.py index 2598814..0d0572a 100644 --- a/integration_tests/test_live_ticket_identity.py +++ b/integration_tests/test_live_ticket_identity.py @@ -22,7 +22,12 @@ import pytest -from easyvista_python_client import EasyvistaClient, Request, RequestUpdate +from easyvista_python_client import ( + EasyvistaClient, + EasyvistaValidationError, + Request, + RequestUpdate, +) from integration_tests._assertions import assert_populated, assert_shape pytestmark = pytest.mark.integration @@ -73,6 +78,153 @@ def test_title_is_writable(live_client: EasyvistaClient, ticket_factory): assert title_updated, "TITLE was not changed by RequestUpdate(title=...)" +def _other_live_values( + client: EasyvistaClient, column: str, current: object, limit: int = 5 +) -> list[int]: + """Up to ``limit`` distinct ids in use on ``column``, none equal to ``current``. + + Needed because writing back the value a ticket already carries proves + nothing: ``ticket_factory`` sets ``IMPACT_ID`` from ``live_write_config``, so + a read-back against that same id would pass even if the field were silently + dropped. Sampling ids that genuinely exist on the instance avoids hardcoding + an instance-specific value. + + Several candidates, not one: an id that is in use *somewhere* is not + necessarily legal *here*. ``IMPACT_ID`` and ``OWNER_ID`` are foreign keys + constrained by the ticket's catalog entry, severity matrix and domain, so the + first sampled id may be refused for a ticket ``ticket_factory`` just created. + The caller tries them in order (see :func:`_write_first_accepted`). + + An empty list means the sampled page carries no second value at all, which + the caller turns into a skip rather than a failure. + """ + page = client.search_tickets(max_rows=200, fields=["RFC_NUMBER", column]) + values: list[int] = [] + for record in page.records: + value = getattr(record, column.lower(), None) + if value is None or value == current or value in values: + continue + values.append(value) + if len(values) >= limit: + break + return values + + +def _write_first_accepted( + client: EasyvistaClient, rfc: str, column: str, candidates: list[int] +) -> int | None: + """PUT each candidate to ``column`` until the read-back confirms one landed. + + ``None`` when every candidate was refused, which the caller turns into a + skip. A refusal here means "that id is not assignable to this ticket", not + "``RequestUpdate`` cannot write this column" -- reporting it as the latter + would be a false red, and this test is about the latter only. + + The read-back after every PUT is load-bearing, not defensive style: this + API's documented refusal mode -- restated in + :func:`test_request_update_writes_impact_owner_and_external_reference`'s + own docstring -- is a **200 with the field silently dropped**, not a raised + error. Treating "no exception raised" as "the instance accepted it" would + make the first sampled id -- accepted or not -- the last one tried, and a + silently declined candidate would surface as "RequestUpdate cannot write + this column" instead of "that value is not valid for this ticket": exactly + the false red this helper exists to prevent. + + Only ``EasyvistaValidationError`` is caught around the PUT itself, which is + the transport's mapping of the two statuses a *refused value* can still + raise as (400 and 590). An auth failure, a 404 or a 5xx still reddens, + because none of those means "this id is illegal here". + """ + attr = column.lower() + for candidate in candidates: + try: + client.update_ticket(rfc, RequestUpdate(**{attr: candidate})) + except EasyvistaValidationError: + continue + if getattr(client.get_ticket(rfc), attr, None) == candidate: + return candidate + return None + + +def test_request_update_writes_impact_owner_and_external_reference( + live_client: EasyvistaClient, ticket_factory +): + """The three columns ``RequestUpdate`` gained on this branch, read back. + + Their unit test pins only the emitted body shape -- the client's own + lowercase key names -- which under this branch's measured rule proves + nothing: a 200 on a PUT is not a receipt, and a field the API cannot honour + is silently dropped while the request succeeds. Without this, EasyVista + renaming ``EXTERNAL_REFERENCE`` or declining ``OWNER_ID`` on this verb would + leave the whole suite green and the public API still advertising all three. + The same reasoning produced ``ActionUpdate``'s live guard in + ``test_live_change_window.py``. + + One field per PUT, deliberately. A combined body that came back 200 with one + field dropped would be exactly the failure this test exists to catch, and a + combined body that raised would not say which field caused it. + + A PUT the instance *refuses* is not that failure. ``IMPACT_ID`` and + ``OWNER_ID`` are foreign keys whose legal values depend on the ticket's + catalog entry and domain, so an id sampled off another ticket may be rejected + for this one; that is a skip, not a red. ``EXTERNAL_REFERENCE`` needs no + instance-side legality and stays an unconditional assertion. + + P2: ``reference`` is a self-authored nonce, so it may appear in a message. + The impact and owner ids are read off the instance and must not. + """ + rfc = ticket_factory() + before = live_client.get_ticket(rfc) + + # EXTERNAL_REFERENCE first and unconditionally: it is free text, so no + # instance-side legality can stand between the PUT and the read-back, and + # asserting it before the two foreign keys keeps this guard from being + # skipped past when one of those cannot be established. + reference = f"EVCLI{uuid.uuid4().hex[:10].upper()}REF" # 18 chars; cap is 50 + live_client.update_ticket(rfc, RequestUpdate(external_reference=reference)) + reference_landed = live_client.get_ticket(rfc).external_reference == reference + assert reference_landed, ( + f"EXTERNAL_REFERENCE is not {reference} after " + "RequestUpdate(external_reference=...) -- the field was accepted with a " + "200 and silently dropped" + ) + + # IMPACT_ID and OWNER_ID are foreign keys, and an id in use on another ticket + # is not necessarily assignable to this one, so a refusal is a skip: several + # candidates are tried, and only if none is accepted does the column go + # unmeasured. Failing there would report "RequestUpdate cannot write + # OWNER_ID" when what happened is "that owner is not valid for this ticket". + new_impact = _write_first_accepted( + live_client, + rfc, + "IMPACT_ID", + _other_live_values(live_client, "IMPACT_ID", before.impact_id), + ) + new_owner = _write_first_accepted( + live_client, + rfc, + "OWNER_ID", + _other_live_values(live_client, "OWNER_ID", before.owner_id), + ) + if new_impact is None or new_owner is None: + # P2: no sampled id is named, only the column names authored here. + pytest.skip( + "no sampled IMPACT_ID / OWNER_ID differing from this ticket's own " + "was accepted for it -- cannot distinguish an honoured write from a " + "dropped one" + ) + + after = live_client.get_ticket(rfc) + impact_landed = after.impact_id == new_impact + owner_landed = after.owner_id == new_owner + assert impact_landed, ( + "IMPACT_ID does not match the id sent by RequestUpdate(impact_id=...)" + ) + assert owner_landed, ( + "OWNER_ID does not match the id sent by RequestUpdate(owner_id=...)" + ) + + def test_update_does_not_disturb_the_identifier( live_client: EasyvistaClient, ticket_factory ): diff --git a/integration_tests/test_live_ticket_metadata.py b/integration_tests/test_live_ticket_metadata.py index dc4fb22..0579afc 100644 --- a/integration_tests/test_live_ticket_metadata.py +++ b/integration_tests/test_live_ticket_metadata.py @@ -99,10 +99,11 @@ def test_description_round_trips_through_the_comment_memo( ): # Phase 0 follow-up, verified live: `RequestUpdate.description` writes the # ticket's COMMENT memo, not DESCRIPTION, and a description supplied at - # CREATE time is not readable back through either. On this deployment - # DESCRIPTION is empty on every ticket sampled (0/15, portal-created - # included) while COMMENT is populated on all of them -- so COMMENT is - # where a ticket's body text lives here. This pins the path that works. + # CREATE time is not readable back through either. This pins the path that + # works. It does NOT pin which memo an instance populates: a later pooled + # 77-row sample across four orderings found COMMENT on 57, DESCRIPTION on + # 27 and both on 24, so the earlier "DESCRIPTION empty on 0/15" reading was + # a sampling artifact and must not be generalized (see RequestUpdate). rfc = ticket_factory() body = f"EVCLI{uuid.uuid4().hex[:10].upper()}BODY" live_client.update_ticket(rfc, RequestUpdate(description=body)) diff --git a/pyproject.toml b/pyproject.toml index dc3f997..426eba7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ exclude = [ [project] name = "easyvista-python-client" -version = "0.1.0" +version = "0.2.0" description = "Typed Python client for the EasyVista Service Manager REST API" readme = "README.md" requires-python = ">=3.10" @@ -94,7 +94,10 @@ dev = [ "sphinx>=7.2,<8.2", "sphinx-rtd-theme>=2.0", "tomli>=2.0; python_version < '3.11'", - "twine>=5.1", + # >=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). + "twine>=7.0", # Exact-pinned, not a range: unasync_build.py --check is a byte-equality # gate between _async/ and the checked-in _sync/, and a different # generator version can regenerate byte-different-but-equally-"correct" @@ -254,8 +257,10 @@ fail_under = 95 # # The other two entries are pre-emptive, not repairs: as of this commit the # source contains no `if TYPE_CHECKING:` block and no `raise -# NotImplementedError`, and adding them left the total at 1272 statements / -# 99.21% exactly. They are here so the first one written does not read as a +# NotImplementedError`, so adding them excluded nothing and changed no number. +# (For reference, the gate reports 1437 statements / 99.37% here; treat that as +# a snapshot, not a canary -- it moves with every added line.) They are here so +# the first one written does not read as a # coverage gap -- a TYPE_CHECKING body exists for mypy and cannot execute, and # an unimplemented-stub line is not a line anyone can test. `if TYPE_CHECKING:` # excludes the guard and its whole body. diff --git a/scripts/tests/test_api_reference_coverage.py b/scripts/tests/test_api_reference_coverage.py new file mode 100644 index 0000000..d06fb46 --- /dev/null +++ b/scripts/tests/test_api_reference_coverage.py @@ -0,0 +1,74 @@ +"""Every public export must appear in ``docs/api_reference.rst``. + +An export missing from the reference is invisible to anyone reading the +published documentation: Sphinx renders only what the file lists, so there is +no warning, no broken link and nothing for ``sphinx -W`` to catch. It looks +exactly like a symbol that was never added. + +This is not hypothetical. ``PostTask`` -- the model for posting a comment, and +the one write model the actions guide recommends over its sibling -- was +exported, exercised in six user-guide snippets and absent from the reference +until 2026-09-02. So were the two exported module constants. + +Offline by construction: imports the package and reads one file. + +What this deliberately does **not** check, so a green run is not over-trusted: + +- **Not that the entry renders.** A stale dotted path fails the Sphinx build + instead, which is the gate that owns that question. +- **Not that the docstring is any good**, or that the directive is the right + one (``autoclass`` versus ``autodata``). +- **Not the reverse direction.** The reference may document a symbol that is + not in ``__all__`` -- module-level helpers reached through their full path, + such as ``references.label_from_record``, are legitimately listed. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import easyvista_python_client as ev + +REPO_ROOT = Path(__file__).resolve().parents[2] +API_REFERENCE = REPO_ROOT / "docs" / "api_reference.rst" + +#: Exports that belong somewhere other than the API reference, with the reason. +_EXEMPT = { + # The version string is release metadata, documented in publishing.rst + # where the four places to bump it are enumerated. An autodata entry here + # would render a bare literal that goes stale on every release. + "__version__", +} + +_DIRECTIVE = re.compile( + r"^\.\.\s+auto(?:class|function|data|exception|method)::\s+([\w.]+)", + re.MULTILINE, +) + + +def _documented_names() -> set[str]: + """Trailing attribute names of every autodoc directive in the reference.""" + text = API_REFERENCE.read_text(encoding="utf-8") + return {path.rsplit(".", 1)[-1] for path in _DIRECTIVE.findall(text)} + + +def test_api_reference_exists() -> None: + assert API_REFERENCE.is_file(), f"{API_REFERENCE} is missing" + + +def test_every_public_export_is_documented() -> None: + """``__all__`` minus the exemptions must be covered by an autodoc entry.""" + documented = _documented_names() + missing = sorted(set(ev.__all__) - documented - _EXEMPT) + assert not missing, ( + f"exported but absent from docs/api_reference.rst: {missing}. Add an " + "autodoc directive for each, or add it to _EXEMPT here with the reason " + "it belongs elsewhere." + ) + + +def test_exemptions_are_still_exported() -> None: + """An exemption for a symbol that no longer exists is dead weight.""" + stale = sorted(name for name in _EXEMPT if name not in set(ev.__all__)) + assert not stale, f"_EXEMPT names symbols that are no longer exported: {stale}" diff --git a/scripts/tests/test_credential_rename_guard.py b/scripts/tests/test_credential_rename_guard.py new file mode 100644 index 0000000..772ad1c --- /dev/null +++ b/scripts/tests/test_credential_rename_guard.py @@ -0,0 +1,250 @@ +"""CI-visible regression guards for the 2026-08-25 account-credential rename. + +``EASYVISTA_TEST_USER`` / ``secrets/easyvista_test_user`` held the EasyVista +*account* -- the instance id forming the ``{account}`` path segment of +``https://host/api/{version}/{account}`` -- and never a login. They are now +``EASYVISTA_TEST_ACCOUNT`` / ``secrets/easyvista_test_account``, and the old name +is **refused** rather than honoured as a fallback: silently accepting it would +preserve exactly the misreading the rename removes. + +**Why this file exists at all.** The equivalent guards for +``integration_tests/conftest.py`` live in +``integration_tests/test_fixture_helpers.py``, which is the right home for them +-- but that directory's collection hook stamps ``pytest.mark.integration`` on +every item **by location**, and CI runs ``pytest -m "not integration"``. Measured: +``pytest integration_tests/test_fixture_helpers.py -m "not integration"`` collects +0 and deselects 32. Those guards therefore only fire on a bare ``pytest``, which +this project's own docs warn against running while ``secrets/`` exists. On top of +that, ``integration_tests/`` is excluded from mypy by *both* gates (pyproject's +``[tool.mypy] exclude`` and the mirrored ``^integration_tests/`` in +``.pre-commit-config.yaml``). Without this module the rename would be enforced by +nothing but ruff's F821. + +``scripts/`` is in ``testpaths``, carries no such marker, and is type-checked, so +the guards here do run in CI. They cover the two validators' own copies of the +tripwire, plus a source-level contract over ``integration_tests/conftest.py`` -- +which is how the un-runnable half gets covered from a runnable place. + +The scripts are loaded by path because ``scripts/`` is not an importable package. +""" + +from __future__ import annotations + +import ast +import importlib.util +import re +import sys +from pathlib import Path +from typing import Any + +import pytest + +_SCRIPTS = Path(__file__).resolve().parents[1] +_REPO = _SCRIPTS.parent +_CONFTEST = _REPO / "integration_tests" / "conftest.py" + +_VALIDATORS = ("validate_docs_examples", "validate_live_content_fidelity") + +# The retired spellings, quoted here rather than imported: a test that reads its +# expectations out of the module under test cannot fail when that module changes. +_LEGACY_ENV = "EASYVISTA_TEST_USER" +_LEGACY_FILE = "easyvista_test_user" +_CURRENT_ENV = "EASYVISTA_TEST_ACCOUNT" +_CURRENT_FILE = "easyvista_test_account" + +_SECRET_VALUE = "50004" + + +def _load(name: str) -> Any: + spec = importlib.util.spec_from_file_location(name, _SCRIPTS / f"{name}.py") + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + # Register before exec so dataclasses in the module can resolve their own + # __module__ under ``from __future__ import annotations``. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(params=_VALIDATORS) +def validator( + request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> Any: + """One validator module, with every credential source isolated. + + ``SECRETS_DIR`` is redirected at an empty tmp dir and the four environment + variables the resolvers read are cleared, so a developer's real ``secrets/`` + cannot decide the outcome of these tests. + """ + module = _load(request.param) + monkeypatch.setattr(module, "SECRETS_DIR", tmp_path) + for name in ( + "EASYVISTA_TEST_URL", + "EASYVISTA_TEST_TOKEN", + _CURRENT_ENV, + _LEGACY_ENV, + ): + monkeypatch.delenv(name, raising=False) + return module + + +# --- the tripwire itself ------------------------------------------------------ + + +def test_the_retired_env_var_aborts_and_names_its_replacement( + validator: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """Honouring the old name is the bug; aborting is the feature.""" + monkeypatch.setenv(_LEGACY_ENV, _SECRET_VALUE) + + with pytest.raises(SystemExit) as info: + validator._reject_legacy_account_name() + + message = str(info.value) + assert _LEGACY_ENV in message + assert _CURRENT_ENV in message + assert "never a login" in message + # Name the variable, never print what is in it. + assert _SECRET_VALUE not in message + + +def test_the_retired_secrets_file_aborts(validator: Any, tmp_path: Path) -> None: + """The likely real case: the env var was never set, the file was left behind.""" + (tmp_path / _LEGACY_FILE).write_text(_SECRET_VALUE, encoding="utf-8") + + with pytest.raises(SystemExit) as info: + validator._reject_legacy_account_name() + + message = str(info.value) + assert f"secrets/{_LEGACY_FILE}" in message + assert f"secrets/{_CURRENT_FILE}" in message + assert _SECRET_VALUE not in message + + +def test_both_retired_sources_are_reported_in_one_abort( + validator: Any, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Naming only one source would send someone round the loop twice.""" + monkeypatch.setenv(_LEGACY_ENV, _SECRET_VALUE) + (tmp_path / _LEGACY_FILE).write_text(_SECRET_VALUE, encoding="utf-8") + + with pytest.raises(SystemExit) as info: + validator._reject_legacy_account_name() + + message = str(info.value) + assert _LEGACY_ENV in message + assert f"secrets/{_LEGACY_FILE}" in message + assert " are still set" in message # plural agreement, not "is" + + +def test_a_clean_environment_is_silent(validator: Any) -> None: + """The tripwire must cost nothing when nothing is stale.""" + assert validator._reject_legacy_account_name() is None + + +def test_a_blank_retired_env_var_does_not_trip_the_wire( + validator: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """An empty string is not configuration -- ``_resolve`` ignores it too. + + Tripping on it would turn an exported-but-empty variable into an abort with + no obvious cause. + """ + monkeypatch.setenv(_LEGACY_ENV, " ") + + assert validator._reject_legacy_account_name() is None + + +def test_the_tripwire_never_fires_at_import_time( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Importing a validator with the retired name set must not abort. + + ``scripts/tests/test_validate_live_content_fidelity.py`` exec-loads + ``validate_live_content_fidelity.py`` at MODULE IMPORT during CI's unit run. + That is safe only because the tripwire is a function the resolvers call, not + module-level code. Hoisting it to import time would make CI's own unit suite + ``SystemExit`` on any machine with the stale variable exported -- this pins + that it stays a ``def``. + """ + monkeypatch.setenv(_LEGACY_ENV, _SECRET_VALUE) + + for name in _VALIDATORS: + _load(name) # must not raise + + +# --- the source contract, covering what CI cannot run ------------------------- + + +@pytest.mark.parametrize( + "path", + [_CONFTEST, *(_SCRIPTS / f"{n}.py" for n in _VALIDATORS)], + ids=["conftest", *_VALIDATORS], +) +def test_the_retired_name_is_never_resolved_as_a_credential(path: Path) -> None: + """No resolver call may still read the retired name. + + The user chose a hard rename over a deprecated fallback, so the old spelling + is allowed to survive only as the tripwire's constants and as prose + explaining the change. Any line that both calls a resolver and mentions it is + a fallback creeping back in. + + This is the only automated check on ``integration_tests/conftest.py``'s half + of the rename that CI actually executes -- see this module's docstring. + """ + offenders = [ + f"{path.name}:{number}: {line.strip()}" + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1) + if ("_resolve(" in line or "_r(" in line) and _LEGACY_FILE in line.lower() + ] + + assert not offenders, "retired credential name is being resolved:\n" + "\n".join( + offenders + ) + + +@pytest.mark.parametrize( + "path", + [_CONFTEST, *(_SCRIPTS / f"{n}.py" for n in _VALIDATORS)], + ids=["conftest", *_VALIDATORS], +) +def test_every_resolver_knows_the_current_name(path: Path) -> None: + """A half-applied rename -- old name gone, new one never added -- must fail. + + Deleting the retired name without wiring up its replacement would leave a + bare-host setup with no way to supply an account at all, and the previous + test would happily pass. + """ + source = path.read_text(encoding="utf-8") + + assert _CURRENT_ENV in source, f"{path.name} never mentions {_CURRENT_ENV}" + assert _CURRENT_FILE in source, f"{path.name} never mentions {_CURRENT_FILE}" + + +def test_every_credential_conftest_reads_is_named_in_its_docstring() -> None: + """The live suite's credential contract must not drift out of its own docs. + + ``integration_tests/conftest.py``'s module docstring is the canonical + statement of which environment variables and ``secrets/`` files the live + suite consults -- it is what a contributor reads, and it is what sent someone + hunting for a login that never existed. It had already drifted: it announced + "Two further per-instance ids" while the module resolved eight, so six secret + files were undocumented. + + Prose cannot be kept honest by review alone, so pin it. Every + ``EASYVISTA_TEST_*`` name the source mentions has to appear in the docstring. + """ + source = _CONFTEST.read_text(encoding="utf-8") + docstring = ast.get_docstring(ast.parse(source)) or "" + + referenced = set(re.findall(r"EASYVISTA_TEST_[A-Z_]+", source)) + # The wildcard the docstring uses for the "no credentials" skip message is a + # glob, not a variable. + referenced.discard("EASYVISTA_TEST_") + + undocumented = sorted(name for name in referenced if name not in docstring) + + assert not undocumented, ( + "conftest.py resolves these but its module docstring never names them: " + + ", ".join(undocumented) + ) diff --git a/scripts/tests/test_skills_contract.py b/scripts/tests/test_skills_contract.py index 4197399..93bf957 100644 --- a/scripts/tests/test_skills_contract.py +++ b/scripts/tests/test_skills_contract.py @@ -46,6 +46,7 @@ import pytest import easyvista_python_client as ev +from easyvista_python_client.models.common import EasyvistaWriteModel REPO_ROOT = Path(__file__).resolve().parents[2] SKILLS_DIR = REPO_ROOT / "skills" @@ -193,6 +194,8 @@ def test_readme_lists_every_skill() -> None: "PostRequest": ev.PostRequest, "RequestUpdate": ev.RequestUpdate, "PostAction": ev.PostAction, + "PostTask": ev.PostTask, + "ActionUpdate": ev.ActionUpdate, "PostAsset": ev.PostAsset, "PostDepartment": ev.PostDepartment, "DepartmentUpdate": ev.DepartmentUpdate, @@ -456,3 +459,29 @@ def test_snippet_hosts_are_synthetic(skill: Path) -> None: f"{skill.name} carries a non-synthetic URL {url!r}; every " "host in a skill must sit under example.com" ) + + +def test_write_models_map_is_complete() -> None: + """Every exported EasyvistaWriteModel subclass maps in _WRITE_MODELS. + + The ``_WRITE_MODELS`` dict pairs write model names to their classes so that + snippet keyword validation can find them. A write model exported from the + package but missing from the dict has its snippets silently skipped, leaving + typos and dropped keywords undetected. This test converts the hand-maintained + enumeration into a self-checking gate that fails when a new write model is + exported without a map entry. + """ + exported = { + name + for name in ev.__all__ + if ( + inspect.isclass(obj := getattr(ev, name, None)) + and obj is not EasyvistaWriteModel + and issubclass(obj, EasyvistaWriteModel) + ) + } + mapped = set(_WRITE_MODELS.keys()) + assert exported == mapped, ( + f"exported write models {sorted(exported)} do not match " + f"_WRITE_MODELS {sorted(mapped)}; missing from map: {sorted(exported - mapped)}" + ) diff --git a/scripts/tests/test_source_citations.py b/scripts/tests/test_source_citations.py new file mode 100644 index 0000000..d2112e8 --- /dev/null +++ b/scripts/tests/test_source_citations.py @@ -0,0 +1,113 @@ +"""No tracked file may cite a gitignored path. + +The sibling guard in ``test_skills_contract.py`` enforces this for +``skills/*/SKILL.md``. It does not read Python sources, which is exactly how +three shipped docstrings came to cite ``docs/API_Info.md`` -- a gitignored, +instance-private handover note -- as though it were the vendor specification. +For every reader who has only the published repository those are dead links. + +``_UNPUBLISHED`` here is deliberately **narrower** than the tuple of the same +name in ``scripts/tests/test_skills_contract.py`` -- see the comment on the +tuple itself for which entries are missing and why. Keep the two in sync only +*where they overlap*: a path added to the sibling because it became gitignored +belongs here too, but the entries this one omits must stay omitted, because +adding them would fail tracked documentation that names those locations as +instructions. The duplication is deliberate: the two modules are imported +independently by pytest and sharing a constant between them would mean relying +on ``scripts/tests`` landing on ``sys.path``. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# A NARROWER list than the sibling guard's, and deliberately so. This one +# forbids citing a gitignored *document as evidence*; it does not forbid +# naming a gitignored *location as an instruction*. +# +# `secrets/`, `.claude/` and `.superpowers/` are therefore absent. Tracked +# documentation must be free to name them: CONTRIBUTING.md tells contributors +# to put credentials under `secrets/`, docs/development.rst documents the +# resolution order, and scripts/tests/test_credential_rename_guard.py asserts +# on `secrets/` filenames as its whole subject. Forbidding those would delete +# correct instructions, which is the opposite of this guard's purpose. +_UNPUBLISHED = ( + "API_Info.md", + "easyvista-field-inventory.md", + "easyvista-test-profile-blocked-operations.md", + "docs/superpowers", + "scripts/probe_", +) + +# The two guard modules quote every needle literally, so they can never be +# their own subjects. generate_field_inventory.py names +# docs/easyvista-field-inventory.md because it WRITES that file -- a generator +# naming its own output path is correct, not a dead citation. +_SELF_EXEMPT = { + "test_source_citations.py", + "test_skills_contract.py", + "generate_field_inventory.py", +} + + +def _tracked_text_files() -> list[Path]: + """Every tracked file whose prose a public reader can open.""" + found: list[Path] = [] + for pattern in ( + "easyvista_python_client/**/*.py", + "docs/*.rst", + "integration_tests/**/*.py", + ): + found.extend(REPO_ROOT.glob(pattern)) + for name in ("README.md", "CONTRIBUTING.md", "CHANGELOG.md"): + path = REPO_ROOT / name + if path.is_file(): + found.append(path) + # docs/*.md is tracked EXCEPT the handful of gitignored handover/generated + # notes already named in _UNPUBLISHED (they exist on a dev machine that + # has generated them, but are absent from the published repository). Skip + # those explicitly rather than relying on their absence -- scanning one + # would fail this guard on the file legitimately containing its own name. + for path in REPO_ROOT.glob("docs/*.md"): + if path.name not in _UNPUBLISHED: + found.append(path) + # scripts/probe_*.py is gitignored as a glob; everything else under + # scripts/ is tracked. + for path in REPO_ROOT.glob("scripts/**/*.py"): + if not path.name.startswith("probe_"): + found.append(path) + return sorted(p for p in found if p.name not in _SELF_EXEMPT) + + +def _ids() -> list[str]: + return [p.relative_to(REPO_ROOT).as_posix() for p in _tracked_text_files()] + + +def test_scan_scope_covers_integration_tests_and_docs_md() -> None: + """Pins the widened scan scope so a narrower future glob is caught. + + ``integration_tests/`` is prose-heavy and cites source files by name (see + ``RequestUpdate``'s own docstring, which points at + ``test_live_ticket_metadata.py``). ``docs/*.md`` must be scanned too -- + otherwise ``docs/vendor-api-reference.md``, the artifact this guard exists + to make citable, would itself be unguarded against reintroducing exactly + the citation it replaces. + """ + found = set(_tracked_text_files()) + assert REPO_ROOT / "integration_tests" / "test_live_ticket_metadata.py" in found + assert REPO_ROOT / "docs" / "vendor-api-reference.md" in found + + +@pytest.mark.parametrize("path", _tracked_text_files(), ids=_ids()) +def test_no_gitignored_citations(path: Path) -> None: + text = path.read_text(encoding="utf-8") + for needle in _UNPUBLISHED: + assert needle not in text, ( + f"{path.relative_to(REPO_ROOT).as_posix()} references {needle!r}, " + "which is gitignored and invisible to anyone reading the published " + "repository. Cite docs/vendor-api-reference.md instead." + ) diff --git a/scripts/validate_docs_examples.py b/scripts/validate_docs_examples.py index cd31016..004da90 100644 --- a/scripts/validate_docs_examples.py +++ b/scripts/validate_docs_examples.py @@ -2,19 +2,29 @@ """Validate the documentation examples against the real library (and, optionally, a live EasyVista test instance). -The examples in ``docs/user_guide.rst`` / ``docs/installation.rst`` are the -contract this script checks. It runs three tiers: +This script does **not** read the documents. It checks a *hand-maintained +transcription* of what ``docs/user_guide.rst`` / ``docs/installation.rst`` +claim -- no ``.rst`` file is ever opened, and nothing mechanically ties a check +here to the passage it stands for. A doc example nobody transcribed cannot fail +here, so a green run is evidence about the transcribed subset only, not about +the documents. Keep that in mind before trusting it: prose examples in +particular (inline literals in a bullet, rather than a ``code-block``) have +gone stale under a green run. It runs three tiers: 1. **offline** (always): every symbol, model field, method signature, attribute - and serialization claim the docs make is exercised through the *public* - package surface (``import easyvista_python_client``). No network. This is the - part that catches a doc that names a field/kwarg/attribute the code does not - have. + and serialization claim *transcribed below* is exercised through the + *public* package surface (``import easyvista_python_client``). No network. + This is the part that catches a transcribed claim naming a + field/kwarg/attribute the code does not have. 2. **live read-only** (if credentials resolve): the read examples (``search_tickets``, ``get_ticket``, ``search_assets``, ``iter_tickets``, ``list_actions``, ``list_documents``) plus the *rejected* create that the docs - use to demonstrate ``EasyvistaValidationError`` (no record is created). + use to demonstrate ``EasyvistaValidationError``. That last one is **not** + read-only: a 590 rejection was measured to leave a row behind anyway (one + instance, 2026-08-25 -- 12 attempts, 3 RFC numbers returned, all 12 + tickets present afterwards), so this tier can leak one ticket per run. See + ``rejected_create`` below. 3. **live writes** (only with ``--writes`` AND credentials): the create / update / close / action / document / asset examples, which create real records on @@ -22,9 +32,15 @@ Credentials resolve exactly like ``integration_tests/conftest.py``: - url <- EASYVISTA_TEST_URL | secrets/easyvista_test_url - user <- EASYVISTA_TEST_USER (or _ACCOUNT) | secrets/easyvista_test_user - token <- EASYVISTA_TEST_TOKEN | secrets/easyvista_test_token + url <- EASYVISTA_TEST_URL | secrets/easyvista_test_url + account <- EASYVISTA_TEST_ACCOUNT | secrets/easyvista_test_account + token <- EASYVISTA_TEST_TOKEN | secrets/easyvista_test_token + +``account`` is **not a login**: it is the instance id forming the ``{account}`` +path segment of ``https://host/api/{version}/{account}`` (e.g. ``50004``). Auth +is the ``token`` alone. It is read only when ``url`` is a bare host -- a full API +root already carries the account. Spelled ``EASYVISTA_TEST_USER`` before +2026-08-25; that name is now refused, not silently accepted. A ``.env`` file at the repo root is loaded first if ``python-dotenv`` is installed. Secret values are never printed. @@ -252,7 +268,7 @@ def post_request_full() -> None: catalog_code="SAMPLE_CATALOG", title="Printer down", description="The 3rd-floor printer is offline", - origin=7, + origin="Phone", department_id=9, urgency_id=8, impact_id=28, @@ -319,6 +335,7 @@ def signatures() -> None: "close_ticket": {"rfc_number", "status_guid", "delete_actions", "comment"}, "create_action": {"rfc_number", "action"}, "list_actions": {"rfc_number"}, + "iter_actions": {"rfc_number", "fields", "page_size", "max_records"}, "create_asset": {"asset"}, "get_asset": {"asset_id"}, "search_tickets": {"search", "fields", "sort", "max_rows", "offset"}, @@ -328,6 +345,7 @@ def signatures() -> None: "add_document": {"rfc_number", "filename", "content"}, "list_documents": {"rfc_number"}, "download_document": {"document"}, + "stream_document": {"document", "chunk_size"}, } problems = [] for method, params in expected.items(): @@ -355,6 +373,7 @@ def async_parity() -> None: "close_ticket", "create_action", "list_actions", + "iter_actions", "create_asset", "get_asset", "search_tickets", @@ -364,6 +383,7 @@ def async_parity() -> None: "add_document", "list_documents", "download_document", + "stream_document", "from_env", } missing = [m for m in public if not hasattr(AsyncEasyvistaClient, m)] @@ -372,6 +392,11 @@ def async_parity() -> None: assert inspect.isasyncgenfunction(AsyncEasyvistaClient.iter_tickets), ( "AsyncEasyvistaClient.iter_tickets is not an async generator" ) + # And so must stream_document: the user guide documents it as something + # you iterate, which on the async surface means `async for`. + assert inspect.isasyncgenfunction(AsyncEasyvistaClient.stream_document), ( + "AsyncEasyvistaClient.stream_document is not an async generator" + ) r.check( "AsyncEasyvistaClient mirrors sync method names [Sync vs async]", @@ -397,6 +422,18 @@ def search_result_fields() -> None: search_result_fields, ) + # 11b. Action exposes the label the docs name as an attribute. + def action_visibility_label() -> None: + from easyvista_python_client import Action + + action = Action.model_validate( + {"ACTION_ID": "1", "ACTION_LABEL_FR": "Analyse de Resolution"} + ) + assert action.action_label_fr == "Analyse de Resolution" + assert action.reference("ACTION_TYPE").id is None # absent -> empty, no raise + + r.check("Action.action_label_fr [Actions]", action_visibility_label) + # 12. Exception hierarchy + attributes carried by EasyvistaError. def exceptions() -> None: exc = EasyvistaError("boom", status_code=590, ev_code="2013", ev_message="bad") @@ -452,6 +489,32 @@ def _resolve(env_names: tuple[str, ...], filename: str) -> str | None: return None +# Retired 2026-08-25: the value is the API-root ``{account}`` path segment, not a +# login. Reading the old spelling as a fallback would re-admit the confusion the +# rename removed, so it is refused. Only names are printed, never values. +_LEGACY_ACCOUNT_ENV = "EASYVISTA_TEST_USER" +_LEGACY_ACCOUNT_FILE = "easyvista_test_user" + + +def _reject_legacy_account_name() -> None: + """Abort when the pre-rename account credential is still configured.""" + stale: list[str] = [] + value = os.environ.get(_LEGACY_ACCOUNT_ENV) + if value and value.strip(): + stale.append("the " + _LEGACY_ACCOUNT_ENV + " environment variable") + if (SECRETS_DIR / _LEGACY_ACCOUNT_FILE).is_file(): + stale.append("secrets/" + _LEGACY_ACCOUNT_FILE) + if stale: + verb = "is" if len(stale) == 1 else "are" + raise SystemExit( + " and ".join(stale) + " " + verb + " still set. That name was retired on " + "2026-08-25 and is no longer read: the value is the EasyVista " + "account -- the instance id in https://host/api/{version}/{account} " + "-- and never a login. Rename it to EASYVISTA_TEST_ACCOUNT / " + "secrets/easyvista_test_account." + ) + + def _resolve_int(env_names: tuple[str, ...], filename: str) -> int | None: value = _resolve(env_names, filename) return int(value) if value is not None else None @@ -461,12 +524,14 @@ def resolve_live_config() -> EasyvistaConfig | None: from easyvista_python_client import EasyvistaConfig url = _resolve(("EASYVISTA_TEST_URL",), "easyvista_test_url") - user = _resolve( - ("EASYVISTA_TEST_USER", "EASYVISTA_TEST_ACCOUNT"), "easyvista_test_user" - ) + account = _resolve(("EASYVISTA_TEST_ACCOUNT",), "easyvista_test_account") token = _resolve(("EASYVISTA_TEST_TOKEN",), "easyvista_test_token") if not url or not token: return None + # Ordered after the no-credentials exit, matching integration_tests/conftest.py: + # a checkout with nothing configured stays gracefully skippable whether or not + # a stray legacy file is lying around. + _reject_legacy_account_name() root = url.rstrip("/") if "/api/" in root: server, _, rest = root.partition("/api/") @@ -477,9 +542,11 @@ def resolve_live_config() -> EasyvistaConfig | None: return EasyvistaConfig( server=server, account=account, token=token, api_version=version ) - if not user: + # A bare host: the account cannot be parsed out of the URL, so it must be + # supplied separately. + if not account: return None - return EasyvistaConfig(server=root, account=user, token=token) + return EasyvistaConfig(server=root, account=account, token=token) # --------------------------------------------------------------------------- # @@ -596,6 +663,24 @@ def list_actions() -> None: else: r.skip("list_actions()", "no tickets on instance") + def iter_actions() -> None: + if not sample_rfc: + raise AssertionError("no ticket available") + # Bounded: the offset contract is unverified on this endpoint, + # so a sweep that never advances is possible. + seen = list(client.iter_actions(sample_rfc, max_records=30)) + assert all(isinstance(a, Action) for a in seen) + ids = [a.action_id for a in seen if a.action_id is not None] + assert len(ids) == len(set(ids)), ( + "iter_actions repeated an action id: the endpoint ignored " + "offset, so paging loops instead of advancing" + ) + + if sample_rfc: + r.check(f"iter_actions('{sample_rfc}', max_records=30)", iter_actions) + else: + r.skip("iter_actions()", "no tickets on instance") + def list_documents() -> None: if not sample_rfc: raise AssertionError("no ticket available") @@ -645,11 +730,22 @@ def reporting() -> None: ) def rejected_create() -> None: - # The error-handling example: missing mandatory title is rejected - # server-side (HTTP 590) -- no ticket is created, so this is safe. - # catalog_code must be *valid* on this instance so the missing title - # is the payload's only defect -- an unknown catalog would also 590, - # and the assertion below couldn't tell the two failures apart. + # The error-handling example: an under-specified body (a valid + # catalog, but none of the ids the catalog needs) is rejected + # server-side with HTTP 590. `title` is NOT the mandatory field -- + # the full documented body with no title creates fine -- so do not + # describe this as a missing-title test. + # + # NOT read-only. A 590 rejection was measured to write the row + # anyway (one instance, 2026-08-25: 12 attempts, 3 RFC_NUMBERs + # returned, all 12 tickets present afterwards), so each run of this + # check can leave an orphan on the target instance. It is left in + # place because the docs demonstrate exactly this call, but it is + # the one live-read-only check that writes. + # + # catalog_code must be *valid* on this instance so the missing ids + # are the payload's only defect -- an unknown catalog would also + # 590, and the assertion below couldn't tell the two failures apart. try: client.create_ticket(PostRequest(catalog_code=catalog_code)) except EasyvistaValidationError as exc: @@ -659,13 +755,13 @@ def rejected_create() -> None: if catalog_code: r.check( - "create_ticket(missing title) -> EasyvistaValidationError(590)" + "create_ticket(under-specified) -> EasyvistaValidationError(590)" " [Error handling]", rejected_create, ) else: r.skip( - "create_ticket(missing title) -> EasyvistaValidationError(590)" + "create_ticket(under-specified) -> EasyvistaValidationError(590)" " [Error handling]", "no EASYVISTA_TEST_CATALOG_CODE" " (or secrets/easyvista_test_catalog_code)", @@ -823,7 +919,7 @@ def add_document() -> None: else: r.skip("get_ticket/update/action/document", "no ticket was created") - # create_asset [Assets] -- catalog_id not in API_Info.md; profile may 403. + # create_asset [Assets] -- catalog_id not vendor-documented; profile may 403. def create_asset() -> None: a = client.create_asset( PostAsset(catalog_id=asset_catalog_id, asset_tag="DOCSVAL001") @@ -922,9 +1018,11 @@ def main() -> int: config = resolve_live_config() if config is None: print("\n== Live tiers SKIPPED ==") - print(" No credentials. Set EASYVISTA_TEST_URL + EASYVISTA_TEST_TOKEN") - print(" (and EASYVISTA_TEST_USER if the URL has no /api/ segment), or add") - print(" secrets/easyvista_test_url and secrets/easyvista_test_token.") + print(" No credentials. Set EASYVISTA_TEST_URL and") + print(" EASYVISTA_TEST_TOKEN, or add secrets/easyvista_test_url and") + print(" secrets/easyvista_test_token. EASYVISTA_TEST_ACCOUNT (or") + print(" secrets/easyvista_test_account) is needed only when the URL") + print(" has no /api/ segment -- it is the instance id, not a login.") r.skip("live read-only tier", "no credentials") if args.writes: r.skip("live writes tier", "no credentials") diff --git a/scripts/validate_live_content_fidelity.py b/scripts/validate_live_content_fidelity.py index a1df3c9..e88fa88 100644 --- a/scripts/validate_live_content_fidelity.py +++ b/scripts/validate_live_content_fidelity.py @@ -43,9 +43,15 @@ Credentials resolve like ``integration_tests/conftest.py`` but **test-only**:: - url <- EASYVISTA_TEST_URL | secrets/easyvista_test_url - user <- EASYVISTA_TEST_USER | secrets/easyvista_test_user (account fallback) - token <- EASYVISTA_TEST_TOKEN | secrets/easyvista_test_token + url <- EASYVISTA_TEST_URL | secrets/easyvista_test_url + account <- EASYVISTA_TEST_ACCOUNT | secrets/easyvista_test_account + token <- EASYVISTA_TEST_TOKEN | secrets/easyvista_test_token + +``account`` is **not a login**: it is the instance id forming the ``{account}`` +path segment of ``https://host/api/{version}/{account}`` (e.g. ``50004``). Auth +is the ``token`` alone. It is read only when ``url`` is a bare host -- a full API +root already carries the account. Spelled ``EASYVISTA_TEST_USER`` before +2026-08-25; that name is now refused, not silently accepted. A repo-root ``.env`` is loaded first if ``python-dotenv`` is installed. Secret values are never printed. @@ -214,6 +220,32 @@ def _resolve_int(env_names: tuple[str, ...], filename: str) -> int | None: return int(value) if value is not None else None +# Retired 2026-08-25: the value is the API-root ``{account}`` path segment, not a +# login. Reading the old spelling as a fallback would re-admit the confusion the +# rename removed, so it is refused. Only names are printed, never values. +_LEGACY_ACCOUNT_ENV = "EASYVISTA_TEST_USER" +_LEGACY_ACCOUNT_FILE = "easyvista_test_user" + + +def _reject_legacy_account_name() -> None: + """Abort when the pre-rename account credential is still configured.""" + stale: list[str] = [] + value = os.environ.get(_LEGACY_ACCOUNT_ENV) + if value and value.strip(): + stale.append("the " + _LEGACY_ACCOUNT_ENV + " environment variable") + if (SECRETS_DIR / _LEGACY_ACCOUNT_FILE).is_file(): + stale.append("secrets/" + _LEGACY_ACCOUNT_FILE) + if stale: + verb = "is" if len(stale) == 1 else "are" + raise SystemExit( + " and ".join(stale) + " " + verb + " still set. That name was retired on " + "2026-08-25 and is no longer read: the value is the EasyVista " + "account -- the instance id in https://host/api/{version}/{account} " + "-- and never a login. Rename it to EASYVISTA_TEST_ACCOUNT / " + "secrets/easyvista_test_account." + ) + + def _host(url: str) -> str: # urlsplit().hostname strips any userinfo/port and lowercases; fall back to # the first path segment for a bare host with no netloc. @@ -230,12 +262,14 @@ def resolve_test_config() -> EasyvistaConfig | None: from easyvista_python_client import EasyvistaConfig url = _resolve(("EASYVISTA_TEST_URL",), "easyvista_test_url") - user = _resolve( - ("EASYVISTA_TEST_USER", "EASYVISTA_TEST_ACCOUNT"), "easyvista_test_user" - ) + account = _resolve(("EASYVISTA_TEST_ACCOUNT",), "easyvista_test_account") token = _resolve(("EASYVISTA_TEST_TOKEN",), "easyvista_test_token") if not url or not token: return None + # Ordered after the no-credentials exit, matching integration_tests/conftest.py: + # a checkout with nothing configured stays gracefully skippable whether or not + # a stray legacy file is lying around. + _reject_legacy_account_name() root = url.rstrip("/") @@ -258,9 +292,11 @@ def resolve_test_config() -> EasyvistaConfig | None: return EasyvistaConfig( server=server, account=account, token=token, api_version=version ) - if not user: + # A bare host: the account cannot be parsed out of the URL, so it must be + # supplied separately. + if not account: return None - return EasyvistaConfig(server=root, account=user, token=token) + return EasyvistaConfig(server=root, account=account, token=token) # --------------------------------------------------------------------------- # @@ -824,6 +860,9 @@ def main() -> int: if config is None: print("No TEST credentials. Set EASYVISTA_TEST_URL + EASYVISTA_TEST_TOKEN") print("or add secrets/easyvista_test_url and secrets/easyvista_test_token.") + print("EASYVISTA_TEST_ACCOUNT (or secrets/easyvista_test_account) is") + print("needed only when the URL has no /api/ segment: it is the instance") + print("id in https://host/api/{version}/{account}, not a login.") return 2 # These are per-instance and must not be hardcoded (see diff --git a/skills/README.md b/skills/README.md index 44aebad..7927f88 100644 --- a/skills/README.md +++ b/skills/README.md @@ -14,13 +14,14 @@ installed wheel, which carries only the `easyvista_python_client` package. | Skill | Use when the agent needs to | Main public API | | --- | --- | --- | | `easyvista-client-setup` | Build and configure an authenticated client | `EasyvistaConfig`, `EasyvistaClient`, `AsyncEasyvistaClient` | -| `easyvista-search-syntax` | Write or debug any `search=` expression | `ev_equals_filter`, `ev_in_filter`, `escape_ev_value`, `is_safe_ev_value` | +| `easyvista-search-syntax` | Write or debug any `search=` expression, including a date/time window | `ev_equals_filter`, `ev_in_filter`, `ev_contains_filter`, `ev_since_filter`, and 4 more filter builders | | `easyvista-ticket-workflow` | Create, read, search, update or close tickets, or read instance-specific columns off any record | `PostRequest`, `Request`, `RequestUpdate`, `SearchResult`, `Reference`, `FieldClassification` | -| `easyvista-ticket-actions` | Read or write a ticket's action log | `PostAction`, `Action`, `resolve_memo` | -| `easyvista-document-workflow` | Attach, list or download ticket files | `Document`, `add_document`, `download_document` | +| `easyvista-ticket-actions` | Post a comment (task), or read and write a ticket's action log | `PostTask`, `create_task`, `PostAction`, `Action`, `ActionUpdate`, `resolve_memo` | +| `easyvista-document-workflow` | Attach, list, download, stream or delete ticket files | `Document`, `add_document`, `download_document`, `stream_document`, `delete_document` | | `easyvista-asset-workflow` | Create, fetch, search or iterate assets | `PostAsset`, `Asset` | | `easyvista-directory` | Resolve or provision departments and employees | `Department`, `Employee`, `find_departments` | | `easyvista-reporting-and-context` | Count and break down tickets, or build one context bundle | `TicketStatistics`, `aggregate_tickets`, `TicketContext`, `DepartmentContext` | +| `easyvista-instance-discovery` | Find out what ids, routes and reference tables *this* deployment actually has, before hardcoding one | `describe_instance`, `discover`, `list_reference_table`, `get_api_spec`, `InstanceProfile`, `DiscoveredReference` | ## Sync and async @@ -30,7 +31,8 @@ The package ships two clients with one endpoint surface: `await`, `for` over the iterators. - `AsyncEasyvistaClient` — asynchronous, doing real non-blocking I/O. `async with AsyncEasyvistaClient(config) as client`, `await` every method, - `async for` over the iterators. + `async for` over the iterators and over `stream_document`, which is an async + generator rather than a coroutine. Neither wraps the other. `_async/` is hand-written and `_sync/` is generated from it by `unasync_build.py` under a byte-equality CI gate, so the two surfaces diff --git a/skills/easyvista-asset-workflow/SKILL.md b/skills/easyvista-asset-workflow/SKILL.md index e390baa..d6ae8cd 100644 --- a/skills/easyvista-asset-workflow/SKILL.md +++ b/skills/easyvista-asset-workflow/SKILL.md @@ -5,7 +5,7 @@ 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." metadata: package: easyvista-python-client - version: "0.1.0" + version: "0.2.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, @@ -108,13 +108,50 @@ with EasyvistaClient.from_env() as client: print(asset.asset_id, asset.asset_tag) ``` +```python +from easyvista_python_client import EasyvistaClient, ev_contains_filter + +with EasyvistaClient.from_env() as client: + # On the instance this package was characterized against, a bare '~' is + # exact match, just like ':' -- ev_contains_filter appends the explicit + # wildcard a partial-tag search needs there: ASSET_TAG~"*LAPTOP*". The + # vendor documents '~' as plain Contains; pass wildcard=None on such a + # deployment. The value must carry no '_' or '[' -- "LAPTOP_01" raises. + found = client.search_assets(search=ev_contains_filter("ASSET_TAG", "LAPTOP")) + print(found.total_record_count) +``` + ## Gotchas - `catalog_id` is required by EasyVista and is an `int`; `get_asset` takes a `str` id. The asymmetry is real. -- `ASSET_TAG` filters as **exact match** with `~` as well as `:` — there is - no substring search for a partial tag - (`integration_tests/test_live_search_syntax.py::test_tilde_on_asset_tag_is_exact_match`). +- `ASSET_TAG~"LAPTOP"` (a bare value, no wildcard) is **exact match** on the + instance this package was characterized against, identical to + `ASSET_TAG:"LAPTOP"` — `~` degenerates to equality without an explicit + wildcard there (verified live + `integration_tests/test_live_search_syntax.py::test_tilde_without_a_wildcard_is_exact_on_asset_tag`). + The vendor documents `~` as plain **Contains** and names no wildcard, so a + conformant deployment behaves the other way. For a partial-tag search use + `ev_contains_filter` / `ev_starts_with_filter`, which append `*` by default: + `ev_contains_filter("ASSET_TAG", "LAPTOP")` builds `ASSET_TAG~"*LAPTOP*"`. + On a deployment that compares `*` literally that default returns **zero rows + with HTTP 200 and no hint** — pass `wildcard=None` there, or `wildcard="%"` + for a LIKE-style backend. See `easyvista-search-syntax` for the full grammar + and for how to tell which reading your deployment follows. +- **An asset tag containing `_` or `[` cannot go through the pattern builders, + at any `wildcard=` setting.** `*` and `%` are not the only metacharacters + under `~`, and these two belong to the operator rather than to the wildcard + the builders append: `_` matches any single character and `[` opens a + character class (measured live 2026-08-18 with a *wildcard-free* pattern), + and there is no escape — a backslash is compared literally. So + `ev_contains_filter` / `ev_starts_with_filter` raise `ValueError` for `_` or + `[` in the value even with `wildcard=None`, and additionally for `*` or `%` + while a wildcard is being appended. `_` is pervasive in asset tags: + `ev_contains_filter("ASSET_TAG", "LAPTOP_01")` raises rather than also + matching `LAPTOP-01` and `LAPTOP001` with HTTP 200 and no hint. For an + **exact** match on such a tag use `ev_equals_filter("ASSET_TAG", + "LAPTOP_01")` — `:` does not expand a wildcard. To pattern-match around one, + filter server-side on a wider condition and compare exactly in Python. - The `Asset` model declares only `asset_id`, `asset_tag`, `serial_number`, `status_id` and `href`; everything else the instance returns is preserved by `extra="allow"` and reachable through `classify_fields()`. `reference()` diff --git a/skills/easyvista-client-setup/SKILL.md b/skills/easyvista-client-setup/SKILL.md index 37ed8d1..b367bb6 100644 --- a/skills/easyvista-client-setup/SKILL.md +++ b/skills/easyvista-client-setup/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and valid EasyVista credentials." metadata: package: easyvista-python-client - version: "0.1.0" + version: "0.2.0" --- `easyvista_python_client` ships both clients over one surface: @@ -14,12 +14,17 @@ and does real non-blocking I/O. Same method names, same arguments, same results. Pick the one matching the runtime — synchronous script or async event loop — and keep it consistent within one application. +Two exceptions to "returns coroutines": the `iter_*` methods and +`stream_document` are **async generators** on the async client. Iterate them +with `async for`; `await client.stream_document(...)` raises `TypeError`. + ## Procedure 1. Pick the client class: `EasyvistaClient` for synchronous code, `AsyncEasyvistaClient` inside an event loop. 2. Build an `EasyvistaConfig`: `server` (the instance root, no `/api` - segment) and `account` are always required. + segment) and `account` are always required. `account` is the **instance + identifier**, not a login — see the gotcha below before guessing it. 3. Supply exactly one credential: `token=` for Bearer, or `login=` and `password=` for HTTP Basic. Neither present raises `ValueError` at construction. @@ -41,20 +46,68 @@ Every `EasyvistaConfig` field, and its default: | Field | Default | Notes | | --- | --- | --- | | `server` | required | Instance root, no `/api` segment | -| `account` | required | | +| `account` | required | Instance id forming the last path segment of `api_root`, e.g. `"12345"`. **Not a login** — see Gotchas | | `token` | `None` | Bearer credential | | `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 | -| `verify_ssl` | `True` | TLS certificate verification | +| `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` | +| `document_delete_path_style` | `"nested"` | Which delete route `delete_document` sends: `"nested"` (`requests/{rfc}/documents/{id}`) or `"top_level"` (`documents/{id}`). Both routes exist on this API; which one a profile grants varies. Overridable per call. | +| `datetime_input_formats` | `()` | Extra `strptime` patterns for timestamp columns, tried only after EasyVista's own ISO-8601 form fails. Nothing is guessed — an unlisted format still raises. | +| `extra_headers` | `{}` | Merged over every header sent to the instance; an `Authorization` key raises at construction | +| `user_agent` | `None` | `None` sends `DEFAULT_USER_AGENT`; pass a string to replace it | +| `default_params` | `{}` | Query parameters on every JSON API request, **under** any the call sets; not applied to downloads | +| `additional_download_hosts` | `frozenset()` | https hosts `download_document` / `stream_document` may fetch from, **without** the credential | `config.api_root` and `config.uses_basic_auth` are read-only properties derived from the fields above, not settable inputs. The dataclass itself is frozen — no field can be reassigned after construction. +## Adapting to a deployment that is not the default + +The last four fields exist so a deployment differing from the common case +needs no fork. Every default is the value that works without them. + +```python +import ssl + +from easyvista_python_client import DEFAULT_USER_AGENT, EasyvistaClient, EasyvistaConfig + +config = EasyvistaConfig( + server="https://ev.example.com", + account="12345", + token="YOUR_TOKEN", + # An API gateway in front of the instance needs its own key, and a WAF + # asked to whitelist this integration needs something to whitelist. + extra_headers={"Ocp-Apim-Subscription-Key": "YOUR_GATEWAY_KEY"}, + user_agent=f"{DEFAULT_USER_AGENT} my-app/1.4", + # A corporate private CA — disabling verification is not the only answer. + verify_ssl=ssl.create_default_context(cafile="/etc/ssl/corp-root.pem"), +) +``` + +## 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 +and the same error mapping, returning the decoded JSON unchanged. + +```python +with EasyvistaClient(config) as client: + # A reference table this package has no model for. + statuses = client.send("GET", "status", params={"max_rows": 200}) + + # Per-call query parameters work on the wrapped methods too. + ticket = client.get_ticket("YOUR_RFC_NUMBER", params={"formatDate": "iso"}) +``` + +`path` always joins to `api_root`, so an absolute URL is never followed — that +is what keeps the credential scoped to the configured instance. To fetch a URL +the API handed back, use `download_document` / `stream_document`. + ## Environment defaults `EasyvistaConfig.from_env()` and `EasyvistaClient.from_env()` read: @@ -69,6 +122,25 @@ A missing server or account raises `ValueError`. `from_env` takes **no keyword overrides** — build an `EasyvistaConfig` directly when you need to override one value. +It reads the connection settings only, and no tuning field: not `timeout`, not +`max_retries`, not `default_max_rows`, not `document_delete_path_style`, not +`datetime_input_formats`. That is deliberate — these are code decisions, not +deployment secrets, and the package is installed from PyPI rather than +configured by its environment. To keep `from_env`'s credential resolution and +change one of them: + +```python +import dataclasses + +from easyvista_python_client import EasyvistaClient, EasyvistaConfig + +config = dataclasses.replace( + EasyvistaConfig.from_env(), document_delete_path_style="top_level" +) +with EasyvistaClient(config) as client: + ... +``` + ## Examples ```python @@ -174,6 +246,14 @@ derive from `EasyvistaError`. - `server` is the instance root. Do **not** append `/api/v1/`; the client composes `config.api_root` from `server`, `api_version` and `account`. +- `account` is **not a user account**, despite sitting beside `login` and + `password` in the same config. It is the EasyVista *instance* identifier — a + number such as `"12345"` — that forms the last path segment of + `https://host/api/{version}/{account}`. Nothing authenticates with it; that is + `token`, or `login` + `password`, and those are unrelated values. If the + instance URL you were handed already reads + `https://my.easyvista.com/api/v1/12345`, then `server` is + `https://my.easyvista.com` and `account` is `12345`. - `EasyvistaConfig` is frozen. To change a setting, build a new config. - Constructing a config with neither `token` nor a complete `login`/`password` pair raises `ValueError` immediately — a credential problem surfaces before diff --git a/skills/easyvista-directory/SKILL.md b/skills/easyvista-directory/SKILL.md index 4d93806..ad14ac8 100644 --- a/skills/easyvista-directory/SKILL.md +++ b/skills/easyvista-directory/SKILL.md @@ -5,7 +5,7 @@ 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)." metadata: package: easyvista-python-client - version: "0.1.0" + version: "0.2.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, @@ -24,14 +24,22 @@ skill for the rules; they are not repeated here. ## Resolving a department by name -The main reason this skill exists. `find_departments(name, limit=None)` does -the right thing in one call: +The main reason this skill exists. `find_departments(name, limit=None, +by="auto")` does the right thing in one call: -- **Fast path:** an all-digit `name` matches `DEPARTMENT_ID` exactly; - otherwise `DEPARTMENT_CODE` exactly. A hit returns immediately. +- **Fast path:** `by="auto"` matches `DEPARTMENT_CODE` exactly; for an + all-digit name it tries `DEPARTMENT_CODE` first and then `DEPARTMENT_ID`. + Code first is deliberate — a department whose code is all digits used to be + looked up as an id, returning a **different department with no error**. The + fallback costs one extra request only when the digits are an id and not a + code. Pin one column with `by="DEPARTMENT_ID"`, give your own order with a + list, or pass `by=[]` to skip the fast path. - **Fuzzy fallback:** scans every department client-side and matches `name` as a substring of any string field, normalized so - `"Acme Corp" == "ACME-CORP" == "acmecorp"`. + `"Acme Corp" == "ACME-CORP" == "acmecorp"` and accent-folded, so an + unaccented search term matches an accented label. That matters on any + instance whose department names are not plain ASCII — before it, a French + label was unreachable unless you typed the accents. - A name that cannot be expressed in the search grammar (it contains a `"`) skips the server fast path entirely and goes straight to the local scan, so it returns correct results rather than raising. @@ -92,7 +100,7 @@ from easyvista_python_client import EasyvistaClient with EasyvistaClient.from_env() as client: note = client.get_department_comment(42) if note is None: - print("no note, or the profile cannot read it") + print("no note") # a 403/404 raises instead of returning None -- see Gotchas else: print(repr(note)) # "" means the memo exists and is empty ``` @@ -136,7 +144,21 @@ with EasyvistaClient.from_env() as client: - `get_department_comment` returns `""` for an empty memo and `None` only when the memo is absent — but it propagates transport errors, so a 403/404 raises rather than returning `None`. That distinction is - deliberate. + deliberate. Inside `get_department_context` the same read is wrapped and + degrades to `None` instead; the bundle never fails on one branch — and + records the swallow in `ctx.degraded` so you can tell "no note" from "the + note was forbidden". +- **The department memo route selects a *column*, not a fixed path.** In the + instance's own OpenAPI document the last segment of + `GET departments/{id}/{comment}` is a path *parameter*, and the sibling + `GET requests/{rfc_number}/{comment}` describes it as "Memo field type, + could be comment, description". The default `"comment_department"` is only + the column the verified instance carries, so a deployment naming its + department memo differently passes its own: + `get_department_comment(id, memo_field="comment_service")`, or + `get_department_context(id, memo_fields=("comment_service",))` — a sequence + there, mirroring `get_ticket_context`, with every resolved memo landing in + `ctx.memos` and `ctx.note` being the first with text. - `find_departments`' fuzzy fallback scans **every** department. On a large instance it pages the whole table; pass `limit=` and prefer an exact code when you have one. diff --git a/skills/easyvista-document-workflow/SKILL.md b/skills/easyvista-document-workflow/SKILL.md index 023bbb1..72f90c0 100644 --- a/skills/easyvista-document-workflow/SKILL.md +++ b/skills/easyvista-document-workflow/SKILL.md @@ -1,22 +1,25 @@ --- name: easyvista-document-workflow -description: "Attach, list and download files on an EasyVista ticket with easyvista_python_client — add_document, list_documents and download_document with the Document model. Use for ticket attachments, uploading evidence or logs to a request, or fetching an attachment's bytes." +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." metadata: package: easyvista-python-client - version: "0.1.0" + version: "0.2.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, > use `async with`, `await` every call, and `async for` over the `iter_*` -> methods — the method names and arguments are identical. See +> methods and `stream_document` — the method names and arguments are identical. +> `stream_document` is an async generator, so `await client.stream_document(...)` +> is a `TypeError`; iterate it. See > `easyvista-client-setup`. -Documents are attachments on a ticket. Three methods: `add_document(rfc, -filename=, content=)`, `list_documents(rfc)`, `download_document(document)`. -All are ticket-scoped — there is no standalone document resource on this -client. +Documents are attachments on a ticket. Five methods: `add_document(rfc, +filename=, content=)`, `list_documents(rfc)`, `download_document(document)`, +`stream_document(document, chunk_size=)` and `delete_document(rfc, +document_id, path_style=)`. Add, list and download are ticket-scoped; delete +can address the document by its id alone — see the gotcha below. ## Procedure @@ -25,7 +28,14 @@ client. 2. List with `list_documents(rfc)` → `list[Document]`. 3. Download with `download_document(document)` → `bytes`. Pass the `Document` from the list, or a raw href/path. -4. Write the bytes yourself; the client does not touch the filesystem. +4. For a large attachment, iterate `stream_document(document)` instead → byte + chunks (64 KiB by default, `chunk_size=` to change it). Same accepted + inputs and same URL resolution as `download_document`; the file never has + to exist in memory whole. +5. Write the bytes yourself; the client does not touch the filesystem. +6. Remove an attachment with `delete_document(rfc, document.document_id)`, or + pass the `Document` itself and let the client read the id off it. It + returns nothing (the API answers with an empty body) — re-list to confirm. ## The Document model @@ -36,6 +46,7 @@ observed shape), `name`, `document`, `document_id`, `download_href` (the API's `download_document` resolves the URL as `download_href or href` — it prefers `DDL_HREF` and **falls back to `HREF`**. Either field on its own is enough, so a record with an empty `download_href` may still be perfectly downloadable. +`stream_document` resolves it exactly the same way. ## Examples @@ -76,21 +87,104 @@ with EasyvistaClient.from_env() as client: Path(document.filename or "attachment.bin").write_bytes(payload) ``` +```python +from pathlib import Path + +from easyvista_python_client import EasyvistaClient + +# Streaming: the bytes go straight to disk, so a 32 MB attachment never sits +# in memory whole. Only the download streams -- see the Gotchas on upload. +with EasyvistaClient.from_env() as client: + for document in client.list_documents("YOUR_RFC_NUMBER"): + target = Path(document.filename or "attachment.bin") + with target.open("wb") as sink: + for chunk in client.stream_document(document, chunk_size=1024 * 1024): + sink.write(chunk) +``` + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + rfc = "YOUR_RFC_NUMBER" + documents = client.list_documents(rfc) + # DELETE requests/{rfc}/documents/{document_id} -- the default route. + # Returns nothing on success. The Document itself is accepted too. + client.delete_document(rfc, documents[0]) +``` + +On a deployment that grants the other route instead, the document is addressed +by its id alone and the RFC is unused: + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + client.delete_document(None, "YOUR_DOCUMENT_ID", path_style="top_level") +``` + ## Gotchas - `content` must be `bytes`. Read files in binary mode. +- **Upload cannot stream, and that is the API's constraint, not a gap here.** + EasyVista takes an attachment as base64 inside a JSON body, so + `add_document` has to materialise the whole payload before it can send + anything. Only the download direction has a chunked form. +- **`stream_document` does not retry a mid-stream failure.** Opening the + download is retried under the usual policy, but from the first chunk onwards + the request is committed and a transport failure raises + `EasyvistaConnectionError` instead of starting over — restarting would hand + you bytes you already have. Nothing resumes a partly consumed stream, so + either discard what you collected and stream again, or use + `download_document`, which retries the whole fetch, when the file is small + enough to buffer. +- `stream_document` is a generator: nothing is requested until you start + iterating, so a `ValueError` for a missing URL or an `EasyvistaError` for a + foreign one surfaces on the first step, not at the call. A non-positive + `chunk_size` raises `ValueError` there too, rather than escaping as an httpx + internal error. +- **Stopping early on the async client needs an explicit close.** After + `break`ing out of an `async for`, the response stays checked out of the + connection pool until the event loop's async-generator finalizer runs — a + garbage-collection cycle later. Use + `contextlib.aclosing(client.stream_document(doc))` or call `aclose()`, or a + prefix-reading fan-out under a bounded `max_connections` will stall on + connections it looks like it released. The sync client releases at the `break`. +- **Streamed bytes are not proof of instance origin.** An absolute URL in a + response *body* is refused when it names another host, but an HTTP *redirect* + off the instance is followed (signed-location hops need it); the credential is + dropped on the foreign request, and the foreign bytes are returned as the + attachment's content. +- It is `stream_document`, not `iter_document`: every `iter_*` method on this + client iterates *records*, and this one iterates the bytes of one document. - `download_document` raises `ValueError` only when **neither** `DDL_HREF` nor `HREF` is set. Guard on both (`download_href is None and href is None`), or catch the `ValueError`. Skipping a record because `download_href` alone is unset silently drops attachments the client would have fetched through `href`. - A download URL pointing outside the configured instance raises - `EasyvistaError`. Downloads follow redirects (signed URLs are common), and - httpx strips the `Authorization` header on a cross-origin redirect, so a - foreign host would receive the request unauthenticated — refusing is - deliberate. -- A 403 on an attachment still surfaces as `EasyvistaAuthError`; the binary - path reuses the same error mapping and retry policy as the JSON one. + `EasyvistaError`, on the streaming path as well as the buffered one. + Downloads follow redirects (signed URLs are common), and httpx strips the + `Authorization` header on a cross-origin redirect, so a foreign host would + receive the request unauthenticated — refusing is deliberate. +- A 403 on an attachment still surfaces as `EasyvistaAuthError`; both binary + paths reuse the same error mapping and the same retry policy as the JSON one. - `filename` is derived, not always sent by the API. Fall back to a literal name before writing to disk. -- There is no delete-document method on this client. +- `delete_document(rfc, document_id)` sends the **nested** `DELETE + requests/{rfc}/documents/{document_id}` by default. A second route, + `DELETE documents/{id}`, also exists on this API; the verified instance + denied it with 403, which is why `"nested"` is the default. Pass + `path_style="top_level"` (or set + `EasyvistaConfig(document_delete_path_style="top_level")`) on a deployment + that grants the other one — there `rfc_number` is unused, so pass `None`. + `document_id` must be non-blank in either style: a blank one would address + the collection rather than one item, which `delete_document` refuses with + `ValueError` before sending anything. You may pass the `Document` itself + instead of its id. +- **A 403 on this API does not mean "denied".** It is also what an unknown + path answers, so a 403 alone never distinguishes a route a profile blocks + from one that does not exist. Both delete routes are declared in the + instance's own OpenAPI document, so the 403 measured against the top-level + one was a real denial — but that had to be established from the spec, not + from the status code. diff --git a/skills/easyvista-instance-discovery/SKILL.md b/skills/easyvista-instance-discovery/SKILL.md new file mode 100644 index 0000000..926111f --- /dev/null +++ b/skills/easyvista-instance-discovery/SKILL.md @@ -0,0 +1,127 @@ +--- +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." +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." +metadata: + package: easyvista-python-client + version: "0.2.0" +--- + +> **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, +> use `async with`, `await` every call, and `async for` over the `iter_*` +> methods — the method names and arguments are identical. See +> `easyvista-client-setup`. + +**Every id in EasyVista is per-deployment configuration, not an API constant.** +`8` is *Clôturé* and `12` is *En cours* on the instance this package was +characterized against — adjacent numbers, opposite meanings. Resolve at +start-up and fail loudly; never freeze one into code. + +## The four methods + +- `get_api_spec(path="swagger")` → the instance's own OpenAPI document. + `paths` is **tier 2** — authoritative for *this* deployment. + `components.schemas` is **tier 3** — example-derived, illustrative only. +- `list_reference_table(path, search=, fields=, sort=, max_rows=, offset=, + params=)` → `SearchResult[GenericRecord]` over any list route. +- `discover(name, strategy="auto", reference_path=, sample_size=, search=, + reference_search=, max_rows=, with_guid=)` → `list[DiscoveredReference]` + for one reference name. +- `describe_instance(names=, strategy=, reference_paths=, sample_size=, + action_sample_tickets=, search=, max_rows=, include_spec=)` → + `InstanceProfile`, which never raises for one part. + +## Procedure + +1. Run `describe_instance()` once at start-up. Read `.unavailable` **first** — + a total outage looks exactly like a bare instance except that every gap is + 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`. +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. + +## Examples + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + profile = client.describe_instance() + print(profile.version, len(profile.spec_paths)) + for gap, reason in profile.unavailable.items(): + print("gap:", gap, reason) + + for status in client.discover("STATUS"): + # .guid is what set_status and close_ticket address a status by. + print(status.id, status.label, status.guid) + + for catalog in client.discover("CATALOG_REQUEST"): + # .code is what PostRequest(catalog_code=...) takes. + print(catalog.code, catalog.label) + + rows = client.list_reference_table("urgency") + print(rows.record_count, rows.total_record_count) +``` + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + # The vendor documents GET /urgencies; this deployment declares + # GET /urgency. The default is the one the instance declares, and + # reference_path= reaches the other without forking the package. + urgencies = client.discover("URGENCY", reference_path="urgencies") + print([u.id for u in urgencies]) + + # A custom column has no table, so it is read off sampled tickets. + sites = client.discover("e_site", sample_size=500) + print([(s.label or s.id, s.count) for s in sites]) +``` + +## Gotchas + +- **A 403 does not mean "denied".** This API answers 403 for a path that does + not exist as well as for one a profile blocks, so the status code alone never + distinguishes them. `get_api_spec()["paths"]` does. +- **`get_api_spec` answers HTTP 201, not 200.** This client is unaffected — its + transport treats any 2xx as success — but code you write beside it that gates + on `status_code == 200` skips the document in silence and concludes the + instance publishes no spec. +- **`list_reference_table` lets a 403 propagate; it never returns `[]`.** An + empty reference table is a legitimate answer on a lightly configured + instance, so collapsing a denial into an empty list would make "you may not + read this" indistinguishable from "there is nothing here". `describe_instance` + is the layer that swallows it, and it names the gap in `.unavailable`. +- **Four names have no route at all**: `IMPACT`, `SEVERITY`, `ORIGIN` and + `ACTION_TYPE`. That is a topology fact from the spec, not a 403 someone + measured, so no strategy reaches a table for them. What you get is *the ids + in use in the sample* — an id configured but unused is invisible, and a + `count` is a sample count, never a population one. Priority is not + discoverable at all: EasyVista derives it from urgency × impact. +- **`catalog_guid` is not discoverable.** No route returns one. Build with + `catalog_code`, which `discover("CATALOG_REQUEST")` puts in `.code`. +- **A `STATUS_GUID` only ever comes from a sample.** No reference read returns + one, but every ticket's nested `STATUS` object carries it. A status no + sampled ticket currently holds keeps `guid=None` — the sample cannot reach + it, and inventing one would hand you a GUID that addresses nothing. +- **Discovering `GROUP` by sampling gives ids with no labels.** An action + carries `GROUP_ID` but no group label. That is stated rather than papered + over with a fabricated one; grant the profile read access to `/groups` for + real labels. +- **The ids for "internal note" vs "customer comment" are discoverable; the + meaning is not.** `discover("ACTION_TYPE")` returns the types in use with + their translated labels — a human still has to read which is which. See + `easyvista-ticket-actions`. +- `GenericRecord` declares no columns, so `classify_fields()` puts every + `E_`-prefixed column in the `custom` bucket, including an official one like + `E_MAIL`. That is the trade for a model that assumes no schema. +- Read a `GenericRecord` column by its API name: + `record.model_dump(by_alias=True)["STATUS_ID"]`, or generically with + `record.reference(name)`. +- `describe_instance` samples **once**, not once per name, and issues roughly a + dozen requests — all GETs. It catches only `EasyvistaError`, so a bug in this + package still propagates rather than being buried as a fake instance limit. diff --git a/skills/easyvista-reporting-and-context/SKILL.md b/skills/easyvista-reporting-and-context/SKILL.md index 58361f6..855c805 100644 --- a/skills/easyvista-reporting-and-context/SKILL.md +++ b/skills/easyvista-reporting-and-context/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, easyvista-python-client, and network access to an EasyVista Service Manager REST API." metadata: package: easyvista-python-client - version: "0.1.0" + version: "0.2.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, @@ -22,14 +22,22 @@ one call, degrading around profile restrictions instead of failing. - `count_tickets(search=None)` — one cheap call. Sends `max_rows=1` and reads the envelope's `total_record_count`; fetches no records. - `ticket_statistics(search=..., dimensions=..., created_since=..., - created_until=..., max_records=100)` — fetches up to `max_records` matching - tickets and groups them. **The default cap is 100**; pass `max_records=None` - to aggregate all. + created_until=..., max_records=100, languages=...)` — fetches up to + `max_records` matching tickets and groups them. **The default cap is 100**; + pass `max_records=None` to aggregate all. - `aggregate_tickets(tickets, dimensions=..., created_since=..., - created_until=...)` — the same aggregation, pure and offline, over tickets - you already hold. -- `TicketStatistics` carries `total` and `breakdowns` (`{dimension: {label: - count}}`). For every dimension, `sum(breakdowns[dim].values()) == total`. + created_until=..., languages=...)` — the same aggregation, pure and offline, + over tickets you already hold. +- `languages=` orders the language columns tried when a dimension's nested + reference object becomes a bucket key (default `DEFAULT_LANGUAGE_ORDER`, + English first). Reorder it on a deployment whose primary language is not + English, or the breakdown keys come back as raw ids. +- `TicketStatistics` carries `total`, `breakdowns` (`{dimension: {label: + count}}`), `truncated` and `population_total`. For every dimension, + `sum(breakdowns[dim].values()) == total`. `truncated` is True when the fetch + hit its record cap, so `total` describes a sample; `population_total` is the + server's own count for the search, read off the first page at no extra + request and counted **before** any client-side date window. - The default dimensions are `STATUS`, `DEPARTMENT`, `CATALOG_REQUEST`, `URGENCY` and `IMPACT`. Any field name works, including custom `e_*` columns. Pass them explicitly as a list — the default tuple is not part of @@ -38,6 +46,12 @@ one call, degrading around profile restrictions instead of failing. `CREATION_DATE_UT`, accepting a `datetime` or an ISO-8601 string, applied client-side. A ticket with a missing or unparseable date is excluded when a bound is set. A malformed bound string raises `ValueError`. +- An **offset-less** bound string is interpreted as **UTC**, not instance-local. + On a `+02:00` instance, `created_since="2026-01-01T00:00:00"` silently excludes + tickets created between 00:00 and 02:00 local on 1 January. Pass an aware + `datetime` or an offset-bearing string. (This client-side filter is + deliberately more permissive than `ev_since_filter`, which refuses an + offset-less time outright.) ## Context bundles @@ -46,18 +60,42 @@ one call, degrading around profile restrictions instead of failing. resolves the href-only description/comment memos and lists actions and documents. Missing sub-resources (404) and profile-restricted lists (403) degrade to `None` / `[]` rather than failing the call. Actions in the bundle - come back pre-resolved to a string body; for the raw list/item record shapes - and how to find a just-created action's id (diff `list_actions` across the - call), see `easyvista-ticket-actions`. -- `TicketContext.to_markdown()` renders an **href-free** Markdown document: an + come back pre-resolved to a string body: **`DESCRIPTION`, falling back to + `COMMENT` only when the description memo is empty** — the same rule the UI + applies, so an exported log matches the ticket on screen. (Resolving + `DESCRIPTION` alone used to drop the body of exactly the actions a human + *can* read.) For the raw list/item record shapes and how to find a + just-created action's id (diff `list_actions` across the call), see + `easyvista-ticket-actions`. +- `get_ticket_context(rfc, memo_fields=("description", "comment"))` names which + Memo sub-resources to resolve. The two defaults are the ones EasyVista + populates out of the box, but which memo carries a ticket's body is + per-deployment configuration, so an instance using another one is reached by + naming it here. Every resolved memo lands in `TicketContext.memos`, keyed by + the name requested; `description` and `comment` additionally keep their own + attributes, and are `None` when not requested. Pass a tuple or list, never a + bare string — `str` satisfies `Sequence[str]` and would be iterated one + letter at a time, one request per character. +- `TicketContext.to_markdown(fields=None, languages=...)` renders an + **href-free** Markdown + document: an `# Ticket ` heading, a field table, the body, `## Actions` and - `## Attachments`. Nothing in the output leaks an API URL. -- `get_department_context(department_id, recent_tickets=10, dimensions=None, - include_statistics=True, include_assets=True, resolve_manager=True, - include_note=True)` → `DepartmentContext(department, employees, manager, - note, ticket_count, recent_tickets, ticket_statistics, assets)`. Only the + `## Attachments`. Nothing in the output leaks an API URL. Headings name the + role a block plays, not the field it came from: one populated memo is the + body under `## Description` whichever field carried it, two defaults keep + `## Description` / `## Comment`, and memos asked for through `memo_fields` + follow the same rule (several get a heading each, derived from the requested + name). +- `get_department_context(department_id, recent_tickets=10, + recent_tickets_sort="RFC_NUMBER DESC", ticket_fields=..., employee_fields=None, + asset_fields=None, dimensions=None, statistics_max_records=100, + memo_fields=("comment_department",), include_statistics=True, + include_assets=True, resolve_manager=True, include_note=True)` → + `DepartmentContext(department, employees, manager, note, ticket_count, + recent_tickets, ticket_statistics, assets, memos, degraded)`. Only the department itself is guaranteed; each related part degrades to `[]` / `None` - / `0`. Resolve a human name or code to a `department_id` with + / `0` **and records itself in `ctx.degraded`**, so a swallowed 403 is no + longer indistinguishable from an empty result. Resolve a human name or code to a `department_id` with `find_departments` first, and read the department's own note independently with `get_department_comment` — see `easyvista-directory`. @@ -148,10 +186,38 @@ with EasyvistaClient.from_env() as client: ## Gotchas +- **`to_markdown`'s structural headings are fixed English by design.** + `languages=` changes the label *content* — the Status/Department/Location/ + Catalog values and each action's heading — but never the frame: `## + Description`, `## Actions`, `## Attachments` and `| Field | Value |` are the + same on every deployment. That is deliberate: this export feeds LLM and RAG + pipelines, a chunker splits on `## `, and a heading that varied per instance + would break those silently. Localize the frame in your own code if a human is + the reader. - **`ticket_statistics` caps at 100 tickets by default.** When the cap - truncates, the result describes the fetched subset, not the population — - compare `stats.total` against `count_tickets(search=...)` and pass - `max_records=None` when they disagree. + truncates, the result describes the fetched subset, not the population — and + now says so: `stats.truncated` is True and `stats.population_total` carries + the server's own count for the search, at no extra request. Pass + `max_records=None` when the breakdown must describe the whole population. + Note `truncated` reports "the cap was reached", so it is True for a + population whose size is exactly the cap; compare it against + `population_total` when that distinction matters. +- **`get_department_context`'s recent tickets are projected by default**, which + is what makes `.title` populated. It previously sent no `fields=`, and on the + verified instance the default list projection returns TITLE present but + **empty**, so every recent ticket's `.title` was `None` for every caller. + Projecting also narrows what else comes back: pass `ticket_fields=None` for + the old unprojected request, or your own list to widen it. +- The statistics sample inside the bundle is capped at + `statistics_max_records=100` and is **unsorted** — it is not the department's + first hundred tickets by any ordering. `ticket_count` is the true total and + ignores that cap; `ticket_statistics.truncated` tells you the sample was one. +- **`ctx.degraded` distinguishes "empty" from "forbidden".** Entries are + `":"` — split with `rsplit(":", 1)`, because a memo + branch is itself named `"memo:"`. `to_markdown` renders + `_Not available (HTTP 403)._` for a refused actions or attachments section + rather than omitting it, so an export cannot read as "this ticket has no + attachments" when the list was actually refused. - A dimension whose label cannot be resolved groups under `"(unknown)"`; the breakdown still sums to `total`. - `get_ticket_context` resolves action bodies by default at **two extra @@ -166,13 +232,20 @@ with EasyvistaClient.from_env() as client: the async surface lets siblings already in flight settle before the error propagates, so a failing call can issue more requests than the sync surface would. -- `recent_tickets` ordering is best-effort: it depends on the server - honouring the descending-sort token `RECENT_TICKETS_SORT`, and that - dependency — like the assumption that an unknown `sort` falls back to the - default order rather than erroring — is not confirmed against a live - instance (open item O-DIR-1; `easyvista-search-syntax` hedges the same - claim). Until checked against your own instance, treat the ordering as - unconfirmed rather than guaranteed descending. +- `recent_tickets` is genuinely sorted **by descending `RFC_NUMBER`**: + `RECENT_TICKETS_SORT` uses the space-separated descending token + (`RFC_NUMBER DESC`), which EasyVista honours — verified live 2026-08-17 by + `integration_tests/test_live_change_window.py` (closes open item O-DIR-1). + That is newest-first only where RFC numbers are issued monotonically. It is a + varchar (`I240101_0001`), so a descending *string* sort orders by the + request-type prefix first: on an instance issuing more than one prefix letter, + every `R…` ticket outranks every `I…` ticket regardless of date. What is + measured is the descending-ness, not the recency — sort a date column yourself + if you need a date guarantee. + A colon-separated token (`RFC_NUMBER:DESC`), `-RFC_NUMBER` and + `DESC(RFC_NUMBER)` are all silently ignored instead, falling back to the + server's default order with no error — that was the earlier, unconfirmed + form this constant used to rely on. - `TicketContext.to_markdown()` titles the body "Description" whichever memo carried it, and emits both headings only when both memos have text. Do not parse the heading to infer the source field — read `context.description` / diff --git a/skills/easyvista-search-syntax/SKILL.md b/skills/easyvista-search-syntax/SKILL.md index 146ec10..d6b27e1 100644 --- a/skills/easyvista-search-syntax/SKILL.md +++ b/skills/easyvista-search-syntax/SKILL.md @@ -1,11 +1,11 @@ --- 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, escape_ev_value and is_safe_ev_value. Use whenever building a search= argument, filtering EasyVista records, or debugging a filter that returned everything or nothing — EasyVista silently ignores conditions it cannot honour and returns the whole table." +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." metadata: package: easyvista-python-client - version: "0.1.0" + version: "0.2.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, @@ -15,22 +15,62 @@ metadata: Every `search_*` and `iter_*` method takes the same `search` string. The grammar is small and two of its three failure modes are silent, so this skill -is a prerequisite for any filtering work. Except where a claim below is -explicitly flagged as unconfirmed, everything here was characterized against a -live instance by `integration_tests/test_live_search_syntax.py` — that file is -the authority when something here looks wrong. +is a prerequisite for any filtering work. Everything here was characterized +against a live instance by `integration_tests/test_live_search_syntax.py` +(the base grammar) and `integration_tests/test_live_change_window.py` (the +interval, wildcard and sort grammars) — those files are the authority when +something here looks wrong. ## The grammar - `FIELD:"value"` — exact match. -- `~` is a **synonym for `:`** — exact match, not "contains", on code-like - fields (`DEPARTMENT_CODE`, `ASSET_TAG`) and on free-text label fields - (`DEPARTMENT_FR`) alike. Vendor documentation claiming otherwise is wrong. - **No substring operator has been identified.** -- `%` inside a value is a **literal character**, not a wildcard. +- `~` is documented by the vendor as plain **Contains** (Oxygen 1.7+) — one + word, no example, no wildcard mentioned. **On the instance this package was + characterized against it is a pattern operator instead** (measured live + 2026-08-17; one deployment, may not generalise): it behaves like "contains" + or "starts with" only when the value carries an **explicit** wildcard. `*` + and `%` both expand there (`FIELD~"*abc*"` substring, `FIELD~"abc*"` prefix, + and `%` reproduced `*`'s match count exactly). Given a **bare** value, `~` + degenerates to exact match — identical to `:` — which is why this skill once + documented it as exact-match-only; that conclusion held only for the + wildcard-free inputs it was tested with. `:` never expands a wildcard even + when the value contains one: `FIELD:"abc*"` matches nothing. + `ev_contains_filter` / `ev_starts_with_filter` append `*` by default, which + is right for a deployment like the verified one. On a deployment that + follows the vendor's reading and compares `*` literally, that default + returns **zero rows with HTTP 200 and no hint** — pass `wildcard=None` there + to emit the bare value, or `wildcard="%"` for a LIKE-style backend. Confirm + which reading your deployment follows once, by comparing a filtered count + against the unfiltered baseline; you cannot tell from a single response. + Note `wildcard=None` on `ev_starts_with_filter` removes the *anchor*, not + just the token — it is a substring match on a vendor-conformant deployment. +- `*` and `%` are not the only metacharacters under `~`, and the other two + belong to the **operator**, not to the wildcard the builders append. `_` + matches any **single** character and `[` opens a character class — measured + live 2026-08-18 on one instance with a *wildcard-free* pattern: replacing an + RFC's last character with `_`, or with `[0-9]`, turned a 1-row exact match + into 9, while `[x]` still matched the one row. There is + **no escape**: a backslash before `_` returned 0 rows, i.e. it is compared + literally. So `ev_contains_filter` / `ev_starts_with_filter` raise + `ValueError` for `_` or `[` in the value at **every** `wildcard=` setting, + `None` included, and additionally for `*` or `%` while a wildcard is being + appended (a second one would compose with it). With `wildcard=None` a `*` or + `%` in the value passes through, which is how to hand-build a pattern. + This bites on ordinary input, not exotic input: `_` is pervasive in + EasyVista codes, and `ev_contains_filter("ASSET_TAG", "LAPTOP_01")` would + otherwise also match `LAPTOP-01` and `LAPTOP001` with HTTP 200 and no hint. + For an **exact** match on such a value use `ev_equals_filter` — `:` does not + expand a wildcard, so a `_` there is compared literally. Only if you need to + pattern-match *around* a literal `_` are you stuck: filter server-side on a + wider condition and match exactly in Python. - `,` combines conditions: **OR** when every condition names the same field, **AND** across different fields. - `;` is **not** a combinator; it is swallowed into the quoted value. +- There is **no comparison operator** (`>=`, `BETWEEN`, `[a TO b]`…). Writing + one fails one of two different ways depending on its exact shape — see fate + 3 below — never by narrowing the result. Use `ev_since_filter` / + `ev_between_filter` for a date/time window instead (see "Filtering by a + change window"). - There is **no escape for a `"` inside a value**. Raw, backslash-escaped and doubled renderings were all tested against a ticket verifiably created with a quote in its title; none matched. @@ -41,18 +81,93 @@ the authority when something here looks wrong. 2. **Silently dropped** — no error. EasyVista removes any condition it cannot honour and applies what is left; with nothing left, it returns **every** row. This happens for structurally unparseable input - (`DEPARTMENT_FR LIKE "%TECH%"`, bare garbage), for an unknown field, and - for a well-formed condition on a returned-but-unsearchable field. Dropping - is **per condition**: in a two-condition search, one can be honoured while - the other vanishes. + (`DEPARTMENT_FR LIKE "%TECH%"`, bare garbage, a colon-free comparison like + `LAST_UPDATE>="2026-01-01"`), for an unknown field, and for a well-formed + condition on a returned-but-unsearchable field. Dropping is **per + condition**: in a two-condition search, one can be honoured while the + other vanishes. 3. **Rejected outright** — `EasyvistaValidationError` (HTTP 590) when the value's *type* does not match the column, e.g. sending a status name to - the integer `STATUS_ID`. This is the friendly failure. + the integer `STATUS_ID`. This is the friendly failure. A comparison + operator embedded *inside* `FIELD:"value"` syntax lands here too — + `LAST_UPDATE:">=2026-01-01"` and `LAST_UPDATE:"[2026-01-01 TO *]"` both + raise HTTP 590, because the quoted text must still parse as `LAST_UPDATE`'s + date type. So a comparison operator has **two** fates, not one: drop the + `FIELD:` colon and it is silently dropped (fate 2); keep the colon and + embed the operator in the value and it is a type mismatch (fate 3). + Neither ever narrows the result. The counter-intuitive case: a **broken quote does not** return the table. `DEPARTMENT_CODE:"X""` still parses as a field expression, the value swallows the junk, and it matches nothing (0 rows). +## Filtering by a change window + +There is no comparison operator, so a range is an interval in the *value* +position: `ev_since_filter("LAST_UPDATE", watermark)` builds +`LAST_UPDATE:(;)`, an open-ended lower bound; `ev_between_filter` +builds a closed `LAST_UPDATE:(a;b)`. Pass a `datetime` (preferred, and what a +`Request` timestamp field already is) or a timestamp string — either bound is +validated as a real timestamp because it is interpolated **unquoted**, so a +stray `;` or `)` inside it would silently change the query rather than being +escaped away. + +**A bound naming a time must carry its UTC offset**, and both builders refuse one +that does not — as a `datetime` or as a string. EasyVista *accepts* an +offset-less literal and reads it in another zone, moving the bound later and +skipping records with no error (measured live: 13 rows with the offset, 11 +without, same wall-clock text). A bare date is fine; it has no time to misplace. + +An admitted string bound naming a **time** is re-rendered to millisecond +precision with an offset, because that is the only time rendering the wire +honours: `LAST_UPDATE:(2025-11-28T16:14:41+01:00;)` — second precision with an +offset, the most obvious way to satisfy the rule above — is **HTTP 590**, as are +minute precision and a space instead of `T` (what `str(aware_datetime)` +produces). So the string and `datetime` paths emit identical bounds; do not +hand-build the literal. + +The lower bound is **inclusive** and milliseconds are honoured (verified live on +three independent boundaries), so a watermark taken as `max(t.last_update)` +re-reads the boundary record on the next sweep. De-duplicate by `rfc_number`. + +**Sort a sweep `LAST_UPDATE DESC`, and de-duplicate.** `iter_*` walks the result +set by *offset*, and the rows a change window selects are by construction the +rows that are changing, so a ticket touched between two pages moves *within the +set being paged*. An unsorted sweep can drop such a row silently — and so can +either sort direction. What differs is where the dropped row's own timestamp +lands relative to the watermark this sweep records: + +- **`LAST_UPDATE DESC`**: the re-touched row jumps to the head, behind the read + cursor, so this sweep misses it — but its stamp is now *above* the watermark, + so the next sweep selects it again. **Deferred, self-healing.** +- **`LAST_UPDATE` / `LAST_UPDATE ASC`**: the re-touched row moves to the tail and + everything behind it shifts one place head-ward, so the row that crosses the + cursor is one whose own stamp did **not** change. It falls *below* the new + watermark and no later sweep selects it. **Permanent miss.** + +Both tokens are honoured (measured live); descending is chosen for the reason +above. De-duplicate by `rfc_number` — the duplicates are the deferred rows +arriving on a later sweep, plus the inclusive-boundary re-read. + +**A sweep that never finishes is a separate trap.** `DESC` yields the newest +row first, so the watermark reaches its *final* value on page 1. A sweep that +is interrupted, or capped with `max_records` (as some pagination examples in +this repo do), still ends up holding the newest stamp — advance the watermark +from that and the next window's `(newest;)` bound permanently excludes every +row the incomplete sweep never read. Only advance the watermark after a sweep +runs to completion. + +If even a deferred miss is unacceptable, do not use `iter_*`: page +`search_tickets` yourself with **keyset** pagination — sort ascending and, after +each page, advance the *window* to `ev_since_filter(field, max(stamps on the +page))` at `offset=0` instead of incrementing an offset. With no offset there is +no cursor for a row to shift past. `iter_tickets` cannot express this because it +owns its own offset. + +(An earlier version of this skill recommended ascending, reasoning that it turns +a permanent miss into a duplicate. That was wrong: the row an ascending sweep +drops is not the re-touched one.) + ## What is searchable Only **top-level scalar columns**. Two families are returned but not @@ -64,6 +179,15 @@ searchable, and naming one matches everything: inside `STATUS`) — they are not top-level columns at all. Filter `STATUS_ID`. +A **dotted path across a relation** is the exception and IS honoured in +`search`: `REQUEST.RFC_NUMBER:""` on `/actions` genuinely scopes, and it is +what `list_actions` is built on (pinned by +`integration_tests/test_live_ticket_history.py::test_list_actions_filters_to_the_requested_ticket`). +What is silently ignored is a **bare** nested sub-key (`STATUS_EN`) and a +`*_PATH` display column — not the dotted form. Note `fields` does not accept the +dotted form even where `search` does: a projection like `DESCRIPTION.HREF` is +silently dropped. + The rule is about **nesting, not language**: `DEPARTMENT_FR` is a top-level column on `departments` and filters correctly. `CATALOG_GUID` is not an instance of this rule — it is not returned at all, so it is merely an unknown @@ -72,9 +196,11 @@ ids are instance-specific. ## Procedure -1. Build every filter with `ev_equals_filter` / `ev_in_filter`. Never +1. Build every filter with a helper: `ev_equals_filter` / `ev_in_filter` for + exact match, `ev_contains_filter` / `ev_starts_with_filter` for a pattern, + `ev_since_filter` / `ev_between_filter` for a date/time window. Never f-string a value into a `search`. -2. Handle `None`: both builders return `None` for a blank or missing value, +2. Handle `None`: every builder returns `None` for a blank or missing value, so `search=None` means unfiltered — guard when that is not what you want. 3. Call `is_safe_ev_value(value)` first when you would rather skip a filter than raise; `escape_ev_value` raises `ValueError` on a value containing @@ -153,6 +279,41 @@ with EasyvistaClient.from_env() as client: result = client.find_departments(user_supplied, limit=10) ``` +```python +from easyvista_python_client import EasyvistaClient, ev_contains_filter + +with EasyvistaClient.from_env() as client: + # ev_contains_filter appends '*' by default: on the instance this package + # was characterized against, a bare '~' is exact match and the wildcard is + # what makes it "contains". The vendor documents '~' as plain Contains -- + # if that is your deployment, pass wildcard=None instead. + result = client.search_assets(search=ev_contains_filter("ASSET_TAG", "LAPTOP")) + print(result.total_record_count) +``` + +```python +from easyvista_python_client import EasyvistaClient, ev_since_filter + +with EasyvistaClient.from_env() as client: + ticket = client.get_ticket("I240101_0001") + # ticket.last_update is already an aware datetime -- feed it straight back + # in as a watermark for "everything changed since this ticket". + search = ev_since_filter("LAST_UPDATE", ticket.last_update) + if search is not None: + # The sort direction is load-bearing. Descending on the filtered + # column defers a mid-sweep miss to the next sweep (the row's stamp + # ends up above the watermark); ascending loses it for good. Hence + # the de-duplication -- see "Filtering by a change window". + seen = set() + for changed in client.iter_tickets( + search=search, sort="LAST_UPDATE DESC", page_size=100 + ): + if changed.rfc_number in seen: + continue + seen.add(changed.rfc_number) + print(changed.rfc_number) +``` + ## Gotchas - A `,` reaching the server inside untrusted input **widens** a same-field @@ -161,12 +322,15 @@ with EasyvistaClient.from_env() as client: `escape_ev_value` does. - `ev_equals_filter` returns `None` for a blank value; passing that straight through as `search=` silently means "no filter". -- An unknown `sort` token is believed to be ignored, not rejected, falling - back to the default order — but unlike the rest of this skill, that is - **not** covered by the live suite. It is what +- The sort token must be **space-separated**: `FIELD DESC` (or `field desc`) + genuinely reorders the result, and bare `FIELD` / `FIELD ASC` both sort + ascending — verified live by + `integration_tests/test_live_change_window.py`. `FIELD:DESC`, `-FIELD` and + `DESC(FIELD)` are all silently ignored — the query falls back to the + server's default order with no error, so a sweep written with one of those + looks sorted and is not. Nothing validates the token locally. This is what `easyvista_python_client/directory.py`'s `RECENT_TICKETS_SORT` relies on - (open item O-DIR-1); treat it as unconfirmed until checked against your - own instance. + (closes open item O-DIR-1). - `count_tickets` is the cheap way to check a filter: it sends `max_rows=1` and reads the envelope total without fetching records. - `search_*` returns one page; `iter_*` pages until the server reports no diff --git a/skills/easyvista-ticket-actions/SKILL.md b/skills/easyvista-ticket-actions/SKILL.md index 8465758..b9f9bac 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_action, list_actions and get_action with PostAction and Action, including how to recover a created action's id and how to resolve an action's note text, which the list endpoint does not return. Use for ticket followups, work notes, 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 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." 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." metadata: package: easyvista-python-client - version: "0.1.0" + version: "0.2.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, @@ -14,14 +14,40 @@ metadata: > `easyvista-client-setup`. Actions are EasyVista's per-ticket work log — the closest equivalent to a -followup. Three methods: `create_action(rfc, action)`, `list_actions(rfc)` and -`get_action(action_id)`. The list and item shapes differ substantially, which -is where most mistakes come from. +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 +substantially, which is where most mistakes come from. ## Two shapes of the same record -- `list_actions(rfc)` returns a **slim collection record**. It does **not** - carry the note text. +- `list_actions(rfc)` returns a **slim collection record**: by default it + carries `ACTION_ID`, `ACTION_LABEL_FR`, `ACTION_NUMBER`, `DONE_BY_ID` and + `EXPECTED_START_DATE_UT`, but **not** the note text, and not + `CREATION_DATE_UT`/`LAST_UPDATE` either. (Which language column the default + projection returns was measured on one French instance and may differ on + yours.) +- **Read `action.label`, never `action.action_label_fr`.** `label` is a + property that scans every `ACTION_LABEL_` column in + `DEFAULT_LANGUAGE_ORDER` and skips untranslated `[placeholder]` values. On a + single-language instance the *other* language columns echo the primary text + wrapped in brackets, so on an English deployment `action_label_fr` is + `"[Customer Comment]"` — not `None` — and reading it directly yields the + placeholder. Being a property, it is not a serialized field: it never appears + in `model_dump()` or `classify_fields()`. For a different language order call + `localized_label(action.model_dump(by_alias=True), "ACTION_LABEL", + languages=("_GE",))`. +- Pass `fields=` to widen that projection in one request instead of an item + fetch per action: `list_actions(rfc, fields=["ACTION_ID", + "ACTION_TYPE_ID", "CREATION_DATE_UT", "LAST_UPDATE", "DONE_BY_ID"])` + returns those columns top-level on every row. The note text stays + unreachable this way — `DESCRIPTION`/`COMMENT` are Memo sub-resources and + come back as HREF objects under any projection — and `fields="*"` is + **not** a wildcard, it silently reduces to `ACTION_ID` alone. +- `iter_actions(rfc)` is `list_actions` with paging: same records, same + `fields=` projection, but it follows `@next` until the server runs out + instead of stopping at one page. Use it whenever a ticket's **complete** log + matters; see the pagination gotcha below. - `get_action(action_id)` returns a **fuller item record** whose `DESCRIPTION` and `COMMENT` are memo href objects — that is, `action.description` is a `dict` with an `HREF`, not a string, until something resolves it. @@ -31,34 +57,320 @@ is where most mistakes come from. which fetches each action item-level and resolves its memo for you — see `easyvista-reporting-and-context`. +## Two stored text fields, but only ONE is displayed + +**An action stores two text fields**, `description` and `comment`, each +addressable afterwards as its own memo (`actions/{id}/description`, +`actions/{id}/comment`). `PostAction` writes both, and both persist from a +single create — verified live 2026-08-28: each read back with exactly the text +sent. The instance's own OpenAPI declares both on the create body and its +example populates both. + +**They are independent in storage, not in visibility.** The UI shows **one** +text field per action, under a header reading literally *"comment or +description"*: it renders `DESCRIPTION` when that memo has text, and falls back +to `COMMENT` only when it is empty. So **`description` shadows `comment`** +(measured in the UI 2026-09-01 on one instance, Service Manager 2025.3 — one +instance, one date, may not generalise). + +A `comment` written beside a populated `description` is stored, reads back +cleanly through the API, and is **never shown to anyone**. There is no error and +no dropped field, so nothing signals the loss. `comment` is not a private +channel — it is the unread one. + +```python +# `description` is the field the history renders. Anything a person must read +# goes here. +PostAction( + action_type_id=YOUR_TYPE_ID, + group_id=YOUR_GROUP_ID, + description="The text a reader will actually see.", + # `comment` is a second memo on the same record. Set it only when you are + # deliberately leaving `description` empty, or when you mean it as + # API-only metadata -- with a description present, nobody reads it. +) +``` + +To make text private, use the action **type** (see below) — that is the only +distinction the API carries. + +## To post a comment, create a TASK — not an action + +**This is the single most important thing in this skill.** A task and an action +are the same underlying record; they differ in the state they are born in, and +that decides whether anyone ever sees the text. + +| | `create_action` | `create_task` | +|---|---|---| +| endpoint | `POST requests/{rfc}/actions` | `POST requests/{rfc}/tasks` | +| body | wrapped | **flat** at the root | +| born | **open** — work still to do | **ended** — work reported | +| in the UI | pending row, text **not** shown | history entry **with** its `description` | +| needs ending | yes | no | +| `parent_action_id` | needed when the ticket has 2+ open actions | not needed | +| parent-resolved | **yes** — needs exactly one open action, or an explicit parent | no | +| use for | work someone must still do | **comments** | + +```python +from easyvista_python_client import PostTask + +client.create_task( + "YOUR_RFC_NUMBER", + PostTask( + action_type_id=YOUR_INTERNAL_TYPE_ID, + group_id=YOUR_GROUP_ID, + description="Internal working note.", + ), +) +``` + +Verified live 2026-08-28: tasks came back with `END_DATE_UT` and +`STATUS_ID_ON_TERMINATE` already set, and their text appeared in the history. +`action_type_id` and one of `group_id` / `group_name` / `group_mail` are +mandatory; `PostTask` refuses a body missing either rather than letting the +server answer with a 590 that names no field. + +**If a caller creates an action and stops**, nothing is lost — the text is +stored — but the row renders without it until the action is ended. Use +**`end_action`**, which wraps the vendor's `PUT actions/{rfc_number}` / +`end_action` route — +[docs](https://docs.easyvista.com/docs/rest-api-finish-an-action-attached-to-an-incident-request.md). + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + client.end_action( + "YOUR_RFC_NUMBER", + action_id=YOUR_ACTION_ID, + # Your instance's own date spelling, not ISO 8601. + start_date="01/09/2026 17:00:00", + end_date="01/09/2026 17:15:00", + elapsed_time=15, # MINUTES + ) + # A 200 is not a receipt on this API, and the response is href-only. + print(client.get_action(YOUR_ACTION_ID).model_dump(by_alias=True)["END_DATE_UT"]) +``` + +> **Ending a workflow action advances the workflow.** Measured 2026-09-01 on +> one instance (2/2 tickets): ending a fresh ticket's open type-20 *Traitement +> Operation* action moved the **ticket** from *En cours* to *Résolu* and +> 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. + +> **Retraction (2026-09-01).** An earlier revision of this skill said every +> documented form returned `590 Action not found` and called that an +> instance/profile restriction to raise with an administrator. **That was +> wrong.** The 590 comes from replaying the call against an action that is +> *already ended* — the message means "no OPEN action matched", not "you may +> not do this". Do not raise it with your administrator. + +Details, all measured 2026-09-01 on one instance (Service Manager 2025.3 — one +instance, one date, may not generalise): + +- `end_date` takes `dd/mm/yyyy hh:mm:ss` (also `hh:mm`; a bare date lands at + midnight). **ISO 8601 is rejected** with `590 "Invalid End Date"`. +- `elapsed_time` is in **minutes**. +- Send `start_date` explicitly. Left to derive it, the server returns + `START_DATE_UT` early by the instance's UTC offset — confirmed with a + DST control, so it is the offset and not a fixed constant. An explicit + `start_date` is stored faithfully. +- The path segment is the **RFC number**, not an action id, despite the route + living under `/actions`; `PUT actions/{action_id}` answers 404 even with the + id also in the body. + +For comments none of this arises, because a task is born ended. + +## Visibility is by action TYPE, and the labels say which + +There is no per-action visibility flag — 88 item-level columns, no +public/private boolean. The distinction lives in the **type**. On the verified +instance: **94** = `Commentaire [Public]` / `Customer Comment`, **95** = +`Note Interne [Privé]` / `Internal Note`. + +**There is no reference table, and the ids are still discoverable.** Both +halves matter: + +- The instance's own OpenAPI declares **no `action-types` route at all** (read + from `GET {api_root}/swagger`, 2026-08-27, EasyVista 2025.3). `GET + action-types` answers 403, but on this API a forbidden path and an unknown + one both answer 403, so that response never told you which it was. There is + nothing to enumerate and nothing for an administrator to unblock here. +- Every action record nevertheless carries its own `ACTION_TYPE_ID` beside + translated `ACTION_LABEL_*` columns, so the types an instance actually uses + are recoverable from the data — `client.discover("ACTION_TYPE")` does exactly + that sampling for you (see `easyvista-instance-discovery`). + +Two bracket conventions appear in `ACTION_LABEL_*` and they mean **opposite** +things: + +| In `ACTION_LABEL_*` | Means | Example | +|---|---|---| +| whole label wrapped in brackets, echoing another language | untranslated placeholder, no meaning — `localized_label` discards it | `EN='[Analyse et résolution]'` on a French instance | +| bracketed **suffix** on distinct text, with real sibling translations | a genuine marker written by whoever configured the instance | `FR='Commentaire [Public]'` beside `EN='Customer Comment'` | + +The test is whether the siblings are real translations or brackets — not +whether brackets are present. An earlier revision of this package conflated +the two and deleted the true finding. Do not read every `[...]` as noise, and +do not read one as "restricted" either: a marker is a convention on one +deployment, not an API feature. Nothing on an action record states what its +type *means*, so confirm the mapping with whoever administers the instance +before relying on it for anything that must not leak. + +The honest answer to "how do I post an internal note?" is: **ask the EasyVista +administrator which action type id to use**, pin it in configuration, and pass +it. + +```python +from easyvista_python_client import EasyvistaClient, PostAction + +# Read off YOUR instance with the block above and confirmed with its +# administrator. 94/95 are what the verified instance uses; not portable. +INTERNAL_NOTE_TYPE_ID = 95 +YOUR_GROUP_ID = 3 + +with EasyvistaClient.from_env() as client: + client.create_action( + "YOUR_RFC_NUMBER", + PostAction( + action_type_id=INTERNAL_NOTE_TYPE_ID, + group_id=YOUR_GROUP_ID, + description="Internal: credentials rotated.", + ), + ) +``` + +To reconcile what the administrator says against the data, list the types a +ticket already uses. Most will be workflow-generated steps, not human notes — +those carry an empty `DONE_BY_ID`. + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + for action in client.iter_actions("YOUR_RFC_NUMBER"): + action_type = action.reference("ACTION_TYPE") + print(action_type.id, action_type.display, action.done_by_id) +``` + +## Editing an action + +`update_action(action_id, ActionUpdate(description=...))` edits an existing +action's note — **including one that has already been ended**, where the new +text replaces what the history shows (measured 2026-09-01, one instance). That +is how to correct or extend a note visibly after the fact. + +**Write `description`, not `comment`.** `ActionUpdate` exposes both, but +`description` shadows `comment` in the UI (see above), so an +`ActionUpdate(comment=...)` on an action that already has a description returns +200, re-reads cleanly through the API, and changes nothing a person sees. + +Two asymmetries worth knowing: + +- Unlike `create_action`/`list_actions`, which are ticket-scoped + (`rfc_number`), `update_action` is keyed on the **action id alone** — it + calls the top-level `PUT actions/{id}`. That is not a permission quirk: no + `requests/{rfc}/actions/{id}` route exists on this API at all. + `POST requests/{rfc}/actions` is create-only; the list, item and update + operations live on `/actions` and `/actions/{id}`. A 403 here would not have + told you which, because this API answers 403 for an unknown path as well as + for a denied one. +- An action can be **edited but not deleted**: this API declares only GET, PUT + and PATCH on `actions/{id}` — there is no DELETE verb — so there is + deliberately no `delete_action`. + ## Discover the ids first -`action_type_id` and `group_id` are instance-specific. Read them off existing -actions before writing. +`action_type_id` and `group_id` are instance-specific. One call finds both — +see `easyvista-instance-discovery`: ```python from easyvista_python_client import EasyvistaClient with EasyvistaClient.from_env() as client: - for action in client.list_actions("YOUR_RFC_NUMBER"): - print(action.action_id, action.reference("ACTION_TYPE").display) + for found in client.discover("ACTION_TYPE"): + print(found.id, found.label, found.count) + for group in client.discover("GROUP"): + print(group.id, group.label) ``` +Sampling by hand is the same thing spelled out. Most rows are +workflow-generated steps rather than human notes — those carry an empty +`DONE_BY_ID`: + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + for action in client.iter_actions("YOUR_RFC_NUMBER"): + print(action.action_id, action.action_type_id, action.label, + action.done_by_id) +``` + +The **ids** are discoverable either way. Which one means "internal note" and +which means "customer comment" is a label a human still has to read: confirm it +with the EasyVista administrator, then pin the ids in your own configuration. + ## Procedure -1. List existing actions to learn the instance's action types and groups. -2. Build a `PostAction`: identify the type with `action_type_id` (or - `action_type_name`) and the assigned group with `group_id` (or - `group_name`); put the note in `description`. -3. `create_action(rfc, action)`. -4. To address the action you just created, diff `list_actions` across the - call — the create response cannot give you the id (see Gotchas). +1. Discover the instance's action type ids and group ids, with their labels + (see "Discover the ids first" above). Which id means "internal" is a + per-deployment configuration choice: confirm it with the EasyVista + administrator, then pin it in your own configuration. +2. Decide what the record IS, because it decides the call: + - **a comment** — something to be read — go to 3a; + - **work someone must still do** — go to 3b. +3. a. `create_task(rfc, PostTask(action_type_id=..., group_id=..., description=...))`. + This is the default and covers every comment, note and progress update. + A task is the same record as an action but born **ended**, so its text + appears in the ticket history immediately. `action_type_id` (or + `action_type_name`) and one of `group_id` / `group_name` / `group_mail` + are mandatory; `PostTask` refuses a body missing either locally, rather + than letting the server answer with a 590 that names no field. + + b. `create_action(rfc, PostAction(action_type_id=..., group_id=..., description=...))`. + Use it **only** for work still to be done. The action is created *open*, + 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. +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 `resolve_memo`, or take `get_ticket_context(rfc)` and read `context.actions`. +6. To edit a note afterwards, call `update_action(action_id, + ActionUpdate(description=...))` — by the action id alone, not the ticket. ## Examples +Posting a comment — the common case, and a **task**, not an action: + +```python +from easyvista_python_client import EasyvistaClient, PostTask + +with EasyvistaClient.from_env() as client: + client.create_task( + "YOUR_RFC_NUMBER", + PostTask( + action_type_id=1, + group_id=1, + description="Called the user back; printer power-cycled.", + ), + ) +``` + +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: + ```python from easyvista_python_client import EasyvistaClient, PostAction @@ -68,14 +380,15 @@ with EasyvistaClient.from_env() as client: PostAction( action_type_id=1, group_id=1, - description="Called the user back; printer power-cycled.", + description="Chase the supplier for a replacement drum.", ), ) print(action.href) ``` -`action_type_id=1` and `group_id=1` above are placeholders — use the ids the -discovery block printed for your instance. +`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. ```python from easyvista_python_client import EasyvistaClient, PostAction @@ -85,7 +398,9 @@ 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, description="Triaged.")) + client.create_action( + rfc, PostAction(action_type_id=1, group_id=1, description="Triaged.") + ) after = client.list_actions(rfc) created = [a for a in after if a.action_id not in before] print([a.action_id for a in created]) @@ -113,8 +428,44 @@ with EasyvistaClient.from_env() as client: print(action.action_id, action.description) ``` +```python +from easyvista_python_client import ActionUpdate, EasyvistaClient + +with EasyvistaClient.from_env() as client: + # Keyed on the action id alone -- NOT the ticket's rfc_number. + client.update_action( + 12345, ActionUpdate(description="Corrected: printer power-cycled twice.") + ) + # The PUT's own response body has never been captured, so do not read the + # returned Action's fields -- re-read instead. + print(client.get_action(12345).description) +``` + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + # Project timestamps and author onto the list in one request instead of + # an item fetch per action. + actions = client.list_actions( + "YOUR_RFC_NUMBER", + fields=["ACTION_ID", "ACTION_TYPE_ID", "CREATION_DATE_UT", "DONE_BY_ID"], + ) + for action in actions: + print(action.action_id, action.created_at, action.done_by_id) +``` + ## Gotchas +- **`PostAction` requires an action type and a group.** Tier 1 lists both as + required on the create-an-action route, so a body missing either is refused + at construction rather than drawing an HTTP 590 that names no field. Name the + type with `action_type_id` (preferred), `action_type_name` or + `action_type_guid`, and the group with `group_id`, `group_name` or + `group_mail` — any one of each is enough, and a field passed through + `extra_payload` counts. `PostTask` has always enforced the same rule on the + same vendor sentence. `parent_action_id` is also available, for an action of + a child type hanging off its parent. - **`create_action` gives you no usable id.** The live create response is an HREF naming the **parent request**, with no `ACTION_ID`. The model's href-derivation deliberately declines to fire (the tail is an RFC number, @@ -124,6 +475,22 @@ with EasyvistaClient.from_env() as client: alone has empty bodies. - `action.description` and `action.comment` are `str | dict | None`. Check the type before treating either as text. +- **A non-empty `description` hides `comment` from the UI.** The history shows + one text field per action — `DESCRIPTION`, falling back to `COMMENT` only + when `DESCRIPTION` is empty (measured 2026-09-01, one instance, 2025.3). So a + resolution written to `comment` beside a populated `description` is stored, + API-readable and unread. To reproduce what a person saw, take + `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. +- **`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. - `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 @@ -132,3 +499,36 @@ with EasyvistaClient.from_env() as client: - A profile restriction on the actions sub-resource surfaces as `EasyvistaAuthError` (403); the context bundle degrades to `[]` rather than failing. +- `update_action` takes only an `action_id`, no `rfc_number` — passing the + nested `requests/{rfc}/actions/{id}` shape (as `create_action` and + `list_actions` do) is not how this one works, and the nested form is + rejected with HTTP 403 anyway. +- `list_actions(fields=...)` has two silent footguns: `fields="*"` is not a + wildcard (it reduces to `ACTION_ID` alone), and a dotted path like + `"DESCRIPTION.HREF"` is silently dropped rather than raising. +- **`list_actions` returns ONE page and does not paginate.** The cap is + `config.default_max_rows`; a ticket with more actions than that is truncated + with **no error**, and the call discards the envelope's total so nothing in the + result reveals it. A freshly created ticket already carries about twelve + actions (most workflow-generated), so a busy ticket crosses a default cap + easily. The same cap truncates `get_ticket_context`'s `actions` and + `TicketContext.to_markdown()`'s rendered log — for a comment sync, that means + silently missing comments on exactly the busiest tickets. **Use + `iter_actions(rfc)`** when a whole log matters, or raise + `EasyvistaConfig.default_max_rows` to widen the single page. +- **`iter_actions`' pagination is not live-verified.** It assumes the + `offset`/`@next` contract every other search on this API follows, but unlike + `iter_tickets` that has not been measured on the `actions` endpoint. If the + endpoint ignores `offset`, page two repeats page one and the sweep never + terminates. Bound it with `max_records` the first time you point it at a + ticket whose action count you do not already know. +- **`update_action`'s return value is an unverified echo.** The PUT's response + body has never been captured, and the parser falls back to the raw body when + there are no records — so if the API answers empty or HREF-only, you get an + `Action` whose every field is `None`. Re-read with `get_action` instead of + reading fields off the returned object. +- `Action` names its timestamps `created_at`/`updated_at` where `Request` and + `Employee` use `creation_date_ut`/`last_update` for the identical wire + columns. `getattr(record, "last_update")` raises `AttributeError` on an + `Action`; for code spanning record types, go through `classify_fields()` or + `.reference()`, where the wire alias is uniform. diff --git a/skills/easyvista-ticket-workflow/SKILL.md b/skills/easyvista-ticket-workflow/SKILL.md index 5bdb604..7d7626d 100644 --- a/skills/easyvista-ticket-workflow/SKILL.md +++ b/skills/easyvista-ticket-workflow/SKILL.md @@ -5,7 +5,7 @@ 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." metadata: package: easyvista-python-client - version: "0.1.0" + version: "0.2.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, @@ -55,18 +55,36 @@ deployment needs before you build a payload for it. ## Procedure -1. Discover the ids (above). Never hardcode an id copied from another - instance — catalog codes, status/urgency/impact ids and group ids are all - instance-specific. -2. Build a `PostRequest`. `catalog_code` plus `title` is the practical - minimum; most catalogs also require `origin`, `department_id`, - `urgency_id` and `impact_id`. +1. Discover the ids. One call does it: `client.describe_instance()`, or + `client.discover("CATALOG_REQUEST")` / `discover("URGENCY")` / + `discover("IMPACT")` for one name at a time — see + `easyvista-instance-discovery`. Never hardcode an id copied from another + instance: catalog codes, status/urgency/impact ids and group ids are all + instance-specific, and adjacent numbers can mean opposite things. +2. Build a `PostRequest`. The subject is the only vendor-required part, given + either as `catalog_guid` — the vendor documents the **guid** as the + preferred identifier — or as `catalog_code`; a body with neither is refused + locally. Add `title`, and note that most catalogs also want `origin`, + `department_id`, `urgency_id` and `impact_id`: that fuller body was accepted + on every catalog tried on one instance, so it is a hedge against per-catalog + configuration rather than an API requirement. `PostRequest` declares the rest + of the vendor's create body too — `description`, `external_reference`, + `severity_id`, `recipient_id` / `recipient_mail` / `recipient_name` / + `recipient_identification`, `requestor_mail` / `requestor_name` / + `requestor_identification`, `location_id` / `location_code`, + `department_code`, `parentrequest`, `phone` and `submit_date` — so check the + model before reaching for an escape hatch. 3. Put instance-specific columns in `custom_fields`; they serialize with an - `e_` prefix unless already prefixed. + `e_` prefix unless already prefixed. For an **official** column a model does + not declare, use `extra_payload` instead — un-prefixed, merged last (a key + matching a declared one *ignoring case* replaces it), and not validated. 4. Call `create_ticket(ticket)`. It returns a `Request` whose `rfc_number` is usable immediately — see the first Gotcha for why. 5. To set body text you can read back afterwards, follow the create with - `update_ticket(rfc, RequestUpdate(description=...))`. + `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. 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 @@ -85,7 +103,7 @@ with EasyvistaClient.from_env() as client: PostRequest( catalog_code="YOUR_CATALOG_CODE", title="Printer offline on the third floor", - origin=1, + origin="Phone", # a channel NAME, not an id -- see below department_id=1, urgency_id=1, impact_id=1, @@ -96,9 +114,14 @@ with EasyvistaClient.from_env() as client: print(ticket.rfc_number) ``` -Every numeric id above is a placeholder — `origin=1`, `department_id=1`, -`urgency_id=1` and `impact_id=1` are not guaranteed to mean anything on your -instance. Use the ids the discovery block printed for it instead. +`department_id=1`, `urgency_id=1` and `impact_id=1` above are **placeholders** +and are not guaranteed to mean anything on your instance — use the ids +`client.describe_instance()` or the discovery block printed for it. + +`origin` is not an id at all: the vendor documents it as a channel **name** +(`"Phone"`, `"Email"`), which makes it the one create field with a portable, +human-readable form. An int id is also accepted (measured on one instance) and +passes through unchanged. ```python from easyvista_python_client import EasyvistaClient, RequestUpdate @@ -111,6 +134,21 @@ with EasyvistaClient.from_env() as client: print(updated.rfc_number) ``` +```python +from easyvista_python_client import EasyvistaClient, RequestUpdate + +with EasyvistaClient.from_env() as client: + # impact_id, owner_id and external_reference can all be changed after + # create, not only set at create time. external_reference is capped at + # 50 characters -- 51 raises pydantic's own ValidationError locally, + # before any request is sent. + updated = client.update_ticket( + "YOUR_RFC_NUMBER", + RequestUpdate(impact_id=1, owner_id=1, external_reference="TICKET-REF-0001"), + ) + print(updated.rfc_number) +``` + ```python from easyvista_python_client import EasyvistaClient, ev_equals_filter @@ -154,6 +192,22 @@ with EasyvistaClient.from_env() as client: ## Gotchas +- **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` + are parsed, so `json.dumps(ticket.model_dump(by_alias=True))` and + `json.dumps(ticket.classify_fields().official)` raise `TypeError`. For a dump, + use `model_dump(mode="json")`. `classify_fields()` takes **no arguments**, so + that keyword has nowhere to go there: render the values with + `format_ev_datetime` before serialising the bucket, or re-key a JSON-mode dump + by the bucket's keys — `dumped = ticket.model_dump(mode="json", + by_alias=True)`, then `{k: dumped[k] for k in ticket.classify_fields().official}`. + Only the + **declared** columns are parsed — an instance-specific date reached through + `classify_fields().custom` is still the raw string, so pass it through + `parse_ev_datetime` before comparing the two. No write model accepts a + `datetime`, `custom_fields` included: a `datetime` there fails inside the HTTP + layer with a bare `TypeError`, so render it yourself. - `create_ticket`'s response body is **HREF-only** — the API returns no `RFC_NUMBER`. `Request` derives `rfc_number` from the trailing path segment of the `href` (its own model validator does this, and it is checked against @@ -194,3 +248,9 @@ with EasyvistaClient.from_env() as client: the catalog is misconfigured is a reasonable next thing to try. - The accepted **write** format for the date fields is unverified; both a string and an int probe returned 590. Do not attempt to set them. +- `RequestUpdate` deliberately does **not** expose `severity_id` (`SEVERITY_ID` + is rejected with HTTP 590) or a priority field (EasyVista derives priority + from urgency x impact rather than exposing a writable column). `urgency_id` + is also absent: it raised HTTP 590 while still changing the stored value on + the verified instance, so it is not offered until that is resolved (open + item `O-590-PARTIAL`). diff --git a/unasync_build.py b/unasync_build.py index 9728472..de3fe73 100644 --- a/unasync_build.py +++ b/unasync_build.py @@ -80,12 +80,36 @@ #: * **Third-party naming.** unasync's built-in ``Async*`` -> ``Sync*`` #: convention would produce ``SyncClient``, which does not exist; the real #: httpx name is ``Client``. Same for tenacity's ``AsyncRetrying``. +#: * **httpx's own async method names**, which are spelled with a leading +#: ``a`` rather than the ``Async`` prefix the convention knows about, so +#: nothing infers them. ``aclose``, ``aread`` and ``aiter_bytes`` all have +#: sync twins in httpx that differ only by that letter, and an unmapped one +#: is emitted verbatim into the sync tree. Only ``aclose`` **on the client** +#: then fails loudly: ``httpx.Client`` has no ``aclose``, so that line raises +#: ``AttributeError``. Every other case is *silent*, which is the real reason +#: this dict has to cover them all -- httpx defines both spellings on +#: ``httpx.Response`` (checked against 0.28.1: ``aread``, ``aiter_bytes`` and +#: ``aclose`` are all present on the one class), so the attribute exists in +#: sync code and merely does the wrong thing. ``response.aread()`` without an +#: ``await`` builds a coroutine and drops it -- a ``RuntimeWarning`` and an +#: unread body, after which the next line's ``_raise_for_response`` raises +#: ``httpx.ResponseNotRead`` instead of the mapped EasyVista exception; and +#: ``for chunk in response.aiter_bytes(...)`` raises ``TypeError: +#: 'async_generator' object is not iterable``. A wrong exception escaping the +#: client, not a missing attribute -- exactly the silent-collision class +#: ``testing/test_unasync_codegen.py`` says a diff of the two trees cannot +#: catch, so a new httpx async method must be added here rather than trusted +#: to blow up on its own. #: * **This package's own public class name**, which differs between the two -#: surfaces by design, and ``aclose``, which is public async API. +#: surfaces by design. ``aclose`` is in both categories: httpx's method and +#: this package's public async API. #: #: Everything else -- helpers, module names, the executor's methods -- is #: spelled *identically* in both trees. Keeping this list short is #: deliberate: every entry is a chance for a silent collision. +#: ``testing/test_unasync_codegen.py`` scans the async tree for identifiers +#: that would be rewritten by any of these, so a local or a parameter that +#: happens to share a spelling fails there rather than in production. TOKEN_REPLACEMENTS = { # Intra-tree imports are absolute, so the package segment is itself a # NAME token and rewriting it repoints every one of them at the @@ -95,6 +119,12 @@ "AsyncClient": "Client", "AsyncRetrying": "Retrying", "aclose": "close", + # The streaming download path: `Response.aiter_bytes` yields the body in + # chunks and `Response.aread` materialises it, the latter needed on the + # error path because a streaming response refuses `.content` until it has + # been read. + "aread": "read", + "aiter_bytes": "iter_bytes", } #: The qualified package prefix, and what it becomes in the generated tree.