From dc7bda69efa850bdc3f4b5b50a7deae28eafdac4 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 10:00:15 +0200 Subject: [PATCH 01/16] feat(skills): add the agent skills tree with a contract test and the setup skill Agent Skills let an AI agent drive this client correctly without reading its source, but prose documentation drifts silently from the API it describes -- a renamed method or a dropped keyword breaks an agent months later against a live instance, with no signal at review time. This adds the drift gate first: scripts/tests/test_skills_contract.py imports the installed package and re-checks every SKILL.md's frontmatter shape, metadata.version against __version__, and every fenced python block against ast.parse, entirely offline. TDD: the test module was written and run before skills/ existed, and failed exactly as expected (missing directory, missing README, zero parametrized cases). skills/easyvista-client-setup/SKILL.md was added second to turn it green. skills/README.md is the index Tasks 3-9 will each add one row to. The first skill, easyvista-client-setup, covers building EasyvistaConfig (server/account/api_version, token vs login+password, from_env), the sync/async client split, and the EasyvistaError hierarchy including the 590-is-really-a-400 status code. One line of the brief's verbatim test code (the README-completeness assertion) was 89 columns against this repo's 88-column ruff limit; wrapped it across two lines with identical message text so `ruff check .` stays clean without changing the assertion. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/tests/test_skills_contract.py | 157 +++++++++++++++++++++ skills/README.md | 38 +++++ skills/easyvista-client-setup/SKILL.md | 186 +++++++++++++++++++++++++ 3 files changed, 381 insertions(+) create mode 100644 scripts/tests/test_skills_contract.py create mode 100644 skills/README.md create mode 100644 skills/easyvista-client-setup/SKILL.md diff --git a/scripts/tests/test_skills_contract.py b/scripts/tests/test_skills_contract.py new file mode 100644 index 0000000..39157b2 --- /dev/null +++ b/scripts/tests/test_skills_contract.py @@ -0,0 +1,157 @@ +"""Contract tests for the Agent Skills under ``skills/``. + +Every ``SKILL.md`` makes checkable claims about the public surface of +``easyvista_python_client``: the symbols it imports, the client methods it +calls, the keyword arguments it passes, the model fields it sets. This module +re-checks each claim against the installed package, so a rename or a dropped +keyword fails here instead of failing an agent months later against a live +instance. + +Offline by construction: it imports the package and reads files. No +credentials, no network, nothing instantiated that would open a socket. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path +from typing import Any + +import pytest + +import easyvista_python_client as ev + +REPO_ROOT = Path(__file__).resolve().parents[2] +SKILLS_DIR = REPO_ROOT / "skills" + +# The Agent Skills spec caps the description; a longer one is silently +# truncated by the loader, which would hide the trigger conditions. +_MAX_DESCRIPTION = 1024 + +_PY_BLOCK = re.compile(r"^```python\n(.*?)^```", re.MULTILINE | re.DOTALL) + + +def _skill_dirs() -> list[Path]: + """Every skill directory, sorted. Empty when ``skills/`` does not exist.""" + if not SKILLS_DIR.is_dir(): + return [] + return sorted(p for p in SKILLS_DIR.iterdir() if p.is_dir()) + + +def _skill_ids() -> list[str]: + return [p.name for p in _skill_dirs()] + + +def _parse_frontmatter(text: str) -> dict[str, Any]: + """Parse the small YAML subset the frontmatter uses. + + Flat ``key: value`` lines plus one nested block (``metadata:``) of + two-space-indented ``key: value`` lines. Values may be double-quoted. + Hand-rolled rather than importing PyYAML: the shape is fixed and adding a + dependency to lint documentation is not worth it. + """ + if not text.startswith("---\n"): + raise ValueError("SKILL.md must open with a '---' frontmatter fence") + _, _, rest = text.partition("---\n") + body, fence, _ = rest.partition("\n---\n") + if not fence: + raise ValueError("frontmatter is not closed by a '---' line") + data: dict[str, Any] = {} + block: dict[str, Any] | None = None + for line in body.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + key, sep, value = line.partition(":") + if not sep: + raise ValueError(f"unparseable frontmatter line: {line!r}") + text_value = value.strip().strip('"') + if line.startswith(" "): + if block is None: + raise ValueError(f"indented key outside a block: {line!r}") + block[key.strip()] = text_value + continue + if not text_value: + block = {} + data[key.strip()] = block + continue + block = None + data[key.strip()] = text_value + return data + + +def _python_blocks(text: str) -> list[str]: + """Every fenced ``python`` code block's source, in document order.""" + return [match.group(1) for match in _PY_BLOCK.finditer(text)] + + +def test_skills_directory_exists_and_is_populated() -> None: + assert SKILLS_DIR.is_dir(), f"{SKILLS_DIR} does not exist" + assert _skill_dirs(), "skills/ holds no skill directories" + + +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_skill_directory_holds_exactly_one_skill_md(skill: Path) -> None: + files = sorted(p.name for p in skill.iterdir()) + assert files == ["SKILL.md"], f"{skill.name} holds {files}, expected ['SKILL.md']" + + +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_frontmatter_name_matches_directory(skill: Path) -> None: + meta = _parse_frontmatter((skill / "SKILL.md").read_text(encoding="utf-8")) + assert meta.get("name") == skill.name, ( + f"frontmatter name {meta.get('name')!r} != directory {skill.name!r}; " + "the Agent Skills spec requires them to match" + ) + + +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_frontmatter_description_is_present_and_bounded(skill: Path) -> None: + meta = _parse_frontmatter((skill / "SKILL.md").read_text(encoding="utf-8")) + description = meta.get("description") + assert isinstance(description, str) and description.strip(), ( + f"{skill.name} has no description; it is what the agent matches on" + ) + assert len(description) <= _MAX_DESCRIPTION, ( + f"{skill.name} description is {len(description)} chars, " + f"over the {_MAX_DESCRIPTION} limit" + ) + + +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_frontmatter_metadata_tracks_the_package(skill: Path) -> None: + meta = _parse_frontmatter((skill / "SKILL.md").read_text(encoding="utf-8")) + assert meta.get("license") == "MIT" + block = meta.get("metadata") + assert isinstance(block, dict), f"{skill.name} has no metadata block" + assert block.get("package") == "easyvista-python-client" + # A release that bumps __version__ and forgets the skills fails here. + assert block.get("version") == ev.__version__, ( + f"{skill.name} claims version {block.get('version')!r}, " + f"package is {ev.__version__!r}" + ) + + +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_python_snippets_parse(skill: Path) -> None: + blocks = _python_blocks((skill / "SKILL.md").read_text(encoding="utf-8")) + assert blocks, f"{skill.name} has no python examples" + for index, block in enumerate(blocks): + try: + ast.parse(block) + except SyntaxError as exc: + raise AssertionError( + f"{skill.name} python block #{index + 1} does not parse: {exc}" + ) from exc + + +def test_readme_lists_every_skill() -> None: + readme = SKILLS_DIR / "README.md" + assert readme.is_file(), "skills/README.md is missing" + text = readme.read_text(encoding="utf-8") + listed = set(re.findall(r"`(easyvista-[a-z-]+)`", text)) + present = {p.name for p in _skill_dirs()} + assert present - listed == set(), ( + f"not listed in README: {sorted(present - listed)}" + ) + assert listed - present == set(), f"listed but missing: {sorted(listed - present)}" diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..b952421 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,38 @@ +# easyvista-python-client Agent Skills + +This folder holds Agent Skills for the operations exposed by the public +`easyvista_python_client` API. Each child directory is a standalone skill +following the Agent Skills specification: the directory name matches the `name` +frontmatter field, and the instructions live in `SKILL.md`. + +These skills are source-tree project material. They ship in the source +distribution for contributors and source consumers; they are not part of the +installed wheel, which carries only the `easyvista_python_client` package. + +## Skills + +| Skill | Use when the agent needs to | Main public API | +| --- | --- | --- | +| `easyvista-client-setup` | Build and configure an authenticated client | `EasyvistaConfig`, `EasyvistaClient`, `AsyncEasyvistaClient` | + +## Sync and async + +The package ships two clients with one endpoint surface: + +- `EasyvistaClient` — synchronous. `with EasyvistaClient(config) as client`, no + `await`, `for` over the iterators. +- `AsyncEasyvistaClient` — asynchronous, doing real non-blocking I/O. + `async with AsyncEasyvistaClient(config) as client`, `await` every method, + `async for` over the iterators. + +Neither wraps the other. `_async/` is hand-written and `_sync/` is generated +from it by `unasync_build.py` under a byte-equality CI gate, so the two surfaces +cannot drift apart. Examples in these skills are synchronous; the async form is +shown in full in `easyvista-client-setup`. + +## Instance-specific values + +Every id in EasyVista — catalog codes, status ids, urgency and impact ids, +action type ids, group ids — is configured per instance. No example here carries +a real one. Where a write needs an id, the skill shows how to read it off the +instance first and uses an obvious placeholder in the payload. diff --git a/skills/easyvista-client-setup/SKILL.md b/skills/easyvista-client-setup/SKILL.md new file mode 100644 index 0000000..37ed8d1 --- /dev/null +++ b/skills/easyvista-client-setup/SKILL.md @@ -0,0 +1,186 @@ +--- +name: easyvista-client-setup +description: "Create and configure the synchronous easyvista_python_client.EasyvistaClient or the asynchronous AsyncEasyvistaClient — server/account/api_version, Bearer token or HTTP Basic credentials, EasyvistaConfig.from_env, timeouts, retries, TLS verification, default page size, and the EasyvistaError hierarchy. Use before calling any EasyVista API, or when the user asks how to connect to EasyVista with easyvista_python_client." +license: MIT +compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and valid EasyVista credentials." +metadata: + package: easyvista-python-client + version: "0.1.0" +--- + +`easyvista_python_client` ships both clients over one surface: +`EasyvistaClient` is blocking, and `AsyncEasyvistaClient` returns coroutines +and does real non-blocking I/O. Same method names, same arguments, same +results. Pick the one matching the runtime — synchronous script or async +event loop — and keep it consistent within one application. + +## Procedure + +1. Pick the client class: `EasyvistaClient` for synchronous code, + `AsyncEasyvistaClient` inside an event loop. +2. Build an `EasyvistaConfig`: `server` (the instance root, no `/api` + segment) and `account` are always required. +3. Supply exactly one credential: `token=` for Bearer, or `login=` and + `password=` for HTTP Basic. Neither present raises `ValueError` at + construction. +4. Leave `api_version` at `"v1"` unless the instance says otherwise; the + client builds `{server}/api/{api_version}/{account}`. +5. Prefer `EasyvistaConfig.from_env()` / `EasyvistaClient.from_env()` for + anything with credentials in the environment. +6. Keep `verify_ssl=True` unless the user confirms an internal endpoint that + cannot present a valid chain. +7. Raise `max_retries` above its `0` default only for flaky networks; 429 and + 5xx are retried with exponential backoff, and 590 deliberately is not. +8. Use the client as a context manager so its HTTP session closes; call + `client.close()` (`await client.aclose()`) when it outlives the block. + +## Configuration + +Every `EasyvistaConfig` field, and its default: + +| Field | Default | Notes | +| --- | --- | --- | +| `server` | required | Instance root, no `/api` segment | +| `account` | required | | +| `token` | `None` | Bearer credential | +| `login` | `None` | HTTP Basic credential, paired with `password` | +| `password` | `None` | HTTP Basic credential, paired with `login` | +| `timeout` | `30.0` | Seconds | +| `max_retries` | `0` | Applies to 429 and 5xx only | +| `verify_ssl` | `True` | TLS certificate verification | +| `default_max_rows` | `100` | Page size when `max_rows` / `page_size` is omitted | +| `api_version` | `"v1"` | Used to build `api_root` | + +`config.api_root` and `config.uses_basic_auth` are read-only properties +derived from the fields above, not settable inputs. The dataclass itself is +frozen — no field can be reassigned after construction. + +## Environment defaults + +`EasyvistaConfig.from_env()` and `EasyvistaClient.from_env()` read: + +1. `EASYVISTA_URL` (or `EASYVISTA_SERVER`) for `server`. +2. `EASYVISTA_ACCOUNT` for `account`. +3. `EASYVISTA_TOKEN` for `token`; if unset, `EASYVISTA_TOKEN_FILE` (a path + whose contents are read and stripped). +4. Otherwise `EASYVISTA_LOGIN` / `EASYVISTA_PASSWORD` for Basic auth. + +A missing server or account raises `ValueError`. `from_env` takes **no +keyword overrides** — build an `EasyvistaConfig` directly when you need to +override one value. + +## Examples + +```python +from easyvista_python_client import EasyvistaClient, EasyvistaConfig + +config = EasyvistaConfig( + server="https://my.easyvista.example.com", + account="12345", + token="YOUR_API_TOKEN", +) + +with EasyvistaClient(config) as client: + result = client.search_tickets(max_rows=10) + print(result.record_count, "of", result.total_record_count) +``` + +```python +from easyvista_python_client import EasyvistaClient, EasyvistaConfig + +config = EasyvistaConfig( + server="https://my.easyvista.example.com", + account="12345", + login="rest.user", + password="YOUR_PASSWORD", +) + +with EasyvistaClient(config) as client: + ticket = client.get_ticket("YOUR_RFC_NUMBER") + print(ticket.rfc_number, ticket.title) +``` + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + print(client.count_tickets()) +``` + +```python +import asyncio + +from easyvista_python_client import AsyncEasyvistaClient, EasyvistaConfig + + +async def main() -> None: + config = EasyvistaConfig( + server="https://my.easyvista.example.com", + account="12345", + token="YOUR_API_TOKEN", + ) + async with AsyncEasyvistaClient(config) as client: + result = await client.search_tickets(max_rows=10) + print(result.total_record_count) + async for ticket in client.iter_tickets(max_records=5): + print(ticket.rfc_number) + + +asyncio.run(main()) +``` + +```python +from easyvista_python_client import ( + EasyvistaAuthError, + EasyvistaClient, + EasyvistaError, + EasyvistaNotFound, + EasyvistaValidationError, +) + +with EasyvistaClient.from_env() as client: + try: + ticket = client.get_ticket("YOUR_RFC_NUMBER") + except EasyvistaNotFound: + ticket = None + except EasyvistaValidationError as exc: + print("rejected:", exc.status_code, exc.ev_code, exc.ev_message) + raise + except EasyvistaAuthError as exc: + print("profile not authorized:", exc.status_code) + raise + except EasyvistaError as exc: + print("other API failure:", exc.status_code) + raise +``` + +## Errors + +| HTTP status | Exception | Meaning here | +| --- | --- | --- | +| 401 / 403 | `EasyvistaAuthError` | 403 is usually a *profile* restriction on the token, not a bad credential; the context bundles degrade around it rather than failing. | +| 404 | `EasyvistaNotFound` | The resource does not exist. | +| 400 | `EasyvistaValidationError` | The request was rejected as malformed. | +| **590** | `EasyvistaValidationError` | EasyVista's "Internal Easyvista Error" is in practice a rejected request — a missing mandatory field or an invalid catalog reference. It is deterministic, so it is **not** retried. | +| 429 | `EasyvistaRateLimitError` | Retried when `max_retries > 0`. | +| 5xx | `EasyvistaServerError` | Retried when `max_retries > 0`. | +| Transport failure (timeout, refused connection) | `EasyvistaConnectionError` | No response was obtained at all. | + +Every one of these carries `status_code`, `ev_code` and `ev_message`, and all +derive from `EasyvistaError`. + +## Gotchas + +- `server` is the instance root. Do **not** append `/api/v1/`; the + client composes `config.api_root` from `server`, `api_version` and + `account`. +- `EasyvistaConfig` is frozen. To change a setting, build a new config. +- Constructing a config with neither `token` nor a complete `login`/`password` + pair raises `ValueError` immediately — a credential problem surfaces before + any request. +- `from_env()` accepts no overrides, unlike the sister GLPI client's. +- `default_max_rows` (100) is the page size used when `max_rows` / `page_size` + is omitted — it is not a total cap; the `iter_*` methods page past it. +- Retries are off by default (`max_retries=0`). +- Closing matters: the client owns an HTTP session. Prefer the context + manager. From 3ca5159a84f56b43f57e9ae3c6d07a6c12d33279 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 10:16:46 +0200 Subject: [PATCH 02/16] test(skills): check skill snippets against the real public surface Task 1 checked that each SKILL.md is well-formed (frontmatter shape, parseable python blocks). It never checked that the code inside those blocks would actually work: an imported name that got renamed, a keyword argument dropped from a client method, a write-model field that no longer exists -- none of that failed a test, only a live agent months later. This extends scripts/tests/test_skills_contract.py with five snippet-level checks, each parametrized over every skill directory exactly like Task 1's tests: imports resolve against easyvista_python_client.__all__ (and never reach into a private submodule); every client.() call and its keyword arguments exist on both EasyvistaClient and AsyncEasyvistaClient, verified via inspect.signature; every PostRequest/PostAction/... call sets only real pydantic fields; no skill references a gitignored path invisible to a reader of the published repo; and every URL literal in a snippet sits under example.com so nothing that looks like a real endpoint leaks in. `inspect` was added to the top-level import block (alphabetical, next to `ast`) rather than imported locally inside the test, so ruff's import rules stay satisfied without a follow-up hoist. All five new tests pass immediately against the one existing skill, easyvista-client-setup -- it was written to satisfy them, so this is not a red-then-green change. To prove the assertions actually bite, each was verified against a deliberate violation injected into that skill's SKILL.md (bad import, nonexistent method, bad keyword on a real method, bad write-model field, a gitignored-path reference, a non-example.com host) and reverted after confirming a named, diagnosable failure; `git diff -- skills/` is empty, confirming no residue. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/tests/test_skills_contract.py | 135 ++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/scripts/tests/test_skills_contract.py b/scripts/tests/test_skills_contract.py index 39157b2..15643d4 100644 --- a/scripts/tests/test_skills_contract.py +++ b/scripts/tests/test_skills_contract.py @@ -14,6 +14,7 @@ from __future__ import annotations import ast +import inspect import re from pathlib import Path from typing import Any @@ -155,3 +156,137 @@ def test_readme_lists_every_skill() -> None: f"not listed in README: {sorted(present - listed)}" ) assert listed - present == set(), f"listed but missing: {sorted(listed - present)}" + + +# Snippets address a client through a variable with one of these names, or +# through the class itself for classmethods like ``from_env``. +_CLIENT_NAMES = {"client", "EasyvistaClient", "AsyncEasyvistaClient"} + +# Write payloads a snippet may construct, checked against their real fields. +_WRITE_MODELS = { + "PostRequest": ev.PostRequest, + "RequestUpdate": ev.RequestUpdate, + "PostAction": ev.PostAction, + "PostAsset": ev.PostAsset, + "PostDepartment": ev.PostDepartment, + "DepartmentUpdate": ev.DepartmentUpdate, + "PostEmployee": ev.PostEmployee, + "EmployeeUpdate": ev.EmployeeUpdate, +} + +# Files that are gitignored: a skill naming one is a dead link for every +# reader who only has the published repository. +_UNPUBLISHED = ( + "API_Info.md", + "easyvista-field-inventory.md", + "easyvista-test-profile-blocked-operations.md", + "docs/superpowers", + "secrets/", +) + +_URL_LITERAL = re.compile(r"https?://[^\s\"']+") + + +def _client_calls(tree: ast.AST) -> list[ast.Call]: + """Every ``client.(...)`` call in a parsed snippet.""" + calls = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + target = node.func.value + if isinstance(target, ast.Name) and target.id in _CLIENT_NAMES: + calls.append(node) + return calls + + +def _snippet_trees(skill: Path) -> list[ast.Module]: + text = (skill / "SKILL.md").read_text(encoding="utf-8") + return [ast.parse(block) for block in _python_blocks(text)] + + +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_imported_symbols_are_public(skill: Path) -> None: + exported = set(ev.__all__) + for tree in _snippet_trees(skill): + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + module = node.module or "" + assert not module.startswith("easyvista_python_client."), ( + f"{skill.name} imports from the private module {module!r}; " + "skills use the package root only" + ) + if module != "easyvista_python_client": + continue + for alias in node.names: + assert alias.name in exported, ( + f"{skill.name} imports {alias.name!r}, which is not in " + "easyvista_python_client.__all__" + ) + + +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_client_methods_and_keywords_exist(skill: Path) -> None: + for tree in _snippet_trees(skill): + for call in _client_calls(tree): + method = call.func.attr + for cls in (ev.EasyvistaClient, ev.AsyncEasyvistaClient): + bound = getattr(cls, method, None) + assert bound is not None, ( + f"{skill.name} calls client.{method}(), which does not exist " + f"on {cls.__name__}" + ) + signature = inspect.signature(getattr(ev.EasyvistaClient, method)) + accepted = set(signature.parameters) - {"self"} + for keyword in call.keywords: + if keyword.arg is None: # **kwargs splat + continue + assert keyword.arg in accepted, ( + f"{skill.name} passes {keyword.arg}= to client.{method}(), " + f"which accepts {sorted(accepted)}" + ) + + +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_write_model_keywords_exist(skill: Path) -> None: + for tree in _snippet_trees(skill): + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + model = _WRITE_MODELS.get(node.func.id) + if model is None: + continue + fields = set(model.model_fields) + for keyword in node.keywords: + if keyword.arg is None: + continue + assert keyword.arg in fields, ( + f"{skill.name} sets {keyword.arg}= on {node.func.id}, whose " + f"fields are {sorted(fields)}" + ) + + +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_no_unpublished_or_private_references(skill: Path) -> None: + text = (skill / "SKILL.md").read_text(encoding="utf-8") + for needle in _UNPUBLISHED: + assert needle not in text, ( + f"{skill.name} references {needle!r}, which is gitignored and " + "invisible to anyone reading the published repository" + ) + assert "easyvista_python_client._" not in text, ( + f"{skill.name} names a private module; skills document the public surface" + ) + + +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_snippet_hosts_are_synthetic(skill: Path) -> None: + for tree in _snippet_trees(skill): + for node in ast.walk(tree): + if not isinstance(node, ast.Constant) or not isinstance(node.value, str): + continue + for url in _URL_LITERAL.findall(node.value): + assert "example.com" in url, ( + f"{skill.name} carries a non-synthetic URL {url!r}; every " + "host in a skill must sit under example.com" + ) From 83aacd140a126428e07d73da5f64e8fb40f075ce Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 10:41:59 +0200 Subject: [PATCH 03/16] fix(skills): stop false-failing on close/aclose and close snippet-matching gaps Review of the snippet-contract tests found two real defects, both present in the plan's verbatim code rather than introduced by transcription: EasyvistaClient and AsyncEasyvistaClient are deliberately asymmetric on exactly one pair -- unasync renames aclose to close when it generates the sync client from the async source, so only the sync class has close() and only the async class has aclose(). Requiring every called method to exist on both classes meant the first skill to turn the setup skill's own advice ("call client.close() / await client.aclose()") into a snippet would fail for a method that is correct by design. test_client_methods_ and_keywords_exist now exempts only that named pair (_ASYMMETRIC_METHODS, with a comment on why), requires it exist on at least one class, and still runs the keyword check against whichever class owns it -- every other method keeps the strict both-classes rule unchanged. Three checks also passed vacuously on shapes they didn't match, checking nothing without saying so: _client_calls only matched a bare `client` name, so `ctx.client.foo()` was invisible; the write-model check only matched a bare constructor name, so `ev.PostRequest(bogus=1)` skipped validation; and test_imported_symbols_are_public only looked at `from X import Y`, so `import easyvista_python_client as ev` followed by `ev._private(...)` evaded both this check and the private-reference substring check. Fixed by matching attribute-chain receivers and callees (_is_client_expr, _write_model_name) and by forbidding any import of easyvista_python_client itself, aliased or not, in favor of the from-import form the plan already requires. Also added the positive- coverage assertion test_client_methods_and_keywords_exist was missing: every skill must make at least one client.() call, mirroring test_python_snippets_parse's existing non-empty check. Every fix was verified by injecting the specific defect it closes into the real easyvista-client-setup/SKILL.md (or, for the no-client-call case, a throwaway scratch skill directory), confirming a named failure, and reverting -- git diff -- skills/ is empty. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/tests/test_skills_contract.py | 97 ++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/scripts/tests/test_skills_contract.py b/scripts/tests/test_skills_contract.py index 15643d4..baefbdf 100644 --- a/scripts/tests/test_skills_contract.py +++ b/scripts/tests/test_skills_contract.py @@ -186,6 +186,27 @@ def test_readme_lists_every_skill() -> None: _URL_LITERAL = re.compile(r"https?://[^\s\"']+") +# unasync generates the sync client from the async source and renames +# ``aclose`` to ``close`` as part of that transform, so this one pair is +# deliberately asymmetric: only ``EasyvistaClient`` has ``close``, only +# ``AsyncEasyvistaClient`` has ``aclose``. Every other client method is +# expected to exist on both classes; this is the sole exemption from that +# rule, not a general relaxation of it. +_ASYMMETRIC_METHODS = {"close", "aclose"} + + +def _is_client_expr(node: ast.expr) -> bool: + """True for a bare client name or an attribute chain ending in one. + + Covers both ``client.foo()`` and ``ctx.client.foo()`` -- the receiver + does not have to be a bare name for the call to be a real client call. + """ + if isinstance(node, ast.Name): + return node.id in _CLIENT_NAMES + if isinstance(node, ast.Attribute): + return node.attr in _CLIENT_NAMES + return False + def _client_calls(tree: ast.AST) -> list[ast.Call]: """Every ``client.(...)`` call in a parsed snippet.""" @@ -193,12 +214,24 @@ def _client_calls(tree: ast.AST) -> list[ast.Call]: for node in ast.walk(tree): if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): continue - target = node.func.value - if isinstance(target, ast.Name) and target.id in _CLIENT_NAMES: + if _is_client_expr(node.func.value): calls.append(node) return calls +def _write_model_name(func: ast.expr) -> str | None: + """The write-model name a call targets, bare or module-qualified. + + Covers both ``PostRequest(...)`` and ``ev.PostRequest(...)`` -- the + callee does not have to be a bare name for the call to be checkable. + """ + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + return func.attr + return None + + def _snippet_trees(skill: Path) -> list[ast.Module]: text = (skill / "SKILL.md").read_text(encoding="utf-8") return [ast.parse(block) for block in _python_blocks(text)] @@ -209,6 +242,19 @@ def test_imported_symbols_are_public(skill: Path) -> None: exported = set(ev.__all__) for tree in _snippet_trees(skill): for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + qualifies = alias.name == "easyvista_python_client" or ( + alias.name.startswith("easyvista_python_client.") + ) + as_clause = f" as {alias.asname}" if alias.asname else "" + assert not qualifies, ( + f"{skill.name} does `import {alias.name}{as_clause}`; " + "snippets must `from easyvista_python_client import " + "...` instead, so every name they touch through the " + "module is checkable against __all__" + ) + continue if not isinstance(node, ast.ImportFrom): continue module = node.module or "" @@ -227,33 +273,50 @@ def test_imported_symbols_are_public(skill: Path) -> None: @pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) def test_client_methods_and_keywords_exist(skill: Path) -> None: - for tree in _snippet_trees(skill): - for call in _client_calls(tree): - method = call.func.attr + all_calls = [call for tree in _snippet_trees(skill) for call in _client_calls(tree)] + assert all_calls, f"{skill.name} has no client.() call in any example" + for call in all_calls: + method = call.func.attr + if method in _ASYMMETRIC_METHODS: + owners = [ + cls + for cls in (ev.EasyvistaClient, ev.AsyncEasyvistaClient) + if getattr(cls, method, None) is not None + ] + assert owners, ( + f"{skill.name} calls client.{method}(), which does not exist " + "on EasyvistaClient or AsyncEasyvistaClient" + ) + signature_owner = owners[0] + else: for cls in (ev.EasyvistaClient, ev.AsyncEasyvistaClient): bound = getattr(cls, method, None) assert bound is not None, ( f"{skill.name} calls client.{method}(), which does not exist " f"on {cls.__name__}" ) - signature = inspect.signature(getattr(ev.EasyvistaClient, method)) - accepted = set(signature.parameters) - {"self"} - for keyword in call.keywords: - if keyword.arg is None: # **kwargs splat - continue - assert keyword.arg in accepted, ( - f"{skill.name} passes {keyword.arg}= to client.{method}(), " - f"which accepts {sorted(accepted)}" - ) + signature_owner = ev.EasyvistaClient + signature = inspect.signature(getattr(signature_owner, method)) + accepted = set(signature.parameters) - {"self"} + for keyword in call.keywords: + if keyword.arg is None: # **kwargs splat + continue + assert keyword.arg in accepted, ( + f"{skill.name} passes {keyword.arg}= to client.{method}(), " + f"which accepts {sorted(accepted)}" + ) @pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) def test_write_model_keywords_exist(skill: Path) -> None: for tree in _snippet_trees(skill): for node in ast.walk(tree): - if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + if not isinstance(node, ast.Call): + continue + name = _write_model_name(node.func) + if name is None: continue - model = _WRITE_MODELS.get(node.func.id) + model = _WRITE_MODELS.get(name) if model is None: continue fields = set(model.model_fields) @@ -261,7 +324,7 @@ def test_write_model_keywords_exist(skill: Path) -> None: if keyword.arg is None: continue assert keyword.arg in fields, ( - f"{skill.name} sets {keyword.arg}= on {node.func.id}, whose " + f"{skill.name} sets {keyword.arg}= on {name}, whose " f"fields are {sorted(fields)}" ) From 4afdb45d5f94998eba2d18d1fb65990f1807b0fa Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 10:50:13 +0200 Subject: [PATCH 04/16] docs(skills): add the search-syntax skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EasyVista's server-side search grammar is genuinely counter-intuitive and every failure mode but one is silent: `~` is exact-match rather than "contains" (the vendor docs are wrong), a condition on an unknown field or an unsearchable-but-returned column (the `*_PATH` display columns, the nested `STATUS_*` sub-keys) is dropped with no error and the whole table comes back, and only a genuine type mismatch (e.g. a non-int value on `STATUS_ID`) raises loudly as HTTP 590. A broken quote is the one case that looks like it should fall into the silent-ignore trap but does not — it still parses as a field expression and matches nothing. Because two of the three failure modes give no signal at all, every other skill in this set that builds a `search=` argument needs to point here rather than re-deriving these rules per skill. This is also why the skill leads with a baseline-comparison pattern (`count_tickets()` before and after) as the only reliable way to tell "the filter matched everything" apart from "the filter was ignored". Every claim is traceable to `integration_tests/test_live_search_syntax.py`, which characterized this grammar against a live instance; the skill cites it as the authority rather than restating vendor documentation known to be wrong. Also adds the `easyvista-search-syntax` row to `skills/README.md`. Verified with: .\.venv\Scripts\python.exe -m pytest scripts/tests/test_skills_contract.py -v --no-cov (22 passed) Co-Authored-By: Claude Opus 5 (1M context) --- skills/README.md | 1 + skills/easyvista-search-syntax/SKILL.md | 170 ++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 skills/easyvista-search-syntax/SKILL.md diff --git a/skills/README.md b/skills/README.md index b952421..e498ead 100644 --- a/skills/README.md +++ b/skills/README.md @@ -14,6 +14,7 @@ installed wheel, which carries only the `easyvista_python_client` package. | Skill | Use when the agent needs to | Main public API | | --- | --- | --- | | `easyvista-client-setup` | Build and configure an authenticated client | `EasyvistaConfig`, `EasyvistaClient`, `AsyncEasyvistaClient` | +| `easyvista-search-syntax` | Write or debug any `search=` expression | `ev_equals_filter`, `ev_in_filter`, `escape_ev_value`, `is_safe_ev_value` | ## Sync and async diff --git a/skills/easyvista-search-syntax/SKILL.md b/skills/easyvista-search-syntax/SKILL.md new file mode 100644 index 0000000..0deb290 --- /dev/null +++ b/skills/easyvista-search-syntax/SKILL.md @@ -0,0 +1,170 @@ +--- +name: easyvista-search-syntax +description: "Write correct EasyVista server-side search expressions for search_tickets, iter_tickets, count_tickets, search_assets, search_departments and search_employees using ev_equals_filter, ev_in_filter, escape_ev_value and is_safe_ev_value. Use whenever building a search= argument, filtering EasyVista records, or debugging a filter that returned everything or nothing — EasyVista silently ignores conditions it cannot honour and returns the whole table." +license: MIT +compatibility: "Requires Python 3.10+, easyvista-python-client, and network access to an EasyVista Service Manager REST API." +metadata: + package: easyvista-python-client + version: "0.1.0" +--- + +> **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, +> use `async with`, `await` every call, and `async for` over the `iter_*` +> methods — the method names and arguments are identical. See +> `easyvista-client-setup`. + +Every `search_*` and `iter_*` method takes the same `search` string. The +grammar is small and two of its three failure modes are silent, so this skill +is a prerequisite for any filtering work. Everything below was characterized +against a live instance by `integration_tests/test_live_search_syntax.py` — +that file is the authority when something here looks wrong. + +## The grammar + +- `FIELD:"value"` — exact match. +- `~` is a **synonym for `:`** — exact match, not "contains", on code-like + fields (`DEPARTMENT_CODE`, `ASSET_TAG`) and on free-text label fields + (`DEPARTMENT_FR`) alike. Vendor documentation claiming otherwise is wrong. + **No substring operator has been identified.** +- `%` inside a value is a **literal character**, not a wildcard. +- `,` combines conditions: **OR** when every condition names the same field, + **AND** across different fields. +- `;` is **not** a combinator; it is swallowed into the quoted value. +- There is **no escape for a `"` inside a value**. Raw, backslash-escaped and + doubled renderings were all tested against a ticket verifiably created with + a quote in its title; none matched. + +## Three fates of a condition + +The core of this skill: + +1. **Honoured.** +2. **Silently dropped** — no error. EasyVista removes any condition it cannot + honour and applies what is left; with nothing left, it returns **every** + row. This happens for structurally unparseable input + (`DEPARTMENT_FR LIKE "%TECH%"`, bare garbage), for an unknown field, and + for a well-formed condition on a returned-but-unsearchable field. Dropping + is **per condition**: in a two-condition search, one can be honoured while + the other vanishes. +3. **Rejected outright** — `EasyvistaValidationError` (HTTP 590) when the + value's *type* does not match the column, e.g. sending a status name to + the integer `STATUS_ID`. This is the friendly failure. + +State the counter-intuitive case explicitly: a **broken quote does not** +return the table. `DEPARTMENT_CODE:"X""` still parses as a field expression, +the value swallows the junk, and it matches nothing (0 rows). + +## What is searchable + +Only **top-level scalar columns**. Two families are returned but not +searchable, and naming one matches everything: + +- the denormalized `*_PATH` display columns (`SD_CATALOG_PATH`, + `DEPARTMENT_PATH`) — filter the `*_ID` sibling instead; +- the sub-keys of a nested reference object (`STATUS_EN` / `STATUS_FR` / + `STATUS_GUID` inside `STATUS`) — they are not top-level columns at all. + Filter `STATUS_ID`. + +The rule is about **nesting, not language**: `DEPARTMENT_FR` is a top-level +column on `departments` and filters correctly. `CATALOG_GUID` is not an +instance of this rule — it is not returned at all, so it is merely an unknown +field. There is **no verified way to filter tickets by status name**; status +ids are instance-specific. + +## Procedure + +1. Build every filter with `ev_equals_filter` / `ev_in_filter`. Never + f-string a value into a `search`. +2. Handle `None`: both builders return `None` for a blank or missing value, + so `search=None` means unfiltered — guard when that is not what you want. +3. Call `is_safe_ev_value(value)` first when you would rather skip a filter + than raise; `escape_ev_value` raises `ValueError` on a value containing + `"`. +4. Prefer an `*_ID` column over any label column. +5. Verify the filter was applied: compare the filtered count against the + unfiltered baseline (see below) before trusting a result set. +6. If the call raises 590, the value's type does not match the column — that + is a real signal, not a bug. + +## Examples + +```python +from easyvista_python_client import EasyvistaClient, ev_equals_filter + +with EasyvistaClient.from_env() as client: + search = ev_equals_filter("STATUS_ID", 3) + result = client.search_tickets(search=search, max_rows=50) + print(result.record_count, "of", result.total_record_count) + for ticket in result.records: + print(ticket.rfc_number, ticket.title) +``` + +```python +from easyvista_python_client import EasyvistaClient, ev_in_filter + +with EasyvistaClient.from_env() as client: + # ',' is OR when every condition names the same field. + search = ev_in_filter("DEPARTMENT_CODE", ["ACME", "GLOBEX"]) + result = client.search_departments(search=search) + print(result.total_record_count) +``` + +```python +from easyvista_python_client import EasyvistaClient, ev_equals_filter + +with EasyvistaClient.from_env() as client: + # ',' is AND across different fields: build the parts, then join them. + parts = [ + ev_equals_filter("STATUS_ID", 3), + ev_equals_filter("DEPARTMENT_ID", 42), + ] + search = ",".join(part for part in parts if part is not None) + print(client.count_tickets(search=search)) +``` + +```python +from easyvista_python_client import EasyvistaClient, ev_equals_filter + +with EasyvistaClient.from_env() as client: + # Prove the filter was applied. A silently-dropped condition returns the + # whole table, which is indistinguishable from a filter that matched + # everything -- except by comparison with the unfiltered baseline. + baseline = client.count_tickets() + search = ev_equals_filter("DEPARTMENT_ID", 42) + matched = client.count_tickets(search=search) + if matched >= baseline: + raise RuntimeError( + f"search={search!r} matched {matched} of {baseline} records -- " + "EasyVista ignored the condition" + ) +``` + +```python +from easyvista_python_client import EasyvistaClient, is_safe_ev_value, ev_equals_filter + +user_supplied = 'ACME "North"' + +with EasyvistaClient.from_env() as client: + if is_safe_ev_value(user_supplied): + result = client.search_departments( + search=ev_equals_filter("DEPARTMENT_CODE", user_supplied) + ) + else: + # No escape for '"' exists; fall back to the client-side fuzzy scan. + result = client.find_departments(user_supplied, limit=10) +``` + +## Gotchas + +- A `,` reaching the server inside untrusted input **widens** a same-field + query — this is the injection vector. A `,` **inside** the quotes is + inert, so blocking the `"` is what blocks the attack, which is exactly what + `escape_ev_value` does. +- `ev_equals_filter` returns `None` for a blank value; passing that straight + through as `search=` silently means "no filter". +- An unknown `sort` token is ignored, not rejected — the server falls back to + its default order. +- `count_tickets` is the cheap way to check a filter: it sends `max_rows=1` + and reads the envelope total without fetching records. +- `search_*` returns one page; `iter_*` pages until the server reports no + `@next` or `max_records` is reached. From ce042c11d72ae38cde2b29b68f1bbd75f197ac91 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 11:14:11 +0200 Subject: [PATCH 05/16] fix(skills): flag the unverified sort claim, drop unprobed STATUS_GUID Code review on the search-syntax skill caught two places where confidence outran evidence, both against the skill's own stated bar: the intro promises that everything below was characterized by integration_tests/test_live_search_syntax.py, and that file is silent on `sort` entirely and never actually searches STATUS_GUID. - The Gotchas bullet claiming an unknown `sort` token is ignored rather than rejected was stated as flat fact alongside eight genuinely live-verified bullets, with no way for a reader to tell them apart. Its only basis in this repo is the open item O-DIR-1 comment in easyvista_python_client/directory.py (RECENT_TICKETS_SORT), which itself says the behavior is "not yet live-confirmed." Reworded to say so explicitly and to cite directory.py and O-DIR-1, without hedging the claims around it that are genuinely tested. - The nested-sub-key parenthetical in "What is searchable" listed STATUS_GUID alongside STATUS_EN/STATUS_FR as silently dropped. test_a_nested_reference_subkey_is_not_searchable (line 579) explicitly excludes STATUS_GUID from its candidate pool and never searches on it, so no executed assertion covers it. Dropped it from the parenthetical rather than keep an untested claim next to tested ones. Verified with: .\.venv\Scripts\python.exe -m pytest scripts/tests/test_skills_contract.py -v --no-cov (22 passed) Co-Authored-By: Claude Opus 5 (1M context) --- skills/easyvista-search-syntax/SKILL.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/skills/easyvista-search-syntax/SKILL.md b/skills/easyvista-search-syntax/SKILL.md index 0deb290..e8f272f 100644 --- a/skills/easyvista-search-syntax/SKILL.md +++ b/skills/easyvista-search-syntax/SKILL.md @@ -61,9 +61,9 @@ searchable, and naming one matches everything: - the denormalized `*_PATH` display columns (`SD_CATALOG_PATH`, `DEPARTMENT_PATH`) — filter the `*_ID` sibling instead; -- the sub-keys of a nested reference object (`STATUS_EN` / `STATUS_FR` / - `STATUS_GUID` inside `STATUS`) — they are not top-level columns at all. - Filter `STATUS_ID`. +- the sub-keys of a nested reference object (`STATUS_EN` / `STATUS_FR` + inside `STATUS`) — they are not top-level columns at all. Filter + `STATUS_ID`. The rule is about **nesting, not language**: `DEPARTMENT_FR` is a top-level column on `departments` and filters correctly. `CATALOG_GUID` is not an @@ -162,8 +162,12 @@ with EasyvistaClient.from_env() as client: `escape_ev_value` does. - `ev_equals_filter` returns `None` for a blank value; passing that straight through as `search=` silently means "no filter". -- An unknown `sort` token is ignored, not rejected — the server falls back to - its default order. +- An unknown `sort` token is believed to be ignored, not rejected, falling + back to the default order — but unlike the rest of this skill, that is + **not** covered by the live suite. It is what + `easyvista_python_client/directory.py`'s `RECENT_TICKETS_SORT` relies on + (open item O-DIR-1); treat it as unconfirmed until checked against your + own instance. - `count_tickets` is the cheap way to check a filter: it sends `max_rows=1` and reads the envelope total without fetching records. - `search_*` returns one page; `iter_*` pages until the server reports no From bccef9f6c2f94e47a7fb748a248c25c2aac33d04 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 11:25:26 +0200 Subject: [PATCH 06/16] docs(skills): add the ticket-workflow skill Tickets are the operation an agent will reach for most, so this is Task 4 of the agent-skills build-out: create/read/search/paginate/update/close via PostRequest, Request, RequestUpdate and the requests-resource client methods. It establishes the "discover the ids first" pattern (read a real ticket, pull ids off reference()/classify_fields(), then write) that the asset, department and employee skills will reuse rather than re-deriving. Every non-obvious claim was checked against source before being written down, not copied from the brief on faith: - create_ticket's HREF-only response deriving a usable rfc_number is backed by Request._derive_rfc_from_href's model validator (models/ request.py) and by integration_tests/test_live_ticket_identity.py's test_rfc_number_is_derived_from_the_create_response_href, which asserts it against a real create response, not just a synthetic one. - RequestUpdate(description=...) writing COMMENT rather than DESCRIPTION is stated verbatim in RequestUpdate's own docstring, and cross-checked against the create-time caveat on PostRequest.description. - The one brief claim that could not be traced to package source or integration_tests/ (specific punctuation triggering a 590) was kept but demoted to a hedged "lead, not a rule," sourced explicitly to the content-fidelity probe script's defensive comment rather than stated with the same confidence as the verified claims. scripts/tests/test_skills_contract.py passes all 32 cases (3 skills x the per-skill checks, plus the 2 whole-suite ones), confirming every method/ keyword/import/write-model-field claim against the installed package. Co-Authored-By: Claude Opus 5 (1M context) --- skills/README.md | 1 + skills/easyvista-ticket-workflow/SKILL.md | 191 ++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 skills/easyvista-ticket-workflow/SKILL.md diff --git a/skills/README.md b/skills/README.md index e498ead..a0bc259 100644 --- a/skills/README.md +++ b/skills/README.md @@ -15,6 +15,7 @@ installed wheel, which carries only the `easyvista_python_client` package. | --- | --- | --- | | `easyvista-client-setup` | Build and configure an authenticated client | `EasyvistaConfig`, `EasyvistaClient`, `AsyncEasyvistaClient` | | `easyvista-search-syntax` | Write or debug any `search=` expression | `ev_equals_filter`, `ev_in_filter`, `escape_ev_value`, `is_safe_ev_value` | +| `easyvista-ticket-workflow` | Create, read, search, update or close tickets | `PostRequest`, `Request`, `RequestUpdate`, `SearchResult` | ## Sync and async diff --git a/skills/easyvista-ticket-workflow/SKILL.md b/skills/easyvista-ticket-workflow/SKILL.md new file mode 100644 index 0000000..99c40ad --- /dev/null +++ b/skills/easyvista-ticket-workflow/SKILL.md @@ -0,0 +1,191 @@ +--- +name: easyvista-ticket-workflow +description: "Create, read, search, paginate, update and close EasyVista tickets (requests) with easyvista_python_client — PostRequest, Request, RequestUpdate, create_ticket, create_tickets, get_ticket, search_tickets, iter_tickets, count_tickets, update_ticket and close_ticket. Use for any ticket/incident/request operation, including discovering the instance-specific catalog codes and ids a create needs." +license: MIT +compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the requests resource." +metadata: + package: easyvista-python-client + version: "0.1.0" +--- + +> **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, +> use `async with`, `await` every call, and `async for` over the `iter_*` +> methods — the method names and arguments are identical. See +> `easyvista-client-setup`. + +Tickets are EasyVista's `requests` resource. Reads are `get_ticket`, +`search_tickets`, `iter_tickets` and `count_tickets`; writes are +`create_ticket`, `create_tickets`, `update_ticket` and `close_ticket`. +Filtering any `search=` argument follows the grammar in +`easyvista-search-syntax` — see that skill for the rules; they are not +repeated here. + +## Discover the ids first + +Which fields a create actually requires is configured **per catalog, on the +EasyVista side** — the client cannot know them statically, and a missing one +comes back as `EasyvistaValidationError` (HTTP 590, code 2013). So before +writing anything: read a real ticket, and take the ids off it. + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + probe = client.search_tickets(max_rows=1) + sample = client.get_ticket(probe.records[0].rfc_number) + + # Ids this instance actually uses, with their human labels. + for name in ("STATUS", "DEPARTMENT", "URGENCY", "IMPACT", "CATALOG_REQUEST"): + ref = sample.reference(name) + print(name, "->", ref.id, ref.display) + + # Instance-specific custom columns, separated from the official ones. + buckets = sample.classify_fields() + print("custom:", sorted(buckets.custom)) + print("links:", sorted(buckets.links)) +``` + +`reference(name)` resolves any reference attribute — nested object or bare +id — to a `Reference` with `.id`, `.label` and `.display` (label if present, +else id, else `None`). `classify_fields()` partitions the record into a +`FieldClassification` with `.official`, `.custom` (the instance's `e_*` +columns), `.available` and `.links` buckets. Both work on any record from any +instance with no configuration, so this pattern is how you learn what *this* +deployment needs before you build a payload for it. + +## Procedure + +1. Discover the ids (above). Never hardcode an id copied from another + instance — catalog codes, status/urgency/impact ids and group ids are all + instance-specific. +2. Build a `PostRequest`. `catalog_code` plus `title` is the practical + minimum; most catalogs also require `origin`, `department_id`, + `urgency_id` and `impact_id`. +3. Put instance-specific columns in `custom_fields`; they serialize with an + `e_` prefix unless already prefixed. +4. Call `create_ticket(ticket)`. It returns a `Request` whose `rfc_number` is + usable immediately — see the first Gotcha for why. +5. To set body text you can read back afterwards, follow the create with + `update_ticket(rfc, RequestUpdate(description=...))`. +6. Read one ticket with `get_ticket(rfc)`; search a page with + `search_tickets(...)`; walk every match with `iter_tickets(...)`. +7. Close with `close_ticket(rfc, status_guid=..., delete_actions=..., + comment=...)`. + +## Examples + +```python +from easyvista_python_client import EasyvistaClient, PostRequest + +with EasyvistaClient.from_env() as client: + ticket = client.create_ticket( + PostRequest( + catalog_code="YOUR_CATALOG_CODE", + title="Printer offline on the third floor", + origin=1, + department_id=1, + urgency_id=1, + impact_id=1, + recipient_mail="user@example.com", + custom_fields={"building": "HQ"}, + ) + ) + print(ticket.rfc_number) +``` + +Every numeric id above is a placeholder — `origin=1`, `department_id=1`, +`urgency_id=1` and `impact_id=1` are not guaranteed to mean anything on your +instance. Use the ids the discovery block printed for it instead. + +```python +from easyvista_python_client import EasyvistaClient, RequestUpdate + +with EasyvistaClient.from_env() as client: + updated = client.update_ticket( + "YOUR_RFC_NUMBER", + RequestUpdate(title="Printer offline - third floor", description="Confirmed offline at 09:15."), + ) + print(updated.rfc_number) +``` + +```python +from easyvista_python_client import EasyvistaClient, ev_equals_filter + +with EasyvistaClient.from_env() as client: + search = ev_equals_filter("STATUS_ID", 3) + + page = client.search_tickets(search=search, fields=["RFC_NUMBER", "TITLE"], max_rows=50) + print(page.record_count, "of", page.total_record_count) + + for ticket in client.iter_tickets(search=search, page_size=100, max_records=500): + print(ticket.rfc_number, ticket.title) + + print("total matching:", client.count_tickets(search=search)) +``` + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + closed = client.close_ticket( + "YOUR_RFC_NUMBER", + status_guid="YOUR_CLOSED_STATUS_GUID", + delete_actions=1, + comment="Resolved: printer power-cycled.", + ) + print(closed.rfc_number) +``` + +```python +from easyvista_python_client import EasyvistaClient, PostRequest + +with EasyvistaClient.from_env() as client: + made = client.create_tickets( + [ + PostRequest(catalog_code="YOUR_CATALOG_CODE", title="Batch item one"), + PostRequest(catalog_code="YOUR_CATALOG_CODE", title="Batch item two"), + ] + ) + print([t.rfc_number for t in made]) +``` + +## Gotchas + +- `create_ticket`'s response body is **HREF-only** — the API returns no + `RFC_NUMBER`. `Request` derives `rfc_number` from the trailing path segment + of the `href` (its own model validator does this, and it is checked against + a live create response, not just a synthetic one), so `ticket.rfc_number` + works right after a create; nothing else on the returned `Request` is + populated. Re-read with `get_ticket(ticket.rfc_number)` if you need the + full record. +- `RequestUpdate(description=...)` writes the ticket's **COMMENT** memo, not + `DESCRIPTION` — on the instance this client was verified against, + `DESCRIPTION` stayed empty and `COMMENT` carried the text. Which one a given + deployment actually populates is a per-instance configuration choice. Read + it back with `resolve_memo("requests/{rfc}/comment")`, or take + `TicketContext.comment`, which resolves it for you. +- A `description` passed to **`PostRequest`** at create time was not readable + back through either memo on the verified instance. Follow the create with + an `update_ticket` when the body must be retrievable. +- `create_tickets` sends **one POST per ticket, sequentially, on both + surfaces** — EasyVista creates only the first item of a multi-item body. A + failure at item *k* means items 0..k-1 exist and the rest do not. It is + deliberately not a fan-out. +- `Request.title` is legitimately `None` for tickets created through the + portal/catalog on some instances; the human summary lives in the + description or the catalog path instead. +- Write models reject unknown fields (`extra="forbid"`), so a typo raises + locally rather than being silently dropped by the API. +- Mandatory fields are per-catalog. HTTP 590 with code 2013 means a missing + mandatory field or an invalid catalog reference — it is **not** retried, + because it is deterministic. +- Some punctuation in title/description text has produced a 590 in ad hoc + content probing — the project's own content-fidelity probe script + deliberately keeps its create payload free of `--`, `[`, `]`, `/` and `.` + for exactly that reason. This is not covered by the tracked automated test + suite, so treat it as a lead rather than a confirmed rule: if a create + 590s and every id above checks out, retrying with punctuation stripped + from the title/description is worth trying before concluding the catalog + is misconfigured. +- The accepted **write** format for the date fields is unverified; both a + string and an int probe returned 590. Do not attempt to set them. From d0caae0a7003e9fb43b6682ae7d26d35333d2ec3 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 11:38:29 +0200 Subject: [PATCH 07/16] docs(skills): stop overstating the punctuation gotcha in ticket-workflow Code review flagged the punctuation gotcha in the ticket-workflow skill as "Needs fixes": it opened with "has produced a 590 in ad hoc content probing," indicative past tense in the same voice the file uses for its verified claims. Re-reading scripts/validate_live_content_fidelity.py:340-341 directly confirms the source is a precaution ("would abort the whole run"), not a record of an observed rejection, and "ad hoc content probing" appears nowhere in the tracked repository -- it was invented framing that lent the claim borrowed empirical weight. Rewrote the bullet so every clause is traceable to the script: what it does (keeps its create payload plain ASCII, avoiding certain punctuation), why (quoted from its own comment, not paraphrased upward), and an explicit statement that no tracked test has actually observed the rejection it guards against. Kept the bullet rather than cutting it, since the underlying precaution is real and low-cost to pass on to a caller debugging an unexpected 590 -- the fix corrects the confidence level, not the presence, of the claim. No other bullet changed; the claims already verified as accurate (HREF-only create/rfc_number derivation, COMMENT-vs-DESCRIPTION, sequential create_tickets, 590/code-2013) are untouched. Co-Authored-By: Claude Opus 5 (1M context) --- skills/easyvista-ticket-workflow/SKILL.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/skills/easyvista-ticket-workflow/SKILL.md b/skills/easyvista-ticket-workflow/SKILL.md index 99c40ad..526ab42 100644 --- a/skills/easyvista-ticket-workflow/SKILL.md +++ b/skills/easyvista-ticket-workflow/SKILL.md @@ -179,13 +179,15 @@ with EasyvistaClient.from_env() as client: - Mandatory fields are per-catalog. HTTP 590 with code 2013 means a missing mandatory field or an invalid catalog reference — it is **not** retried, because it is deterministic. -- Some punctuation in title/description text has produced a 590 in ad hoc - content probing — the project's own content-fidelity probe script - deliberately keeps its create payload free of `--`, `[`, `]`, `/` and `.` - for exactly that reason. This is not covered by the tracked automated test - suite, so treat it as a lead rather than a confirmed rule: if a create - 590s and every id above checks out, retrying with punctuation stripped - from the title/description is worth trying before concluding the catalog - is misconfigured. +- The project's own content-fidelity probe script + (`scripts/validate_live_content_fidelity.py`) keeps its create payload to + plain ASCII, avoiding `--`, `[`, `]`, `/` and `.`, as a **precaution** + against a server-side content rejection (HTTP 590) — its own comment + reasons that the create call "is not wrapped, so a server-side content + rejection (HTTP 590) here would abort the whole run." No tracked test has + actually observed such a rejection, so treat this as a precaution worth + copying, not a documented rule: if a create 590s and every id above checks + out, stripping punctuation from the title/description before concluding + the catalog is misconfigured is a reasonable next thing to try. - The accepted **write** format for the date fields is unverified; both a string and an int probe returned 590. Do not attempt to set them. From aa8a4db1a2b6a8f4a09d14c31fc43757fd9555c5 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 11:44:05 +0200 Subject: [PATCH 08/16] docs(skills): add the ticket-actions skill Actions are EasyVista's per-ticket work log, and the surface has two genuine traps an agent needs spelled out before it writes one: a created action's id is not recoverable from the create response (the returned HREF names the parent request, not the action -- verified in easyvista_python_client/models/action.py's href-derivation validator and exercised live in integration_tests/test_live_ticket_history.py), and list_actions never returns note text at all -- that only comes back through get_action's DESCRIPTION memo href, which the caller must resolve itself or via get_ticket_context. Adds skills/easyvista-ticket-actions/SKILL.md: the discover-ids-first pattern for action_type_id/group_id, the create-then-diff-list_actions idiom (which Task 9's reporting-and-context skill will reference), and a get_action/resolve_memo example for reading a note back. Adds the matching row to skills/README.md. Co-Authored-By: Claude Opus 5 (1M context) --- skills/README.md | 1 + skills/easyvista-ticket-actions/SKILL.md | 134 +++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 skills/easyvista-ticket-actions/SKILL.md diff --git a/skills/README.md b/skills/README.md index a0bc259..181a23f 100644 --- a/skills/README.md +++ b/skills/README.md @@ -16,6 +16,7 @@ installed wheel, which carries only the `easyvista_python_client` package. | `easyvista-client-setup` | Build and configure an authenticated client | `EasyvistaConfig`, `EasyvistaClient`, `AsyncEasyvistaClient` | | `easyvista-search-syntax` | Write or debug any `search=` expression | `ev_equals_filter`, `ev_in_filter`, `escape_ev_value`, `is_safe_ev_value` | | `easyvista-ticket-workflow` | Create, read, search, update or close tickets | `PostRequest`, `Request`, `RequestUpdate`, `SearchResult` | +| `easyvista-ticket-actions` | Read or write a ticket's action log | `PostAction`, `Action`, `resolve_memo` | ## Sync and async diff --git a/skills/easyvista-ticket-actions/SKILL.md b/skills/easyvista-ticket-actions/SKILL.md new file mode 100644 index 0000000..0fd4e49 --- /dev/null +++ b/skills/easyvista-ticket-actions/SKILL.md @@ -0,0 +1,134 @@ +--- +name: easyvista-ticket-actions +description: "Read and write the action log on an EasyVista ticket with easyvista_python_client — create_action, list_actions and get_action with PostAction and Action, including how to recover a created action's id and how to resolve an action's note text, which the list endpoint does not return. Use for ticket followups, work notes, progress entries or any per-ticket action history." +license: MIT +compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the actions sub-resource." +metadata: + package: easyvista-python-client + version: "0.1.0" +--- + +> **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, +> use `async with`, `await` every call, and `async for` over the `iter_*` +> methods — the method names and arguments are identical. See +> `easyvista-client-setup`. + +Actions are EasyVista's per-ticket work log — the closest equivalent to a +followup. Three methods: `create_action(rfc, action)`, `list_actions(rfc)` and +`get_action(action_id)`. The list and item shapes differ substantially, which +is where most mistakes come from. + +## Two shapes of the same record + +- `list_actions(rfc)` returns a **slim collection record**. It does **not** + carry the note text. +- `get_action(action_id)` returns a **fuller item record** whose `DESCRIPTION` + and `COMMENT` are memo href objects — that is, `action.description` is a + `dict` with an `HREF`, not a string, until something resolves it. +- The note text supplied as `PostAction.description` comes back through + **`DESCRIPTION`**, not `COMMENT`. +- The simplest way to get resolved bodies is `client.get_ticket_context(rfc)`, + which fetches each action item-level and resolves its memo for you — see + `easyvista-reporting-and-context`. + +## Discover the ids first + +`action_type_id` and `group_id` are instance-specific. Read them off existing +actions before writing. + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + for action in client.list_actions("YOUR_RFC_NUMBER"): + print(action.action_id, action.reference("ACTION_TYPE").display) +``` + +## Procedure + +1. List existing actions to learn the instance's action types and groups. +2. Build a `PostAction`: identify the type with `action_type_id` (or + `action_type_name`) and the assigned group with `group_id` (or + `group_name`); put the note in `description`. +3. `create_action(rfc, action)`. +4. To address the action you just created, diff `list_actions` across the + call — the create response cannot give you the id (see Gotchas). +5. To read note text, either call `get_action` and resolve the memo href with + `resolve_memo`, or take `get_ticket_context(rfc)` and read + `context.actions`. + +## Examples + +```python +from easyvista_python_client import EasyvistaClient, PostAction + +with EasyvistaClient.from_env() as client: + action = client.create_action( + "YOUR_RFC_NUMBER", + PostAction( + action_type_id=1, + group_id=1, + description="Called the user back; printer power-cycled.", + ), + ) + print(action.href) +``` + +With a note under it: both ids are placeholders — use the ones the discovery +block printed. + +```python +from easyvista_python_client import EasyvistaClient, PostAction + +with EasyvistaClient.from_env() as client: + rfc = "YOUR_RFC_NUMBER" + + # The create response carries no ACTION_ID, so diff the list around it. + before = {a.action_id for a in client.list_actions(rfc)} + client.create_action(rfc, PostAction(action_type_id=1, description="Triaged.")) + after = client.list_actions(rfc) + created = [a for a in after if a.action_id not in before] + print([a.action_id for a in created]) +``` + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + action = client.get_action(12345) + + # On the item-level record the note is a memo href, not a string. + body = action.description + if isinstance(body, dict) and body.get("HREF"): + body = client.resolve_memo(body["HREF"]) + print(body) +``` + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + context = client.get_ticket_context("YOUR_RFC_NUMBER") + for action in context.actions: + print(action.action_id, action.description) +``` + +## Gotchas + +- **`create_action` gives you no usable id.** The live create response is an + HREF naming the **parent request**, with no `ACTION_ID`. The model's + href-derivation deliberately declines to fire (the tail is an RFC number, + not a numeric id) rather than inventing one. Diff `list_actions` across the + call. `integration_tests/test_live_ticket_history.py` shows the pattern. +- `list_actions` does not return note text. A rendering built from the list + alone has empty bodies. +- `action.description` and `action.comment` are `str | dict | None`. Check + the type before treating either as text. +- `action.action_type` is a nested object on the live API, not a string. Use + `action.reference("ACTION_TYPE").display` for the label. +- Resolving every body costs two extra requests per action (item fetch, then + memo). `get_ticket_context(rfc, resolve_action_bodies=False)` skips it when + you only need the list. +- A profile restriction on the actions sub-resource surfaces as + `EasyvistaAuthError` (403); the context bundle degrades to `[]` rather than + failing. From 804f59992f52d01519e485c276ffdfd6406cdb93 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 11:53:48 +0200 Subject: [PATCH 09/16] docs(skills): add the document-workflow skill Documents the ticket-scoped attachment surface (add_document, list_documents, download_document) so an agent can upload, list and fetch attachments without reading resources/documents.py or the transport's redirect/host-check logic. Every gotcha here traces to source: the ValueError from download_href() when no DDL_HREF/HREF is set, the EasyvistaError from resolve_url() when a download URL points off-instance (guarding against leaking the Bearer token to a foreign redirect target, since httpx drops Authorization cross-origin), the 403-to-EasyvistaAuthError mapping shared between the JSON and binary transport paths, and the filename fallback chain in models/document.py. Adds the corresponding row to skills/README.md. Co-Authored-By: Claude Opus 5 (1M context) --- skills/README.md | 1 + skills/easyvista-document-workflow/SKILL.md | 87 +++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 skills/easyvista-document-workflow/SKILL.md diff --git a/skills/README.md b/skills/README.md index 181a23f..b4a38f2 100644 --- a/skills/README.md +++ b/skills/README.md @@ -17,6 +17,7 @@ installed wheel, which carries only the `easyvista_python_client` package. | `easyvista-search-syntax` | Write or debug any `search=` expression | `ev_equals_filter`, `ev_in_filter`, `escape_ev_value`, `is_safe_ev_value` | | `easyvista-ticket-workflow` | Create, read, search, update or close tickets | `PostRequest`, `Request`, `RequestUpdate`, `SearchResult` | | `easyvista-ticket-actions` | Read or write a ticket's action log | `PostAction`, `Action`, `resolve_memo` | +| `easyvista-document-workflow` | Attach, list or download ticket files | `Document`, `add_document`, `download_document` | ## Sync and async diff --git a/skills/easyvista-document-workflow/SKILL.md b/skills/easyvista-document-workflow/SKILL.md new file mode 100644 index 0000000..f4a760d --- /dev/null +++ b/skills/easyvista-document-workflow/SKILL.md @@ -0,0 +1,87 @@ +--- +name: easyvista-document-workflow +description: "Attach, list and download files on an EasyVista ticket with easyvista_python_client — add_document, list_documents and download_document with the Document model. Use for ticket attachments, uploading evidence or logs to a request, or fetching an attachment's bytes." +license: MIT +compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the documents sub-resource." +metadata: + package: easyvista-python-client + version: "0.1.0" +--- + +> **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, +> use `async with`, `await` every call, and `async for` over the `iter_*` +> methods — the method names and arguments are identical. See +> `easyvista-client-setup`. + +Documents are attachments on a ticket. Three methods: `add_document(rfc, +filename=, content=)`, `list_documents(rfc)`, `download_document(document)`. +All are ticket-scoped — there is no standalone document resource on this +client. + +## Procedure + +1. Upload with `add_document(rfc, filename="name.ext", content=b"...")`. + `content` is **bytes**, not a path and not `str`. +2. List with `list_documents(rfc)` → `list[Document]`. +3. Download with `download_document(document)` → `bytes`. Pass the `Document` + from the list, or a raw href/path. +4. Write the bytes yourself; the client does not touch the filesystem. + +## The Document model + +`filename` (falls back to `DOCUMENT` then `NAME`, so it is populated on every +observed shape), `name`, `document`, `document_id`, `download_href` (the API's +`DDL_HREF`, the direct-download URL), `href`. + +## Examples + +```python +from pathlib import Path + +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + document = client.add_document( + "YOUR_RFC_NUMBER", + filename="diagnostics.log", + content=Path("diagnostics.log").read_bytes(), + ) + print(document.filename, document.document_id) +``` + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + for document in client.list_documents("YOUR_RFC_NUMBER"): + print(document.document_id, document.filename, document.download_href) +``` + +```python +from pathlib import Path + +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + for document in client.list_documents("YOUR_RFC_NUMBER"): + if document.download_href is None: + continue + payload = client.download_document(document) + Path(document.filename or "attachment.bin").write_bytes(payload) +``` + +## Gotchas + +- `content` must be `bytes`. Read files in binary mode. +- `download_document` raises `ValueError` when the record carries no download + URL — check `download_href` first, or catch it. +- A download URL pointing outside the configured instance raises + `EasyvistaError`. Downloads follow redirects (signed URLs are common), and + httpx strips the `Authorization` header on a cross-origin redirect, so a + foreign host would receive the request unauthenticated — refusing is + deliberate. +- A 403 on an attachment still surfaces as `EasyvistaAuthError`; the binary + path reuses the same error mapping and retry policy as the JSON one. +- `filename` is derived, not always sent by the API. Fall back to a literal + name before writing to disk. +- There is no delete-document method on this client. From e48cc77112d95565b020c608841ced762a8bc980 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 12:04:32 +0200 Subject: [PATCH 10/16] docs(skills): add the asset-workflow skill Assets are the last per-resource CRUD-ish skill in the base set: register, fetch, search and iterate equipment/CI records with create_asset, get_asset, search_assets and iter_assets. Follows the "discover the ids first" pattern established by easyvista-ticket-workflow and cross-references easyvista-search-syntax for the search grammar instead of restating it. Two claims from the task brief did not hold up against the source and were adjusted rather than copied verbatim: - The discovery snippet originally called asset.reference("CATALOG"). Asset (easyvista_python_client/models/asset.py) declares only asset_id, asset_tag, serial_number, status_id and href -- no catalog field, nested or otherwise -- so reference() has nothing to resolve a catalog id against, unlike Request's declared *_ID fields. The snippet now prints asset.classify_fields().official so a reader can find whatever raw key their instance actually uses. - The profile-gated write claim ("commonly profile-gated") is softened: the 403 -> EasyvistaAuthError mapping is generic and verified (easyvista_python_client/exceptions.py), but no tracked test performs a live asset create, so how commonly that specific restriction applies is not something this repository has verified. The catalog_id-is-int-but-get_asset-takes-str asymmetry and the "no update/delete" claim were confirmed directly against easyvista_python_client/models/asset.py and the sync/async client method lists, and kept as stated. scripts/tests/test_skills_contract.py passes for all 6 skills (62 cases). Co-Authored-By: Claude Opus 5 (1M context) --- skills/README.md | 1 + skills/easyvista-asset-workflow/SKILL.md | 122 +++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 skills/easyvista-asset-workflow/SKILL.md diff --git a/skills/README.md b/skills/README.md index b4a38f2..7f24c34 100644 --- a/skills/README.md +++ b/skills/README.md @@ -18,6 +18,7 @@ installed wheel, which carries only the `easyvista_python_client` package. | `easyvista-ticket-workflow` | Create, read, search, update or close tickets | `PostRequest`, `Request`, `RequestUpdate`, `SearchResult` | | `easyvista-ticket-actions` | Read or write a ticket's action log | `PostAction`, `Action`, `resolve_memo` | | `easyvista-document-workflow` | Attach, list or download ticket files | `Document`, `add_document`, `download_document` | +| `easyvista-asset-workflow` | Create, fetch, search or iterate assets | `PostAsset`, `Asset` | ## Sync and async diff --git a/skills/easyvista-asset-workflow/SKILL.md b/skills/easyvista-asset-workflow/SKILL.md new file mode 100644 index 0000000..321833a --- /dev/null +++ b/skills/easyvista-asset-workflow/SKILL.md @@ -0,0 +1,122 @@ +--- +name: easyvista-asset-workflow +description: "Create, fetch, search and iterate EasyVista assets with easyvista_python_client — create_asset, get_asset, search_assets and iter_assets with PostAsset and Asset. Use for equipment, hardware or CI records: registering a new asset, looking one up by tag, or listing a department's assets." +license: MIT +compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the assets resource." +metadata: + package: easyvista-python-client + version: "0.1.0" +--- + +> **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, +> use `async with`, `await` every call, and `async for` over the `iter_*` +> methods — the method names and arguments are identical. See +> `easyvista-client-setup`. + +Assets are EasyVista's equipment records — the `assets` resource. Four +methods: `create_asset`, `get_asset`, `search_assets` and `iter_assets`; there +is no update or delete (see Gotchas). Filtering any `search=` argument follows +the grammar in `easyvista-search-syntax` — see that skill for the rules; they +are not repeated here. + +## Discover the catalog id first + +`PostAsset.catalog_id` is **required** and identifies the equipment model on +your instance. On `Request`, the ticket-workflow skill discovers ids like this +with `reference()`, because fields such as `STATUS_ID` are declared and +`reference()` can resolve a nested label around them. `Asset` is different: +it declares only five fields (`asset_id`, `asset_tag`, `serial_number`, +`status_id`, `href` — see Gotchas), none of them a catalog, so there is +nothing for `reference()` to resolve a catalog id against out of the box. +Print the raw fields on a real asset instead, and find whatever key this +instance uses: + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + page = client.search_assets(max_rows=5) + for asset in page.records: + print(asset.asset_id, asset.asset_tag, asset.serial_number) + # Asset declares only five fields (see Gotchas); everything else the + # instance returns -- including whatever key carries the catalog -- + # is preserved here and visible through classify_fields(). + print("raw fields:", sorted(asset.classify_fields().official)) +``` + +Look through `raw fields` for whatever this instance calls the catalog (or +ask whoever administers catalogs on it) and read its id from there; that is +the value `catalog_id` needs. + +## Procedure + +1. Discover `catalog_id` (above). Never hardcode one copied from another + instance. +2. Build a `PostAsset(catalog_id=..., asset_tag=..., serial_number=...)`; + instance-specific columns go in `custom_fields`. +3. Call `create_asset(asset)`. +4. Fetch one with `get_asset(asset_id)` — `asset_id` is a string here. +5. Search a page with `search_assets(search=..., max_rows=...)`; walk every + match with `iter_assets(...)`. + +## Examples + +```python +from easyvista_python_client import EasyvistaClient, PostAsset + +with EasyvistaClient.from_env() as client: + asset = client.create_asset( + PostAsset( + catalog_id=1, + asset_tag="LAPTOP-0001", + serial_number="SN-0001", + comment_asset="Issued to the service desk pool.", + ) + ) + print(asset.asset_id, asset.asset_tag) +``` + +`catalog_id=1` above is a placeholder — use the id the discovery block +printed for your instance. + +```python +from easyvista_python_client import EasyvistaClient, ev_equals_filter + +with EasyvistaClient.from_env() as client: + found = client.search_assets( + search=ev_equals_filter("ASSET_TAG", "LAPTOP-0001"), max_rows=50 + ) + print(found.record_count, "of", found.total_record_count) + for asset in found.records: + print(asset.asset_id, asset.serial_number) +``` + +```python +from easyvista_python_client import EasyvistaClient, ev_equals_filter + +with EasyvistaClient.from_env() as client: + search = ev_equals_filter("DEPARTMENT_ID", 42) + for asset in client.iter_assets(search=search, page_size=100, max_records=1000): + print(asset.asset_id, asset.asset_tag) +``` + +## Gotchas + +- `catalog_id` is required by EasyVista and is an `int`; `get_asset` takes a + `str` id. The asymmetry is real. +- `ASSET_TAG` filters as **exact match** with `~` as well as `:` — there is + no substring search for a partial tag + (`integration_tests/test_live_search_syntax.py::test_tilde_on_asset_tag_is_exact_match`). +- The `Asset` model declares only `asset_id`, `asset_tag`, `serial_number`, + `status_id` and `href`; everything else the instance returns is preserved + by `extra="allow"` and reachable through `classify_fields()`. There is no + declared catalog field, which is why the discovery block above prints raw + keys instead of calling `reference()`. +- Asset field names beyond those five are pending live validation — that is + the module's own framing, not just this skill's caution. Verify a column + filters (baseline check, `easyvista-search-syntax`) before relying on it. +- There is no update or delete method for assets on this client. +- A profile restriction on asset creation surfaces as `EasyvistaAuthError` + (403) — the same generic 401/403 mapping used everywhere on this client. + No tracked test creates an asset live, so how commonly that restriction + applies in practice is not something this repository has verified. From 5c9fd45354ab0f019b25117116f1fee4a3a74f6e Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 12:15:45 +0200 Subject: [PATCH 11/16] fix(skills): correct the asset-workflow discovery snippet after review Review of the asset-workflow skill (Task 7) came back "Needs fixes" with two Important findings and one Minor, all in the discovery-section replacement I wrote for the brief's unsupportable reference("CATALOG") call -- the two deliberate brief deviations themselves (dropping reference("CATALOG"), softening the profile-gating claim) were judged correct. - The discovery snippet printed sorted(asset.classify_fields().official), which sorts a dict and so yields keys only -- never a value -- while the prose told the reader to "read its id from there." Since catalog_id is required on PostAsset, a reader following the recipe literally could never obtain it. Now prints the buckets themselves (official and links) so both keys and values are visible, and the prose matches what the code actually prints. - The stated reason reference() couldn't help ("Asset declares no catalog field, so there is nothing to resolve against") was a wrong description of the mechanism: reference() resolves off model_dump(by_alias=True), and extra="allow" folds undeclared raw API keys into that dump exactly like declared ones (models/common.py:37-42). easyvista-ticket-workflow's own reference("STATUS") / reference("CATALOG_REQUEST") calls prove this -- neither name is a field Request declares (models/request.py:19-72). Reworded to the real, narrower reason: no tracked source confirms an AM_ASSET payload carries any CATALOG-shaped key at all, declared or not. - Added a check of classify_fields().links alongside .official, since an href-only catalog sub-resource would land there instead (field_model.py:47-50) and be invisible to the .official-only recipe. scripts/tests/test_skills_contract.py still passes all 62 cases. Co-Authored-By: Claude Opus 5 (1M context) --- skills/easyvista-asset-workflow/SKILL.md | 44 +++++++++++++++--------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/skills/easyvista-asset-workflow/SKILL.md b/skills/easyvista-asset-workflow/SKILL.md index 321833a..e390baa 100644 --- a/skills/easyvista-asset-workflow/SKILL.md +++ b/skills/easyvista-asset-workflow/SKILL.md @@ -22,14 +22,19 @@ are not repeated here. ## Discover the catalog id first `PostAsset.catalog_id` is **required** and identifies the equipment model on -your instance. On `Request`, the ticket-workflow skill discovers ids like this -with `reference()`, because fields such as `STATUS_ID` are declared and -`reference()` can resolve a nested label around them. `Asset` is different: -it declares only five fields (`asset_id`, `asset_tag`, `serial_number`, -`status_id`, `href` — see Gotchas), none of them a catalog, so there is -nothing for `reference()` to resolve a catalog id against out of the box. -Print the raw fields on a real asset instead, and find whatever key this -instance uses: +your instance. `reference()` resolves declared and undeclared fields equally +well: it works from `self.model_dump(by_alias=True)`, and `extra="allow"` +folds every raw API key -- named in the model or not -- into that dump. This +is exactly how `easyvista-ticket-workflow` resolves `reference("STATUS")` and +`reference("CATALOG_REQUEST")` on `Request`, neither of which is a field +`Request` declares either. The reason `reference()` does not help with the +catalog here is different: no tracked source confirms an AM_ASSET payload +carries any key resembling `CATALOG` or `CATALOG_ID` at all, declared or +not -- there is simply nothing known yet to point it at. Print the raw +fields (and the href-only `links` bucket, in case the catalog comes back as +a bare `{"HREF": ...}` sub-resource rather than an inline value) on a real +asset instead, and read the id straight off whichever key turns out to be +the catalog on your instance: ```python from easyvista_python_client import EasyvistaClient @@ -39,14 +44,17 @@ with EasyvistaClient.from_env() as client: for asset in page.records: print(asset.asset_id, asset.asset_tag, asset.serial_number) # Asset declares only five fields (see Gotchas); everything else the - # instance returns -- including whatever key carries the catalog -- - # is preserved here and visible through classify_fields(). - print("raw fields:", sorted(asset.classify_fields().official)) + # instance returns -- including whatever field carries the catalog -- + # is preserved here, keys and values both. + buckets = asset.classify_fields() + print("raw fields:", buckets.official) + print("href-only fields:", buckets.links) ``` -Look through `raw fields` for whatever this instance calls the catalog (or -ask whoever administers catalogs on it) and read its id from there; that is -the value `catalog_id` needs. +Look through `raw fields` (and `href-only fields`, if the catalog turns out +to be a link rather than an inline value) for whatever this instance calls +the catalog, and read its id straight off the printed value; that is what +`catalog_id` needs. ## Procedure @@ -109,9 +117,11 @@ with EasyvistaClient.from_env() as client: (`integration_tests/test_live_search_syntax.py::test_tilde_on_asset_tag_is_exact_match`). - The `Asset` model declares only `asset_id`, `asset_tag`, `serial_number`, `status_id` and `href`; everything else the instance returns is preserved - by `extra="allow"` and reachable through `classify_fields()`. There is no - declared catalog field, which is why the discovery block above prints raw - keys instead of calling `reference()`. + by `extra="allow"` and reachable through `classify_fields()`. `reference()` + resolves declared and undeclared fields equally well (see the discovery + section above) -- the reason it is not used for the catalog is that no + tracked source confirms an AM_ASSET payload carries a `CATALOG`-shaped key + at all, not that the field is undeclared. - Asset field names beyond those five are pending live validation — that is the module's own framing, not just this skill's caution. Verify a column filters (baseline check, `easyvista-search-syntax`) before relying on it. From b8b4ea80310cd0c32907920a8684e511eb3e5d61 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 12:22:37 +0200 Subject: [PATCH 12/16] docs(skills): add the directory skill Departments and employees are read-well-trodden but write-provisional: no profile authorized for directory writes was available when the client was built, so PostDepartment/DepartmentUpdate/PostEmployee/EmployeeUpdate field sets are documented as a best guess rather than a verified contract. The skill leads with find_departments' two-path resolution (exact id/code fast path, client-side fuzzy scan otherwise, with the quote-character carve-out that skips the fast path instead of raising) since resolving a department by a human-typed name is the main reason this skill exists, and calls out get_department_comment's None-vs-raise distinction, the DEPARTMENT_PATH/DEPARTMENT_FR searchability asymmetry, and E_MAIL's status as a declared official field so classify_fields() never misfiles it. Co-Authored-By: Claude Opus 5 (1M context) --- skills/README.md | 1 + skills/easyvista-directory/SKILL.md | 142 ++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 skills/easyvista-directory/SKILL.md diff --git a/skills/README.md b/skills/README.md index 7f24c34..53d4652 100644 --- a/skills/README.md +++ b/skills/README.md @@ -19,6 +19,7 @@ installed wheel, which carries only the `easyvista_python_client` package. | `easyvista-ticket-actions` | Read or write a ticket's action log | `PostAction`, `Action`, `resolve_memo` | | `easyvista-document-workflow` | Attach, list or download ticket files | `Document`, `add_document`, `download_document` | | `easyvista-asset-workflow` | Create, fetch, search or iterate assets | `PostAsset`, `Asset` | +| `easyvista-directory` | Resolve or provision departments and employees | `Department`, `Employee`, `Reference`, `FieldClassification` | ## Sync and async diff --git a/skills/easyvista-directory/SKILL.md b/skills/easyvista-directory/SKILL.md new file mode 100644 index 0000000..758d2f4 --- /dev/null +++ b/skills/easyvista-directory/SKILL.md @@ -0,0 +1,142 @@ +--- +name: easyvista-directory +description: "Look up and provision EasyVista departments and employees with easyvista_python_client — get_department, search_departments, iter_departments, find_departments, get_department_comment, create_department, update_department and the matching employee methods, plus Reference and FieldClassification for reading instance-specific columns. Use to resolve a department by name or code, list a department's people, read a directory memo, or create/update directory records." +license: MIT +compatibility: "Requires Python 3.10+, easyvista-python-client, network access to an EasyVista Service Manager REST API, and a profile authorized for the departments and employees resources (writes are additionally profile-gated)." +metadata: + package: easyvista-python-client + version: "0.1.0" +--- + +> **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, +> use `async with`, `await` every call, and `async for` over the `iter_*` +> methods — the method names and arguments are identical. See +> `easyvista-client-setup`. + +Departments and employees are two resources with parallel surfaces. +Departments: `get_department`, `search_departments`, `iter_departments`, +`find_departments`, `get_department_comment`, `create_department`, +`update_department`. Employees: `get_employee`, `search_employees`, +`iter_employees`, `create_employee`, `update_employee`. Reads are the +well-trodden path; writes are provisional (see Gotchas). + +## Resolving a department by name + +The main reason this skill exists. `find_departments(name, limit=None)` does +the right thing in one call: + +- **Fast path:** an all-digit `name` matches `DEPARTMENT_ID` exactly; + otherwise `DEPARTMENT_CODE` exactly. A hit returns immediately. +- **Fuzzy fallback:** scans every department client-side and matches `name` + as a substring of any string field, normalized so + `"Acme Corp" == "ACME-CORP" == "acmecorp"`. +- A name that cannot be expressed in the search grammar (it contains a `"`) + skips the server fast path entirely and goes straight to the local scan, + so it returns correct results rather than raising. + +## Reading labels and instance columns + +`Department.name` is a property returning the best localized label +(`DEPARTMENT_EN` → `_FR` → `_GE` → `_IT` → `_PO` → `_SP`), skipping +`[BRACKETED]` placeholders, falling back to `department_code` then +`department_path`. For anything else, use `record.reference(name)` and +`record.classify_fields()`. + +## Procedure + +1. Resolve the department with `find_departments(name)` when you have a + human name or code; `get_department(id)` when you have an id. +2. List its people with + `iter_employees(search=ev_equals_filter("DEPARTMENT_ID", dept.department_id))`. +3. Read the department note with `get_department_comment(id)`. +4. For any other memo/link column, take the href from + `classify_fields().links` and pass it to `resolve_memo`. +5. Only attempt `create_*` / `update_*` with a profile authorized for + directory writes. + +## Examples + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + matches = client.find_departments("acme corp", limit=5) + for department in matches: + print(department.department_id, department.department_code, department.name) +``` + +```python +from easyvista_python_client import EasyvistaClient, ev_equals_filter + +with EasyvistaClient.from_env() as client: + department = client.get_department(42) + print(department.name, department.department_path, department.manager_id) + + search = ev_equals_filter("DEPARTMENT_ID", department.department_id) + for employee in client.iter_employees(search=search, max_records=200): + print(employee.employee_id, employee.last_name, employee.e_mail) +``` + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + note = client.get_department_comment(42) + if note is None: + print("no note, or the profile cannot read it") + else: + print(repr(note)) # "" means the memo exists and is empty +``` + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + employee = client.get_employee(1001) + + buckets = employee.classify_fields() + print("official:", sorted(buckets.official)) + print("custom:", sorted(buckets.custom)) + + for name, href in buckets.links.items(): + print(name, "->", client.resolve_memo(href)) +``` + +```python +from easyvista_python_client import EasyvistaClient, EmployeeUpdate, PostEmployee + +with EasyvistaClient.from_env() as client: + created = client.create_employee( + PostEmployee( + last_name="Doe", + e_mail="jdoe@example.com", + department_id=42, + login="jdoe", + ) + ) + client.update_employee(created.employee_id, EmployeeUpdate(phone_number="+33100000000")) +``` + +## Gotchas + +- **Directory writes are provisional.** `PostDepartment`, `DepartmentUpdate`, + `PostEmployee` and `EmployeeUpdate` field sets are a best guess: no profile + authorized for directory writes was available to verify them. Expect 403, + and treat a successful create as instance-specific until you have + confirmed it. +- `get_department_comment` returns `""` for an empty memo and `None` only + when the memo is absent — but it propagates transport errors, so a + 403/404 raises rather than returning `None`. That distinction is + deliberate. +- `find_departments`' fuzzy fallback scans **every** department. On a large + instance it pages the whole table; pass `limit=` and prefer an exact code + when you have one. +- `DEPARTMENT_PATH` is returned but **not searchable** — filtering it + returns the whole table. Filter `DEPARTMENT_ID` or `DEPARTMENT_CODE`. + `DEPARTMENT_FR` *is* a top-level column and does filter. +- `E_MAIL` is a **declared official** field on `Employee`, not a custom + `e_*` column; `classify_fields()` knows this and will not misfile it. +- Numeric directory columns come back as `""` when unset; the models + coerce that to `None`, so never test for `""`. +- `Department.name` is a property, not a serialized field — it never + appears in `model_dump()` or in `classify_fields()`. From 9bbfab9d9662409ce17c6693c59ea585e1c1a566 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 12:35:20 +0200 Subject: [PATCH 13/16] docs(skills): add the reporting-and-context skill Completes the skills/ index: count_tickets, ticket_statistics and aggregate_tickets for counts/breakdowns, plus get_ticket_context and get_department_context for one-call bundles that degrade around profile restrictions instead of failing. Every claim was re-verified against easyvista_python_client/reporting.py, context.py, directory.py and _async/client.py rather than trusted from the task brief as-is: - ticket_statistics' max_records=100 default and truncation behavior (client.py) match the brief. - The asymmetric degradation rules are real and were read directly, not assumed: get_ticket_context's two memos go through _safe_memo (catches EasyvistaNotFound and EasyvistaAuthError) while its actions/documents lists catch EasyvistaAuthError only; get_department_context's seven branches all catch both. - aggregate_tickets sums to total per dimension and groups unresolvable labels under "(unknown)" (reporting.py); html_to_text only ever emits text-node data, confirming to_markdown's href-free guarantee at the parser level, not just by inspection of the assembled document. - The recent_tickets descending-sort claim (RECENT_TICKETS_SORT, directory.py, open item O-DIR-1) is hedged to match the wording already used by easyvista-search-syntax, rather than restated as settled fact. Links to easyvista-ticket-actions (the list_actions diff idiom, the resolve_action_bodies flag name) and easyvista-directory (find_departments, get_department_comment) instead of restating them. Co-Authored-By: Claude Opus 5 (1M context) --- skills/README.md | 1 + .../easyvista-reporting-and-context/SKILL.md | 183 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 skills/easyvista-reporting-and-context/SKILL.md diff --git a/skills/README.md b/skills/README.md index 53d4652..8d0ad21 100644 --- a/skills/README.md +++ b/skills/README.md @@ -20,6 +20,7 @@ installed wheel, which carries only the `easyvista_python_client` package. | `easyvista-document-workflow` | Attach, list or download ticket files | `Document`, `add_document`, `download_document` | | `easyvista-asset-workflow` | Create, fetch, search or iterate assets | `PostAsset`, `Asset` | | `easyvista-directory` | Resolve or provision departments and employees | `Department`, `Employee`, `Reference`, `FieldClassification` | +| `easyvista-reporting-and-context` | Count and break down tickets, or build one context bundle | `TicketStatistics`, `aggregate_tickets`, `TicketContext`, `DepartmentContext` | ## Sync and async diff --git a/skills/easyvista-reporting-and-context/SKILL.md b/skills/easyvista-reporting-and-context/SKILL.md new file mode 100644 index 0000000..58361f6 --- /dev/null +++ b/skills/easyvista-reporting-and-context/SKILL.md @@ -0,0 +1,183 @@ +--- +name: easyvista-reporting-and-context +description: "Aggregate EasyVista tickets into counts and per-dimension breakdowns, and assemble one-call context bundles, with easyvista_python_client — count_tickets, ticket_statistics, aggregate_tickets, TicketStatistics, get_ticket_context, TicketContext.to_markdown and get_department_context. Use for ticket dashboards, per-status or per-department counts, and for exporting a ticket or a department as an LLM-ready document." +license: MIT +compatibility: "Requires Python 3.10+, easyvista-python-client, and network access to an EasyVista Service Manager REST API." +metadata: + package: easyvista-python-client + version: "0.1.0" +--- + +> **Sync and async.** Examples use `EasyvistaClient`. For `AsyncEasyvistaClient`, +> use `async with`, `await` every call, and `async for` over the `iter_*` +> methods — the method names and arguments are identical. See +> `easyvista-client-setup`. + +Two related jobs. Reporting turns matching tickets into counts and +breakdowns. Context bundles fetch a record plus everything hanging off it in +one call, degrading around profile restrictions instead of failing. + +## Reporting + +- `count_tickets(search=None)` — one cheap call. Sends `max_rows=1` and reads + the envelope's `total_record_count`; fetches no records. +- `ticket_statistics(search=..., dimensions=..., created_since=..., + created_until=..., max_records=100)` — fetches up to `max_records` matching + tickets and groups them. **The default cap is 100**; pass `max_records=None` + to aggregate all. +- `aggregate_tickets(tickets, dimensions=..., created_since=..., + created_until=...)` — the same aggregation, pure and offline, over tickets + you already hold. +- `TicketStatistics` carries `total` and `breakdowns` (`{dimension: {label: + count}}`). For every dimension, `sum(breakdowns[dim].values()) == total`. +- The default dimensions are `STATUS`, `DEPARTMENT`, `CATALOG_REQUEST`, + `URGENCY` and `IMPACT`. Any field name works, including custom `e_*` + columns. Pass them explicitly as a list — the default tuple is not part of + the public surface. +- `created_since` / `created_until` are **inclusive** bounds on + `CREATION_DATE_UT`, accepting a `datetime` or an ISO-8601 string, applied + client-side. A ticket with a missing or unparseable date is excluded when a + bound is set. A malformed bound string raises `ValueError`. + +## Context bundles + +- `get_ticket_context(rfc, resolve_action_bodies=True)` → + `TicketContext(ticket, description, comment, actions, documents)`. It + resolves the href-only description/comment memos and lists actions and + documents. Missing sub-resources (404) and profile-restricted lists (403) + degrade to `None` / `[]` rather than failing the call. Actions in the bundle + come back pre-resolved to a string body; for the raw list/item record shapes + and how to find a just-created action's id (diff `list_actions` across the + call), see `easyvista-ticket-actions`. +- `TicketContext.to_markdown()` renders an **href-free** Markdown document: an + `# Ticket ` heading, a field table, the body, `## Actions` and + `## Attachments`. Nothing in the output leaks an API URL. +- `get_department_context(department_id, recent_tickets=10, dimensions=None, + include_statistics=True, include_assets=True, resolve_manager=True, + include_note=True)` → `DepartmentContext(department, employees, manager, + note, ticket_count, recent_tickets, ticket_statistics, assets)`. Only the + department itself is guaranteed; each related part degrades to `[]` / `None` + / `0`. Resolve a human name or code to a `department_id` with + `find_departments` first, and read the department's own note independently + with `get_department_comment` — see `easyvista-directory`. + +## Procedure + +1. For a single number, use `count_tickets`. +2. For a breakdown, use `ticket_statistics` and pass the dimensions you want + explicitly. +3. Raise or remove `max_records` when the breakdown must describe the whole + population — and cross-check the total against `count_tickets`. +4. For one ticket as a document, `get_ticket_context(rfc).to_markdown()`. +5. For a department overview, resolve the id with `find_departments` if you + only have a name (see `easyvista-directory`), then call + `get_department_context(id)`; switch off the parts you do not need to save + requests. +6. When you already hold `Request` objects, call `aggregate_tickets` directly + — no network. + +## Examples + +```python +from easyvista_python_client import EasyvistaClient, ev_equals_filter + +with EasyvistaClient.from_env() as client: + search = ev_equals_filter("DEPARTMENT_ID", 42) + + print("open tickets:", client.count_tickets(search=search)) + + stats = client.ticket_statistics( + search=search, dimensions=["STATUS", "URGENCY"], max_records=None + ) + print("aggregated:", stats.total) + for dimension, counts in stats.breakdowns.items(): + for label, count in sorted(counts.items(), key=lambda item: -item[1]): + print(f"{dimension}: {label} = {count}") +``` + +```python +from datetime import datetime, timezone + +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + stats = client.ticket_statistics( + dimensions=["STATUS"], + created_since=datetime(2026, 1, 1, tzinfo=timezone.utc), + created_until="2026-06-30T23:59:59Z", + max_records=None, + ) + print(stats.total, stats.breakdowns["STATUS"]) +``` + +```python +from easyvista_python_client import EasyvistaClient, aggregate_tickets + +with EasyvistaClient.from_env() as client: + tickets = list(client.iter_tickets(max_records=500)) + +# Offline: no further requests. +stats = aggregate_tickets(tickets, dimensions=["STATUS", "DEPARTMENT"]) +print(stats.total, stats.breakdowns) +``` + +```python +from pathlib import Path + +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + context = client.get_ticket_context("YOUR_RFC_NUMBER") + print(context.ticket.rfc_number, len(context.actions), len(context.documents)) + Path("ticket.md").write_text(context.to_markdown(), encoding="utf-8") +``` + +```python +from easyvista_python_client import EasyvistaClient + +with EasyvistaClient.from_env() as client: + overview = client.get_department_context( + 42, recent_tickets=5, include_assets=False, dimensions=["STATUS"] + ) + print(overview.department.name, overview.ticket_count) + print("manager:", overview.manager.last_name if overview.manager else None) + print("people:", len(overview.employees)) + if overview.ticket_statistics is not None: + print(overview.ticket_statistics.breakdowns["STATUS"]) +``` + +## Gotchas + +- **`ticket_statistics` caps at 100 tickets by default.** When the cap + truncates, the result describes the fetched subset, not the population — + compare `stats.total` against `count_tickets(search=...)` and pass + `max_records=None` when they disagree. +- A dimension whose label cannot be resolved groups under `"(unknown)"`; the + breakdown still sums to `total`. +- `get_ticket_context` resolves action bodies by default at **two extra + requests per action**. Pass `resolve_action_bodies=False` when the list is + enough. +- The bundles' degradation rules differ on purpose: in the ticket bundle the + two memos degrade on both 403 and 404 while the action and document lists + degrade on 403 **only**, so a 404 there still fails the call. In the + department bundle every branch degrades on both. +- The async surface issues the independent branches concurrently and the sync + one runs them in order; results are identical either way. On a hard failure + the async surface lets siblings already in flight settle before the error + propagates, so a failing call can issue more requests than the sync surface + would. +- `recent_tickets` ordering is best-effort: it depends on the server + honouring the descending-sort token `RECENT_TICKETS_SORT`, and that + dependency — like the assumption that an unknown `sort` falls back to the + default order rather than erroring — is not confirmed against a live + instance (open item O-DIR-1; `easyvista-search-syntax` hedges the same + claim). Until checked against your own instance, treat the ordering as + unconfirmed rather than guaranteed descending. +- `TicketContext.to_markdown()` titles the body "Description" whichever memo + carried it, and emits both headings only when both memos have text. Do not + parse the heading to infer the source field — read `context.description` / + `context.comment`. +- `aggregate_tickets` needs the dimension columns to have been fetched. + `ticket_statistics` requests them for you; a hand-rolled + `iter_tickets(fields=[...])` that omits them yields `"(unknown)"` + everywhere. From 6f3fcf3a561eaad4e9d7026940753b1b0d5c9122 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 12:49:23 +0200 Subject: [PATCH 14/16] build: ship the agent skills in the sdist and audit them in CI Tasks 1-9 built a complete skills/ tree (README plus eight SKILL.md directories) but never wired it into the package build, so it would have silently never shipped: the sdist manifest in [tool.hatch.build.targets.sdist] is an explicit allowlist (adopted after an earlier default-inclusion regime leaked local agent state into an artifact), and anything not named there is dropped regardless of what git tracks. Add "/skills" to that allowlist, and give the build-audit CI job a two- directional assertion mirroring its existing _sync/-tree check: skills/ must be present in the sdist and absent from the wheel, so a future PR that deletes the allowlist entry (skills silently stop shipping) or accidentally widens the wheel's package boundary (skills leak into an installed package) both fail CI instead of surfacing after a PyPI upload, which cannot be taken back. Verified both failure modes fire by temporarily reverting the allowlist entry and separately force-including skills into the wheel, rebuilding each time, and confirming the assertion raises. Point README, CONTRIBUTING and CHANGELOG at the finished tree so it is discoverable and its maintenance obligations (SKILL.md updates on API changes, version bumps on release, the scripts/tests/test_skills_contract.py gate) are documented alongside the rest of the contributor guidance. Full verification: ruff/mypy/unasync --check clean, 584 unit tests pass with coverage unchanged at 1270 statements / 99.21%, pre-commit run --all-files green (5/5 hooks), and python -m build confirmed by hand: 9 skills/ entries in the sdist (66 total), 0 in the wheel (42 total). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ CHANGELOG.md | 6 ++++++ CONTRIBUTING.md | 11 +++++++++++ README.md | 11 +++++++++++ pyproject.toml | 1 + 5 files changed, 49 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3b0cf3..dca964e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,3 +90,23 @@ jobs: ) print("both client trees ship:", ", ".join(artifacts)) PY + # The sdist manifest is an allowlist, so `/skills` shipping is a single + # line someone can delete without noticing -- and the wheel boundary is + # the only thing keeping the skills OUT of an installed package. Assert + # both directions here, next to the assertions that already guard the + # test suite and the generated sync tree. + - run: | + python - <<'PY' + import pathlib, tarfile, zipfile + wheel = next(pathlib.Path("dist").glob("*.whl")) + sdist = next(pathlib.Path("dist").glob("*.tar.gz")) + sdist_names = tarfile.open(sdist).getnames() + wheel_names = zipfile.ZipFile(wheel).namelist() + assert any(n.endswith("skills/README.md") for n in sdist_names), ( + "skills/ is missing from the sdist -- is '/skills' still in the " + "[tool.hatch.build.targets.sdist] include allowlist?" + ) + leaked = [n for n in wheel_names if "skills/" in n] + assert not leaked, f"skills/ leaked into the wheel: {leaked}" + print("skills/ ships in the sdist only") + PY diff --git a/CHANGELOG.md b/CHANGELOG.md index eb2bd27..97960ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ a deprecation policy will follow the 1.0 release. ### Added +- `skills/`: eight Agent Skills covering client setup, search syntax, tickets, + ticket actions, documents, assets, the directory and reporting/context, with + an index in `skills/README.md`. Shipped in the source distribution, not in + the wheel. +- `scripts/tests/test_skills_contract.py`: checks every skill's frontmatter and + code snippets against the real public API, so a rename fails CI. - Public `filters.py`: `ev_equals_filter`, `ev_in_filter`, `escape_ev_value`, and `is_safe_ev_value` for building EasyVista `search` expressions safely. - `Request` now declares fields that were previously reachable only as untyped diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c79af48..cefdc47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,3 +81,14 @@ Never commit an instance host, account id, or token — see the note below. example a model asserted only via the resource and client tests that build it) does not need its own near-empty test file. Check coverage before adding one. + +## Agent skills + +A change to the public API must update the affected `skills/*/SKILL.md`, and a +release that bumps `__version__` must bump every skill's `metadata.version`. +`scripts/tests/test_skills_contract.py` is the gate: it parses every `SKILL.md` +and asserts each symbol, client method, keyword argument and model field the +skill names still exists on the public surface. Run it with +`pytest scripts/tests/test_skills_contract.py --no-cov` (see the coverage note +above — a single-file run without `--no-cov` fails the 95% gate even when +every test passes). diff --git a/README.md b/README.md index 605d977..771f7c0 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,17 @@ Set `EASYVISTA_URL` (or `EASYVISTA_SERVER`), `EASYVISTA_ACCOUNT`, and either `EASYVISTA_TOKEN` / `EASYVISTA_TOKEN_FILE` or `EASYVISTA_LOGIN` + `EASYVISTA_PASSWORD`, then call `EasyvistaConfig.from_env()`. +## Agent skills + +`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) +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. diff --git a/pyproject.toml b/pyproject.toml index d892344..b981315 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,6 +141,7 @@ packages = ["easyvista_python_client"] include = [ "/easyvista_python_client", "/docs", + "/skills", "/CHANGELOG.md", "/CONTRIBUTING.md", "/LICENSE", From 32667550028ad83c5ad0e1b8f6a7098309c41ebc Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 13:18:43 +0200 Subject: [PATCH 15/16] docs(skills): fix the download guard, the leaked directives and the index A final whole-branch review, reading all eight skills together, found defects that per-skill review could not see. This is the prose half of the fix wave. The download guard was actively wrong. resources/documents.py resolves the URL as `document.download_href or document.href`, but three places in easyvista-document-workflow encoded the rule that DDL_HREF alone decides: the third example skipped any attachment whose download_href was unset, the Gotcha said to check download_href first, and the model section described download_href as *the* direct-download URL. An agent copying that example silently dropped attachments the client would have fetched through HREF -- the one defect here that produces wrong code rather than merely unhelpful prose. All three now state the fallback, and the Gotcha says the ValueError fires only when neither field is set. Three authoring directives had leaked out of the plan into shipped skill bodies, where an agent loads them as instructions. The one telling the reader to state a case explicitly mattered most: a second-person imperative sitting inside an agent-facing document is plausibly parseable as something the agent should do. The facts are kept, the imperatives are gone, and ticket-actions' placeholder note now matches the shape its two peers already use. A sweep of all eight files for further leaks of the same shape found none -- the five remaining imperatives are Procedure steps addressed to the agent doing the API work. Two index rows routed agents to skills that never covered the promised names. SearchResult was the only __all__ entry no skill named at all, and Reference/FieldClassification appeared in easyvista-directory's frontmatter but never in its body -- they are taught in easyvista-ticket-workflow. Rather than duplicate the teaching, each name now sits on the row of the skill that owns it, ticket-workflow names SearchResult where it already returns one, and directory gets the return types it was telling readers to call without ever describing, plus a hand-off. easyvista-directory was also the only search-using skill with no pointer to easyvista-search-syntax despite building filters in two snippets, and it restated two grammar facts search-syntax owns. Two copies of a fact with no gate keeping them in sync is a drift source even while they agree, so the general half is dropped and the department-specific half kept. Finally, search-syntax's intro claimed everything below was characterized against a live instance, which its own sort Gotcha correctly contradicts -- sort appears zero times in that suite. The intro is what licenses an agent to trust the rest of the file unhedged, so it now carves out the exemption in the same words the hedged bullet uses. Co-Authored-By: Claude Opus 5 (1M context) --- skills/README.md | 4 ++-- skills/easyvista-directory/SKILL.md | 18 ++++++++++++++---- skills/easyvista-document-workflow/SKILL.md | 15 ++++++++++++--- skills/easyvista-search-syntax/SKILL.md | 15 +++++++-------- skills/easyvista-ticket-actions/SKILL.md | 4 ++-- skills/easyvista-ticket-workflow/SKILL.md | 5 ++++- 6 files changed, 41 insertions(+), 20 deletions(-) diff --git a/skills/README.md b/skills/README.md index 8d0ad21..44aebad 100644 --- a/skills/README.md +++ b/skills/README.md @@ -15,11 +15,11 @@ installed wheel, which carries only the `easyvista_python_client` package. | --- | --- | --- | | `easyvista-client-setup` | Build and configure an authenticated client | `EasyvistaConfig`, `EasyvistaClient`, `AsyncEasyvistaClient` | | `easyvista-search-syntax` | Write or debug any `search=` expression | `ev_equals_filter`, `ev_in_filter`, `escape_ev_value`, `is_safe_ev_value` | -| `easyvista-ticket-workflow` | Create, read, search, update or close tickets | `PostRequest`, `Request`, `RequestUpdate`, `SearchResult` | +| `easyvista-ticket-workflow` | Create, read, search, update or close tickets, or read instance-specific columns off any record | `PostRequest`, `Request`, `RequestUpdate`, `SearchResult`, `Reference`, `FieldClassification` | | `easyvista-ticket-actions` | Read or write a ticket's action log | `PostAction`, `Action`, `resolve_memo` | | `easyvista-document-workflow` | Attach, list or download ticket files | `Document`, `add_document`, `download_document` | | `easyvista-asset-workflow` | Create, fetch, search or iterate assets | `PostAsset`, `Asset` | -| `easyvista-directory` | Resolve or provision departments and employees | `Department`, `Employee`, `Reference`, `FieldClassification` | +| `easyvista-directory` | Resolve or provision departments and employees | `Department`, `Employee`, `find_departments` | | `easyvista-reporting-and-context` | Count and break down tickets, or build one context bundle | `TicketStatistics`, `aggregate_tickets`, `TicketContext`, `DepartmentContext` | ## Sync and async diff --git a/skills/easyvista-directory/SKILL.md b/skills/easyvista-directory/SKILL.md index 758d2f4..4d93806 100644 --- a/skills/easyvista-directory/SKILL.md +++ b/skills/easyvista-directory/SKILL.md @@ -18,7 +18,9 @@ Departments: `get_department`, `search_departments`, `iter_departments`, `find_departments`, `get_department_comment`, `create_department`, `update_department`. Employees: `get_employee`, `search_employees`, `iter_employees`, `create_employee`, `update_employee`. Reads are the -well-trodden path; writes are provisional (see Gotchas). +well-trodden path; writes are provisional (see Gotchas). Filtering any +`search=` argument follows the grammar in `easyvista-search-syntax` — see that +skill for the rules; they are not repeated here. ## Resolving a department by name @@ -42,6 +44,13 @@ the right thing in one call: `department_path`. For anything else, use `record.reference(name)` and `record.classify_fields()`. +`reference(name)` resolves a reference attribute — nested object or bare id — +to a `Reference` with `.id`, `.label` and `.display`. `classify_fields()` +partitions the record into a `FieldClassification` with `.official`, `.custom` +(the instance's `e_*` columns), `.available` and `.links` buckets. Both are +available on every record type this client returns; +`easyvista-ticket-workflow` covers them in full. + ## Procedure 1. Resolve the department with `find_departments(name)` when you have a @@ -131,9 +140,10 @@ with EasyvistaClient.from_env() as client: - `find_departments`' fuzzy fallback scans **every** department. On a large instance it pages the whole table; pass `limit=` and prefer an exact code when you have one. -- `DEPARTMENT_PATH` is returned but **not searchable** — filtering it - returns the whole table. Filter `DEPARTMENT_ID` or `DEPARTMENT_CODE`. - `DEPARTMENT_FR` *is* a top-level column and does filter. +- `DEPARTMENT_PATH` is returned but **not searchable** — filter + `DEPARTMENT_ID` or `DEPARTMENT_CODE` instead. What EasyVista does with a + condition it will not honour, and which other directory columns are + affected, is `easyvista-search-syntax`'s subject. - `E_MAIL` is a **declared official** field on `Employee`, not a custom `e_*` column; `classify_fields()` knows this and will not misfile it. - Numeric directory columns come back as `""` when unset; the models diff --git a/skills/easyvista-document-workflow/SKILL.md b/skills/easyvista-document-workflow/SKILL.md index f4a760d..023bbb1 100644 --- a/skills/easyvista-document-workflow/SKILL.md +++ b/skills/easyvista-document-workflow/SKILL.md @@ -33,6 +33,10 @@ client. observed shape), `name`, `document`, `document_id`, `download_href` (the API's `DDL_HREF`, the direct-download URL), `href`. +`download_document` resolves the URL as `download_href or href` — it prefers +`DDL_HREF` and **falls back to `HREF`**. Either field on its own is enough, so +a record with an empty `download_href` may still be perfectly downloadable. + ## Examples ```python @@ -64,7 +68,9 @@ from easyvista_python_client import EasyvistaClient with EasyvistaClient.from_env() as client: for document in client.list_documents("YOUR_RFC_NUMBER"): - if document.download_href is None: + # Both fields must be empty for the download to be impossible: + # download_document falls back to href when DDL_HREF is unset. + if document.download_href is None and document.href is None: continue payload = client.download_document(document) Path(document.filename or "attachment.bin").write_bytes(payload) @@ -73,8 +79,11 @@ with EasyvistaClient.from_env() as client: ## Gotchas - `content` must be `bytes`. Read files in binary mode. -- `download_document` raises `ValueError` when the record carries no download - URL — check `download_href` first, or catch it. +- `download_document` raises `ValueError` only when **neither** `DDL_HREF` nor + `HREF` is set. Guard on both (`download_href is None and href is None`), or + catch the `ValueError`. Skipping a record because `download_href` alone is + unset silently drops attachments the client would have fetched through + `href`. - A download URL pointing outside the configured instance raises `EasyvistaError`. Downloads follow redirects (signed URLs are common), and httpx strips the `Authorization` header on a cross-origin redirect, so a diff --git a/skills/easyvista-search-syntax/SKILL.md b/skills/easyvista-search-syntax/SKILL.md index e8f272f..146ec10 100644 --- a/skills/easyvista-search-syntax/SKILL.md +++ b/skills/easyvista-search-syntax/SKILL.md @@ -15,9 +15,10 @@ metadata: Every `search_*` and `iter_*` method takes the same `search` string. The grammar is small and two of its three failure modes are silent, so this skill -is a prerequisite for any filtering work. Everything below was characterized -against a live instance by `integration_tests/test_live_search_syntax.py` — -that file is the authority when something here looks wrong. +is a prerequisite for any filtering work. Except where a claim below is +explicitly flagged as unconfirmed, everything here was characterized against a +live instance by `integration_tests/test_live_search_syntax.py` — that file is +the authority when something here looks wrong. ## The grammar @@ -36,8 +37,6 @@ that file is the authority when something here looks wrong. ## Three fates of a condition -The core of this skill: - 1. **Honoured.** 2. **Silently dropped** — no error. EasyVista removes any condition it cannot honour and applies what is left; with nothing left, it returns **every** @@ -50,9 +49,9 @@ The core of this skill: value's *type* does not match the column, e.g. sending a status name to the integer `STATUS_ID`. This is the friendly failure. -State the counter-intuitive case explicitly: a **broken quote does not** -return the table. `DEPARTMENT_CODE:"X""` still parses as a field expression, -the value swallows the junk, and it matches nothing (0 rows). +The counter-intuitive case: a **broken quote does not** return the table. +`DEPARTMENT_CODE:"X""` still parses as a field expression, the value swallows +the junk, and it matches nothing (0 rows). ## What is searchable diff --git a/skills/easyvista-ticket-actions/SKILL.md b/skills/easyvista-ticket-actions/SKILL.md index 0fd4e49..8465758 100644 --- a/skills/easyvista-ticket-actions/SKILL.md +++ b/skills/easyvista-ticket-actions/SKILL.md @@ -74,8 +74,8 @@ with EasyvistaClient.from_env() as client: print(action.href) ``` -With a note under it: both ids are placeholders — use the ones the discovery -block printed. +`action_type_id=1` and `group_id=1` above are placeholders — use the ids the +discovery block printed for your instance. ```python from easyvista_python_client import EasyvistaClient, PostAction diff --git a/skills/easyvista-ticket-workflow/SKILL.md b/skills/easyvista-ticket-workflow/SKILL.md index 526ab42..5bdb604 100644 --- a/skills/easyvista-ticket-workflow/SKILL.md +++ b/skills/easyvista-ticket-workflow/SKILL.md @@ -68,7 +68,10 @@ deployment needs before you build a payload for it. 5. To set body text you can read back afterwards, follow the create with `update_ticket(rfc, RequestUpdate(description=...))`. 6. Read one ticket with `get_ticket(rfc)`; search a page with - `search_tickets(...)`; walk every match with `iter_tickets(...)`. + `search_tickets(...)`, which returns a `SearchResult` carrying `.records`, + `.record_count` (this page) and `.total_record_count` (every match on the + server); walk every match with `iter_tickets(...)`, which yields `Request` + objects directly and pages for you. 7. Close with `close_ticket(rfc, status_guid=..., delete_actions=..., comment=...)`. From 100b8d0e9d6749c0f1563241dd9526f31368a1fa Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 31 Jul 2026 13:19:08 +0200 Subject: [PATCH 16/16] test(skills): close the drift gate's dead-link and mis-tagged-block blind spots The contract gate reads as though it checks the skills' claims. It does not: it is a name-and-keyword gate over python code blocks, and every prose claim -- the Configuration and Errors tables, every Procedure step, every Gotcha -- is unverified text. Attribute reads pass (ticket.rfc_numbr would too), positional arguments and arity are unchecked, and nothing is ever instantiated, so PostAsset(catalog_id="1") passes an int field. The docstring now says so, in a "What this does not check" section, because a future contributor reading a green run deserves to know what green means here. Two blind spots were cheap enough to close rather than document. Five skills cite a repo path as their evidence -- the live-suite modules they were characterized against, the module whose sort token they hedge. A rename turns all five into dead links that nothing notices, because no import or tool reads a Markdown citation. The same is true of the cross-references that make the eight skills a graph rather than eight documents: delegating the grammar to easyvista-search-syntax breaks silently if that directory is renamed. Both extractions are deliberately conservative, because a false failure here would block a correct commit over prose -- worse than missing a dead link. A path candidate must be an entire inline-code span, must contain a slash, and must end in a known source extension; that rejects the five route templates and URL fragments already in the skills (`/api`, `{server}/api/{api_version}/...`, `requests/{rfc}/comment`) and a bare `SKILL.md` written about documents in general. A pytest node-id suffix is stripped before the existence check. The cost -- a broken bare-filename reference goes unnoticed -- is stated in the comment. Cross-references exempt the distribution name, which shares the easyvista- prefix with every skill name but is not one. Both assertions were proved to fail: an injected nonexistent path and an injected nonexistent skill each failed, naming the offending skill and the offending reference. _PY_BLOCK now accepts ```py as well as ```python. The two tags render identically, so a block tagged the other way would have skipped every snippet check with no visible difference in the document. _UNPUBLISHED gains scripts/probe_ (the prefix form, since the check is a substring test and the .gitignore glob would match nothing -- and it deliberately does not catch the tracked validate_live_content_fidelity.py that a skill cites), plus .claude/ and .superpowers/, which sit in the same "local agent/tooling state" .gitignore block. The remaining ignores are build artifacts, not documents a skill would cite as evidence. Finally, the CI sdist check asserted only that skills/README.md ships, which would pass on an sdist that shipped the index and none of the eight skills it indexes. It now also counts SKILL.md entries against the directories on disk. Both assertions are kept: the count alone would pass on an sdist that dropped the index instead. Verified against a real locally built sdist (8 of 8) and against synthetic manifests for the index-only and one-skill-missing regressions. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 12 ++- scripts/tests/test_skills_contract.py | 105 +++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dca964e..3f579f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,7 +106,17 @@ jobs: "skills/ is missing from the sdist -- is '/skills' still in the " "[tool.hatch.build.targets.sdist] include allowlist?" ) + # The index alone shipping would satisfy the assertion above while + # every skill it indexes was missing, so count them: one SKILL.md + # per skill directory on disk, no more and no fewer. + skills_dir = pathlib.Path("skills") + on_disk = sorted(p.name for p in skills_dir.iterdir() if p.is_dir()) + shipped = sorted(n for n in sdist_names if n.endswith("/SKILL.md")) + assert len(shipped) == len(on_disk), ( + f"the sdist carries {len(shipped)} SKILL.md files but skills/ has " + f"{len(on_disk)} directories on disk ({on_disk}); shipped: {shipped}" + ) leaked = [n for n in wheel_names if "skills/" in n] assert not leaked, f"skills/ leaked into the wheel: {leaked}" - print("skills/ ships in the sdist only") + print(f"skills/ ships in the sdist only ({len(shipped)} SKILL.md)") PY diff --git a/scripts/tests/test_skills_contract.py b/scripts/tests/test_skills_contract.py index baefbdf..4197399 100644 --- a/scripts/tests/test_skills_contract.py +++ b/scripts/tests/test_skills_contract.py @@ -9,6 +9,30 @@ Offline by construction: it imports the package and reads files. No credentials, no network, nothing instantiated that would open a socket. + +What this does *not* check, so that a green run is not over-trusted. It is a +name-and-keyword gate over ``python`` code blocks, not a semantic one: + +- **No prose claim is verified.** The Configuration and Errors tables, every + Procedure step and every Gotcha in every skill are unchecked text. A + behavioural claim that goes stale fails nothing here. +- **No attribute read on a returned object.** ``ticket.rfc_numbr`` passes; + only names imported from the package root and keywords passed to a client + method or a write model are looked up. +- **No positional arguments and no arity.** Only ``keyword=`` arguments are + matched against the signature. +- **No required fields and no value types.** ``PostAsset(catalog_id="1")`` + passes even though the field is an ``int``, and a write model missing a + mandatory field passes too -- nothing is ever instantiated. +- **No call through an unrecognized receiver.** A method call is only checked + when its receiver is named in ``_CLIENT_NAMES``; ``svc.get_ticket(...)`` + is invisible. +- **No code block tagged anything but ``python``/``py``.** A block with + another tag, or none, skips every snippet check silently. +- **Nothing about the repository beyond existence** for the two link checks + below: a referenced path is checked to exist, not to still contain the + symbol or test the prose attributes to it, and a cross-referenced skill is + checked to exist, not to still cover the topic it is cited for. """ from __future__ import annotations @@ -30,7 +54,9 @@ # truncated by the loader, which would hide the trigger conditions. _MAX_DESCRIPTION = 1024 -_PY_BLOCK = re.compile(r"^```python\n(.*?)^```", re.MULTILINE | re.DOTALL) +# Both tags render identically, so a block tagged ``py`` would otherwise skip +# every snippet check below without any visible difference in the document. +_PY_BLOCK = re.compile(r"^```(?:python|py)\n(.*?)^```", re.MULTILINE | re.DOTALL) def _skill_dirs() -> list[Path]: @@ -182,10 +208,47 @@ def test_readme_lists_every_skill() -> None: "easyvista-test-profile-blocked-operations.md", "docs/superpowers", "secrets/", + # `.gitignore` ignores `scripts/probe_*.py` as a glob; the prefix is the + # substring every one of those paths shares. It deliberately does not + # match `scripts/validate_live_content_fidelity.py`, which is tracked and + # which easyvista-ticket-workflow cites. + "scripts/probe_", + # Local agent/tooling state, ignored by the same root .gitignore block. + ".claude/", + ".superpowers/", ) _URL_LITERAL = re.compile(r"https?://[^\s\"']+") +# Repo paths a skill cites, extracted deliberately narrowly: an inline-code +# span whose *entire* content is a slash-bearing relative path ending in a +# known source extension, optionally followed by a pytest node id +# (``file.py::test_name``). Three restrictions do the work: +# +# * whole-span match -- prose that merely mentions a filename is never a +# candidate, only a span the author fenced as a path; +# * a `/` is required -- so a bare `SKILL.md` or `README.md` written about +# documents in general is not read as a path relative to the repo root; +# * the character class excludes `{`, `<` and spaces -- so API route +# templates (`{server}/api/{api_version}/{account}`, `requests/{rfc}/comment`, +# `/api/v1/`) cannot be mistaken for files on disk. +# +# The cost is that a genuinely broken bare-filename reference goes unnoticed. +# That is the intended trade: a false failure here would block a correct +# commit over prose, which is worse than missing one class of dead link. +_REPO_PATH = re.compile( + r"`([A-Za-z0-9_][A-Za-z0-9_.-]*/[A-Za-z0-9_./-]*" + r"\.(?:py|md|toml|yml|yaml|cfg|ini|txt))(?:::[A-Za-z0-9_]+)*`" +) + +# A cross-referenced sibling skill, always written as an inline-code span. +_SKILL_REF = re.compile(r"`(easyvista-[a-z][a-z-]*)`") + +# The distribution name shares the `easyvista-` prefix with every skill name +# but is not one, so a skill that backticks it must not be read as citing a +# missing sibling. +_NOT_A_SKILL_REF = frozenset({"easyvista-python-client"}) + # unasync generates the sync client from the async source and renames # ``aclose`` to ``close`` as part of that transform, so this one pair is # deliberately asymmetric: only ``EasyvistaClient`` has ``close``, only @@ -342,6 +405,46 @@ def test_no_unpublished_or_private_references(skill: Path) -> None: ) +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_referenced_repo_paths_exist(skill: Path) -> None: + """Every repo path a skill cites still exists. + + Skills point at live-suite modules and package sources as their evidence + ("that file is the authority when something here looks wrong"). A rename + turns those into dead links that nothing else in the repository notices, + because no import or tool reads a Markdown citation. + """ + text = (skill / "SKILL.md").read_text(encoding="utf-8") + for match in _REPO_PATH.finditer(text): + relative = match.group(1) + assert (REPO_ROOT / relative).exists(), ( + f"{skill.name} cites {relative!r}, which does not exist under " + f"{REPO_ROOT}; a rename left the citation pointing at nothing" + ) + + +@pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) +def test_cross_referenced_skills_exist(skill: Path) -> None: + """Every sibling skill a skill routes the agent to is really there. + + The skills form a graph: each one delegates the grammar, the client setup + or the context bundles to a named peer rather than repeating it. A rename + or a removal breaks the delegation silently -- the agent is sent to a + skill that cannot be loaded, and the fact lives nowhere else. + """ + text = (skill / "SKILL.md").read_text(encoding="utf-8") + present = {p.name for p in _skill_dirs()} + for match in _SKILL_REF.finditer(text): + name = match.group(1) + if name in _NOT_A_SKILL_REF: + continue + assert name in present, ( + f"{skill.name} cross-references the skill {name!r}, which is not " + f"a directory under {SKILLS_DIR}; skills present are " + f"{sorted(present)}" + ) + + @pytest.mark.parametrize("skill", _skill_dirs(), ids=_skill_ids()) def test_snippet_hosts_are_synthetic(skill: Path) -> None: for tree in _snippet_trees(skill):