unaltraweb 0.7.0 → 0.7.2

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +27 -5
  3. data/docs/_documentation/en/33-reusable-workflows.md +28 -3
  4. data/docs/_documentation/en/40-distribution.md +57 -19
  5. data/docs/_documentation/en/43-workspace-path-policies.md +13 -0
  6. data/docs/_documentation/en/46-live-runtime.md +6 -3
  7. data/docs/_documentation/en/47-private-practice-pdfs.md +130 -0
  8. data/docs/agents/consolidation-0.7.2.md +97 -0
  9. data/docs/agents/d0-runtime-identity.md +22 -5
  10. data/docs/agents/deploy-provenance-86.md +86 -0
  11. data/docs/agents/deployment-integration-0.7.1.md +142 -0
  12. data/docs/agents/manual-authoring-components.md +23 -0
  13. data/docs/agents/mcp-contract.md +112 -6
  14. data/docs/agents/owner-closeout-85.md +269 -0
  15. data/docs/agents/owner-closeout-93.md +185 -0
  16. data/docs/agents/practice-editions-98.md +816 -0
  17. data/docs/agents/practice-pdf-installable-98.md +250 -0
  18. data/docs/agents/practice-runtime-archives-98.md +147 -0
  19. data/docs/agents/private-practice-verifier-98.md +296 -0
  20. data/docs/agents/source-ci-98.md +148 -0
  21. data/scripts/check_practice_verifier_runtime.py +69 -0
  22. data/scripts/export_practice_runtime_archives.py +138 -0
  23. data/scripts/manual/filters/practice-finish.lua +36 -0
  24. data/scripts/manual/filters/practice-layout.lua +84 -0
  25. data/scripts/manual/practice_pdf.py +31 -0
  26. data/scripts/manual/render_practice.py +125 -0
  27. data/scripts/manual/templates/practice.tex +95 -0
  28. data/scripts/prepare_practice_candidate.py +159 -0
  29. data/scripts/prepare_practice_editions_candidate.py +119 -0
  30. data/scripts/test_wheel_install.py +16 -1
  31. data/scripts/unaltraweb-mcp-bootstrap.sh +1 -1
  32. data/scripts/validate_distribution.py +51 -5
  33. data/scripts/validate_workflows.py +33 -4
  34. data/src/unaltraweb_mcp/component-contract.json +59 -22
  35. data/src/unaltraweb_mcp/component-contract.schema.json +35 -2
  36. data/src/unaltraweb_mcp/distribution.py +42 -2
  37. data/src/unaltraweb_mcp/editorial_sources.py +62 -4
  38. metadata +22 -2
