easyvista-python-client 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/CHANGELOG.md +125 -4
  2. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/PKG-INFO +5 -2
  3. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/README.md +4 -1
  4. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/docs/publishing.rst +17 -5
  5. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/docs/user_guide.rst +23 -7
  6. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/docs/vendor-api-reference.md +131 -0
  7. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_async/client.py +21 -0
  8. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_sync/client.py +21 -0
  9. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_version.py +1 -1
  10. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/models/action.py +112 -0
  11. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/models/common.py +84 -0
  12. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/references.py +9 -1
  13. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/pyproject.toml +1 -1
  14. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/skills/easyvista-asset-workflow/SKILL.md +1 -1
  15. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/skills/easyvista-client-setup/SKILL.md +1 -1
  16. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/skills/easyvista-directory/SKILL.md +1 -1
  17. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/skills/easyvista-document-workflow/SKILL.md +1 -1
  18. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/skills/easyvista-instance-discovery/SKILL.md +1 -1
  19. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/skills/easyvista-reporting-and-context/SKILL.md +1 -1
  20. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/skills/easyvista-search-syntax/SKILL.md +1 -1
  21. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/skills/easyvista-ticket-actions/SKILL.md +55 -1
  22. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/skills/easyvista-ticket-workflow/SKILL.md +1 -1
  23. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/.gitignore +0 -0
  24. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/.pre-commit-config.yaml +0 -0
  25. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/.readthedocs.yaml +0 -0
  26. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/CONTRIBUTING.md +0 -0
  27. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/LICENSE +0 -0
  28. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/docs/_static/.gitkeep +0 -0
  29. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/docs/_templates/.gitkeep +0 -0
  30. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/docs/api_reference.rst +0 -0
  31. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/docs/conf.py +0 -0
  32. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/docs/development.rst +0 -0
  33. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/docs/index.rst +0 -0
  34. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/docs/installation.rst +0 -0
  35. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/docs/sponsoring.rst +0 -0
  36. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/__init__.py +0 -0
  37. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_async/__init__.py +0 -0
  38. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_async/_concurrency.py +0 -0
  39. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_async/_transport.py +0 -0
  40. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_fields.py +0 -0
  41. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_html.py +0 -0
  42. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_sync/__init__.py +0 -0
  43. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_sync/_concurrency.py +0 -0
  44. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_sync/_transport.py +0 -0
  45. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/_transport.py +0 -0
  46. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/config.py +0 -0
  47. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/context.py +0 -0
  48. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/directory.py +0 -0
  49. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/discovery.py +0 -0
  50. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/exceptions.py +0 -0
  51. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/field_model.py +0 -0
  52. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/filters.py +0 -0
  53. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/models/__init__.py +0 -0
  54. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/models/asset.py +0 -0
  55. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/models/department.py +0 -0
  56. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/models/document.py +0 -0
  57. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/models/employee.py +0 -0
  58. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/models/generic.py +0 -0
  59. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/models/request.py +0 -0
  60. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/pagination.py +0 -0
  61. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/py.typed +0 -0
  62. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/reporting.py +0 -0
  63. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/resources/__init__.py +0 -0
  64. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/resources/actions.py +0 -0
  65. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/resources/assets.py +0 -0
  66. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/resources/departments.py +0 -0
  67. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/resources/descriptor.py +0 -0
  68. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/resources/discovery.py +0 -0
  69. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/resources/documents.py +0 -0
  70. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/resources/employees.py +0 -0
  71. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/resources/requests.py +0 -0
  72. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/easyvista_python_client/timestamps.py +0 -0
  73. {easyvista_python_client-0.2.0 → easyvista_python_client-0.3.0}/skills/README.md +0 -0
@@ -4,11 +4,124 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
- While the package is pre-1.0, breaking changes may land between minor versions;
8
- a deprecation policy will follow the 1.0 release.
7
+ While the package is pre-1.0, breaking changes may land between **minor**
8
+ versions; a deprecation policy will follow the 1.0 release. A patch release
9
+ never carries one. Each breaking change is marked `**BREAKING**` in its section
10
+ with the reasoning, so read the section for the version you are moving to.
11
+
12
+ **The git tag is what defines a version.** Where a section's narrative or date
13
+ disagrees with the tree its tag points at, the tag is authoritative and the prose
14
+ is the error. Tags carry no `v` prefix.
9
15
 
10
16
  ## [Unreleased]
11
17
 
