Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions glpi_python_client/testing/tests/test_skill_references.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,73 @@ def test_no_skill_describes_the_retired_thread_pool_bridge() -> None:
"these skills describe machinery that no longer exists:\n"
+ "\n".join(offenders)
)


#: Public client methods deliberately left out of ``skills/``, each with the
#: reason. Empty on purpose: every method the client exposes is documented
#: somewhere, and an entry here is a decision someone has to justify rather
#: than a place to park undone work.
_UNDOCUMENTED: dict[str, str] = {}


def _public_methods() -> set[str]:
"""Every public callable on the client surface."""

return {
name
for name in dir(GlpiClient)
if not name.startswith("_") and callable(getattr(GlpiClient, name, None))
}


def _documented() -> str:
"""Every skill document concatenated, for a whole-word name search."""

return "\n".join(path.read_text(encoding="utf-8-sig") for path in _skill_files())


def test_every_public_method_is_named_by_some_skill() -> None:
"""A method no skill mentions is a method no agent will ever call.

The other checks in this module all validate what the skills *say*.
None of them asks what the skills *omit*, which is how the knowledge
base and plugin-fields families -- twenty-five public methods between
them -- shipped across two releases with no documentation anywhere
and a fully green suite.

Matched on a word boundary rather than a substring, so documenting
``get_ticket_task`` does not silently satisfy ``get_ticket``.
"""

prose = _documented()
missing = sorted(
name
for name in _public_methods()
if name not in _UNDOCUMENTED and not re.search(rf"\b{re.escape(name)}\b", prose)
)
assert missing == [], (
"these public client methods are named by no skill -- document them, "
"or add them to _UNDOCUMENTED with a reason:\n" + "\n".join(missing)
)


def test_the_undocumented_allowlist_has_no_dead_entries() -> None:
"""An allowlist outliving the method it excused hides the next gap."""

dead = sorted(_UNDOCUMENTED.keys() - _public_methods())
assert dead == [], (
"these _UNDOCUMENTED entries name methods that no longer exist:\n"
+ "\n".join(dead)
)


def test_the_coverage_scan_discriminates() -> None:
"""Positive control: the word boundary is load-bearing, so prove it.

Without this, a regex that quietly stopped matching would leave the
coverage check passing forever while reading nothing.
"""

assert not re.search(r"\bget_ticket\b", "see get_ticket_task above")
assert re.search(r"\bget_ticket\b", "call get_ticket(321) now")
assert len(_public_methods()) > 50, "client surface looks wrong -- API moved?"
6 changes: 4 additions & 2 deletions skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ These skills are source-tree project material. They are included in source distr
| --- | --- | --- |
| `glpi-client-setup` | Build and configure an authenticated client | `GlpiClient`, `AsyncGlpiClient`, `.from_env()` |
| `glpi-ticket-workflow` | Search, fetch, create, update, or delete tickets | `GetTicket`, `PostTicket`, `PatchTicket`, `DeleteTicket` |
| `glpi-ticket-timeline` | Read timeline records or write followups, tasks, solutions, and document links | `PostFollowup`, `PostTicketTask`, `PostSolution`, `PostTimelineDocument` (plus matching Get/Patch/Delete) |
| `glpi-ticket-timeline` | Read timeline records or write followups, tasks, solutions, and document links | `PostFollowup`, `PostTicketTask`, `PostSolution`, `PostTimelineDocument` (plus matching Get/Patch/Delete for followups/tasks/solutions; document reads return `GetDocument`, not a `GetTimelineDocument`) |
| `glpi-document-workflow` | Manage document metadata, upload binary content, download binaries | `GetDocument`, `PostDocument`, `PatchDocument`, `DeleteDocument` |
| `glpi-user-location-provisioning` | Search and provision users, locations, and entities | `GetUser`, `PostUser`, `GetLocation`, `PostLocation`, `GetEntity`, `PostEntity` |
| `glpi-reporting-and-context` | Aggregate ticket statistics, aggregate task durations, or load one ticket context bundle | `GlpiClient`, `GlpiTicketContext`, public enums |
| `glpi-team-members` | List, add, or remove ticket team members | `GetTeamMember`, `PostTeamMember` |
| `glpi-knowledge-base` | Search, read, or write KB articles, categories, comments, and revisions | `GetKBArticle`, `PostKBArticle`, `GetKBCategory`, `GetKBArticleComment`, `GetKBArticleRevision` |
| `glpi-plugin-fields` | Discover and read/write Fields-plugin custom fields | `GetPluginFieldsContainer`, `GetPluginFieldsField`, `GetPluginFieldsValueRow` |

## Sync and async

Expand All @@ -23,6 +25,6 @@ The package ships two clients with identical endpoint surfaces:
- `GlpiClient` — synchronous. `with GlpiClient(...) as client`, no `await`.
- `AsyncGlpiClient` — asynchronous, performing real non-blocking I/O. `async with AsyncGlpiClient(...) as client`, `await` every method.

