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.
@@ -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]