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.
- checksums.yaml +4 -4
- data/Makefile +27 -5
- data/docs/_documentation/en/33-reusable-workflows.md +28 -3
- data/docs/_documentation/en/40-distribution.md +57 -19
- data/docs/_documentation/en/43-workspace-path-policies.md +13 -0
- data/docs/_documentation/en/46-live-runtime.md +6 -3
- data/docs/_documentation/en/47-private-practice-pdfs.md +130 -0
- data/docs/agents/consolidation-0.7.2.md +97 -0
- data/docs/agents/d0-runtime-identity.md +22 -5
- data/docs/agents/deploy-provenance-86.md +86 -0
- data/docs/agents/deployment-integration-0.7.1.md +142 -0
- data/docs/agents/manual-authoring-components.md +23 -0
- data/docs/agents/mcp-contract.md +112 -6
- data/docs/agents/owner-closeout-85.md +269 -0
- data/docs/agents/owner-closeout-93.md +185 -0
- data/docs/agents/practice-editions-98.md +816 -0
- data/docs/agents/practice-pdf-installable-98.md +250 -0
- data/docs/agents/practice-runtime-archives-98.md +147 -0
- data/docs/agents/private-practice-verifier-98.md +296 -0
- data/docs/agents/source-ci-98.md +148 -0
- data/scripts/check_practice_verifier_runtime.py +69 -0
- data/scripts/export_practice_runtime_archives.py +138 -0
- data/scripts/manual/filters/practice-finish.lua +36 -0
- data/scripts/manual/filters/practice-layout.lua +84 -0
- data/scripts/manual/practice_pdf.py +31 -0
- data/scripts/manual/render_practice.py +125 -0
- data/scripts/manual/templates/practice.tex +95 -0
- data/scripts/prepare_practice_candidate.py +159 -0
- data/scripts/prepare_practice_editions_candidate.py +119 -0
- data/scripts/test_wheel_install.py +16 -1
- data/scripts/unaltraweb-mcp-bootstrap.sh +1 -1
- data/scripts/validate_distribution.py +51 -5
- data/scripts/validate_workflows.py +33 -4
- data/src/unaltraweb_mcp/component-contract.json +59 -22
- data/src/unaltraweb_mcp/component-contract.schema.json +35 -2
- data/src/unaltraweb_mcp/distribution.py +42 -2
- data/src/unaltraweb_mcp/editorial_sources.py +62 -4
- 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"])
|