Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
373cc17
refactor: promote the EasyVista timestamp parser into a shared leaf
baraline Aug 17, 2026
ff0c793
feat: add interval and wildcard search filter builders
baraline Aug 17, 2026
4122dad
fix(review): validate interval bound calendar validity, not just shape
baraline Aug 17, 2026
80996be
fix: RECENT_TICKETS_SORT was silently ignored, so recent_tickets was …
baraline Aug 17, 2026
c9e7d55
fix(review): stop overclaiming RECENT_TICKETS_SORT's live verification
baraline Aug 17, 2026
c867856
feat!: parse read-path timestamps into aware datetimes
baraline Aug 17, 2026
82c3964
fix(review): render datetimes in the two shared by-alias extractors
baraline Aug 17, 2026
8a752ee
feat: declare the action timestamp, author and workflow fields
baraline Aug 17, 2026
b49c92e
fix(review): correct the action docstring's self-contradictory list c…
baraline Aug 17, 2026
7ce7730
feat: accept a fields= projection on list_actions
baraline Aug 17, 2026
23fe3ef
docs: surface the fields=* footgun on the public list_actions docstring
baraline Aug 17, 2026
f4ba30c
feat: add update_action and delete_document
baraline Aug 17, 2026
9552dbb
feat: widen RequestUpdate with impact_id, owner_id, external_reference
baraline Aug 17, 2026
3f1514b
test: guard the interval, sort and wildcard grammars live
baraline Aug 17, 2026
47bff08
fix(review): close task-9's interval, sort and control gaps
baraline Aug 17, 2026
83d110d
docs: correct the tilde-operator claim and document the interval grammar
baraline Aug 17, 2026
caea6fa
docs(fix): close five stale-claim gaps the file list missed in round 1
baraline Aug 17, 2026
d2cc0ac
test(skills): validate ActionUpdate snippets and guard the write-mode…
baraline Aug 17, 2026
83c2be8
fix: refuse an interval bound whose time carries no UTC offset
baraline Aug 18, 2026
8ac38fa
feat: stream an attachment's bytes instead of buffering the whole file
baraline Aug 18, 2026
1c15a64
fix: correct five false streaming claims and pin the three untested ones
baraline Aug 18, 2026
cf9ab81
docs: make six shared comments true on both generated surfaces
baraline Aug 18, 2026
e9121df
release: 0.2.0
baraline Aug 18, 2026
8d71cab
fix(filters): normalise interval bounds, refuse `_`/`[`, raise on jun…
baraline Aug 18, 2026
659a6ea
fix(client): cap list_actions explicitly and guard a non-positive chu…
baraline Aug 18, 2026
fe0e304
test(live): pin the rendering matrix, the ascending sort and RequestU…
baraline Aug 18, 2026
5de6fda
docs: resolve the changelog's self-contradictions and six false relea…
baraline Aug 18, 2026
eafe60e
fix: an explicit None is an absence, not a malformed timestamp
baraline Aug 18, 2026
49ef8d8
docs(sweep): correct the change-window sort ruling to descending
baraline Aug 18, 2026
8660f45
docs(search): document the `~` metacharacters wherever the builders a…
baraline Aug 18, 2026
d05aae2
fix(filters): diagnose a sub-minute offset, and record the truncation…
baraline Aug 18, 2026
0bee73b
docs(filters): document the DESC watermark's page-1 finality trap
baraline Aug 18, 2026
4d6dd79
fix(test): treat a silent write-drop as a refusal in the ticket-ident…
baraline Aug 18, 2026
53c31dd
test(integration): close a P2 leak and two shared-instance races in c…
baraline Aug 18, 2026
ec487af
docs(test): correct the advertised write footprint of the live suite
baraline Aug 18, 2026
a1e297d
fix(requests)!: send the documented create body, set status by GUID
baraline Aug 25, 2026
884bd3a
feat(actions): page the whole action log, and declare ACTION_LABEL_FR
baraline Aug 27, 2026
58cef8f
docs: cite a tracked vendor reference, and guard against gitignored c…
baraline Aug 28, 2026
ff2fa04
fix(docs): close guard coverage gap and tier-tag two claims correctly
baraline Aug 28, 2026
b3e5c9c
feat(models): add extra_payload, an un-prefixed passthrough on write …
baraline Aug 28, 2026
49ed9c8
feat(requests): restore catalog_guid, and refuse a create body with n…
baraline Aug 28, 2026
69333fc
fix(requests): accept the vendor-documented string form of origin and…
baraline Aug 28, 2026
6a8dc3d
feat(requests): declare the twelve vendor-documented create fields th…
baraline Aug 28, 2026
f85affa
docs(requests): tag every claim with the evidence behind it
baraline Aug 28, 2026
92b25ca
docs(requests): fix six mis-provenanced claims from the tier-tagging …
baraline Aug 28, 2026
ef32765
feat(context): let the caller choose which memos get_ticket_context r…
baraline Aug 28, 2026
5bb4705
fix(context): scope memo cost claims to memo_fields, tag the selector…
baraline Aug 28, 2026
dc57c9f
test(integration): pin the 2025.3 baseline the vendor reference is wr…
baraline Aug 28, 2026
b3ac007
docs(changelog): record the vendor-grounded baseline changes
baraline Aug 28, 2026
4d9a2a0
fix(models)!: type four create ids from the vendor's own column types
baraline Aug 28, 2026
c8b932d
fix(models): merge extra_payload case-insensitively
baraline Aug 28, 2026
cefcd65
fix(context): render a memo the caller asked for, and retag the memo …
baraline Aug 28, 2026
ab2aa30
docs: surface catalog_guid, extra_payload and memo_fields
baraline Aug 28, 2026
26128bd
docs: fix an unbuildable extra_payload example, name the new create f…
baraline Aug 28, 2026
7d62907
docs(changelog): scope the case-insensitivity claim to the create body
baraline Aug 28, 2026
48bd4aa
docs: fix five examples that raise if copied, and retract two false c…
baraline Aug 28, 2026
f69a246
feat(actions)!: declare PostAction.comment, the second text channel
baraline Aug 28, 2026
6d277f0
docs(actions): a comment is an action that has been ENDED
baraline Aug 28, 2026
aa97132
feat(actions): add create_task, the one-call way to post a comment
baraline Aug 28, 2026
c8d28a0
fix(requests): complete the close body, and close O-CLOSE
baraline Aug 28, 2026
e9c23c4
chore: keep CLAUDE.md local
baraline Aug 31, 2026
ac5daf5
feat(config): add the escape hatches a foreign deployment needs
baraline Aug 31, 2026
b304669
feat(references)!: make the label language order a parameter
baraline Aug 31, 2026
7dc1268
feat(filters): let the caller choose the wildcard `~` needs
baraline Aug 31, 2026
7f2670a
feat(models)!: tolerate what another deployment returns
baraline Aug 31, 2026
09f6b85
fix(pagination): unwrap a response envelope whatever its casing
baraline Aug 31, 2026
83e4746
feat(directory)!: parameterise the department context end to end
baraline Aug 31, 2026
360d208
feat(discovery): answer "what do I have to pass on this instance?"
baraline Aug 31, 2026
42364f6
docs: settle the action-type contradiction; retract the 403 verdicts
baraline Aug 31, 2026
28ece05
feat(actions): add end_action, and show the note text a reader sees
baraline Sep 2, 2026
c6e9f0d
docs: document end_action, and close a silent API-reference gap
baraline Sep 2, 2026
73078d8
release: 0.2.0
baraline Sep 2, 2026
83114d5
fix(timestamps): refuse the ISO basic form on every Python version
baraline Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,10 @@ jobs:
# easyvista_python_client.__version__ -- and the git tag is a third. PyPI
# takes whatever pyproject says, so a tag that disagrees publishes a
# release nobody can find by version, and a __version__ that disagrees
# misreports at runtime. Both are unfixable after upload. Tags in this
# repo are v-prefixed (see the CHANGELOG compare links), so the leading v
# is stripped before comparing.
# misreports at runtime. Both are unfixable after upload. The repo's only
# existing tag, 0.1.0, is UNPREFIXED; v-prefixing starts at v0.2.0. The
# leading v is therefore stripped before comparing, so both forms
# validate.
- name: Validate release tag matches package version
if: github.event_name == 'release'
shell: bash
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,9 @@ docs/easyvista-field-inventory.md
# print real employees' names and e-mail addresses. Kept on disk so they stay
# usable locally; never published.
scripts/probe_*.py

