Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7a6c9ba
build: stop shipping unit tests in the wheel and sdist
baraline Jul 28, 2026
5a109af
test: add the generated test-client twins and colocate test_constants
baraline Jul 28, 2026
abccf4f
test: colocate the filter and payload unit tests
baraline Jul 28, 2026
4f67819
test: colocate the HTTP helper unit tests
baraline Jul 28, 2026
b9805b9
test: colocate the transport unit tests and fold in retry semantics
baraline Jul 28, 2026
5695558
fix: make the transport test module docstring surface-neutral
baraline Jul 28, 2026
638dc6b
test: colocate the client configuration unit tests
baraline Jul 28, 2026
0f2751f
test: colocate the client construction and lifecycle unit tests
baraline Jul 28, 2026
b09c906
fix: make the init-failure test docstring surface-neutral
baraline Jul 28, 2026
b542a65
test: colocate the auth and v1-session unit tests
baraline Jul 28, 2026
fdf3936
test: colocate the ticket and team mixin unit tests
baraline Jul 29, 2026
8e23efa
fix: restore the fake_search stub for test_iter_search_tickets_single…
baraline Jul 29, 2026
13df9dc
test: colocate the ticket timeline mixin unit tests
baraline Jul 29, 2026
4e61ba7
test: colocate the user and entity mixin unit tests
baraline Jul 29, 2026
57a7bc4
test: colocate the location and document mixin unit tests
baraline Jul 29, 2026
9ee6b5e
test: colocate the knowledge base mixin unit tests
baraline Jul 29, 2026
458a4cc
test: colocate the fields plugin, statistics and ticket context unit …
baraline Jul 29, 2026
2bd5731
test: make the concurrency suites hand-written twins
baraline Jul 29, 2026
67870f8
fix: strengthen the sync lock-contention test with a real hold
baraline Jul 29, 2026
447304c
test: move the repo-wide suites into glpi_python_client/testing/tests
baraline Jul 29, 2026
d0c3a56
test: make the unasync root guard actually detect a wrong root
baraline Jul 29, 2026
7234f5d
docs: describe the colocated test layout
baraline Jul 29, 2026
17db79a
docs: point the tests package at a file that ships
baraline Jul 29, 2026
60dc8d3
chore: retire the test-name migration guard
baraline Jul 29, 2026
21d2392
fix: close the gaps the whole-branch review found
baraline Jul 29, 2026
c2441d3
fix: correct the generated tree's docstrings and close four review gaps
baraline Jul 30, 2026
f758dae
test: measure coverage on the async tree, and drive it as hard as the…
baraline Jul 30, 2026
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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,25 @@ jobs:
- name: Check the generated sync tree is up to date
run: python unasync_build.py --check

# The unit suite lives inside the package directory, so a lost
# exclusion pattern silently republishes it. Assert the built wheel,
# not just the config that is supposed to produce it.
- name: Build the wheel and assert it ships no tests
run: |
python -m build --wheel --outdir dist_check .
python - <<'PY'
import glob, sys, zipfile

wheel = sorted(glob.glob("dist_check/*.whl"))[-1]
names = zipfile.ZipFile(wheel).namelist()
shipped = [n for n in names if "/tests/" in n or n.endswith("conftest.py")]
if shipped:
sys.exit("wheel ships test files:\n " + "\n ".join(shipped))
if "glpi_python_client/testing/utils.py" not in names:
sys.exit("wheel is missing the documented testing helpers")
print(f"wheel is clean: {len(names)} entries, no tests")
PY

- name: Build documentation
run: python -m sphinx -W --keep-going -b html docs docs/_build/html

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- **The unit test suite was published inside the wheel and the sdist.** Both
artefacts carried 56 test modules, so every install shipped the project's
own tests into the consuming environment. `[tool.hatch.build] exclude` now
drops `tests/` and `conftest.py`, taking the wheel from 180 entries to 126
and the sdist from 204 to 150. Nothing a downstream consumer imports was
removed: `glpi_python_client.testing` — the documented factories, fixtures
and fake responses — still ships in full, and the package now imports
cleanly in an environment with no `pytest` installed.

