unaltraweb 0.7.1 → 0.7.3
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 +26 -4
- data/docs/_documentation/en/40-distribution.md +33 -10
- data/docs/_documentation/en/43-workspace-path-policies.md +13 -0
- 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 +8 -0
- data/docs/agents/deployment-integration-0.7.1.md +4 -0
- data/docs/agents/manual-authoring-components.md +23 -0
- data/docs/agents/mcp-contract.md +109 -4
- 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/qgis-0.6.1-adoption-109.md +110 -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 +20 -0
- data/scripts/unaltraweb-mcp-bootstrap.sh +1 -1
- data/scripts/validate_distribution.py +18 -1
- data/src/unaltraweb_mcp/component-contract.json +28 -17
- data/src/unaltraweb_mcp/editorial_sources.py +62 -4
- metadata +20 -2
|
@@ -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"])
|
|
@@ -76,6 +76,11 @@ def main() -> int:
|
|
|
76
76
|
for installed_path, source_path in launcher_files.items():
|
|
77
77
|
if archive.read(launcher_prefix + installed_path) != (ROOT / source_path).read_bytes():
|
|
78
78
|
raise RuntimeError(f"Wheel launcher asset differs from source: {installed_path}")
|
|
79
|
+
for name in ("component-contract.json", "practice-verifier-runtime.json"):
|
|
80
|
+
if archive.read("unaltraweb_mcp/" + name) != (ROOT / "src/unaltraweb_mcp" / name).read_bytes():
|
|
81
|
+
raise RuntimeError(f"Wheel dependency declaration differs from source: {name}")
|
|
82
|
+
if any(name.startswith(("veure_qgis_mcp/", "unaltracaptura_annotations/")) for name in names):
|
|
83
|
+
raise RuntimeError("Private QGIS implementation leaked into the public wheel")
|
|
79
84
|
forbidden_roots = {"docs", "scripts", "_layouts", "_includes", "_sass"}
|
|
80
85
|
leaked = []
|
|
81
86
|
for name in names:
|
|
@@ -103,6 +108,10 @@ def main() -> int:
|
|
|
103
108
|
"unaltraweb_mcp/editorial_sources.py",
|
|
104
109
|
"unaltraweb_mcp/image_backgrounds.py",
|
|
105
110
|
"unaltraweb_mcp/image_probe.py",
|
|
111
|
+
"unaltraweb_mcp/source_checks.py",
|
|
112
|
+
"unaltraweb_mcp/source-check-policy-v1.schema.json",
|
|
113
|
+
"unaltraweb_mcp/practice_verifier.py",
|
|
114
|
+
"unaltraweb_mcp/practice-verifier-runtime.json",
|
|
106
115
|
"unaltraweb_mcp/scaffolds/common/AGENTS.md.tmpl",
|
|
107
116
|
"unaltraweb_mcp/scaffolds/common/Makefile.tmpl",
|
|
108
117
|
"unaltraweb_mcp/scaffolds/common/README.md.tmpl",
|
|
@@ -117,6 +126,7 @@ def main() -> int:
|
|
|
117
126
|
"unaltraweb_mcp/scaffolds/common/.github/dependabot.yml.tmpl",
|
|
118
127
|
"unaltraweb_mcp/scaffolds/common/.github/pull_request_template.md",
|
|
119
128
|
"unaltraweb_mcp/scaffolds/common/.github/workflows/deploy.yml.tmpl",
|
|
129
|
+
"unaltraweb_mcp/scaffolds/common/.github/workflows/source-checks.yml.tmpl",
|
|
120
130
|
]
|
|
121
131
|
missing = [name for name in required if name not in names]
|
|
122
132
|
if missing:
|
|
@@ -129,6 +139,10 @@ def main() -> int:
|
|
|
129
139
|
python = environment / "bin/python"
|
|
130
140
|
cli = environment / "bin/unaltraweb-mcp"
|
|
131
141
|
run([str(python), "-m", "pip", "install", "--no-deps", str(wheels[0])], cwd=temp, env=env)
|
|
142
|
+
source_cli = environment / "bin/unaltraweb-source-checks"
|
|
143
|
+
source_help = run([str(source_cli), "--help"], cwd=temp).stdout
|
|
144
|
+
if "--base-sha" not in source_help or "--reports" not in source_help:
|
|
145
|
+
raise RuntimeError("Installed source-CI entrypoint is missing its bounded interface")
|
|
132
146
|
docker_cli = environment / "bin/unaltraweb-mcp-docker"
|
|
133
147
|
launcher = Path(run([str(docker_cli), "path"], cwd=temp).stdout.strip())
|
|
134
148
|
if launcher != environment / "share/unaltraweb-launcher":
|
|
@@ -178,6 +192,11 @@ def main() -> int:
|
|
|
178
192
|
], cwd=temp).stdout)
|
|
179
193
|
if not created["ok"]:
|
|
180
194
|
raise RuntimeError(f"new-web failed for {profile} from clean wheel: {created}")
|
|
195
|
+
source_policy = json.loads((profile_site / ".unaltraweb/source-checks.json").read_bytes())
|
|
196
|
+
if source_policy["profile"] != profile:
|
|
197
|
+
raise RuntimeError("Source-CI policy did not declare the generated profile")
|
|
198
|
+
if not (profile_site / ".github/workflows/source-checks.yml").is_file():
|
|
199
|
+
raise RuntimeError("Source-CI workflow control is missing")
|
|
181
200
|
readme = (profile_site / "README.md").read_text(encoding="utf-8")
|
|
182
201
|
agents = (profile_site / "AGENTS.md").read_text(encoding="utf-8")
|
|
183
202
|
collaboration = (profile_site / ".github/CONTRIBUTING.md").read_text(encoding="utf-8")
|
|
@@ -317,6 +336,7 @@ def main() -> int:
|
|
|
317
336
|
"Gemfile.lock",
|
|
318
337
|
".github/pull_request_template.md",
|
|
319
338
|
".github/workflows/deploy.yml",
|
|
339
|
+
".github/workflows/source-checks.yml",
|
|
320
340
|
}
|
|
321
341
|
if set(manifest["files"]) != expected_managed:
|
|
322
342
|
raise RuntimeError(f"generated {profile} scaffold baseline is incomplete: {manifest['files']}")
|
|
@@ -15,7 +15,7 @@ Managed mode requires an immutable reference or an expected full image ID.
|
|
|
15
15
|
USAGE
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:
|
|
18
|
+
image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:6aa20cff86c3891a876e37ba0548fa36f3c5633c9f22ba672e9f50123d677cf6}"
|
|
19
19
|
project="${MCP_CONSUMER_WORKSPACE:-${UNALTRAWEB_PROJECT:-}}"
|
|
20
20
|
operation=serve
|
|
21
21
|
expected_image="${UNALTRAWEB_EXPECTED_IMAGE_ID:-}"
|
|
@@ -41,6 +41,7 @@ from unaltraweb_mcp.distribution import ( # noqa: E402
|
|
|
41
41
|
validate_component_contract,
|
|
42
42
|
)
|
|
43
43
|
from scripts.validate_workflows import DEPLOY_PDF_PROVENANCE_POLICY_SHA256, load_workflow_text, reusable_deploy_input_errors # noqa: E402
|
|
44
|
+
from scripts import check_practice_verifier_runtime as practice_verifier # noqa: E402
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def load_yaml(path: Path) -> dict[str, Any]:
|
|
@@ -111,6 +112,9 @@ def validate(root: Path = ROOT) -> list[str]:
|
|
|
111
112
|
required_components = {
|
|
112
113
|
"gem", "wheel", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf", "diavisuals", "vegavisuals"
|
|
113
114
|
}
|
|
115
|
+
if practice_verifier.COMPONENT in contract.get("components", {}):
|
|
116
|
+
required_components.add(practice_verifier.COMPONENT)
|
|
117
|
+
errors.extend(practice_verifier.publication_errors(contract))
|
|
114
118
|
if contract.get("schema_version") != 1:
|
|
115
119
|
errors.append("component contract schema_version must be 1")
|
|
116
120
|
if set(contract.get("components", {})) != required_components:
|
|
@@ -121,6 +125,10 @@ def validate(root: Path = ROOT) -> list[str]:
|
|
|
121
125
|
if __version__ != version:
|
|
122
126
|
errors.append(f"wheel version {__version__} != contract version {version}")
|
|
123
127
|
errors.extend(component_version_errors(contract))
|
|
128
|
+
if practice_verifier.required(contract):
|
|
129
|
+
dockerfile = (root / "Dockerfile.mcp").read_text()
|
|
130
|
+
if "install_practice_verifier" in dockerfile or re.search(r"(?im)^\s*(?:RUN|COPY|ADD).*unaltracaptura", dockerfile):
|
|
131
|
+
errors.append("Public MCP image must not download or embed the private QGIS package")
|
|
124
132
|
included = {name for name, item in contract["components"].items() if item["included_in_wheel"]}
|
|
125
133
|
if included != {"wheel"}:
|
|
126
134
|
errors.append(f"wheel must not bundle external components: {sorted(included)}")
|
|
@@ -212,6 +220,9 @@ def validate(root: Path = ROOT) -> list[str]:
|
|
|
212
220
|
if make_value(root / "packaging/launcher/Makefile", "MCP_RELEASE_IMAGE") != component_reference("mcp"):
|
|
213
221
|
errors.append("installed launcher must select its own package release image")
|
|
214
222
|
project = pyproject.get("project", {})
|
|
223
|
+
dependencies = [*project.get("dependencies", []), *(entry for values in project.get("optional-dependencies", {}).values() for entry in values)]
|
|
224
|
+
if any(re.search(r"unaltracaptura[-_]qgis", str(entry), re.I) for entry in dependencies):
|
|
225
|
+
errors.append("Public Web wheel must not install the private QGIS package as a dependency")
|
|
215
226
|
if "version" in project or project.get("dynamic") != ["version"]:
|
|
216
227
|
errors.append("pyproject version must be derived from the package component contract")
|
|
217
228
|
if pyproject.get("tool", {}).get("setuptools", {}).get("dynamic", {}).get("version", {}).get("attr") != "unaltraweb_mcp.__version__":
|
|
@@ -515,7 +526,7 @@ def publish_ref_errors(
|
|
|
515
526
|
default_branch: str,
|
|
516
527
|
component_ids: list[str],
|
|
517
528
|
) -> list[str]:
|
|
518
|
-
errors: list[str] =
|
|
529
|
+
errors: list[str] = practice_verifier.publication_errors(contract)
|
|
519
530
|
release = contract.get("release", {})
|
|
520
531
|
version = str(release.get("version") or "")
|
|
521
532
|
release_tag = str(release.get("tag") or "")
|
|
@@ -590,6 +601,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
590
601
|
component_id for component_id, selected in contract["components"].items()
|
|
591
602
|
if selected["release_status"] == "pending"
|
|
592
603
|
)
|
|
604
|
+
verifier_errors = practice_verifier.publication_errors(contract)
|
|
605
|
+
if verifier_errors and practice_verifier.COMPONENT not in pending:
|
|
606
|
+
pending.append(practice_verifier.COMPONENT)
|
|
607
|
+
pending.sort()
|
|
593
608
|
unavailable = sorted(
|
|
594
609
|
component_id for component_id, selected in contract["components"].items()
|
|
595
610
|
if selected["release_status"] == "unavailable"
|
|
@@ -612,6 +627,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
612
627
|
"errors": errors,
|
|
613
628
|
"pending_releases": pending,
|
|
614
629
|
"unavailable_releases": unavailable,
|
|
630
|
+
"practice_verifier": {"selection_ready": not verifier_errors, "errors": verifier_errors, "origin": "prepared-image",
|
|
631
|
+
"private_checks": {"state": "not_run", "reason": "Public CI uses metadata/unit fixtures; native private acceptance and review are separate required gates"}},
|
|
615
632
|
}, indent=2, sort_keys=True))
|
|
616
633
|
readiness_errors = release_tag_errors + candidate_receipt_errors
|
|
617
634
|
structural_errors = [error for error in errors if error not in readiness_errors]
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"$schema": "component-contract.schema.json",
|
|
3
3
|
"schema_version": 1,
|
|
4
4
|
"release": {
|
|
5
|
-
"version": "0.7.
|
|
6
|
-
"tag": "v0.7.
|
|
5
|
+
"version": "0.7.3",
|
|
6
|
+
"tag": "v0.7.3"
|
|
7
7
|
},
|
|
8
8
|
"consumer_integration": {
|
|
9
9
|
"schema_version": 1,
|
|
@@ -55,17 +55,17 @@
|
|
|
55
55
|
"package_only_commands": ["doctor", "import-calibre", "new-web", "version"],
|
|
56
56
|
"factory_required_commands": ["factory-dir"],
|
|
57
57
|
"package_only_mcp": ["artifact-import-check", "import-artifact-bundle", "bibliography-add-entry", "bibliography-inventory", "build-health", "content-approval-inventory", "content-freshness-check", "content-inventory", "detect-site", "editorial-policy", "editorial-publication-check", "editorial-review-prepare", "editorial-review-record", "editorial-review-resolve", "editorial-status", "html-audit", "http-check", "image-background-check", "initialize-site", "language-policy", "list-tools", "manual-authoring-capabilities", "manual-editorial-quality-check", "manual-pdf-preview-clean", "manual-source-quality-check", "new-web", "preview-start", "preview-status", "preview-stop", "profile-check", "profile-prune", "profile-prune-plan", "prose-check", "scaffold-sync", "site-context", "site-doctor", "site-source-delete", "site-source-read", "site-source-write", "starter-templates", "translation-plan"],
|
|
58
|
-
"factory_required_mcp": ["bibliometrics-check", "bibliometrics-fetch-scimago", "bibliometrics-update", "build-site", "manual-computation-check", "manual-computation-render", "manual-computation-render-figures", "manual-computation-status", "manual-pdf-build", "manual-pdf-preview-prepare", "manual-pdf-publish", "manual-pdf-status", "manual-release-check", "manual-release-prepare", "manual-release-status", "prompts", "serve", "site-check", "web-capture-check", "web-capture-render", "web-capture-status"],
|
|
59
|
-
"not_bundled": ["gem", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf", "diavisuals", "vegavisuals"]
|
|
58
|
+
"factory_required_mcp": ["bibliometrics-check", "bibliometrics-fetch-scimago", "bibliometrics-update", "build-site", "manual-computation-check", "manual-computation-render", "manual-computation-render-figures", "manual-computation-status", "manual-pdf-build", "manual-pdf-preview-prepare", "manual-pdf-publish", "manual-pdf-status", "manual-practice-compose", "manual-practice-pdf-build", "manual-release-check", "manual-release-prepare", "manual-release-status", "prompts", "serve", "site-check", "web-capture-check", "web-capture-render", "web-capture-status"],
|
|
59
|
+
"not_bundled": ["gem", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf", "diavisuals", "vegavisuals", "qgis_practice_verifier"]
|
|
60
60
|
},
|
|
61
61
|
"components": {
|
|
62
62
|
"gem": {
|
|
63
63
|
"kind": "gem",
|
|
64
64
|
"name": "unaltraweb",
|
|
65
|
-
"version": "0.7.
|
|
66
|
-
"release": "v0.7.
|
|
65
|
+
"version": "0.7.3",
|
|
66
|
+
"release": "v0.7.3",
|
|
67
67
|
"release_status": "ready",
|
|
68
|
-
"reference": "unaltraweb (= 0.7.
|
|
68
|
+
"reference": "unaltraweb (= 0.7.3)",
|
|
69
69
|
"repository": "https://github.com/dosquartsdedocs/unaltraweb",
|
|
70
70
|
"included_in_wheel": false,
|
|
71
71
|
"features": ["jekyll-core", "site-build"]
|
|
@@ -73,21 +73,21 @@
|
|
|
73
73
|
"wheel": {
|
|
74
74
|
"kind": "python-wheel",
|
|
75
75
|
"name": "unaltraweb-mcp",
|
|
76
|
-
"version": "0.7.
|
|
77
|
-
"release": "v0.7.
|
|
76
|
+
"version": "0.7.3",
|
|
77
|
+
"release": "v0.7.3",
|
|
78
78
|
"release_status": "ready",
|
|
79
|
-
"reference": "unaltraweb-mcp==0.7.
|
|
79
|
+
"reference": "unaltraweb-mcp==0.7.3",
|
|
80
80
|
"repository": "https://github.com/dosquartsdedocs/unaltraweb",
|
|
81
81
|
"included_in_wheel": true,
|
|
82
|
-
"features": ["calibre-import", "doctor", "new-web", "offline-inspection", "mcp-control-plane"]
|
|
82
|
+
"features": ["calibre-import", "doctor", "new-web", "offline-inspection", "mcp-control-plane", "source-ci"]
|
|
83
83
|
},
|
|
84
84
|
"runtime": {
|
|
85
85
|
"kind": "container",
|
|
86
86
|
"name": "unaltraweb runtime",
|
|
87
|
-
"version": "0.7.
|
|
88
|
-
"release": "v0.7.
|
|
87
|
+
"version": "0.7.3",
|
|
88
|
+
"release": "v0.7.3",
|
|
89
89
|
"release_status": "ready",
|
|
90
|
-
"reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.7.
|
|
90
|
+
"reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.7.3",
|
|
91
91
|
"image_repository": "ghcr.io/dosquartsdedocs/unaltraweb",
|
|
92
92
|
"repository": "https://github.com/dosquartsdedocs/unaltraweb",
|
|
93
93
|
"included_in_wheel": false,
|
|
@@ -96,10 +96,10 @@
|
|
|
96
96
|
"mcp": {
|
|
97
97
|
"kind": "container",
|
|
98
98
|
"name": "unaltraweb MCP runtime",
|
|
99
|
-
"version": "0.7.
|
|
100
|
-
"release": "v0.7.
|
|
99
|
+
"version": "0.7.3",
|
|
100
|
+
"release": "v0.7.3",
|
|
101
101
|
"release_status": "ready",
|
|
102
|
-
"reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.7.
|
|
102
|
+
"reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.7.3",
|
|
103
103
|
"image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-mcp",
|
|
104
104
|
"repository": "https://github.com/dosquartsdedocs/unaltraweb",
|
|
105
105
|
"included_in_wheel": false,
|
|
@@ -153,6 +153,17 @@
|
|
|
153
153
|
"included_in_wheel": false,
|
|
154
154
|
"features": ["manual-pdf"]
|
|
155
155
|
},
|
|
156
|
+
"qgis_practice_verifier": {
|
|
157
|
+
"kind": "companion",
|
|
158
|
+
"name": "unaltracaptura-qgis",
|
|
159
|
+
"version": "0.6.1",
|
|
160
|
+
"release": "v0.6.1",
|
|
161
|
+
"release_status": "released",
|
|
162
|
+
"reference": "https://github.com/dosquartsdedocs/unaltracaptura-qgis.git@v0.6.1",
|
|
163
|
+
"repository": "https://github.com/dosquartsdedocs/unaltracaptura-qgis",
|
|
164
|
+
"included_in_wheel": false,
|
|
165
|
+
"features": ["qgis-practice-v1", "qgis-practice-v2", "private-prepared-runtime"]
|
|
166
|
+
},
|
|
156
167
|
"diavisuals": {
|
|
157
168
|
"kind": "companion",
|
|
158
169
|
"name": "diavisuals",
|
|
@@ -21,6 +21,8 @@ CONTENT_ROOTS = ("_pages", "_posts", "_news", "_projects", "_outputs", "_chapter
|
|
|
21
21
|
TEXT_SUFFIXES = {".md", ".markdown", ".html", ".qmd", ".rmd"}
|
|
22
22
|
PUBLIC_FIELDS = {"title", "short_title", "description", "excerpt", "summary", "bio", "caption", "alt"}
|
|
23
23
|
GENRES = {"prose", "bio", "news", "chapter", "procedure", "reference", "preface", "quote", "example", "cv"}
|
|
24
|
+
PRACTICE_SOURCE = re.compile(r"practiques/([a-z0-9][a-z0-9-]{0,63})/(ca|es|en)/(alumnat|docent)/(LLEGIU-ME|LEEME|README)\.md\Z")
|
|
25
|
+
PRACTICE_NAMES = {"ca": "LLEGIU-ME", "es": "LEEME", "en": "README"}
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
def default_language(config: dict[str, Any]) -> str:
|
|
@@ -107,6 +109,51 @@ def relative_path(raw: str) -> str:
|
|
|
107
109
|
return raw
|
|
108
110
|
|
|
109
111
|
|
|
112
|
+
def private_practice_readings(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
113
|
+
"""One bounded author-declared mapping shared by private review and PDFs."""
|
|
114
|
+
section = (config.get("unaltraweb") or {}).get("practices") or {}
|
|
115
|
+
if not isinstance(section, dict):
|
|
116
|
+
raise EditorialError("unaltraweb.practices must be a mapping")
|
|
117
|
+
rows = section.get("readings", [])
|
|
118
|
+
if not isinstance(rows, list) or len(rows) > 128:
|
|
119
|
+
raise EditorialError("Private reading mappings must be a bounded list")
|
|
120
|
+
result, identities, names = {}, set(), set()
|
|
121
|
+
for row in rows:
|
|
122
|
+
if (not isinstance(row, dict) or not {"source", "slug", "lang", "recipient"} <= row.keys()
|
|
123
|
+
or set(row) - {"source", "slug", "lang", "recipient", "bindings"}
|
|
124
|
+
or not all(isinstance(row[key], str) for key in ("source", "slug", "lang", "recipient"))):
|
|
125
|
+
raise EditorialError("Private reading mapping requires source/slug/lang/recipient")
|
|
126
|
+
if "bindings" in row and (not isinstance(row["bindings"], list) or len(row["bindings"]) > 200):
|
|
127
|
+
raise EditorialError("Reading resource bindings must be a bounded list")
|
|
128
|
+
source = relative_path(row["source"])
|
|
129
|
+
if (Path(source).parts[0] not in {"practiques", "_practices"} or Path(source).suffix != ".md"
|
|
130
|
+
or len(Path(source).parts) < 3 or any(part.startswith(".") for part in Path(source).parts)
|
|
131
|
+
or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", row["slug"])
|
|
132
|
+
or row["lang"] not in PRACTICE_NAMES or row["recipient"] not in {"alumnat", "docent"}):
|
|
133
|
+
raise EditorialError("Invalid confined private reading mapping")
|
|
134
|
+
identity = (row["slug"], row["lang"], row["recipient"])
|
|
135
|
+
canonical = PRACTICE_SOURCE.fullmatch(source)
|
|
136
|
+
if canonical and (canonical.groups()[:3] != identity or canonical[4] != PRACTICE_NAMES[row["lang"]]):
|
|
137
|
+
raise EditorialError("Mapping conflicts with the canonical reading identity")
|
|
138
|
+
other = "docent" if row["recipient"] == "alumnat" else "alumnat"
|
|
139
|
+
if other in Path(source).parts or source.casefold() in names or identity in identities:
|
|
140
|
+
raise EditorialError("Duplicate or cross-recipient private reading mapping")
|
|
141
|
+
result[source] = dict(row)
|
|
142
|
+
names.add(source.casefold())
|
|
143
|
+
identities.add(identity)
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def private_practice_identity(config: dict[str, Any], source: str) -> dict[str, Any] | None:
|
|
148
|
+
declared = private_practice_readings(config)
|
|
149
|
+
if source in declared:
|
|
150
|
+
return declared[source]
|
|
151
|
+
match = PRACTICE_SOURCE.fullmatch(source)
|
|
152
|
+
if match and match[4] == PRACTICE_NAMES[match[2]]:
|
|
153
|
+
return {"source": source, "slug": match[1], "lang": match[2], "recipient": match[3]}
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
110
157
|
class Reader:
|
|
111
158
|
"""Descriptor-relative reads; never follows an input symlink or creates files."""
|
|
112
159
|
def __init__(self, root: Path, *, max_bytes: int | None = None, max_total_bytes: int | None = None):
|
|
@@ -427,8 +474,10 @@ def corpus(reader: Reader, config: dict[str, Any], profile: str, target: str = "
|
|
|
427
474
|
extra = config.get("collections") or {}
|
|
428
475
|
if not manual_only and isinstance(extra, dict):
|
|
429
476
|
roots.update("_" + key for key in extra if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", str(key)))
|
|
477
|
+
roots.difference_update({"practiques", "_practices"})
|
|
478
|
+
private = private_practice_identity(config, target) if target and profile == "unaltremanual" else None
|
|
430
479
|
root_pages = reader.root_pages() if not manual_only else []
|
|
431
|
-
if target and target != "_config.yml" and target.split("/")[0] not in roots and target not in root_pages:
|
|
480
|
+
if target and not private and target != "_config.yml" and target.split("/")[0] not in roots and target not in root_pages:
|
|
432
481
|
raise EditorialError("Editorial target must be reader-facing content or public metadata.")
|
|
433
482
|
paths = ([target] if Path(target).suffix else reader.walk(target)) if target else [path for root in sorted(roots) for path in reader.walk(root)]
|
|
434
483
|
if not target:
|
|
@@ -443,7 +492,7 @@ def corpus(reader: Reader, config: dict[str, Any], profile: str, target: str = "
|
|
|
443
492
|
raise EditorialError("Site languages must be a list of language codes.")
|
|
444
493
|
sources, fragments, skipped = {}, [], []
|
|
445
494
|
for path in sorted(set(paths)):
|
|
446
|
-
if any(isinstance(pattern, str) and (fnmatch.fnmatch(path, pattern) or path.startswith(pattern.rstrip("/") + "/")) for pattern in excludes):
|
|
495
|
+
if not private and any(isinstance(pattern, str) and (fnmatch.fnmatch(path, pattern) or path.startswith(pattern.rstrip("/") + "/")) for pattern in excludes):
|
|
447
496
|
skipped.append({"path": path, "reason": "excluded by site configuration"})
|
|
448
497
|
continue
|
|
449
498
|
suffix = Path(path).suffix.lower()
|
|
@@ -456,6 +505,8 @@ def corpus(reader: Reader, config: dict[str, Any], profile: str, target: str = "
|
|
|
456
505
|
continue
|
|
457
506
|
lang = next((part for part in Path(path).parts if part in languages), default_lang)
|
|
458
507
|
genre = "news" if path.startswith(("_news/", "_posts/")) else "chapter" if path.startswith(f"_{collection}/") else "reference" if profile == "unaltredocs" else "prose"
|
|
508
|
+
if private:
|
|
509
|
+
lang, genre = private["lang"], "procedure"
|
|
459
510
|
if metadata:
|
|
460
511
|
if suffix == ".json":
|
|
461
512
|
data = strict_json(content)
|
|
@@ -469,12 +520,18 @@ def corpus(reader: Reader, config: dict[str, Any], profile: str, target: str = "
|
|
|
469
520
|
profiles = [profiles]
|
|
470
521
|
if not isinstance(profiles, list) or any(not isinstance(item, str) for item in profiles):
|
|
471
522
|
raise EditorialError(f"Invalid profiles in {path}.")
|
|
472
|
-
if front.get("
|
|
523
|
+
if private and front.get("lang", lang) != lang:
|
|
524
|
+
raise EditorialError("Reading language differs from its declared mapping")
|
|
525
|
+
if not private and (front.get("published") is False or (profiles and profile not in profiles)):
|
|
473
526
|
skipped.append({"path": path, "reason": "not published in this profile"})
|
|
474
527
|
continue
|
|
475
528
|
if manual_only and path.startswith("_pages/") and front.get("layout") not in {"manual-home", "manual-chapter"} and "unaltremanual" not in profiles:
|
|
476
529
|
continue
|
|
477
530
|
sources[path] = {"path": path, "sha256": digest(content.encode()), "text": content, "editable_source": path}
|
|
531
|
+
if private:
|
|
532
|
+
sources[path].update(scope="private-practice", reading=private)
|
|
533
|
+
for unit in units:
|
|
534
|
+
unit["scope"] = "private-practice"
|
|
478
535
|
fragments.extend(units)
|
|
479
536
|
if len(fragments) > MAX_FRAGMENTS:
|
|
480
537
|
raise EditorialError("Editorial fragment budget exceeded; select a smaller target.")
|
|
@@ -501,4 +558,5 @@ def corpus(reader: Reader, config: dict[str, Any], profile: str, target: str = "
|
|
|
501
558
|
if target and not sources:
|
|
502
559
|
raise EditorialError(f"No selected publishable sources for target: {target}")
|
|
503
560
|
return {"sources": sources, "fragments": fragments, "skipped": skipped,
|
|
504
|
-
"coverage": "
|
|
561
|
+
"coverage": ("Explicitly selected private practice reading; no publication or author approval implied." if private else
|
|
562
|
+
"Markdown/HTML prose and public metadata fields; code, math and quotations require semantic/rendered review.")}
|