trueppm-mcp 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ """trueppm-mcp — read-only Model Context Protocol server for self-hosted TruePPM.
2
+
3
+ A thin protocol adapter that talks to TruePPM only over HTTP (ADR-0186): it never
4
+ imports Django, never touches the ORM or database, and never imports from the
5
+ proprietary enterprise repo. RBAC is enforced once, at the API layer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib.metadata import PackageNotFoundError
11
+ from importlib.metadata import version as _pkg_version
12
+
13
+ from trueppm_mcp.client import (
14
+ ApiError,
15
+ AuthError,
16
+ NonAgentTokenError,
17
+ RateLimitError,
18
+ TruePPMClient,
19
+ )
20
+ from trueppm_mcp.config import ConfigError, Settings
21
+ from trueppm_mcp.server import build_server
22
+
23
+ try:
24
+ # Single source of truth: the version is whatever pip installed (set from
25
+ # pyproject.toml at build time), matching trueppm_scheduler's pattern —
26
+ # so this can't freeze at a stale literal the way trueppm_api's did.
27
+ __version__ = _pkg_version("trueppm-mcp")
28
+ except PackageNotFoundError: # running from an un-installed source tree
29
+ __version__ = "0.0.0+unknown"
30
+
31
+ __all__ = [
32
+ "ApiError",
33
+ "AuthError",
34
+ "ConfigError",
35
+ "NonAgentTokenError",
36
+ "RateLimitError",
37
+ "Settings",
38
+ "TruePPMClient",
39
+ "__version__",
40
+ "build_server",
41
+ ]
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m trueppm_mcp``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from trueppm_mcp.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
trueppm_mcp/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """Command-line entry point for the TruePPM MCP server.
2
+
3
+ ``trueppm-mcp`` (or ``python -m trueppm_mcp``) boots the server. stdio is the
4
+ primary transport — an AI client spawns this process and speaks MCP over the
5
+ pipe. ``--transport http`` (or ``sse``) runs a network transport for web-based
6
+ assistants.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+ from typing import Literal
15
+
16
+ from trueppm_mcp.client import TruePPMClient
17
+ from trueppm_mcp.config import ConfigError, Settings
18
+ from trueppm_mcp.server import (
19
+ ENV_ALLOWED_HOSTS,
20
+ build_server,
21
+ normalize_allowed_host,
22
+ parse_allowed_hosts,
23
+ )
24
+
25
+ #: Map our CLI transport names onto FastMCP's literal transport identifiers.
26
+ _TRANSPORTS: dict[str, Literal["stdio", "sse", "streamable-http"]] = {
27
+ "stdio": "stdio",
28
+ "sse": "sse",
29
+ "http": "streamable-http",
30
+ }
31
+
32
+
33
+ def _allowed_host_arg(value: str) -> str:
34
+ try:
35
+ return normalize_allowed_host(value)
36
+ except ValueError as exc:
37
+ raise argparse.ArgumentTypeError(str(exc)) from exc
38
+
39
+
40
+ def _build_parser() -> argparse.ArgumentParser:
41
+ parser = argparse.ArgumentParser(
42
+ prog="trueppm-mcp",
43
+ description="Read-only MCP server for self-hosted TruePPM.",
44
+ )
45
+ parser.add_argument(
46
+ "--transport",
47
+ choices=sorted(_TRANSPORTS),
48
+ default="stdio",
49
+ help="MCP transport to serve (default: stdio).",
50
+ )
51
+ parser.add_argument(
52
+ "--host",
53
+ default="127.0.0.1",
54
+ help="Bind host for the http/sse transports (default: 127.0.0.1).",
55
+ )
56
+ parser.add_argument(
57
+ "--port",
58
+ type=int,
59
+ default=8000,
60
+ help="Bind port for the http/sse transports (default: 8000).",
61
+ )
62
+ parser.add_argument(
63
+ "--allowed-host",
64
+ action="append",
65
+ type=_allowed_host_arg,
66
+ default=[],
67
+ metavar="HOST",
68
+ help=(
69
+ "Extra Host name to accept on the http/sse transports, e.g. a "
70
+ "reverse-proxy hostname (repeatable). Also read, comma-separated, from "
71
+ f"{ENV_ALLOWED_HOSTS}. Loopback names are always accepted."
72
+ ),
73
+ )
74
+ return parser
75
+
76
+
77
+ def main(argv: list[str] | None = None) -> int:
78
+ """Parse arguments, build the server, and run the chosen transport.
79
+
80
+ Returns a process exit code: ``0`` on a clean shutdown, ``2`` on a
81
+ configuration error (so a misconfigured client gets an actionable message on
82
+ stderr rather than a traceback). Authentication is verified by the server
83
+ lifespan at startup.
84
+ """
85
+ args = _build_parser().parse_args(argv)
86
+
87
+ try:
88
+ settings = Settings.from_env()
89
+ except ConfigError as exc:
90
+ print(f"Configuration error: {exc}", file=sys.stderr)
91
+ return 2
92
+
93
+ try:
94
+ env_hosts = parse_allowed_hosts(os.environ.get(ENV_ALLOWED_HOSTS))
95
+ except ValueError as exc:
96
+ print(f"Configuration error: {ENV_ALLOWED_HOSTS}: {exc}", file=sys.stderr)
97
+ return 2
98
+ # dict.fromkeys: de-duplicate while keeping env-then-flag order.
99
+ allowed_hosts = list(dict.fromkeys([*env_hosts, *args.allowed_host]))
100
+ client = TruePPMClient(settings)
101
+ server = build_server(client, host=args.host, port=args.port, allowed_hosts=allowed_hosts)
102
+ server.run(transport=_TRANSPORTS[args.transport])
103
+ return 0
104
+
105
+
106
+ if __name__ == "__main__": # pragma: no cover
107
+ raise SystemExit(main())
trueppm_mcp/client.py ADDED
@@ -0,0 +1,486 @@
1
+ """Async HTTP client for the TruePPM REST API.
2
+
3
+ The MCP server is *only* an HTTP client of the public REST API (ADR-0186 §C/§I):
4
+ it never imports Django, never touches the ORM or the database, and never imports
5
+ from the proprietary enterprise repo. Authorization — RBAC role gates,
6
+ member-scoped querysets, the 404-vs-403 existence oracle — is enforced once, at
7
+ the API layer, identically for this client and the web client. This module holds
8
+ no privileged path and is not the security boundary.
9
+
10
+ The bearer token is set as a request header and is never logged.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import email.utils
16
+ from collections.abc import Mapping
17
+ from dataclasses import dataclass
18
+ from datetime import UTC, datetime
19
+ from types import TracebackType
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from trueppm_mcp.config import Settings
25
+
26
+ #: Connection-verification endpoint (relative to ``/api/v1/``). Returns the
27
+ #: identity of the token's owner; a 401 means the token does not authenticate.
28
+ AUTH_VERIFY_PATH = "auth/me/"
29
+
30
+ #: Per-request timeout (seconds). Read tools are simple GETs; a slow API should
31
+ #: surface as a clear error rather than hang the AI client indefinitely.
32
+ DEFAULT_TIMEOUT = 30.0
33
+
34
+ #: Cap on the number of rows a paginated read tool accumulates before it stops
35
+ #: following ``next`` and reports the result as truncated. The API paginates at
36
+ #: ``PAGE_SIZE`` (50) rows/page, so this follows up to ~20 pages. It bounds the
37
+ #: LLM context a single ``list_*`` tool can spend: a well-behaved list returns in
38
+ #: full, and a pathologically large one is capped with an explicit ``truncated``
39
+ #: signal rather than silently cut at the first page (#1731).
40
+ DEFAULT_MAX_ROWS = 1000
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Refusal:
45
+ """Why the API refused a call, as the API itself described it (#2689).
46
+
47
+ The API records a two-axis refusal taxonomy on every refused agent call and,
48
+ since ADR-0809, returns it in the 401/403 body. Before that, an operator saw a
49
+ bare HTTP status here and had to make a second, separate call to
50
+ ``GET /agent-actions/?constraint=…`` to learn the reason — for the persona this
51
+ server exists to serve, a system that fails without saying why is the worst
52
+ outcome.
53
+
54
+ Attributes:
55
+ verdict: Always ``"refused"`` today; carried so a caller can branch on one
56
+ field across the whole taxonomy rather than inferring it.
57
+ reason: The coarse axis — ``"identity"`` (the credential was rejected) or
58
+ ``"policy"`` (the credential is fine, a guard denied the call).
59
+ constraint: The finer axis — which specific guard fired, e.g.
60
+ ``"token_identity"`` or ``"capability_scope"``. Empty when the API
61
+ withheld it: the constraint vocabulary includes schedule-shaped codes
62
+ that could name a resource the caller may not read, so the API
63
+ discloses only from an allow-list. Absence is not an error.
64
+ """
65
+
66
+ verdict: str
67
+ reason: str
68
+ constraint: str = ""
69
+
70
+ def describe(self) -> str:
71
+ """One line an operator can act on, e.g. ``"policy refusal (capability_scope)"``."""
72
+
73
+ if self.constraint:
74
+ return f"{self.reason} refusal ({self.constraint})"
75
+ return f"{self.reason} refusal"
76
+
77
+
78
+ class TruePPMError(RuntimeError):
79
+ """Base for API-call failures, carrying the structured refusal when present."""
80
+
81
+ def __init__(self, message: str, refusal: Refusal | None = None) -> None:
82
+ super().__init__(message)
83
+ #: The parsed refusal taxonomy, or ``None`` when the API sent none — an
84
+ #: older server, or a failure that is not a governed refusal (a 404, a
85
+ #: 5xx). Callers must treat ``None`` as "no reason given", never as an
86
+ #: error in itself.
87
+ self.refusal = refusal
88
+
89
+
90
+ class ApiError(TruePPMError):
91
+ """Raised when the API returns an unexpected (non-401) error status."""
92
+
93
+
94
+ class AuthError(TruePPMError):
95
+ """Raised when the API rejects the configured bearer token (HTTP 401)."""
96
+
97
+
98
+ class NonAgentTokenError(TruePPMError):
99
+ """Raised when the configured token authenticates (HTTP 200) but is not an
100
+ agent credential (#2938).
101
+
102
+ Distinct from :class:`AuthError`: the API did not reject the token — a
103
+ ``legacy:full`` personal access token authenticates fine and always will,
104
+ because restraining it would restrain the person who owns it (#2877). What
105
+ this error catches is the *honest mistake*: someone pasted their own PAT into
106
+ ``TRUEPPM_API_TOKEN`` instead of minting an ``mcp:read`` token. Left
107
+ unchecked, the server boots successfully on a credential the operator's
108
+ instance kill switch and the team's agent opt-out do not govern — a silent
109
+ bypass by configuration, not by attack. Raised by
110
+ :meth:`TruePPMClient.verify_auth`, before the server lifespan yields, so it
111
+ aborts the boot the same way a bad token does rather than letting every tool
112
+ call succeed unprotected.
113
+ """
114
+
115
+
116
+ class RateLimitError(ApiError):
117
+ """Raised when the API rate-limits the call (HTTP 429) — #2924.
118
+
119
+ A subclass of :class:`ApiError` so existing ``except ApiError`` handlers keep
120
+ working, but distinguishable for anything that wants to wait and retry.
121
+
122
+ This matters because it is not a rare edge: ``MCP_TOKEN_COMPUTE_RATE`` is
123
+ **12/min** and covers ``whatif``, ``monte-carlo/latest``, ``forecast`` and
124
+ ``sprint-forecast`` — exactly the tools an agent exercises in a burst while
125
+ exploring a schedule. Folding that into a generic "Unexpected response …
126
+ HTTP 429." told the model a sentence it could not act on: it had no way to
127
+ know the call would succeed shortly, so it reported a nonspecific failure.
128
+
129
+ Attributes:
130
+ retry_after: Seconds to wait, parsed from the ``Retry-After`` header, or
131
+ ``None`` when the header was absent or unparseable. ``None`` means
132
+ "no hint given" — never treat it as "do not retry".
133
+ """
134
+
135
+ def __init__(
136
+ self,
137
+ message: str,
138
+ refusal: Refusal | None = None,
139
+ retry_after: float | None = None,
140
+ ) -> None:
141
+ super().__init__(message, refusal)
142
+ self.retry_after = retry_after
143
+
144
+
145
+ def _http_date_delta(raw: str, date_header: str | None) -> float | None:
146
+ """Seconds between an HTTP-date ``Retry-After`` and "now", or ``None`` if unusable.
147
+
148
+ "Now" is the response's own ``Date`` header when it sent a usable one, so client
149
+ clock skew cannot turn into a wildly wrong wait; local time is the fallback.
150
+
151
+ Total by design, and that is the contract callers depend on: every failure path
152
+ returns ``None`` and nothing escapes. ``parsedate_to_datetime`` *raises* on an
153
+ unparseable value rather than returning ``None``, so a junk header would
154
+ otherwise crash the 429 path that is already handling a failure.
155
+ """
156
+
157
+ try:
158
+ parsed = email.utils.parsedate_to_datetime(raw)
159
+ except (TypeError, ValueError):
160
+ return None
161
+ if parsed is None: # pragma: no cover — defensive across versions
162
+ return None
163
+ now = None
164
+ if date_header:
165
+ # Same trap as above — a malformed Date header raises rather than
166
+ # returning None, and it must not crash the 429 path either.
167
+ try:
168
+ now = email.utils.parsedate_to_datetime(date_header)
169
+ except (TypeError, ValueError):
170
+ now = None
171
+ if now is None:
172
+ now = datetime.now(UTC)
173
+ try:
174
+ return (parsed - now).total_seconds()
175
+ except (TypeError, ValueError):
176
+ return None
177
+
178
+
179
+ def parse_retry_after(response: httpx.Response) -> float | None:
180
+ """Seconds to wait from a ``Retry-After`` header, or ``None`` if unusable.
181
+
182
+ RFC 9110 allows two forms and DRF sends the first, but a reverse proxy or CDN
183
+ in front of the instance may rewrite it to the second, so both are handled:
184
+
185
+ * delta-seconds — a non-negative integer (``Retry-After: 30``)
186
+ * an HTTP-date — absolute (``Retry-After: Wed, 21 Oct 2026 07:28:00 GMT``),
187
+ converted to a delta against the response's own ``Date`` header when it has
188
+ one, and against local time otherwise. Using the server's ``Date`` avoids
189
+ turning client clock skew into a wildly wrong wait.
190
+
191
+ Total by design: anything unparseable, negative, or absurd yields ``None``.
192
+ A malformed header must degrade to "no hint", never crash the call that is
193
+ already failing.
194
+ """
195
+
196
+ raw = response.headers.get("Retry-After")
197
+ if not raw:
198
+ return None
199
+ raw = raw.strip()
200
+ try:
201
+ seconds = float(int(raw))
202
+ except ValueError:
203
+ delta = _http_date_delta(raw, response.headers.get("Date"))
204
+ if delta is None:
205
+ return None
206
+ seconds = delta
207
+ if seconds < 0:
208
+ return None
209
+ return seconds
210
+
211
+
212
+ def parse_refusal(response: httpx.Response) -> Refusal | None:
213
+ """Extract the refusal envelope from an error response, if it carries one.
214
+
215
+ Total by design: a body that is not JSON, not an object, or carries no
216
+ ``refusal`` key yields ``None``. A refusal that cannot be parsed must never
217
+ turn a clean 403 into a client-side crash — the HTTP status is still the
218
+ answer, and the envelope is additional explanation.
219
+ """
220
+
221
+ try:
222
+ body = response.json()
223
+ except ValueError:
224
+ return None
225
+ if not isinstance(body, dict):
226
+ return None
227
+ envelope = body.get("refusal")
228
+ if not isinstance(envelope, dict):
229
+ return None
230
+ reason = envelope.get("reason")
231
+ if not isinstance(reason, str) or not reason:
232
+ return None
233
+ verdict = envelope.get("verdict")
234
+ constraint = envelope.get("constraint")
235
+ return Refusal(
236
+ verdict=verdict if isinstance(verdict, str) else "refused",
237
+ reason=reason,
238
+ constraint=constraint if isinstance(constraint, str) else "",
239
+ )
240
+
241
+
242
+ def _rate_limit_message(path: str, retry_after: float | None) -> str:
243
+ """The one sentence the model acts on: that it is rate-limited, and for how long.
244
+
245
+ The wait is stated explicitly rather than left for the model to infer, because
246
+ "HTTP 429" alone reads as a failure and the correct response — wait a moment
247
+ and repeat the same call — is not derivable from it.
248
+ """
249
+
250
+ if retry_after is None:
251
+ return (
252
+ f"Rate limited by the TruePPM API on {path} (HTTP 429). No Retry-After "
253
+ "header was sent; wait a few seconds and retry the same call."
254
+ )
255
+ return (
256
+ f"Rate limited by the TruePPM API on {path} (HTTP 429). Retry the same call "
257
+ f"in {retry_after:.0f} second(s)."
258
+ )
259
+
260
+
261
+ def _refusal_suffix(refusal: Refusal | None) -> str:
262
+ """`" — policy refusal (capability_scope)"`, or empty when nothing was sent."""
263
+
264
+ return f" — {refusal.describe()}" if refusal is not None else ""
265
+
266
+
267
+ class TruePPMClient:
268
+ """Thin async wrapper over ``httpx.AsyncClient`` carrying the bearer token.
269
+
270
+ Args:
271
+ settings: Resolved :class:`~trueppm_mcp.config.Settings`.
272
+ transport: Optional ``httpx`` transport. Injected in tests
273
+ (``httpx.MockTransport``); ``None`` uses the real network transport.
274
+ timeout: Per-request timeout in seconds.
275
+ """
276
+
277
+ def __init__(
278
+ self,
279
+ settings: Settings,
280
+ *,
281
+ transport: httpx.AsyncBaseTransport | None = None,
282
+ timeout: float = DEFAULT_TIMEOUT,
283
+ ) -> None:
284
+ # A trailing slash on the base URL makes httpx's RFC-3986 relative join
285
+ # append the path segment instead of replacing the last one, so
286
+ # "auth/me/" resolves to ".../api/v1/auth/me/".
287
+ self._client = httpx.AsyncClient(
288
+ base_url=f"{settings.api_base_url}/",
289
+ headers={
290
+ "Authorization": f"Bearer {settings.token}",
291
+ "Accept": "application/json",
292
+ },
293
+ timeout=timeout,
294
+ transport=transport,
295
+ )
296
+
297
+ async def verify_auth(self) -> dict[str, Any]:
298
+ """Confirm the token authenticates by calling ``GET /auth/me/``.
299
+
300
+ Returns:
301
+ The identity payload (the token owner's user record) on HTTP 200.
302
+
303
+ Raises:
304
+ AuthError: On HTTP 401 — the token is missing, malformed, or revoked.
305
+ ApiError: On any other non-success status.
306
+ NonAgentTokenError: The token authenticates but is not an agent
307
+ credential (#2938) — see that class's docstring.
308
+ """
309
+ response = await self._client.get(AUTH_VERIFY_PATH)
310
+ if response.status_code == httpx.codes.UNAUTHORIZED:
311
+ # No token material in the message — only the fact of rejection, plus
312
+ # whatever reason the API chose to disclose.
313
+ refusal = parse_refusal(response)
314
+ raise AuthError(
315
+ "The TruePPM API rejected the configured token (HTTP 401)"
316
+ f"{_refusal_suffix(refusal)}.",
317
+ refusal,
318
+ )
319
+ if response.status_code == httpx.codes.TOO_MANY_REQUESTS:
320
+ retry_after = parse_retry_after(response)
321
+ raise RateLimitError(
322
+ _rate_limit_message(AUTH_VERIFY_PATH, retry_after),
323
+ parse_refusal(response),
324
+ retry_after,
325
+ )
326
+ if response.is_error:
327
+ refusal = parse_refusal(response)
328
+ raise ApiError(
329
+ f"Unexpected response from {AUTH_VERIFY_PATH}: "
330
+ f"HTTP {response.status_code}{_refusal_suffix(refusal)}.",
331
+ refusal,
332
+ )
333
+ result: dict[str, Any] = response.json()
334
+ posture = result.get("token")
335
+ is_agent = isinstance(posture, dict) and bool(posture.get("is_agent"))
336
+ if not is_agent:
337
+ scopes = posture.get("scopes") if isinstance(posture, dict) else None
338
+ raise NonAgentTokenError(
339
+ "This token is a full-access personal credential"
340
+ f"{f' (scopes: {scopes})' if scopes else ''}, not an agent "
341
+ "credential — the instance kill switch and your team's agent "
342
+ "opt-out will not apply to it. Mint an mcp:read token instead: "
343
+ "Personal Access Tokens → Create token → 'Read-only for AI assistants'."
344
+ )
345
+ return result
346
+
347
+ async def get(self, path: str, params: Mapping[str, Any] | None = None) -> Any:
348
+ """Issue an authenticated ``GET`` and return the decoded JSON body.
349
+
350
+ Every read tool routes through here (ADR-0186 §D/§I): the request is a
351
+ plain ``GET`` against ``/api/v1/<path>`` carrying the bearer token, so
352
+ authorization is enforced once, at the API layer, exactly as for the web
353
+ client. This method holds no privileged path.
354
+
355
+ Args:
356
+ path: Path relative to the ``/api/v1/`` base (e.g. ``"projects/"`` or
357
+ ``"projects/{id}/forecast/"``). No leading slash.
358
+ params: Optional query parameters. Callers omit ``None`` values; a
359
+ filter left unset is simply not sent.
360
+
361
+ Returns:
362
+ The decoded JSON body — a ``dict`` for object endpoints, a ``list``
363
+ or paginated ``dict`` for collection endpoints.
364
+
365
+ Raises:
366
+ AuthError: On HTTP 401 — the token is missing, malformed, or revoked.
367
+ The message never contains token material.
368
+ ApiError: On any other error status (404 for a resource the caller
369
+ cannot read, 5xx, etc.).
370
+ """
371
+ response = await self._client.get(path, params=dict(params) if params else None)
372
+ if response.status_code == httpx.codes.UNAUTHORIZED:
373
+ # No token material in the message — only the fact of rejection, plus
374
+ # whatever reason the API chose to disclose.
375
+ refusal = parse_refusal(response)
376
+ raise AuthError(
377
+ "The TruePPM API rejected the configured token (HTTP 401)"
378
+ f"{_refusal_suffix(refusal)}.",
379
+ refusal,
380
+ )
381
+ if response.status_code == httpx.codes.TOO_MANY_REQUESTS:
382
+ # Not folded into the generic branch below: the compute bucket is
383
+ # 12/min, so this is the routine outcome of an exploratory burst and
384
+ # the model needs a wait hint rather than an opaque status (#2924).
385
+ retry_after = parse_retry_after(response)
386
+ raise RateLimitError(
387
+ _rate_limit_message(path, retry_after),
388
+ parse_refusal(response),
389
+ retry_after,
390
+ )
391
+ if response.is_error:
392
+ refusal = parse_refusal(response)
393
+ raise ApiError(
394
+ f"Unexpected response from {path}: "
395
+ f"HTTP {response.status_code}{_refusal_suffix(refusal)}.",
396
+ refusal,
397
+ )
398
+ return response.json()
399
+
400
+ async def get_paginated(
401
+ self,
402
+ path: str,
403
+ params: Mapping[str, Any] | None = None,
404
+ *,
405
+ max_rows: int = DEFAULT_MAX_ROWS,
406
+ ) -> tuple[list[Any], int, bool]:
407
+ """Follow DRF pagination, accumulating rows up to ``max_rows`` (#1731).
408
+
409
+ The list tools must not silently reason over only the first page: a DRF
410
+ collection returns ``{count, next, previous, results}`` at ``PAGE_SIZE``
411
+ (50) rows/page, so a bare ``results`` read truncates at 50 with no signal.
412
+ This method follows the absolute ``next`` links until it has ``max_rows``
413
+ rows or runs out of pages, and reports whether more rows exist server-side
414
+ so the caller can tell the model the set is partial.
415
+
416
+ Args:
417
+ path: Path relative to the ``/api/v1/`` base (e.g. ``"tasks/"``).
418
+ params: Query parameters for the first page. ``next`` carries its own
419
+ params, so following pages send none of their own.
420
+ max_rows: Stop accumulating once this many rows are collected.
421
+
422
+ Returns:
423
+ ``(rows, total_count, truncated)``:
424
+ * ``rows`` — the accumulated result rows, capped at ``max_rows``.
425
+ * ``total_count`` — the server's reported ``count`` (the true total),
426
+ or ``len(rows)`` for an unpaginated (bare-list) body.
427
+ * ``truncated`` — ``True`` when fewer rows were returned than exist
428
+ server-side (``next`` remained, or the cap was hit).
429
+
430
+ Raises:
431
+ AuthError: On HTTP 401 (see :meth:`get`).
432
+ ApiError: On any other error status (see :meth:`get`).
433
+ """
434
+ payload = await self.get(path, params=params)
435
+ # An endpoint that returns a bare JSON list is unpaginated; the whole body
436
+ # is the row set (still cap it defensively).
437
+ if isinstance(payload, list):
438
+ return payload[:max_rows], len(payload), len(payload) > max_rows
439
+ if not isinstance(payload, Mapping) or "results" not in payload:
440
+ return [], 0, False
441
+
442
+ rows: list[Any] = []
443
+ results = payload.get("results")
444
+ if isinstance(results, list):
445
+ rows.extend(results)
446
+ total_count = payload.get("count")
447
+ next_url = payload.get("next")
448
+ # ``next`` is an absolute URL carrying its own page/query params, so it is
449
+ # passed to get() as the full path with no additional params.
450
+ while isinstance(next_url, str) and next_url and len(rows) < max_rows:
451
+ page = await self.get(next_url)
452
+ if not isinstance(page, Mapping):
453
+ break
454
+ page_results = page.get("results")
455
+ if not isinstance(page_results, list) or not page_results:
456
+ # A well-behaved DRF page never advertises ``next`` past the last
457
+ # populated page. Stop on the first empty/malformed page so a
458
+ # misbehaving server that keeps returning a non-null ``next`` with
459
+ # no rows can't spin this loop — it stays provably bounded.
460
+ break
461
+ rows.extend(page_results)
462
+ next_url = page.get("next")
463
+
464
+ truncated = (isinstance(next_url, str) and bool(next_url)) or len(rows) > max_rows
465
+ rows = rows[:max_rows]
466
+ if not isinstance(total_count, int):
467
+ total_count = len(rows)
468
+ # A count larger than what we returned is truncation even if we exhausted
469
+ # ``next`` (defensive against an inconsistent count/next).
470
+ truncated = truncated or total_count > len(rows)
471
+ return rows, total_count, truncated
472
+
473
+ async def aclose(self) -> None:
474
+ """Close the underlying connection pool."""
475
+ await self._client.aclose()
476
+
477
+ async def __aenter__(self) -> TruePPMClient:
478
+ return self
479
+
480
+ async def __aexit__(
481
+ self,
482
+ exc_type: type[BaseException] | None,
483
+ exc: BaseException | None,
484
+ tb: TracebackType | None,
485
+ ) -> None:
486
+ await self.aclose()