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,309 @@
1
+ """Render resource — PDF, HTML, CSV, XLSX, DOCX, PPTX."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ from .._http import HttpClient
11
+ from ..types import DryRunResult
12
+
13
+ # Format selector for RenderResource.dry_run() — selects which per-format
14
+ # route to hit. The result shape is identical regardless of format; the
15
+ # selector only affects which route is queried (matters when different
16
+ # formats have different rate limits or feature gates server-side).
17
+ DryRunFormat = Literal["pdf", "html", "csv", "xlsx", "docx", "pptx"]
18
+
19
+ _DRY_RUN_PATHS: dict[str, str] = {
20
+ "pdf": "/render/pdf",
21
+ "html": "/render/html",
22
+ "csv": "/render/csv",
23
+ "xlsx": "/render/xlsx",
24
+ "docx": "/render/docx",
25
+ "pptx": "/render/pptx",
26
+ }
27
+
28
+
29
+ @dataclass
30
+ class BinaryResult:
31
+ """Raw binary render result with response metadata.
32
+
33
+ Provides a ``save()`` convenience method for writing the bytes to a file.
34
+ """
35
+
36
+ data: bytes
37
+ content_type: str | None
38
+ content_disposition: str | None
39
+
40
+ def save(self, path: str | Path) -> Path:
41
+ """Write the binary data to ``path`` and return the resolved path."""
42
+ out = Path(path)
43
+ out.parent.mkdir(parents=True, exist_ok=True)
44
+ out.write_bytes(self.data)
45
+ return out
46
+
47
+
48
+ @dataclass
49
+ class PptxResult(BinaryResult):
50
+ """PPTX render result with structured warning count.
51
+
52
+ ``warning_count`` is parsed from the ``X-Render-Warnings: count=N`` header.
53
+ Common warning sources: known-dimension overflow, mixed-mark bullet collapse
54
+ in richText, totalPages marker drop in header/footer.
55
+ """
56
+
57
+ warning_count: int = 0
58
+
59
+
60
+ def _build_render_body(
61
+ template: str,
62
+ data: dict[str, Any],
63
+ *,
64
+ version: str | None,
65
+ options: dict[str, Any] | None,
66
+ ) -> dict[str, Any]:
67
+ body: dict[str, Any] = {"template": template, "data": data}
68
+ if version is not None:
69
+ body["version"] = version
70
+ if options is not None:
71
+ body["options"] = options
72
+ return body
73
+
74
+
75
+ def _parse_warning_count(header_value: str | None) -> int:
76
+ if not header_value:
77
+ return 0
78
+ match = re.search(r"count=(\d+)", header_value)
79
+ if not match:
80
+ return 0
81
+ try:
82
+ return int(match.group(1))
83
+ except ValueError:
84
+ return 0
85
+
86
+
87
+ class RenderResource:
88
+ """Render templates to various output formats."""
89
+
90
+ def __init__(self, http: HttpClient) -> None:
91
+ self._http = http
92
+
93
+ def pdf(
94
+ self,
95
+ template: str,
96
+ data: dict[str, Any],
97
+ *,
98
+ version: str | None = None,
99
+ options: dict[str, Any] | None = None,
100
+ ) -> BinaryResult:
101
+ """Render a template to PDF. Returns raw binary."""
102
+ body = _build_render_body(template, data, version=version, options=options)
103
+ content, headers = self._http.request_bytes("POST", "/render/pdf", json=body)
104
+ return BinaryResult(
105
+ data=content,
106
+ content_type=headers.get("content-type"),
107
+ content_disposition=headers.get("content-disposition"),
108
+ )
109
+
110
+ def pdf_stream(
111
+ self,
112
+ template: str,
113
+ data: dict[str, Any],
114
+ *,
115
+ version: str | None = None,
116
+ options: dict[str, Any] | None = None,
117
+ stream: Literal["auto", "force", "off"] = "auto",
118
+ ) -> Any:
119
+ """Render a template to PDF and stream the response body.
120
+
121
+ Returns a context manager yielding an ``httpx.Response``; iterate
122
+ ``response.iter_bytes()`` inside the ``with`` block to consume chunks
123
+ as they arrive.
124
+
125
+ ``stream="force"`` passes ``?stream=true`` so incompatible post-render
126
+ hook registrations surface as a 400 error up front instead of silently
127
+ falling back to buffered. ``stream="off"`` passes ``?stream=false`` to
128
+ force a buffered response (useful behind proxies that mangle chunked
129
+ encoding). ``stream="auto"`` (default) lets the server choose based on
130
+ whether hooks are registered.
131
+
132
+ Error contract: the streamed body may be truncated on mid-stream
133
+ failure. ``httpx`` raises a transport error when that happens; clients
134
+ must treat the bytes received before the error as possibly-incomplete
135
+ rather than relying on PDF EOF-sniffing.
136
+
137
+ Example::
138
+
139
+ with client.render.pdf_stream("big-report", data) as response:
140
+ with open("out.pdf", "wb") as f:
141
+ for chunk in response.iter_bytes():
142
+ f.write(chunk)
143
+ """
144
+ body = _build_render_body(template, data, version=version, options=options)
145
+ params: dict[str, Any] = {}
146
+ if stream == "force":
147
+ params["stream"] = "true"
148
+ elif stream == "off":
149
+ params["stream"] = "false"
150
+ return self._http.stream_bytes("POST", "/render/pdf", json=body, params=params or None)
151
+
152
+ def html(
153
+ self,
154
+ template: str,
155
+ data: dict[str, Any],
156
+ *,
157
+ version: str | None = None,
158
+ options: dict[str, Any] | None = None,
159
+ ) -> str:
160
+ """Render a template to HTML. Returns the HTML string."""
161
+ body = _build_render_body(template, data, version=version, options=options)
162
+ return self._http.request_text("POST", "/render/html", json=body)
163
+
164
+ def csv(
165
+ self,
166
+ template: str,
167
+ data: dict[str, Any],
168
+ *,
169
+ version: str | None = None,
170
+ options: dict[str, Any] | None = None,
171
+ ) -> BinaryResult:
172
+ """Render a template to CSV. Returns raw binary."""
173
+ body = _build_render_body(template, data, version=version, options=options)
174
+ content, headers = self._http.request_bytes("POST", "/render/csv", json=body)
175
+ return BinaryResult(
176
+ data=content,
177
+ content_type=headers.get("content-type"),
178
+ content_disposition=headers.get("content-disposition"),
179
+ )
180
+
181
+ def xlsx(
182
+ self,
183
+ template: str,
184
+ data: dict[str, Any],
185
+ *,
186
+ version: str | None = None,
187
+ options: dict[str, Any] | None = None,
188
+ ) -> BinaryResult:
189
+ """Render a template to XLSX. Returns raw binary."""
190
+ body = _build_render_body(template, data, version=version, options=options)
191
+ content, headers = self._http.request_bytes("POST", "/render/xlsx", json=body)
192
+ return BinaryResult(
193
+ data=content,
194
+ content_type=headers.get("content-type"),
195
+ content_disposition=headers.get("content-disposition"),
196
+ )
197
+
198
+ def docx(
199
+ self,
200
+ template: str,
201
+ data: dict[str, Any],
202
+ *,
203
+ version: str | None = None,
204
+ options: dict[str, Any] | None = None,
205
+ ) -> BinaryResult:
206
+ """Render a template to DOCX. Returns raw binary."""
207
+ body = _build_render_body(template, data, version=version, options=options)
208
+ content, headers = self._http.request_bytes("POST", "/render/docx", json=body)
209
+ return BinaryResult(
210
+ data=content,
211
+ content_type=headers.get("content-type"),
212
+ content_disposition=headers.get("content-disposition"),
213
+ )
214
+
215
+ def pptx(
216
+ self,
217
+ template: str,
218
+ data: dict[str, Any],
219
+ *,
220
+ version: str | None = None,
221
+ options: dict[str, Any] | None = None,
222
+ ) -> PptxResult:
223
+ """Render a template to PPTX. Returns raw binary plus warning count.
224
+
225
+ Beta in v0.60.0. The route is gated server-side by ``PPTX_ENABLED`` —
226
+ when disabled, calls return 404.
227
+ """
228
+ body = _build_render_body(template, data, version=version, options=options)
229
+ content, headers = self._http.request_bytes("POST", "/render/pptx", json=body)
230
+ return PptxResult(
231
+ data=content,
232
+ content_type=headers.get("content-type"),
233
+ content_disposition=headers.get("content-disposition"),
234
+ warning_count=_parse_warning_count(headers.get("x-render-warnings")),
235
+ )
236
+
237
+ def dry_run(
238
+ self,
239
+ template: str,
240
+ data: dict[str, Any],
241
+ *,
242
+ format: DryRunFormat = "pdf",
243
+ version: str | None = None,
244
+ options: dict[str, Any] | None = None,
245
+ ) -> DryRunResult:
246
+ """Dry-run a render: validate input data and exercise all template
247
+ expressions via a trial HTML render, then return structured results
248
+ without producing any binary output. Skips Chromium / DOCX / PPTX
249
+ entirely. Useful for CI/CD pre-flight checks.
250
+
251
+ Hits the same per-format route as the normal render method
252
+ (``/render/``, ``/render/html``, etc.) with ``dryRun: true`` in the
253
+ body. The route returns JSON regardless of its normal content type.
254
+
255
+ Unlike the normal render methods, this method does NOT raise on
256
+ template author errors (validation failures, undefined variables,
257
+ etc.) — those are surfaced in the result so callers can display them
258
+ to users. The only way this method raises is if the request itself
259
+ is malformed (network error, 4xx auth error, etc.) — in which case
260
+ :class:`PulpEngineError` is raised as usual.
261
+
262
+ Args:
263
+ template: Template key to dry-run.
264
+ data: Input data payload.
265
+ format: Which format route to hit. Defaults to ``"pdf"``. The
266
+ resulting :class:`DryRunResult` shape is identical regardless
267
+ of format — the format selector only affects which route is
268
+ queried (matters when different formats have different rate
269
+ limits or feature gates server-side).
270
+ version: Optional exact saved template version to dry-run.
271
+ options: Optional render options (paperSize, orientation).
272
+
273
+ Returns:
274
+ A :class:`DryRunResult` with ``valid``, ``validation``,
275
+ ``expressions``, ``field_mappings_applied``, and
276
+ ``data_sources_resolved`` fields.
277
+
278
+ Example::
279
+
280
+ result = client.render.dry_run("invoice", {"amount": 100})
281
+ if not result.valid:
282
+ for err in result.expressions.errors:
283
+ print(err.message, err.location.node_path if err.location else "")
284
+ """
285
+ path = _DRY_RUN_PATHS[format]
286
+ body: dict[str, Any] = {"template": template, "data": data, "dryRun": True}
287
+ if version is not None:
288
+ body["version"] = version
289
+ if options is not None:
290
+ body["options"] = options
291
+ response = self._http.request_json("POST", path, json=body)
292
+ return DryRunResult.model_validate(response)
293
+
294
+ def validate(
295
+ self,
296
+ template: str,
297
+ data: dict[str, Any],
298
+ *,
299
+ version: str | None = None,
300
+ ) -> dict[str, Any]:
301
+ """Validate a template for publication readiness.
302
+
303
+ Returns a dict with ``valid: bool`` and ``issues: list``.
304
+ """
305
+ body: dict[str, Any] = {"template": template, "data": data}
306
+ if version is not None:
307
+ body["version"] = version
308
+ result = self._http.request_json("POST", "/render/validate", json=body)
309
+ return result # type: ignore[no-any-return]
@@ -0,0 +1,95 @@
1
+ """Schedules resource — scheduled render jobs and execution history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .._http import HttpClient
8
+ from ..pagination import PaginatedResult
9
+ from ..types import Schedule, ScheduleExecution
10
+
11
+
12
+ class SchedulesResource:
13
+ """Scheduled render jobs and execution history."""
14
+
15
+ def __init__(self, http: HttpClient) -> None:
16
+ self._http = http
17
+
18
+ def list(
19
+ self,
20
+ *,
21
+ limit: int | None = None,
22
+ offset: int | None = None,
23
+ enabled: bool | None = None,
24
+ ) -> PaginatedResult[Schedule]:
25
+ """List schedule definitions (paginated). Admin only."""
26
+ body = self._http.request_json(
27
+ "GET",
28
+ "/schedules/",
29
+ params={
30
+ "limit": limit,
31
+ "offset": offset,
32
+ "enabled": "true" if enabled is True else "false" if enabled is False else None,
33
+ },
34
+ )
35
+ return PaginatedResult(
36
+ items=[Schedule.model_validate(item) for item in body["items"]],
37
+ total=body["total"],
38
+ limit=body["limit"],
39
+ offset=body["offset"],
40
+ )
41
+
42
+ def get(self, id: str) -> Schedule:
43
+ """Get a schedule by ID. Admin only."""
44
+ body = self._http.request_json("GET", f"/schedules/{id}")
45
+ return Schedule.model_validate(body)
46
+
47
+ def create(self, schedule: dict[str, Any]) -> Schedule:
48
+ """Create a new schedule. Admin only."""
49
+ body = self._http.request_json("POST", "/schedules/", json=schedule)
50
+ return Schedule.model_validate(body)
51
+
52
+ def update(self, id: str, schedule: dict[str, Any]) -> Schedule:
53
+ """Full-replace a schedule (PUT). Admin only."""
54
+ body = self._http.request_json("PUT", f"/schedules/{id}", json=schedule)
55
+ return Schedule.model_validate(body)
56
+
57
+ def patch(self, id: str, patch: dict[str, Any]) -> Schedule:
58
+ """Partially update a schedule (PATCH). Admin only."""
59
+ body = self._http.request_json("PATCH", f"/schedules/{id}", json=patch)
60
+ return Schedule.model_validate(body)
61
+
62
+ def delete(self, id: str) -> None:
63
+ """Delete a schedule. Admin only."""
64
+ self._http.request_json("DELETE", f"/schedules/{id}")
65
+
66
+ def trigger(self, id: str) -> ScheduleExecution:
67
+ """Manually trigger a schedule execution. Admin only."""
68
+ body = self._http.request_json("POST", f"/schedules/{id}/trigger")
69
+ return ScheduleExecution.model_validate(body)
70
+
71
+ def list_executions(
72
+ self,
73
+ id: str,
74
+ *,
75
+ limit: int | None = None,
76
+ offset: int | None = None,
77
+ status: str | None = None,
78
+ ) -> PaginatedResult[ScheduleExecution]:
79
+ """List execution history for a schedule (paginated). Admin only."""
80
+ body = self._http.request_json(
81
+ "GET",
82
+ f"/schedules/{id}/executions",
83
+ params={"limit": limit, "offset": offset, "status": status},
84
+ )
85
+ return PaginatedResult(
86
+ items=[ScheduleExecution.model_validate(item) for item in body["items"]],
87
+ total=body["total"],
88
+ limit=body["limit"],
89
+ offset=body["offset"],
90
+ )
91
+
92
+ def get_execution(self, id: str, exec_id: str) -> ScheduleExecution:
93
+ """Get a specific execution. Admin only."""
94
+ body = self._http.request_json("GET", f"/schedules/{id}/executions/{exec_id}")
95
+ return ScheduleExecution.model_validate(body)
@@ -0,0 +1,161 @@
1
+ """Templates resource — CRUD, versioning, schema, validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .._http import HttpClient
8
+ from ..pagination import PaginatedResult
9
+ from ..types import (
10
+ TemplateDiff,
11
+ TemplateSummary,
12
+ TemplateWithDefinition,
13
+ ValidationResult,
14
+ VersionSummary,
15
+ )
16
+
17
+
18
+ class TemplatesResource:
19
+ """Template CRUD and version management."""
20
+
21
+ def __init__(self, http: HttpClient) -> None:
22
+ self._http = http
23
+
24
+ def list(
25
+ self,
26
+ *,
27
+ limit: int | None = None,
28
+ offset: int | None = None,
29
+ ) -> PaginatedResult[TemplateSummary]:
30
+ """List templates (paginated)."""
31
+ body = self._http.request_json(
32
+ "GET",
33
+ "/templates/",
34
+ params={"limit": limit, "offset": offset},
35
+ )
36
+ return PaginatedResult(
37
+ items=[TemplateSummary.model_validate(item) for item in body["items"]],
38
+ total=body["total"],
39
+ limit=body["limit"],
40
+ offset=body["offset"],
41
+ )
42
+
43
+ def get(self, key: str) -> TemplateWithDefinition:
44
+ """Get a template with its full definition."""
45
+ body = self._http.request_json("GET", f"/templates/{key}")
46
+ return TemplateWithDefinition.model_validate(body)
47
+
48
+ def create(self, definition: dict[str, Any]) -> TemplateSummary:
49
+ """Create a new template."""
50
+ body = self._http.request_json("POST", "/templates/", json=definition)
51
+ return TemplateSummary.model_validate(body)
52
+
53
+ def update(
54
+ self,
55
+ key: str,
56
+ version: str,
57
+ definition: dict[str, Any],
58
+ ) -> TemplateWithDefinition:
59
+ """Update a template.
60
+
61
+ Requires the current version for optimistic concurrency (sent as
62
+ ``If-Match`` header). The server returns 412 Precondition Failed if
63
+ another writer has updated the template since the version was fetched.
64
+ """
65
+ body = self._http.request_json(
66
+ "PUT",
67
+ f"/templates/{key}",
68
+ json=definition,
69
+ headers={"If-Match": f'"{version}"'},
70
+ )
71
+ return TemplateWithDefinition.model_validate(body)
72
+
73
+ def delete(self, key: str, version: str) -> None:
74
+ """Delete a template (admin only). Requires the current version."""
75
+ self._http.request_json(
76
+ "DELETE",
77
+ f"/templates/{key}",
78
+ headers={"If-Match": f'"{version}"'},
79
+ )
80
+
81
+ def schema(self, key: str) -> dict[str, Any]:
82
+ """Get the JSON Schema describing the template's expected input data."""
83
+ body = self._http.request_json("GET", f"/templates/{key}/schema")
84
+ return body # type: ignore[no-any-return]
85
+
86
+ def sample(self, key: str) -> dict[str, Any]:
87
+ """Get auto-generated sample data for a template."""
88
+ body = self._http.request_json("GET", f"/templates/{key}/sample")
89
+ return body # type: ignore[no-any-return]
90
+
91
+ def validate(self, key: str, input_data: dict[str, Any]) -> ValidationResult:
92
+ """Validate input data against the template's schema."""
93
+ body = self._http.request_json(
94
+ "POST",
95
+ f"/templates/{key}/validate",
96
+ json=input_data,
97
+ )
98
+ return ValidationResult.model_validate(body)
99
+
100
+ def versions(
101
+ self,
102
+ key: str,
103
+ *,
104
+ limit: int | None = None,
105
+ offset: int | None = None,
106
+ ) -> PaginatedResult[VersionSummary]:
107
+ """List version history (paginated)."""
108
+ body = self._http.request_json(
109
+ "GET",
110
+ f"/templates/{key}/versions",
111
+ params={"limit": limit, "offset": offset},
112
+ )
113
+ return PaginatedResult(
114
+ items=[VersionSummary.model_validate(item) for item in body["items"]],
115
+ total=body["total"],
116
+ limit=body["limit"],
117
+ offset=body["offset"],
118
+ )
119
+
120
+ def get_version(self, key: str, version: str) -> TemplateWithDefinition:
121
+ """Get a specific historical version."""
122
+ body = self._http.request_json("GET", f"/templates/{key}/versions/{version}")
123
+ return TemplateWithDefinition.model_validate(body)
124
+
125
+ def restore(
126
+ self,
127
+ key: str,
128
+ version: str,
129
+ current_version: str,
130
+ ) -> TemplateWithDefinition:
131
+ """Restore a historical version as the current version (admin only)."""
132
+ body = self._http.request_json(
133
+ "POST",
134
+ f"/templates/{key}/versions/{version}/restore",
135
+ headers={"If-Match": f'"{current_version}"'},
136
+ )
137
+ return TemplateWithDefinition.model_validate(body)
138
+
139
+ def diff(
140
+ self,
141
+ key: str,
142
+ *,
143
+ before_version: str,
144
+ after_version: str,
145
+ ) -> TemplateDiff:
146
+ """Diff two stored versions of the same template key.
147
+
148
+ Returns the raw ``TemplateDiff`` envelope from
149
+ ``@pulp-engine/template-diff``. Both versions must exist in the
150
+ resolved tenant; otherwise the server returns 404 (raised as
151
+ :class:`PulpEngineError`).
152
+ """
153
+ body = self._http.request_json(
154
+ "POST",
155
+ f"/templates/{key}/diff",
156
+ json={
157
+ "beforeVersion": before_version,
158
+ "afterVersion": after_version,
159
+ },
160
+ )
161
+ return TemplateDiff.model_validate(body)