From f7564f6bfa755a153df77753761ab8b6c2bcbe70 Mon Sep 17 00:00:00 2001 From: baraline Date: Wed, 2 Sep 2026 22:21:54 +0200 Subject: [PATCH] feat(actions)!: declare the effort columns, and settle task vs action A GLPI comment corresponds to an EasyVista TASK, not an action. Actions carry effort and cost, so a timeline mirrored without that distinction imports time-tracking and workflow rows as conversation. This makes the distinction expressible on the read side. BREAKING: ELAPSED_TIME, TIME_COST, CONTRACTUAL_COST, START_DATE_UT and END_DATE_UT move from extra="allow" extras to declared fields on Action. action.ELAPSED_TIME now raises AttributeError (use action.elapsed_time), the keys leave model_extra, and a by-alias dump yields int/Decimal/datetime rather than str. A dependant pinned >=0.2.0,<0.3 must widen deliberately, which is the point: the retyping can change an answer silently rather than raise. The "" sentinel and "0" stay different answers -- None for "", 0 and Decimal("0.00") for the zeroes -- because collapsing them destroys the only signal saying whether a record tracks effort at all. Costs parse from the API's decimal comma into exact Decimals; a grouping separator or three-or-more fraction digits is refused rather than guessed at, since '1.234,56' and '1,234.56' are the same amount under opposite conventions. Magnitude is not a trigger. None on these columns is ambiguous -- the default list_actions row omits all five, so None there means "not projected", not "does not apply". Adds Action.is_workflow_generated (WORKFLOW_ID set; clean on 1500/1500 measured rows) and deliberately NO is_task(). No column examined says which route created a record, and the effort-shape heuristic is refuted in both directions: 39 of 49 public comments carried a non-empty ELAPSED_TIME, one with a real TIME_COST, while 173 workflow rows carried an empty one. Which action types count as conversation is per-deployment policy and stays with the caller. Records as tier 2, on two 2025.3 deployments, that /requests/{rfc_number}/tasks is POST-only with no task read route under any spelling -- so create_task returns an Action and there is deliberately no list_tasks. Side finding, tier 4: ACTION_LABEL_* is the workflow STEP's label, not the action type's name. Also adds scripts/tests/test_no_private_instance_identifiers.py. Preparing this release put a real preprod hostname into docs/vendor-api-reference.md -- tracked, public, and shipped in the sdist -- with all five gates passing, because none of them reads prose. Both a push and a PyPI upload are irreversible, so the check is now mechanical. It stores SHA-256 digests rather than a plaintext needle list: a grep guard must contain its needles, and the first draft of this one published the hostname it existed to protect, in a module exempted from its own check. With digests it needs no exemption and scans itself. Version 0.3.0. Corrects the CHANGELOG compare links, which pointed at a v0.2.0 tag that does not exist, and docs/publishing.rst, which ordered a v-prefixed tag the repository has never used. The tag defines a version; the prose is corrected to match the tags. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 129 ++++++++++- README.md | 5 +- docs/publishing.rst | 22 +- docs/user_guide.rst | 30 ++- docs/vendor-api-reference.md | 131 +++++++++++ easyvista_python_client/_async/client.py | 21 ++ easyvista_python_client/_sync/client.py | 21 ++ easyvista_python_client/_version.py | 2 +- easyvista_python_client/models/action.py | 112 +++++++++ easyvista_python_client/models/common.py | 84 +++++++ .../models/tests/test_action.py | 214 ++++++++++++++++++ easyvista_python_client/references.py | 10 +- .../testing/test_public_api.py | 2 +- .../test_live_action_effort_shape.py | 153 +++++++++++++ pyproject.toml | 2 +- .../test_no_private_instance_identifiers.py | 205 +++++++++++++++++ scripts/tests/test_source_citations.py | 5 + skills/easyvista-asset-workflow/SKILL.md | 2 +- skills/easyvista-client-setup/SKILL.md | 2 +- skills/easyvista-directory/SKILL.md | 2 +- skills/easyvista-document-workflow/SKILL.md | 2 +- skills/easyvista-instance-discovery/SKILL.md | 2 +- .../easyvista-reporting-and-context/SKILL.md | 2 +- skills/easyvista-search-syntax/SKILL.md | 2 +- skills/easyvista-ticket-actions/SKILL.md | 56 ++++- skills/easyvista-ticket-workflow/SKILL.md | 2 +- 26 files changed, 1190 insertions(+), 30 deletions(-) create mode 100644 integration_tests/test_live_action_effort_shape.py create mode 100644 scripts/tests/test_no_private_instance_identifiers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c4909d..74596ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,124 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -While the package is pre-1.0, breaking changes may land between minor versions; -a deprecation policy will follow the 1.0 release. +While the package is pre-1.0, breaking changes may land between **minor** +versions; a deprecation policy will follow the 1.0 release. A patch release +never carries one. Each breaking change is marked `**BREAKING**` in its section +with the reasoning, so read the section for the version you are moving to. + +**The git tag is what defines a version.** Where a section's narrative or date +disagrees with the tree its tag points at, the tag is authoritative and the prose +is the error. Tags carry no `v` prefix. ## [Unreleased] +## [0.3.0] - 2026-09-02 + +Makes the task-vs-action distinction inspectable. A GLPI comment corresponds to +an EasyVista **task**, not an action; actions carry effort and cost, so a +timeline mirrored without that distinction imports time-tracking and workflow +rows as conversation. + +**Upgrading.** One breaking change, in `Action` (see `### Changed`). The minor +bump is deliberate: a dependant pinned `>=0.2.0,<0.3` does **not** pick this up, +and must widen its constraint on purpose — which is the point, because the +retyping below can change an answer silently rather than raise. + +Before widening, check whether you read `action.ELAPSED_TIME`, `TIME_COST`, +`CONTRACTUAL_COST`, `START_DATE_UT` or `END_DATE_UT` off `model_extra`, or read +those keys out of a `model_dump(by_alias=True)` and treated them as strings. Both +change here. Nothing on the write side moves: `PostAction`, `PostTask` and +`ActionUpdate` are untouched. + +### Added + +- Five columns declared on `Action` with real types: `elapsed_time` (minutes, + `int | None`), `time_cost` and `contractual_cost` (exact `Decimal | None`, + parsed from the API's French decimal comma), `start_date_ut` and + `end_date_ut` (aware `datetime | None`). They previously arrived only as + untyped `extra="allow"` strings. + + **The `""` sentinel and `"0"` stay different answers.** `""` means the column + does not apply to this record and maps to `None`; `"0"` / `"0,00"` means it + applies and is zero, and maps to `0` / `Decimal("0.00")`. Collapsing the two + destroys the only signal that says whether a record tracks effort. Measured + over 1500 live rows: `ELAPSED_TIME` was `""` on 384 and `"0"` on 895. +- `Action.is_workflow_generated` — whether `WORKFLOW_ID` is set, i.e. whether + the workflow engine owns the row. Clean on 1500/1500 measured rows, and the + one structural fact in this area that holds. + + It is **not** an `is_task()`, and the package deliberately does not ship one: + nothing on an action record says which route created it, and the + effort-shape heuristic that looks like it should is wrong in both directions + — 173 of 1500 rows had a `WORKFLOW_ID` with an empty `ELAPSED_TIME`, and 39 + of 49 public comments carried a non-empty one, one of them with a real + `TIME_COST` of `99,00`. Which action types count as conversation is + per-deployment policy and stays with the caller. +- `scripts/tests/test_no_private_instance_identifiers.py` — a guard refusing any + private EasyVista instance identifier (hostname, domain, real catalog GUID or + code) in a tracked file. `.gitignore` has always stated that policy in prose; + it had no enforcement, and preparing this release put a real preprod hostname + into `docs/vendor-api-reference.md` — a tracked file that also ships in the + sdist — with all five gates passing, because none of them reads prose for + this. Both a public push and a PyPI upload are irreversible, so the check is + now mechanical. It carries its own negative control, and deliberately does + **not** refuse the illustrative account number or the synthetic + `EAZ_INC_000` stand-in the tracked tests already use. +- `models.common.OptionalDecimal`, the annotated type behind the two cost + columns. Accepts either decimal separator, so a dot-configured deployment + needs no setting, and **refuses a grouping separator** rather than guessing + — `'1.234,56'` and `'1,234.56'` are the same amount under opposite + conventions. Not exported from the package root. + +### Changed + +- **BREAKING** — `ELAPSED_TIME`, `TIME_COST`, `CONTRACTUAL_COST`, + `START_DATE_UT` and `END_DATE_UT` are now declared fields on `Action` instead + of `extra="allow"` extras. Three consequences: + - `action.ELAPSED_TIME` (extra attribute access) raises `AttributeError`; use + `action.elapsed_time`. The same for the other four. + - They are gone from `action.model_extra`, and + `model_dump(by_alias=True)["ELAPSED_TIME"]` is now an `int | None` rather + than a `str`. + - `classify_fields()` bucketing is **unchanged** — none of the five starts + with `E_`, so all five have always landed in `.official`. What changed is + *presence*: `.official` (and the dump) now always carries all five keys, + `None` included, where previously a key the API did not return was simply + absent. Code that iterates `.official` sees five more keys on a default + `list_actions` row. + - **`None` is now ambiguous.** These columns are not on the default + `list_actions` projection, so on a default row every one reads `None`, + meaning *not returned* — not the `""` that means *does not apply*. The two + are indistinguishable once validated. Project them explicitly or read + item-level before reading meaning into a `None`. + - A `Decimal` in a dump is not JSON-serialisable, so + `json.dumps(action.model_dump(by_alias=True))` raises where a cost is + populated. Use `model_dump(mode="json")`, which renders the amount as a + string with a `.` decimal point rather than the `,` the API sent. + - A malformed value now raises where it previously passed through as a + string. This is the same trade `OptionalDateTime` already makes — a wrong + number is worse than a loud failure — and it carries the same blast radius: + the descriptor validates a page in a list comprehension, so one bad value + fails a whole `list_actions` call. See `O-COSTGROUP` in + `docs/vendor-api-reference.md`. + +### Documentation + +- `docs/vendor-api-reference.md` records, as tier 2 read on two 2025.3 + deployments on 2026-09-02, that `/requests/{rfc_number}/tasks` is **POST + only** and that **no task read route exists** under any spelling — the only + timeline reads are `GET /actions`, `/actions/{id}` and + `/actions/{id}/{comment}`. A task is written as a task and read back as an + action, which is why there is no `list_tasks`/`iter_tasks` and why + `create_task` returns an `Action`. `create_task`'s docstring now says so + where a reader meets it. +- Same file, tier 4: the effort-column measurements above, and two side + findings. `ACTION_LABEL_*` is the label of the workflow **step**, not the + name of the action type — type 20 appeared under six different labels — so + it is a stable type name only for the non-workflow types. And types 14, 27 + and 28 have an empty label in every language column at both list and item + level, so those ids cannot be named through the API at all (`O-ACTIONTYPE28`). + ## [0.2.0] - 2026-09-02 The first release since `0.1.0`. A `0.2.0` section was prepared and dated @@ -1002,6 +1115,13 @@ the release is additive. Initial public release. +> **The tag is authoritative, and it disagrees with this date.** The `0.1.0` tag +> points at `3216a33` (2026-08-04), roughly 150 commits after `6df6a75`, the +> commit this section was written to describe. Per the policy above, `0.1.0` +> **is** the tree at `3216a33`; the date on this heading and the scope below +> under-describe it. The tag is published, so it is not being moved — this note +> exists so the discrepancy is recorded rather than rediscovered. + ### Added - Synchronous `EasyvistaClient` and asynchronous `AsyncEasyvistaClient` over the @@ -1022,6 +1142,7 @@ 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.2.0...HEAD -[0.2.0]: https://github.com/baraline/easyvista_python_client/compare/0.1.0...v0.2.0 +[Unreleased]: https://github.com/baraline/easyvista_python_client/compare/0.3.0...HEAD +[0.3.0]: https://github.com/baraline/easyvista_python_client/compare/0.2.0...0.3.0 +[0.2.0]: https://github.com/baraline/easyvista_python_client/compare/0.1.0...0.2.0 [0.1.0]: https://github.com/baraline/easyvista_python_client/releases/tag/0.1.0 diff --git a/README.md b/README.md index aba3a7b..08ab850 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,10 @@ 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, breaking changes may land between -minor versions; a deprecation policy will follow the 1.0 release. +**minor** versions; a deprecation policy will follow the 1.0 release. A patch +release never carries one. Each breaking change is marked `**BREAKING**` in its +`CHANGELOG.md` section with the reasoning, so read the section for the version +you are moving to. ## Documentation diff --git a/docs/publishing.rst b/docs/publishing.rst index eaf4770..e9ec6e6 100644 --- a/docs/publishing.rst +++ b/docs/publishing.rst @@ -27,11 +27,23 @@ Cutting a release #. 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 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.) +#. Publish a GitHub release whose tag is the version, **unprefixed** -- ``0.3.0`` + for version ``0.3.0``, never ``v0.3.0``. + + .. warning:: + + This step previously ordered a ``v``-prefixed tag and said "``v``-prefixing + starts at ``v0.2.0``". That never happened: **both tags that exist are + bare** -- ``0.1.0`` and ``0.2.0`` -- so following the old instruction would + have produced a repository with two tag conventions. It also left the + ``CHANGELOG.md`` compare links pointing at a ``v0.2.0`` that does not + exist, so two of them 404ed until 0.3.0 fixed them. + + **The tag is what defines a version**; the prose is corrected to match the + tags, not the other way round. Keep every tag bare, and keep the + ``CHANGELOG.md`` links bare with it. (The release workflow strips a leading + ``v`` before comparing, so a prefixed tag would still *build* -- which is + exactly why this drifted unnoticed.) 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 diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 68f89ec..a8481a1 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -1160,7 +1160,8 @@ 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 +``Action.updated_at`` / ``Action.start_date_ut`` / ``Action.end_date_ut`` 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 @@ -1193,14 +1194,29 @@ is still the raw wire string, so within one record dump ``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 + pass ``model_dump(mode="json")``. + + **Since 0.3.0 an ``Action`` can also carry a ``Decimal``** — ``time_cost`` + and ``contractual_cost`` — which raises the same way + (``TypeError: Object of type Decimal is not JSON serializable``). + ``model_dump(mode="json")`` handles it too, rendering the amount as a string. + + ``classify_fields()`` takes **no arguments**, so there is nowhere to put that + keyword. The only recipe that covers *both* types is to 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. + .. warning:: + + Rendering the bucket with + :func:`~easyvista_python_client.format_ev_datetime` — which earlier + revisions of this page offered as the alternative — **only ever handled + ``datetime``**, and now leaves a ``Decimal`` in place to raise on + ``json.dumps``. Use the JSON-mode dump above instead. Note the amount then + renders with a ``.`` decimal point, not the ``,`` the API sent. + 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` diff --git a/docs/vendor-api-reference.md b/docs/vendor-api-reference.md index c14a509..ba4a65a 100644 --- a/docs/vendor-api-reference.md +++ b/docs/vendor-api-reference.md @@ -104,6 +104,114 @@ and not proof of it; it omits `group_id`, `group_name` and `comment`, which it lists `available_field_1`/`_6`, which `PostTask` does not declare and which `extra_payload` reaches. +### A task is write-only as a resource, and read back as an action + +**Tier 2, read 2026-09-02** on the development instance (100 paths), and +independently the same day on a second deployment (also 2025.3, also 100 +paths). Both declare exactly: + +| Path | Verbs | +| --- | --- | +| `/requests/{rfc_number}/tasks` | **POST only** | +| `/requests/{rfc_number}/actions` | POST only | +| `/actions` | GET | +| `/actions/{id}` | GET, PATCH, PUT | +| `/actions/{id}/{comment}` | GET | + +There is **no read route for a task** — no `GET /requests/{rfc}/tasks`, no +`/tasks/{id}`, nothing under any other spelling. The only timeline reads are +the three `/actions` routes. So a task is *written* through `tasks` and *read +back* through `actions`, and that is the whole story: a task and an action are +the same row in the same table, differing only in the state they are born in +(open vs already ended). This is why the package has `list_actions` and +deliberately **no `list_tasks`/`iter_tasks`** — there is no route to wrap. + +A GET against the tasks path answers `403 "Unauthorized Method for your +profile"`, which per *Route topology* above proves nothing either way; the +spec's `paths` is what settles it. + +**`create_task` returns an `Action`.** That is where a reader first meets the +confusion, and the annotation is correct rather than sloppy: there is no task +resource to model, so there is no `Task` read model and could not be one. + +### The effort columns, and why they do not discriminate task from action + +Five columns on an action record — `ELAPSED_TIME`, `TIME_COST`, +`CONTRACTUAL_COST`, `START_DATE_UT`, `END_DATE_UT` — are declared on `Action` +as of 0.3.0. Until then they arrived only as `extra="allow"` extras: untyped +strings, with a French decimal comma on the two costs. + +**Tier 4, measured 2026-09-02, 1500 action rows on the development instance** +(one instance, one date, so it may not generalise), corroborated by an +independent measurement the same day on that second deployment (1465 timeline +entries across 120 tickets), which agreed on every point below. + +**`""` and `"0"` are different answers.** `""` means the column does not apply +to this record; `"0"` (or `"0,00"`) means it applies and is zero. + +| Column | `""` | zero | non-zero | +| --- | --- | --- | --- | +| `ELAPSED_TIME` | 384 | 895 (`'0'`) | 221 | +| `TIME_COST` | 691 | 808 (`'0,00'`) | 1 (`'99,00'`) | +| `CONTRACTUAL_COST` | 691 | 808 (`'0,00'`) | 1 (`'129,00'`) | + +A parser that maps both to `0`, or both to `None`, destroys the only signal +that says whether a record tracks effort. `Action` preserves it: `None` for +`""`, `0` / `Decimal("0.00")` for the zeroes. + +**The shape heuristic is false in both directions.** It is tempting to read +"workflow rows carry `WORKFLOW_ID`/`STAGE_ID` with `ELAPSED_TIME='0'` and +`'0,00'` costs, task-shaped rows carry none of it and empty effort" as a +task/action discriminator. Measured, it fails both ways: + +* **173 of 1500** rows carried a `WORKFLOW_ID` *and* an empty `ELAPSED_TIME` + — 126 of them the type-20 `Analyse et résolution` workflow step. So + "workflow row ⇒ effort is `'0'`" is false. +* **171 of 1500** rows carried no `WORKFLOW_ID` *and* a non-empty + `ELAPSED_TIME`. Among them **39 of the 49** type-94 `Commentaire [Public]` + rows — ordinary public comments — usually with `ELAPSED_TIME='1'`. One + public comment carried `ELAPSED_TIME='12'`, `TIME_COST='99,00'` and + `CONTRACTUAL_COST='129,00'`. So "effort recorded ⇒ not a comment" is false, + and a filter built on it drops four public comments in five. + +`ACTION_TYPE_ID` alone does not discriminate either, which the second +deployment measured directly: type 94 appeared in both shapes there (74 rows +with a `PARENT_ACTION_ID`, 18 without; 37 with a non-zero `ELAPSED_TIME`, 55 +without). + +What an effort column reports is **whether effort was recorded**, not what kind +of record this is. No column examined across those 1500 rows recorded which +route created it — stated as a measurement, not as a proof of absence: the +item-level record carries 88 columns, not all of which were tallied, and a +deployment may populate one this instance leaves empty. If you find a column +that does discriminate, it belongs here. + +**What *is* clean: `WORKFLOW_ID`.** 1500/1500 rows — a `WORKFLOW_ID` is set iff +the workflow engine produced the row. No row of the conversation types (94 +`Commentaire [Public]`, 95 `Note Interne [Privé]`, 7 `Appel`) carried one. +`Action.is_workflow_generated` exposes exactly that and nothing more. Deciding +which of the remaining types count as conversation is per-deployment policy — +an `action_type_id` allowlist — and stays with the caller. + +**Side finding, tier 4, same measurement: `ACTION_LABEL_*` is the label of the +workflow *step*, not the name of the action type.** Type 20 appeared as +`Analyse et résolution` (126 rows), `Traitement` (10), `Traitement du refus`, +`Traitement de la demande`, `test` and `notif`; type 30 as `stocker le groupe +d'implémentation`, `Mise à jour SLA` and `sauvegarde`; type 82 under two +labels. So for **workflow** types the label varies row to row and cannot be +used as a type name. For the non-workflow types (94, 95, 7) it was stable and +is the type's real name. This qualifies the *Visibility is by action type* +note: `discover("ACTION_TYPE")` recovers real names for the human types, and +per-step text for the workflow ones. + +**Types 14, 27 and 28 have an empty `ACTION_LABEL_*` in every language column** +— all six on the list projection and all twelve (`_EN`, `_FR`, `_GE`, `_IT`, +`_PO`, `_SP`, `_L1`..`_L6`) on the item GET. There is no `action-types` route +to ask, so on this deployment those ids **cannot be named through the API at +all**. What is known about 28 is behavioural, not nominal: it is the row that +carries the text passed to `set_status(comment=...)`, so it must not be +filtered out of a timeline read. + 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 @@ -143,8 +251,10 @@ 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 | +| `/requests/{rfc_number}/tasks` | POST | create-only; **no task read route exists** — read them back through `/actions` | | `/actions` | GET | the only action list | | `/actions/{id}` | GET, PATCH, PUT | the only action item; **no DELETE** | +| `/actions/{id}/{comment}` | GET | `{comment}` is a memo-field *selector*, not a literal | | `/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"` | @@ -217,6 +327,27 @@ The vendor documents `catalog_guid` as the *preferred* identifier (tier 1) and 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-COSTGROUP** — `TIME_COST` / `CONTRACTUAL_COST` are parsed by + `models/common._parse_ev_decimal`, which accepts either decimal separator and + **refuses a grouping separator** rather than guessing (`'1.234,56'` and + `'1,234.56'` are the same amount under opposite conventions). Every amount + observed live had exactly two fraction digits and no grouping (1500 rows, + 2026-09-02), so the refusal has never fired. It **also** refuses three or more + fraction digits, for the same ambiguity (`'1,234'` could be `1.234` or a + comma-grouped `1234`) — which means a genuinely 3-decimal currency is refused + too. **Magnitude is not a trigger**: `'1000,00'` parses fine, since it carries + no grouping separator. Because the descriptor validates a page in a list + comprehension, a refusal fails a whole `list_actions` call, not one row. If a + refused literal is ever seen, record it here and widen the pattern with + evidence. +* **O-ACTIONTYPE28** — types 14, 27 and 28 have an empty `ACTION_LABEL_*` in + every language column at both list and item level, and there is no + `action-types` route, so nothing in the API can name them. Type 28 is known + behaviourally (it carries `set_status(comment=...)` text) and 14 and 27 not + at all. Settling this needs the EasyVista **admin console**, not the API: the + administration screen listing action types, and specifically which type ids + that deployment classes as *task* types. Nobody working on this package has + console access; if you do, transcribe the list here. * **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** diff --git a/easyvista_python_client/_async/client.py b/easyvista_python_client/_async/client.py index a2a9d92..6d75598 100644 --- a/easyvista_python_client/_async/client.py +++ b/easyvista_python_client/_async/client.py @@ -629,6 +629,27 @@ async def create_task(self, rfc_number: str, task: PostTask) -> Action: no ``parent_action_id`` and works on a ticket at any stage, including one whose open actions a status change has already drained. + **This returns an** :class:`~easyvista_python_client.Action`, and that + is not a mistake in the annotation: a task *is* an action record, and + there is no separate task resource to model. ``tasks`` is a create-only + route — the instance's own OpenAPI declares + ``POST /requests/{rfc_number}/tasks`` and no other verb on it, and + declares no read route for a task anywhere (tier 2, read 2026-09-02 on + one deployment, 100 paths, and independently on a second the same day). + The only timeline reads are ``GET /actions``, ``GET /actions/{id}`` and + ``GET /actions/{id}/{comment}``, so **a task is written as a task and + read back as an action** — which is why this package has + :meth:`list_actions` and no ``list_tasks``. A GET against the tasks + path answers 403, and per + ``docs/vendor-api-reference.md`` no 403 on this API distinguishes an + absent route from a denied one, so the spec is what settles it. + + A corollary worth stating, because it is where the read side goes + wrong: **once written, nothing on the record says which route created + it.** In particular the effort columns do not — see + :attr:`~easyvista_python_client.Action.is_workflow_generated` for the + measurements that refute the tempting heuristic. + 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 diff --git a/easyvista_python_client/_sync/client.py b/easyvista_python_client/_sync/client.py index 7aaace4..225290d 100644 --- a/easyvista_python_client/_sync/client.py +++ b/easyvista_python_client/_sync/client.py @@ -629,6 +629,27 @@ def create_task(self, rfc_number: str, task: PostTask) -> Action: no ``parent_action_id`` and works on a ticket at any stage, including one whose open actions a status change has already drained. + **This returns an** :class:`~easyvista_python_client.Action`, and that + is not a mistake in the annotation: a task *is* an action record, and + there is no separate task resource to model. ``tasks`` is a create-only + route — the instance's own OpenAPI declares + ``POST /requests/{rfc_number}/tasks`` and no other verb on it, and + declares no read route for a task anywhere (tier 2, read 2026-09-02 on + one deployment, 100 paths, and independently on a second the same day). + The only timeline reads are ``GET /actions``, ``GET /actions/{id}`` and + ``GET /actions/{id}/{comment}``, so **a task is written as a task and + read back as an action** — which is why this package has + :meth:`list_actions` and no ``list_tasks``. A GET against the tasks + path answers 403, and per + ``docs/vendor-api-reference.md`` no 403 on this API distinguishes an + absent route from a denied one, so the spec is what settles it. + + A corollary worth stating, because it is where the read side goes + wrong: **once written, nothing on the record says which route created + it.** In particular the effort columns do not — see + :attr:`~easyvista_python_client.Action.is_workflow_generated` for the + measurements that refute the tempting heuristic. + 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 diff --git a/easyvista_python_client/_version.py b/easyvista_python_client/_version.py index b465765..1f331bf 100644 --- a/easyvista_python_client/_version.py +++ b/easyvista_python_client/_version.py @@ -5,4 +5,4 @@ live only in ``__init__``. """ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/easyvista_python_client/models/action.py b/easyvista_python_client/models/action.py index 069cd66..a3a7089 100644 --- a/easyvista_python_client/models/action.py +++ b/easyvista_python_client/models/action.py @@ -11,6 +11,7 @@ EasyvistaModel, EasyvistaWriteModel, OptionalDateTime, + OptionalDecimal, OptionalInt, _shipped_keys, ) @@ -42,6 +43,42 @@ class Action(EasyvistaModel): 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. + **Effort columns: ``""`` and ``"0"`` are different answers.** ``elapsed_time`` + (minutes), ``time_cost``, ``contractual_cost``, ``start_date_ut`` and + ``end_date_ut`` are declared as of 0.3.0, having previously reached callers + only as untyped ``extra="allow"`` strings. When the API **returns** the + column, ``""`` means it does not apply to this record and ``"0"`` / + ``"0,00"`` means it applies and is zero (tier 4, measured 2026-09-02 over + 1500 rows on two instances -- so it may not generalise). This model preserves + that distinction deliberately -- ``None`` for ``""``, ``0`` / + ``Decimal("0.00")`` for the zeroes -- because collapsing the two destroys the + only signal saying whether a record tracks effort at all. The two cost columns + arrive with a French decimal comma (``'99,00'``) and parse to an exact + :class:`~decimal.Decimal`. Either decimal separator is accepted; a grouping + separator and three-or-more fraction digits are **refused** rather than + guessed at, because ``'1.234,56'`` and ``'1,234.56'`` are the same amount + under opposite conventions. Magnitude is not a trigger -- ``'1000,00'`` + parses. The parser is ``models/common.py::_parse_ev_decimal``, named as a + path rather than cross-referenced because it is private and carries no + rendered API-reference page. + + .. warning:: + + **``None`` is ambiguous, and the default list row is the trap.** None of + these five columns rides the default ``list_actions`` projection, so on a + default list row every one of them reads ``None`` -- meaning *not + returned*, not *does not apply*. The two are indistinguishable on the + model. Project them explicitly (``list_actions(rfc, fields=[..., + "ELAPSED_TIME", "WORKFLOW_ID"])``) or read item-level with + :meth:`~easyvista_python_client.EasyvistaClient.get_action` before reading + any meaning into a ``None``. To tell the two apart from a raw response, + check whether the key is present at all -- once validated, that is gone. + + What that signal does **not** settle is whether a row was created as a task + or as an action -- nothing on the record does, and the effort-shape heuristic + that looks like it should is measurably wrong in both directions. See + :attr:`is_workflow_generated`. + 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 @@ -103,6 +140,29 @@ class Action(EasyvistaModel): 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") + # --- effort and cost (EV-TASKSHAPE, added 0.3.0) ------------------------- + # Until 0.3.0 these five reached callers only as untyped ``extra="allow"`` + # strings. **The "" sentinel and "0" are different answers** -- "" means the + # column does not apply to this record, "0" that it applies and is zero -- + # and ``OptionalInt``/``OptionalDecimal`` preserve exactly that: ``None`` for + # "", ``0`` for "0". Measured over 1500 live rows 2026-09-02, ELAPSED_TIME + # was "" on 384 and "0" on 895; the costs were "" on 691 and "0,00" on 808. + # + # ``elapsed_time`` is in MINUTES, is never derived from the window, and is + # stored verbatim even when it contradicts it -- but a zero-length window + # (``start_date_ut == end_date_ut``) stores 0 whatever was sent. + # + # Named for the wire (``start_date_ut``/``end_date_ut``) to match + # ``Request.end_date_ut``, rather than following this model's own + # ``created_at``/``updated_at``, which the class docstring flags as a wart. + # Both are the ACTION's effort window: unlike ``Request.end_date_ut``, which + # is stamped at RESOLUTION, an action's ``END_DATE_UT`` is set when the + # action itself is ended. + elapsed_time: OptionalInt = Field(default=None, alias="ELAPSED_TIME") + time_cost: OptionalDecimal = Field(default=None, alias="TIME_COST") + contractual_cost: OptionalDecimal = Field(default=None, alias="CONTRACTUAL_COST") + start_date_ut: OptionalDateTime = Field(default=None, alias="START_DATE_UT") + end_date_ut: OptionalDateTime = Field(default=None, alias="END_DATE_UT") @model_validator(mode="after") def _derive_action_id_from_href(self) -> Action: @@ -150,6 +210,58 @@ def label(self) -> str | None: """ return localized_label(self.model_dump(by_alias=True), "ACTION_LABEL") + @property + def is_workflow_generated(self) -> bool: + """Whether the workflow engine owns this row, i.e. ``WORKFLOW_ID`` is set. + + A ticket's catalog workflow auto-spawns its own action rows -- about a + dozen on a freshly created ticket -- and each carries a ``WORKFLOW_ID`` + naming the workflow instance. Rows created by a person or by an API + caller do not. Measured 2026-09-02 over 1500 live action rows on one + instance (tier 4, so it may not generalise): no row of the + conversation-bearing types (94 ``Commentaire [Public]``, 95 ``Note + Interne [Prive]``, 7 ``Appel``) carried one, and every row of the + workflow step types (1, 20, 21, 23, 30, 32, 33, 50, 65, 82, 84) that + the engine had produced did. + + .. warning:: + + **This is not a task/action discriminator, and there is none.** A + task (``create_task``) and an action (``create_action``) are the same + record in the same table, told apart only by the state they are born + in -- open versus already ended -- and **neither ``WORKFLOW_ID`` nor + the effort columns recover which route created a row**. Measured + 2026-09-02, both directions of the tempting shape heuristic fail on + one instance: + + * 173 of 1500 rows had a ``WORKFLOW_ID`` **and** an empty + ``ELAPSED_TIME`` -- 126 of them the type-20 ``Analyse et + resolution`` workflow step. So "workflow row => effort is 0" is + false. + * 39 of 49 type-94 ``Commentaire [Public]`` rows -- ordinary public + comments -- carried a **non-empty** ``ELAPSED_TIME``, usually + ``'1'``; one carried ``ELAPSED_TIME='12'``, ``TIME_COST='99,00'`` + and ``CONTRACTUAL_COST='129,00'``. So "effort set => not a + comment" is false, and a filter built on it drops four public + comments in five. + + What an effort column reports is whether *effort was recorded*, not + what kind of record this is. Deciding which action types count as + conversation is per-deployment policy: pin the ``action_type_id`` + allowlist your administrator confirms, and use this property only to + exclude the workflow engine's own rows. See + ``docs/vendor-api-reference.md`` for the measurements. + + A plain property, not a serialized field, so it never appears in + ``model_dump`` or ``classify_fields``. ``False``, never ``None``, when + ``WORKFLOW_ID`` is absent from the projection -- the default + ``list_actions`` row omits it, so on a default list row this reads + ``False`` for every action whether or not the engine owns it. Project + ``WORKFLOW_ID`` explicitly (``list_actions(rfc, + fields=[..., "WORKFLOW_ID"])``) or read item-level before trusting it. + """ + return self.workflow_id is not None + class PostAction(EasyvistaWriteModel): """Payload for creating an action on a ticket. diff --git a/easyvista_python_client/models/common.py b/easyvista_python_client/models/common.py index 2eceaba..f14894a 100644 --- a/easyvista_python_client/models/common.py +++ b/easyvista_python_client/models/common.py @@ -2,8 +2,10 @@ from __future__ import annotations +import re from collections.abc import Sequence from datetime import datetime, timezone +from decimal import Decimal from typing import Annotated, Any from pydantic import ( @@ -37,6 +39,88 @@ def _empty_str_to_none(value: Any) -> Any: """An ``int | None`` field that treats the API's ``""`` sentinel as ``None``.""" +# An optional sign, digits, then at most one separator and one or two fraction +# digits. Deliberately refuses a grouping separator -- see the validator. +_EV_DECIMAL = re.compile(r"^[+-]?\d+(?:[.,]\d{1,2})?$") + + +def _parse_ev_decimal(value: Any) -> Any: + """Parse EasyVista's locale-formatted money column into an exact ``Decimal``. + + The instance renders a currency column with its own decimal separator: on + the verified deployment ``TIME_COST`` and ``CONTRACTUAL_COST`` come back as + ``'0,00'`` / ``'99,00'`` — a **comma** — which is why this exists rather + than the column being a plain ``float``. Both separators are accepted, so a + dot-configured deployment needs no setting; the separator is a deployment's + locale, not an EasyVista constant. + + ``""`` maps to ``None`` and that is load-bearing: ``""`` means the column + does **not apply** to this record, while ``'0,00'`` means it applies and is + zero. Collapsing the two — to ``0`` or to ``None`` — destroys the only + signal that says whether a record tracks cost at all. See + :class:`~easyvista_python_client.models.action.Action` for what that signal + is worth and what it does **not** prove. + + **Two shapes raise rather than being guessed at**, both of them formats this + parser has never seen live: + + * **A grouping separator.** ``'1.234,56'`` and ``'1,234.56'`` are the same + amount under opposite conventions, and ``'9,999'`` is either ``9.999`` or + ``9999`` with nothing in the value to say which. + * **Three or more fraction digits.** ``'1,234'`` is refused for the same + reason — it is indistinguishable from a comma-grouped ``1234`` — which + also means a genuinely 3-decimal currency is refused here. + + Note what is **not** a trigger: magnitude. ``'1000,00'`` parses fine, because + it carries no grouping separator. An earlier revision of this docstring said + "an amount above 999 is the case to watch", which was wrong — what matters is + the *format*, not the size. + + Every amount observed live carried exactly two fraction digits and no + grouping (1500 rows, 2026-09-02: ``'0,00'``, ``'99,00'``, ``'129,00'``). + Refusing is the same trade :func:`_empty_str_to_none_datetime` makes — a + wrong number is worse than a loud failure — and it carries the same cost: + ``resources/descriptor.py`` validates a page in a list comprehension, so one + unparseable amount fails the whole ``list_actions`` call rather than that + row. See ``O-COSTGROUP`` in ``docs/vendor-api-reference.md``. + + ``Decimal``, not ``float``, because these are money: ``Decimal('0.10') + + Decimal('0.20') == Decimal('0.30')`` and the float equivalent does not. + A non-string value (an ``int``, ``float`` or ``Decimal`` handed in + directly) passes through to pydantic's own ``Decimal`` validator untouched. + """ + if value is None: + return None + if isinstance(value, str): + text = value.strip() + if not text: + return None + if not _EV_DECIMAL.match(text): + raise ValueError( + f"{value!r} is not an EasyVista amount. Expected digits with at " + "most one decimal separator ('.' or ',') and ONE OR TWO " + "fraction digits, e.g. '0,00' or '99.00'. Refused, rather than " + "guessed at: a grouping separator (because '1.234,56' and " + "'1,234.56' are the same amount under opposite conventions), and " + "three or more fraction digits (because '1,234' is " + "indistinguishable from a comma-grouped 1234). Magnitude is not " + "a trigger -- '1000,00' parses. If this instance really formats " + "amounts this way, that is a finding worth recording -- report " + "the value." + ) + return Decimal(text.replace(",", ".")) + return value + + +OptionalDecimal = Annotated[Decimal | None, BeforeValidator(_parse_ev_decimal)] +"""An exact ``Decimal | None`` for an EasyVista currency column. + +Accepts either decimal separator and maps the ``""`` sentinel to ``None``, +which is **not** the same answer as ``Decimal('0.00')`` — see +:func:`_parse_ev_decimal`. +""" + + def _parse_with_context_formats(value: Any, info: ValidationInfo) -> datetime | None: """Try the caller's own timestamp formats, if it supplied any. diff --git a/easyvista_python_client/models/tests/test_action.py b/easyvista_python_client/models/tests/test_action.py index d38a4d8..0634504 100644 --- a/easyvista_python_client/models/tests/test_action.py +++ b/easyvista_python_client/models/tests/test_action.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from decimal import Decimal import pytest from pydantic import ValidationError @@ -408,3 +409,216 @@ def test_an_extra_payload_action_type_guid_satisfies_the_task_guard(): """ body = PostTask(group_id=3, extra_payload={"action_type_guid": "{TYPE}"}).to_api() assert body["action_type_guid"] == "{TYPE}" + + +# --- the effort/cost columns (EV-TASKSHAPE) ---------------------------------- +# A GLPI comment maps to an EasyVista TASK, not an ACTION, so a caller syncing +# a timeline must be able to see whether an effort column APPLIES to a record +# at all. These five columns arrived as untyped ``extra="allow"`` strings until +# 0.3.0; the tests below pin the one distinction that carries the signal. + + +def test_elapsed_time_distinguishes_absent_from_zero(): + """`''` means the column does not apply; `'0'` means it applies and is zero. + + Measured 2026-09-02 over 1500 live action rows: 384 carried ``''`` and 895 + carried ``'0'``. Collapsing the two -- to ``0`` or to ``None`` -- destroys + the only signal that says whether a row tracks effort. + """ + + def elapsed(raw): + row = {"ACTION_ID": "1", "ELAPSED_TIME": raw} + return Action.model_validate(row).elapsed_time + + assert elapsed("") is None + assert elapsed("0") == 0 + assert elapsed("27") == 27 + + +def test_the_absent_versus_zero_distinction_survives_a_dump(): + """A caller comparing two rows dumps them; ``None`` and ``0`` must not merge.""" + absent = Action.model_validate( + {"ACTION_ID": "1", "ELAPSED_TIME": "", "TIME_COST": ""} + ) + zero = Action.model_validate( + {"ACTION_ID": "2", "ELAPSED_TIME": "0", "TIME_COST": "0,00"} + ) + assert absent.model_dump(by_alias=True)["ELAPSED_TIME"] is None + assert zero.model_dump(by_alias=True)["ELAPSED_TIME"] == 0 + assert absent.model_dump(by_alias=True)["TIME_COST"] is None + assert zero.model_dump(by_alias=True)["TIME_COST"] == Decimal("0.00") + + +@pytest.mark.parametrize("alias", ["TIME_COST", "CONTRACTUAL_COST"]) +def test_a_french_decimal_comma_cost_parses_exactly(alias): + """Both cost columns arrive comma-separated (``'0,00'``, ``'99,00'``).""" + attr = alias.lower() + assert getattr(Action.model_validate({alias: ""}), attr) is None + assert getattr(Action.model_validate({alias: "0,00"}), attr) == Decimal("0.00") + assert getattr(Action.model_validate({alias: "99,00"}), attr) == Decimal("99.00") + # Decimal equality is scale-insensitive, so '129,00' equals Decimal("129"). + # That is NOT the float-exactness claim -- see + # test_a_cost_is_an_exact_decimal_not_a_float for that. + assert getattr(Action.model_validate({alias: "129,00"}), attr) == Decimal("129") + + +@pytest.mark.parametrize("alias", ["TIME_COST", "CONTRACTUAL_COST"]) +def test_a_dot_separated_cost_parses_too(alias): + """The separator is a deployment's locale, not an EasyVista constant.""" + assert getattr(Action.model_validate({alias: "99.00"}), alias.lower()) == Decimal( + "99.00" + ) + + +@pytest.mark.parametrize("value", ["1 234,56", "1.234,56", "1,234.56", "abc", "9,999"]) +def test_an_unrecognised_cost_is_reported_not_guessed_at(value): + """A grouped or junk amount raises rather than inventing a wrong number. + + ``'1.234,56'`` and ``'1,234.56'`` are the same amount under opposite + conventions and ``'9,999'`` is either 9.999 or 9999 -- unguessable. A wrong + money value is worse than a loud failure, the same trade + ``OptionalDateTime`` makes for a wrong instant. + """ + with pytest.raises(ValidationError): + Action.model_validate({"TIME_COST": value}) + + +def test_the_effort_window_parses_as_aware_datetimes(): + """``START_DATE_UT``/``END_DATE_UT`` are the action's own effort window.""" + action = Action.model_validate( + { + "ACTION_ID": "1", + "START_DATE_UT": "2026-09-02T09:04:01.000+02:00", + "END_DATE_UT": "", + } + ) + assert action.start_date_ut == datetime(2026, 9, 2, 9, 4, 1, tzinfo=_CEST) + assert action.end_date_ut is None + + +@pytest.mark.parametrize( + "alias", + ["ELAPSED_TIME", "TIME_COST", "CONTRACTUAL_COST", "START_DATE_UT", "END_DATE_UT"], +) +def test_the_effort_columns_are_declared_not_left_in_model_extra(alias): + """Before 0.3.0 all five reached callers only as untyped extras.""" + action = Action.model_validate({"ACTION_ID": "1", alias: ""}) + assert alias not in (action.model_extra or {}) + + +def test_is_workflow_generated_reads_the_workflow_id(): + """1500/1500 live rows: a WORKFLOW_ID is set iff the engine owns the row.""" + assert Action.model_validate({"WORKFLOW_ID": "37"}).is_workflow_generated is True + assert Action.model_validate({"WORKFLOW_ID": ""}).is_workflow_generated is False + assert Action.model_validate({"ACTION_ID": "1"}).is_workflow_generated is False + + +def test_a_public_comment_carrying_effort_is_not_reported_as_workflow_generated(): + """The counter-example that refutes the effort-shape heuristic. + + Measured 2026-09-02: a type-94 ``Commentaire [Public]`` carried + ``ELAPSED_TIME='12'``, ``TIME_COST='99,00'`` and ``CONTRACTUAL_COST='129,00'`` + with an empty ``WORKFLOW_ID``. Any "effort set => not a comment" filter + drops it. + """ + action = Action.model_validate( + { + "ACTION_ID": "14980", + "ACTION_TYPE_ID": "94", + "ACTION_LABEL_FR": "Commentaire [Public]", + "ELAPSED_TIME": "12", + "TIME_COST": "99,00", + "CONTRACTUAL_COST": "129,00", + "WORKFLOW_ID": "", + } + ) + assert action.is_workflow_generated is False + assert action.elapsed_time == 12 + assert action.time_cost == Decimal("99.00") + assert action.contractual_cost == Decimal("129.00") + + +def test_a_cost_is_an_exact_decimal_not_a_float(): + """Pins Decimal specifically -- a float field would pass the assertions above. + + ``Decimal("0.1") + Decimal("0.2") == Decimal("0.3")``; the float equivalent + does not. If ``time_cost`` were ever retyped to ``float`` every other cost + test here would still pass, so this is the one that would fail. + """ + a = Action.model_validate({"TIME_COST": "0,10"}) + b = Action.model_validate({"TIME_COST": "0,20"}) + assert isinstance(a.time_cost, Decimal) + assert a.time_cost + b.time_cost == Decimal("0.30") + assert 0.1 + 0.2 != 0.3 # the float trap this type avoids + + +def test_magnitude_is_not_what_the_cost_parser_refuses(): + """``'1000,00'`` parses; only the FORMAT is refused, never the size. + + An earlier revision of the docs said "an amount above 999 is the case that + would trigger it", which was wrong -- 1000,00 carries no grouping separator. + """ + assert Action.model_validate({"TIME_COST": "1000,00"}).time_cost == Decimal("1000") + assert Action.model_validate({"TIME_COST": "999999,99"}).time_cost == Decimal( + "999999.99" + ) + + +def test_three_fraction_digits_are_refused_as_ambiguous(): + """``'1,234'`` is either 1.234 or a comma-grouped 1234 -- unguessable.""" + with pytest.raises(ValidationError): + Action.model_validate({"TIME_COST": "1,234"}) + + +def test_none_does_not_distinguish_absent_from_not_projected(): + """The documented ambiguity, pinned so nobody "fixes" it into a wrong answer. + + A default ``list_actions`` row omits all five effort columns, so they read + ``None`` there -- the SAME value the ``""`` sentinel produces. The model + cannot tell "does not apply" from "not returned", which is why the docstring + tells callers to project the columns before reading meaning into a ``None``. + """ + not_projected = Action.model_validate({"ACTION_ID": "1"}) + does_not_apply = Action.model_validate({"ACTION_ID": "1", "ELAPSED_TIME": ""}) + assert not_projected.elapsed_time is None + assert does_not_apply.elapsed_time is None + + +def test_the_effort_columns_are_always_present_in_a_dump_once_declared(): + """Declaring them changed PRESENCE, not the classify_fields bucket. + + None of the five starts with ``E_``, so ``classify()`` always put them in + ``.official``. What the 0.3.0 change altered is that the keys are now + present even when the API did not return them. + """ + action = Action.model_validate({"ACTION_ID": "1"}) + official = action.classify_fields().official + for alias in ( + "ELAPSED_TIME", + "TIME_COST", + "CONTRACTUAL_COST", + "START_DATE_UT", + "END_DATE_UT", + ): + assert alias in official + assert official[alias] is None + assert alias not in action.classify_fields().custom + + +def test_a_cost_survives_a_json_mode_dump(): + """A Decimal is not JSON-native; ``mode="json"`` is the documented recipe.""" + import json + + action = Action.model_validate({"ACTION_ID": "1", "TIME_COST": "99,00"}) + with pytest.raises(TypeError): + json.dumps(action.model_dump(by_alias=True)) + dumped = action.model_dump(mode="json", by_alias=True) + assert json.dumps(dumped) # does not raise + # Renders with a '.', not the ',' the API sent -- documented in user_guide. + assert "99.00" in str(dumped["TIME_COST"]) + + +def test_reference_renders_a_cost_column_instead_of_returning_it_empty(): + """``_scalar`` gained a Decimal branch; without it this Reference was empty.""" + action = Action.model_validate({"ACTION_ID": "1", "TIME_COST": "99,00"}) + assert action.reference("TIME_COST").display == "99.00" diff --git a/easyvista_python_client/references.py b/easyvista_python_client/references.py index 2f260da..21214db 100644 --- a/easyvista_python_client/references.py +++ b/easyvista_python_client/references.py @@ -26,6 +26,7 @@ from collections.abc import Iterator, Sequence from dataclasses import dataclass from datetime import datetime +from decimal import Decimal from functools import lru_cache from typing import Any @@ -141,7 +142,14 @@ def _scalar(value: Any) -> str | None: return format_ev_datetime(value) except ValueError: return value.isoformat() - if isinstance(value, (str, int)) and str(value).strip(): + # ``Decimal`` joins ``str``/``int`` here because the read path retypes the + # currency columns (``TIME_COST``, ``CONTRACTUAL_COST``) into exact + # ``Decimal``s. Without this branch ``reference("TIME_COST")`` returned an + # EMPTY Reference on a populated column -- the same class of silent gap the + # ``datetime`` branch above was added to close. Rendered with ``str`` rather + # than the wire's own decimal comma: this extractor feeds labels and + # identifiers, not money formatting, and it must never raise. + if isinstance(value, (str, int, Decimal)) and str(value).strip(): return str(value).strip() return None diff --git a/easyvista_python_client/testing/test_public_api.py b/easyvista_python_client/testing/test_public_api.py index 58b26ef..9eaf806 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.2.0" + assert easyvista_python_client.__version__ == "0.3.0" def test_public_exports_available(): diff --git a/integration_tests/test_live_action_effort_shape.py b/integration_tests/test_live_action_effort_shape.py new file mode 100644 index 0000000..a775bc4 --- /dev/null +++ b/integration_tests/test_live_action_effort_shape.py @@ -0,0 +1,153 @@ +"""Pin the task-vs-action facts in ``docs/vendor-api-reference.md``. + +**READ-ONLY.** Like ``test_live_baseline_version.py`` this module creates, +updates and closes nothing: it issues ``GET actions`` with an explicit +projection and reads the instance's own OpenAPI ``paths``. It is safe to run on +its own against a live instance. + +Two claims are pinned, both of which the package's types and docstrings now +depend on: + +1. The effort columns parse off real records, and the ``""`` sentinel stays + distinguishable from a real zero. +2. There is no task read route, so a task can only be read back as an action. + +Credential-gated like the rest of ``integration_tests/`` and never run in CI. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +import pytest + +from easyvista_python_client import Action, EasyvistaClient + +pytestmark = pytest.mark.integration + +# Wide enough to see the shape; the default list projection returns none of it. +_PROJECTION = [ + "ACTION_ID", + "ACTION_TYPE_ID", + "WORKFLOW_ID", + "ELAPSED_TIME", + "TIME_COST", + "CONTRACTUAL_COST", + "START_DATE_UT", + "END_DATE_UT", +] + + +@pytest.fixture(scope="module") +def action_rows(live_client: EasyvistaClient) -> list[dict]: + """One page of raw ``GET actions`` rows, projected. Read-only.""" + data = live_client.send( + "GET", + "actions", + params={"fields": ",".join(_PROJECTION), "max_rows": 500}, + ) + rows = data.get("records") or [] + if not rows: + pytest.skip("instance has no action records to sample") + return rows + + +def test_the_effort_columns_parse_off_every_sampled_row( + action_rows: list[dict], +) -> None: + """``Action`` validates real rows rather than raising on their formats. + + This is the guard for ``_parse_ev_decimal``'s deliberate strictness: it + refuses a grouping separator and three-or-more fraction digits, and the + descriptor validates a page in a list comprehension, so one such amount + would fail a whole ``list_actions`` call. Magnitude is not a trigger -- + ``'1000,00'`` parses. If a refused format ever appears on this instance, it + fails here first with the literal in hand. + """ + for row in action_rows: + action = Action.model_validate(row) + assert action.elapsed_time is None or isinstance(action.elapsed_time, int) + for cost in (action.time_cost, action.contractual_cost): + assert cost is None or isinstance(cost, Decimal) + for stamp in (action.start_date_ut, action.end_date_ut): + assert stamp is None or ( + isinstance(stamp, datetime) and stamp.tzinfo is not None + ) + + +def test_the_empty_sentinel_and_a_real_zero_stay_distinguishable( + action_rows: list[dict], +) -> None: + """The distinction the connector depends on, asserted on live data. + + ``""`` means the column does not apply; ``"0"`` that it applies and is + zero. Skips rather than fails if this instance happens to carry only one of + the two -- absence of a mixed sample is not evidence against the rule. + """ + absent = [r for r in action_rows if r.get("ELAPSED_TIME") == ""] + zero = [r for r in action_rows if r.get("ELAPSED_TIME") == "0"] + if not absent or not zero: + pytest.skip( + "no mixed sample on this page " + f"({len(absent)} empty, {len(zero)} zero ELAPSED_TIME)" + ) + assert Action.model_validate(absent[0]).elapsed_time is None + assert Action.model_validate(zero[0]).elapsed_time == 0 + + +def test_effort_recorded_does_not_imply_a_workflow_row( + action_rows: list[dict], +) -> None: + """Refutes the heuristic ``is_workflow_generated``'s docstring warns about. + + Measured 2026-09-02 on two deployments: rows with no ``WORKFLOW_ID`` carry a + non-empty ``ELAPSED_TIME`` (public comments among them), and rows with one + carry an empty ``ELAPSED_TIME``. Either direction existing is enough to + show the columns do not classify the record; this asserts the direction that + would silently drop comments from a timeline sync. + """ + actions = [Action.model_validate(row) for row in action_rows] + counter_examples = [ + a for a in actions if a.elapsed_time is not None and not a.is_workflow_generated + ] + if not counter_examples: + pytest.skip("no non-workflow row on this page records effort") + + # The refutation itself: a row the heuristic would call a time-tracking or + # workflow entry, which carries no WORKFLOW_ID at all. + sample = counter_examples[0] + assert sample.elapsed_time is not None + assert sample.workflow_id is None + assert sample.is_workflow_generated is False, ( + f"action {sample.action_id} (type {sample.action_type_id}) records " + f"elapsed_time={sample.elapsed_time} with no WORKFLOW_ID, so 'effort " + "set => workflow or time-tracking row' cannot classify a timeline entry" + ) + + +def test_there_is_no_task_read_route(live_client: EasyvistaClient) -> None: + """``tasks`` is POST-only, which is why ``create_task`` returns an ``Action``. + + Tier 2 -- the instance's own ``paths``, authoritative for this deployment. + Deliberately not asserted from a 403 on a GET: this API answers 403 for an + absent route as readily as for a denied one. + """ + paths = live_client.get_api_spec().get("paths") or {} + task_paths = { + path: verbs for path, verbs in paths.items() if "task" in path.lower() + } + assert task_paths, "no tasks route declared at all; create_task cannot work" + for path, verbs in task_paths.items(): + declared = { + verb.upper() + for verb in verbs or {} + if verb.lower() in {"get", "post", "put", "patch", "delete"} + } + assert declared == {"POST"}, ( + f"{path} declares {sorted(declared)}; this package documents the " + "tasks route as create-only and has no list_tasks because of it" + ) + + # The corollary: the action routes are the only way to read one back. + assert "GET" in {v.upper() for v in paths.get("/actions", {})} diff --git a/pyproject.toml b/pyproject.toml index 426eba7..0cf2ea9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ exclude = [ [project] name = "easyvista-python-client" -version = "0.2.0" +version = "0.3.0" description = "Typed Python client for the EasyVista Service Manager REST API" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/tests/test_no_private_instance_identifiers.py b/scripts/tests/test_no_private_instance_identifiers.py new file mode 100644 index 0000000..0cca1e3 --- /dev/null +++ b/scripts/tests/test_no_private_instance_identifiers.py @@ -0,0 +1,205 @@ +"""Guard: no private EasyVista instance identifier reaches a tracked file. + +``.gitignore`` withholds the instance-describing notes because "they carry the +instance host/account, the end customer's org structure, and a map of that +instance's bespoke customization". That policy had no automated enforcement, and +in the 0.3.0 candidate a real preprod hostname reached +``docs/vendor-api-reference.md`` -- a tracked file in a public repository that +also ships inside the sdist (``pyproject.toml`` includes ``/docs``). All five CI +gates passed on that tree, because none of them looks at prose for this. + +Two properties make the leak worth a dedicated guard rather than a review habit: + +* **It is irreversible.** A public push writes the host into permanent git + history, and ``pyproject.toml`` says in as many words that "a PyPI upload + cannot be taken back". This repository already paid that price once -- its + history was squashed at publish-prep to purge private-instance references. +* **It is silent.** Nothing about a hostname in a sentence looks wrong to ruff, + mypy, Sphinx or the test suite. + +**Why hashes and not a plaintext needle list.** The obvious implementation -- +``git grep`` for the forbidden strings -- requires this file to *contain* them, +and this file is tracked and public like any other. The first draft of this +guard did exactly that: it published the hostname it existed to protect, in a +module exempted from its own check. So the identifiers are stored as SHA-256 +digests. The scanner hashes candidate tokens out of each file and compares +digests, so the plaintext appears nowhere in the repository and this module +needs no exemption. + +That makes the check one-way, with the trade-offs a one-way check has. It +catches an identifier written *verbatim*; it cannot catch a paraphrase, a +partially redacted host, or a value split across a line break. It is a backstop +against the mistake actually made, not a proof of absence. + +To add an identifier, hash it and add the digest with a description:: + + import hashlib + hashlib.sha256(VALUE.lower().encode()).hexdigest() + +Scope note: this checks *identifiers*, where ``test_source_citations.py`` checks +*cited paths*. They are deliberately separate guards over the same file set. +""" + +from __future__ import annotations + +import hashlib +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# SHA-256 of the lowercased token. Plaintext is deliberately absent -- see the +# module docstring. The illustrative account number used throughout the tracked +# docs and tests is NOT listed: an account id alone names no host, and listing +# it would fail this guard on a dozen deliberate uses. Nor is the synthetic +# catalog-code stand-in the tracked tests use. The host is the identifying +# half, and that is what is refused. +_FORBIDDEN_DIGESTS: dict[str, str] = { + "6c296fde55b852788be0818c74fc20be7554d5fede6bbd3d421965755dd3d0c1": ( + "a private EasyVista instance name" + ), + "7df7849f4de367e34a3ecbfa8f4d1ac19a8af929d942dd91ec7f9a0151899f11": ( + "a private EasyVista instance hostname" + ), + "d1342080c9d59a1ed80c89b3fd458ec4dde04f2cdb682b2480b006765362fefa": ( + "the private EasyVista instance's domain" + ), + "10b52891164a5a877cf3bf66454fad9a00fe2eff00c19bde97c4b77bcb632bb9": ( + "a real catalog GUID from a private instance" + ), + "d35d7dc7573826dca2cc3483a0cb17ec5f913d3d7bda7b0d0b9ec1c93fbce2ab": ( + "a real catalog code from a private instance" + ), +} + +# A hostname, GUID, code or bare word: starts alphanumeric, then anything a +# host or identifier may carry. ``\w`` covers the underscore in a catalog code. +_TOKEN = re.compile(r"[A-Za-z0-9][\w.-]*") + +# Mirrors test_source_citations.py: gitignored notes that legitimately carry +# these values on a dev machine and are absent from the published repository. +_UNPUBLISHED_FILENAMES = { + "API_Info.md", + "easyvista-field-inventory.md", + "easyvista-test-profile-blocked-operations.md", +} + + +def _digests(token: str) -> set[str]: + """Every digest worth testing for one extracted token. + + Three forms, each closing a real gap: + + * the whole token -- an exact hostname or catalog code; + * each dot-suffix -- so a bare domain is caught inside a longer FQDN; + * the leading eight characters -- so a full GUID is caught from the + recorded prefix, which is all that was ever known of it. + """ + lowered = token.lower().strip(".-") + if not lowered: + return set() + forms = {lowered, lowered[:8]} + parts = lowered.split(".") + for index in range(1, len(parts)): + forms.add(".".join(parts[index:])) + return {hashlib.sha256(form.encode()).hexdigest() for form in forms if form} + + +def _offending(text: str) -> tuple[str, str] | None: + """The first offending token and what it is, or ``None`` when clean.""" + for match in _TOKEN.finditer(text): + token = match.group(0) + for digest in _digests(token): + if digest in _FORBIDDEN_DIGESTS: + return token, _FORBIDDEN_DIGESTS[digest] + return None + + +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", + "skills/**/*.md", + "scripts/**/*.py", + ): + found.extend(REPO_ROOT.glob(pattern)) + for name in ("README.md", "CONTRIBUTING.md", "CHANGELOG.md", "CLAUDE.md"): + path = REPO_ROOT / name + if path.is_file(): + found.append(path) + for path in REPO_ROOT.glob("docs/*.md"): + if path.name not in _UNPUBLISHED_FILENAMES: + found.append(path) + # scripts/probe_*.py is gitignored as a glob; everything else is tracked. + return sorted({p for p in found if not p.name.startswith("probe_")}) + + +def _ids() -> list[str]: + return [p.relative_to(REPO_ROOT).as_posix() for p in _tracked_text_files()] + + +def test_scan_scope_covers_the_file_the_leak_reached() -> None: + """Pins the scope, so a narrower future glob cannot silently un-guard it.""" + found = set(_tracked_text_files()) + assert REPO_ROOT / "docs" / "vendor-api-reference.md" in found + assert REPO_ROOT / "README.md" in found + assert REPO_ROOT / "skills" / "easyvista-ticket-actions" / "SKILL.md" in found + + +def test_this_module_guards_itself() -> None: + """The hole in the first draft: it was exempt, and it held the plaintext. + + Storing digests means this file needs no exemption, so it is scanned like + every other. That is the property worth pinning -- an exemption is exactly + where a leak hides. + """ + assert Path(__file__).resolve() in set(_tracked_text_files()) + + +def test_the_scanner_catches_a_listed_identifier() -> None: + """A guard nobody has seen fail is a guard nobody knows works. + + Exercised against a *synthetic* digest injected for the duration of the + test, because the real plaintext is deliberately not available here -- that + is the whole design. What this proves is the mechanism: tokenise, hash, + match, report. + """ + synthetic = "zzz-not-a-real-instance.invalid" + digest = hashlib.sha256(synthetic.encode()).hexdigest() + suffix_digest = hashlib.sha256(b"sub.invalid").hexdigest() + _FORBIDDEN_DIGESTS[digest] = "a synthetic value, for this test only" + _FORBIDDEN_DIGESTS[suffix_digest] = "a synthetic domain, for this test only" + try: + hit = _offending(f"measured on {synthetic} last week") + assert hit is not None, "the scanner missed a token whose digest is listed" + assert hit[0] == synthetic + # The dot-suffix form: a bare domain caught inside a longer FQDN. + assert _offending("see host.sub.invalid for details") is not None + finally: + _FORBIDDEN_DIGESTS.pop(digest, None) + _FORBIDDEN_DIGESTS.pop(suffix_digest, None) + + +def test_the_scanner_allows_the_deliberate_synthetic_values() -> None: + """The illustrative account number and the code stand-in must stay usable.""" + assert _offending('catalog_code="EAZ_INC_000"') is None + assert _offending("the account segment, a number such as 50004") is None + assert _offending("a second 2025.3 deployment") is None + + +@pytest.mark.parametrize("path", _tracked_text_files(), ids=_ids()) +def test_no_private_instance_identifier(path: Path) -> None: + hit = _offending(path.read_text(encoding="utf-8")) + assert hit is None, ( + f"{path.relative_to(REPO_ROOT).as_posix()} contains {hit[0]!r} -- " + f"{hit[1]}. Tracked files are published to a public repository and ship " + "inside the sdist, and neither a git push nor a PyPI upload can be " + "taken back. Describe the deployment anonymously instead ('the " + "development instance', 'a second 2025.3 deployment'); no measurement " + "in this repository depends on naming a host." + ) diff --git a/scripts/tests/test_source_citations.py b/scripts/tests/test_source_citations.py index d2112e8..b3d8ccb 100644 --- a/scripts/tests/test_source_citations.py +++ b/scripts/tests/test_source_citations.py @@ -51,6 +51,11 @@ "test_source_citations.py", "test_skills_contract.py", "generate_field_inventory.py", + # A third guard module, added 0.3.0. It must name the same gitignored notes + # in its own skip list -- otherwise it would scan one and fail on the file + # legitimately containing its own name -- so it is exempt here for exactly + # the reason this module is. + "test_no_private_instance_identifiers.py", } diff --git a/skills/easyvista-asset-workflow/SKILL.md b/skills/easyvista-asset-workflow/SKILL.md index d6ae8cd..e9d5b2d 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.2.0" + version: "0.3.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-client-setup/SKILL.md b/skills/easyvista-client-setup/SKILL.md index b367bb6..269b851 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.2.0" + version: "0.3.0" --- `easyvista_python_client` ships both clients over one surface: diff --git a/skills/easyvista-directory/SKILL.md b/skills/easyvista-directory/SKILL.md index ad14ac8..3042650 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.2.0" + version: "0.3.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-document-workflow/SKILL.md b/skills/easyvista-document-workflow/SKILL.md index 72f90c0..285202f 100644 --- a/skills/easyvista-document-workflow/SKILL.md +++ b/skills/easyvista-document-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 documents sub-resource." metadata: package: easyvista-python-client - version: "0.2.0" + version: "0.3.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-instance-discovery/SKILL.md b/skills/easyvista-instance-discovery/SKILL.md index 926111f..5a61b6d 100644 --- a/skills/easyvista-instance-discovery/SKILL.md +++ b/skills/easyvista-instance-discovery/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. Every call here is a GET; nothing is created, updated or deleted." metadata: package: easyvista-python-client - version: "0.2.0" + version: "0.3.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-reporting-and-context/SKILL.md b/skills/easyvista-reporting-and-context/SKILL.md index 855c805..5632bc2 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.2.0" + version: "0.3.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-search-syntax/SKILL.md b/skills/easyvista-search-syntax/SKILL.md index d6b27e1..51699ca 100644 --- a/skills/easyvista-search-syntax/SKILL.md +++ b/skills/easyvista-search-syntax/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.2.0" + version: "0.3.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, diff --git a/skills/easyvista-ticket-actions/SKILL.md b/skills/easyvista-ticket-actions/SKILL.md index b9f9bac..a1d44ec 100644 --- a/skills/easyvista-ticket-actions/SKILL.md +++ b/skills/easyvista-ticket-actions/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 actions sub-resource." metadata: package: easyvista-python-client - version: "0.2.0" + version: "0.3.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, @@ -130,6 +130,43 @@ Verified live 2026-08-28: tasks came back with `END_DATE_UT` and mandatory; `PostTask` refuses a body missing either rather than letting the server answer with a 590 that names no field. +**A task is write-only as a resource.** `POST requests/{rfc}/tasks` is the only +verb the route has, and the API declares **no task read route at all** (tier 2, +read 2026-09-02 on two 2025.3 deployments). So `create_task` returns an +`Action`, there is no `Task` read model and no `list_tasks` — a task is written +as a task and read back through `list_actions` like any other row. A GET against +the tasks path answers 403, which on this API proves nothing either way. + +**No action-record column observed so far says which route created it**, so do +not try to reconstruct the distinction on the read side. In particular the effort +columns do not: measured over 1500 live rows 2026-09-02, 39 of 49 +`Commentaire [Public]` comments carried a non-empty `ELAPSED_TIME` (one with +`TIME_COST='99,00'`), while 173 workflow rows carried an empty one. To pick +conversation out of a timeline, filter on an `action_type_id` allowlist your +administrator confirms, and use `action.is_workflow_generated` to drop the +workflow engine's own rows. + +**`is_workflow_generated` needs `WORKFLOW_ID` projected, or it is always +`False`.** It reads that one column, and the column is NOT on the default +`list_actions` projection — so on default rows the filter silently drops nothing +and every workflow row survives. The same applies to all five effort columns: +unprojected they read `None`, which is indistinguishable from the `''` that means +"does not apply". Ask for them: + +```python +actions = client.iter_actions( + "YOUR_RFC_NUMBER", + fields=[ + "ACTION_ID", "ACTION_TYPE_ID", "WORKFLOW_ID", + "ELAPSED_TIME", "START_DATE_UT", "END_DATE_UT", + ], +) +conversation = [ + a for a in actions + if not a.is_workflow_generated and a.action_type_id in YOUR_COMMENT_TYPE_IDS +] +``` + **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}` / @@ -532,3 +569,20 @@ with EasyvistaClient.from_env() as client: 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. +- **On the effort columns, `''` and `'0'` are different answers.** + `elapsed_time` (minutes), `time_cost`, `contractual_cost`, `start_date_ut` and + `end_date_ut` are declared as of 0.3.0. `''` means the column does not apply + to this record and reads as `None`; `'0'` / `'0,00'` means it applies and is + zero, and reads as `0` / `Decimal("0.00")`. Do not normalise the two together + — that erases the only signal saying whether a record tracks effort at all. + The costs are exact `Decimal`s parsed from the API's decimal comma; an amount + with a grouping separator raises rather than being guessed at, and because a + page is validated all at once that fails the whole `list_actions` call. +- **`ACTION_LABEL_*` is the workflow STEP's label, not the action type's name** + (measured 2026-09-02, 1500 rows). Type 20 appeared under six labels: + `Analyse et résolution`, `Traitement`, `Traitement du refus`, `Traitement de + la demande`, `test` and `notif`. For the non-workflow + types (94, 95, 7) it is stable and is the real type name; for workflow types + it varies row to row, so never key on it. Types 14, 27 and 28 have an empty + label in every language column at both list and item level — the API cannot + name them, and only the admin console can. diff --git a/skills/easyvista-ticket-workflow/SKILL.md b/skills/easyvista-ticket-workflow/SKILL.md index 7d7626d..86de65e 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.2.0" + version: "0.3.0" --- > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,