# Local agent instructions. Working notes for whoever drives this repo with an
# AI assistant, not project documentation: they reference the private preprod
# instance's behaviour and this machine's credential layout. Anything here that
# a public reader needs belongs in CONTRIBUTING.md, docs/, or a skill instead.
CLAUDE.md
931 changes: 883 additions & 48 deletions CHANGELOG.md

Large diffs are not rendered by default.

26 changes: 23 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,29 @@ python -m sphinx -W --keep-going -b html docs docs/_build/html
inside the package, because it calls a **real EasyVista instance that you
supply**. It never runs in CI — CI runs `pytest -m "not integration"`.

Credentials come from `EASYVISTA_TEST_*` environment variables, falling back to
files under `secrets/` (both gitignored). With none configured the suite skips
cleanly, so `pytest` on a fresh checkout is offline and green.
Credentials resolve from an environment variable first, then a lowercase file
under `secrets/` (both gitignored):

| Environment variable | Fallback file | What it is |
| ------------------------- | -------------------------------- | ---------- |
| `EASYVISTA_TEST_URL` | `secrets/easyvista_test_url` | The instance URL. Normally the full API root, `https://host/api/v1/{account}`. |
| `EASYVISTA_TEST_TOKEN` | `secrets/easyvista_test_token` | The Bearer token. **The only credential that authenticates anything.** |
| `EASYVISTA_TEST_ACCOUNT` | `secrets/easyvista_test_account` | The account id — see below. **Not a login.** |

