feat: preflight - prerequisites checked and tool paths recorded before wikitool runs; launcher refuses without it (#151, POSIX half)
Files changed: - .claude/settings.json - .gitea/workflows/ci.yml - .gitea/workflows/nightly.yml - .gitea/workflows/release.yml - .gitea/workflows/tracker-live.yml - .github/hooks/wiki-trace.json - .gitignore - .vibe/hooks.toml - AGENTS.md - CHANGES.md - EVALS.md - INSTALL.md - README.md - VERSION - instructions/bootstrap.md - instructions/preflight.md - instructions/setup-instance.md - instructions/upgrade-instance.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/cli.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/config.py - tools/chemenu/corpus_cache.py - tools/chemenu/prerequisites.py - tools/chemenu/search/ripgrep.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_dist_upgrade.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_preflight.py - tools/chemenu/toolpaths.py - tools/preflight.sh - tools/prerequisites.txt - tools/run_wikitool.py - tools/trace-hook - tools/wikitool
This commit is contained in:
1 parent
5a731729f6
commit
e4b2b6d9b1
40 files changed
+1849
-125
No files matched your search
+12
-2
@@ -21,6 +21,7 @@ import typer.core as _typer_core
|
||||
_typer_core.HAS_RICH = False
|
||||
|
||||
from chemenu import cli_contract # noqa: E402 - after the HAS_RICH patch, which must land first
|
||||
from chemenu import toolpaths # noqa: E402
|
||||
|
||||
try:
|
||||
from chemenu.commands import (
|
||||
@@ -59,8 +60,9 @@ except ModuleNotFoundError as exc:
|
||||
# degradation or a raw traceback.
|
||||
sys.stderr.write(
|
||||
f"wikitool: missing required dependency '{exc.name}'.\n"
|
||||
"This is not optional - schema validation depends on it. Run:\n"
|
||||
" cd tools && .venv/bin/pip install -r requirements.txt\n"
|
||||
"This is not optional - schema validation depends on it. Run the preflight,\n"
|
||||
"which (re)installs tools/.venv from tools/requirements.txt:\n"
|
||||
" tools/preflight.sh\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -326,6 +328,14 @@ def _run_traced(command: str, args: list[str], charged: bool = False) -> None:
|
||||
code = exc.code
|
||||
exit_code = code if isinstance(code, int) else (0 if code is None else 1)
|
||||
raise
|
||||
except toolpaths.ToolPathError as exc:
|
||||
# Raised from wherever git or rg is about to start, often deep inside a
|
||||
# helper that treats a missing tool as "no answer". It is neither a
|
||||
# crash nor a validation error to retry: the fix is the preflight, so
|
||||
# it gets the ERROR line and exit 1 rather than a traceback.
|
||||
exit_code = 1
|
||||
print(f"ERROR {exc}", file=sys.stdout)
|
||||
raise SystemExit(1) from None
|
||||
except BaseException:
|
||||
exit_code = 1
|
||||
raise
|
||||
|
||||
@@ -54,6 +54,7 @@ from chemenu import (
|
||||
kb_state,
|
||||
ownership,
|
||||
toc,
|
||||
toolpaths,
|
||||
version as version_mod,
|
||||
)
|
||||
from chemenu.commands._util import console, fail, rel_path, success, today_iso
|
||||
@@ -953,7 +954,7 @@ def _git_working_tree_status() -> Optional[str]:
|
||||
repository at all - which is a valid, if unprotected, state for a tarball
|
||||
instance, not a reason to refuse."""
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(config.ROOT), "status", "--porcelain"],
|
||||
[toolpaths.git(), "-C", str(config.ROOT), "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
@@ -1515,6 +1516,7 @@ def run_upgrade(
|
||||
summary += (
|
||||
" Nothing was committed and nothing is verified yet."
|
||||
" `instructions/upgrade-instance.md` carries the order for everything that follows"
|
||||
" and resumes at `wikitool instructions sync`."
|
||||
" and resumes with the preflight (`tools/preflight.sh`), which `tools/wikitool` now"
|
||||
" refuses to run without."
|
||||
)
|
||||
success(summary)
|
||||
@@ -68,7 +68,7 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import blocks, cli_contract, config, conventions, kb_collections, markdown_code, toc, version as version_mod
|
||||
from chemenu import blocks, cli_contract, config, conventions, kb_collections, markdown_code, toc, toolpaths, version as version_mod
|
||||
from chemenu.commands import dist_cmd
|
||||
from chemenu.commands._util import fail, rel_path, success
|
||||
|
||||
@@ -842,7 +842,7 @@ def _git(args: list[str], stdin: Optional[str] = None) -> Optional[subprocess.Co
|
||||
are unknowable rather than wrong."""
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=config.ROOT, capture_output=True, text=True, input=stdin
|
||||
[toolpaths.git(), *args], cwd=config.ROOT, capture_output=True, text=True, input=stdin
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
@@ -11,6 +11,7 @@ remote yet, or no `WIKITOOL_SESSION_ID` set, is a valid state, not a fault.
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -20,7 +21,7 @@ from typing import Optional
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from chemenu import cli_contract, config, conventions, kb_collections, version as version_mod
|
||||
from chemenu import cli_contract, config, conventions, kb_collections, prerequisites, toolpaths, version as version_mod
|
||||
from chemenu.commands import git_publish, instructions_cmd
|
||||
from chemenu.commands._util import rel_path
|
||||
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
|
||||
@@ -40,33 +41,100 @@ class Check:
|
||||
def _git(args: list[str]) -> Optional[subprocess.CompletedProcess]:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=config.ROOT, capture_output=True, text=True, timeout=5
|
||||
[toolpaths.git(), *args], cwd=config.ROOT, capture_output=True, text=True, timeout=5
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
except (OSError, subprocess.SubprocessError, toolpaths.ToolPathError):
|
||||
# A broken tool-paths file is reported once, by `check_tool_paths` -
|
||||
# not as a crash from every check that happens to need git.
|
||||
return None
|
||||
|
||||
|
||||
PREFLIGHT_FIX = "Run tools/preflight.sh"
|
||||
|
||||
|
||||
def check_python() -> Check:
|
||||
version = sys.version_info
|
||||
if version < (3, 11):
|
||||
return Check(
|
||||
"python", "FAIL", f"Python {version.major}.{version.minor} found, need >= 3.11",
|
||||
"Install Python 3.11+ and recreate tools/.venv",
|
||||
f"Install Python 3.11+, then: {PREFLIGHT_FIX}",
|
||||
)
|
||||
return Check("python", "OK", f"Python {version.major}.{version.minor}.{version.micro}")
|
||||
|
||||
|
||||
def check_ripgrep() -> Check:
|
||||
if shutil.which("rg"):
|
||||
return Check("ripgrep", "OK", "rg found on PATH")
|
||||
try:
|
||||
rg = toolpaths.rg()
|
||||
except toolpaths.ToolPathError as exc:
|
||||
return Check("ripgrep", "FAIL", str(exc), PREFLIGHT_FIX)
|
||||
if shutil.which(rg):
|
||||
where = "on PATH" if rg == "rg" else f"at {rg}"
|
||||
return Check("ripgrep", "OK", f"rg found {where}")
|
||||
return Check(
|
||||
"ripgrep", "FAIL", "rg not found on PATH - `search` and `sources coverage` need it",
|
||||
"Install ripgrep (e.g. `apt install ripgrep` / `brew install ripgrep`)",
|
||||
"ripgrep", "FAIL", "rg not found - `search` and `sources coverage` need it",
|
||||
f"Install ripgrep (e.g. `apt install ripgrep` / `brew install ripgrep`), then: {PREFLIGHT_FIX}",
|
||||
)
|
||||
|
||||
|
||||
def check_tool_paths() -> Check:
|
||||
"""`.wikitool-tools.json`: written by a preflight that finished, and every
|
||||
path in it still there. The launcher refuses to start without a complete
|
||||
file, so a FAIL here is mostly a path that went away since - an uninstalled
|
||||
or moved tool."""
|
||||
name = toolpaths.FILE_NAME
|
||||
try:
|
||||
data = toolpaths.load()
|
||||
except toolpaths.ToolPathError as exc:
|
||||
return Check("tool-paths", "FAIL", str(exc), PREFLIGHT_FIX)
|
||||
if data is None:
|
||||
return Check(
|
||||
"tool-paths", "FAIL", f"{name} is missing - the preflight has not run in this checkout",
|
||||
PREFLIGHT_FIX,
|
||||
)
|
||||
if data.get("complete") is not True:
|
||||
return Check(
|
||||
"tool-paths", "FAIL", f"{name} is incomplete - the last preflight run stopped before the end",
|
||||
PREFLIGHT_FIX,
|
||||
)
|
||||
recorded = data["tools"]
|
||||
needed = [tool.name for tool in prerequisites.load_manifest().tools_for(prerequisites.platform())]
|
||||
unrecorded = [tool for tool in needed if not recorded.get(tool)]
|
||||
gone = [f"{tool} ({path})" for tool, path in sorted(recorded.items())
|
||||
if not (isinstance(path, str) and os.path.isfile(path))]
|
||||
problems = []
|
||||
if unrecorded:
|
||||
problems.append("not recorded: " + ", ".join(unrecorded))
|
||||
if gone:
|
||||
problems.append("no longer there: " + ", ".join(gone))
|
||||
if problems:
|
||||
return Check("tool-paths", "FAIL", f"{name}: " + "; ".join(problems), PREFLIGHT_FIX)
|
||||
return Check("tool-paths", "OK", f"{name}: " + ", ".join(sorted(recorded)) + " recorded and present")
|
||||
|
||||
|
||||
def check_install_dir() -> Check:
|
||||
"""The install folder against Windows' MAX_PATH, the same limit the
|
||||
preflight enforces before it unpacks anything."""
|
||||
problem = prerequisites.install_dir_problem()
|
||||
if problem is None:
|
||||
if prerequisites.platform() != "windows":
|
||||
detail = "no folder length limit on this platform"
|
||||
elif prerequisites.long_paths_enabled():
|
||||
detail = "Windows long paths are on - no folder length limit"
|
||||
else:
|
||||
detail = "fits Windows' path limit"
|
||||
return Check("install-dir", "OK", detail)
|
||||
return Check(
|
||||
"install-dir", "FAIL", problem[:1].upper() + problem[1:],
|
||||
"Move the wiki to a shorter folder (for example C:\\Chemenu) and run the preflight there, "
|
||||
"or have someone with administrator rights turn on long paths in Windows",
|
||||
)
|
||||
|
||||
|
||||
def check_author() -> Check:
|
||||
author = config.default_author()
|
||||
try:
|
||||
author = config.default_author()
|
||||
except toolpaths.ToolPathError as exc:
|
||||
return Check("author", "FAIL", f"`git config user.name` cannot be asked: {exc}", PREFLIGHT_FIX)
|
||||
if author is None:
|
||||
return Check(
|
||||
"author", "FAIL", "Neither $WIKI_AUTHOR nor `git config user.name` resolves",
|
||||
@@ -313,10 +381,8 @@ def check_publish_remotes() -> Check:
|
||||
f"Gate armed: {len(urls)} allowed push target(s) in "
|
||||
f"{config.PUBLISH_REMOTES_FILENAME}",
|
||||
)
|
||||
result = subprocess.run(
|
||||
["git", "remote"], cwd=config.ROOT, capture_output=True, text=True
|
||||
)
|
||||
remotes = [r for r in result.stdout.split() if r]
|
||||
result = _git(["remote"])
|
||||
remotes = [r for r in result.stdout.split() if r] if result is not None else []
|
||||
if len(remotes) > 1:
|
||||
return Check(
|
||||
"publish-remotes", "WARN",
|
||||
@@ -600,7 +666,9 @@ def check_kb_version() -> Check:
|
||||
def run_doctor() -> list[Check]:
|
||||
checks: list[Check] = [
|
||||
check_python(),
|
||||
check_tool_paths(),
|
||||
check_ripgrep(),
|
||||
check_install_dir(),
|
||||
check_author(),
|
||||
check_stack_version(),
|
||||
check_kb_version(),
|
||||
@@ -635,6 +703,13 @@ def run_doctor() -> list[Check]:
|
||||
"Checks dependencies (Python, ripgrep), author resolution, stack version, git "
|
||||
"identity/branch/remote, published skills, the kb/raw/reports/work/instructions "
|
||||
"structure, and generated files.",
|
||||
"Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that "
|
||||
"finished, every tool `tools/prerequisites.txt` names for this platform recorded, and "
|
||||
"every recorded path still there - a `FAIL` otherwise, fixed by running "
|
||||
"`tools/preflight.sh` again.",
|
||||
"Install folder (`install-dir`): on Windows with long paths off, a `FAIL` when the "
|
||||
"folder holding `tools/` is longer than `tools/prerequisites.txt` allows (95 "
|
||||
"characters) - the limit the preflight enforces before it sets anything up.",
|
||||
"Personalization: `USER.md`/`SOUL.md` present **and** filled - a file still carrying "
|
||||
"the template's sentinel is a `FAIL`.",
|
||||
"KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three "
|
||||
@@ -674,6 +749,7 @@ def run_doctor() -> list[Check]:
|
||||
),
|
||||
see_also=(
|
||||
"`instructions/setup-instance.md` - the setup steps most findings point back to",
|
||||
"`instructions/preflight.md` - what `tool-paths` and `install-dir` point back to",
|
||||
"`INSTALL.md` § \"Konfiguration\" - the per-checkout configuration files",
|
||||
"`EVALS.md` - telemetry state and caps",
|
||||
"`instructions/session-setup.md` - setting `WIKITOOL_SESSION_ID`",
|
||||
@@ -684,7 +760,7 @@ def doctor_command(
|
||||
):
|
||||
"""Check that this instance is correctly configured.
|
||||
\f
|
||||
Dependencies, author, git identity/remote, published skills, structure,
|
||||
Dependencies, recorded tool paths, install folder length, author, git identity/remote, published skills, structure,
|
||||
personalization, KB conventions, generated files, session scoping,
|
||||
telemetry state, whether the MCP `submit` tool is armed, and which
|
||||
task-tracker provider (if any) is configured for the GTD review.
|
||||
|
||||
@@ -60,7 +60,7 @@ from typing import NamedTuple, Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import cli_contract, config
|
||||
from chemenu import cli_contract, config, toolpaths
|
||||
from chemenu.commands._util import fail, needs_clearance, success
|
||||
from chemenu.telemetry import emit
|
||||
|
||||
@@ -81,6 +81,9 @@ GATE_EXEMPT_PREFIXES = ("work/",)
|
||||
|
||||
|
||||
def _run(args: list[str]) -> subprocess.CompletedProcess:
|
||||
"""Run a `git ...` argument list, starting git from its recorded path."""
|
||||
if args and args[0] == "git":
|
||||
args = [toolpaths.git(), *args[1:]]
|
||||
return subprocess.run(args, cwd=config.ROOT, capture_output=True, text=True)
|
||||
|
||||
|
||||
@@ -266,7 +269,7 @@ def collect_changes(paths: list[str]) -> list[FileChange]:
|
||||
|
||||
def run(args: list[str]) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=config.ROOT, capture_output=True, text=True, env=env,
|
||||
[toolpaths.git(), *args], cwd=config.ROOT, capture_output=True, text=True, env=env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
fail(f"git {args[0]} failed:\n{result.stderr}")
|
||||
|
||||
@@ -21,7 +21,7 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import cli_contract, config, corpus_diff, kb_scan, kb_state, version as version_mod
|
||||
from chemenu import cli_contract, config, corpus_diff, kb_scan, kb_state, toolpaths, version as version_mod
|
||||
from chemenu.commands._util import console, fail, rel_path, success, today_iso
|
||||
from chemenu.frontmatter_io import read_page
|
||||
from chemenu.page import Page
|
||||
@@ -500,7 +500,7 @@ def baseline_command(
|
||||
|
||||
def _git_show(rev: str, relative: str) -> Optional[str]:
|
||||
result = subprocess.run(
|
||||
["git", "show", f"{rev}:{relative}"],
|
||||
[toolpaths.git(), "show", f"{rev}:{relative}"],
|
||||
cwd=config.ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -510,7 +510,7 @@ def _git_show(rev: str, relative: str) -> Optional[str]:
|
||||
|
||||
def _paths_at(rev: str) -> Optional[list[str]]:
|
||||
result = subprocess.run(
|
||||
["git", "ls-tree", "-r", "--name-only", "-z", rev, "--", "kb"],
|
||||
[toolpaths.git(), "ls-tree", "-r", "--name-only", "-z", rev, "--", "kb"],
|
||||
cwd=config.ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -299,9 +299,11 @@ def default_author() -> str | None:
|
||||
override = os.environ.get("WIKI_AUTHOR", "").strip()
|
||||
if override:
|
||||
return override
|
||||
from chemenu import toolpaths # local: toolpaths imports this module
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "config", "user.name"],
|
||||
[toolpaths.git(), "config", "user.name"],
|
||||
cwd=_root(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -28,7 +28,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import config, toolpaths
|
||||
from chemenu.page import Page
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def is_dirty(root: Optional[Path] = None, path: Optional[Path] = None) -> bool:
|
||||
def _git(args: list[str], root: Optional[Path] = None):
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
[toolpaths.git(), *args],
|
||||
cwd=root or config.ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""`tools/prerequisites.txt` read from Python, for `wikitool doctor`.
|
||||
|
||||
The file is the one list of what the machine needs; `tools/preflight.sh` and
|
||||
`tools/preflight.ps1` read it too, which is why it is a `|`-separated line format
|
||||
rather than anything a shell would need a parser for. This module answers the
|
||||
same questions the preflight asks, so `doctor` can report afterwards what the
|
||||
preflight enforced up front - a tool whose recorded path has gone, a checkout
|
||||
moved into a folder too long for Windows.
|
||||
|
||||
`CHEMENU_PREFLIGHT_PLATFORM` and `CHEMENU_PREFLIGHT_LONGPATHS` stand in for the
|
||||
real platform and registry, exactly as they do for the preflight scripts; the
|
||||
test suite is their only user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from chemenu import config, titles
|
||||
|
||||
ENV_PLATFORM = "CHEMENU_PREFLIGHT_PLATFORM"
|
||||
ENV_LONGPATHS = "CHEMENU_PREFLIGHT_LONGPATHS"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tool:
|
||||
name: str
|
||||
minimum: Optional[str]
|
||||
platforms: str
|
||||
label: str
|
||||
why: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
limits: dict[str, int]
|
||||
tools: tuple[Tool, ...]
|
||||
|
||||
def tools_for(self, platform: str) -> tuple[Tool, ...]:
|
||||
return tuple(t for t in self.tools if t.platforms in ("all", platform))
|
||||
|
||||
|
||||
def manifest_path() -> Path:
|
||||
return config._PACKAGE_ROOT / "tools" / "prerequisites.txt"
|
||||
|
||||
|
||||
def load_manifest(path: Optional[Path] = None) -> Manifest:
|
||||
path = manifest_path() if path is None else path
|
||||
limits: dict[str, int] = {}
|
||||
tools: list[Tool] = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip() or line.startswith("#"):
|
||||
continue
|
||||
fields = line.split("|")
|
||||
if fields[0] == "limit":
|
||||
limits[fields[1]] = int(fields[2])
|
||||
elif fields[0] == "tool":
|
||||
minimum = None if fields[2] == "-" else fields[2]
|
||||
tools.append(Tool(fields[1], minimum, fields[3], fields[4], fields[5]))
|
||||
return Manifest(limits, tuple(tools))
|
||||
|
||||
|
||||
def platform() -> str:
|
||||
"""windows, macos or linux - the same three names the preflight uses."""
|
||||
forced = os.environ.get(ENV_PLATFORM, "").strip()
|
||||
if forced:
|
||||
return forced
|
||||
if sys.platform == "win32":
|
||||
return "windows"
|
||||
if sys.platform == "darwin":
|
||||
return "macos"
|
||||
return "linux"
|
||||
|
||||
|
||||
def long_paths_enabled() -> bool:
|
||||
"""Whether Windows lifts MAX_PATH on this machine. An unreadable key counts
|
||||
as off, as it does in the preflight: the limit then protects a machine that
|
||||
did not need it, rather than the other way round."""
|
||||
forced = os.environ.get(ENV_LONGPATHS, "").strip()
|
||||
if forced:
|
||||
return forced == "1"
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try: # pragma: no cover - Windows only
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\FileSystem"
|
||||
) as key:
|
||||
value, _ = winreg.QueryValueEx(key, "LongPathsEnabled")
|
||||
return value == 1
|
||||
except OSError: # pragma: no cover - Windows only
|
||||
return False
|
||||
|
||||
|
||||
def install_dir() -> str:
|
||||
"""The folder that holds `tools/`, as the operating system spells it."""
|
||||
return str(config._PACKAGE_ROOT)
|
||||
|
||||
|
||||
def install_dir_problem(folder: Optional[str] = None) -> Optional[str]:
|
||||
"""Why this install folder is too long for Windows with long paths off, or
|
||||
None when it fits (or the question does not arise on this platform)."""
|
||||
if platform() != "windows" or long_paths_enabled():
|
||||
return None
|
||||
folder = install_dir() if folder is None else folder
|
||||
limit = load_manifest().limits["install_dir_max"]
|
||||
length = titles.path_length(folder)
|
||||
if length <= limit:
|
||||
return None
|
||||
return (
|
||||
f"the install folder is {length} characters long and Windows allows at most "
|
||||
f"{limit} here (long paths are off): {folder}"
|
||||
)
|
||||
@@ -23,7 +23,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import config, toolpaths
|
||||
from chemenu.errors import BackendError
|
||||
from chemenu.page import Page
|
||||
from chemenu.search.base import page_key
|
||||
@@ -65,7 +65,7 @@ class RipgrepFailed(BackendError):
|
||||
def build_argv(query: SearchQuery, root: Path) -> list[str]:
|
||||
"""The exact command line. Split out so a test can assert the safety
|
||||
properties above without running anything."""
|
||||
argv = ["rg", "--json", "--smart-case", "--glob", "*.md"]
|
||||
argv = [toolpaths.rg(), "--json", "--smart-case", "--glob", "*.md"]
|
||||
if not query.regex:
|
||||
argv.append("--fixed-strings")
|
||||
# `--` terminates option parsing: a query starting with `-` is a search
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config, conventions
|
||||
from chemenu import config, conventions, toolpaths
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.session import HARNESS_ENV_VARS
|
||||
from chemenu.telemetry import policy as telemetry_policy
|
||||
@@ -33,6 +33,8 @@ _WIKITOOL_ENV = (
|
||||
"WIKITOOL_UPDATE_TOKEN",
|
||||
"CHEMENU_ROOT",
|
||||
"WIKITOOL_TASKS_CONFIG",
|
||||
"CHEMENU_PREFLIGHT_PLATFORM",
|
||||
"CHEMENU_PREFLIGHT_LONGPATHS",
|
||||
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
|
||||
|
||||
# Environment git reads for identity or for where its repo lives. A stray
|
||||
@@ -156,6 +158,12 @@ def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
for name in (*_WIKITOOL_ENV, *_GIT_ENV):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
# `.wikitool-tools.json` beside this checkout records where the developer's
|
||||
# git and rg live. A test must not depend on whether the preflight has run
|
||||
# here, so every test sees no file - the bare-name fallback - unless it
|
||||
# writes one of its own and points `toolpaths.tools_file` at it.
|
||||
monkeypatch.setattr(toolpaths, "tools_file", lambda: home / toolpaths.FILE_NAME)
|
||||
|
||||
# The same hole as the environment above, one layer in: `config` resolves
|
||||
# its paths on access, and `monkeypatch.setattr(config, "KB_DIR", ...)`
|
||||
# undoes itself by writing the *resolved* old path back as a real
|
||||
|
||||
@@ -345,7 +345,9 @@ def test_closing_report_points_at_the_upgrade_instruction(instance, tmp_path, ca
|
||||
|
||||
out = " ".join(capsys.readouterr().out.split()) # rich wraps; rejoin first
|
||||
assert "instructions/upgrade-instance.md" in out
|
||||
assert "instructions sync" in out
|
||||
# The first thing the new launcher needs (Gitea #151): it refuses to start
|
||||
# until the preflight has passed against the swapped-in tools/.
|
||||
assert "tools/preflight.sh" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("preserved", [".wikitool-kb.json", "CHANGES.md", "kb/log.md", "raw/notes/.gitkeep"])
|
||||
|
||||
@@ -4,12 +4,14 @@ is missing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config, conventions
|
||||
from chemenu import config, conventions, prerequisites, toolpaths
|
||||
from chemenu.commands import doctor, instructions_cmd
|
||||
|
||||
|
||||
@@ -17,6 +19,18 @@ def _git(root: Path, *args: str) -> None:
|
||||
subprocess.run(["git", *args], cwd=root, check=True, capture_output=True)
|
||||
|
||||
|
||||
def _write_tools_file(path: Path, tools: dict, complete: bool = True) -> Path:
|
||||
path.write_text(
|
||||
json.dumps({"schema": 1, "complete": complete, "tools": tools}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _real_tools() -> dict:
|
||||
return {"python": sys.executable, "git": shutil.which("git"), "rg": shutil.which("rg")}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""A minimal, fully-configured wiki instance: a git repo with identity,
|
||||
@@ -72,6 +86,11 @@ def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
_git(root, "config", "user.name", "Fixture Author")
|
||||
_git(root, "config", "user.email", "fixture@example.com")
|
||||
|
||||
# What the preflight would have written: every tool this platform needs,
|
||||
# at the path it really has on this machine.
|
||||
tools_file = _write_tools_file(root / toolpaths.FILE_NAME, _real_tools())
|
||||
monkeypatch.setattr(toolpaths, "tools_file", lambda: tools_file)
|
||||
|
||||
instructions_cmd.sync(force=False)
|
||||
return root
|
||||
|
||||
@@ -558,3 +577,66 @@ def test_tasks_provider_names_an_active_override(instance, monkeypatch):
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tasks-provider") == "OK"
|
||||
assert "WIKITOOL_TASKS_CONFIG" in _detail(checks, "tasks-provider")
|
||||
|
||||
|
||||
# --- tool paths and the install folder (Gitea #151) --------------------------
|
||||
|
||||
|
||||
def test_tool_paths_missing_file_is_a_fail_naming_the_preflight(instance):
|
||||
(instance / toolpaths.FILE_NAME).unlink()
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tool-paths") == "FAIL"
|
||||
fix = next(c.fix for c in checks if c.name == "tool-paths")
|
||||
assert "tools/preflight.sh" in fix
|
||||
|
||||
|
||||
def test_tool_paths_incomplete_run_is_a_fail(instance):
|
||||
_write_tools_file(instance / toolpaths.FILE_NAME, _real_tools(), complete=False)
|
||||
assert _status(doctor.run_doctor(), "tool-paths") == "FAIL"
|
||||
|
||||
|
||||
def test_tool_paths_vanished_path_is_a_fail(instance):
|
||||
tools = {**_real_tools(), "rg": str(instance / "gone" / "rg")}
|
||||
_write_tools_file(instance / toolpaths.FILE_NAME, tools)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tool-paths") == "FAIL"
|
||||
assert "no longer there: rg" in _detail(checks, "tool-paths")
|
||||
# The same broken path makes the ripgrep check fail instead of finding rg on PATH.
|
||||
assert _status(checks, "ripgrep") == "FAIL"
|
||||
|
||||
|
||||
def test_tool_paths_unrecorded_tool_is_a_fail(instance):
|
||||
tools = _real_tools()
|
||||
del tools["git"]
|
||||
_write_tools_file(instance / toolpaths.FILE_NAME, tools)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tool-paths") == "FAIL"
|
||||
assert "not recorded: git" in _detail(checks, "tool-paths")
|
||||
|
||||
|
||||
def test_tool_paths_complete_file_is_ok(instance):
|
||||
assert _status(doctor.run_doctor(), "tool-paths") == "OK"
|
||||
|
||||
|
||||
def _folder_of(length: int) -> str:
|
||||
return "C:\\" + "x" * (length - 3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length, longpaths, expected", [
|
||||
(95, "0", "OK"),
|
||||
(96, "0", "FAIL"),
|
||||
(96, "1", "OK"),
|
||||
])
|
||||
def test_install_dir_limit_at_the_boundary(instance, monkeypatch, length, longpaths, expected):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "windows")
|
||||
monkeypatch.setenv(prerequisites.ENV_LONGPATHS, longpaths)
|
||||
folder = _folder_of(length)
|
||||
assert len(folder) == length
|
||||
monkeypatch.setattr(prerequisites, "install_dir", lambda: folder)
|
||||
assert _status(doctor.run_doctor(), "install-dir") == expected
|
||||
|
||||
|
||||
def test_install_dir_is_not_limited_off_windows(instance, monkeypatch):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "linux")
|
||||
monkeypatch.setattr(prerequisites, "install_dir", lambda: "/" + "x" * 300)
|
||||
assert _status(doctor.run_doctor(), "install-dir") == "OK"
|
||||
@@ -0,0 +1,529 @@
|
||||
"""tools/preflight.sh, the launcher, `tools/trace-hook` and `toolpaths` (Gitea #151).
|
||||
|
||||
The preflight runs before Python is known to exist, so it is tested as what it
|
||||
is: a POSIX shell script, run under every shell this machine has of dash and
|
||||
bash, against a `PATH` built for the test. That `PATH` holds two directories -
|
||||
the handful of utilities the script uses, symlinked one by one, and stubs for
|
||||
the tools under test - so whether `rg` or `python3` is "installed" is decided
|
||||
here and nowhere else. The stub Python fakes `-m venv` and `-m pip` as well, so
|
||||
no test creates a real venv or touches the network; the CI workflows run the
|
||||
preflight for real.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config, prerequisites, titles, toolpaths
|
||||
|
||||
TOOLS = config._PACKAGE_ROOT / "tools"
|
||||
|
||||
# What preflight.sh, the launcher and trace-hook call besides shell built-ins
|
||||
# and the tools under test. `iconv` is optional in the script itself.
|
||||
UTILITIES = ("dirname", "uname", "sed", "tr", "wc", "iconv", "tail", "cat", "mv", "head",
|
||||
"cut", "grep", "mkdir", "cp", "rm")
|
||||
|
||||
|
||||
def _shells() -> list[str]:
|
||||
found = {}
|
||||
for name in ("dash", "bash", "sh"):
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
found.setdefault(os.path.realpath(path), path)
|
||||
return sorted(found.values())
|
||||
|
||||
|
||||
SHELLS = _shells()
|
||||
|
||||
|
||||
def _executable(path: Path, text: str) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return path
|
||||
|
||||
|
||||
FAKE_PYTHON = """#!/bin/sh
|
||||
# A Python that answers the preflight's probe as itself, and fakes venv and pip.
|
||||
case "$1" in
|
||||
-c)
|
||||
case "$2" in
|
||||
*hashlib*) exec "{real}" "$@" ;;
|
||||
esac
|
||||
printf '%s\\n%s\\n' "${{FAKE_PY_VERSION:-3.13}}" "$0"
|
||||
exit 0 ;;
|
||||
-m)
|
||||
case "$2" in
|
||||
venv)
|
||||
shift 2
|
||||
[ "$1" = --clear ] && shift
|
||||
if [ -n "${{FAKE_VENV_FAIL:-}}" ]; then
|
||||
echo "Error: ensurepip is not available" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$1/bin"
|
||||
cp "$0" "$1/bin/python"
|
||||
exit 0 ;;
|
||||
pip)
|
||||
shift 2
|
||||
[ "$1" = --version ] && exit 0
|
||||
echo "$*" >> "$PIP_LOG"
|
||||
if [ -n "${{FAKE_PIP_FAIL:-}}" ]; then
|
||||
echo "ERROR: Could not find a version that satisfies the requirement (network unreachable)" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0 ;;
|
||||
esac ;;
|
||||
esac
|
||||
exit 1
|
||||
"""
|
||||
|
||||
|
||||
class Machine:
|
||||
"""A stack tree plus a PATH of stubs, for one preflight run or several."""
|
||||
|
||||
def __init__(self, base: Path, root_name: str = "instance"):
|
||||
self.base = base
|
||||
self.root = base / root_name
|
||||
self.tools = self.root / "tools"
|
||||
self.tools.mkdir(parents=True)
|
||||
for name in ("preflight.sh", "prerequisites.txt", "wikitool", "run_wikitool.py", "trace-hook"):
|
||||
shutil.copy2(TOOLS / name, self.tools / name)
|
||||
(self.tools / "requirements.txt").write_text("PyYAML\n", encoding="utf-8")
|
||||
self.sysbin = base / "sysbin"
|
||||
self.sysbin.mkdir(exist_ok=True)
|
||||
for name in UTILITIES:
|
||||
found = shutil.which(name)
|
||||
if found and not (self.sysbin / name).exists():
|
||||
(self.sysbin / name).symlink_to(found)
|
||||
self.stubs = base / "stubs"
|
||||
self.pip_log = base / "pip.log"
|
||||
self.extra_env: dict[str, str] = {}
|
||||
self.path_dirs: list[Path] = [self.stubs, self.sysbin]
|
||||
|
||||
def stub(self, name: str, text: str, where: Path | None = None) -> Path:
|
||||
return _executable((where or self.stubs) / name, text)
|
||||
|
||||
def python(self, name: str = "python3", where: Path | None = None) -> Path:
|
||||
return self.stub(name, FAKE_PYTHON.format(real=sys.executable), where)
|
||||
|
||||
def standard(self, rg: bool = True, pwsh: bool = False) -> "Machine":
|
||||
self.python()
|
||||
self.stub("git", "#!/bin/sh\necho 'git version 2.47.1'\n")
|
||||
if rg:
|
||||
self.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1 (rev 4649aa9700)'\n")
|
||||
if pwsh:
|
||||
self.stub("pwsh", "#!/bin/sh\necho 'PowerShell 7.6.6'\n")
|
||||
return self
|
||||
|
||||
def run(self, *args: str, shell: str) -> subprocess.CompletedProcess:
|
||||
env = {
|
||||
"PATH": os.pathsep.join(str(d) for d in self.path_dirs),
|
||||
"HOME": str(self.base),
|
||||
"PIP_LOG": str(self.pip_log),
|
||||
**self.extra_env,
|
||||
}
|
||||
return subprocess.run(
|
||||
[shell, str(self.tools / "preflight.sh"), *args],
|
||||
capture_output=True, text=True, env=env, timeout=60,
|
||||
)
|
||||
|
||||
@property
|
||||
def tools_file(self) -> Path:
|
||||
return self.root / toolpaths.FILE_NAME
|
||||
|
||||
def recorded(self) -> dict:
|
||||
return json.loads(self.tools_file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def assert_guidance(output: str, *fragments: str) -> None:
|
||||
"""Every exit-42 stop carries the block: what, why, the fixing command, what next."""
|
||||
assert "STOP" in output
|
||||
for marker in ("Why:", "Fix:", "Next:"):
|
||||
assert marker in output, f"no {marker!r} in:\n{output}"
|
||||
for fragment in fragments:
|
||||
assert fragment in output, f"no {fragment!r} in:\n{output}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def machine(tmp_path: Path) -> Machine:
|
||||
return Machine(tmp_path.resolve())
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(not SHELLS, reason="no POSIX shell on this machine")
|
||||
|
||||
|
||||
# --- the happy path ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_complete_path_records_absolute_paths_and_sets_up_the_venv(machine, shell):
|
||||
machine.standard()
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
data = machine.recorded()
|
||||
assert data["schema"] == 1 and data["complete"] is True
|
||||
assert set(data["tools"]) == {"python", "git", "rg"}
|
||||
for path in data["tools"].values():
|
||||
assert os.path.isabs(path) and os.path.isfile(path)
|
||||
assert data["tools"]["rg"] == str(machine.stubs / "rg")
|
||||
assert (machine.tools / ".venv" / "bin" / "python").is_file()
|
||||
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
|
||||
assert "Preflight passed" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_second_run_changes_nothing(machine, shell):
|
||||
machine.standard()
|
||||
assert machine.run(shell=shell).returncode == 0
|
||||
before = machine.tools_file.read_text(encoding="utf-8")
|
||||
again = machine.run(shell=shell)
|
||||
assert again.returncode == 0, again.stdout
|
||||
assert machine.tools_file.read_text(encoding="utf-8") == before
|
||||
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
|
||||
|
||||
|
||||
# --- stop cases -------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_missing_rg_stops_with_42_and_a_missing_line(machine, shell):
|
||||
machine.standard(rg=False)
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert any(line.split()[:2] == ["MISSING", "rg"] for line in result.stdout.splitlines())
|
||||
assert_guidance(result.stdout, "ripgrep (rg) was not found", "--set rg=")
|
||||
# What was found is kept for the next round of the loop, but not as ready.
|
||||
data = machine.recorded()
|
||||
assert data["complete"] is False and "rg" not in data["tools"]
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_python_too_old_stops(machine, shell):
|
||||
machine.standard()
|
||||
machine.extra_env["FAKE_PY_VERSION"] = "3.9"
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert "TOO_OLD" in result.stdout
|
||||
assert_guidance(result.stdout, "Python 3.9 is too old")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_invalid_set_path_writes_nothing(machine, shell):
|
||||
machine.standard()
|
||||
result = machine.run("--set", f"rg={machine.base / 'nowhere' / 'rg'}", shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "The path given for ripgrep (rg) does not work")
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_invalid_set_path_leaves_an_existing_file_alone(machine, shell):
|
||||
machine.standard()
|
||||
assert machine.run(shell=shell).returncode == 0
|
||||
before = machine.tools_file.read_text(encoding="utf-8")
|
||||
result = machine.run("--set", "git=/nowhere/git", shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert machine.tools_file.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_valid_set_path_is_recorded_and_kept(machine, shell):
|
||||
machine.standard(rg=False)
|
||||
elsewhere = machine.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1'\n", machine.base / "opt")
|
||||
result = machine.run("--set", f"rg={elsewhere}", shell=shell)
|
||||
assert result.returncode == 0, result.stdout
|
||||
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
|
||||
# The next run finds it in the file, although it is still not on PATH.
|
||||
again = machine.run(shell=shell)
|
||||
assert again.returncode == 0, again.stdout
|
||||
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_set_for_a_tool_this_platform_does_not_need_is_a_usage_error(machine, shell):
|
||||
machine.standard()
|
||||
result = machine.run("--set", "nonsense=/bin/true", shell=shell)
|
||||
assert result.returncode == 1
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_venv_that_cannot_be_created_stops(machine, shell):
|
||||
machine.standard()
|
||||
machine.extra_env["FAKE_VENV_FAIL"] = "1"
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "tools/.venv) could not be created", "ensurepip is not available")
|
||||
assert machine.recorded()["complete"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_pip_failure_stops_and_is_retried_next_time(machine, shell):
|
||||
machine.standard()
|
||||
machine.extra_env["FAKE_PIP_FAIL"] = "1"
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "could not be installed into tools/.venv", "network unreachable")
|
||||
assert machine.recorded()["complete"] is False
|
||||
del machine.extra_env["FAKE_PIP_FAIL"]
|
||||
assert machine.run(shell=shell).returncode == 0
|
||||
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 2
|
||||
|
||||
|
||||
# --- the store alias ----------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
@pytest.mark.parametrize("platform, alias", [("linux", "python3"), ("windows", "python")])
|
||||
def test_store_alias_is_never_run_and_never_recorded(machine, shell, platform, alias):
|
||||
marker = machine.base / "alias-was-run"
|
||||
apps = machine.base / "Users" / "u" / "AppData" / "Local" / "Microsoft" / "WindowsApps"
|
||||
machine.stub(alias, f"#!/bin/sh\necho ran > '{marker}'\nexit 9009\n", apps)
|
||||
machine.path_dirs.insert(0, apps)
|
||||
machine.standard(pwsh=True)
|
||||
machine.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": platform, "CHEMENU_PREFLIGHT_LONGPATHS": "1"})
|
||||
if platform == "windows":
|
||||
machine.python("python") # the real one, behind the alias on PATH
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 0, result.stdout
|
||||
recorded = machine.recorded()["tools"]["python"]
|
||||
assert "WindowsApps" not in recorded
|
||||
assert recorded.startswith(str(machine.stubs))
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_windows_needs_pwsh_too(machine, shell):
|
||||
machine.standard(pwsh=False)
|
||||
machine.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": "windows", "CHEMENU_PREFLIGHT_LONGPATHS": "1"})
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "PowerShell 7 (pwsh) was not found")
|
||||
|
||||
|
||||
# --- install folder length (D32) --------------------------------------------------
|
||||
|
||||
|
||||
def _machine_with_root_of(tmp_path: Path, length: int) -> Machine:
|
||||
base = tmp_path.resolve()
|
||||
name_length = length - len(str(base)) - 1
|
||||
assert name_length > 0, "tmp_path is too long for this test"
|
||||
machine = Machine(base, "r" * name_length)
|
||||
assert len(str(machine.root)) == length
|
||||
machine.standard(pwsh=True)
|
||||
machine.extra_env["CHEMENU_PREFLIGHT_PLATFORM"] = "windows"
|
||||
return machine
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
@pytest.mark.parametrize("length, longpaths, expected", [
|
||||
(95, "0", 0),
|
||||
(96, "0", 42),
|
||||
(96, "1", 0),
|
||||
])
|
||||
def test_install_folder_limit_at_the_boundary(tmp_path, shell, length, longpaths, expected):
|
||||
machine = _machine_with_root_of(tmp_path, length)
|
||||
machine.extra_env["CHEMENU_PREFLIGHT_LONGPATHS"] = longpaths
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == expected, result.stdout
|
||||
if expected == 42:
|
||||
assert_guidance(result.stdout, f"too long ({length} characters, at most 95)", "C:\\Chemenu")
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
def test_manifest_limit_and_path_budget_fit_max_path_together():
|
||||
"""95 + separator + 160 must stay within Windows' 259 - the two numbers are
|
||||
one sum, kept in two places (tools/prerequisites.txt, titles.PATH_BUDGET)."""
|
||||
limit = prerequisites.load_manifest().limits["install_dir_max"]
|
||||
assert limit + 1 + titles.PATH_BUDGET <= 259
|
||||
|
||||
|
||||
def test_manifest_names_the_tools_the_stack_starts():
|
||||
manifest = prerequisites.load_manifest()
|
||||
assert [t.name for t in manifest.tools_for("linux")] == ["python", "git", "rg"]
|
||||
assert [t.name for t in manifest.tools_for("windows")] == ["python", "git", "rg", "pwsh"]
|
||||
|
||||
|
||||
# --- the launcher -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _launch(machine: Machine, *args: str) -> subprocess.CompletedProcess:
|
||||
env = {"PATH": os.pathsep.join(str(d) for d in machine.path_dirs), "HOME": str(machine.base)}
|
||||
return subprocess.run([str(machine.tools / "wikitool"), *args],
|
||||
capture_output=True, text=True, env=env, timeout=30)
|
||||
|
||||
|
||||
ECHO_PYTHON = "#!/bin/sh\nprintf '%s\\n' \"$@\"\n"
|
||||
|
||||
|
||||
def _complete_file(machine: Machine, complete: bool = True) -> None:
|
||||
machine.tools_file.write_text(json.dumps(
|
||||
{"schema": 1, "complete": complete, "tools": {"git": "/usr/bin/git"}}, indent=2),
|
||||
encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["no file", "no venv", "incomplete"])
|
||||
def test_launcher_stops_with_42_until_the_preflight_has_passed(machine, state):
|
||||
if state != "no file":
|
||||
_complete_file(machine, complete=(state != "incomplete"))
|
||||
if state != "no venv":
|
||||
machine.stub("python", ECHO_PYTHON, machine.tools / ".venv" / "bin")
|
||||
result = _launch(machine, "doctor")
|
||||
assert result.returncode == 42
|
||||
assert "tools/preflight.sh" in result.stderr
|
||||
assert "Traceback" not in result.stdout + result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout", [("bin", "python"), ("Scripts", "python.exe")])
|
||||
def test_launcher_runs_the_entry_script_with_either_venv_layout(machine, layout):
|
||||
_complete_file(machine)
|
||||
machine.stub(layout[1], ECHO_PYTHON, machine.tools / ".venv" / layout[0])
|
||||
result = _launch(machine, "doctor", "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.splitlines() == [str(machine.tools / "run_wikitool.py"), "doctor", "--json"]
|
||||
|
||||
|
||||
def test_there_is_a_powershell_launcher_slot_but_no_cmd():
|
||||
assert (TOOLS / "wikitool").is_file()
|
||||
assert not (TOOLS / "wikitool.cmd").exists()
|
||||
|
||||
|
||||
# --- trace-hook -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hook(machine: Machine, *args: str) -> subprocess.CompletedProcess:
|
||||
env = {"PATH": os.pathsep.join(str(d) for d in machine.path_dirs), "HOME": str(machine.base)}
|
||||
return subprocess.run([str(machine.tools / "trace-hook"), *args],
|
||||
capture_output=True, text=True, env=env, timeout=30)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout", [("bin", "python"), ("Scripts", "python.exe")])
|
||||
def test_trace_hook_runs_trace_ingest_with_the_venv_python(machine, layout):
|
||||
machine.stub(layout[1], ECHO_PYTHON, machine.tools / ".venv" / layout[0])
|
||||
result = _hook(machine, "--source", "claude-code", "--event", "prompt.submitted")
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.splitlines() == [
|
||||
str(machine.tools / "trace_ingest.py"), "--source", "claude-code", "--event", "prompt.submitted",
|
||||
]
|
||||
|
||||
|
||||
def test_trace_hook_is_silent_without_a_venv(machine):
|
||||
result = _hook(machine, "--source", "claude-code")
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "" and result.stderr == ""
|
||||
|
||||
|
||||
def _hook_commands() -> list[str]:
|
||||
root = config._PACKAGE_ROOT
|
||||
commands = []
|
||||
claude = json.loads((root / ".claude" / "settings.json").read_text(encoding="utf-8"))
|
||||
for entries in claude["hooks"].values():
|
||||
for entry in entries:
|
||||
commands += [hook["command"] for hook in entry["hooks"]]
|
||||
copilot = json.loads((root / ".github" / "hooks" / "wiki-trace.json").read_text(encoding="utf-8"))
|
||||
for entries in copilot["hooks"].values():
|
||||
for hook in entries:
|
||||
commands += [hook["bash"], hook["powershell"]]
|
||||
import tomllib
|
||||
|
||||
vibe = tomllib.loads((root / ".vibe" / "hooks.toml").read_text(encoding="utf-8"))
|
||||
commands += [hook["command"] for hook in vibe["hooks"]]
|
||||
return commands
|
||||
|
||||
|
||||
def test_no_hook_relies_on_a_shebang_or_a_bare_python():
|
||||
commands = _hook_commands()
|
||||
assert len(commands) > 10
|
||||
for command in commands:
|
||||
assert command.startswith(("./tools/trace-hook ", ".\\tools\\.venv\\Scripts\\python.exe ")), command
|
||||
|
||||
|
||||
# --- toolpaths --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _point_at(monkeypatch, path: Path) -> Path:
|
||||
monkeypatch.setattr(toolpaths, "tools_file", lambda: path)
|
||||
return path
|
||||
|
||||
|
||||
def test_no_file_means_the_bare_name(tmp_path, monkeypatch):
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME)
|
||||
assert toolpaths.git() == "git" and toolpaths.rg() == "rg"
|
||||
|
||||
|
||||
def test_recorded_path_is_used(tmp_path, monkeypatch):
|
||||
rg = _executable(tmp_path / "bin" / "rg", "#!/bin/sh\n")
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(
|
||||
json.dumps({"schema": 1, "complete": True, "tools": {"rg": str(rg)}}), encoding="utf-8")
|
||||
assert toolpaths.rg() == str(rg)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content, message", [
|
||||
('{"schema": 1, "complete": true, "tools": {}}', "not recorded"),
|
||||
('{"schema": 1, "complete": true, "tools": {"git": "/nowhere/git"}}', "no longer exists"),
|
||||
("{not json", "not valid JSON"),
|
||||
('{"schema": 2, "tools": {}}', "shape this version reads"),
|
||||
])
|
||||
def test_a_present_but_unusable_file_never_falls_back_to_path(tmp_path, monkeypatch, content, message):
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(content, encoding="utf-8")
|
||||
with pytest.raises(toolpaths.ToolPathError, match=message) as caught:
|
||||
toolpaths.git()
|
||||
assert "tools/preflight.sh" in str(caught.value)
|
||||
assert not isinstance(caught.value, OSError)
|
||||
|
||||
|
||||
def _logging_wrapper(directory: Path, name: str, log: Path) -> Path:
|
||||
real = shutil.which(name)
|
||||
assert real, f"{name} is needed for this test"
|
||||
return _executable(directory / name, f"#!/bin/sh\necho \"$@\" >> '{log}'\nexec '{real}' \"$@\"\n")
|
||||
|
||||
|
||||
def test_git_and_rg_start_from_the_recorded_paths_not_from_path(tmp_path, monkeypatch):
|
||||
log = tmp_path / "calls.log"
|
||||
hidden = tmp_path / "not-on-path"
|
||||
git = _logging_wrapper(hidden, "git", log)
|
||||
rg = _logging_wrapper(hidden, "rg", log)
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(json.dumps(
|
||||
{"schema": 1, "complete": True, "tools": {"git": str(git), "rg": str(rg)}}), encoding="utf-8")
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Recorded Git"], cwd=repo, check=True)
|
||||
(repo / "kb").mkdir()
|
||||
(repo / "kb" / "Page.md").write_text("---\ntitle: Page\n---\nkingfisher\n", encoding="utf-8")
|
||||
monkeypatch.setattr(config, "ROOT", repo)
|
||||
monkeypatch.setenv("PATH", str(tmp_path / "empty"))
|
||||
|
||||
assert config.default_author() == "Recorded Git"
|
||||
|
||||
from chemenu.search.base import SearchQuery
|
||||
from chemenu.search.ripgrep import RipgrepBackend
|
||||
backend = RipgrepBackend(search_root=repo / "kb", repo_root=repo)
|
||||
backend.search(SearchQuery(text="kingfisher"), {})
|
||||
|
||||
calls = log.read_text(encoding="utf-8")
|
||||
assert "config user.name" in calls
|
||||
assert "kingfisher" in calls
|
||||
|
||||
|
||||
def test_cli_turns_a_tool_path_error_into_an_error_line(tmp_path, monkeypatch, capsys):
|
||||
from chemenu import cli
|
||||
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(
|
||||
'{"schema": 1, "complete": true, "tools": {}}', encoding="utf-8")
|
||||
monkeypatch.setattr(sys, "argv", ["wikitool", "search", "kingfisher"])
|
||||
with pytest.raises(SystemExit) as exited:
|
||||
cli._run_traced("search", ["kingfisher"])
|
||||
assert exited.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "ERROR" in out and "tools/preflight.sh" in out
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Where the programs this package starts live: `.wikitool-tools.json`.
|
||||
|
||||
The preflight (`tools/preflight.sh`, `tools/preflight.ps1`) finds `git`, `rg` and
|
||||
the rest once, checks they run, and records their absolute native paths in this
|
||||
file beside `tools/`. Everything here that starts one of them asks `resolve()`
|
||||
for the path instead of trusting whatever `PATH` the calling process inherited -
|
||||
a harness session on Windows can hold a `PATH` from before the tool was
|
||||
installed, and then the same machine answers "missing" in one terminal and
|
||||
"present" in the next.
|
||||
|
||||
The file belongs to the checkout the code runs from, not to the corpus it is
|
||||
pointed at: `CHEMENU_ROOT` may name another tree, and that tree's tools are this
|
||||
installation's tools. So it is read from `config._PACKAGE_ROOT`, never `ROOT`.
|
||||
|
||||
Enforcement lives in the launcher, which refuses to start (exit 42) until the
|
||||
preflight has written a complete file. Here, an absent file falls back to the
|
||||
bare program name - the case of the test suite and of `python -m chemenu.cli`
|
||||
run by hand. A file that is present but wrong never falls back: a tool it does
|
||||
not name, or names at a path that has gone, is a `ToolPathError` that tells the
|
||||
user to run the preflight again.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from chemenu import config
|
||||
from chemenu.errors import ChemenuError
|
||||
|
||||
FILE_NAME = ".wikitool-tools.json"
|
||||
SCHEMA = 1
|
||||
PREFLIGHT = "run the preflight again: tools/preflight.sh"
|
||||
|
||||
|
||||
class ToolPathError(ChemenuError):
|
||||
"""The recorded tool paths cannot be used. Deliberately not an `OSError`:
|
||||
call sites that treat a failed start as "tool absent" must not swallow
|
||||
this - the fix is a command for the user, not a degraded answer."""
|
||||
|
||||
|
||||
def tools_file() -> Path:
|
||||
return config._PACKAGE_ROOT / FILE_NAME
|
||||
|
||||
|
||||
def load(path: Optional[Path] = None) -> Optional[dict]:
|
||||
"""The parsed file, or None when it does not exist. Raises `ToolPathError`
|
||||
for a file that exists but is not one this version writes."""
|
||||
path = tools_file() if path is None else path
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
raise ToolPathError(f"{FILE_NAME} cannot be read ({exc}) - {PREFLIGHT}") from exc
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise ToolPathError(f"{FILE_NAME} is not valid JSON ({exc}) - {PREFLIGHT}") from exc
|
||||
if not isinstance(data, dict) or data.get("schema") != SCHEMA or not isinstance(data.get("tools"), dict):
|
||||
raise ToolPathError(
|
||||
f"{FILE_NAME} is not in the shape this version reads (schema {SCHEMA} with a "
|
||||
f"`tools` object) - {PREFLIGHT}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def resolve(name: str) -> str:
|
||||
"""The program to start for `name`: its recorded path, or `name` itself
|
||||
when no file has been written."""
|
||||
data = load()
|
||||
if data is None:
|
||||
return name
|
||||
path = data["tools"].get(name)
|
||||
if not isinstance(path, str) or not path:
|
||||
raise ToolPathError(f"`{name}` is not recorded in {FILE_NAME} - {PREFLIGHT}")
|
||||
if not os.path.isfile(path):
|
||||
raise ToolPathError(f"`{name}` is recorded at {path}, which no longer exists - {PREFLIGHT}")
|
||||
return path
|
||||
|
||||
|
||||
def git() -> str:
|
||||
return resolve("git")
|
||||
|
||||
|
||||
def rg() -> str:
|
||||
return resolve("rg")
|
||||
Reference in new issue
Block a user