18
+ ## [0.3.0] - 2026-09-02
19
+
20
+ Makes the task-vs-action distinction inspectable. A GLPI comment corresponds to
21
+ an EasyVista **task**, not an action; actions carry effort and cost, so a
22
+ timeline mirrored without that distinction imports time-tracking and workflow
23
+ rows as conversation.
24
+
25
+ **Upgrading.** One breaking change, in `Action` (see `### Changed`). The minor
26
+ bump is deliberate: a dependant pinned `>=0.2.0,<0.3` does **not** pick this up,
27
+ and must widen its constraint on purpose — which is the point, because the
28
+ retyping below can change an answer silently rather than raise.
29
+
30
+ Before widening, check whether you read `action.ELAPSED_TIME`, `TIME_COST`,
31
+ `CONTRACTUAL_COST`, `START_DATE_UT` or `END_DATE_UT` off `model_extra`, or read
32
+ those keys out of a `model_dump(by_alias=True)` and treated them as strings. Both
33
+ change here. Nothing on the write side moves: `PostAction`, `PostTask` and
34
+ `ActionUpdate` are untouched.
35
+
36
+ ### Added
37
+
38
+ - Five columns declared on `Action` with real types: `elapsed_time` (minutes,
39
+ `int | None`), `time_cost` and `contractual_cost` (exact `Decimal | None`,
40
+ parsed from the API's French decimal comma), `start_date_ut` and
41
+ `end_date_ut` (aware `datetime | None`). They previously arrived only as
42
+ untyped `extra="allow"` strings.
43
+
44
+ **The `""` sentinel and `"0"` stay different answers.** `""` means the column
45
+ does not apply to this record and maps to `None`; `"0"` / `"0,00"` means it
46
+ applies and is zero, and maps to `0` / `Decimal("0.00")`. Collapsing the two
47
+ destroys the only signal that says whether a record tracks effort. Measured
48
+ over 1500 live rows: `ELAPSED_TIME` was `""` on 384 and `"0"` on 895.
49
+ - `Action.is_workflow_generated` — whether `WORKFLOW_ID` is set, i.e. whether
50
+ the workflow engine owns the row. Clean on 1500/1500 measured rows, and the
51
+ one structural fact in this area that holds.
52
+
53
+ It is **not** an `is_task()`, and the package deliberately does not ship one:
54
+ nothing on an action record says which route created it, and the
55
+ effort-shape heuristic that looks like it should is wrong in both directions
56
+ — 173 of 1500 rows had a `WORKFLOW_ID` with an empty `ELAPSED_TIME`, and 39
57
+ of 49 public comments carried a non-empty one, one of them with a real
58
+ `TIME_COST` of `99,00`. Which action types count as conversation is
59
+ per-deployment policy and stays with the caller.
60
+ - `scripts/tests/test_no_private_instance_identifiers.py` — a guard refusing any
61
+ private EasyVista instance identifier (hostname, domain, real catalog GUID or
62
+ code) in a tracked file. `.gitignore` has always stated that policy in prose;
63
+ it had no enforcement, and preparing this release put a real preprod hostname
64
+ into `docs/vendor-api-reference.md` — a tracked file that also ships in the
65
+ sdist — with all five gates passing, because none of them reads prose for
66
+ this. Both a public push and a PyPI upload are irreversible, so the check is
67
+ now mechanical. It carries its own negative control, and deliberately does
68
+ **not** refuse the illustrative account number or the synthetic
69
+ `EAZ_INC_000` stand-in the tracked tests already use.
70
+ - `models.common.OptionalDecimal`, the annotated type behind the two cost
71
+ columns. Accepts either decimal separator, so a dot-configured deployment
72
+ needs no setting, and **refuses a grouping separator** rather than guessing
73
+ — `'1.234,56'` and `'1,234.56'` are the same amount under opposite
74
+ conventions. Not exported from the package root.
75
+
76
+ ### Changed
77
+
78
+ - **BREAKING** — `ELAPSED_TIME`, `TIME_COST`, `CONTRACTUAL_COST`,
79
+ `START_DATE_UT` and `END_DATE_UT` are now declared fields on `Action` instead
80
+ of `extra="allow"` extras. Three consequences:
81
+ - `action.ELAPSED_TIME` (extra attribute access) raises `AttributeError`; use
82
+ `action.elapsed_time`. The same for the other four.
83
+ - They are gone from `action.model_extra`, and
84
+ `model_dump(by_alias=True)["ELAPSED_TIME"]` is now an `int | None` rather
85
+ than a `str`.
86
+ - `classify_fields()` bucketing is **unchanged** — none of the five starts
87
+ with `E_`, so all five have always landed in `.official`. What changed is
88
+ *presence*: `.official` (and the dump) now always carries all five keys,
89
+ `None` included, where previously a key the API did not return was simply
90
+ absent. Code that iterates `.official` sees five more keys on a default
91
+ `list_actions` row.
92
+ - **`None` is now ambiguous.** These columns are not on the default
93
+ `list_actions` projection, so on a default row every one reads `None`,
94
+ meaning *not returned* — not the `""` that means *does not apply*. The two
95
+ are indistinguishable once validated. Project them explicitly or read
96
+ item-level before reading meaning into a `None`.
97
+ - A `Decimal` in a dump is not JSON-serialisable, so
98
+ `json.dumps(action.model_dump(by_alias=True))` raises where a cost is
99
+ populated. Use `model_dump(mode="json")`, which renders the amount as a
100
+ string with a `.` decimal point rather than the `,` the API sent.
101
+ - A malformed value now raises where it previously passed through as a
102
+ string. This is the same trade `OptionalDateTime` already makes — a wrong
103
+ number is worse than a loud failure — and it carries the same blast radius:
104
+ the descriptor validates a page in a list comprehension, so one bad value
105
+ fails a whole `list_actions` call. See `O-COSTGROUP` in
106
+ `docs/vendor-api-reference.md`.
107
+
108
+ ### Documentation
109
+
110
+ - `docs/vendor-api-reference.md` records, as tier 2 read on two 2025.3
111
+ deployments on 2026-09-02, that `/requests/{rfc_number}/tasks` is **POST
112
+ only** and that **no task read route exists** under any spelling — the only
113
+ timeline reads are `GET /actions`, `/actions/{id}` and
114
+ `/actions/{id}/{comment}`. A task is written as a task and read back as an
115
+ action, which is why there is no `list_tasks`/`iter_tasks` and why
116
+ `create_task` returns an `Action`. `create_task`'s docstring now says so
117
+ where a reader meets it.
118
+ - Same file, tier 4: the effort-column measurements above, and two side
119
+ findings. `ACTION_LABEL_*` is the label of the workflow **step**, not the
120
+ name of the action type — type 20 appeared under six different labels — so
121
+ it is a stable type name only for the non-workflow types. And types 14, 27
122
+ and 28 have an empty label in every language column at both list and item
123
+ level, so those ids cannot be named through the API at all (`O-ACTIONTYPE28`).
124
+
12
125
  ## [0.2.0] - 2026-09-02
13
126
 
14
127
  The first release since `0.1.0`. A `0.2.0` section was prepared and dated
@@ -1002,6 +1115,13 @@ the release is additive.
1002
1115
 
1003
1116
  Initial public release.
1004
1117
 
1118
+ > **The tag is authoritative, and it disagrees with this date.** The `0.1.0` tag
1119
+ > points at `3216a33` (2026-08-04), roughly 150 commits after `6df6a75`, the
1120
+ > commit this section was written to describe. Per the policy above, `0.1.0`
1121
+ > **is** the tree at `3216a33`; the date on this heading and the scope below
1122
+ > under-describe it. The tag is published, so it is not being moved — this note
1123
+ > exists so the discrepancy is recorded rather than rediscovered.
1124
+
1005
1125
  ### Added
1006
1126
 
1007
1127
  - Synchronous `EasyvistaClient` and asynchronous `AsyncEasyvistaClient` over the
@@ -1022,6 +1142,7 @@ Initial public release.
1022
1142
  status/error code, with non-retryable validation errors (HTTP 590, code 2013).
1023
1143
  - `py.typed` marker — the package ships inline type information.
1024
1144
 
1025
- [Unreleased]: https://github.com/baraline/easyvista_python_client/compare/v0.2.0...HEAD
1026
- [0.2.0]: https://github.com/baraline/easyvista_python_client/compare/0.1.0...v0.2.0
1145
+ [Unreleased]: https://github.com/baraline/easyvista_python_client/compare/0.3.0...HEAD
1146
+ [0.3.0]: https://github.com/baraline/easyvista_python_client/compare/0.2.0...0.3.0
1147
+ [0.2.0]: https://github.com/baraline/easyvista_python_client/compare/0.1.0...0.2.0
1027
1148
  [0.1.0]: https://github.com/baraline/easyvista_python_client/releases/tag/0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: easyvista-python-client
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Typed Python client for the EasyVista Service Manager REST API
5
5
  Project-URL: Homepage, https://github.com/baraline/easyvista_python_client
6
6
  Project-URL: Documentation, https://easyvista-python-client.readthedocs.io/en/latest/
@@ -64,7 +64,10 @@ Typed Python client for the EasyVista Service Manager REST API. Sync + async,
64
64
  Pydantic models, Bearer or Basic auth.
65
65
 
66
66
  While the package is preparing for 1.0, breaking changes may land between
67
- minor versions; a deprecation policy will follow the 1.0 release.
67
+ **minor** versions; a deprecation policy will follow the 1.0 release. A patch
68
+ release never carries one. Each breaking change is marked `**BREAKING**` in its
69
+ `CHANGELOG.md` section with the reasoning, so read the section for the version
70
+ you are moving to.
68
71
 
69
72
  ## Documentation
70
73
 
@@ -11,7 +11,10 @@ Typed Python client for the EasyVista Service Manager REST API. Sync + async,
11
11
  Pydantic models, Bearer or Basic auth.
12
12
 
13
13
  While the package is preparing for 1.0, breaking changes may land between
14
- minor versions; a deprecation policy will follow the 1.0 release.
14
+ **minor** versions; a deprecation policy will follow the 1.0 release. A patch
15
+ release never carries one. Each breaking change is marked `**BREAKING**` in its
16
+ `CHANGELOG.md` section with the reasoning, so read the section for the version
17
+ you are moving to.
15
18
 
16
19
  ## Documentation
17
20
 
@@ -27,11 +27,23 @@ Cutting a release
27
27
  #. Move the ``CHANGELOG.md`` ``[Unreleased]`` entries under the new version and update the
28
28
  compare links at the bottom of the file.
29
29
  #. Merge to ``main`` and let CI go green.
30
- #. Publish a GitHub release whose tag is the version, ``v``-prefixed --
31
- ``v0.2.0`` for version ``0.2.0``. (The workflow strips a leading ``v`` before comparing,
32
- so an unprefixed tag also passes. The only tag that exists today, ``0.1.0``, is
33
- **unprefixed** -- ``v``-prefixing starts at ``v0.2.0``, which is why the
34
- ``CHANGELOG.md`` link for ``0.1.0`` points at the bare tag.)
30
+ #. Publish a GitHub release whose tag is the version, **unprefixed** -- ``0.3.0``
31
+ for version ``0.3.0``, never ``v0.3.0``.
32
+
33
+ .. warning::
34
+
35
+ This step previously ordered a ``v``-prefixed tag and said "``v``-prefixing
36
+ starts at ``v0.2.0``". That never happened: **both tags that exist are
37
+ bare** -- ``0.1.0`` and ``0.2.0`` -- so following the old instruction would
38
+ have produced a repository with two tag conventions. It also left the
39
+ ``CHANGELOG.md`` compare links pointing at a ``v0.2.0`` that does not
40
+ exist, so two of them 404ed until 0.3.0 fixed them.
41
+
42
+ **The tag is what defines a version**; the prose is corrected to match the
43
+ tags, not the other way round. Keep every tag bare, and keep the
44
+ ``CHANGELOG.md`` links bare with it. (The release workflow strips a leading
45
+ ``v`` before comparing, so a prefixed tag would still *build* -- which is
46
+ exactly why this drifted unnoticed.)
35
47
 
36
48
  The workflow then runs the test matrix (3.10--3.14) and the quality gates -- Ruff, mypy,
37
49
  the generated-``_sync``-tree check, the hand-written-twin lint and a warnings-as-errors
@@ -1160,7 +1160,8 @@ Timestamps
1160
1160
  ``Request``'s timestamp fields (``submit_date_ut``, ``creation_date_ut``,
1161
1161
  ``max_resolution_date_ut``, ``expected_date_ut``, ``end_date_ut``,
1162
1162
  ``last_update``), ``Employee.last_update``, and ``Action.created_at`` /