Neither wraps the other: the async tree is hand-written and the synchronous one is generated from it by `unasync_build.py`, so the two cannot drift apart. The snippets in each skill are written against `AsyncGlpiClient`; every skill opens with a note on how to read them for the synchronous client.
Neither wraps the other: the async tree is hand-written and the synchronous one is generated from it by `unasync_build.py`, so the two cannot drift apart. Every skill opens with a note telling you how to read its snippets across the two surfaces. For eight of the nine that note says the same thing -- the snippets are written against `AsyncGlpiClient`, so drop the `await` and the `async` for `GlpiClient`. `glpi-client-setup` is the exception and says so in its own note: choosing between the two clients is what that skill is *for*, so it shows both directly, side by side, and neither surface is a translation of the other.

When fanning out concurrently on the async client, bound the fan-out with an `asyncio.Semaphore` — see `glpi-client-setup`. An unbounded fan-out is slower, not faster.
67 changes: 57 additions & 10 deletions skills/glpi-client-setup/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
---
name: glpi-client-setup
description: "Create and configure the synchronous glpi_python_client.GlpiClient or the asynchronous glpi_python_client.AsyncGlpiClient, including from_env, OAuth credential pairs, entity/profile headers, SSL settings, and the optional legacy v1 document-upload session. Use before calling GLPI APIs or when the user asks how to connect to GLPI with glpi_python_client."
description: "Create and configure the synchronous glpi_python_client.GlpiClient or the asynchronous glpi_python_client.AsyncGlpiClient, including from_env, OAuth credential pairs, entity/profile headers, SSL settings, and the optional legacy v1 session (v1_base_url / v1_user_token) that backs document uploads, the Fields plugin helpers, KB category writes and actor-based statistics. Use before calling GLPI APIs, when configuring the v1 session for any of those features, or when the user asks how to connect to GLPI with glpi_python_client."
license: MIT
compatibility: "Requires Python 3.10+, glpi-python-client, network access to a GLPI v2 API, and valid GLPI credentials."
metadata:
package: glpi-python-client
version: "0.4.0"
version: "0.4.1"
---

# GLPI Client Setup

> Unlike the other skills in this package, the snippets below are not written
> against one client and translated for the other: `with GlpiClient(...)` and
> `async with AsyncGlpiClient(...)` examples both appear directly, side by
> side, because choosing between the two surfaces is what this skill is for.
> Read each example as written for the client it names.

The package exposes two clients with identical endpoint surfaces:

- `glpi_python_client.GlpiClient` — synchronous, blocking client. Use it from
Expand Down Expand Up @@ -44,8 +50,22 @@ call `client.close()` (or `await client.close()`) when finished.
`client_secret`, `username`/`password`, or both pairs together.
5. Add `glpi_entity`, `glpi_profile`, and `entity_recursive=True` only
when the operation must run in a specific GLPI scope.
6. Add `v1_base_url` and `v1_user_token` only when binary document
uploads are needed (`upload_document`). `v1_app_token` is optional.
6. Add `v1_base_url` and `v1_user_token` whenever a v1-backed feature is
used, not only for uploads. They are required by: binary document
uploads (`upload_document`); the Fields plugin helpers
(`get_ticket_custom_fields`, `set_ticket_custom_fields`,
`list_plugin_fields_containers`, `list_plugin_fields_fields`,
`list_item_plugin_field_rows`, `create_item_plugin_field_row`,
`update_item_plugin_field_row`); KB category writes
(`set_kb_article_categories`, and `PostKBArticle.categories` /
`PatchKBArticle.categories` passed to `create_kb_article` /
`update_kb_article`); and actor-based statistics
(`get_user_activity`, `get_task_durations(user_id=...)`) — v2 cannot
filter on a ticket's actors at all, so those resolve through the v1
search engine. The same session also switches `get_task_durations`
to a bulk v1 task sweep once a run covers 25 tickets or more. Any of
these raises `RuntimeError` when the v1 session is absent.
`v1_app_token` is optional.
7. Keep `verify_ssl=True` unless the user explicitly confirms a test or
internal endpoint that cannot validate TLS.
8. Bound any large async fan-out with an `asyncio.Semaphore` on the
Expand Down Expand Up @@ -130,17 +150,25 @@ with GlpiClient.from_env() as glpi:
Environment setup, asynchronous:

```python
import asyncio

from glpi_python_client import AsyncGlpiClient

async with AsyncGlpiClient.from_env() as glpi:
tickets = await glpi.search_tickets("status==1")

async def main() -> None:
async with AsyncGlpiClient.from_env() as glpi:
tickets = await glpi.search_tickets("status==1")


asyncio.run(main())
```

Document-upload setup (works on either client):
Legacy v1 session setup — enables every v1-backed feature from step 6,
not only uploads (works on either client):

