User Guide
The glpi_python_client package exposes two high-level clients
whose surface is built from contract-aligned per-endpoint mixins:
glpi_python_client.GlpiClient— synchronous, blocking client.glpi_python_client.AsyncGlpiClient— asynchronous client doing real non-blocking I/O on the event loop.
Neither is a wrapper around the other; both are the same code, as Sync vs async surface below explains.
Both clients speak the GLPI v2 high-level API and fall back to the
legacy v1 API for features that are not exposed by v2, currently
binary document uploads and the Fields plugin custom-field
helpers. They expose the exact same endpoint methods and accept the
same constructor arguments.
Public methods always return Pydantic models (or simple Python types)
and never raw dictionaries.
How this guide is organised
The guide is split into the following sections:
Creating a client — how to instantiate either client from explicit parameters or from environment variables.
Sync vs async surface — when to pick which client and how both are produced from a single source.
Seed data for the examples — a self-contained snippet that creates the records reused by every later example. Run it once on a throwaway GLPI instance to follow along.
GLPI API interface — the contract-aligned helpers that map one-to-one to GLPI v2 endpoints (tickets, timeline, team members, users, locations, entities, documents).
- Added functionalities — helpers built on top of the API mixins:
the
Fieldsplugin custom-field helpers, the aggregated ticket context view, and the reporting helpers.
End-to-end examples — full workflows that combine the previous building blocks.
Error handling — the public exception hierarchy, what each branch means, and how retries behave.
The sample snippets in sections 3 to 6 use the synchronous
GlpiClient. Every snippet works on the asynchronous client by
replacing with ... as client: with async with ... as client: and
prefixing every client method call with await — the public method
names and signatures are identical.
1. Create a client
Provide the GLPI v2 API URL and at least one complete authentication
pair. The OAuth password grant accepts either client_id /
client_secret, username / password, or both pairs at once.
from glpi_python_client import GlpiClient
with GlpiClient(
glpi_api_url="https://glpi.example.com/api.php/v2",
client_id="oauth-client-id",
client_secret="oauth-client-secret",
username="api-user",
password="api-password",
glpi_entity=1,
glpi_profile=4,
) as client:
tickets = client.search_tickets("status==1", limit=10)
for ticket in tickets:
print(ticket.id, ticket.name)
The asynchronous client takes the same arguments and is used inside an
async with block:
import asyncio
from glpi_python_client import AsyncGlpiClient
async def main() -> None:
async with AsyncGlpiClient(
glpi_api_url="https://glpi.example.com/api.php/v2",
client_id="oauth-client-id",
client_secret="oauth-client-secret",
username="api-user",
password="api-password",
) as client:
tickets = await client.search_tickets("status==1", limit=10)
for ticket in tickets:
print(ticket.id, ticket.name)
asyncio.run(main())
Optional constructor arguments
glpi_entity— numeric GLPI entity ID sent as theGLPI-Entityheader.glpi_profile— numeric GLPI profile ID sent as theGLPI-Profileheader.entity_recursive— whenTruethe request scope includes child entities.language— value of theAccept-Languageheader (defaults to"en_GB").verify_ssl— set toFalseonly on test instances with self-signed certificates.auth_token_refresh— number of seconds before token expiry at which the auth manager proactively refreshes the OAuth access token.v1_base_urlandv1_user_token— together enable the legacy v1fallback used by
GlpiClient.upload_document()and theFieldsplugin helpers such asGlpiClient.get_ticket_custom_fields().
from_env
When the same configuration is already exposed through environment
variables, GlpiClient.from_env() (and
AsyncGlpiClient.from_env()) read the GLPI_-prefixed keys and
build the client for you:
GLPI_API_URLGLPI_CLIENT_IDandGLPI_CLIENT_SECRETGLPI_USERNAMEandGLPI_PASSWORDGLPI_ENTITY,GLPI_PROFILE,GLPI_ENTITY_RECURSIVEGLPI_LANGUAGE,GLPI_VERIFY_SSLGLPI_V1_BASE_URL,GLPI_V1_USER_TOKEN,GLPI_V1_APP_TOKEN
from glpi_python_client import AsyncGlpiClient, GlpiClient
sync_client = GlpiClient.from_env()
async_client = AsyncGlpiClient.from_env()
2. Sync vs async surface
Both GlpiClient and AsyncGlpiClient expose the same
public endpoint methods. The parity is enforced by a unit test so any
new sync endpoint is automatically reflected on the async client.
When to pick which
Use
GlpiClientfor plain Python scripts, CLI tools, cron entries, and synchronous services. No event loop, noawait.Use
AsyncGlpiClientwhen your application already runs an event loop (for example a FastAPI or aiohttp service, an async CLI, or a Jupyter notebook cell), or when you want concurrent fan-out.
How the two clients stay in step
Both clients are the same code. The asynchronous tree is written by hand
and the synchronous one is generated from it: a build step strips
async/await and renames the handful of tokens that differ between
the two surfaces. The generated tree is committed, and CI regenerates it
and fails on any difference, so the two cannot drift apart.
This is why the endpoint surfaces are identical and why a fix never has
to be applied twice. It also means neither client is a wrapper around the
other: AsyncGlpiClient performs real non-blocking I/O on the
event loop, and GlpiClient performs real blocking I/O with no
thread pool, no executor, and no coroutine scheduling.
Exactly one module is maintained separately for each surface, because the two need genuinely different primitives rather than differently-spelled ones:
Fan-out. Aggregating helpers such as
AsyncGlpiClient.get_ticket_context()issue several GLPI calls through a sharedgatherhelper. On the async surface that isasyncio.gather()and the calls overlap; on the sync surface the arguments have already been evaluated by the timegatheris entered, so the same expression means “one after the other”. The calling code is identical.The auth lock.
AsyncGlpiClientuses anasyncio.LockandGlpiClientathreading.Lock. Neither substitutes for the other. Athreading.Lockon the event loop would be held across anawait, so a second task waiting on it would block the loop and the task holding it could never resume to release it. Anasyncio.Lockin the sync client would bind itself to whichever event loop first contended it, breaking the guarantee that oneGlpiClientmay be shared across threads.
Pagination helpers (iter_search_tickets, iter_search_users,
iter_search_entities) are exposed as async generators on the
async client. Iterate them with async for to walk every page
without blocking the event loop:
async for batch in client.iter_search_tickets("status==1", batch_size=200):
for ticket in batch:
...
The synchronous versions of the same helpers issue the calls sequentially.
Bounding concurrency
There is no thread pool to size and no executor argument: the async
client issues real non-blocking requests, so concurrency is bounded by
the underlying HTTP connection pool rather than by worker threads.
To keep a large fan-out from overwhelming the GLPI server, bound it on
your side with an asyncio.Semaphore:
import asyncio
from glpi_python_client import AsyncGlpiClient
async def main() -> None:
limit = asyncio.Semaphore(8)
async with AsyncGlpiClient.from_env() as client:
async def fetch(ticket_id: int):
async with limit:
return await client.get_ticket(ticket_id)
tickets = await asyncio.gather(*(fetch(i) for i in range(1, 101)))
print(len(tickets))
asyncio.run(main())
3. Seed data for the examples
Every later snippet operates on a small, predictable set of records.
Run the seed coroutine below once against a throwaway GLPI instance to
materialise the records; the rest of the guide assumes the identifiers
it prints are available under the variable names location_id,
alice_id, bob_id, and ticket_id.
Warning
This guide intentionally creates and deletes real records. Always target a development or sandbox GLPI environment, never a production tenant.
from glpi_python_client import (
GlpiClient,
PostFollowup,
PostLocation,
PostTeamMember,
PostTicket,
PostUser,
)
def seed() -> dict[str, int]:
"""Create the demo records reused by the rest of the user guide."""
with GlpiClient.from_env() as client:
location_id = client.create_location(
PostLocation(name="HQ Paris")
)
alice_id = client.create_user(
PostUser(
username="alice.dupont",
password="initial-pwd",
password2="initial-pwd",
realname="Dupont",
firstname="Alice",
)
)
bob_id = client.create_user(
PostUser(
username="bob.martin",
password="initial-pwd",
password2="initial-pwd",
realname="Martin",
firstname="Bob",
)
)
ticket_id = client.create_ticket(
PostTicket(
name="Wi-Fi unreachable",
content="802.1X handshake fails on the 5 GHz radio.",
)
)
client.add_ticket_team_member(
ticket_id,
PostTeamMember(type="User", id=bob_id, role="assigned"),
)
client.create_ticket_followup(
ticket_id,
PostFollowup(content="Reproduced on the lab laptop."),
)
return {
"location_id": location_id,
"alice_id": alice_id,
"bob_id": bob_id,
"ticket_id": ticket_id,
}
if __name__ == "__main__":
print(seed())
Example output (identifiers vary across instances):
{'location_id': 7, 'alice_id': 21, 'bob_id': 22, 'ticket_id': 123}
A teardown snippet to drop the seed records once the walkthrough is complete:
def cleanup(ids: dict[str, int]) -> None:
"""Delete the seed records previously created by ``seed``."""
with GlpiClient.from_env() as client:
client.delete_ticket(ids["ticket_id"], force=True)
client.delete_user(ids["alice_id"], force=True)
client.delete_user(ids["bob_id"], force=True)
client.delete_location(ids["location_id"], force=True)
In the rest of the guide every snippet is wrapped in an
with GlpiClient.from_env() as client: block. The integer
variables ticket_id, alice_id, bob_id, and location_id
are assumed to come from the seed dictionary above.
4. GLPI API interface
The helpers in this section map one-to-one to GLPI v2 endpoints. They all return Pydantic models from the public package root.
Get / Post / Patch / Delete models
Each GLPI resource is represented by four Pydantic models named after the verb of the HTTP operation:
Get<Name>— what the server returns from list and read endpoints.Post<Name>— request body for the create endpoint.Patch<Name>— partial body for the update endpoint.Delete<Name>— optional body for the delete endpoint (typically a singleforceflag).
The full set is re-exported from the package root, including
GetTicket / PostTicket / PatchTicket / DeleteTicket,
GetUser / PostUser / PatchUser / DeleteUser,
GetLocation / PostLocation / PatchLocation / DeleteLocation,
GetEntity / PostEntity / PatchEntity / DeleteEntity,
GetFollowup, GetTicketTask, GetSolution, GetTimelineDocument,
GetTeamMember, and GetDocument together with their post / patch /
delete variants.
All models inherit from a permissive base: the GLPI server is the
authoritative validator, so any extra keys returned by the live server
flow into the public extra_payload attribute rather than raising a
validation error. Caller-provided extra_payload keys win over
ambient extras when both are present.
from glpi_python_client import PostTicket
ticket = PostTicket(
name="Printer offline",
content="The third-floor printer cannot be reached.",
extra_payload={"_room_code": "PAR-3F-12"},
)
new_id = client.create_ticket(ticket)
fetched = client.get_ticket(new_id)
print(fetched.id, fetched.name)
print(fetched.extra_payload)
Example output:
124 Printer offline
{'_room_code': 'PAR-3F-12'}
Tickets
The ticket mixin exposes search, fetch, create, update, and delete
helpers under /Assistance/Ticket.
from glpi_python_client import PatchTicket
client.update_ticket(
ticket_id,
PatchTicket(content="Updated diagnosis: radius timeout."),
)
ticket = client.get_ticket(ticket_id)
print(ticket.id, ticket.name, ticket.status)
results = client.search_tickets("status==1", limit=3)
for t in results:
print(t.id, t.name)
Example output:
123 Wi-Fi unreachable id=1 name='New'
123 Wi-Fi unreachable
124 Printer offline
125 VPN drops
force=True on GlpiClient.delete_ticket() permanently deletes
the ticket; omit it (or pass force=False) to send the record to the
GLPI trash. search_tickets accepts a raw RSQL filter string and
forwards limit / start to the API for pagination.
Ticket timeline
The ticket timeline groups followups, tasks, solutions, and document
links under /Assistance/Ticket/{id}/Timeline/{Followup|Task|Solution|Document}.
Each subresource exposes the same list_ / get_ / create_ / update_ /
delete_ shape (link_ / unlink_ for documents).
from glpi_python_client import (
PostFollowup,
PostSolution,
PostTicketTask,
)
followup_id = client.create_ticket_followup(
ticket_id,
PostFollowup(content="Triaged: ongoing"),
)
task_id = client.create_ticket_task(
ticket_id,
PostTicketTask(content="On-site visit", duration=900),
)
solution_id = client.create_ticket_solution(
ticket_id,
PostSolution(content="Replaced the access point"),
)
followups = client.list_ticket_followups(ticket_id)
tasks = client.list_ticket_tasks(ticket_id)
solutions = client.list_ticket_solutions(ticket_id)
print(len(followups), len(tasks), len(solutions))
print(followups[0].content)
Example output:
2 1 1
Reproduced on the lab laptop.
Note
The live GLPI v2 server returns each timeline list entry wrapped in a
{"type": ..., "item": {...}} envelope, even when the OpenAPI
contract documents a flat array. The client unwraps that envelope
transparently for list_ticket_followups, list_ticket_tasks,
list_ticket_solutions, and list_ticket_timeline_documents.
Team members
Team members are managed via /Assistance/Ticket/{id}/TeamMember.
from glpi_python_client import PostTeamMember
client.add_ticket_team_member(
ticket_id,
PostTeamMember(type="User", id=alice_id, role="observer"),
)
members = client.list_ticket_team_members(ticket_id)
for m in members:
print(m.id, m.type, m.name, m.role)
client.remove_ticket_team_member(
ticket_id,
team_member_id=members[0].id,
)
Example output:
22 User bob.martin assigned
21 User alice.dupont observer
The OpenAPI contract marks the id field as read-only, but the live
server requires it on the POST body. The client honours the live
behaviour and exposes id as a writable field on
glpi_python_client.PostTeamMember.
Users, locations, entities
Each of these resources exposes the same search_ / get_ / create_ /
update_ / delete_ shape:
alice = client.get_user(alice_id)
print(alice.id, alice.username, alice.realname, alice.firstname)
matches = client.search_users(f"username=={alice.username}")
print([(u.id, u.username) for u in matches])
location = client.get_location(location_id)
print(location.id, location.name)
entities = client.search_entities(limit=2)
for e in entities:
print(e.id, e.name, e.completename)
Example output:
21 alice.dupont Dupont Alice
[(21, 'alice.dupont')]
7 HQ Paris
0 Root entity Root entity
1 Paris Root entity > Paris
Documents
Document metadata is handled with the standard Get/Post/Patch/Delete
helpers under /Management/Document. Binary content goes through two
dedicated helpers:
uploaded_id = client.upload_document(
filename="diagnostic.txt",
content=b"link layer ok\nradius timeout 3s\n",
mime_type="text/plain",
ticket_id=ticket_id,
)
print("uploaded document", uploaded_id)
raw_bytes = client.download_document_content(uploaded_id)
print(len(raw_bytes), "bytes downloaded")
Example output:
uploaded document 88
34 bytes downloaded
upload_document requires the legacy v1 session to be configured on
the client (v1_base_url and v1_user_token) because the GLPI v2
contract does not advertise a binary upload endpoint.
Knowledge base
The knowledge base mixins map to /Knowledgebase. Articles and
categories expose the search_ / get_ / create_ / update_ / delete_
shape; comments are nested under an article; revisions are read-only.
Article content and description accept and return Markdown. An
article’s categories association is read-only in the v2 GLPI contract,
so the client sets it through a legacy fallback — see
Assigning categories.
Note
The Knowledge base API was introduced in the GLPI High-Level API
2.2.0. Instances serving an older API version (e.g. 2.1.0) do not
expose /Knowledgebase and these helpers will raise a
ValueError (HTTP 404).
from glpi_python_client import (
PostKBArticle,
PostKBArticleComment,
PostKBCategory,
)
client.create_kb_category(PostKBCategory(name="Networking"))
categories = client.search_kb_categories("name==Networking")
print([c.name for c in categories])
article_id = client.create_kb_article(
PostKBArticle(
name="Reset a Wi-Fi controller",
content="Hold **reset** for 10s, then re-provision.",
is_faq=True,
)
)
client.create_kb_article_comment(
article_id,
PostKBArticleComment(comment="Confirmed on firmware 4.2."),
)
article = client.get_kb_article(article_id)
print(article.id, article.name)
faq = client.search_kb_articles("is_faq==1", limit=10)
for entry in faq:
print(entry.id, entry.name)
revisions = client.list_kb_article_revisions(article_id)
print(len(revisions), "revision(s)")
Example output:
['Networking']
Assigning categories
On GLPI 11 the v2 API cannot write a KB article’s categories (the nested
categories[].id is readOnly and category writes are silently
dropped). GLPI 11 stores KB categories as a many-to-many relationship that
only the legacy apirest.php can write, so the client applies categories
through the legacy v1 session. Configure v1_base_url / v1_user_token
(pointing at the legacy apirest.php) and either pass categories on
create/update or call the helper directly. The supplied ids replace the
article’s full category set; passing an empty list clears every category.
from glpi_python_client import IdNameRef
# Categories set on create are applied via the legacy fallback. The
# create is atomic: if the assignment fails, the new article is rolled
# back and the error is re-raised.
article_id = client.create_kb_article(
PostKBArticle(
name="Reset a Wi-Fi controller",
content="Hold **reset** for 10s.",
categories=[IdNameRef(id=14)],
)
)
# Or set them explicitly at any time.
client.set_kb_article_categories(article_id, [14]) # replace the full set
client.set_kb_article_categories(article_id, []) # clear all
42 Reset a Wi-Fi controller
42 Reset a Wi-Fi controller
1 revision(s)
Enums
Public IntEnum classes mirror the GLPI numeric constants and stay at
the package root for easy use in RSQL filters:
glpi_python_client.GlpiTicketStatus,
glpi_python_client.GlpiTicketType,
glpi_python_client.GlpiPriority,
glpi_python_client.GlpiTaskState,
glpi_python_client.GlpiSolutionStatus,
glpi_python_client.GlpiTimelinePosition,
glpi_python_client.GlpiUserAuthType, and
glpi_python_client.GlpiGlobalValidation.
from glpi_python_client import GlpiTicketStatus
solved = client.search_tickets(
f"status=={int(GlpiTicketStatus.SOLVED)}", limit=2
)
print([(t.id, t.name) for t in solved])
Example output:
[(120, 'Replaced toner cartridge'), (121, 'Reset VPN profile')]
5. Added functionalities
The helpers in this section are not part of the GLPI contract. They are small utilities the client builds on top of the API mixins.
Ticket custom fields via the Fields plugin
The Fields plugin exposes
ticket custom fields through the legacy v1 API rather than the GLPI v2
contract. Configure the client with v1_base_url and
v1_user_token (or the matching GLPI_V1_* environment
variables), then use the discovery helpers when you need the plugin’s
internal container and field names:
from glpi_python_client import GlpiClient
with GlpiClient(
glpi_api_url="https://glpi.example.com/api.php/v2",
client_id="oauth-client-id",
client_secret="oauth-client-secret",
username="api-user",
password="api-password",
v1_base_url="https://glpi.example.com/apirest.php",
v1_user_token="legacy-user-token",
) as client:
containers = client.list_plugin_fields_containers(itemtype="Ticket")
for container in containers:
print(container.id, container.name)
fields = client.list_plugin_fields_fields(container_id=container.id)
print([field.name for field in fields])
custom_fields = client.get_ticket_custom_fields(ticket_id)
print(custom_fields)
client.set_ticket_custom_fields(
ticket_id,
{
"extrainfo": {
"extrainfofield": "<p>Handled by the NOC shift</p>",
}
},
)
The high-level get_ticket_custom_fields /
set_ticket_custom_fields pair uses the mapping
{container_name: {field_name: value}} and automatically decides
whether the v1 plugin needs a row creation or an in-place update. Drop
to list_item_plugin_field_rows, create_item_plugin_field_row,
or update_item_plugin_field_row only when you need the raw v1 row
shape.
Aggregated ticket context
GlpiClient.get_ticket_context() runs the ticket fetch and the four
timeline list calls concurrently and returns a single
glpi_python_client.GlpiTicketContext model:
bundle = client.get_ticket_context(ticket_id)
print(bundle.ticket.id, bundle.ticket.name)
print(
len(bundle.followups),
len(bundle.tasks),
len(bundle.solutions),
len(bundle.documents),
)
Example output:
123 Wi-Fi unreachable
2 1 1 1
GlpiTicketContext.to_markdown() renders the ticket title, a
metadata subtitle, and every timeline event (followups, tasks,
solutions, document links) as a single Markdown transcript. Events are
always ordered by date_creation:
print(bundle.to_markdown())
Example output:
# Ticket #123 — Wi-Fi unreachable
> Status: New | Requester: Alice Dupont | Last edited by: Bob Martin | Created at: 2026-01-02T09:00:00+00:00 | Updated at: 2026-01-02T09:20:00+00:00
## Description
802.1X handshake fails on the 5 GHz radio.
## Timeline
### Followup #45
> Created by: Bob Martin | Created at: 2026-01-02T09:05:00+00:00
Reproduced on the lab laptop.
### Task #12
> Created by: Bob Martin | Created at: 2026-01-02T09:10:00+00:00 | Duration: 900s | State: Todo
On-site visit.
### Solution #7
> Created by: Bob Martin | Created at: 2026-01-02T09:20:00+00:00 | Status: Approved
Replaced the access point.
## Documents
- diagnostic.txt
Customising the Markdown output
Pass a TicketMarkdownOptions instance to select which sections
and metadata fields appear in the output. All flags default to True
so the default call reproduces the full transcript shown above.
Flag |
Controls |
|---|---|
|
|
|
Followup entries in |
|
Task entries in |
|
Solution entries in |
|
|
|
|
|
|
|
|
|
All ticket-level date fields |
|
|
|
|
|
All date fields in event subtitles |
|
|
|
|
|
|
|
|
|
|
Example — description and timeline only, no metadata fields:
from glpi_python_client import TicketMarkdownOptions
opts = TicketMarkdownOptions(
include_documents=False,
show_status=False,
show_requester=False,
show_editor=False,
show_dates=False,
show_event_author=False,
show_event_editor=False,
show_event_dates=False,
show_event_state=False,
show_event_status=False,
show_duration=False,
show_technician=False,
show_approver=False,
)
print(bundle.to_markdown(opts))
Reporting helpers
The custom statistics mixin exposes several helpers that aggregate the ticket and ticket-task records returned by the contract-aligned mixins. They all return plain Python dictionaries so they can be serialised or forwarded as-is.
Streaming pagination with iter_search_*
The search_* helpers return one page at a time and require the
caller to manage the start cursor. The companion iter_search_*
generators handle pagination automatically by yielding successive
batches until the API returns fewer rows than the requested
batch_size (the natural end-of-stream signal):
GlpiClient.iter_search_tickets()GlpiClient.iter_search_users()GlpiClient.iter_search_entities()
# Walk every "open" ticket without loading the full result set in memory.
total = 0
for batch in client.iter_search_tickets("status==1", batch_size=200):
total += len(batch)
for ticket in batch:
print(ticket.id, ticket.name)
print(f"processed {total} tickets")
Note
Always pass an RSQL filter to iter_search_tickets. Querying
without any filter can return very large result sets and may cause
the GLPI server to return a 500 errors.
On the asynchronous client the same helpers are exposed as async
generators through the bridge, so each next() call runs off the
event loop and the consumer uses async for:
async for batch in async_client.iter_search_users("", batch_size=100):
for user in batch:
print(user.id, user.username)
get_ticket_statistics
Counts tickets created within an ISO date window and groups them by
entity, status, priority, and type. The start_date is inclusive
from 00:00:00 and the end_date is inclusive through 23:59:59, so
tickets created at any time on those days are counted. Optional
filters restrict the result set on the server side:
entity_id— restrict to a single entity by numeric identifier.entity_name— substring match against the entitynamecolumn; the helper resolves matching IDs viasearch_entitiesand ORs them together. Ignored whenentity_idis provided.extra_filter— raw RSQL fragment AND-joined with the date window.
# Tickets created in January 2026 on a specific entity, restricted to
# priority "HIGH" (5) via an extra raw RSQL fragment.
stats = client.get_ticket_statistics(
start_date="2026-01-01",
end_date="2026-01-31",
entity_id=3,
extra_filter="priority==5",
)
print(stats)
# Resolve the entity by (partial) name instead of by ID:
stats = client.get_ticket_statistics(
start_date="2026-01-01",
end_date="2026-01-31",
entity_name="Helpdesk",
)
When entity_name matches no entity the helper short-circuits and
returns {"entities": {}} without issuing any ticket search.
Returned shape (the outer key is always "entities"; entity keys are
the GLPI numeric identifier as a string, "unknown" when missing):
{
"entities": {
"0": {
"total": 12,
"by_status": {"1": 5, "2": 3, "5": 4},
"by_priority": {"LOW": 2, "MEDIUM": 7, "HIGH": 3},
"by_type": {"INCIDENT": 9, "REQUEST": 3},
},
"1": {
"total": 4,
"by_status": {"1": 1, "5": 3},
"by_priority": {"MEDIUM": 4},
"by_type": {"INCIDENT": 4},
},
}
}
total— number of tickets in the entity bucket.by_status— keyed by the GLPI numeric status as a string; resolve withglpi_python_client.GlpiTicketStatus.by_priority/by_type— keyed by the matching IntEnum member name ("LOW","INCIDENT", …); unknown values fall back to the raw numeric value as a string.
get_task_statistics
Aggregates task durations across a caller-supplied list of ticket
identifiers. GLPI does not expose a global task collection endpoint, so
callers typically collect the relevant ticket IDs through
search_tickets first.
ticket_ids = [
t.id for t in client.search_tickets("status==2", limit=200)
]
tasks = client.get_task_statistics(ticket_ids)
print(tasks)
Returned shape (durations are integer seconds, matching the GLPI
duration field; user keys are the GLPI numeric user identifier as a
string, "unknown" when missing):
{
"ticket_count": 3,
"task_count": 5,
"total_duration": 6300,
"duration_by_user": {"22": 4500, "21": 1800},
"duration_by_ticket": {123: 2700, 124: 1800, 125: 1800},
}
Returned identifiers are the raw GLPI numeric values; resolve them with
the appropriate search_* helpers when human-readable labels are
needed (for example
client.get_user(22) to turn user key "22" into a full
GetUser model).
get_task_durations
Aggregates task durations over a date window with rich server-side
filters and an optional per-task detail list. Internally the helper
iterates iter_search_tickets() to collect every matching ticket,
then computes per-user and per-entity totals.
Available filters:
start_date/end_date/default_days— ISOYYYY-MM-DDdate window;start_dateis inclusive from 00:00:00,end_dateis inclusive through 23:59:59, anddefault_daysis used whenstart_dateis omitted.entity_id— restrict to a single entity by identifier.entity_name— substring match resolved throughsearch_entities; ignored whenentity_idis given.user_id— tickets where the user is either assignee or requester (OR semantics).user_editor_id— tickets last updated by this user.user_recipient_id— tickets where this user is the requester.extra_filter— raw RSQL fragment AND-joined with everything else.return_task_details— whenTrue, fetch every non-zero ticket’s task list and include them astasksin the result.
# Sum durations for a tech on a specific entity over the last 30 days.
summary = client.get_task_durations(
entity_id=3,
user_id=42,
)
print(summary["total_duration"], summary["task_count"])
print(summary["duration_by_entity"]) # {"3": 7200}
# Same query but ask for the per-task breakdown.
detailed = client.get_task_durations(
entity_id=3,
user_id=42,
return_task_details=True,
)
for task in detailed["tasks"] or []:
print(task["task_id"], task["ticket_id"], task["duration"])
Returned shape:
{
"start_date": "2026-01-01",
"end_date": "2026-01-31",
"total_duration": 7200,
"task_count": 4,
"duration_by_user": {"42": 7200},
"duration_by_entity": {"3": 7200},
"tasks": None, # or a list[dict] when return_task_details=True
}
On the async client the same method is overridden to run the per-ticket
task fetches concurrently with asyncio.gather() when
return_task_details=True.
get_user_activity
Aggregates per-user activity over a date window: tickets where the
user appears as technician (users_id_assign), tickets where the
user appears as requester (users_id_requester), and the user’s
task duration totals. Multiple users that resolve to the same display
key ("<firstname> <realname>") are merged into a single bucket.
The helper raises ValueError when no identifier is supplied or
when the search criteria match no users in the directory.
# Activity for a single user identified by username (substring match).
report = client.get_user_activity(
username="alice",
start_date="2026-01-01",
end_date="2026-01-31",
)
for display_name, data in report["users"].items():
print(
display_name,
data["tickets_as_technician"],
data["tickets_as_recipient"],
data["task_durations"]["total_duration"],
)
# Activity for every user whose last name contains "Smith".
report = client.get_user_activity(realname="Smith", default_days=90)
Returned shape:
{
"users": {
"Alice Smith": {
"user_ids": [42],
"tickets_as_technician": 7,
"tickets_as_recipient": 2,
"task_durations": {
"start_date": "2026-01-01",
"end_date": "2026-01-31",
"total_duration": 7200,
"task_count": 4,
"duration_by_user": {"42": 7200},
"duration_by_entity": {"3": 7200},
},
}
}
}
6. End-to-end examples
The snippets below combine the building blocks of the previous
sections. Every example is mirrored by an integration test in
integration_tests/test_integration.py (named test_example_*).
They all assume the seed step from 3. Seed data for the examples has been executed
and that ticket_id / alice_id / bob_id are bound to the
matching identifiers.
Example 1 — Create a ticket and read it back
from glpi_python_client import GlpiClient, PostTicket
with GlpiClient.from_env() as client:
new_id = client.create_ticket(
PostTicket(
name="Printer offline",
content="The third-floor printer cannot be reached.",
)
)
context = client.get_ticket_context(new_id)
print(context.to_markdown())
Expected Markdown (abridged):
# Ticket #124 — Printer offline
> Status: New
## Description
The third-floor printer cannot be reached.
Example 2 — Add a followup response
from glpi_python_client import PostFollowup
client.create_ticket_followup(
ticket_id,
PostFollowup(content="Capturing radius logs."),
)
context = client.get_ticket_context(ticket_id)
print(context.to_markdown())
Expected Markdown (abridged):
# Ticket #123 — Wi-Fi unreachable
## Timeline
### Followup #46
> Created at: 2026-01-02T10:15:00+00:00
Capturing radius logs.
Example 3 — Add a task with a duration
from glpi_python_client import PostTicketTask
client.create_ticket_task(
ticket_id,
PostTicketTask(
content="On-site visit to swap the access point.",
duration=1800,
),
)
context = client.get_ticket_context(ticket_id)
print(context.to_markdown())
Expected Markdown (abridged):
### Task #13
> Duration: 1800s
On-site visit to swap the access point.
Example 4 — Close a ticket with a solution
GLPI moves a ticket to the Solved status as soon as a solution is posted, so adding a solution is the supported way to change the ticket status from the v2 API.
from glpi_python_client import PostSolution
client.create_ticket_solution(
ticket_id,
PostSolution(content="Replaced the access point firmware."),
)
context = client.get_ticket_context(ticket_id)
print(context.to_markdown())
Expected Markdown (abridged):
# Ticket #123 — Wi-Fi unreachable
> Status: Solved
## Timeline
### Solution #8
Replaced the access point firmware.
Example 5 — Upload a document to an existing ticket
upload_document accepts a ticket_id and links the new document
to the timeline in a single call. The call requires the legacy v1
session (v1_base_url and v1_user_token).
client.upload_document(
filename="diagnostic.txt",
content=b"link layer ok\nradius timeout 3s\n",
mime_type="text/plain",
ticket_id=ticket_id,
)
context = client.get_ticket_context(ticket_id)
print(context.to_markdown())
Expected Markdown (abridged):
## Documents
- diagnostic.txt
Example 6 — Full ticket workflow with a dedicated technician
The following script mirrors the integration test suite. It creates a fresh ticket, exercises every timeline subresource, assigns a technician, and tears the records down at the end.
from glpi_python_client import (
GlpiClient,
PostFollowup,
PostSolution,
PostTeamMember,
PostTicket,
PostTicketTask,
PostUser,
)
def workflow() -> None:
with GlpiClient.from_env() as client:
user_id = client.create_user(
PostUser(
username="bob.workflow",
password="initial-pwd",
password2="initial-pwd",
realname="Workflow",
firstname="Bob",
)
)
new_ticket_id = client.create_ticket(
PostTicket(name="VPN drops", content="Daily VPN drops at 11:00")
)
try:
client.create_ticket_followup(
new_ticket_id,
PostFollowup(content="Reproduced on lab laptop"),
)
client.create_ticket_task(
new_ticket_id,
PostTicketTask(content="Capture VPN logs", duration=1800),
)
client.add_ticket_team_member(
new_ticket_id,
PostTeamMember(type="User", id=user_id, role="assigned"),
)
client.create_ticket_solution(
new_ticket_id,
PostSolution(content="Upgraded VPN client"),
)
context = client.get_ticket_context(new_ticket_id)
print(context.ticket.name, len(context.followups))
finally:
client.delete_ticket(new_ticket_id, force=True)
client.delete_user(user_id, force=True)
workflow()
Example output:
VPN drops 1
Example 7 — Build a monthly report
Combines get_ticket_statistics() and get_task_statistics()
to summarise a calendar month.
from glpi_python_client import GlpiClient
def monthly_report(start: str, end: str) -> dict[str, object]:
with GlpiClient.from_env() as client:
ticket_stats = client.get_ticket_statistics(
start_date=start, end_date=end
)
solved_tickets = client.search_tickets(
"status==5", limit=200
)
task_stats = client.get_task_statistics(
[t.id for t in solved_tickets]
)
return {"tickets": ticket_stats, "tasks": task_stats}
if __name__ == "__main__":
print(monthly_report("2026-01-01", "2026-01-31"))
Example output:
{
'tickets': {
'entities': {
'0': {
'total': 12,
'by_status': {'1': 5, '2': 3, '5': 4},
'by_priority': {'MEDIUM': 9, 'HIGH': 3},
'by_type': {'INCIDENT': 9, 'REQUEST': 3},
}
}
},
'tasks': {
'ticket_count': 4,
'task_count': 5,
'total_duration': 6300,
'duration_by_user': {'22': 4500, '21': 1800},
'duration_by_ticket': {120: 1800, 121: 900, 122: 1800, 123: 1800},
},
}
7. Error handling
Exceptions the client raises for a bad argument, an unexpected HTTP
status, or an unusable response body derive from
GlpiError, so one handler covers that part
of the library surface:
from glpi_python_client import GlpiClient, GlpiError
client = GlpiClient.from_env()
try:
ticket = client.get_ticket(42)
except GlpiError as exc:
print(f"GLPI call failed: {exc}")
That single except clause covers network-level faults too.
Connection failures, DNS errors and timeouts are translated into
GlpiTransportError – or its
GlpiTimeoutError subclass for a timeout –
so you never need to import the HTTP library to catch them. The original
transport exception stays attached as __cause__ for debugging, and
these faults are retried three times before they surface:
from glpi_python_client import GlpiClient, GlpiTimeoutError, GlpiTransportError
client = GlpiClient.from_env()
try:
ticket = client.get_ticket(42)
except GlpiTimeoutError as exc:
print(f"GLPI was too slow: {exc} (cause: {exc.__cause__!r})")
except GlpiTransportError as exc:
print(f"GLPI was unreachable: {exc}")
A handful of sites also deliberately still raise bare RuntimeError
(using a closed client, a missing v1 document session, a partially
failed knowledge-base write) or TypeError (a malformed environment
value) instead of a library type, so except RuntimeError / except
TypeError code written against earlier releases keeps working.
The hierarchy lets you narrow as far as you need:
GlpiError
├── GlpiTransportError reserved for the httpx transport swap;
│ └── GlpiTimeoutError not raised yet -- see the note above
├── GlpiStatusError GLPI answered with an unexpected status
│ ├── GlpiAuthError 401 / 403
│ ├── GlpiNotFoundError 404
│ └── GlpiServerError 5xx (retried up to 3 attempts before it
│ reaches you)
├── GlpiValidationError the client rejected your argument
└── GlpiProtocolError GLPI answered 2xx with an unusable body
GlpiStatusError carries the diagnostics you
usually want:
from glpi_python_client import GlpiNotFoundError
try:
ticket = client.get_ticket(999999)
except GlpiNotFoundError as exc:
print(exc.status_code) # 404
print(exc.url) # the absolute URL that was requested
print(exc.response_text) # the response body
Note
GlpiStatusError,
GlpiValidationError and
GlpiProtocolError also inherit
ValueError. Code written against earlier releases, which
raised bare ValueError, keeps working unchanged.
Retry behaviour
Each transport and v1-session retry decorator retries a server error
(5xx) up to 3 attempts with a 3-second fixed wait before
GlpiServerError reaches you. Client errors
(4xx) are never retried — they cannot succeed on a second attempt.
OAuth token acquisition follows the same 3-attempt policy, with one
exception: refreshing an already-issued token does not raise directly on
a failed response. It logs a warning and falls through to a fresh token
acquisition, which carries its own independent 3-attempt retry
decorator. The refresh method’s own retry decorator only retries a
network-level fault on the refresh request itself (a
GlpiTransportError raised before any
response is received) —
it does not retry a GlpiServerError
from the fall-through, since that failure is already being retried by
the nested acquisition call. A persistent 5xx encountered while
refreshing therefore costs exactly 1 refresh POST + up to 3 nested
acquisition POSTs = 4 POST requests before
GlpiServerError reaches you. A rejected
credential (401/403) is not retried at either layer and fails after at
most 2 POST requests.
Search methods are deliberately tolerant: search_tickets and its
siblings return an empty list rather than raising when GLPI rejects the
query. Methods that fetch or mutate one specific record always raise.