1163
- ``Action.updated_at`` are timezone-aware
1163
+ ``Action.updated_at`` / ``Action.start_date_ut`` / ``Action.end_date_ut`` are
1164
+ timezone-aware
1164
1165
  :class:`datetime.datetime`, parsed from EasyVista's ISO-8601-with-offset wire
1165
1166
  format (``2026-08-17T15:40:41.610+02:00``, millisecond precision — verified
1166
1167
  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
1193
1194
  ``json.dumps(record.model_dump(by_alias=True))`` and
1194
1195
  ``json.dumps(record.classify_fields().official)`` raise
1195
1196
  ``TypeError: Object of type datetime is not JSON serializable``. For a dump,
1196
- pass ``model_dump(mode="json")``. ``classify_fields()`` takes **no arguments**,
1197
- so there is nowhere to put that keyword: render the ``datetime`` values with
1198
- :func:`~easyvista_python_client.format_ev_datetime` before serialising the
1199
- bucket, or classify the JSON-mode dump yourself — the buckets are keyed by
1200
- wire column name, so ``{k: dumped[k] for k in record.classify_fields().official}``
1201
- over ``dumped = record.model_dump(mode="json", by_alias=True)`` gives the same
1197
+ pass ``model_dump(mode="json")``.
1198
+
1199
+ **Since 0.3.0 an ``Action`` can also carry a ``Decimal``** — ``time_cost``
1200
+ and ``contractual_cost`` — which raises the same way
1201
+ (``TypeError: Object of type Decimal is not JSON serializable``).
1202
+ ``model_dump(mode="json")`` handles it too, rendering the amount as a string.
1203
+
1204
+ ``classify_fields()`` takes **no arguments**, so there is nowhere to put that
1205
+ keyword. The only recipe that covers *both* types is to classify the
1206
+ JSON-mode dump yourself — the buckets are keyed by wire column name, so
1207
+ ``{k: dumped[k] for k in record.classify_fields().official}`` over
1208
+ ``dumped = record.model_dump(mode="json", by_alias=True)`` gives the same
1202
1209
  split with serialisable values.
1203
1210
 
1211
+ .. warning::
1212
+
1213
+ Rendering the bucket with
1214
+ :func:`~easyvista_python_client.format_ev_datetime` — which earlier
1215
+ revisions of this page offered as the alternative — **only ever handled
1216
+ ``datetime``**, and now leaves a ``Decimal`` in place to raise on
1217
+ ``json.dumps``. Use the JSON-mode dump above instead. Note the amount then
1218
+ renders with a ``.`` decimal point, not the ``,`` the API sent.
1219
+
1204
1220
  Use :func:`~easyvista_python_client.format_ev_datetime` to render a
1205
1221
  ``datetime`` back into the literal EasyVista's grammar accepts (e.g. as an
1206
1222
  interval bound above), and :func:`~easyvista_python_client.parse_ev_datetime`
@@ -104,6 +104,114 @@ and not proof of it; it omits `group_id`, `group_name` and `comment`, which
104
104
  it lists `available_field_1`/`_6`, which `PostTask` does not declare and which
105
105
  `extra_payload` reaches.
106
106
 
