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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14"]

steps:
- name: Check out repository
Expand Down
7 changes: 2 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14"]

steps:
- name: Check out repository
Expand Down Expand Up @@ -100,10 +100,7 @@ jobs:
normalized_tag="${release_tag#v}"
pyproject_version="$(python - <<'PY'
from pathlib import Path
try:
from tomllib import loads as toml_loads
except ModuleNotFoundError:
from tomli import loads as toml_loads
from tomllib import loads as toml_loads

pyproject = toml_loads(Path('pyproject.toml').read_text(encoding='utf-8'))
print(pyproject['project']['version'])
Expand Down
106 changes: 84 additions & 22 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,94 @@ All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## Unreleased
## 0.6.0 — 2026-10-01

### Added
### Changed (breaking)

- `Assets/Computer` endpoint support: `search_computers`,
`iter_search_computers`, `get_computer`, `create_computer`,
`update_computer`, `delete_computer`, and the `GetComputer` /
`PostComputer` / `PatchComputer` / `DeleteComputer` models.
- Computer-to-contract links: `list_computer_contracts`,
`get_computer_contract`, `link_computer_contract`,
`update_computer_contract`, `unlink_computer_contract`. The client sets
the link's `itemtype` itself, because the GLPI contract types it as a
free string.
- `Management/Contract` endpoint support, including the cost sub-resource
and the `Dropdowns/ContractType` dropdown.
- `GlpiContractRenewalType` for the contract's documented `renewal_type`
enum (no renewal, tacit, explicit).
- Two agent skills: `glpi-asset-workflow` and `glpi-contract-workflow`.
- **Python 3.10 is no longer supported; 3.11 is the minimum.** The
`typing-extensions` and `tomli` backports it needed are dropped.
- **Content conversion is rebuilt on three libraries: markdownify,
mdformat and cmark-gfm.** `from_transport` reads GLPI's HTML with
`markdownify`, and `mdformat` re-renders that Markdown from its syntax
tree, so it keeps only the escapes CommonMark needs. `to_transport`
renders through `cmark-gfm`, the GitHub reference implementation, in
place of python-markdown. A thin layer of glue sits on top. Measured on
346 real bodies sampled from a GLPI 11 instance:
- 322 display the same after a round trip, against 299 before;
- 344 read back as the same Markdown, against 240.

Of 205 realistic caller-written Markdown documents, all 205 survive
Markdown → HTML → Markdown with the same display.
- **Markdown is rendered as CommonMark with GFM tables.** A newline is a
line break, as `nl2br` made it before. Raw HTML passes through.
Differences you may see in Markdown you write:
- a list or a table written straight after a line now starts a list or a
table, where python-markdown wanted a blank line first;
- lists nested by two or three spaces nest;
- `1)` starts a numbered list;
- `#Important`, with no space, is text rather than a heading;
- `*a **b** c*` keeps its bold;
- a backslash ending a line is a line break, so write `C:\Temp\` at the
end of a line as `` `C:\Temp\` ``;
- `<word>` is read as an HTML tag, so put a placeholder such as `<login>`
in backticks.
- **`.content` is spelled as canonical CommonMark.** A line break reads
back as `\` and a newline, a nested list is indented by its bullet's
width, and a table comes back unpadded. Text that would otherwise read as
syntax is escaped: `__init__` reads `\_\_init\_\_`, and a `* point` line
reads `\* point`. Stored digests of `.content` change once.
- **A plain-text body is literal text on the read path.** A value with no
HTML element used to come back verbatim and be rendered as Markdown. It is
now read as GLPI displays it, its lines as lines.
`GlpiContentConverter.from_transport` takes `plain_text_is_markdown`.
`True` is what the write models' validator passes: caller-authored
Markdown passes verbatim unless it starts with an HTML tag, so Markdown
carrying an inline `<br>` or `<kbd>` stays Markdown.
- **Dependencies.**
- Added: `cmarkgfm>=2025.10` (compiled wheels for CPython 3.11–3.14 on
Linux, macOS and Windows), `mdformat>=0.7.22,<0.8`,
`mdformat-tables>=1.0` and `markdown-it-py>=3.0`.
- Dropped: `markdown`. python-markdown 3.11 had broken the previous
reader.
- Raised: `beautifulsoup4>=4.15`, which fixed the parser defect that
dropped the text after a `<br />` in a body that also held a bare
`<br>`. That removes the workaround.

### Notes
### Fixed

- `Contract.date_begin` is modelled as `datetime.date`, not `datetime`.
The GLPI contract declares `format: date`, and keeping it a plain date
keeps it out of the server-clock conversion that rewrites aware
timestamps — which on a date-only field could roll the value to the
previous or next day.
- **Literal text came back as Markdown syntax.** These now read back as the
text a user typed:
- `\serveur\compta`, which had lost a backslash;
- `__init__` and `______`, which had become bold;
- a `-----` line under text, which had made a heading;
- `* point` and `> merci` lines, which had become a list and a quote;
- `[1]: https://...`, which had been consumed as a reference definition;
- a `|` in a table cell, which had dropped the rest of the row.
- **A table nested in a table cell lost all its text.** That is the usual
layout of an e-mail signature. The inner table is now written as its
cells' text, its line breaks kept as `<br>`.
- **Nested lists flattened on the first write.** Nested items, the text
after a nested list, and numbering, `start` included, survive.
- `<script>`, `<style>` and `<title>` bodies no longer leak into the text.
- A blank line inside a paragraph (`<br><br>`, or Outlook's
`<br>&nbsp;<br>`) is kept.
- A label in bold right before a figure, as in `<b>Total:</b>12`, keeps its
bold.
- A code fence keeps its language, and a `|` inside code in a table cell no
longer splits the row.
- A long body converts in linear time, and a body nested deeper than the
stack is read as its text instead of raising.

