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, computers, contracts).
- 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",
server_timezone="Europe/Paris",
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",
server_timezone="Europe/Paris",
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_SERVER_TIMEZONE– required. IANA name of the timezone the GLPI server runs in (e.g.Europe/Paris). GLPI does not advertise it, and it is needed to interpret the timestamps the server sends without an offset. There is no default: guessing UTC against a Europe/Paris instance shifts those values by an hour or two and never raises.GLPI_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.
Assets
The asset mixins map to /Assets/*. GLPI models roughly two dozen
asset itemtypes (computers, monitors, printers, network equipment, and
so on), and this client currently implements only Computer – there
is no search_monitors or get_printer. Treat Computer as the
one supported asset type rather than a stand-in for the rest of the
family.
search_ / get_ / create_ / update_ / delete_ follow the same shape
as the other resources, with iter_search_computers for streaming
pagination and the same rsql_filter / limit / start /
sort arguments as search_tickets:
from glpi_python_client import PatchComputer, PostComputer
computer_id = client.create_computer(
PostComputer(name="ws-1042", serial="PF3KL9QJ")
)
client.update_computer(
computer_id, PatchComputer(comment="Reimaged for the finance team")
)
computer = client.get_computer(computer_id)
print(computer.id, computer.name, computer.serial)
results = client.search_computers("name==ws-1042", limit=5)
for c in results:
print(c.id, c.name)
Example output:
1042 ws-1042 PF3KL9QJ
1042 ws-1042
Linking a computer to a contract
A computer’s coverage contracts are tracked as links under
/Assets/Computer/{id}/Contract, exposed as
list_computer_contracts, get_computer_contract,
link_computer_contract, update_computer_contract, and
unlink_computer_contract:
from glpi_python_client import IdNameRef, PostContract, PostContractItem
contract_id = client.create_contract(PostContract(name="Dell ProSupport 2026"))
link_id = client.link_computer_contract(
computer_id, PostContractItem(contract=IdNameRef(id=contract_id))
)
for link in client.list_computer_contracts(computer_id):
print(link.id, link.itemtype, link.items_id)
client.unlink_computer_contract(computer_id, link_id, force=True)
Example output:
17 Computer 1042
The underlying Contract_Item GLPI resource is shared by every asset
type, so PostContractItem and PatchContractItem both carry an
itemtype field typed as a free string rather than an enum –
nothing stops a caller from writing "Computre". Because of that,
link_computer_contract and update_computer_contract ignore
whatever itemtype and items_id are set on the body passed in
and set both fields themselves from computer_id. Pass only
contract (and comment, if the contract ever adds one); a typo
in the two identifying fields would otherwise attach the link to the
wrong kind of object with no error from either side.
Contracts
The contract mixin maps to /Management/Contract with the usual
search_ / get_ / create_ / update_ / delete_ shape and
iter_search_contracts for streaming pagination:
from glpi_python_client import PatchContract, PostContract
contract_id = client.create_contract(
PostContract(name="Dell ProSupport 2026", number="CTR-2026-001")
)
client.update_contract(
contract_id, PatchContract(comment="Renewed for another year")
)
contract = client.get_contract(contract_id)
print(contract.id, contract.name, contract.date_begin)
results = client.search_contracts("name==Dell ProSupport 2026", limit=5)
for c in results:
print(c.id, c.name)
Example output:
501 Dell ProSupport 2026 2026-01-15
501 Dell ProSupport 2026
Warning
date_begin on GetContract and
PostContract is a plain
datetime.date, not a datetime. The GLPI
contract declares this field with format: date – a contract has
no time-of-day for its start. That choice is load-bearing, not
cosmetic: the server-clock conversion in models/_base.py that
rewrites timestamps into the configured server_timezone only
touches values that are instances of datetime.datetime, and
a plain date is not one, so date_begin never
enters that conversion. Had it been modelled as datetime it would
have been eligible, and converting a midnight, offset-naive value
between timezones can roll it onto the previous or next calendar
day. Note the asymmetry: date_begin and date_end on
ContractCost (below) are datetime fields, because the
contract declares those two with format: date-time. That
difference comes from the GLPI contract itself and is not an
inconsistency to reconcile.
Contract costs
Cost lines live under /Management/Contract/{id}/Cost as a genuine
sub-resource with their own create, update, and delete endpoints:
list_contract_costs, get_contract_cost,
create_contract_cost, update_contract_cost, and
delete_contract_cost.
from glpi_python_client import PatchContractCost, PostContractCost
cost_id = client.create_contract_cost(
contract_id, PostContractCost(name="Year 1", cost=4200.0)
)
client.update_contract_cost(
contract_id, cost_id, PatchContractCost(comment="Paid on invoice #88")
)
cost = client.get_contract_cost(contract_id, cost_id)
print(cost.id, cost.name, cost.cost)
print(len(client.list_contract_costs(contract_id)))
client.delete_contract_cost(contract_id, cost_id, force=True)
Example output:
9 Year 1 4200.0
1
Note
costs on GetContract is read-only:
it comes back populated with references to the contract’s cost
lines, but the field does not exist at all on PostContract or
PatchContract. Write cost lines through
create_contract_cost, update_contract_cost, and
delete_contract_cost instead of trying to assign costs on
the parent contract.
Contract types
/Dropdowns/ContractType is a plain dropdown: search_ / get_ /
create_ / update_ / delete_ plus iter_search_contract_types.
Unlike search_computers and search_contracts, the contract-type
search helpers do not accept a sort argument.
from glpi_python_client import PostContractType
type_id = client.create_contract_type(PostContractType(name="Maintenance"))
contract_type = client.get_contract_type(type_id)
print(contract_type.id, contract_type.name)
Example output:
6 Maintenance
Assign the type – and a renewal behaviour – through the parent contract:
from glpi_python_client import GlpiContractRenewalType, IdNameRef, PatchContract
client.update_contract(
contract_id,
PatchContract(
type=IdNameRef(id=type_id),
renewal_type=GlpiContractRenewalType.TACIT,
),
)
glpi_python_client.GlpiContractRenewalType mirrors the three
values GLPI documents for Contract.renewal_type: NONE (no
renewal), TACIT (automatic renewal), and EXPLICIT (manual
renewal).
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; on
GetKBArticle they are properties over
content_html and description_html, converted on first read – see
Rich-text content: Markdown in, Markdown out, which matters here because searching the
knowledge base returns whole article bodies. 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']
42 Reset a Wi-Fi controller
42 Reset a Wi-Fi controller
1 revision(s)
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
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,
glpi_python_client.GlpiGlobalValidation, and
glpi_python_client.GlpiContractRenewalType.
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",
server_timezone="Europe/Paris",
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 this both records how the ticket was resolved and advances its lifecycle in one call. Prefer it over setting the status directly whenever there is a resolution to record — a status moved on its own leaves no trace of why.
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.
For the transitions that have no timeline record behind them — putting a
ticket on hold, planning it, taking it back from Solved — set the
status directly. PatchTicket carries a status field and
update_ticket sends it:
from glpi_python_client import GlpiTicketStatus, PatchTicket
client.update_ticket(ticket_id, PatchTicket(status=GlpiTicketStatus.PENDING))
The field lives on PatchTicket and deliberately not on
PostTicket: GLPI ignores a status sent at creation time and the new
ticket comes back as New, so a create argument for it would do
nothing.
Warning
status is typed as glpi_python_client.GlpiTicketStatus
and rejects anything outside it, because the server accepts
everything and its interface hides the result. Writing 99
answers 200 and stores it; the API then reports
{"id": 99, "name": "99"}, the web form displays the ticket as
New, and the ticket disappears from the ticket list while staying
open in the database — only the history records the change. A typo
such as 55 for 5 would lose a ticket with no error anywhere,
which is why the enum refuses it here.
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, an unusable response body, or content it cannot convert 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 the request never produced a response
│ └── GlpiTimeoutError GLPI was too slow
├── 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
└── GlpiContentError a rich-text body could not be converted
between HTML and Markdown
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.
GlpiContentError and
GlpiTransportError do not inherit it:
there was never a bare ValueError at either kind of site, and
neither is a value the caller got wrong.
Rich-text content: Markdown in, Markdown out
Ticket, followup, task, solution and knowledge-base bodies travel to GLPI as HTML. You work in Markdown in both directions and the package handles the translation, but the two directions are not symmetric and the difference shows up in the field names.
Writing is the simple half: give content Markdown and it is rendered
to HTML when the request is built.
Reading gives you two views of the same body:
ticket = client.get_ticket(42)
ticket.content_html # '<p>The printer is <strong>offline</strong>.</p>'
ticket.content # 'The printer is **offline**.'
.content is what you want and what earlier releases gave you, so
read-side code needs no change. What changed is when the conversion
runs: on the first read of .content, cached afterwards, rather than
while the model is being built. Two things follow.
Listing records is cheap. client.search_tickets() used to convert
every body on the page whether or not you looked at one; now a search
that only reads id and date_mod converts nothing at all.
A body that cannot be converted no longer takes its page down with it. The whole page is built in one pass, so a single unconvertible record used to make its page-mates unreadable too. The failure is now scoped to the record whose body you actually read.
Note
Deeply nested HTML is the case worth knowing about. The HTML-to-Markdown
converter walks the document recursively and exhausts the interpreter’s
stack at around 494 levels of nesting. .content does not try to
predict that – it attempts the conversion and, when the walk does not
fit, strips the tags instead. It degrades, it never truncates, and it
does not raise: every character the normal rendering would have
produced still appears, so a body never says less because of how deeply
it happened to nest. What you lose is structure,
not words — link targets and image alt text, code-block fencing and
<pre> indentation, and -padded alignment. Anything else
that goes wrong raises
GlpiContentError.
Because the result is cached on first read, treat a read model as
immutable afterwards. Assigning to content_html – or
model_copy(update={"content_html": ...}) – leaves the cached
Markdown in place, and nothing in repr, == or model_dump
will tell you. Rebuild through model_validate instead.
Two smaller consequences, if you are upgrading from 0.4.x: content is
no longer in GetTicket.model_fields, and GetTicket(...).model_dump()
emits content_html holding HTML where it used to emit content
holding Markdown. Pass by_alias=True for a dump keyed the way GLPI
keys it.
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.