`EASYVISTA_TEST_ACCOUNT` is the EasyVista *instance identifier* that forms the
`{account}` path segment of `https://host/api/{version}/{account}` — a number
such as `50004` — and it feeds `EasyvistaConfig.account`. Nothing authenticates
with it. It is read **only** when the URL is a bare host: a full API root already
carries the account, in which case the value is never consulted at all.

> This variable was spelled `EASYVISTA_TEST_USER` (and `secrets/easyvista_test_user`)
> before 2026-08-25, which read as a username and never was one. The old name is
> now **refused with an error naming its replacement** rather than silently
> accepted, so a leftover copy cannot quietly reintroduce the confusion. If you
> have one, rename it.

With none configured the suite skips cleanly, so `pytest` on a fresh checkout is
offline and green.

> **These tests are not read-only.** They create tickets and close them in
> teardown. Once your credentials are present they run as part of a plain
Expand Down
106 changes: 93 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@

[![CI](https://github.com/baraline/easyvista_python_client/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/baraline/easyvista_python_client/actions/workflows/ci.yml)
[![Coverage](https://codecov.io/gh/baraline/easyvista_python_client/branch/main/graph/badge.svg)](https://codecov.io/gh/baraline/easyvista_python_client)
[![License](https://img.shields.io/github/license/baraline/easyvista_python_client)](LICENSE)
[![License](https://img.shields.io/github/license/baraline/easyvista_python_client)](https://github.com/baraline/easyvista_python_client/blob/main/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://github.com/baraline/easyvista_python_client)
[![Docs](https://readthedocs.org/projects/easyvista-python-client/badge/?version=latest)](https://easyvista-python-client.readthedocs.io/en/latest/)


Typed Python client for the EasyVista Service Manager REST API. Sync + async,
Pydantic models, Bearer or Basic auth.

While the package is preparing for 1.0, alot of potential breaking change might happen between versions. A deprecation policy will be put in place once 1.0 is out and the package have been stabilized.
While the package is preparing for 1.0, breaking changes may land between
minor versions; a deprecation policy will follow the 1.0 release.

## Documentation

Expand All @@ -35,18 +36,26 @@ from easyvista_python_client import (
ev_equals_filter,
)

# `account` is the instance id in the API root (https://host/api/v1/12345), not a username.
config = EasyvistaConfig(server="https://my.easyvista.com", account="12345", token="...")
with EasyvistaClient(config) as client:
# catalog_code, the *_id values and the close status_guid are instance-specific.
# catalog_code, the *_id values and the close status_guid are
# instance-specific -- `client.describe_instance()` finds them for you.
# `external_reference` is your own marker, and it is what lets you
# reconcile a create that failed: see the note below this block.
ticket = client.create_ticket(
PostRequest(
catalog_code="INC_STANDARD",
title="Printer down",
description="The 3rd-floor printer is offline",
origin=7,
# The vendor documents `origin` as a channel NAME, not an id --
# the one create field with a portable form. An int is also
# accepted (measured on one instance) and passes through as sent.
origin="Phone",
department_id=9,
urgency_id=8,
impact_id=28,
external_reference="MYAPP-0001", # your own marker; set it always
)
)
fetched = client.get_ticket(ticket.rfc_number)
Expand All @@ -57,7 +66,10 @@ with EasyvistaClient(config) as client:
for t in client.iter_tickets(search=open_status, page_size=100, max_records=1000):
... # async: `async for t in client.iter_tickets(...)`

# close it with your instance's "closed" status GUID
# close it with your instance's "closed" status GUID. Every argument is
# optional -- `client.close_ticket(ticket.rfc_number)` sends the close with
# no status of its own, but where that lands the ticket is not established
# by this package; see the user guide before relying on it.
client.close_ticket(
ticket.rfc_number,
status_guid="{00000000-0000-0000-0000-000000000000}",
Expand All @@ -66,9 +78,58 @@ with EasyvistaClient(config) as client:
)
```

> Minimum fields for a create are catalog-specific (server-side). `catalog_code` + `title`
> work for incident catalogs; a missing mandatory field raises `EasyvistaValidationError`
> (HTTP 590, code 2013) — it is not retried.
> A create needs a subject: `catalog_guid` (the vendor's preferred identifier) or
> `catalog_code`. Anything beyond that is catalog-specific and enforced server-side, so a
> field a given catalog insists on raises `EasyvistaValidationError` (HTTP 590, code 2013)
> — it is not retried, and the message names no field.
>
> **Do not retry that 590 blindly.** Measured on one instance (2026-08-25), a rejected
> create may still have created the ticket: 12 attempts returned 3 `RFC_NUMBER`s and
> afterwards all 12 tickets existed. A 590 means *possibly created*, never *not created*.
> Set `external_reference` on every create and reconcile by that marker — it survives the
> failed insert and is searchable.

## Comments and actions

An action is a unit of work, and it is born **open** — an open action shows in the
UI as a pending row with its text *not* displayed, which reads as though the note
was lost. A comment is an action that has been **ended**.

```python
from easyvista_python_client import PostAction, PostTask

# A COMMENT: `create_task` posts the same record already ended, in one call.
# Put the text in `description` -- the UI renders one field per action and
# `description` shadows `comment`, so text in `comment` beside a populated
# `description` is stored, readable through the API, and displayed to nobody.
client.create_task(
rfc,
PostTask(action_type_id=94, group_id=3, description="Investigating now."),
)

# WORK SOMEONE MUST STILL DO: create it open, then end it when it is done.
client.create_action(
rfc,
PostAction(action_type_id=94, group_id=3, description="Chase the supplier."),
)
client.end_action(
rfc,
action_id=1234, # not recoverable from the create response
start_date="01/09/2026 17:00:00", # your instance's format, not ISO 8601
end_date="01/09/2026 17:15:00",
elapsed_time=15, # MINUTES
)
```

> **There is no private-comment flag.** Visibility is carried by the action
> *type*, which is per-deployment — read the ids off existing actions with
> `client.discover("ACTION_TYPE")` rather than hardcoding one.
>
> **`end_action` on a workflow action changes the ticket.** Ending your own
> action only ends it; ending the ticket's open workflow step advances the
> workflow and moves the ticket's status. Naming `action_id` is therefore
> required — the vendor's id-less "end every open action" form is behind an
> explicit `end_all=True`.

## Assets and documents

Expand All @@ -78,6 +139,7 @@ from easyvista_python_client import (
EasyvistaClient,
EasyvistaConfig,
PostAsset,
ev_contains_filter,
ev_equals_filter,
)

Expand All @@ -86,6 +148,16 @@ with EasyvistaClient(EasyvistaConfig.from_env()) as client:
tag_filter = ev_equals_filter("ASSET_TAG", "LAPTOP-001")
found = client.search_assets(search=tag_filter, max_rows=50)

# On the instance this package was characterized against, `~` needs an
# explicit wildcard to mean "contains" -- a bare value is exact match,
# identical to `:`. ev_contains_filter appends it for you; the vendor
# documents `~` as plain Contains, so pass wildcard=None if that is your
# deployment. It raises ValueError if the value carries `_` or `[` (both
# are metacharacters to `~` itself, with no escape) or `*`/`%` while a
# wildcard is being appended. For an exact match on a tag like
# "LAPTOP_01", use ev_equals_filter: `:` does not expand a wildcard.
partial = client.search_assets(search=ev_contains_filter("ASSET_TAG", "LAPTOP"))

# attach a file to a ticket (uploaded as base64 inside the JSON body)
pdf = Path("report.pdf")
client.add_document("I240101_0001", filename=pdf.name, content=pdf.read_bytes())
Expand All @@ -95,10 +167,18 @@ with EasyvistaClient(EasyvistaConfig.from_env()) as client:
## Usage (async)

```python
import asyncio

from easyvista_python_client import AsyncEasyvistaClient, EasyvistaConfig

async with AsyncEasyvistaClient(EasyvistaConfig.from_env()) as client:
ticket = await client.get_ticket("I240101_0001")

async def main():
async with AsyncEasyvistaClient(EasyvistaConfig.from_env()) as client:
ticket = await client.get_ticket("I240101_0001")
print(ticket.rfc_number)


asyncio.run(main())
```

## Configuration via environment
Expand All @@ -112,19 +192,19 @@ then call `EasyvistaConfig.from_env()`.
`skills/` holds Agent Skills for driving this client from an AI agent — one per
domain (client setup, search syntax, tickets, actions, documents, assets,
directory, reporting and context). Each is a directory with a `SKILL.md`
following the Agent Skills specification; see [skills/README.md](skills/README.md)
following the Agent Skills specification; see [skills/README.md](https://github.com/baraline/easyvista_python_client/blob/main/skills/README.md)
for the index.

They are source-tree material: present in the git repository and the source
distribution, absent from the installed wheel.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and quality checks.
See [CONTRIBUTING.md](https://github.com/baraline/easyvista_python_client/blob/main/CONTRIBUTING.md) for development setup and quality checks.

## License

MIT — see [LICENSE](LICENSE).
MIT — see [LICENSE](https://github.com/baraline/easyvista_python_client/blob/main/LICENSE).

## Sponsoring

Expand Down
49 changes: 46 additions & 3 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Configuration

.. autoclass:: easyvista_python_client.config.EasyvistaConfig

.. autodata:: easyvista_python_client.DEFAULT_USER_AGENT

Models
------

Expand All @@ -26,6 +28,10 @@ Models

.. autoclass:: easyvista_python_client.models.action.PostAction

.. autoclass:: easyvista_python_client.models.action.PostTask

.. autoclass:: easyvista_python_client.models.action.ActionUpdate

.. autoclass:: easyvista_python_client.models.asset.Asset

.. autoclass:: easyvista_python_client.models.asset.PostAsset
Expand All @@ -48,6 +54,8 @@ Models

.. autoclass:: easyvista_python_client.context.TicketContext

.. autodata:: easyvista_python_client.DEFAULT_MARKDOWN_FIELDS

.. autoclass:: easyvista_python_client.directory.DepartmentContext

Reporting
Expand All @@ -61,23 +69,58 @@ Filters
-------

Build ``search`` expressions with these rather than f-strings: EasyVista ignores a filter it cannot
parse and returns every record, and ``,`` combines conditions — so an unescaped value fails silently
or widens the result rather than raising.
parse and returns every record, ``,`` combines conditions so an unescaped value can silently widen
the result, and there is no comparison operator — a range must be expressed as an interval.

.. autofunction:: easyvista_python_client.filters.ev_equals_filter

.. autofunction:: easyvista_python_client.filters.ev_in_filter

.. autofunction:: easyvista_python_client.filters.ev_contains_filter

.. autofunction:: easyvista_python_client.filters.ev_starts_with_filter

.. autofunction:: easyvista_python_client.filters.ev_since_filter

.. autofunction:: easyvista_python_client.filters.ev_between_filter

.. autofunction:: easyvista_python_client.filters.escape_ev_value

.. autofunction:: easyvista_python_client.filters.is_safe_ev_value

Timestamps
----------

EasyVista's timestamp format, parsed and rendered in one place — see
:ref:`timestamps` for how the read models use these.

.. autofunction:: easyvista_python_client.timestamps.parse_ev_datetime

.. autofunction:: easyvista_python_client.timestamps.format_ev_datetime

References
----------

.. autoclass:: easyvista_python_client.references.Reference

.. autofunction:: easyvista_python_client.references.localized_label
.. autodata:: easyvista_python_client.DEFAULT_LANGUAGE_ORDER

.. autofunction:: easyvista_python_client.localized_label

.. autofunction:: easyvista_python_client.references.label_from_record

Instance discovery
------------------

.. autoclass:: easyvista_python_client.DiscoveredReference

.. autoclass:: easyvista_python_client.InstanceProfile

.. autoclass:: easyvista_python_client.ReferenceSource

.. autoclass:: easyvista_python_client.GenericRecord

.. autodata:: easyvista_python_client.DEFAULT_DISCOVERY_NAMES

Every read model exposes ``.reference(name)`` returning a :class:`~easyvista_python_client.references.Reference`
for any field, including custom ``e_*`` fields.
Expand Down
26 changes: 23 additions & 3 deletions docs/development.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,29 @@ Integration tests
calls a **real EasyVista instance** that you supply. It never runs in CI — CI runs
``pytest -m "not integration"``.

Credentials resolve from ``EASYVISTA_TEST_URL`` / ``EASYVISTA_TEST_USER`` / ``EASYVISTA_TEST_TOKEN``,
falling back to files under ``secrets/`` (both gitignored). With no credentials configured the suite
**skips cleanly**, so a plain ``pytest`` on a fresh checkout is offline and green.
Credentials resolve from an environment variable first, then a lowercase file under ``secrets/``
(both gitignored)::

url <- EASYVISTA_TEST_URL | secrets/easyvista_test_url
account <- EASYVISTA_TEST_ACCOUNT | secrets/easyvista_test_account
token <- EASYVISTA_TEST_TOKEN | secrets/easyvista_test_token

``EASYVISTA_TEST_TOKEN`` is the Bearer token, and the only credential that authenticates anything.
``EASYVISTA_TEST_ACCOUNT`` is **not a login**: it is the instance identifier forming the
``{account}`` path segment of ``https://host/api/{version}/{account}`` -- a number such as
``50004`` -- and it feeds ``EasyvistaConfig.account``. It is read only when ``EASYVISTA_TEST_URL``
is a bare host; a full API root already carries the account, and then the value is never consulted
at all.

.. note::

``EASYVISTA_TEST_ACCOUNT`` was spelled ``EASYVISTA_TEST_USER`` (and ``secrets/easyvista_test_user``)
before 2026-08-25, which read as a username and never was one. The old name is now **refused with
an error naming its replacement**, not silently accepted, so a leftover copy cannot quietly
reintroduce the confusion.

With no credentials configured the suite **skips cleanly**, so a plain ``pytest`` on a fresh checkout
is offline and green.

.. warning::

Expand Down
Loading
Loading