Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,12 @@ different event from one that moved because it was wrong.
the registry imported all four adapters at startup (#47). An adapter is now
imported the first time it is created, and one that cannot be says which
module is missing; the other adapters, and the adapters list, are unaffected.

- `scripts/build_site.py --out` deleted whatever directory it was given unless
it was the repository, `site/`, or above them, so a typo such as `--out docs`
or `--out .git` deleted source or history (#50). It now deletes only a
directory that is empty or carries the `.plumbline-site` marker a build
writes, and refuses anything else before doing any work. A `_site` built
before this change has no marker, so it is refused once; remove it by hand.
- A row whose response named no model was never priced, because pricing
looked up only the model reported; the guard, which prices the requested
model, and the report then disagreed about the same run (#36). Such a row is
Expand Down
39 changes: 35 additions & 4 deletions scripts/build_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -858,15 +858,42 @@ def search_index(rendered: dict[str, Rendered], explainer: str) -> list[dict[str
return entries


#: Written into every site this script builds. The output directory is deleted
#: before a build, so only one that carries this, or holds nothing, is deleted.
MARKER = ".plumbline-site"


def refusal_to_clear(out: Path) -> str | None:
"""Why ``out`` must not be deleted and rebuilt, or None when it may be.

A path that does not exist, an empty directory, and a directory an earlier
build wrote may be cleared. Anything else is somebody's files, and a typo in
--out would otherwise delete the source, the history, or a home directory.
"""
out = out.resolve()
if out in (ROOT, SITE) or out in ROOT.parents or SITE in out.parents:
return f"{out} is the repository, the site sources, or above them"
if not out.exists():
return None
if not out.is_dir():
return f"{out} is a file, not a directory"
if (out / MARKER).is_file() or not any(out.iterdir()):
return None
return (
f"{out} is not a site this build made (it has files and no {MARKER}), so it "
"is not deleted. Choose a new or empty directory, or remove this one yourself."
)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--out", type=Path, default=ROOT / "_site", help="output directory")
args = parser.parse_args()
out: Path = args.out.resolve()
# The output directory is deleted and rebuilt, so it must be a directory of
# its own and never the repository, the site sources, or anything above them.
if out in (ROOT, SITE) or out in ROOT.parents or SITE in out.parents:
print(f"site build refused: {out} is not a safe output directory", file=sys.stderr)
# The output directory is deleted and rebuilt, so it is checked before any
# work is done.
if (refusal := refusal_to_clear(out)) is not None:
print(f"site build refused: {refusal}", file=sys.stderr)
return 1

try:
Expand All @@ -885,6 +912,10 @@ def main() -> int:
# vendor/ holds the markdown renderer, which runs here at build time; the
# pages it produces are finished HTML, so it is not shipped.
shutil.copytree(SITE, out, ignore=shutil.ignore_patterns("vendor"))
(out / MARKER).write_text(
"Built by scripts/build_site.py, which deletes this directory on its next build.\n",
encoding="utf-8",
)
(out / "example-run.json").write_text(json.dumps(example, indent=1) + "\n", encoding="utf-8")
(out / "index.html").write_text(explainer, encoding="utf-8")
(out / "search-index.json").write_text(
Expand Down
32 changes: 32 additions & 0 deletions tests/test_build_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,35 @@ def test_search_index_caps_long_sections(site: ModuleType) -> None:
rendered[site.DOCS[0].slug]["sections"][2]["text"] = "x" * 10_000
index = site.search_index(rendered, EXPLAINER)
assert max(len(entry["x"]) for entry in index) == site.SEARCH_TEXT_LIMIT


def test_out_refuses_a_directory_the_build_did_not_make(site: ModuleType, tmp_path: Path) -> None:
# The output directory is deleted before the build writes it, so a typo in
# --out must never reach source, history, or home.
for protected in (site.ROOT, site.SITE, site.ROOT / "docs", site.ROOT / "src"):
assert site.refusal_to_clear(protected) is not None
assert site.refusal_to_clear(site.ROOT / ".git") is not None
assert site.refusal_to_clear(Path.home()) is not None

ours = tmp_path / "keep"
ours.mkdir()
(ours / "notes.txt").write_text("mine", encoding="utf-8")
assert "not a site this build made" in site.refusal_to_clear(ours)

a_file = tmp_path / "a-file"
a_file.write_text("x", encoding="utf-8")
assert site.refusal_to_clear(a_file) is not None


def test_out_accepts_new_empty_or_previously_built_directories(
site: ModuleType, tmp_path: Path
) -> None:
assert site.refusal_to_clear(tmp_path / "new") is None
empty = tmp_path / "empty"
empty.mkdir()
assert site.refusal_to_clear(empty) is None
built = tmp_path / "built"
built.mkdir()
(built / site.MARKER).write_text("", encoding="utf-8")
(built / "index.html").write_text("<p>old</p>", encoding="utf-8")
assert site.refusal_to_clear(built) is None
Loading