Files
Vijay Janapa Reddi 27d4f8114b binder: fix repo-root, build, doctor and info bugs; clearer errors; dedup
Bug fixes
- Build lock is keyed on books/ so builds started from different
  directories block each other (fixes two tests broken by 89b828efa3).
- ConfigManager resolves the repo root when binder runs from books/ or
  binder/, so bib, newsletter, doctor and render paths are right.
- `build <word> --all` treats intro/scaling/agentic/physical as chapter
  names (it built every volume), and Ctrl-C stops a --all loop.
- doctor checked chapter names that no longer exist and failed on the
  generated _quarto.yml; info defaulted to a deleted books/contents.
- `headings apply --path <typo>` no longer rewrites the whole book;
  `bib clean` stops on unreadable chapters instead of dropping entries.
- Discovery falls back to a line scan on invalid YAML instead of crashing.
- Volume III/IV were labeled Volume II in layout and release output.

Output
- Unexpected errors print the command, exception type, and raising
  file:line (new cli/core/errors.py), with the traceback on stderr.
- Chapter-not-found suggests the closest names and the list command.
- Silent except blocks in bib lookups, info, headings, layout,
  maintenance, preview and debug now say what went wrong.

Cleanup
- Shared qmd_files_under/display_path, file-rewrite loop, doctor result
  helper, preview server, clean helpers, volume display names.
- Removed legacy import fallbacks, dead config attributes, legacy backup
  restore logic, and unused imports.
2026-10-19 19:28:49 +02:00

198 lines
7.4 KiB
Python