### Known limitations

- A Markdown table needs a header row and the same number of cells in every
row. A header-less HTML table gains an empty header row, and a row that
spans the table gains empty cells.
- Struck-through text is kept as raw `<s>`, since CommonMark has no
strikethrough.
- Literal text that looks like syntax is sometimes escaped where
CommonMark would not need it, for example `5\*3` or `x \<= y`. It
displays as typed.

## 0.5.0 — 2026-09-08

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ python -m sphinx -W --keep-going -b html docs docs/_build/html

## GitHub Actions

- `.github/workflows/ci.yml` runs tests for Python 3.10 through 3.14 on pull
- `.github/workflows/ci.yml` runs tests for Python 3.11 through 3.14 on pull
requests and pushes to `main`.
- The same workflow runs `ruff`, `mypy`, and a warning-free Sphinx build on
Python 3.12.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![CI](https://github.com/baraline/glpi_python_client/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/baraline/glpi_python_client/actions/workflows/ci.yml)
[![Coverage](https://codecov.io/gh/baraline/glpi_python_client/branch/main/graph/badge.svg)](https://codecov.io/gh/baraline/glpi_python_client)
[![License](https://img.shields.io/github/license/baraline/glpi_python_client)](LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://github.com/baraline/glpi_python_client)
[![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://github.com/baraline/glpi_python_client)
[![Docs](https://readthedocs.org/projects/glpi-python-client/badge/?version=latest)](https://glpi-python-client.readthedocs.io/en/latest/)

`glpi-python-client` is a typed Python client for the GLPI REST API.
Expand Down
21 changes: 11 additions & 10 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -113,18 +113,19 @@ conversion happens, which buys two things: listing records costs nothing
per body, and a body that cannot be converted no longer stops the rest of
its page being read.

The Markdown is CommonMark with GFM tables, rendered by cmark-gfm. It
spells a body's text as literal text -- ``\_\_init\_\_``, ``\\\serveur``,
``\# pas un titre`` -- so rendering it displays what GLPI displayed and
reading that back gives the same Markdown. A write model keeps the caller's
Markdown verbatim unless it starts with an HTML tag. See
:ref:`content-conversion`.

Very deeply nested HTML is the case worth knowing about.
``markdownify`` walks the document recursively and runs out of stack at
around 494 levels of nesting. The converter does not try to predict
that: it attempts the conversion and, if the walk does not fit, strips
tags instead. It degrades, it never truncates, and it does not raise:
every character the normal rendering would have produced still appears.
What is lost is structure rather than words — link targets and image alt
text, code fencing and ``<pre>`` indentation, ``&nbsp;`` alignment.
Because the budget is the stack left when the conversion starts, the
same body can convert from one call site and degrade from a deeper one.
Anything else that goes wrong in either direction raises
:class:`GlpiContentError`.
a few hundred levels of nesting. The converter attempts the conversion
and, if the walk does not fit, reads the body's text instead, a line per
block: the words survive, the structure does not. Anything else that goes
wrong in either direction raises :class:`GlpiContentError`.

Because the conversion is cached on first read, a read model should be
treated as immutable afterwards: assigning to ``content_html``, or
Expand Down
6 changes: 1 addition & 5 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@
from datetime import date
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path

try:
from tomllib import loads as toml_loads
except ModuleNotFoundError:
from tomli import loads as toml_loads
from tomllib import loads as toml_loads


def _read_project_version() -> str:
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Installation
Requirements
------------

``glpi-python-client`` supports Python 3.10 and newer. Runtime dependencies are installed
``glpi-python-client`` supports Python 3.11 and newer. Runtime dependencies are installed
from the package metadata and include ``httpx``, ``tenacity``,
``beautifulsoup4``, ``lxml``, and ``pydantic``.

Expand Down
2 changes: 1 addition & 1 deletion docs/publishing_rtd.rst
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ GitHub Actions and Read the Docs
The repository ships with two GitHub Actions workflows:

* ``.github/workflows/ci.yml`` runs on pull requests and pushes to ``main``.
It executes ``pytest`` on Python 3.10 through 3.14, then runs ``ruff``,
It executes ``pytest`` on Python 3.11 through 3.14, then runs ``ruff``,
``mypy``, and the Sphinx build on Python 3.12.
* ``.github/workflows/release.yml`` runs on published GitHub releases. It
repeats the quality checks, builds the source and wheel distributions,
Expand Down
30 changes: 30 additions & 0 deletions docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,36 @@ 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.

The Markdown is CommonMark with GFM tables. Rendering it -- as the package
does on the way back to GLPI, with cmark-gfm -- displays what GLPI
displayed, and reading that rendering back gives the same Markdown. Text in
a body is literal: Markdown has one spelling for ``__init__`` typed by a
user and for bold ``init``, so text that would read as syntax is escaped:

.. code-block:: python

from glpi_python_client.content import GlpiContentConverter

GlpiContentConverter.from_transport(
r"<p>Voir __init__ et \\serveur\compta</p><p># pas un titre</p>"
)
# Voir \_\_init\_\_ et \\\serveur\compta
#
# \# pas un titre

Ordinary prose stays as typed: ``fichier_de_test_v2.xlsx``, ``C:\Temp``,
``R&D``, a ``#`` mid-sentence. The spelling is canonical: a line break reads
back as ``\`` and a newline, a nested list is indented by its bullet's width,
and a table comes back unpadded. A body with no HTML element is plain text
and is read as GLPI shows it, its lines as lines.

Writing, your Markdown is rendered by cmark-gfm. A newline is a line break,
GFM tables work, and raw HTML passes through, so put a placeholder such as
``<login>`` in backticks. A write model keeps your Markdown verbatim unless
it starts with an HTML tag, in which case it is read as HTML. A Markdown
table needs a header row, so a header-less HTML table reads back with an
empty one, and struck-through text stays as raw ``<s>``.

.. note::

Deeply nested HTML is the case worth knowing about. The HTML-to-Markdown
Expand Down
2 changes: 1 addition & 1 deletion glpi_python_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@
date_window,
)

__version__ = "0.5.0"
__version__ = "0.6.0"

__all__ = [
"AsyncGlpiClient",
Expand Down
6 changes: 3 additions & 3 deletions glpi_python_client/_async/auth/_v1_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@

import json
import logging
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import Any, cast

import httpx
Expand Down Expand Up @@ -170,7 +170,7 @@ async def _init_session(self) -> None:
raise GlpiProtocolError("GLPI v1 initSession returned no session_token")

self._session_token = str(token)
self._session_started_at = datetime.now(tz=timezone.utc)
self._session_started_at = datetime.now(tz=UTC)
logger.info("GLPI v1 session initialised.")

async def _ensure_session(self) -> None:
Expand All @@ -196,7 +196,7 @@ def _is_session_stale(self) -> bool:

if self._session_started_at is None:
return True
return datetime.now(tz=timezone.utc) >= (
return datetime.now(tz=UTC) >= (
self._session_started_at + self._session_refresh_interval
)

Expand Down
6 changes: 3 additions & 3 deletions glpi_python_client/_async/auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from __future__ import annotations

import logging
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta

import httpx
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
Expand Down Expand Up @@ -241,7 +241,7 @@ def _store_token_data(
if refresh_token:
self.refresh_token = refresh_token
expires_in = int(str(token_data.get("expires_in") or 3600))
now = datetime.now(tz=timezone.utc)
now = datetime.now(tz=UTC)
self.token_updated_at = now
self.token_expires_at = now + timedelta(seconds=expires_in)
logger.info("GLPI OAuth token %s successfully.", label)
Expand Down Expand Up @@ -399,7 +399,7 @@ async def ensure_token(self) -> None:
await self._acquire_token()
return

now = datetime.now(tz=timezone.utc)
now = datetime.now(tz=UTC)
token_expired = (
self.token_expires_at is not None and now >= self.token_expires_at
)
Expand Down
12 changes: 6 additions & 6 deletions glpi_python_client/_async/auth/tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import cast

import httpx
Expand Down Expand Up @@ -125,8 +125,8 @@ async def test_token_manager_refreshes_when_configured_interval_elapses() -> Non
)
auth.access_token = "old-token"
auth.refresh_token = "refresh-token"
auth.token_updated_at = datetime.now(tz=timezone.utc) - timedelta(seconds=61)
auth.token_expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=1)
auth.token_updated_at = datetime.now(tz=UTC) - timedelta(seconds=61)
auth.token_expires_at = datetime.now(tz=UTC) + timedelta(hours=1)

await auth.ensure_token()

Expand Down Expand Up @@ -166,7 +166,7 @@ def test_token_manager_logout_clears_cached_tokens() -> None:
)
auth.access_token = "access-token"
auth.refresh_token = "refresh-token"
auth.token_updated_at = datetime.now(tz=timezone.utc)
auth.token_updated_at = datetime.now(tz=UTC)
auth.token_expires_at = auth.token_updated_at + timedelta(hours=1)

auth.logout()
Expand Down Expand Up @@ -282,8 +282,8 @@ def _make_refresh_ready_manager(
)
manager.access_token = "stale-token"
manager.refresh_token = "refresh-token"
manager.token_updated_at = datetime.now(tz=timezone.utc) - timedelta(hours=2)
manager.token_expires_at = datetime.now(tz=timezone.utc) - timedelta(seconds=1)
manager.token_updated_at = datetime.now(tz=UTC) - timedelta(hours=2)
manager.token_expires_at = datetime.now(tz=UTC) - timedelta(seconds=1)
return manager


Expand Down
8 changes: 1 addition & 7 deletions glpi_python_client/_async/clients/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,7 @@

import logging
import os
import sys
from typing import TYPE_CHECKING

if sys.version_info >= (3, 11):
from typing import Self
else: # pragma: no cover - fallback for Python 3.10
from typing_extensions import Self
from typing import TYPE_CHECKING, Self

from glpi_python_client._async._concurrency import Lock
from glpi_python_client._async.clients.commons._config import (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,10 @@ def _pin_token(client: Any) -> None:
helper runs through it, so the token has to be supplied here instead.
"""

from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta

client._auth.access_token = "stub-token"
client._auth.token_expires_at = datetime.now(tz=timezone.utc) + timedelta(days=1)
client._auth.token_expires_at = datetime.now(tz=UTC) + timedelta(days=1)


async def test_stream_document_content_yields_chunks(client: Any) -> None:
Expand Down
Loading
Loading