compile-pdf-cjd 0.1.0__tar.gz

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,29 @@
1
+ language: en-US
2
+ reviews:
3
+ profile: chill
4
+ request_changes_workflow: false
5
+ high_level_summary: true
6
+ poem: false
7
+ review_status: true
8
+ collapse_walkthrough: true
9
+ auto_review:
10
+ enabled: true
11
+ drafts: false
12
+ path_filters:
13
+ - "!dist/**"
14
+ - "!build/**"
15
+ - "!.next/**"
16
+ - "!node_modules/**"
17
+ - "!**/__pycache__/**"
18
+ - "!**/*.min.js"
19
+ - "!**/*.lock"
20
+ - "!**/package-lock.json"
21
+ - "!**/uv.lock"
22
+ - "!**/poetry.lock"
23
+ path_instructions:
24
+ - path: "**/*.{ts,tsx,js,jsx}"
25
+ instructions: "Flag unhandled promises, missing null checks, hardcoded secrets/env vars, and unsafe `any` usage. Skip stylistic preferences — Prettier handles those."
26
+ - path: "**/*.py"
27
+ instructions: "Flag unhandled exceptions, missing type hints on public functions, hardcoded secrets, and SQL injection risks. Skip style — Ruff handles those."
28
+ chat:
29
+ auto_reply: true
@@ -0,0 +1,21 @@
1
+ ---
2
+ description: PrintWithSynergy baseline rules
3
+ alwaysApply: true
4
+ ---
5
+
6
+ You are working in this repository, part of the printwithsynergy organization.
7
+
8
+ # Conventions
9
+ - Conventional commits (feat:, fix:, chore:, refactor:, docs:, test:)
10
+ - Never commit secrets. Use .env (gitignored) for local config.
11
+ - Prefer pure functions; isolate side effects.
12
+ - Write tests alongside code changes when behavior changes.
13
+
14
+ # Before modifying code
15
+ - Use the code-review-graph MCP tools (get_impact_radius_tool, get_blast_radius) to check downstream impact.
16
+ - For unfamiliar symbols, use sverklo_lookup or sverklo_refs first.
17
+ - Never assume context. If the codebase has CLAUDE.md or AGENTS.md, read it first.
18
+
19
+ # Style
20
+ - Match existing code style. Do not reformat unrelated lines.
21
+ - No "helper" refactors mixed with feature changes.
@@ -0,0 +1,16 @@
1
+ You are working in the `compile-pdf-cjd` repository, part of the printwithsynergy organization.
2
+
3
+ # Conventions
4
+ - Conventional commits (feat:, fix:, chore:, refactor:, docs:, test:)
5
+ - Never commit secrets. Use .env (gitignored) for local config.
6
+ - Prefer pure functions; isolate side effects.
7
+ - Write tests alongside code changes when behavior changes.
8
+
9
+ # Before modifying code
10
+ - Use the code-review-graph MCP tools (get_impact_radius_tool, get_blast_radius) to check downstream impact.
11
+ - For unfamiliar symbols, use sverklo_lookup or sverklo_refs first.
12
+ - Never assume context. If the codebase has CLAUDE.md or AGENTS.md, read it first.
13
+
14
+ # Style
15
+ - Match existing code style. Do not reformat unrelated lines.
16
+ - No "helper" refactors mixed with feature changes.
@@ -0,0 +1,11 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ open-pull-requests-limit: 5
8
+ - package-ecosystem: "github-actions"
9
+ directory: "/"
10
+ schedule:
11
+ interval: "monthly"
@@ -0,0 +1,39 @@
1
+ name: publish-pypi
2
+
3
+ # Publishes compile-pdf-cjd to PyPI when a v*.*.* tag is pushed.
4
+ #
5
+ # Auth: PyPI API token stored in PYPI_TOKEN repository secret.
6
+
7
+ on:
8
+ push:
9
+ tags:
10
+ - "v*.*.*"
11
+
12
+ jobs:
13
+ build:
14
+ name: build sdist + wheel
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
18
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
19
+ with:
20
+ python-version: "3.12"
21
+ - run: pip install --upgrade pip build
22
+ - run: python -m build --sdist --wheel --outdir dist/
23
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
24
+ with:
25
+ name: dist
26
+ path: dist/*
27
+
28
+ publish:
29
+ name: publish to PyPI
30
+ needs: build
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
34
+ with:
35
+ name: dist
36
+ path: dist/
37
+ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
38
+ with:
39
+ password: ${{ secrets.PYPI_TOKEN }}
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ dist/
4
+ *.egg-info/
5
+ .pytest_cache/
@@ -0,0 +1,9 @@
1
+ # compile-pdf-cjd
2
+
3
+ compile-pdf-cjd
4
+
5
+ ## Code Review & Blast-Radius Protocol
6
+ - Before edits: run code-review-graph impact tools on changed symbols
7
+ - After edits: ensure tests pass before commit
8
+ - CodeRabbit reviews PRs automatically; Cursor BugBot provides second opinion
9
+ - Never disable the code-review-graph Launch Agent
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: compile-pdf-cjd
3
+ Version: 0.1.0
4
+ Summary: CompilePDF CJD orchestrator — sequences trap, impose, marks, and rewrite producers in a single Compile Job Definition.
5
+ Project-URL: Homepage, https://compilepdf.com
6
+ Project-URL: Repository, https://github.com/printwithsynergy/compile-pdf-cjd
7
+ Project-URL: Issues, https://github.com/printwithsynergy/compile-pdf-cjd/issues
8
+ Author-email: Print With Synergy <iam@quincy.codes>
9
+ License: AGPL-3.0-or-later
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Multimedia :: Graphics
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: click>=8.1
19
+ Requires-Dist: compile-pdf-core<1.0,>=0.1.0
20
+ Requires-Dist: compile-pdf-impose<1.0,>=0.1.0
21
+ Requires-Dist: compile-pdf-marks<1.0,>=0.1.0
22
+ Requires-Dist: compile-pdf-rewrite<1.0,>=0.1.0
23
+ Requires-Dist: compile-pdf-trap<1.0,>=0.1.0
24
+ Requires-Dist: defusedxml>=0.7.1
25
+ Requires-Dist: fastapi>=0.110
26
+ Requires-Dist: pydantic>=2.6
27
+ Requires-Dist: structlog>=24.1
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy>=1.9; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=4.1; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0; extra == 'dev'
33
+ Requires-Dist: ruff>=0.4; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # compile-pdf-cjd
37
+
38
+ CJD orchestrator — sequences all four CompilePDF producers (trap, rewrite, marks, impose) in one job.
39
+
40
+ Compile Job Definition: a JSON/XML envelope that bundles a multi-producer run into a single submission. Sequences `trap → rewrite → marks → impose` with lineage threading across every stage. XML + JSON formats; each stage's diff artifact is stored in the lineage record.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ uv pip install compile-pdf-cjd
46
+ ```
47
+
48
+ This package depends on the four producer packages plus `compile-pdf-core`; installing it pulls the whole orchestrator chain.
49
+
50
+ ## Position in the stack
51
+
52
+ The only CompilePDF package that imports all four producers. Stand-alone producers live in their own repos:
53
+
54
+ - https://github.com/printwithsynergy/compile-pdf-trap
55
+ - https://github.com/printwithsynergy/compile-pdf-rewrite
56
+ - https://github.com/printwithsynergy/compile-pdf-marks
57
+ - https://github.com/printwithsynergy/compile-pdf-impose
58
+ - https://github.com/printwithsynergy/compile-pdf-core
59
+
60
+ - Repo: https://github.com/printwithsynergy/compile-pdf-cjd
61
+ - Deployment host: https://github.com/printwithsynergy/compile-pdf
62
+ - Marketing: https://compilepdf.com
63
+ - License: AGPL-3.0-or-later
@@ -0,0 +1,28 @@
1
+ # compile-pdf-cjd
2
+
3
+ CJD orchestrator — sequences all four CompilePDF producers (trap, rewrite, marks, impose) in one job.
4
+
5
+ Compile Job Definition: a JSON/XML envelope that bundles a multi-producer run into a single submission. Sequences `trap → rewrite → marks → impose` with lineage threading across every stage. XML + JSON formats; each stage's diff artifact is stored in the lineage record.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ uv pip install compile-pdf-cjd
11
+ ```
12
+
13
+ This package depends on the four producer packages plus `compile-pdf-core`; installing it pulls the whole orchestrator chain.
14
+
15
+ ## Position in the stack
16
+
17
+ The only CompilePDF package that imports all four producers. Stand-alone producers live in their own repos:
18
+
19
+ - https://github.com/printwithsynergy/compile-pdf-trap
20
+ - https://github.com/printwithsynergy/compile-pdf-rewrite
21
+ - https://github.com/printwithsynergy/compile-pdf-marks
22
+ - https://github.com/printwithsynergy/compile-pdf-impose
23
+ - https://github.com/printwithsynergy/compile-pdf-core
24
+
25
+ - Repo: https://github.com/printwithsynergy/compile-pdf-cjd
26
+ - Deployment host: https://github.com/printwithsynergy/compile-pdf
27
+ - Marketing: https://compilepdf.com
28
+ - License: AGPL-3.0-or-later
@@ -0,0 +1,72 @@
1
+ [project]
2
+ name = "compile-pdf-cjd"
3
+ version = "0.1.0"
4
+ description = "CompilePDF CJD orchestrator — sequences trap, impose, marks, and rewrite producers in a single Compile Job Definition."
5
+ readme = "README.md"
6
+ license = { text = "AGPL-3.0-or-later" }
7
+ authors = [{ name = "Print With Synergy", email = "iam@quincy.codes" }]
8
+ requires-python = ">=3.12"
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Topic :: Multimedia :: Graphics",
16
+ "Topic :: Software Development :: Libraries",
17
+ ]
18
+
19
+ dependencies = [
20
+ "compile-pdf-core>=0.1.0,<1.0",
21
+ "compile-pdf-trap>=0.1.0,<1.0",
22
+ "compile-pdf-impose>=0.1.0,<1.0",
23
+ "compile-pdf-marks>=0.1.0,<1.0",
24
+ "compile-pdf-rewrite>=0.1.0,<1.0",
25
+ "fastapi>=0.110",
26
+ "pydantic>=2.6",
27
+ "click>=8.1",
28
+ "structlog>=24.1",
29
+ "defusedxml>=0.7.1",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=8.0",
35
+ "pytest-asyncio>=0.23",
36
+ "pytest-cov>=4.1",
37
+ "ruff>=0.4",
38
+ "mypy>=1.9",
39
+ ]
40
+
41
+ [project.scripts]
42
+ compile-pdf-cjd = "compile_pdf_cjd.cli:main"
43
+
44
+ [project.urls]
45
+ Homepage = "https://compilepdf.com"
46
+ Repository = "https://github.com/printwithsynergy/compile-pdf-cjd"
47
+ Issues = "https://github.com/printwithsynergy/compile-pdf-cjd/issues"
48
+
49
+ [build-system]
50
+ requires = ["hatchling"]
51
+ build-backend = "hatchling.build"
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/compile_pdf_cjd"]
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ target-version = "py312"
59
+
60
+ [tool.ruff.lint]
61
+ select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "C4", "RET"]
62
+ ignore = ["E501"]
63
+
64
+ [tool.pytest.ini_options]
65
+ testpaths = ["tests"]
66
+ python_files = ["test_*.py"]
67
+ addopts = ["-ra", "--strict-markers", "--strict-config"]
68
+ asyncio_mode = "auto"
69
+
70
+ [tool.coverage.run]
71
+ source = ["src/compile_pdf_cjd"]
72
+ branch = true
File without changes
@@ -0,0 +1,209 @@
1
+ """FastAPI router for the CJD pipeline + lineage lookup.
2
+
3
+ Mounts:
4
+
5
+ * ``POST /v1/cjd/apply`` — execute a CJD job
6
+ * ``GET /v1/lineage/{lineage_id}`` — fetch a chain by id
7
+ * ``GET /v1/lineage`` — list known lineage ids (paginated)
8
+
9
+ Pass ``?async=true`` on ``POST /v1/cjd/apply`` to submit the job to
10
+ Celery and receive a ``202`` with a ``job_id`` you can poll at
11
+ ``GET /v1/jobs/{job_id}``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+
18
+ import structlog
19
+ from fastapi import APIRouter, HTTPException, Query, Request, status
20
+ from pydantic import BaseModel
21
+
22
+ from compile_pdf_cjd.orchestrator import CjdOrderError, execute
23
+ from compile_pdf_cjd.schema import CjdJob
24
+ from compile_pdf_cjd.xml import CjdXmlError, parse_cjd_xml
25
+ from compile_pdf_core.async_jobs import AsyncJobAccepted, JobStatus, create_job
26
+ from compile_pdf_core.lineage.store import (
27
+ LineageNotFoundError,
28
+ default_store,
29
+ serialize_chain,
30
+ )
31
+ from compile_pdf_core.retention import parse_consent, resolve_tenant
32
+ from compile_pdf_core.tasks import task_payload_hash
33
+ from compile_pdf_core.version import (
34
+ CJD_SCHEMA_VERSION,
35
+ VERSION,
36
+ )
37
+
38
+ logger = structlog.get_logger(__name__)
39
+
40
+ cjd_router = APIRouter()
41
+ lineage_router = APIRouter()
42
+
43
+
44
+ class CjdApplyResponse(BaseModel):
45
+ model_config = {"extra": "forbid"}
46
+
47
+ output_pdf_b64: str
48
+ output_pdf_sha256: str
49
+ lineage_id: str
50
+ steps: list[dict[str, object]]
51
+ trap_diff: dict[str, object] | None = None
52
+ schema_version: str = CJD_SCHEMA_VERSION
53
+ compile_version: str = VERSION
54
+
55
+
56
+ @cjd_router.post(
57
+ "/apply",
58
+ response_model=CjdApplyResponse,
59
+ status_code=status.HTTP_200_OK,
60
+ responses={202: {"model": AsyncJobAccepted}},
61
+ )
62
+ async def cjd_apply(
63
+ job: CjdJob,
64
+ request: Request,
65
+ async_: bool = Query(False, alias="async"),
66
+ ) -> CjdApplyResponse | AsyncJobAccepted:
67
+ """Execute a CJD job: orchestrate the four producers in dependency
68
+ order, persist lineage records, return the final PDF + chain.
69
+
70
+ Pass ``?async=true`` to enqueue the job on Celery and receive a ``202``
71
+ ``AsyncJobAccepted`` response. Poll ``GET /v1/jobs/{job_id}`` until the
72
+ status is ``complete`` or ``failed``.
73
+ """
74
+ if async_:
75
+ job_payload = job.model_dump(mode="json")
76
+ ph = task_payload_hash(job_payload)
77
+ job_id = create_job(kind="cjd", payload_hash=ph)
78
+ from compile_pdf_core.async_tasks import async_wrap_cjd
79
+
80
+ async_wrap_cjd.apply_async(args=[job_id, job_payload])
81
+ logger.info("cjd.apply.async_accepted", job_id=job_id, payload_hash=ph[:16])
82
+ from fastapi.responses import JSONResponse
83
+
84
+ return JSONResponse( # type: ignore[return-value]
85
+ status_code=status.HTTP_202_ACCEPTED,
86
+ content=AsyncJobAccepted(
87
+ job_id=job_id,
88
+ status=JobStatus.pending,
89
+ poll_url=f"/v1/jobs/{job_id}",
90
+ ).model_dump(),
91
+ )
92
+
93
+ consent = parse_consent(request)
94
+ tenant = resolve_tenant(request)
95
+ try:
96
+ result = execute(job, consent=consent, tenant=tenant)
97
+ except CjdOrderError as exc:
98
+ raise HTTPException(status_code=422, detail=f"CJD ordering rejected: {exc}") from exc
99
+ except (ValueError, TypeError) as exc:
100
+ # Includes base64 errors from inside execute().
101
+ raise HTTPException(status_code=400, detail=f"CJD job rejected: {exc}") from exc
102
+
103
+ logger.info(
104
+ "cjd.apply.ok",
105
+ lineage_id=result.lineage_id,
106
+ steps=len(result.steps),
107
+ output_sha=result.output_pdf_sha256[:16],
108
+ consent=consent,
109
+ retained_steps=sum(1 for s in result.steps if s.retained_for_training),
110
+ )
111
+
112
+ return CjdApplyResponse(
113
+ output_pdf_b64=base64.b64encode(result.output_pdf_bytes).decode("ascii"),
114
+ output_pdf_sha256=result.output_pdf_sha256,
115
+ lineage_id=result.lineage_id,
116
+ steps=[_step_to_dict(s) for s in result.steps],
117
+ trap_diff=result.trap_diff,
118
+ )
119
+
120
+
121
+ @cjd_router.post(
122
+ "/apply-xml",
123
+ response_model=CjdApplyResponse,
124
+ status_code=status.HTTP_200_OK,
125
+ )
126
+ async def cjd_apply_xml(request: Request) -> CjdApplyResponse:
127
+ """XML-encoded variant of POST /v1/cjd/apply.
128
+
129
+ Body is a CJD XML envelope (per :mod:`compile_pdf.cjd.xml`); the
130
+ response shape matches the JSON endpoint exactly. Useful for
131
+ operators integrating Compile into JDF / PJTF pipelines that
132
+ already speak XML on the wire.
133
+ """
134
+ body = await request.body()
135
+ if not body:
136
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="empty XML body")
137
+ try:
138
+ job = parse_cjd_xml(body)
139
+ except CjdXmlError as exc:
140
+ raise HTTPException(status_code=422, detail=f"CJD XML rejected: {exc}") from exc
141
+
142
+ consent = parse_consent(request)
143
+ tenant = resolve_tenant(request)
144
+ try:
145
+ result = execute(job, consent=consent, tenant=tenant)
146
+ except CjdOrderError as exc:
147
+ raise HTTPException(status_code=422, detail=f"CJD ordering rejected: {exc}") from exc
148
+ except (ValueError, TypeError) as exc:
149
+ raise HTTPException(status_code=400, detail=f"CJD job rejected: {exc}") from exc
150
+
151
+ logger.info(
152
+ "cjd.apply_xml.ok",
153
+ lineage_id=result.lineage_id,
154
+ steps=len(result.steps),
155
+ output_sha=result.output_pdf_sha256[:16],
156
+ consent=consent,
157
+ retained_steps=sum(1 for s in result.steps if s.retained_for_training),
158
+ )
159
+
160
+ return CjdApplyResponse(
161
+ output_pdf_b64=base64.b64encode(result.output_pdf_bytes).decode("ascii"),
162
+ output_pdf_sha256=result.output_pdf_sha256,
163
+ lineage_id=result.lineage_id,
164
+ steps=[_step_to_dict(s) for s in result.steps],
165
+ trap_diff=result.trap_diff,
166
+ )
167
+
168
+
169
+ class LineageListResponse(BaseModel):
170
+ model_config = {"extra": "forbid"}
171
+
172
+ lineage_ids: list[str]
173
+
174
+
175
+ @lineage_router.get("/{lineage_id}", status_code=status.HTTP_200_OK)
176
+ async def lineage_get(lineage_id: str) -> dict[str, object]:
177
+ """Fetch a lineage chain by id."""
178
+ try:
179
+ chain = default_store().get(lineage_id)
180
+ except LineageNotFoundError as exc:
181
+ raise HTTPException(status_code=404, detail=f"lineage_id not found: {lineage_id}") from exc
182
+ return serialize_chain(chain)
183
+
184
+
185
+ @lineage_router.get(
186
+ "",
187
+ response_model=LineageListResponse,
188
+ status_code=status.HTTP_200_OK,
189
+ )
190
+ async def lineage_list(limit: int = Query(default=50, ge=1, le=500)) -> LineageListResponse:
191
+ """List known lineage ids (best-effort; backend may paginate)."""
192
+ return LineageListResponse(lineage_ids=default_store().list_ids(limit=limit))
193
+
194
+
195
+ def _step_to_dict(step) -> dict[str, object]: # type: ignore[no-untyped-def]
196
+ payload: dict[str, object] = {
197
+ "step_index": step.step_index,
198
+ "producer": step.producer,
199
+ "input_sha256": step.input_sha256,
200
+ "output_sha256": step.output_sha256,
201
+ "cache_key": step.cache_key,
202
+ "plan_sha256": step.plan_sha256,
203
+ "retained_for_training": step.retained_for_training,
204
+ }
205
+ if step.extras:
206
+ payload["extras"] = dict(step.extras)
207
+ if step.trap_diff is not None:
208
+ payload["trap_diff"] = step.trap_diff
209
+ return payload
@@ -0,0 +1,170 @@
1
+ """Click subcommand registration for ``compile-pdf cjd`` + ``lineage``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from compile_pdf_cjd.orchestrator import CjdOrderError, execute
13
+ from compile_pdf_cjd.schema import CjdJob, cjd_job_json_schema
14
+ from compile_pdf_cjd.xml import CjdXmlError, parse_cjd_xml, render_cjd_xml
15
+ from compile_pdf_core.lineage.store import (
16
+ LineageNotFoundError,
17
+ default_store,
18
+ serialize_chain,
19
+ )
20
+
21
+
22
+ def register(group: click.Group) -> None:
23
+ """Attach ``cjd``, ``cjd-schema``, and ``lineage`` subcommands."""
24
+
25
+ @group.command("cjd", help="Execute a CJD job (multi-producer pipeline).")
26
+ @click.option(
27
+ "--job",
28
+ "job_path",
29
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
30
+ required=True,
31
+ help="JSON CJD-job document.",
32
+ )
33
+ @click.option(
34
+ "--input",
35
+ "input_path",
36
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
37
+ default=None,
38
+ help=(
39
+ "Optional. When set, the input PDF bytes are read from this "
40
+ "path and replace job.input_pdf_b64 (handy for large inputs)."
41
+ ),
42
+ )
43
+ @click.option(
44
+ "--trap-diff",
45
+ "trap_diff_path",
46
+ type=click.Path(dir_okay=False, path_type=Path),
47
+ default=None,
48
+ help="Write the trap-diff artifact to this path (no-op if no trap step).",
49
+ )
50
+ @click.option(
51
+ "--xml/--json",
52
+ "use_xml",
53
+ default=False,
54
+ help="Read the job document as XML (default: JSON).",
55
+ )
56
+ @click.argument(
57
+ "output_path",
58
+ type=click.Path(dir_okay=False, path_type=Path),
59
+ )
60
+ def cjd_cmd(
61
+ job_path: Path,
62
+ input_path: Path | None,
63
+ trap_diff_path: Path | None,
64
+ use_xml: bool,
65
+ output_path: Path,
66
+ ) -> None:
67
+ if use_xml:
68
+ try:
69
+ job = parse_cjd_xml(job_path.read_bytes())
70
+ except CjdXmlError as exc:
71
+ click.echo(f"XML job rejected: {exc}", err=True)
72
+ sys.exit(3)
73
+ if input_path is not None:
74
+ # Re-serialize with the override and re-parse via JSON path.
75
+ payload = job.model_dump(mode="json")
76
+ payload["input_pdf_b64"] = base64.b64encode(input_path.read_bytes()).decode("ascii")
77
+ job = CjdJob.model_validate(payload)
78
+ else:
79
+ job_dict = json.loads(job_path.read_text(encoding="utf-8"))
80
+ if input_path is not None:
81
+ job_dict["input_pdf_b64"] = base64.b64encode(input_path.read_bytes()).decode(
82
+ "ascii"
83
+ )
84
+ try:
85
+ job = CjdJob.model_validate(job_dict)
86
+ except Exception as exc:
87
+ click.echo(f"job validation failed: {exc}", err=True)
88
+ sys.exit(3)
89
+
90
+ try:
91
+ result = execute(job)
92
+ except CjdOrderError as exc:
93
+ click.echo(f"job rejected: {exc}", err=True)
94
+ sys.exit(4)
95
+
96
+ output_path.write_bytes(result.output_pdf_bytes)
97
+ if trap_diff_path is not None and result.trap_diff is not None:
98
+ trap_diff_path.write_text(json.dumps(result.trap_diff, indent=2), encoding="utf-8")
99
+
100
+ click.echo(
101
+ json.dumps(
102
+ {
103
+ "lineage_id": result.lineage_id,
104
+ "output_pdf_sha256": result.output_pdf_sha256,
105
+ "steps": [
106
+ {
107
+ "step_index": s.step_index,
108
+ "producer": s.producer,
109
+ "output_sha256": s.output_sha256[:16],
110
+ "cache_key": s.cache_key[:16],
111
+ }
112
+ for s in result.steps
113
+ ],
114
+ "output": str(output_path),
115
+ "trap_diff": str(trap_diff_path)
116
+ if trap_diff_path and result.trap_diff is not None
117
+ else None,
118
+ },
119
+ indent=2,
120
+ )
121
+ )
122
+
123
+ @group.command("cjd-schema", hidden=True, help="Dump the CJD-job JSON Schema.")
124
+ def cjd_schema_cmd() -> None:
125
+ click.echo(json.dumps(cjd_job_json_schema(), indent=2))
126
+
127
+ @group.command(
128
+ "cjd-xml-render",
129
+ hidden=True,
130
+ help="Convert a JSON CJD job to XML and print the result.",
131
+ )
132
+ @click.argument(
133
+ "job_path",
134
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
135
+ )
136
+ def cjd_xml_render_cmd(job_path: Path) -> None:
137
+ job_dict = json.loads(job_path.read_text(encoding="utf-8"))
138
+ try:
139
+ job = CjdJob.model_validate(job_dict)
140
+ except Exception as exc:
141
+ click.echo(f"job validation failed: {exc}", err=True)
142
+ sys.exit(3)
143
+ click.echo(render_cjd_xml(job).decode("utf-8"))
144
+
145
+ @group.command("lineage", help="Print the lineage chain for a previously-run CJD job.")
146
+ @click.argument("lineage_id", type=str)
147
+ @click.option(
148
+ "--chain/--summary",
149
+ default=False,
150
+ help="Print the full chain (default) vs. just the lineage_id + step count.",
151
+ )
152
+ def lineage_cmd(lineage_id: str, chain: bool) -> None:
153
+ try:
154
+ ch = default_store().get(lineage_id)
155
+ except LineageNotFoundError:
156
+ click.echo(f"lineage_id not found: {lineage_id}", err=True)
157
+ sys.exit(5)
158
+ if chain:
159
+ click.echo(json.dumps(serialize_chain(ch), indent=2))
160
+ else:
161
+ click.echo(
162
+ json.dumps(
163
+ {
164
+ "lineage_id": ch.lineage_id,
165
+ "step_count": len(ch.steps),
166
+ "producers": [s.producer for s in ch.steps],
167
+ },
168
+ indent=2,
169
+ )
170
+ )