"""Process-group helpers shared by the build, debug, and parallel runners.
Quarto renders through a tree of child processes (Pandoc, LaTeX, Python
kernels). Binder starts each renderer in its own session so the whole tree
can be stopped at once, and gives the renderer a ``PYTHONPATH`` that imports
this checkout's sources ahead of any installed copy.
"""
from __future__ import annotations
import contextlib
import errno
import os
import signal
import subprocess
import threading
from pathlib import Path
from typing import Iterator, Mapping, Optional, Sequence
def local_render_env(root_dir: Path, base: Optional[Mapping[str, str]] = None) -> dict[str, str]:
"""Return an environment whose ``PYTHONPATH`` imports *root_dir*'s sources first.
The repository root and its nested ``mlsysim`` package directory lead the
path, so a globally installed or stale editable checkout never shadows the
worktree being rendered. An existing ``PYTHONPATH`` is kept after them.
Args:
root_dir: Repository root of the checkout being rendered.
base: Environment to start from; defaults to ``os.environ``.
Returns:
A new environment dictionary.
"""
env = dict(os.environ if base is None else base)
root = Path(root_dir).resolve()
paths = [str(root), str((root / "mlsysim").resolve())]
if env.get("PYTHONPATH"):
paths.append(env["PYTHONPATH"])
env["PYTHONPATH"] = os.pathsep.join(paths)
venv_py = root / ".venv" / "bin" / "python"
if not venv_py.is_file():
try:
git_res = subprocess.run(
["git", "-C", str(root), "rev-parse", "--path-format=absolute", "--git-common-dir"],
capture_output=True, text=True, timeout=2,
)
if git_res.returncode == 0:
common_git = Path(git_res.stdout.strip())
common_root = common_git.parent if common_git.name == ".git" else common_git
candidate = common_root / ".venv" / "bin" / "python"
if candidate.is_file():
venv_py = candidate
except Exception:
pass # best-effort probe: no git, or not a checkout; render without the shared venv
if venv_py.is_file():
if not env.get("QUARTO_PYTHON"):
env["QUARTO_PYTHON"] = str(venv_py.absolute())
env["PATH"] = f"{venv_py.parent.absolute()}:{env.get('PATH', '')}" if env.get("PATH") else str(venv_py.parent.absolute())
return env
def start_process_group(cmd: Sequence[str], **popen_kwargs) -> subprocess.Popen:
"""Start *cmd* as the leader of a new session.
Every process the command spawns joins that session's process group, so
:func:`stop_process_group` can stop the whole tree. Keyword arguments are
passed to :class:`subprocess.Popen`.
"""
return subprocess.Popen(list(cmd), start_new_session=True, **popen_kwargs)
def stop_process_group(process: Optional[subprocess.Popen], grace_seconds: float = 0.0) -> None:
"""Stop a process started with :func:`start_process_group` and wait for it.
With a grace period the group first receives SIGTERM, which lets a child
``binder`` stop its own renderer and restore generated files; anything
still running when the grace period ends is killed. Without a grace period
the group is killed immediately. Where process groups are unavailable
(Windows), the process itself is terminated or killed instead.
Args:
process: Process to stop; ``None`` or an already exited process is a no-op.
grace_seconds: Seconds to wait after SIGTERM before killing.
"""
if process is None or process.poll() is not None:
return
if grace_seconds > 0:
_signal_group(process, terminate=True)
try:
process.wait(timeout=grace_seconds)
return
except subprocess.TimeoutExpired:
pass
_signal_group(process, terminate=False)
process.wait()
def _signal_group(process: subprocess.Popen, terminate: bool) -> None:
"""Send SIGTERM (when *terminate*) or SIGKILL to *process*'s group, ignoring exit races."""
try:
if os.name == "posix":
os.killpg(process.pid, signal.SIGTERM if terminate else signal.SIGKILL)
elif terminate:
process.terminate()
else:
process.kill()
except ProcessLookupError:
pass # The group exited between poll() and the signal.
@contextlib.contextmanager
def interrupts_as_keyboard_interrupt() -> Iterator[None]:
"""Raise ``KeyboardInterrupt`` for SIGINT, SIGTERM and SIGHUP inside the block.
Build code then unwinds through its own ``finally`` blocks, which stop the
renderer and restore generated files, instead of exiting mid-cleanup. The
previous handlers are reinstated on exit. Python only allows signal
handlers on the main thread, so on other threads the block runs unchanged.
"""
if threading.current_thread() is not threading.main_thread():
yield
return
def _raise(signum, frame):
"""Signal handler that raises ``KeyboardInterrupt`` naming the signal."""
raise KeyboardInterrupt(f"Interrupted by signal {signum}")
signals = [signal.SIGINT, signal.SIGTERM]
if hasattr(signal, "SIGHUP"):
signals.append(signal.SIGHUP) # the terminal running the build was closed
previous = {sig: signal.getsignal(sig) for sig in signals}
for sig in previous:
signal.signal(sig, _raise)
try:
yield
finally:
for sig, handler in previous.items():
signal.signal(sig, handler)
class BuildInProgress(RuntimeError):
"""Raised when another build already holds this checkout's build lock."""
BUILD_LOCK_NAME = ".binder-build.lock"
@contextlib.contextmanager
def build_lock(root_dir: Path) -> Iterator[None]:
"""Hold an exclusive per-checkout lock for the length of a build.
Two builds in one checkout overwrite each other's generated ``_quarto.yml``,
``index.qmd`` and ``*_files/execute-results`` freeze files, so the second
one fails with confusing errors ("project change not allowed", a missing
rename target). Failing fast with a clear message is better.
The lock is an ``flock`` on a file in *root_dir*. The operating system drops
it when the holding process exits, however it exits, so a killed build never
leaves a stale lock behind. Separate worktrees have separate roots and
never contend. On platforms without ``fcntl`` (Windows) this does nothing.
Raises:
BuildInProgress: another process holds the lock; the message names its PID.
"""
try:
import fcntl
except ImportError: # pragma: no cover - Windows
yield
return
lock_path = Path(root_dir) / BUILD_LOCK_NAME
handle = open(lock_path, "a+")
try:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
if exc.errno not in (errno.EACCES, errno.EAGAIN):
raise
handle.seek(0)
holder = handle.read().strip() or "unknown"
raise BuildInProgress(
f"another build is already running in {Path(root_dir)} (PID {holder}). "
"Wait for it to finish, or build from a separate worktree."
) from None
handle.seek(0)
handle.truncate()
handle.write(str(os.getpid()))
handle.flush()
try:
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
finally:
handle.close()