pulp-engine 0.85.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.
- pulp_engine/__init__.py +48 -0
- pulp_engine/_http.py +230 -0
- pulp_engine/client.py +96 -0
- pulp_engine/errors.py +142 -0
- pulp_engine/pagination.py +51 -0
- pulp_engine/py.typed +0 -0
- pulp_engine/resources/__init__.py +25 -0
- pulp_engine/resources/admin.py +71 -0
- pulp_engine/resources/assets.py +68 -0
- pulp_engine/resources/audit_events.py +62 -0
- pulp_engine/resources/auth.py +26 -0
- pulp_engine/resources/batch.py +146 -0
- pulp_engine/resources/health.py +23 -0
- pulp_engine/resources/pdf_transform.py +89 -0
- pulp_engine/resources/render.py +309 -0
- pulp_engine/resources/schedules.py +95 -0
- pulp_engine/resources/templates.py +161 -0
- pulp_engine/types.py +394 -0
- pulp_engine-0.85.0.dist-info/METADATA +217 -0
- pulp_engine-0.85.0.dist-info/RECORD +21 -0
- pulp_engine-0.85.0.dist-info/WHEEL +4 -0
pulp_engine/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Pulp Engine Python SDK — typed client for the Pulp Engine document generation API.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
from pulp_engine import PulpEngineClient
|
|
6
|
+
|
|
7
|
+
with PulpEngineClient(
|
|
8
|
+
base_url="https://pulp-engine.example.com", api_key="dk_admin_..."
|
|
9
|
+
) as client:
|
|
10
|
+
result = client.render.pdf("invoice", {"amount": 100})
|
|
11
|
+
result.save("invoice.pdf")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .client import PulpEngineClient
|
|
15
|
+
from .errors import PulpEngineError, PulpEngineTimeoutError, ValidationIssue
|
|
16
|
+
from .pagination import PaginatedResult, paginate
|
|
17
|
+
from .resources.render import BinaryResult, DryRunFormat, PptxResult
|
|
18
|
+
from .types import (
|
|
19
|
+
DryRunExpressionError,
|
|
20
|
+
DryRunExpressionLocation,
|
|
21
|
+
DryRunExpressionsSection,
|
|
22
|
+
DryRunExpressionSuggestion,
|
|
23
|
+
DryRunResult,
|
|
24
|
+
DryRunValidationError,
|
|
25
|
+
DryRunValidationSection,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = "0.60.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"PulpEngineClient",
|
|
32
|
+
"PulpEngineError",
|
|
33
|
+
"PulpEngineTimeoutError",
|
|
34
|
+
"ValidationIssue",
|
|
35
|
+
"PaginatedResult",
|
|
36
|
+
"paginate",
|
|
37
|
+
"BinaryResult",
|
|
38
|
+
"PptxResult",
|
|
39
|
+
"DryRunFormat",
|
|
40
|
+
"DryRunResult",
|
|
41
|
+
"DryRunExpressionError",
|
|
42
|
+
"DryRunExpressionLocation",
|
|
43
|
+
"DryRunExpressionSuggestion",
|
|
44
|
+
"DryRunValidationError",
|
|
45
|
+
"DryRunValidationSection",
|
|
46
|
+
"DryRunExpressionsSection",
|
|
47
|
+
"__version__",
|
|
48
|
+
]
|
pulp_engine/_http.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Internal HTTP wrapper around httpx with auth header injection and error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .errors import build_error
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HttpClient:
|
|
13
|
+
"""Thin wrapper around ``httpx.Client`` that handles auth headers and error mapping.
|
|
14
|
+
|
|
15
|
+
Used internally by all resource classes. Not part of the public API.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
base_url: str,
|
|
22
|
+
api_key: str | None = None,
|
|
23
|
+
editor_token: str | None = None,
|
|
24
|
+
timeout: float = 60.0,
|
|
25
|
+
transport: httpx.BaseTransport | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._base_url = base_url.rstrip("/")
|
|
28
|
+
self._headers: dict[str, str] = {}
|
|
29
|
+
if api_key:
|
|
30
|
+
self._headers["X-Api-Key"] = api_key
|
|
31
|
+
elif editor_token:
|
|
32
|
+
self._headers["X-Editor-Token"] = editor_token
|
|
33
|
+
|
|
34
|
+
client_kwargs: dict[str, Any] = {
|
|
35
|
+
"base_url": self._base_url,
|
|
36
|
+
"headers": self._headers,
|
|
37
|
+
"timeout": timeout,
|
|
38
|
+
}
|
|
39
|
+
if transport is not None:
|
|
40
|
+
client_kwargs["transport"] = transport
|
|
41
|
+
|
|
42
|
+
self._client = httpx.Client(**client_kwargs)
|
|
43
|
+
|
|
44
|
+
def set_api_key(self, api_key: str) -> None:
|
|
45
|
+
"""Switch to API key authentication at runtime."""
|
|
46
|
+
self._headers.pop("X-Editor-Token", None)
|
|
47
|
+
self._headers["X-Api-Key"] = api_key
|
|
48
|
+
self._client.headers["X-Api-Key"] = api_key
|
|
49
|
+
self._client.headers.pop("X-Editor-Token", None)
|
|
50
|
+
|
|
51
|
+
def set_editor_token(self, token: str) -> None:
|
|
52
|
+
"""Switch to editor token authentication at runtime."""
|
|
53
|
+
self._headers.pop("X-Api-Key", None)
|
|
54
|
+
self._headers["X-Editor-Token"] = token
|
|
55
|
+
self._client.headers["X-Editor-Token"] = token
|
|
56
|
+
self._client.headers.pop("X-Api-Key", None)
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
self._client.close()
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> HttpClient:
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def __exit__(self, *_args: Any) -> None:
|
|
65
|
+
self.close()
|
|
66
|
+
|
|
67
|
+
# ── Request helpers ───────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def request_json(
|
|
70
|
+
self,
|
|
71
|
+
method: str,
|
|
72
|
+
path: str,
|
|
73
|
+
*,
|
|
74
|
+
params: dict[str, Any] | None = None,
|
|
75
|
+
json: Any = None,
|
|
76
|
+
headers: dict[str, str] | None = None,
|
|
77
|
+
) -> Any:
|
|
78
|
+
"""Issue an HTTP request and parse the response as JSON.
|
|
79
|
+
|
|
80
|
+
Raises ``PulpEngineError`` on non-2xx responses.
|
|
81
|
+
"""
|
|
82
|
+
response = self._client.request(
|
|
83
|
+
method,
|
|
84
|
+
path,
|
|
85
|
+
params=_clean_params(params),
|
|
86
|
+
json=json,
|
|
87
|
+
headers=headers,
|
|
88
|
+
)
|
|
89
|
+
if not response.is_success:
|
|
90
|
+
raise build_error(response.status_code, _parse_error_body(response))
|
|
91
|
+
if response.status_code == 204 or not response.content:
|
|
92
|
+
return None
|
|
93
|
+
return response.json()
|
|
94
|
+
|
|
95
|
+
def request_bytes(
|
|
96
|
+
self,
|
|
97
|
+
method: str,
|
|
98
|
+
path: str,
|
|
99
|
+
*,
|
|
100
|
+
params: dict[str, Any] | None = None,
|
|
101
|
+
json: Any = None,
|
|
102
|
+
) -> tuple[bytes, httpx.Headers]:
|
|
103
|
+
"""Issue an HTTP request and return the raw response body as bytes.
|
|
104
|
+
|
|
105
|
+
Returns ``(body, response_headers)`` so callers can read headers like
|
|
106
|
+
``Content-Disposition`` or ``X-Render-Warnings``.
|
|
107
|
+
"""
|
|
108
|
+
response = self._client.request(
|
|
109
|
+
method,
|
|
110
|
+
path,
|
|
111
|
+
params=_clean_params(params),
|
|
112
|
+
json=json,
|
|
113
|
+
)
|
|
114
|
+
if not response.is_success:
|
|
115
|
+
raise build_error(response.status_code, _parse_error_body(response))
|
|
116
|
+
return response.content, response.headers
|
|
117
|
+
|
|
118
|
+
def request_text(
|
|
119
|
+
self,
|
|
120
|
+
method: str,
|
|
121
|
+
path: str,
|
|
122
|
+
*,
|
|
123
|
+
params: dict[str, Any] | None = None,
|
|
124
|
+
json: Any = None,
|
|
125
|
+
) -> str:
|
|
126
|
+
"""Issue an HTTP request and return the response body as a string."""
|
|
127
|
+
response = self._client.request(
|
|
128
|
+
method,
|
|
129
|
+
path,
|
|
130
|
+
params=_clean_params(params),
|
|
131
|
+
json=json,
|
|
132
|
+
)
|
|
133
|
+
if not response.is_success:
|
|
134
|
+
raise build_error(response.status_code, _parse_error_body(response))
|
|
135
|
+
return response.text
|
|
136
|
+
|
|
137
|
+
def stream_bytes(
|
|
138
|
+
self,
|
|
139
|
+
method: str,
|
|
140
|
+
path: str,
|
|
141
|
+
*,
|
|
142
|
+
params: dict[str, Any] | None = None,
|
|
143
|
+
json: Any = None,
|
|
144
|
+
headers: dict[str, str] | None = None,
|
|
145
|
+
) -> Any:
|
|
146
|
+
"""Issue an HTTP request and return a context manager yielding the raw
|
|
147
|
+
streaming response.
|
|
148
|
+
|
|
149
|
+
The caller iterates ``response.iter_bytes()`` (or ``iter_lines()``)
|
|
150
|
+
inside the ``with`` block. Used by streaming endpoints so large bodies
|
|
151
|
+
are not buffered in memory.
|
|
152
|
+
|
|
153
|
+
Error responses are decoded eagerly and raised before the caller
|
|
154
|
+
receives the context manager; the stream is only entered on 2xx.
|
|
155
|
+
|
|
156
|
+
The optional ``headers`` argument merges per-request headers on top of
|
|
157
|
+
the client's base auth headers — used by NDJSON streaming endpoints to
|
|
158
|
+
pass ``Accept: application/x-ndjson``.
|
|
159
|
+
"""
|
|
160
|
+
response = self._client.stream(
|
|
161
|
+
method,
|
|
162
|
+
path,
|
|
163
|
+
params=_clean_params(params),
|
|
164
|
+
json=json,
|
|
165
|
+
headers=headers,
|
|
166
|
+
)
|
|
167
|
+
# httpx.Client.stream returns a context manager; we need to enter it,
|
|
168
|
+
# check for a non-2xx, and either re-raise or hand the live stream back
|
|
169
|
+
# to the caller. Because closing the context aborts the request, we
|
|
170
|
+
# return a thin wrapper that closes only on __exit__.
|
|
171
|
+
return _StreamContext(response, self)
|
|
172
|
+
|
|
173
|
+
def request_multipart(
|
|
174
|
+
self,
|
|
175
|
+
method: str,
|
|
176
|
+
path: str,
|
|
177
|
+
*,
|
|
178
|
+
files: dict[str, Any],
|
|
179
|
+
) -> Any:
|
|
180
|
+
"""Issue a multipart/form-data request and parse the JSON response."""
|
|
181
|
+
response = self._client.request(method, path, files=files)
|
|
182
|
+
if not response.is_success:
|
|
183
|
+
raise build_error(response.status_code, _parse_error_body(response))
|
|
184
|
+
return response.json()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _StreamContext:
|
|
188
|
+
"""Context manager returned by :meth:`HttpClient.stream_bytes`.
|
|
189
|
+
|
|
190
|
+
On ``__enter__`` the underlying httpx streaming request is opened and the
|
|
191
|
+
status code is checked. Non-2xx responses raise ``PulpEngineError`` eagerly
|
|
192
|
+
(the error body is read in full). On success, ``iter_bytes()`` /
|
|
193
|
+
``iter_raw()`` on the returned ``httpx.Response`` yield chunks as they
|
|
194
|
+
arrive. ``__exit__`` closes the underlying connection — always call from a
|
|
195
|
+
``with`` block or ``close()`` explicitly, otherwise the connection leaks.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, cm: Any, http: HttpClient) -> None:
|
|
199
|
+
self._cm = cm
|
|
200
|
+
self._http = http
|
|
201
|
+
self._response: Any = None
|
|
202
|
+
|
|
203
|
+
def __enter__(self) -> Any:
|
|
204
|
+
response = self._cm.__enter__()
|
|
205
|
+
if not response.is_success:
|
|
206
|
+
# Read the error body before exiting the stream context.
|
|
207
|
+
response.read()
|
|
208
|
+
error = build_error(response.status_code, _parse_error_body(response))
|
|
209
|
+
self._cm.__exit__(None, None, None)
|
|
210
|
+
raise error
|
|
211
|
+
self._response = response
|
|
212
|
+
return response
|
|
213
|
+
|
|
214
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
215
|
+
self._cm.__exit__(exc_type, exc, tb)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _clean_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
219
|
+
"""Strip ``None`` values from query params so they aren't sent as ``?key=None``."""
|
|
220
|
+
if params is None:
|
|
221
|
+
return None
|
|
222
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_error_body(response: httpx.Response) -> Any:
|
|
226
|
+
"""Try to parse the error response body as JSON, falling back to text."""
|
|
227
|
+
try:
|
|
228
|
+
return response.json()
|
|
229
|
+
except Exception:
|
|
230
|
+
return response.text or None
|
pulp_engine/client.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""PulpEngineClient — main entry point for the Pulp Engine Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._http import HttpClient
|
|
10
|
+
from .resources import (
|
|
11
|
+
AdminResource,
|
|
12
|
+
AssetsResource,
|
|
13
|
+
AuditEventsResource,
|
|
14
|
+
AuthResource,
|
|
15
|
+
BatchResource,
|
|
16
|
+
HealthResource,
|
|
17
|
+
PdfTransformResource,
|
|
18
|
+
RenderResource,
|
|
19
|
+
SchedulesResource,
|
|
20
|
+
TemplatesResource,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PulpEngineClient:
|
|
25
|
+
"""Typed Python client for the Pulp Engine API.
|
|
26
|
+
|
|
27
|
+
Mirrors the resource layout of the TypeScript SDK 1:1, with snake_case
|
|
28
|
+
method names. Supports both API key auth (``X-Api-Key`` header) and
|
|
29
|
+
editor session token auth (``X-Editor-Token`` header).
|
|
30
|
+
|
|
31
|
+
Example::
|
|
32
|
+
|
|
33
|
+
from pulp_engine import PulpEngineClient
|
|
34
|
+
|
|
35
|
+
with PulpEngineClient(
|
|
36
|
+
base_url="https://pulp-engine.example.com", api_key="dk_admin_..."
|
|
37
|
+
) as client:
|
|
38
|
+
templates = client.templates.list()
|
|
39
|
+
result = client.render.pdf("invoice", {"amount": 100})
|
|
40
|
+
result.save("invoice.pdf")
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
base_url: Base URL of the Pulp Engine API.
|
|
44
|
+
api_key: Scoped API key. Sent as ``X-Api-Key`` header.
|
|
45
|
+
editor_token: Editor session token. Mutually exclusive with ``api_key``.
|
|
46
|
+
timeout: Per-request timeout in seconds. Defaults to 60s.
|
|
47
|
+
transport: Optional custom httpx transport (useful for testing with
|
|
48
|
+
``httpx.MockTransport``).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
base_url: str,
|
|
55
|
+
api_key: str | None = None,
|
|
56
|
+
editor_token: str | None = None,
|
|
57
|
+
timeout: float = 60.0,
|
|
58
|
+
transport: httpx.BaseTransport | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._http = HttpClient(
|
|
61
|
+
base_url=base_url,
|
|
62
|
+
api_key=api_key,
|
|
63
|
+
editor_token=editor_token,
|
|
64
|
+
timeout=timeout,
|
|
65
|
+
transport=transport,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self.templates = TemplatesResource(self._http)
|
|
69
|
+
self.render = RenderResource(self._http)
|
|
70
|
+
self.batch = BatchResource(self._http)
|
|
71
|
+
self.pdf_transform = PdfTransformResource(self._http)
|
|
72
|
+
self.assets = AssetsResource(self._http)
|
|
73
|
+
self.audit_events = AuditEventsResource(self._http)
|
|
74
|
+
self.admin = AdminResource(self._http)
|
|
75
|
+
self.auth = AuthResource(self._http)
|
|
76
|
+
self.health = HealthResource(self._http)
|
|
77
|
+
self.schedules = SchedulesResource(self._http)
|
|
78
|
+
|
|
79
|
+
def set_api_key(self, api_key: str) -> None:
|
|
80
|
+
"""Switch to API key authentication at runtime."""
|
|
81
|
+
self._http.set_api_key(api_key)
|
|
82
|
+
|
|
83
|
+
def set_editor_token(self, token: str) -> None:
|
|
84
|
+
"""Switch to editor token authentication at runtime (e.g. after calling
|
|
85
|
+
``client.auth.editor_token()``)."""
|
|
86
|
+
self._http.set_editor_token(token)
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
"""Close the underlying HTTP client and release connections."""
|
|
90
|
+
self._http.close()
|
|
91
|
+
|
|
92
|
+
def __enter__(self) -> PulpEngineClient:
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def __exit__(self, *_args: Any) -> None:
|
|
96
|
+
self.close()
|
pulp_engine/errors.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Structured error raised by all SDK methods on non-2xx responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ValidationIssue:
|
|
11
|
+
"""A single field-level validation issue from a 400/422 response."""
|
|
12
|
+
|
|
13
|
+
path: str
|
|
14
|
+
message: str
|
|
15
|
+
code: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PulpEngineError(Exception):
|
|
19
|
+
"""Base error raised by all SDK methods when the API returns a non-2xx response.
|
|
20
|
+
|
|
21
|
+
Mirrors the TypeScript SDK's ``PulpEngineError``. Preserves the full error
|
|
22
|
+
shape from the API, including the ``code`` discriminant from
|
|
23
|
+
render/validation errors and the ``request_id`` (v0.78.0+) for correlating
|
|
24
|
+
client errors with server log entries.
|
|
25
|
+
|
|
26
|
+
Consumers can catch all SDK errors (including subclasses like
|
|
27
|
+
:class:`PulpEngineTimeoutError`) with a single ``except PulpEngineError:``.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
status: HTTP status code (e.g. 400, 401, 404, 422). ``0`` for client-side
|
|
31
|
+
errors like timeouts where no HTTP response was received.
|
|
32
|
+
error: API error type string (e.g. ``"RenderError"``, ``"ValidationError"``).
|
|
33
|
+
code: Machine-readable error code from the canonical registry (e.g.
|
|
34
|
+
``"template_expression_error"``, ``"render_timeout"``,
|
|
35
|
+
``"asset_blocked"``, ``"not_found"``). Required on every
|
|
36
|
+
v0.78.0+ envelope; ``None`` only for unstructured bodies (e.g.
|
|
37
|
+
upstream LB intercept) or pre-v0.78.0 servers.
|
|
38
|
+
request_id: Server-generated request id (UUID). Mirrors the
|
|
39
|
+
``X-Request-ID`` response header — pass this when filing a
|
|
40
|
+
support ticket and the server log entry can be located. ``None``
|
|
41
|
+
for unstructured bodies and pre-v0.78.0 servers.
|
|
42
|
+
issues: Field-level validation issues. ``None`` for non-validation errors.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
status: int
|
|
46
|
+
error: str
|
|
47
|
+
code: str | None
|
|
48
|
+
request_id: str | None
|
|
49
|
+
issues: list[ValidationIssue] | None
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
status: int,
|
|
55
|
+
error: str,
|
|
56
|
+
message: str,
|
|
57
|
+
code: str | None = None,
|
|
58
|
+
request_id: str | None = None,
|
|
59
|
+
issues: list[ValidationIssue] | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.status = status
|
|
63
|
+
self.error = error
|
|
64
|
+
self.code = code
|
|
65
|
+
self.request_id = request_id
|
|
66
|
+
self.issues = issues
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
return (
|
|
70
|
+
f"{type(self).__name__}(status={self.status}, error={self.error!r}, "
|
|
71
|
+
f"code={self.code!r}, request_id={self.request_id!r}, "
|
|
72
|
+
f"message={str(self)!r})"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PulpEngineTimeoutError(PulpEngineError):
|
|
77
|
+
"""Raised when a client-side wait operation exceeds its timeout.
|
|
78
|
+
|
|
79
|
+
Currently raised by :meth:`BatchResource.wait_for_job` when an async batch
|
|
80
|
+
job does not reach a terminal state within the configured timeout. This is
|
|
81
|
+
a client-side timeout — the server may still complete the job after this
|
|
82
|
+
error is raised.
|
|
83
|
+
|
|
84
|
+
Subclass of :class:`PulpEngineError` so consumers can catch all SDK errors
|
|
85
|
+
with a single ``except PulpEngineError:``.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, message: str) -> None:
|
|
89
|
+
super().__init__(
|
|
90
|
+
status=0,
|
|
91
|
+
error="ClientTimeout",
|
|
92
|
+
message=message,
|
|
93
|
+
code="client_timeout",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_error(status: int, body: Any) -> PulpEngineError:
|
|
98
|
+
"""Build a ``PulpEngineError`` from an HTTP status and parsed JSON body.
|
|
99
|
+
|
|
100
|
+
Handles all four API error shapes (ErrorResponse, ValidationErrorResponse,
|
|
101
|
+
RateLimitErrorResponse, RenderErrorResponse) and falls back to a generic
|
|
102
|
+
shape for unstructured bodies. The v0.78.0+ ``code`` and ``requestId``
|
|
103
|
+
fields are surfaced as ``code`` and ``request_id`` respectively (Python
|
|
104
|
+
snake_case convention; the wire field stays ``requestId``).
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(body, dict):
|
|
107
|
+
error_str = body.get("error") if isinstance(body.get("error"), str) else "UnknownError"
|
|
108
|
+
message = body.get("message") if isinstance(body.get("message"), str) else f"HTTP {status}"
|
|
109
|
+
code = body.get("code") if isinstance(body.get("code"), str) else None
|
|
110
|
+
request_id = body.get("requestId") if isinstance(body.get("requestId"), str) else None
|
|
111
|
+
|
|
112
|
+
issues_raw = body.get("issues")
|
|
113
|
+
issues: list[ValidationIssue] | None = None
|
|
114
|
+
if isinstance(issues_raw, list):
|
|
115
|
+
issues = []
|
|
116
|
+
for item in issues_raw:
|
|
117
|
+
if isinstance(item, dict):
|
|
118
|
+
issues.append(
|
|
119
|
+
ValidationIssue(
|
|
120
|
+
path=str(item.get("path", "")),
|
|
121
|
+
message=str(item.get("message", "")),
|
|
122
|
+
code=item.get("code") if isinstance(item.get("code"), str) else None,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return PulpEngineError(
|
|
127
|
+
status=status,
|
|
128
|
+
error=error_str or "UnknownError",
|
|
129
|
+
message=message or f"HTTP {status}",
|
|
130
|
+
code=code,
|
|
131
|
+
request_id=request_id,
|
|
132
|
+
issues=issues,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return PulpEngineError(
|
|
136
|
+
status=status,
|
|
137
|
+
error="UnknownError",
|
|
138
|
+
message=f"HTTP {status}",
|
|
139
|
+
code=None,
|
|
140
|
+
request_id=None,
|
|
141
|
+
issues=None,
|
|
142
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Pagination helpers mirroring the TS SDK's PaginatedResult envelope."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class PaginatedResult(Generic[T]):
|
|
14
|
+
"""Standard paginated envelope returned by all list endpoints."""
|
|
15
|
+
|
|
16
|
+
items: list[T]
|
|
17
|
+
total: int
|
|
18
|
+
limit: int
|
|
19
|
+
offset: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def paginate(
|
|
23
|
+
fetch_page: Callable[[int, int], PaginatedResult[T]],
|
|
24
|
+
*,
|
|
25
|
+
limit: int = 50,
|
|
26
|
+
) -> Iterator[T]:
|
|
27
|
+
"""Auto-paginate through all pages of a list endpoint.
|
|
28
|
+
|
|
29
|
+
Yields individual items, fetching the next page when the current one is
|
|
30
|
+
exhausted.
|
|
31
|
+
|
|
32
|
+
The ``fetch_page`` callback receives ``(limit, offset)`` in that order, so
|
|
33
|
+
SDK list methods (which take ``limit, offset`` as keyword args) can be
|
|
34
|
+
passed directly without re-routing.
|
|
35
|
+
|
|
36
|
+
Example::
|
|
37
|
+
|
|
38
|
+
from pulp_engine import paginate
|
|
39
|
+
|
|
40
|
+
for template in paginate(
|
|
41
|
+
lambda limit, offset: client.templates.list(limit=limit, offset=offset),
|
|
42
|
+
):
|
|
43
|
+
print(template.key)
|
|
44
|
+
"""
|
|
45
|
+
offset = 0
|
|
46
|
+
while True:
|
|
47
|
+
page = fetch_page(limit, offset)
|
|
48
|
+
yield from page.items
|
|
49
|
+
offset += limit
|
|
50
|
+
if offset >= page.total:
|
|
51
|
+
break
|
pulp_engine/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Resource modules — one per API surface area, mirroring the TypeScript SDK 1:1."""
|
|
2
|
+
|
|
3
|
+
from .admin import AdminResource
|
|
4
|
+
from .assets import AssetsResource
|
|
5
|
+
from .audit_events import AuditEventsResource
|
|
6
|
+
from .auth import AuthResource
|
|
7
|
+
from .batch import BatchResource
|
|
8
|
+
from .health import HealthResource
|
|
9
|
+
from .pdf_transform import PdfTransformResource
|
|
10
|
+
from .render import RenderResource
|
|
11
|
+
from .schedules import SchedulesResource
|
|
12
|
+
from .templates import TemplatesResource
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AdminResource",
|
|
16
|
+
"AssetsResource",
|
|
17
|
+
"AuditEventsResource",
|
|
18
|
+
"AuthResource",
|
|
19
|
+
"BatchResource",
|
|
20
|
+
"HealthResource",
|
|
21
|
+
"PdfTransformResource",
|
|
22
|
+
"RenderResource",
|
|
23
|
+
"SchedulesResource",
|
|
24
|
+
"TemplatesResource",
|
|
25
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Admin resource — named user management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from .._http import HttpClient
|
|
8
|
+
from ..types import UserRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AdminResource:
|
|
12
|
+
"""Admin operations for named user management."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http: HttpClient) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
|
|
17
|
+
def list_users(self) -> list[UserRecord]:
|
|
18
|
+
"""List all named users. Admin only."""
|
|
19
|
+
body = self._http.request_json("GET", "/admin/users/")
|
|
20
|
+
return [UserRecord.model_validate(u) for u in body["users"]]
|
|
21
|
+
|
|
22
|
+
def create_user(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
id: str,
|
|
26
|
+
display_name: str,
|
|
27
|
+
key: str,
|
|
28
|
+
role: Literal["editor", "admin"],
|
|
29
|
+
) -> UserRecord:
|
|
30
|
+
"""Create a new named user. Admin only."""
|
|
31
|
+
body = self._http.request_json(
|
|
32
|
+
"POST",
|
|
33
|
+
"/admin/users/",
|
|
34
|
+
json={"id": id, "displayName": display_name, "key": key, "role": role},
|
|
35
|
+
)
|
|
36
|
+
return UserRecord.model_validate(body)
|
|
37
|
+
|
|
38
|
+
def update_user(
|
|
39
|
+
self,
|
|
40
|
+
id: str,
|
|
41
|
+
*,
|
|
42
|
+
display_name: str | None = None,
|
|
43
|
+
key: str | None = None,
|
|
44
|
+
role: Literal["editor", "admin"] | None = None,
|
|
45
|
+
) -> UserRecord:
|
|
46
|
+
"""Update an existing named user. Admin only."""
|
|
47
|
+
updates: dict[str, str] = {}
|
|
48
|
+
if display_name is not None:
|
|
49
|
+
updates["displayName"] = display_name
|
|
50
|
+
if key is not None:
|
|
51
|
+
updates["key"] = key
|
|
52
|
+
if role is not None:
|
|
53
|
+
updates["role"] = role
|
|
54
|
+
body = self._http.request_json("PUT", f"/admin/users/{id}", json=updates)
|
|
55
|
+
return UserRecord.model_validate(body)
|
|
56
|
+
|
|
57
|
+
def delete_user(self, id: str) -> dict[str, int | bool]:
|
|
58
|
+
"""Delete a named user. Admin only.
|
|
59
|
+
|
|
60
|
+
Returns ``{"deleted": bool, "registrySize": int}``.
|
|
61
|
+
"""
|
|
62
|
+
body = self._http.request_json("DELETE", f"/admin/users/{id}")
|
|
63
|
+
return body # type: ignore[no-any-return]
|
|
64
|
+
|
|
65
|
+
def reload_users(self) -> dict[str, int | bool]:
|
|
66
|
+
"""Reload the user registry from the file source. Admin only.
|
|
67
|
+
|
|
68
|
+
Returns ``{"reloaded": bool, "count": int}``.
|
|
69
|
+
"""
|
|
70
|
+
body = self._http.request_json("POST", "/admin/users/reload")
|
|
71
|
+
return body # type: ignore[no-any-return]
|