- **A `Major` priority ticket made the whole search fail.** GLPI's priority
scale has six levels; the published contract advertises five, and
`GlpiPriority` followed the contract. Since `GetTicket.priority` is typed
Expand Down
41 changes: 28 additions & 13 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ python -m pytest
ticket descriptions, followups, tasks, and solutions.
- `glpi_python_client.testing` exposes `make_client` and
`make_async_client` factories that produce in-memory clients with no
real HTTP plumbing for downstream test suites.
real HTTP plumbing for downstream test suites, plus the shared
`DEFAULT_CLIENT_CONFIG` and the fake response classes. Its `tests/`
subpackage holds this repository's own cross-cutting suites.
- `docs` contains the Read the Docs/Sphinx documentation source.
- `skills` contains contributor-facing Agent Skills for repository
workflows. The source distribution includes them for source consumers
Expand All @@ -111,20 +113,33 @@ python -m pytest
pagination logic in the focused
`glpi_python_client._async.clients.commons` helper module named for that
responsibility.
4. Add unit tests under `glpi_python_client/tests/**` for payload
serialization, response parsing, and client behaviour. They exercise
the generated sync client, which shares its statements with the async
one.

Two suites cover what that cannot reach.
`glpi_python_client/tests/test_unasync_codegen.py` holds the
4. Add unit tests in the `tests/` package beside the module you changed —
`_async/clients/api/assistance/tests/test_ticket.py` for a change to
`_async/clients/api/assistance/_ticket.py`. Write them as `async def`,
then run `python unasync_build.py` and commit both trees. The
generated twins exercise the sync client, so one source covers both
surfaces.

Import the client factory from `glpi_python_client._async._testing`,
not from `glpi_python_client.testing`: the former is generated, so it
returns an `AsyncGlpiClient` in the source and a `GlpiClient` in the
twin. The latter is published API and always returns what its name
says.

Any stub replacing an awaited internal must be a named `async def`, not
a `lambda` — unasync cannot rewrite a lambda into a coroutine function.

Two suites cover what colocated tests cannot reach.
`glpi_python_client/testing/tests/test_unasync_codegen.py` holds the
invariants the CI diff gate is blind to — a token collision is
deterministic, so regeneration reproduces it and the diff stays clean.
`glpi_python_client/tests/test_async_surface.py` runs the async client
for real: that a method actually awaits, that contending tasks do not
deadlock on the auth lock, and that `gather` genuinely overlaps its
arguments. Add to it whenever a change is only observable once
`async`/`await` are real.
`glpi_python_client/_async/tests/test_concurrency.py` and its `_sync`
twin cover what codegen cannot: that a method actually awaits, that
contending tasks do not deadlock on the auth lock, that `gather`
genuinely overlaps its arguments on the async side, and that the sync
twin's own lock excludes and its sequential `gather` preserves order.
Add to it whenever a change is only observable once `async`/`await`
are real.
5. Document the new workflow in `docs/user_guide.rst` or the README.

Keep organization-specific defaults outside the package core.
Expand Down
146 changes: 146 additions & 0 deletions glpi_python_client/_async/_testing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""In-memory client factory and transport stubs for this tree's unit tests.

One of a generated pair: the module is written once and
``unasync_build.py`` transforms it into its twin in the other tree.
``AsyncGlpiClient`` is already a substitution key, so each copy returns
the client of the tree it lands in while the factory stays spelled
``make_client`` on both sides -- call sites read identically wherever
they are. The same holds for the two recorder classes: each replaces a
client's four transport helpers with stubs that capture the call and hand
back a fixed response, so a mixin test can assert the endpoint, verb and
serialised body without any HTTP plumbing.