107
+ ### A task is write-only as a resource, and read back as an action
108
+
109
+ **Tier 2, read 2026-09-02** on the development instance (100 paths), and
110
+ independently the same day on a second deployment (also 2025.3, also 100
111
+ paths). Both declare exactly:
112
+
113
+ | Path | Verbs |
114
+ | --- | --- |
115
+ | `/requests/{rfc_number}/tasks` | **POST only** |
116
+ | `/requests/{rfc_number}/actions` | POST only |
117
+ | `/actions` | GET |
118
+ | `/actions/{id}` | GET, PATCH, PUT |
119
+ | `/actions/{id}/{comment}` | GET |
120
+
121
+ There is **no read route for a task** — no `GET /requests/{rfc}/tasks`, no
122
+ `/tasks/{id}`, nothing under any other spelling. The only timeline reads are
123
+ the three `/actions` routes. So a task is *written* through `tasks` and *read
124
+ back* through `actions`, and that is the whole story: a task and an action are
125
+ the same row in the same table, differing only in the state they are born in
126
+ (open vs already ended). This is why the package has `list_actions` and
127
+ deliberately **no `list_tasks`/`iter_tasks`** — there is no route to wrap.
128
+
129
+ A GET against the tasks path answers `403 "Unauthorized Method for your
130
+ profile"`, which per *Route topology* above proves nothing either way; the
131
+ spec's `paths` is what settles it.
132
+
133
+ **`create_task` returns an `Action`.** That is where a reader first meets the
134
+ confusion, and the annotation is correct rather than sloppy: there is no task
135
+ resource to model, so there is no `Task` read model and could not be one.
136
+
137
+ ### The effort columns, and why they do not discriminate task from action
138
+
139
+ Five columns on an action record — `ELAPSED_TIME`, `TIME_COST`,
140
+ `CONTRACTUAL_COST`, `START_DATE_UT`, `END_DATE_UT` — are declared on `Action`
141
+ as of 0.3.0. Until then they arrived only as `extra="allow"` extras: untyped
142
+ strings, with a French decimal comma on the two costs.
143
+
144
+ **Tier 4, measured 2026-09-02, 1500 action rows on the development instance**
145
+ (one instance, one date, so it may not generalise), corroborated by an
146
+ independent measurement the same day on that second deployment (1465 timeline
147
+ entries across 120 tickets), which agreed on every point below.
148
+
149
+ **`""` and `"0"` are different answers.** `""` means the column does not apply
150
+ to this record; `"0"` (or `"0,00"`) means it applies and is zero.
151
+
152
+ | Column | `""` | zero | non-zero |
153
+ | --- | --- | --- | --- |
154
+ | `ELAPSED_TIME` | 384 | 895 (`'0'`) | 221 |
155
+ | `TIME_COST` | 691 | 808 (`'0,00'`) | 1 (`'99,00'`) |
156
+ | `CONTRACTUAL_COST` | 691 | 808 (`'0,00'`) | 1 (`'129,00'`) |
157
+
158
+ A parser that maps both to `0`, or both to `None`, destroys the only signal
159
+ that says whether a record tracks effort. `Action` preserves it: `None` for
160
+ `""`, `0` / `Decimal("0.00")` for the zeroes.
161
+
162
+ **The shape heuristic is false in both directions.** It is tempting to read
163
+ "workflow rows carry `WORKFLOW_ID`/`STAGE_ID` with `ELAPSED_TIME='0'` and
164
+ `'0,00'` costs, task-shaped rows carry none of it and empty effort" as a
165
+ task/action discriminator. Measured, it fails both ways:
166
+
167
+ * **173 of 1500** rows carried a `WORKFLOW_ID` *and* an empty `ELAPSED_TIME`
168
+ — 126 of them the type-20 `Analyse et résolution` workflow step. So
169
+ "workflow row ⇒ effort is `'0'`" is false.
170
+ * **171 of 1500** rows carried no `WORKFLOW_ID` *and* a non-empty
171
+ `ELAPSED_TIME`. Among them **39 of the 49** type-94 `Commentaire [Public]`
172
+ rows — ordinary public comments — usually with `ELAPSED_TIME='1'`. One
173
+ public comment carried `ELAPSED_TIME='12'`, `TIME_COST='99,00'` and
174
+ `CONTRACTUAL_COST='129,00'`. So "effort recorded ⇒ not a comment" is false,
175
+ and a filter built on it drops four public comments in five.
176
+
177
+ `ACTION_TYPE_ID` alone does not discriminate either, which the second
178
+ deployment measured directly: type 94 appeared in both shapes there (74 rows
179
+ with a `PARENT_ACTION_ID`, 18 without; 37 with a non-zero `ELAPSED_TIME`, 55
180
+ without).
181
+
182
+ What an effort column reports is **whether effort was recorded**, not what kind
183
+ of record this is. No column examined across those 1500 rows recorded which
184
+ route created it — stated as a measurement, not as a proof of absence: the
185
+ item-level record carries 88 columns, not all of which were tallied, and a
186
+ deployment may populate one this instance leaves empty. If you find a column
187
+ that does discriminate, it belongs here.
188
+
189
+ **What *is* clean: `WORKFLOW_ID`.** 1500/1500 rows — a `WORKFLOW_ID` is set iff
190
+ the workflow engine produced the row. No row of the conversation types (94
191
+ `Commentaire [Public]`, 95 `Note Interne [Privé]`, 7 `Appel`) carried one.
192
+ `Action.is_workflow_generated` exposes exactly that and nothing more. Deciding
193
+ which of the remaining types count as conversation is per-deployment policy —
194
+ an `action_type_id` allowlist — and stays with the caller.
195
+
196
+ **Side finding, tier 4, same measurement: `ACTION_LABEL_*` is the label of the
197
+ workflow *step*, not the name of the action type.** Type 20 appeared as
198
+ `Analyse et résolution` (126 rows), `Traitement` (10), `Traitement du refus`,
199
+ `Traitement de la demande`, `test` and `notif`; type 30 as `stocker le groupe
200
+ d'implémentation`, `Mise à jour SLA` and `sauvegarde`; type 82 under two
201
+ labels. So for **workflow** types the label varies row to row and cannot be
202
+ used as a type name. For the non-workflow types (94, 95, 7) it was stable and
203
+ is the type's real name. This qualifies the *Visibility is by action type*
204
+ note: `discover("ACTION_TYPE")` recovers real names for the human types, and
205
+ per-step text for the workflow ones.
206
+
207
+ **Types 14, 27 and 28 have an empty `ACTION_LABEL_*` in every language column**
208
+ — all six on the list projection and all twelve (`_EN`, `_FR`, `_GE`, `_IT`,
209
+ `_PO`, `_SP`, `_L1`..`_L6`) on the item GET. There is no `action-types` route
210
+ to ask, so on this deployment those ids **cannot be named through the API at
211
+ all**. What is known about 28 is behavioural, not nominal: it is the row that
212
+ carries the text passed to `set_status(comment=...)`, so it must not be
213
+ filtered out of a timeline read.
214
+
107
215
  Also worth recording without acting on it: the instance's `POST /assets` schema
108
216
  (tier 3) titles its array `asset` while its own example uses `assets`, which is
109
217
  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.
143
251
  | Path | Verbs | Note |
144
252
  | --- | --- | --- |