```python
with GlpiClient.from_env(
v1_base_url="https://glpi.example.com/apirest.php",
v1_base_url="https://glpi.example.com/api.php/v1",
v1_user_token="legacy-user-token",
) as glpi:
...
Expand All @@ -154,10 +182,29 @@ with GlpiClient.from_env(
- The package no longer exports `GLPIV1Session`. Configure
`v1_base_url`/`v1_user_token` on the client and call
`upload_document` instead.
- Use `glpi_api_url` for the v2 API; `v1_base_url` is only for the
document-upload fallback.
- Use `glpi_api_url` for the v2 API; `v1_base_url` additionally enables
every v1-backed feature listed in step 6 — document uploads, the
Fields plugin helpers, KB category writes and actor-based statistics.
- Closing the client matters because it owns one or two HTTP sessions
plus an OAuth token manager. Prefer the context-manager form.
- Every **API** failure the library raises derives from `GlpiError`,
exported from the package root. Construction raises
`GlpiValidationError` for a missing `glpi_api_url`, a half-supplied
credential pair, or a `v1_base_url` without a `v1_user_token`; calls
raise `GlpiAuthError` (401/403), `GlpiNotFoundError` (404),
`GlpiServerError` (persistent 5xx),
`GlpiTransportError`/`GlpiTimeoutError` (network fault) or
`GlpiProtocolError` (unusable 2xx body). Do not catch `requests`
exceptions — `requests` is not a dependency — and do not catch
`tenacity.RetryError`; the retry decorators re-raise the real error.
- A small set of raise sites is deliberately **outside** that hierarchy,
so `except GlpiError:` will not catch them. Plain `RuntimeError`:
using a closed client; a v1-backed call on a client built without
`v1_base_url`; and a `create_kb_article` whose category fallback
failed *after* the article was already created (the article exists,
its categories were not applied). Plain `TypeError`: an environment
value that is neither a string nor the expected scalar when `from_env`
parses an integer or boolean setting.
- Concurrent callers cannot stampede the token endpoint: the client
holds a lock around OAuth acquisition, so it is safe to launch a
fan-out on `AsyncGlpiClient` before the token has ever been fetched.
Expand Down
7 changes: 4 additions & 3 deletions skills/glpi-document-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and v1 credentials configured on the client for binary uploads."
metadata:
package: glpi-python-client
version: "0.4.0"
version: "0.4.1"
---

# GLPI Document Workflow
Expand All @@ -25,7 +25,7 @@ The `GLPIV1Session` class is no longer part of the public surface; the v1 sessio
6. Delete with `await client.delete_document(document_id, force=True|False|None)`.
7. Download bytes with `content = await client.download_document_content(document_id)`.
8. Upload bytes with `await client.upload_document(filename=..., content=..., mime_type=..., ticket_id=..., entity_id=...)`.
9. To attach an existing GLPI document to a ticket timeline, use `link_ticket_timeline_document` from the timeline skill.
9. To put a file on a ticket timeline, use `upload_document(..., ticket_id=...)` -- it creates the document *and* the ticket link in one call. `link_ticket_timeline_document` from the timeline skill cannot be told **which** existing document to link: `PostTimelineDocument` declares only `extra_payload` and `timeline_position`, and the POST URL carries only the ticket id, so there is no typed slot for a document id. See the timeline skill for the `extra_payload` escape hatch and its caveat.

## Examples

Expand Down Expand Up @@ -64,9 +64,10 @@ document_id = await client.create_document(PostDocument(name="Diagnostic notes")

## Gotchas

- **`search_documents` swallows 4xx and returns `[]`.** This is a library-wide contract, not a document peculiarity: `_resource_list` checks the response status only when the caller passes a `failure_message`, and none of the seven `search_*` helpers (`search_documents`, `search_tickets`, `search_users`, `search_locations`, `search_entities`, `search_kb_articles`, `search_kb_categories`) passes one -- a GLPI error body is not a JSON list, so it is coerced to `[]`. A malformed RSQL filter, a 403 on `/Management/Document`, a missing route and "no such document" all look identical. `get_document`, `download_document_content` and every `list_*` helper do pass a `failure_message` and raise `GlpiStatusError` (narrowed to `GlpiAuthError` / `GlpiNotFoundError` / `GlpiServerError`) normally. So never conclude from an empty `search_documents` that a file is not on the server and re-upload it -- that is how duplicate documents get created; corroborate with a call that raises first.
- `upload_document` raises `RuntimeError` when the v1 session is not configured. Pass `v1_base_url` and `v1_user_token` to the client constructor or `from_env`.
- `upload_document` requires a non-empty `filename`. On the async client the multipart POST is awaited like any other call, so the event loop is not blocked.
- `download_document_content` returns `bytes` and raises on non-200 responses.
- `mime_type` defaults to `application/octet-stream` when omitted on `upload_document`.
- All methods are async; always `await` them.
- The snippets above use `AsyncGlpiClient`, so every call is awaited. The same methods on the synchronous `GlpiClient` are plain blocking calls -- drop the `await`.
- The `delete_document(force=True)` flag permanently deletes; omit (or `False`) to move to the trash.
Loading
Loading