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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Assets resource — upload, list, delete image assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .._http import HttpClient
|
|
6
|
+
from ..pagination import PaginatedResult
|
|
7
|
+
from ..types import AssetRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AssetsResource:
|
|
11
|
+
"""Image asset management."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, http: HttpClient) -> None:
|
|
14
|
+
self._http = http
|
|
15
|
+
|
|
16
|
+
def list(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
limit: int | None = None,
|
|
20
|
+
offset: int | None = None,
|
|
21
|
+
q: str | None = None,
|
|
22
|
+
mime_type: str | None = None,
|
|
23
|
+
legacy_svg: bool | None = None,
|
|
24
|
+
) -> PaginatedResult[AssetRecord]:
|
|
25
|
+
"""List assets (paginated, with optional filters)."""
|
|
26
|
+
body = self._http.request_json(
|
|
27
|
+
"GET",
|
|
28
|
+
"/assets/",
|
|
29
|
+
params={
|
|
30
|
+
"limit": limit,
|
|
31
|
+
"offset": offset,
|
|
32
|
+
"q": q,
|
|
33
|
+
"mimeType": mime_type,
|
|
34
|
+
"legacySvg": legacy_svg,
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
return PaginatedResult(
|
|
38
|
+
items=[AssetRecord.model_validate(item) for item in body["items"]],
|
|
39
|
+
total=body["total"],
|
|
40
|
+
limit=body["limit"],
|
|
41
|
+
offset=body["offset"],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def upload(
|
|
45
|
+
self,
|
|
46
|
+
file: bytes,
|
|
47
|
+
filename: str,
|
|
48
|
+
*,
|
|
49
|
+
content_type: str = "application/octet-stream",
|
|
50
|
+
) -> AssetRecord:
|
|
51
|
+
"""Upload an image asset.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
file: The raw file bytes.
|
|
55
|
+
filename: Original filename — used for extension detection and storage.
|
|
56
|
+
content_type: MIME type. Defaults to ``application/octet-stream``;
|
|
57
|
+
pass the actual type (e.g. ``"image/png"``) when known.
|
|
58
|
+
"""
|
|
59
|
+
body = self._http.request_multipart(
|
|
60
|
+
"POST",
|
|
61
|
+
"/assets/upload",
|
|
62
|
+
files={"file": (filename, file, content_type)},
|
|
63
|
+
)
|
|
64
|
+
return AssetRecord.model_validate(body)
|
|
65
|
+
|
|
66
|
+
def delete(self, id: str) -> None:
|
|
67
|
+
"""Delete an asset by ID."""
|
|
68
|
+
self._http.request_json("DELETE", f"/assets/{id}")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Audit events resource — query and purge audit history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .._http import HttpClient
|
|
6
|
+
from ..pagination import PaginatedResult
|
|
7
|
+
from ..types import AuditEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuditEventsResource:
|
|
11
|
+
"""Query and purge audit event history."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, http: HttpClient) -> None:
|
|
14
|
+
self._http = http
|
|
15
|
+
|
|
16
|
+
def list(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
limit: int | None = None,
|
|
20
|
+
offset: int | None = None,
|
|
21
|
+
event: str | None = None,
|
|
22
|
+
operation: str | None = None,
|
|
23
|
+
actor: str | None = None,
|
|
24
|
+
resource_type: str | None = None,
|
|
25
|
+
resource_id: str | None = None,
|
|
26
|
+
since: str | None = None,
|
|
27
|
+
until: str | None = None,
|
|
28
|
+
) -> PaginatedResult[AuditEvent]:
|
|
29
|
+
"""List audit events (paginated, with optional filters). Admin only."""
|
|
30
|
+
body = self._http.request_json(
|
|
31
|
+
"GET",
|
|
32
|
+
"/audit-events/",
|
|
33
|
+
params={
|
|
34
|
+
"limit": limit,
|
|
35
|
+
"offset": offset,
|
|
36
|
+
"event": event,
|
|
37
|
+
"operation": operation,
|
|
38
|
+
"actor": actor,
|
|
39
|
+
"resourceType": resource_type,
|
|
40
|
+
"resourceId": resource_id,
|
|
41
|
+
"since": since,
|
|
42
|
+
"until": until,
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
return PaginatedResult(
|
|
46
|
+
items=[AuditEvent.model_validate(item) for item in body["items"]],
|
|
47
|
+
total=body["total"],
|
|
48
|
+
limit=body["limit"],
|
|
49
|
+
offset=body["offset"],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def purge(self, before: str) -> dict[str, int]:
|
|
53
|
+
"""Purge audit events older than the given ISO-8601 timestamp. Admin only.
|
|
54
|
+
|
|
55
|
+
Returns ``{"deleted": int}``.
|
|
56
|
+
"""
|
|
57
|
+
body = self._http.request_json(
|
|
58
|
+
"DELETE",
|
|
59
|
+
"/audit-events/",
|
|
60
|
+
params={"before": before},
|
|
61
|
+
)
|
|
62
|
+
return body # type: ignore[no-any-return]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Auth resource — auth status and editor token exchange."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .._http import HttpClient
|
|
6
|
+
from ..types import AuthStatus, EditorTokenResponse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthResource:
|
|
10
|
+
"""Authentication status and editor token exchange."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, http: HttpClient) -> None:
|
|
13
|
+
self._http = http
|
|
14
|
+
|
|
15
|
+
def status(self) -> AuthStatus:
|
|
16
|
+
"""Check authentication status and capabilities (public, no auth required)."""
|
|
17
|
+
body = self._http.request_json("GET", "/auth/status")
|
|
18
|
+
return AuthStatus.model_validate(body)
|
|
19
|
+
|
|
20
|
+
def editor_token(self, key: str, *, actor: str | None = None) -> EditorTokenResponse:
|
|
21
|
+
"""Exchange an API key for a short-lived editor session token (public endpoint)."""
|
|
22
|
+
request_body: dict[str, str] = {"key": key}
|
|
23
|
+
if actor is not None:
|
|
24
|
+
request_body["actor"] = actor
|
|
25
|
+
body = self._http.request_json("POST", "/auth/editor-token", json=request_body)
|
|
26
|
+
return EditorTokenResponse.model_validate(body)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Batch render resource — sync and async batch jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .._http import HttpClient
|
|
12
|
+
from ..errors import PulpEngineTimeoutError
|
|
13
|
+
from ..types import AsyncJobAccepted, AsyncJobStatus, BatchResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BatchResource:
|
|
17
|
+
"""Synchronous and asynchronous batch rendering."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, http: HttpClient) -> None:
|
|
20
|
+
self._http = http
|
|
21
|
+
|
|
22
|
+
def pdf(self, items: list[dict[str, Any]]) -> BatchResult:
|
|
23
|
+
"""Synchronous batch render to PDF."""
|
|
24
|
+
body = self._http.request_json("POST", "/render/batch", json={"items": items})
|
|
25
|
+
return BatchResult.model_validate(body)
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def pdf_stream(self, items: list[dict[str, Any]]) -> Iterator[Iterator[dict[str, Any]]]:
|
|
29
|
+
"""Synchronous batch render to PDF, streamed as NDJSON.
|
|
30
|
+
|
|
31
|
+
Yields an iterator of parsed NDJSON lines. ``result`` lines arrive in
|
|
32
|
+
**completion order** (fastest items first, regardless of input order —
|
|
33
|
+
use ``line["index"]`` to map back). The iteration ends with a single
|
|
34
|
+
``summary`` line carrying authoritative totals.
|
|
35
|
+
|
|
36
|
+
Error contract: 4xx/5xx responses are decoded eagerly and raised as
|
|
37
|
+
:class:`PulpEngineError` before the context manager enters. Mid-stream
|
|
38
|
+
transport failures raise ``httpx.StreamError`` during iteration. A
|
|
39
|
+
truncated stream (no final ``summary`` line) signals the batch was
|
|
40
|
+
interrupted.
|
|
41
|
+
|
|
42
|
+
Example::
|
|
43
|
+
|
|
44
|
+
with client.batch.pdf_stream(items) as lines:
|
|
45
|
+
for line in lines:
|
|
46
|
+
if line["type"] == "result" and line["success"]:
|
|
47
|
+
save_to_disk(line["index"], line["pdf"])
|
|
48
|
+
elif line["type"] == "summary":
|
|
49
|
+
print(f"{line['succeeded']}/{line['total']} succeeded")
|
|
50
|
+
"""
|
|
51
|
+
cm = self._http.stream_bytes(
|
|
52
|
+
"POST",
|
|
53
|
+
"/render/batch",
|
|
54
|
+
json={"items": items},
|
|
55
|
+
headers={"Accept": "application/x-ndjson"},
|
|
56
|
+
)
|
|
57
|
+
with cm as response:
|
|
58
|
+
saw_summary = [False] # boxed so the inner generator can mutate
|
|
59
|
+
|
|
60
|
+
def _iter_lines() -> Iterator[dict[str, Any]]:
|
|
61
|
+
for raw_line in response.iter_lines():
|
|
62
|
+
if not raw_line:
|
|
63
|
+
continue
|
|
64
|
+
parsed = _json.loads(raw_line)
|
|
65
|
+
if isinstance(parsed, dict) and parsed.get("type") == "summary":
|
|
66
|
+
saw_summary[0] = True
|
|
67
|
+
yield parsed
|
|
68
|
+
# End-of-stream guard. Server contract is: always emit a
|
|
69
|
+
# trailing `summary` line before closing. Missing summary
|
|
70
|
+
# signals the batch was interrupted — either by proxy
|
|
71
|
+
# truncation on a line boundary, server crash, or transport
|
|
72
|
+
# failure between the last result and the summary. Raise
|
|
73
|
+
# so consumers cannot mistake a partial stream for a
|
|
74
|
+
# complete one.
|
|
75
|
+
if not saw_summary[0]:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
"pdf_stream: stream ended without a summary line — batch was interrupted"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
yield _iter_lines()
|
|
81
|
+
|
|
82
|
+
def docx(self, items: list[dict[str, Any]]) -> BatchResult:
|
|
83
|
+
"""Synchronous batch render to DOCX."""
|
|
84
|
+
body = self._http.request_json("POST", "/render/batch/docx", json={"items": items})
|
|
85
|
+
return BatchResult.model_validate(body)
|
|
86
|
+
|
|
87
|
+
def submit_async(
|
|
88
|
+
self,
|
|
89
|
+
items: list[dict[str, Any]],
|
|
90
|
+
webhook_url: str,
|
|
91
|
+
webhook_secret: str,
|
|
92
|
+
) -> AsyncJobAccepted:
|
|
93
|
+
"""Submit an async PDF batch render with webhook callback. Returns 202 with job metadata."""
|
|
94
|
+
body = self._http.request_json(
|
|
95
|
+
"POST",
|
|
96
|
+
"/render/batch/async",
|
|
97
|
+
json={
|
|
98
|
+
"items": items,
|
|
99
|
+
"webhookUrl": webhook_url,
|
|
100
|
+
"webhookSecret": webhook_secret,
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
return AsyncJobAccepted.model_validate(body)
|
|
104
|
+
|
|
105
|
+
def submit_async_docx(
|
|
106
|
+
self,
|
|
107
|
+
items: list[dict[str, Any]],
|
|
108
|
+
webhook_url: str,
|
|
109
|
+
webhook_secret: str,
|
|
110
|
+
) -> AsyncJobAccepted:
|
|
111
|
+
"""Submit an async DOCX batch render with webhook callback. Returns 202."""
|
|
112
|
+
body = self._http.request_json(
|
|
113
|
+
"POST",
|
|
114
|
+
"/render/batch/async/docx",
|
|
115
|
+
json={
|
|
116
|
+
"items": items,
|
|
117
|
+
"webhookUrl": webhook_url,
|
|
118
|
+
"webhookSecret": webhook_secret,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
return AsyncJobAccepted.model_validate(body)
|
|
122
|
+
|
|
123
|
+
def poll_job(self, job_id: str) -> AsyncJobStatus:
|
|
124
|
+
"""Poll the status of an async batch job."""
|
|
125
|
+
body = self._http.request_json("GET", f"/render/batch/jobs/{job_id}")
|
|
126
|
+
return AsyncJobStatus.model_validate(body)
|
|
127
|
+
|
|
128
|
+
def wait_for_job(
|
|
129
|
+
self,
|
|
130
|
+
job_id: str,
|
|
131
|
+
*,
|
|
132
|
+
poll_interval_s: float = 2.0,
|
|
133
|
+
timeout_s: float = 300.0,
|
|
134
|
+
) -> AsyncJobStatus:
|
|
135
|
+
"""Wait for an async batch job to complete, polling at a fixed interval.
|
|
136
|
+
|
|
137
|
+
Returns when the job reaches ``completed`` or ``failed`` status.
|
|
138
|
+
Raises :class:`PulpEngineTimeoutError` if the timeout is exceeded.
|
|
139
|
+
"""
|
|
140
|
+
start = time.monotonic()
|
|
141
|
+
while time.monotonic() - start < timeout_s:
|
|
142
|
+
job = self.poll_job(job_id)
|
|
143
|
+
if job.status in ("completed", "failed"):
|
|
144
|
+
return job
|
|
145
|
+
time.sleep(poll_interval_s)
|
|
146
|
+
raise PulpEngineTimeoutError(f"Batch job {job_id} timed out after {timeout_s}s")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Health resource — liveness and readiness probes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .._http import HttpClient
|
|
6
|
+
from ..types import HealthResponse, ReadinessResponse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HealthResource:
|
|
10
|
+
"""Liveness and readiness probes."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, http: HttpClient) -> None:
|
|
13
|
+
self._http = http
|
|
14
|
+
|
|
15
|
+
def liveness(self) -> HealthResponse:
|
|
16
|
+
"""Liveness probe — always 200 if the process is up."""
|
|
17
|
+
body = self._http.request_json("GET", "/health")
|
|
18
|
+
return HealthResponse.model_validate(body)
|
|
19
|
+
|
|
20
|
+
def readiness(self) -> ReadinessResponse:
|
|
21
|
+
"""Readiness probe — 503 if storage is unreachable."""
|
|
22
|
+
body = self._http.request_json("GET", "/health/ready")
|
|
23
|
+
return ReadinessResponse.model_validate(body)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""PDF transform resource — merge, watermark, insert."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .._http import HttpClient
|
|
8
|
+
from .render import BinaryResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PdfTransformResource:
|
|
12
|
+
"""PDF post-processing operations: merge, watermark, insert pages.
|
|
13
|
+
|
|
14
|
+
All methods return :class:`BinaryResult` so callers can use the same
|
|
15
|
+
``.save(path)`` helper as the render endpoints.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, http: HttpClient) -> None:
|
|
19
|
+
self._http = http
|
|
20
|
+
|
|
21
|
+
def merge(
|
|
22
|
+
self,
|
|
23
|
+
sources: list[dict[str, str]],
|
|
24
|
+
*,
|
|
25
|
+
metadata: dict[str, str] | None = None,
|
|
26
|
+
) -> BinaryResult:
|
|
27
|
+
"""Merge multiple PDFs into one.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
sources: List of ``{"data": <base64-encoded PDF>}`` dicts.
|
|
31
|
+
metadata: Optional ``{"title", "author", "subject"}`` for the output PDF.
|
|
32
|
+
"""
|
|
33
|
+
body: dict[str, Any] = {"sources": sources}
|
|
34
|
+
if metadata is not None:
|
|
35
|
+
body["metadata"] = metadata
|
|
36
|
+
content, headers = self._http.request_bytes("POST", "/render/pdf/merge", json=body)
|
|
37
|
+
return BinaryResult(
|
|
38
|
+
data=content,
|
|
39
|
+
content_type=headers.get("content-type"),
|
|
40
|
+
content_disposition=headers.get("content-disposition"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def watermark(
|
|
44
|
+
self,
|
|
45
|
+
pdf: str,
|
|
46
|
+
watermark: dict[str, Any],
|
|
47
|
+
*,
|
|
48
|
+
page_range: dict[str, int] | None = None,
|
|
49
|
+
) -> BinaryResult:
|
|
50
|
+
"""Apply a text or image watermark to a PDF.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
pdf: Base64-encoded source PDF.
|
|
54
|
+
watermark: Watermark spec — see API docs for the full shape.
|
|
55
|
+
page_range: Optional ``{"start": int, "end": int}`` to limit pages.
|
|
56
|
+
"""
|
|
57
|
+
body: dict[str, Any] = {"pdf": pdf, "watermark": watermark}
|
|
58
|
+
if page_range is not None:
|
|
59
|
+
body["pageRange"] = page_range
|
|
60
|
+
content, headers = self._http.request_bytes("POST", "/render/pdf/watermark", json=body)
|
|
61
|
+
return BinaryResult(
|
|
62
|
+
data=content,
|
|
63
|
+
content_type=headers.get("content-type"),
|
|
64
|
+
content_disposition=headers.get("content-disposition"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def insert(
|
|
68
|
+
self,
|
|
69
|
+
target: str,
|
|
70
|
+
insert: str,
|
|
71
|
+
position: dict[str, Any],
|
|
72
|
+
) -> BinaryResult:
|
|
73
|
+
"""Insert pages from one PDF into another.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
target: Base64-encoded target PDF.
|
|
77
|
+
insert: Base64-encoded PDF to insert.
|
|
78
|
+
position: ``{"at": "start"|"end"|"before"|"after", "page": int}``.
|
|
79
|
+
"""
|
|
80
|
+
content, headers = self._http.request_bytes(
|
|
81
|
+
"POST",
|
|
82
|
+
"/render/pdf/insert",
|
|
83
|
+
json={"target": target, "insert": insert, "position": position},
|
|
84
|
+
)
|
|
85
|
+
return BinaryResult(
|
|
86
|
+
data=content,
|
|
87
|
+
content_type=headers.get("content-type"),
|
|
88
|
+
content_disposition=headers.get("content-disposition"),
|
|
89
|
+
)
|