145
253
  | `/requests/{rfc_number}/actions` | POST | create-only; no nested list, item or update |
254
+ | `/requests/{rfc_number}/tasks` | POST | create-only; **no task read route exists** — read them back through `/actions` |
146
255
  | `/actions` | GET | the only action list |
147
256
  | `/actions/{id}` | GET, PATCH, PUT | the only action item; **no DELETE** |
257
+ | `/actions/{id}/{comment}` | GET | `{comment}` is a memo-field *selector*, not a literal |
148
258
  | `/requests/{RFC_NUMBER}/documents` | GET, POST | |
149
259
  | `/requests/{RFC_NUMBER}/documents/{id}` | GET, DELETE | what this package sends by default |
150
260
  | `/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
217
327
  docstrings now hedge. Until someone either re-reads the vendor page and adds
218
328
  the row here, or measures the omitted form live and dates it, the
219
329
  documentation must not assert it.
330
+ * **O-COSTGROUP** — `TIME_COST` / `CONTRACTUAL_COST` are parsed by
331
+ `models/common._parse_ev_decimal`, which accepts either decimal separator and
332
+ **refuses a grouping separator** rather than guessing (`'1.234,56'` and
333
+ `'1,234.56'` are the same amount under opposite conventions). Every amount
334
+ observed live had exactly two fraction digits and no grouping (1500 rows,
335
+ 2026-09-02), so the refusal has never fired. It **also** refuses three or more
336
+ fraction digits, for the same ambiguity (`'1,234'` could be `1.234` or a
337
+ comma-grouped `1234`) — which means a genuinely 3-decimal currency is refused
338
+ too. **Magnitude is not a trigger**: `'1000,00'` parses fine, since it carries
339
+ no grouping separator. Because the descriptor validates a page in a list
340
+ comprehension, a refusal fails a whole `list_actions` call, not one row. If a
341
+ refused literal is ever seen, record it here and widen the pattern with
342
+ evidence.
343
+ * **O-ACTIONTYPE28** — types 14, 27 and 28 have an empty `ACTION_LABEL_*` in
344
+ every language column at both list and item level, and there is no
345
+ `action-types` route, so nothing in the API can name them. Type 28 is known
346
+ behaviourally (it carries `set_status(comment=...)` text) and 14 and 27 not
347
+ at all. Settling this needs the EasyVista **admin console**, not the API: the
348
+ administration screen listing action types, and specifically which type ids
349
+ that deployment classes as *task* types. Nobody working on this package has
350
+ console access; if you do, transcribe the list here.
220
351
  * **O-TASKDOC** — transcribe the vendor's create-a-task field table into the
221
352
  section above, so `PostTask` can be diffed against tier 1. Until then
222
353
  `action_type_guid` is declared on `PostAction` (tier 1, 2023.4+) and **not**
@@ -629,6 +629,27 @@ class AsyncEasyvistaClient:
629
629
  no ``parent_action_id`` and works on a ticket at any stage, including
630
630
  one whose open actions a status change has already drained.
631
631
 
632
+ **This returns an** :class:`~easyvista_python_client.Action`, and that
633
+ is not a mistake in the annotation: a task *is* an action record, and
634
+ there is no separate task resource to model. ``tasks`` is a create-only
635
+ route — the instance's own OpenAPI declares
636
+ ``POST /requests/{rfc_number}/tasks`` and no other verb on it, and
637
+ declares no read route for a task anywhere (tier 2, read 2026-09-02 on
638
+ one deployment, 100 paths, and independently on a second the same day).
639
+ The only timeline reads are ``GET /actions``, ``GET /actions/{id}`` and
640
+ ``GET /actions/{id}/{comment}``, so **a task is written as a task and
641
+ read back as an action** — which is why this package has
642
+ :meth:`list_actions` and no ``list_tasks``. A GET against the tasks
643
+ path answers 403, and per
644
+ ``docs/vendor-api-reference.md`` no 403 on this API distinguishes an
645
+ absent route from a denied one, so the spec is what settles it.
646
+
647
+ A corollary worth stating, because it is where the read side goes
648
+ wrong: **once written, nothing on the record says which route created
649
+ it.** In particular the effort columns do not — see
650
+ :attr:`~easyvista_python_client.Action.is_workflow_generated` for the
651
+ measurements that refute the tempting heuristic.
652
+
632
653
  Like :meth:`create_action`, the returned :class:`Action` carries **no
633
654
  usable ``action_id``** — the create response is an HREF naming the
634
655
  parent request. Diff :meth:`list_actions` across the call to address
@@ -629,6 +629,27 @@ class EasyvistaClient:
629
629
  no ``parent_action_id`` and works on a ticket at any stage, including
630
630
  one whose open actions a status change has already drained.
631
631
 
632
+ **This returns an** :class:`~easyvista_python_client.Action`, and that
633
+ is not a mistake in the annotation: a task *is* an action record, and
634
+ there is no separate task resource to model. ``tasks`` is a create-only
635
+ route — the instance's own OpenAPI declares
636
+ ``POST /requests/{rfc_number}/tasks`` and no other verb on it, and
637
+ declares no read route for a task anywhere (tier 2, read 2026-09-02 on
638
+ one deployment, 100 paths, and independently on a second the same day).
639
+ The only timeline reads are ``GET /actions``, ``GET /actions/{id}`` and
640
+ ``GET /actions/{id}/{comment}``, so **a task is written as a task and
641
+ read back as an action** — which is why this package has
642
+ :meth:`list_actions` and no ``list_tasks``. A GET against the tasks
643
+ path answers 403, and per
644
+ ``docs/vendor-api-reference.md`` no 403 on this API distinguishes an
645
+ absent route from a denied one, so the spec is what settles it.
646
+
647
+ A corollary worth stating, because it is where the read side goes
648
+ wrong: **once written, nothing on the record says which route created
649
+ it.** In particular the effort columns do not — see
650
+ :attr:`~easyvista_python_client.Action.is_workflow_generated` for the
651
+ measurements that refute the tempting heuristic.
652
+
632
653
  Like :meth:`create_action`, the returned :class:`Action` carries **no
633
654
  usable ``action_id``** — the create response is an HREF naming the
634
655
  parent request. Diff :meth:`list_actions` across the call to address
@@ -5,4 +5,4 @@
5
5
  live only in ``__init__``.
6
6
  """
7
7
 
8
- __version__ = "0.2.0"
8
+ __version__ = "0.3.0"
@@ -11,6 +11,7 @@ from .common import (
11
11
  EasyvistaModel,
12
12
  EasyvistaWriteModel,
13
13
  OptionalDateTime,
14
+ OptionalDecimal,
14
15
  OptionalInt,
15
16
  _shipped_keys,
16
17
  )
@@ -42,6 +43,42 @@ class Action(EasyvistaModel):
42
43
  request instead of an item fetch per action — ``list_actions`` returns one
43
44
  page and does not paginate, so it is a page's worth, not a ticket's.
44
45
 
46
+ **Effort columns: ``""`` and ``"0"`` are different answers.** ``elapsed_time``
47
+ (minutes), ``time_cost``, ``contractual_cost``, ``start_date_ut`` and
48
+ ``end_date_ut`` are declared as of 0.3.0, having previously reached callers
49
+ only as untyped ``extra="allow"`` strings. When the API **returns** the
50
+ column, ``""`` means it does not apply to this record and ``"0"`` /
51
+ ``"0,00"`` means it applies and is zero (tier 4, measured 2026-09-02 over
52
+ 1500 rows on two instances -- so it may not generalise). This model preserves
53
+ that distinction deliberately -- ``None`` for ``""``, ``0`` /
54
+ ``Decimal("0.00")`` for the zeroes -- because collapsing the two destroys the
55
+ only signal saying whether a record tracks effort at all. The two cost columns
56
+ arrive with a French decimal comma (``'99,00'``) and parse to an exact
57
+ :class:`~decimal.Decimal`. Either decimal separator is accepted; a grouping
58
+ separator and three-or-more fraction digits are **refused** rather than
59
+ guessed at, because ``'1.234,56'`` and ``'1,234.56'`` are the same amount
60
+ under opposite conventions. Magnitude is not a trigger -- ``'1000,00'``
61
+ parses. The parser is ``models/common.py::_parse_ev_decimal``, named as a
62
+ path rather than cross-referenced because it is private and carries no
63
+ rendered API-reference page.
64
+
65
+ .. warning::
66
+
67
+ **``None`` is ambiguous, and the default list row is the trap.** None of
68
+ these five columns rides the default ``list_actions`` projection, so on a
69
+ default list row every one of them reads ``None`` -- meaning *not
70
+ returned*, not *does not apply*. The two are indistinguishable on the
71
+ model. Project them explicitly (``list_actions(rfc, fields=[...,
72
+ "ELAPSED_TIME", "WORKFLOW_ID"])``) or read item-level with
73
+ :meth:`~easyvista_python_client.EasyvistaClient.get_action` before reading
74
+ any meaning into a ``None``. To tell the two apart from a raw response,
75
+ check whether the key is present at all -- once validated, that is gone.
76
+
77
+ What that signal does **not** settle is whether a row was created as a task
78
+ or as an action -- nothing on the record does, and the effort-shape heuristic
79
+ that looks like it should is measurably wrong in both directions. See
80
+ :attr:`is_workflow_generated`.
81
+
45
82
  Naming: this model calls its two timestamps ``created_at``/``updated_at``
46
83
  where :class:`~easyvista_python_client.models.request.Request` and
47
84
  :class:`~easyvista_python_client.models.employee.Employee` mirror the wire
@@ -103,6 +140,29 @@ class Action(EasyvistaModel):
103
140
  stage_id: OptionalInt = Field(default=None, alias="STAGE_ID")
104
141
  workflow_id: OptionalInt = Field(default=None, alias="WORKFLOW_ID")
105
142
  parent_action_id: OptionalInt = Field(default=None, alias="PARENT_ACTION_ID")
143
+ # --- effort and cost (EV-TASKSHAPE, added 0.3.0) -------------------------
144
+ # Until 0.3.0 these five reached callers only as untyped ``extra="allow"``
145
+ # strings. **The "" sentinel and "0" are different answers** -- "" means the
146
+ # column does not apply to this record, "0" that it applies and is zero --
147
+ # and ``OptionalInt``/``OptionalDecimal`` preserve exactly that: ``None`` for
148
+ # "", ``0`` for "0". Measured over 1500 live rows 2026-09-02, ELAPSED_TIME
149
+ # was "" on 384 and "0" on 895; the costs were "" on 691 and "0,00" on 808.
150
+ #
151
+ # ``elapsed_time`` is in MINUTES, is never derived from the window, and is
152
+ # stored verbatim even when it contradicts it -- but a zero-length window
153
+ # (``start_date_ut == end_date_ut``) stores 0 whatever was sent.
154
+ #
155
+ # Named for the wire (``start_date_ut``/``end_date_ut``) to match
156
+ # ``Request.end_date_ut``, rather than following this model's own
157
+ # ``created_at``/``updated_at``, which the class docstring flags as a wart.
158
+ # Both are the ACTION's effort window: unlike ``Request.end_date_ut``, which
159
+ # is stamped at RESOLUTION, an action's ``END_DATE_UT`` is set when the
160
+ # action itself is ended.
161
+ elapsed_time: OptionalInt = Field(default=None, alias="ELAPSED_TIME")
162
+ time_cost: OptionalDecimal = Field(default=None, alias="TIME_COST")
163
+ contractual_cost: OptionalDecimal = Field(default=None, alias="CONTRACTUAL_COST")
164
+ start_date_ut: OptionalDateTime = Field(default=None, alias="START_DATE_UT")
165
+ end_date_ut: OptionalDateTime = Field(default=None, alias="END_DATE_UT")
106
166
 
107
167
  @model_validator(mode="after")
108
168
  def _derive_action_id_from_href(self) -> Action:
@@ -150,6 +210,58 @@ class Action(EasyvistaModel):
150
210
  """
151
211
  return localized_label(self.model_dump(by_alias=True), "ACTION_LABEL")
152
212
 
213
+ @property
214
+ def is_workflow_generated(self) -> bool:
215
+ """Whether the workflow engine owns this row, i.e. ``WORKFLOW_ID`` is set.
216
+
217
+ A ticket's catalog workflow auto-spawns its own action rows -- about a
218
+ dozen on a freshly created ticket -- and each carries a ``WORKFLOW_ID``
219
+ naming the workflow instance. Rows created by a person or by an API
220
+ caller do not. Measured 2026-09-02 over 1500 live action rows on one
221
+ instance (tier 4, so it may not generalise): no row of the
222
+ conversation-bearing types (94 ``Commentaire [Public]``, 95 ``Note
223
+ Interne [Prive]``, 7 ``Appel``) carried one, and every row of the
224
+ workflow step types (1, 20, 21, 23, 30, 32, 33, 50, 65, 82, 84) that
225
+ the engine had produced did.
226
+
227
+ .. warning::
228
+
229
+ **This is not a task/action discriminator, and there is none.** A
230
+ task (``create_task``) and an action (``create_action``) are the same
231
+ record in the same table, told apart only by the state they are born
232
+ in -- open versus already ended -- and **neither ``WORKFLOW_ID`` nor
233
+ the effort columns recover which route created a row**. Measured
234
+ 2026-09-02, both directions of the tempting shape heuristic fail on
235
+ one instance:
236
+
237
+ * 173 of 1500 rows had a ``WORKFLOW_ID`` **and** an empty
238
+ ``ELAPSED_TIME`` -- 126 of them the type-20 ``Analyse et
239
+ resolution`` workflow step. So "workflow row => effort is 0" is
240
+ false.
241
+ * 39 of 49 type-94 ``Commentaire [Public]`` rows -- ordinary public
242
+ comments -- carried a **non-empty** ``ELAPSED_TIME``, usually
243
+ ``'1'``; one carried ``ELAPSED_TIME='12'``, ``TIME_COST='99,00'``
244
+ and ``CONTRACTUAL_COST='129,00'``. So "effort set => not a
245
+ comment" is false, and a filter built on it drops four public
246
+ comments in five.
247
+
248
+ What an effort column reports is whether *effort was recorded*, not
249
+ what kind of record this is. Deciding which action types count as
250
+ conversation is per-deployment policy: pin the ``action_type_id``
251
+ allowlist your administrator confirms, and use this property only to
252
+ exclude the workflow engine's own rows. See
253
+ ``docs/vendor-api-reference.md`` for the measurements.
254
+
255
+ A plain property, not a serialized field, so it never appears in
256
+ ``model_dump`` or ``classify_fields``. ``False``, never ``None``, when
257
+ ``WORKFLOW_ID`` is absent from the projection -- the default
258
+ ``list_actions`` row omits it, so on a default list row this reads
259
+ ``False`` for every action whether or not the engine owns it. Project
260
+ ``WORKFLOW_ID`` explicitly (``list_actions(rfc,
261
+ fields=[..., "WORKFLOW_ID"])``) or read item-level before trusting it.
262
+ """
263
+ return self.workflow_id is not None
264
+
153
265
 
154
266
  class PostAction(EasyvistaWriteModel):
155
267
  """Payload for creating an action on a ticket.
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  from collections.abc import Sequence
6
7
  from datetime import datetime, timezone
8
+ from decimal import Decimal
7
9
  from typing import Annotated, Any
8
10
 
9
11
  from pydantic import (
@@ -37,6 +39,88 @@ OptionalInt = Annotated[int | None, BeforeValidator(_empty_str_to_none)]
37
39
  """An ``int | None`` field that treats the API's ``""`` sentinel as ``None``."""
38
40
 
39
41
 
42
+ # An optional sign, digits, then at most one separator and one or two fraction
43
+ # digits. Deliberately refuses a grouping separator -- see the validator.
44
+ _EV_DECIMAL = re.compile(r"^[+-]?\d+(?:[.,]\d{1,2})?$")
45
+
46
+
47
+ def _parse_ev_decimal(value: Any) -> Any:
48
+ """Parse EasyVista's locale-formatted money column into an exact ``Decimal``.
49
+
50
+ The instance renders a currency column with its own decimal separator: on
51
+ the verified deployment ``TIME_COST`` and ``CONTRACTUAL_COST`` come back as
52
+ ``'0,00'`` / ``'99,00'`` — a **comma** — which is why this exists rather
53
+ than the column being a plain ``float``. Both separators are accepted, so a
54
+ dot-configured deployment needs no setting; the separator is a deployment's
55
+ locale, not an EasyVista constant.
56
+
57
+ ``""`` maps to ``None`` and that is load-bearing: ``""`` means the column
58
+ does **not apply** to this record, while ``'0,00'`` means it applies and is
59
+ zero. Collapsing the two — to ``0`` or to ``None`` — destroys the only
60
+ signal that says whether a record tracks cost at all. See
61
+ :class:`~easyvista_python_client.models.action.Action` for what that signal
62
+ is worth and what it does **not** prove.
63
+
64
+ **Two shapes raise rather than being guessed at**, both of them formats this
65
+ parser has never seen live:
66
+
67
+ * **A grouping separator.** ``'1.234,56'`` and ``'1,234.56'`` are the same
68
+ amount under opposite conventions, and ``'9,999'`` is either ``9.999`` or
69
+ ``9999`` with nothing in the value to say which.
70
+ * **Three or more fraction digits.** ``'1,234'`` is refused for the same
71
+ reason — it is indistinguishable from a comma-grouped ``1234`` — which
72
+ also means a genuinely 3-decimal currency is refused here.
73
+
74
+ Note what is **not** a trigger: magnitude. ``'1000,00'`` parses fine, because
75
+ it carries no grouping separator. An earlier revision of this docstring said
76
+ "an amount above 999 is the case to watch", which was wrong — what matters is
77
+ the *format*, not the size.
78
+
79
+ Every amount observed live carried exactly two fraction digits and no
80
+ grouping (1500 rows, 2026-09-02: ``'0,00'``, ``'99,00'``, ``'129,00'``).
81
+ Refusing is the same trade :func:`_empty_str_to_none_datetime` makes — a
82
+ wrong number is worse than a loud failure — and it carries the same cost:
83
+ ``resources/descriptor.py`` validates a page in a list comprehension, so one
84
+ unparseable amount fails the whole ``list_actions`` call rather than that
85
+ row. See ``O-COSTGROUP`` in ``docs/vendor-api-reference.md``.
86
+
87
+ ``Decimal``, not ``float``, because these are money: ``Decimal('0.10') +
88
+ Decimal('0.20') == Decimal('0.30')`` and the float equivalent does not.
89
+ A non-string value (an ``int``, ``float`` or ``Decimal`` handed in
90
+ directly) passes through to pydantic's own ``Decimal`` validator untouched.
91
+ """
92
+ if value is None:
93
+ return None
94
+ if isinstance(value, str):
95
+ text = value.strip()
96
+ if not text:
97
+ return None
98
+ if not _EV_DECIMAL.match(text):
99
+ raise ValueError(
100
+ f"{value!r} is not an EasyVista amount. Expected digits with at "
101
+ "most one decimal separator ('.' or ',') and ONE OR TWO "
102
+ "fraction digits, e.g. '0,00' or '99.00'. Refused, rather than "
103
+ "guessed at: a grouping separator (because '1.234,56' and "
104
+ "'1,234.56' are the same amount under opposite conventions), and "
105
+ "three or more fraction digits (because '1,234' is "
106
+ "indistinguishable from a comma-grouped 1234). Magnitude is not "
107
+ "a trigger -- '1000,00' parses. If this instance really formats "
108
+ "amounts this way, that is a finding worth recording -- report "
109
+ "the value."
110
+ )
111
+ return Decimal(text.replace(",", "."))
112
+ return value
113
+
114
+
115
+ OptionalDecimal = Annotated[Decimal | None, BeforeValidator(_parse_ev_decimal)]
116
+ """An exact ``Decimal | None`` for an EasyVista currency column.
117
+
118
+ Accepts either decimal separator and maps the ``""`` sentinel to ``None``,
119
+ which is **not** the same answer as ``Decimal('0.00')`` — see
120
+ :func:`_parse_ev_decimal`.
121
+ """
122
+
123
+
40
124
  def _parse_with_context_formats(value: Any, info: ValidationInfo) -> datetime | None:
41
125
  """Try the caller's own timestamp formats, if it supplied any.
42
126
 
@@ -26,6 +26,7 @@ from __future__ import annotations
26
26
  from collections.abc import Iterator, Sequence
27
27
  from dataclasses import dataclass
28
28
  from datetime import datetime
29
+ from decimal import Decimal
29
30
  from functools import lru_cache
30
31
  from typing import Any
31
32
 
@@ -141,7 +142,14 @@ def _scalar(value: Any) -> str | None:
141
142
  return format_ev_datetime(value)
142
143
  except ValueError:
143
144
  return value.isoformat()
144
- if isinstance(value, (str, int)) and str(value).strip():
145
+ # ``Decimal`` joins ``str``/``int`` here because the read path retypes the
146
+ # currency columns (``TIME_COST``, ``CONTRACTUAL_COST``) into exact
147
+ # ``Decimal``s. Without this branch ``reference("TIME_COST")`` returned an
148
+ # EMPTY Reference on a populated column -- the same class of silent gap the
149
+ # ``datetime`` branch above was added to close. Rendered with ``str`` rather
150
+ # than the wire's own decimal comma: this extractor feeds labels and
151
+ # identifiers, not money formatting, and it must never raise.
152
+ if isinstance(value, (str, int, Decimal)) and str(value).strip():
145
153
  return str(value).strip()
146
154
  return None
147
155
 
@@ -44,7 +44,7 @@ exclude = [
44
44
 
45
45
  [project]
46
46
  name = "easyvista-python-client"
47
- version = "0.2.0"
47
+ version = "0.3.0"
48
48
  description = "Typed Python client for the EasyVista Service Manager REST API"
49
49
  readme = "README.md"
50
50
  requires-python = ">=3.10"
@@ -5,7 +5,7 @@ license: MIT
5
5
  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."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.2.0"
8
+ version: "0.3.0"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and valid EasyVista credentials."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.2.0"
8
+ version: "0.3.0"
9
9
  ---
10
10
 
11
11
  `easyvista_python_client` ships both clients over one surface:
@@ -5,7 +5,7 @@ license: MIT
5
5
  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)."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.2.0"
8
+ version: "0.3.0"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  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."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.2.0"
8
+ version: "0.3.0"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  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."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.2.0"
8
+ version: "0.3.0"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.10+, easyvista-python-client, and network access to an EasyVista Service Manager REST API."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.2.0"
8
+ version: "0.3.0"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: "Requires Python 3.10+, easyvista-python-client, and network access to an EasyVista Service Manager REST API."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.2.0"
8
+ version: "0.3.0"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,
@@ -5,7 +5,7 @@ license: MIT
5
5
  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."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.2.0"
8
+ version: "0.3.0"
9
9
  ---
10
10
 
11
11
  > **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
130
130
  mandatory; `PostTask` refuses a body missing either rather than letting the
131
131
  server answer with a 590 that names no field.
132
132
 
133
+ **A task is write-only as a resource.** `POST requests/{rfc}/tasks` is the only
134
+ verb the route has, and the API declares **no task read route at all** (tier 2,
135
+ read 2026-09-02 on two 2025.3 deployments). So `create_task` returns an
136
+ `Action`, there is no `Task` read model and no `list_tasks` — a task is written
137
+ as a task and read back through `list_actions` like any other row. A GET against
138
+ the tasks path answers 403, which on this API proves nothing either way.
139
+
140
+ **No action-record column observed so far says which route created it**, so do
141
+ not try to reconstruct the distinction on the read side. In particular the effort
142
+ columns do not: measured over 1500 live rows 2026-09-02, 39 of 49
143
+ `Commentaire [Public]` comments carried a non-empty `ELAPSED_TIME` (one with
144
+ `TIME_COST='99,00'`), while 173 workflow rows carried an empty one. To pick
145
+ conversation out of a timeline, filter on an `action_type_id` allowlist your
146
+ administrator confirms, and use `action.is_workflow_generated` to drop the
147
+ workflow engine's own rows.
148
+
149
+ **`is_workflow_generated` needs `WORKFLOW_ID` projected, or it is always
150
+ `False`.** It reads that one column, and the column is NOT on the default
151
+ `list_actions` projection — so on default rows the filter silently drops nothing
152
+ and every workflow row survives. The same applies to all five effort columns:
153
+ unprojected they read `None`, which is indistinguishable from the `''` that means
154
+ "does not apply". Ask for them:
155
+
156
+ ```python
157
+ actions = client.iter_actions(
158
+ "YOUR_RFC_NUMBER",
159
+ fields=[
160
+ "ACTION_ID", "ACTION_TYPE_ID", "WORKFLOW_ID",
161
+ "ELAPSED_TIME", "START_DATE_UT", "END_DATE_UT",
162
+ ],
163
+ )
164
+ conversation = [
165
+ a for a in actions
166
+ if not a.is_workflow_generated and a.action_type_id in YOUR_COMMENT_TYPE_IDS
167
+ ]
168
+ ```
169
+
133
170
  **If a caller creates an action and stops**, nothing is lost — the text is
134
171
  stored — but the row renders without it until the action is ended. Use
135
172
  **`end_action`**, which wraps the vendor's `PUT actions/{rfc_number}` /
@@ -532,3 +569,20 @@ with EasyvistaClient.from_env() as client:
532
569
  columns. `getattr(record, "last_update")` raises `AttributeError` on an
533
570
  `Action`; for code spanning record types, go through `classify_fields()` or
534
571
  `.reference()`, where the wire alias is uniform.
572
+ - **On the effort columns, `''` and `'0'` are different answers.**
573
+ `elapsed_time` (minutes), `time_cost`, `contractual_cost`, `start_date_ut` and
574
+ `end_date_ut` are declared as of 0.3.0. `''` means the column does not apply
575
+ to this record and reads as `None`; `'0'` / `'0,00'` means it applies and is
576
+ zero, and reads as `0` / `Decimal("0.00")`. Do not normalise the two together
577
+ — that erases the only signal saying whether a record tracks effort at all.
578
+ The costs are exact `Decimal`s parsed from the API's decimal comma; an amount
579
+ with a grouping separator raises rather than being guessed at, and because a
580
+ page is validated all at once that fails the whole `list_actions` call.
581
+ - **`ACTION_LABEL_*` is the workflow STEP's label, not the action type's name**
582
+ (measured 2026-09-02, 1500 rows). Type 20 appeared under six labels:
583
+ `Analyse et résolution`, `Traitement`, `Traitement du refus`, `Traitement de
584
+ la demande`, `test` and `notif`. For the non-workflow
585
+ types (94, 95, 7) it is stable and is the real type name; for workflow types
586
+ it varies row to row, so never key on it. Types 14, 27 and 28 have an empty
587
+ label in every language column at both list and item level — the API cannot
588
+ name them, and only the admin console can.
@@ -5,7 +5,7 @@ license: MIT
5
5
  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."
6
6
  metadata:
7
7
  package: easyvista-python-client
8
- version: "0.2.0"
8
+ version: "0.3.0"
9
9
  ---
10
10
 
11
11
  > **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`,