It lives beside the tree rather than in ``glpi_python_client.testing``
because that module is a published downstream helper whose factories must
keep their current names; this one has to change its return type when the
tree is transformed.
"""

from __future__ import annotations

from typing import Any

from glpi_python_client import AsyncGlpiClient
from glpi_python_client.testing.utils import DEFAULT_CLIENT_CONFIG, FakeResponse

__all__ = ["FailingTransportRecorder", "TransportRecorder", "make_client"]


def make_client(**overrides: object) -> AsyncGlpiClient:
"""Return a test client with no real HTTP plumbing.

Any constructor keyword can be overridden while the rest of the shared
base configuration is reused.
"""

config = dict(DEFAULT_CLIENT_CONFIG)
config.update(overrides)
return AsyncGlpiClient(**config) # type: ignore[arg-type]


class TransportRecorder:
"""Capture the four transport calls and answer with fixed responses.

Installed over the client's request helpers so a mixin test can assert
the endpoint, verb and serialised body without any HTTP plumbing.
"""

def __init__(
self,
*,
get_payload: Any = None,
get_status: int = 200,
get_content: bytes | None = None,
post_payload: Any = None,
post_status: int = 201,
patch_status: int = 204,
delete_status: int = 204,
) -> None:
self.calls: list[dict[str, Any]] = []
self._get_payload = get_payload if get_payload is not None else []
self._get_status = get_status
self._get_content = get_content
self._post_payload = post_payload if post_payload is not None else {"id": 999}
self._post_status = post_status
self._patch_status = patch_status
self._delete_status = delete_status

def install(self, client: AsyncGlpiClient) -> None:
"""Replace the four transport helpers with capturing stubs."""

async def _get(
endpoint: str,
params: dict[str, Any] | None = None,
skip_entity: bool = False,
) -> FakeResponse:
self.calls.append(
{
"method": "GET",
"endpoint": endpoint,
"params": params,
"skip_entity": skip_entity,
}
)
return FakeResponse(
status_code=self._get_status,
payload=self._get_payload,
content=self._get_content,
)

async def _post(
endpoint: str,
json_body: dict[str, Any] | None = None,
skip_entity: bool = False,
) -> FakeResponse:
self.calls.append(
{
"method": "POST",
"endpoint": endpoint,
"json": json_body,
"skip_entity": skip_entity,
}
)
return FakeResponse(
status_code=self._post_status, payload=self._post_payload
)

async def _patch(
endpoint: str, json_body: dict[str, Any] | None = None
) -> FakeResponse:
self.calls.append(
{"method": "PATCH", "endpoint": endpoint, "json": json_body}
)
return FakeResponse(status_code=self._patch_status, payload={})

async def _delete(
endpoint: str,
json_body: dict[str, Any] | None = None,
skip_entity: bool = False,
) -> FakeResponse:
self.calls.append(
{
"method": "DELETE",
"endpoint": endpoint,
"json": json_body,
"skip_entity": skip_entity,
}
)
return FakeResponse(status_code=self._delete_status, payload={})

client._get_request = _get # type: ignore[method-assign, assignment]
client._post_request = _post # type: ignore[method-assign, assignment]
client._update_request = _patch # type: ignore[method-assign, assignment]
client._delete_request = _delete # type: ignore[method-assign, assignment]


class FailingTransportRecorder(TransportRecorder):
"""Answer every verb with one fixed non-success status."""

def __init__(self, status: int) -> None:
super().__init__(
get_status=status,
get_payload={"err": "x"},
post_status=status,
patch_status=status,
delete_status=status,
)
5 changes: 3 additions & 2 deletions glpi_python_client/_async/auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,9 @@ class GLPITokenManager:
password : str | None, optional
Password for the password grant flow. Provide it together with
``username``.
session : httpx.AsyncClient | None, optional
Existing HTTP client to reuse.
session : httpx.Client | httpx.AsyncClient | None, optional
Existing HTTP client to reuse. Each surface takes the httpx client
that matches it; the signature names the one in force here.
auth_token_refresh : int | None, optional
Maximum token age in seconds before a refresh is attempted. ``None``
disables interval-based refreshes.
Expand Down
1 change: 1 addition & 0 deletions glpi_python_client/_async/auth/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Unit tests for OAuth token management and the legacy v1 session."""
Loading
Loading