diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3b0cf3..3f579f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,3 +90,33 @@ 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?" + ) + # 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(f"skills/ ships in the sdist only ({len(shipped)} SKILL.md)") + 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", diff --git a/scripts/tests/test_skills_contract.py b/scripts/tests/test_skills_contract.py new file mode 100644 index 0000000..4197399 --- /dev/null +++ b/scripts/tests/test_skills_contract.py @@ -0,0 +1,458 @@ +"""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. + +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 + +import ast +import inspect +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 + +# 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]: + """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)}" + + +# 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/", + # `.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 +# ``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.""" + calls = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + 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)] + + +@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 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 "" + 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: + 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_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): + continue + name = _write_model_name(node.func) + if name is None: + continue + model = _WRITE_MODELS.get(name) + 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 {name}, 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_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): + 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" + ) diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..44aebad --- /dev/null +++ b/skills/README.md @@ -0,0 +1,45 @@ +# 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` | +| `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, 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`, `find_departments` | +| `easyvista-reporting-and-context` | Count and break down tickets, or build one context bundle | `TicketStatistics`, `aggregate_tickets`, `TicketContext`, `DepartmentContext` | + +## 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-asset-workflow/SKILL.md b/skills/easyvista-asset-workflow/SKILL.md new file mode 100644 index 0000000..e390baa --- /dev/null +++ b/skills/easyvista-asset-workflow/SKILL.md @@ -0,0 +1,132 @@ +--- +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. `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 + +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 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` (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 + +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()`. `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. +- 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. 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. diff --git a/skills/easyvista-directory/SKILL.md b/skills/easyvista-directory/SKILL.md new file mode 100644 index 0000000..4d93806 --- /dev/null +++ b/skills/easyvista-directory/SKILL.md @@ -0,0 +1,152 @@ +--- +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). 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 + +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()`. + +`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 + 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** — 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 + 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()`. diff --git a/skills/easyvista-document-workflow/SKILL.md b/skills/easyvista-document-workflow/SKILL.md new file mode 100644 index 0000000..023bbb1 --- /dev/null +++ b/skills/easyvista-document-workflow/SKILL.md @@ -0,0 +1,96 @@ +--- +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`. + +`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 +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"): + # 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) +``` + +## Gotchas + +- `content` must be `bytes`. Read files in binary mode. +- `download_document` raises `ValueError` only when **neither** `DDL_HREF` nor + `HREF` is set. Guard on both (`download_href is None and href is None`), or + catch the `ValueError`. Skipping a record because `download_href` alone is + unset silently drops attachments the client would have fetched through + `href`. +- A download URL pointing outside the configured instance raises + `EasyvistaError`. Downloads follow redirects (signed URLs are common), and + httpx strips the `Authorization` header on a cross-origin redirect, so a + foreign host would receive the request unauthenticated — refusing is + deliberate. +- A 403 on an attachment still surfaces as `EasyvistaAuthError`; the binary + path reuses the same error mapping and retry policy as the JSON one. +- `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. 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. diff --git a/skills/easyvista-search-syntax/SKILL.md b/skills/easyvista-search-syntax/SKILL.md new file mode 100644 index 0000000..146ec10 --- /dev/null +++ b/skills/easyvista-search-syntax/SKILL.md @@ -0,0 +1,173 @@ +--- +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. 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 + +- `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 + +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. + +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 + +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` + 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 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 + `@next` or `max_records` is reached. diff --git a/skills/easyvista-ticket-actions/SKILL.md b/skills/easyvista-ticket-actions/SKILL.md new file mode 100644 index 0000000..8465758 --- /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) +``` + +`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 + +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. diff --git a/skills/easyvista-ticket-workflow/SKILL.md b/skills/easyvista-ticket-workflow/SKILL.md new file mode 100644 index 0000000..5bdb604 --- /dev/null +++ b/skills/easyvista-ticket-workflow/SKILL.md @@ -0,0 +1,196 @@ +--- +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(...)`, 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=...)`. + +## 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. +- 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.