@@ -0,0 +1,36 @@
1
+ -- Consume the core caption filter's output, preserving its short captions/credits.
2
+ local continuation = "continued"
3
+ local function metadata(meta)
4
+ continuation = pandoc.utils.stringify(meta["continuation-label"] or "continued")
5
+ end
6
+
7
+ local function figures(blocks)
8
+ local result, figure = pandoc.List(), nil
9
+ for _, block in ipairs(blocks) do
10
+ if block.t == "RawBlock" and block.text == "\\begin{figure}[H]\n\\centering" then
11
+ assert(not figure, "Nested practice figure")
12
+ figure = pandoc.List()
13
+ elseif figure and block.t == "RawBlock" and block.text == "\\end{figure}" then
14
+ assert(#figure == 2, "Practice figures require one caption and one image")
15
+ local caption = pandoc.write(pandoc.Pandoc({figure[1]}), "latex"):gsub("%s+$", "")
16
+ local image = pandoc.write(pandoc.Pandoc({figure[2]}), "latex"):gsub("%s+$", "")
17
+ result:insert(pandoc.RawBlock("latex", "\\PracticeFigure{" .. caption .. "}{" .. image .. "}"))
18
+ figure = nil
19
+ elseif figure then figure:insert(block)
20
+ else result:insert(block) end
21
+ end
22
+ assert(not figure, "Unclosed practice figure")
23
+ return result
24
+ end
25
+
26
+ local function table_caption(element)
27
+ assert(#element.caption.long > 0, "Practice tables require a Table: caption")
28
+ local rendered = pandoc.write(pandoc.Pandoc({element}), "latex")
29
+ local caption = pandoc.write(pandoc.Pandoc(element.caption.long), "latex"):gsub("%s+$", "")
30
+ rendered = rendered:gsub("\\endfirsthead", function()
31
+ return "\\endfirsthead\n\\caption[]{" .. caption .. " (" .. continuation .. ")}\\tabularnewline"
32
+ end, 1)
33
+ return pandoc.RawBlock("latex", rendered)
34
+ end
35
+
36
+ return {{Meta = metadata}, {Blocks = figures, Table = table_caption}}
@@ -0,0 +1,84 @@
1
+ -- Private practice PDFs use their own counters, pagination and image policy.
2
+ local assets = {}
3
+
4
+ local function metadata(meta)
5
+ for original, target in pairs(meta["practice-assets"] or {}) do
6
+ assets[original] = pandoc.utils.stringify(target)
7
+ end
8
+ end
9
+
10
+ local function image(element)
11
+ local target = assets[element.src]
12
+ assert(target, "Undeclared practice image: " .. element.src)
13
+ element.src = target
14
+ return element
15
+ end
16
+
17
+ local function figure(element)
18
+ assert(#element.content == 1 and element.content[1].content
19
+ and #element.content[1].content == 1 and element.content[1].content[1].t == "Image",
20
+ "Practice figures require one standalone image")
21
+ local picture = element.content[1].content[1]
22
+ picture.attributes.width = nil
23
+ picture.attributes.height = nil
24
+ if picture.title ~= "" then
25
+ element.caption.long = pandoc.read(picture.title, "markdown").blocks
26
+ end
27
+ assert(#element.caption.long > 0, "Practice figures require a caption")
28
+ return element
29
+ end
30
+
31
+ local function line_count(text)
32
+ local _, count = text:gsub("\n", "")
33
+ return count + 1
34
+ end
35
+
36
+ local function blocks(values)
37
+ local result = pandoc.List()
38
+ for index, element in ipairs(values) do
39
+ local needed = nil
40
+ if element.t == "Header" then
41
+ needed = 6
42
+ local following = values[index + 1]
43
+ if following and following.t == "Table" then needed = 9 end
44
+ if following and following.t == "CodeBlock" and line_count(following.text) <= 12 then
45
+ needed = line_count(following.text) + 7
46
+ end
47
+ elseif element.t == "CodeBlock" and line_count(element.text) <= 12 then
48
+ needed = line_count(element.text) + 4
49
+ end
50
+ if needed then
51
+ result:insert(pandoc.RawBlock("latex", "\\Needspace{" .. needed .. "\\baselineskip}"))
52
+ end
53
+ result:insert(element)
54
+ end
55
+ return result
56
+ end
57
+
58
+ local function table_widths(element)
59
+ if #element.colspecs < 3 then return nil end
60
+ for _, column in ipairs(element.colspecs) do
61
+ if column[2] and column[2] ~= 0 then return nil end
62
+ end
63
+ local weights, counts, longest = {}, {}, {}
64
+ for index in ipairs(element.colspecs) do weights[index], counts[index], longest[index] = 0, 0, 0 end
65
+ for _, body in ipairs(element.bodies) do
66
+ for _, row in ipairs(body.body) do
67
+ for index, cell in ipairs(row.cells) do
68
+ local text = pandoc.utils.stringify(cell.contents)
69
+ weights[index] = weights[index] + utf8.len(text)
70
+ counts[index] = counts[index] + 1
71
+ for word in text:gmatch("%S+") do longest[index] = math.max(longest[index], utf8.len(word)) end
72
+ end
73
+ end
74
+ end
75
+ local total = 0
76
+ for index in ipairs(weights) do
77
+ weights[index] = math.max(12, longest[index] * 0.9, math.min(90, weights[index] / math.max(1, counts[index]) * 0.65))
78
+ total = total + weights[index]
79
+ end
80
+ for index, weight in ipairs(weights) do element.colspecs[index] = {element.colspecs[index][1], weight / total} end
81
+ return element
82
+ end
83
+
84
+ return {{Meta = metadata}, {Image = image}, {Figure = figure, Table = table_widths}, {Blocks = blocks}}
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env python3
2
+ """Consumer-bound entry point used by the factory Make contract."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ FACTORY = Path(__file__).resolve().parents[2]
10
+ sys.path.insert(0, str(FACTORY / "src"))
11
+ from unaltraweb_mcp.distribution import consumer_integration
12
+ from unaltraweb_mcp.practice_pdf import build
13
+
14
+
15
+ def main():
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("--project", required=True)
18
+ args = parser.parse_args()
19
+ try:
20
+ result = build(Path(args.project), FACTORY, os.environ.get("PRACTICE_SOURCE", ""),
21
+ os.environ.get("PRACTICE_VERSION", ""), run=os.environ.get("PRACTICE_RUN", ""),
22
+ image=os.environ.get("MANUAL_PDF_IMAGE") or consumer_integration()["manual_pdf_image"],
23
+ dry_run=os.environ.get("PRACTICE_DRY_RUN", "0") == "1")
24
+ except (OSError, ValueError, RuntimeError) as exc:
25
+ result = {"ok": False, "publishes": False, "error": str(exc)}
26
+ print(json.dumps(result, ensure_ascii=False, indent=2))
27
+ return 0 if result["ok"] else 1
28
+
29
+
30
+ if __name__ == "__main__":
31
+ raise SystemExit(main())
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """Run only inside the bounded PDF toolchain with /source readonly and /out writable."""
3
+ import json
4
+ from pathlib import Path
5
+ import re
6
+ import subprocess
7
+ from urllib.parse import unquote, urlsplit
8
+
9
+ SOURCE = Path("/source")
10
+ OUTPUT = Path("/out")
11
+ MARKDOWN = "markdown+fenced_divs+pipe_tables+link_attributes-raw_tex-raw_html"
12
+
13
+
14
+ def run(command, log):
15
+ subprocess.run(command, cwd=OUTPUT, check=True, stdout=log, stderr=subprocess.STDOUT)
16
+
17
+
18
+ def effective_source(request, log):
19
+ """Apply the controller's authorised links to the actual Pandoc AST.
20
+
21
+ This is a lookup/fail-closed rendering pass, not another filesystem resolver.
22
+ The exact effective AST/Markdown are retained with the render artifacts.
23
+ """
24
+ assert request.get("portable_links") == "portable-links.json"
25
+ raw = (SOURCE / "portable-links.json").read_bytes()
26
+ if len(raw) > 1024*1024:
27
+ raise ValueError("Portable link context exceeds its bound")
28
+ links = json.loads(raw)
29
+ assert links["kind"] == "unaltraweb-practice-reading-links" and links["schema_version"] == 1
30
+ mapping = {entry["href"]: entry["target"] for entry in links["links"]}
31
+ run(["pandoc", str(SOURCE / "source.md"), "--from="+MARKDOWN, "--to=json", "--output=source.parsed.json"], log)
32
+ parsed = OUTPUT / "source.parsed.json"
33
+ if parsed.stat().st_size > 16*1024*1024:
34
+ raise ValueError("Practice AST exceeds its bound")
35
+ document = json.loads(parsed.read_bytes())
36
+ pending, count, applied = [(document, 0)], 0, []
37
+ while pending:
38
+ value, depth = pending.pop()
39
+ count += 1
40
+ if count > 200000 or depth > 128:
41
+ raise ValueError("Practice AST exceeds its structural bound")
42
+ if isinstance(value, dict):
43
+ if value.get("t") == "Link":
44
+ target = value["c"][-1]
45
+ href = target[0]
46
+ uri = urlsplit(href)
47
+ if href in mapping:
48
+ target[0] = mapping[href]
49
+ applied.append({"source_href": href, "target_href": target[0]})
50
+ elif not href.startswith("#") and uri.scheme.lower() not in {"https", "http", "mailto"}:
51
+ raise ValueError("Pandoc found a local/unsupported reading link without an authorised portable mapping: " + href)
52
+ pending.extend((child, depth+1) for child in value.values())
53
+ elif isinstance(value, list):
54
+ pending.extend((child, depth+1) for child in value)
55
+ effective = OUTPUT / "source.effective.json"
56
+ effective.write_text(json.dumps(document, ensure_ascii=False, sort_keys=True)+"\n")
57
+ run(["pandoc", str(effective), "--from=json", "--to=markdown", "--wrap=none", "--output=source.effective.md"], log)
58
+ return links, applied
59
+
60
+
61
+ def check_pdf_links(pdf, context):
62
+ allowed = {row["destination"] for row in context["targets"].values()}
63
+ observed = []
64
+ for page_number, page in enumerate(pdf.pages, 1):
65
+ for reference in page.get("/Annots", []):
66
+ annotation = reference.get_object()
67
+ if annotation.get("/Subtype") != "/Link" or annotation.get("/A") is None:
68
+ continue
69
+ action = annotation["/A"].get_object()
70
+ kind = str(action.get("/S"))
71
+ if kind == "/GoTo":
72
+ continue
73
+ if kind not in {"/URI", "/GoToR", "/Launch"}:
74
+ raise ValueError("Unsupported action in a reading link")
75
+ value = action.get("/URI") if kind == "/URI" else action.get("/F")
76
+ if hasattr(value, "get_object"):
77
+ value = value.get_object()
78
+ if isinstance(value, dict):
79
+ value = value.get("/UF") or value.get("/F")
80
+ if not isinstance(value, str):
81
+ raise ValueError("Reading link has no bounded file/URI target")
82
+ uri = urlsplit(value)
83
+ if uri.scheme.lower() in {"https", "http", "mailto"} or value.startswith("#"):
84
+ continue
85
+ if uri.scheme or uri.netloc or uri.query or unquote(uri.path, errors="strict") not in allowed:
86
+ raise ValueError("Final PDF contains a local target outside its portable recipient selection: " + value)
87
+ observed.append({"page": page_number, "kind": kind, "target": value})
88
+ return observed
89
+
90
+
91
+ def main():
92
+ request = json.loads((SOURCE / "request.json").read_text())
93
+ basename = request["basename"]
94
+ assert basename in {"LLEGIU-ME", "LEEME", "README"}
95
+ filters = request["filters"]
96
+ assert filters == ["practice-layout.lua", "code-blocks.lua", "figure-captions.lua", "practice-finish.lua"]
97
+ with (OUTPUT / "render.log").open("x") as log:
98
+ context, applied = effective_source(request, log)
99
+ command = ["pandoc", str(OUTPUT / "source.effective.json"), "--standalone", "--from=json",
100
+ "--number-sections", "--top-level-division=section", "--metadata-file=/source/metadata.yml",
101
+ "--template=/source/practice.tex", "--resource-path=/source", "-t", "latex", "-o", "practice.tex"]
102
+ for name in filters:
103
+ command.append("--lua-filter=/source/" + name)
104
+ run(command, log)
105
+ for _ in range(3):
106
+ run(["xelatex", "-no-shell-escape", "-interaction=nonstopmode", "-halt-on-error", "practice.tex"], log)
107
+ run(["qpdf", "--deterministic-id", "--object-streams=generate", "--recompress-flate",
108
+ "--compression-level=9", "practice.pdf", basename + ".pdf"], log)
109
+ run(["pdftoppm", "-f", "1", "-singlefile", "-png", "-r", "110", basename + ".pdf", "preview"], log)
110
+ latex_log = (OUTPUT / "practice.log").read_text(errors="replace")
111
+ diagnostics = [line for line in latex_log.splitlines() if re.search(r"Overfull|Underfull|Warning:|Missing character", line)]
112
+ from pypdf import PdfReader
113
+ pdf = PdfReader(OUTPUT / (basename + ".pdf"))
114
+ if not pdf.pages or any(float(page.mediabox.width) < float(page.mediabox.height) for page in pdf.pages):
115
+ raise ValueError("Practice PDF must contain landscape pages")
116
+ local_links = check_pdf_links(pdf, context)
117
+ report = {"pages": len(pdf.pages), "diagnostics": diagnostics,
118
+ "portable_link_context": context["context"], "applied_links": applied, "local_pdf_links": local_links,
119
+ "review_required": True, "publishes": False}
120
+ (OUTPUT / "render-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
121
+ print(json.dumps(report, ensure_ascii=False))
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
@@ -0,0 +1,95 @@
1
+ % Article wrapper appended to the factory-owned manual typographic preamble.
2
+ \usepackage{fancyhdr}
3
+ \usepackage{lastpage}
4
+ \pagestyle{fancy}
5
+ \fancyhf{}
6
+ \fancyhead[L]{\small\sffamily\color{ManualMuted}$course-short-title$}
7
+ \fancyhead[R]{\small\sffamily\color{ManualMuted}$short-title$}
8
+ \fancyfoot[L]{\footnotesize\sffamily\color{ManualMuted}v$practice-version$ · $lang$}
9
+ \fancyfoot[C]{\footnotesize\sffamily\color{ManualMuted}$if(draft)$$draft-label$$endif$}
10
+ \fancyfoot[R]{\footnotesize\sffamily\color{ManualMuted}\thepage/\pageref*{LastPage}}
11
+ \renewcommand{\headrulewidth}{0.2pt}
12
+ \renewcommand{\footrulewidth}{0pt}
13
+ \fancypagestyle{practicefirst}{\fancyhead{}\renewcommand{\headrulewidth}{0pt}}
14
+ \titleformat{\section}{\normalfont\sffamily\Large\bfseries\color{ManualSecondary}}{\thesection}{0.55em}{}
15
+ \titleformat{\subsection}{\normalfont\large\bfseries\color{ManualSecondary}}{\thesubsection}{0.6em}{}
16
+ \titlespacing*{\section}{0pt}{1.1em}{0.45em}
17
+ \titlespacing*{\subsection}{0pt}{0.9em}{0.35em}
18
+ \setcounter{secnumdepth}{2}
19
+ \setlength{\emergencystretch}{3em}
20
+ \setlist[enumerate]{before=\raggedright}
21
+ \setlength{\LTleft}{\fill}
22
+ \setlength{\LTright}{\fill}
23
+ \setlength{\LTcapwidth}{\textwidth}
24
+ \captionsetup[figure]{format=plain,justification=centering,singlelinecheck=false,margin=0pt,aboveskip=0pt,belowskip=0pt}
25
+ \captionsetup[table]{format=plain,justification=centering,singlelinecheck=false,margin=0pt,skip=14pt}
26
+
27
+ % Measure the caption once, then fit the image to the remaining text rectangle.
28
+ % The complete unit moves to the next page instead of shrinking into a page tail.
29
+ \newsavebox{\PracticeCaptionBox}
30
+ \newsavebox{\PracticeImageBox}
31
+ \newsavebox{\PracticeFigureBox}
32
+ \newlength{\PracticeImageHeight}
33
+ \newlength{\PracticeFigureHeight}
34
+ \newlength{\PracticeCaptionGap}
35
+ \setlength{\PracticeCaptionGap}{14pt}
36
+ \newcommand{\PracticeFigure}[2]{%
37
+ \par\begingroup
38
+ \setlength{\parskip}{0pt}%
39
+ \setbox\PracticeCaptionBox=\vbox{\hsize=\linewidth\captionsetup{type=figure}#1\par}%
40
+ \setlength{\PracticeImageHeight}{\dimexpr\textheight-\ht\PracticeCaptionBox-\dp\PracticeCaptionBox-\PracticeCaptionGap-2pt\relax}%
41
+ \ifdim\PracticeImageHeight<\baselineskip
42
+ \PackageError{unaltraweb-practice}{Figure caption exceeds the usable page height}{Shorten the caption or split the figure.}%
43
+ \fi
44
+ \setkeys{Gin}{width=\linewidth,height=\PracticeImageHeight,keepaspectratio}%
45
+ \sbox{\PracticeImageBox}{#2}%
46
+ \setbox\PracticeFigureBox=\vbox{%
47
+ \hsize=\linewidth\offinterlineskip
48
+ \hbox to\linewidth{\hfil\usebox{\PracticeCaptionBox}\hfil}%
49
+ \vskip\PracticeCaptionGap
50
+ \hbox to\linewidth{\hfil\usebox{\PracticeImageBox}\hfil}%
51
+ }%
52
+ \setlength{\PracticeFigureHeight}{\dimexpr\ht\PracticeFigureBox+\dp\PracticeFigureBox+2pt\relax}%
53
+ \Needspace{\PracticeFigureHeight}%
54
+ \nointerlineskip\usebox{\PracticeFigureBox}\par
55
+ \endgroup\addvspace{1em}%
56
+ }
57
+
58
+ \begin{document}
59
+ \thispagestyle{practicefirst}
60
+ {\setlength{\parskip}{0pt}%
61
+ \noindent
62
+ \begin{minipage}[c]{0.65\linewidth}
63
+ $if(series-logo)$
64
+ \begin{minipage}[c]{17mm}
65
+ \setlength{\fboxsep}{2mm}%
66
+ \colorbox{ManualBand}{\includegraphics[width=13mm,height=10.4mm,keepaspectratio]{$series-logo$}}%
67
+ \end{minipage}%
68
+ \hspace{3mm}%
69
+ $endif$
70
+ \begin{minipage}[c]{0.76\linewidth}
71
+ {\sffamily\bfseries\color{ManualPrimary}$series$}\par
72
+ {\small\sffamily\color{ManualMuted}$audience-label$}\par
73
+ \end{minipage}
74
+ \end{minipage}%
75
+ \hfill
76
+ \begin{minipage}[c]{0.32\linewidth}
77
+ $if(cover-logo)$\raggedleft\includegraphics[width=58mm,height=26mm,keepaspectratio]{$cover-logo$}$endif$
78
+ \end{minipage}\par
79
+ \vspace{4mm}
80
+ {\small\sffamily\bfseries\color{ManualSecondary}$subject$$if(subject-codes)$ ($subject-codes$)$endif$}\par
81
+ \vspace{1mm}
82
+ {\sffamily\fontsize{9.2}{11.6}\selectfont\color{ManualMuted}%
83
+ $for(teaching-guides)$
84
+ $teaching-guides.degree$\par
85
+ $endfor$
86
+ $if(institution-line)$$institution-line$\par$endif$
87
+ $if(author)$$author$ · $endif$$practice-version-label$ $practice-version$\par
88
+ }
89
+ \vspace{2mm}{\color{ManualSecondary!40}\hrule}
90
+ }
91
+ \vspace{4mm}
92
+ {\LARGE\sffamily\bfseries\color{ManualSecondary}$title$}\par
93
+ \vspace{1mm}
94
+ $body$
95
+ \end{document}
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env python3
2
+ """Explicit offline preparation of the approved #101 practice-PDF source slice.
3
+
4
+ Produces a local candidate, never a release or registry publication. The source
5
+ archive is taken from an exact committed revision; no W1 worktree files enter it.
6
+ The prepared public controller/PDF images and existing host build dependencies
7
+ are required. Normal consumer launch/build commands do not invoke this script.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import gzip
13
+ import hashlib
14
+ import importlib.util
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import threading
22
+ import time
23
+
24
+ ROOT = Path(__file__).resolve().parents[1]
25
+ SOURCE = "d37ef4c64eb7d2f2e7ef83cf037640ff42a964e0"
26
+ BASE = "ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:6aa20cff86c3891a876e37ba0548fa36f3c5633c9f22ba672e9f50123d677cf6"
27
+ BASE_ID = "sha256:f0e7915527fa68a67448287e131ec431aeae902b8269856e4e219e67faade146"
28
+ PDF = "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:1bb3f2dafd741e96c28639ec1cc8ccaeb81cbaa73e649f787c0b4ef59997bd4b"
29
+ PDF_ID = "sha256:618163d3f924439792b2306617bd9612f566c5881166102fa544b4e782d569fa"
30
+ APPROVED_PDF_SHA256 = "c92ec8a0937e9c9c735171d889e5bbca19b7ccd9ccbe8d33da2b6c0e49f9a245"
31
+
32
+
33
+ def sha256(path):
34
+ digest = hashlib.sha256()
35
+ with Path(path).open("rb") as source:
36
+ while piece := source.read(1024*1024):
37
+ digest.update(piece)
38
+ return digest.hexdigest()
39
+
40
+
41
+ def run(command, *, environment=None, timeout=300, log=None):
42
+ result = subprocess.run(command, cwd=ROOT, env=environment, capture_output=True, text=True, timeout=timeout)
43
+ if log is not None:
44
+ Path(log).write_text(result.stdout + result.stderr)
45
+ if result.returncode:
46
+ raise RuntimeError(f"Command failed ({result.returncode}): {command}\n{result.stdout[-12000:]}\n{result.stderr[-12000:]}")
47
+ return result.stdout
48
+
49
+
50
+ def image(reference, expected=None):
51
+ value = json.loads(run(["docker", "image", "inspect", reference]))[0]
52
+ if expected and value["Id"] != expected:
53
+ raise RuntimeError("Prepared image differs from the expected immutable configuration ID")
54
+ if "@sha256:" in reference and reference not in (value.get("RepoDigests") or []):
55
+ raise RuntimeError("Prepared image does not retain the selected repository digest")
56
+ if (value["Os"], value["Architecture"]) != ("linux", "amd64"):
57
+ raise RuntimeError("This candidate acceptance selects Linux/amd64")
58
+ return {"reference": reference, "image_id": value["Id"], "repo_digests": value.get("RepoDigests") or [],
59
+ "source_revision": (value["Config"].get("Labels") or {}).get("org.opencontainers.image.revision"),
60
+ "size": value["Size"], "os": value["Os"], "architecture": value["Architecture"]}
61
+
62
+
63
+ def export_image(identifier, destination):
64
+ """Stream a bounded Docker-save archive; never buffer image layers in RAM."""
65
+ process = subprocess.Popen(["docker", "image", "save", identifier], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
66
+ total, deadline = 0, time.monotonic()+600
67
+ watchdog = threading.Timer(600, process.kill)
68
+ watchdog.daemon = True
69
+ watchdog.start()
70
+ try:
71
+ with destination.open("xb") as raw, gzip.GzipFile(fileobj=raw, filename="", mode="wb", compresslevel=1, mtime=0) as output:
72
+ while piece := process.stdout.read(1024*1024):
73
+ total += len(piece)
74
+ if total > 8*1024**3 or time.monotonic() > deadline:
75
+ raise RuntimeError("Image archive exceeds its preparation budget")
76
+ output.write(piece)
77
+ process.stdout.close()
78
+ error = process.stderr.read(32768).decode(errors="replace")
79
+ if process.wait(timeout=10):
80
+ raise RuntimeError("Docker image export failed: " + error)
81
+ except BaseException:
82
+ process.kill()
83
+ process.wait()
84
+ raise
85
+ finally:
86
+ watchdog.cancel()
87
+ process.stdout.close()
88
+ process.stderr.close()
89
+ return {"path": destination.name, "sha256": sha256(destination), "bytes": destination.stat().st_size,
90
+ "uncompressed_bytes": total, "format": "docker-save-gzip"}
91
+
92
+
93
+ def prepare(output):
94
+ if Path(run(["git", "rev-parse", "--show-toplevel"]).strip()) != ROOT:
95
+ raise RuntimeError("Candidate preparation requires the authoritative factory checkout")
96
+ output = Path(output).absolute()
97
+ if output.resolve() != output or not output.is_relative_to(ROOT / "tmp"):
98
+ raise RuntimeError("Use a new, non-symlink candidate directory under the factory tmp/")
99
+ if os.path.lexists(output):
100
+ raise FileExistsError("Candidate destination already exists: " + str(output))
101
+ if any(importlib.util.find_spec(name) is None for name in ("setuptools", "wheel", "pip")):
102
+ raise RuntimeError("Prepare host setuptools/wheel/pip explicitly before this offline build")
103
+ base, pdf = image(BASE, BASE_ID), image(PDF, PDF_ID)
104
+ output.mkdir(parents=True, exist_ok=False)
105
+ packages = output / "packages"
106
+ packages.mkdir()
107
+ archive = output / "source.tar.gz"
108
+ run(["git", "archive", "--format=tar.gz", "--output", str(archive), SOURCE])
109
+ if archive.stat().st_size > 128*1024**2:
110
+ raise RuntimeError("Approved source archive exceeds the preparation bound")
111
+ epoch = run(["git", "show", "-s", "--format=%ct", SOURCE]).strip()
112
+ environment = {**os.environ, "PIP_NO_INDEX": "1", "SOURCE_DATE_EPOCH": epoch, "PYTHONHASHSEED": "0", "TZ": "UTC"}
113
+ run([sys.executable, "-m", "pip", "wheel", str(archive), "--no-index", "--no-deps", "--no-build-isolation",
114
+ "--wheel-dir", str(packages)], environment=environment, log=output / "wheel-build.log")
115
+ wheels = list(packages.glob("*.whl"))
116
+ if len(wheels) != 1 or wheels[0].name != "unaltraweb_mcp-0.7.1-py3-none-any.whl":
117
+ raise RuntimeError("Unexpected candidate wheel inventory")
118
+ dockerfile = ROOT / "packaging/practice-candidate/Dockerfile"
119
+ shutil.copyfile(dockerfile, output / "Dockerfile")
120
+ (output / ".dockerignore").write_text("*\n!Dockerfile\n!source.tar.gz\n!packages/\n!packages/*.whl\n")
121
+ if shutil.disk_usage(output).free < base["size"] + 1024**3:
122
+ raise RuntimeError("Insufficient free space for the bounded portable image export")
123
+ run(["docker", "build", "--pull=false", "--network", "none", "--build-arg", "BASE_MCP_IMAGE=" + BASE,
124
+ "--build-arg", "SOURCE_SHA=" + SOURCE, "--iidfile", str(output / "image.id"),
125
+ "--file", str(output / "Dockerfile"), str(output)], timeout=600, log=output / "image-build.log")
126
+ identifier = (output / "image.id").read_text().strip()
127
+ candidate_image = image(identifier, identifier)
128
+ if candidate_image["source_revision"] != SOURCE:
129
+ raise RuntimeError("Built controller does not identify the approved source")
130
+ exported = export_image(identifier, output / "controller.docker.tar.gz")
131
+ contract = json.loads(run(["git", "show", SOURCE + ":src/unaltraweb_mcp/component-contract.json"]))
132
+ checker_files = ("__init__.py", "distribution.py", "component-contract.schema.json", "artifact_imports.py",
133
+ "artifact_handoff_v1.py", "artifact-handoff-v1.schema.json", "letter_bundle.py", "pdf_probe.py", "processes.py")
134
+ checker_hashes = {name: hashlib.sha256(run(["git", "show", SOURCE + ":src/unaltraweb_mcp/" + name]).encode()).hexdigest()
135
+ for name in checker_files}
136
+ template_paths = ["scripts/manual/templates/practice.tex", "scripts/manual/templates/manual.tex",
137
+ "scripts/manual/filters/practice-layout.lua", "scripts/manual/filters/practice-finish.lua"]
138
+ templates = {name: hashlib.sha256(run(["git", "show", SOURCE + ":" + name]).encode()).hexdigest() for name in template_paths}
139
+ manifest = {"kind": "unaltraweb.practice-pdf-candidate", "schema_version": 1, "published": False,
140
+ "candidate_id": "practice-pdf-98-" + SOURCE[:12], "source_revision": SOURCE,
141
+ "package_version": "0.7.1", "version_note": "Post-release source candidate; select by these hashes, never as the published 0.7.1 wheel/image",
142
+ "approved_review_pdf_sha256": APPROVED_PDF_SHA256, "template_hashes": templates,
143
+ "source_archive": {"path": archive.name, "sha256": sha256(archive), "bytes": archive.stat().st_size},
144
+ "wheel": {"path": "packages/" + wheels[0].name, "sha256": sha256(wheels[0]), "bytes": wheels[0].stat().st_size},
145
+ "controller": candidate_image, "controller_base": base, "controller_archive": exported, "pdf_worker": pdf,
146
+ "consumer_integration": contract["consumer_integration"], "checker_files": checker_hashes,
147
+ "preparation": {"network": "none", "implicit_pull": False, "builder_sha256": sha256(__file__), "dockerfile_sha256": sha256(dockerfile)}}
148
+ (output / "candidate.json").write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n")
149
+ (output / "install-requirements.txt").write_text(f"{wheels[0].resolve().as_uri()} --hash=sha256:{manifest['wheel']['sha256']}\n")
150
+ print(json.dumps({"ok": True, "candidate": str(output / "candidate.json"), "candidate_sha256": sha256(output / "candidate.json"),
151
+ "controller_image_id": identifier, "wheel_sha256": manifest["wheel"]["sha256"]}, indent=2))
152
+ return manifest
153
+
154
+
155
+ if __name__ == "__main__":
156
+ parser = argparse.ArgumentParser(description=__doc__)
157
+ parser.add_argument("--output", type=Path, required=True)
158
+ arguments = parser.parse_args()
159
+ prepare(arguments.output)
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """Explicit offline successor build: approved base + exact owner source overlays.
3
+
4
+ No commit, publication, source worktree copy or runtime allocation is performed.
5
+ The GIS domain code is installed only from the selected hash-bound producer wheel.
6
+ Historical private/local acceptance only: its embedded-QGIS images must not be
7
+ published as public Web images. The public build now delegates to a prepared image.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import gzip
13
+ import hashlib
14
+ import io
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ import shutil
19
+ import sys
20
+ import tarfile
21
+
22
+ from prepare_practice_candidate import ROOT, SOURCE, BASE, BASE_ID, PDF, PDF_ID, image, run, sha256, export_image
23
+
24
+ OVERLAYS = (
25
+ "Makefile", "mcp-factory.yml", "pyproject.toml",
26
+ "src/unaltraweb_mcp/practice_editions.py", "src/unaltraweb_mcp/practice-edition-v1.schema.json",
27
+ "src/unaltraweb_mcp/practice_pdf.py", "src/unaltraweb_mcp/editorial_sources.py",
28
+ "src/unaltraweb_mcp/site_tools.py", "src/unaltraweb_mcp/mcp_server.py", "src/unaltraweb_mcp/cli.py",
29
+ "src/unaltraweb_mcp/component-contract.json",
30
+ "scripts/manual/render_practice.py",
31
+ )
32
+ TRANSFER = Path("/tmp/opencode/practice32-qgis-0.6.0rc1-round32-r2")
33
+ GIS_SHA = "82f2c6772fa8bb9aaae08237494af72df7e103038643fd6dfcdcd6f1ac45ed5f"
34
+
35
+
36
+ def prepare(output, gis_wheel, gis_sha, profiles=("qgis-practice-v1",)):
37
+ if not profiles or len(profiles) != len(set(profiles)) or set(profiles) - {"qgis-practice-v1", "qgis-practice-v2"}:
38
+ raise ValueError("Select explicit supported producer profiles")
39
+ output = output.absolute()
40
+ if output.resolve() != output or not output.is_relative_to(ROOT / "tmp") or output.exists():
41
+ raise ValueError("Select a new confined candidate directory under tmp/")
42
+ if sha256(gis_wheel) != gis_sha:
43
+ raise ValueError("Producer wheel differs from the selected SHA-256")
44
+ base, pdf = image(BASE, BASE_ID), image(PDF, PDF_ID)
45
+ output.mkdir(parents=True, exist_ok=False)
46
+ packages = output / "packages"
47
+ packages.mkdir()
48
+ original = output / "approved-source.tar.gz"
49
+ run(["git", "archive", "--format=tar.gz", "--output", str(original), SOURCE])
50
+ epoch = int(run(["git", "show", "-s", "--format=%ct", SOURCE]).strip())
51
+ changes = {}
52
+ for name in OVERLAYS:
53
+ path = ROOT / name
54
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > 2*1024*1024:
55
+ raise ValueError("Unsafe candidate overlay: " + name)
56
+ changes[name] = path.read_bytes()
57
+ source = output / "source.tar.gz"
58
+ with original.open("rb") as raw_source, tarfile.open(fileobj=raw_source, mode="r:gz") as previous, \
59
+ source.open("xb") as raw_dest, gzip.GzipFile(fileobj=raw_dest, mode="wb", filename="", mtime=0) as compressed, \
60
+ tarfile.open(fileobj=compressed, mode="w|") as snapshot:
61
+ for member in previous:
62
+ if member.name not in changes:
63
+ snapshot.addfile(member, previous.extractfile(member) if member.isfile() else None)
64
+ for name, raw in sorted(changes.items()):
65
+ info = tarfile.TarInfo(name)
66
+ info.size, info.mode, info.mtime = len(raw), 0o644, epoch
67
+ snapshot.addfile(info, io.BytesIO(raw))
68
+ source_sha = sha256(source)
69
+ env = {**os.environ, "PIP_NO_INDEX": "1", "SOURCE_DATE_EPOCH": str(epoch), "PYTHONHASHSEED": "0", "TZ": "UTC"}
70
+ run([sys.executable, "-m", "pip", "wheel", str(source), "--no-index", "--no-deps", "--no-build-isolation", "--wheel-dir", str(packages)],
71
+ environment=env, log=output / "wheel-build.log")
72
+ web_wheel = next(packages.glob("unaltraweb_mcp-*.whl"))
73
+ shutil.copyfile(gis_wheel, packages / gis_wheel.name)
74
+ dockerfile = ROOT / "packaging/practice-editions-candidate/Dockerfile"
75
+ shutil.copyfile(dockerfile, output / "Dockerfile")
76
+ (output / ".dockerignore").write_text("*\n!Dockerfile\n!source.tar.gz\n!packages/\n!packages/*.whl\n")
77
+ run(["docker", "build", "--pull=false", "--network", "none", "--build-arg", "BASE_MCP_IMAGE="+BASE,
78
+ "--build-arg", "SOURCE_SNAPSHOT_SHA256="+source_sha, "--iidfile", str(output / "image.id"), str(output)],
79
+ timeout=600, log=output / "image-build.log")
80
+ identifier = (output / "image.id").read_text().strip()
81
+ actual = image(identifier, identifier)
82
+ if actual["source_revision"] != "sha256:"+source_sha:
83
+ raise ValueError("Candidate source identity differs")
84
+ schema = json.loads(run(["docker", "run", "--rm", "--pull", "never", "--network", "none", "--read-only", "--entrypoint", "unaltracaptura-qgis", identifier, "practice-schema"]))
85
+ profile_records = {}
86
+ for profile in profiles:
87
+ observed = schema if profile == "qgis-practice-v1" else json.loads(run([
88
+ "docker", "run", "--rm", "--pull", "never", "--network", "none", "--read-only", "--entrypoint", "unaltracaptura-qgis", identifier,
89
+ "practice-schema", "--profile", profile]))
90
+ if observed.get("profile") != profile or observed.get("ok") is not True:
91
+ raise ValueError("Installed producer did not identify the requested domain profile")
92
+ profile_records[profile] = {"package": observed["installed_package"], "schema_sha256": observed["schema_sha256"]}
93
+ archive = export_image(identifier, output / "controller.docker.tar.gz")
94
+ contract = json.loads(changes["src/unaltraweb_mcp/component-contract.json"])
95
+ manifest = {"kind": "unaltraweb.practice-editions-candidate", "schema_version": 1, "published": False,
96
+ "source_base": SOURCE, "source_snapshot_sha256": source_sha,
97
+ "overlays": {name: hashlib.sha256(raw).hexdigest() for name, raw in sorted(changes.items())},
98
+ "version_note": "Unpublished hash-bound successor; package metadata remains 0.7.1 and is not the published 0.7.1 artifact",
99
+ "controller": actual, "controller_archive": archive, "controller_base": base, "pdf_worker": pdf,
100
+ "web_wheel": {"path": "packages/"+web_wheel.name, "sha256": sha256(web_wheel)},
101
+ "gis_wheel": {"path": "packages/"+gis_wheel.name, "sha256": gis_sha},
102
+ "gis_verifier": {"package": schema["installed_package"], "schema_sha256": schema["schema_sha256"]},
103
+ "gis_profiles": profile_records,
104
+ "composition_schema_sha256": sha256(ROOT / "src/unaltraweb_mcp/practice-edition-v1.schema.json"),
105
+ "consumer_integration": contract["consumer_integration"],
106
+ "builder_sha256": sha256(__file__), "dockerfile_sha256": sha256(dockerfile)}
107
+ (output / "candidate.json").write_text(json.dumps(manifest, sort_keys=True, indent=2)+"\n")
108
+ (output / "install-requirements.txt").write_text(f"{web_wheel.as_uri()} --hash=sha256:{sha256(web_wheel)}\n")
109
+ print(json.dumps({"ok": True, "candidate": str(output / "candidate.json"), "sha256": sha256(output / "candidate.json"), "image_id": identifier}, indent=2))
110
+
111
+
112
+ if __name__ == "__main__":
113
+ parser = argparse.ArgumentParser(description=__doc__)
114
+ parser.add_argument("--output", type=Path, required=True)
115
+ parser.add_argument("--gis-wheel", type=Path, default=TRANSFER / "unaltracaptura_qgis-0.6.0rc1-py3-none-any.whl")
116
+ parser.add_argument("--gis-sha256", default=GIS_SHA)
117
+ parser.add_argument("--gis-profile", action="append", choices=["qgis-practice-v1", "qgis-practice-v2"])
118
+ args = parser.parse_args()
119
+ prepare(args.output, args.gis_wheel, args.gis_sha256, args.gis_profile or ["qgis-practice-v1"])