diff --git a/README.md b/README.md index 63c7b83c..e06e1cb0 100644 --- a/README.md +++ b/README.md @@ -48,22 +48,37 @@ information. ## Quick start +PolicyBench is not published on PyPI, so install the command-line tool from +GitHub. With [uv](https://docs.astral.sh/uv/): + ```bash -pip install policybench +uv tool install --python 3.12 git+https://github.com/PolicyEngine/policybench policybench --help ``` -For repository development, clone the full Git repository before running tests: +Or with pip, in a standard (not free-threaded) Python 3.11 to 3.14 virtual +environment: ```bash -pip install -e ".[dev]" -pytest +pip install git+https://github.com/PolicyEngine/policybench +policybench --help +``` + +For repository development, clone the full Git repository and install the +locked environment before running tests: + +```bash +git clone https://github.com/PolicyEngine/policybench +cd policybench +uv sync --locked --extra dev --python 3.12 +uv run pytest ``` Verify the dashboard with its bundled data prepared automatically: ```bash cd app +bun install --frozen-lockfile bun run lint bun run test bun run build @@ -77,6 +92,12 @@ The short version is: generate fixed reference-output manifests first, run Claude models serially, run non-Claude models in parallel, then do a final merge and export pass. +Run these commands from a clone of the repository: they write under +`results/local/`, and `analyze` also writes the dashboard payload to +`app/src/data.json`. They call `policybench` as `uv tool install` puts it on +your `PATH`; in a clone set up with `uv sync`, prefix each with `uv run`, as the +runbook does. + ```bash # Generate reference outputs for 100 sampled households using headline outputs policybench reference-outputs -n 100 --seed 42 diff --git a/tests/test_install_docs.py b/tests/test_install_docs.py new file mode 100644 index 00000000..19ba1b4e --- /dev/null +++ b/tests/test_install_docs.py @@ -0,0 +1,383 @@ +"""Install commands in the docs must name a source that exists. + +PolicyBench is not published on PyPI: on 2026-10-06 both +https://pypi.org/pypi/policybench/json and https://pypi.org/simple/policybench/ +returned 404. A command that installs the ``policybench`` distribution from a +package index by name (``pip install policybench``, ``uvx policybench``) fails +today, and would install whatever a third party later uploads under the name. +The README installs it from GitHub and the development docs from a clone; these +tests hold every surface a reader follows to that. A doc that quotes the index +command, even to warn against it, fails too: readers copy commands. If +PolicyBench is ever published to PyPI, delete this module with the README's +"not published" line. +""" + +import html +import json +import re +import shlex +import tomllib +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +DISTRIBUTION = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["name"] +REPO_URL = "https://github.com/PolicyEngine/policybench" + +# Where readers and agents look for install instructions. Dated records +# (docs/adds0928/, docs/gpt61sol/, reference_audit/) are left out: they quote +# what happened rather than tell anyone what to run. +SURFACES = ( + "*.md", + "docs/*.md", + "docs/requirements.txt", + "paper/README.md", + "paper/index.qmd", + "app/public/paper/web/index.html", + "sensitivity/*.md", + "app/src/**/*.ts", + "app/src/**/*.tsx", + "app/src/notes/*.json", + ".github/workflows/*.yml", +) + +# Each installer and what its positional arguments are. +# requirements: each positional is a requirement (pip install). +# tool: the first positional names the tool's package, unless --from or +# --spec names it, and later tokens are the tool's own arguments (uvx). +# command: positionals are a command to run; only --with adds a package. +# `pip install` also matches inside `uv pip install` and `python -m pip install`. +INSTALLERS = ( + (re.compile(r"(?", ">>", "(", ")"} +FENCE = re.compile(r"^(```|~~~)[^\n]*\n(.*?)^\1", re.DOTALL | re.MULTILINE) +INLINE_CODE = re.compile(r"`([^`]+)`") +HTML_CODE = re.compile(r"<(code|pre)\b[^>]*>(.*?)\1>", re.DOTALL) +TAG = re.compile(r"<[^>]+>") + + +def _normalize(name: str) -> str: + """PEP 503 name normalization.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def _index_name(token: str) -> str | None: + """The distribution ``token`` fetches from an index by name, if any. + + Paths, archives, URLs and PEP 508 direct references (``name @ url``) are + not fetched from an index by name, so they return None. + """ + if not token or token[0] in "-./~$" or "/" in token or "\\" in token: + return None + if token.startswith(("git+", "file:")) or "://" in token: + return None + if token.endswith((".whl", ".tar.gz", ".zip")): + return None + match = re.match(r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?", token) + return _normalize(match.group()) if match else None + + +def _tokens(arguments: str) -> list[str]: + """Shell tokens, with quotes, ``#`` comments and operators handled.""" + for text in (arguments, re.sub(r"[\"']", "", arguments)): + lexer = shlex.shlex(text, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + try: + return list(lexer) + except ValueError: # an unbalanced quote: retry without quotes + continue + return [] + + +def _scan(tokens: list[str], role: str, every_positional: bool) -> list[str]: + """Distributions an installer's arguments fetch from an index by name. + + In code every positional of a ``requirements`` installer counts. In prose + only the one right after the installer and its options does, so "uv tool + install puts policybench on your PATH" names no requirement. + """ + names = [] + named_tool = False + i = 0 + while i < len(tokens) and tokens[i] not in SHELL_OPERATORS: + token = tokens[i].strip("\u201c\u201d\u2018\u2019") # curly quotes in prose + if token in OFFLINE_FLAGS: + return [] + if token.startswith("-"): + flag, has_value, value = token.partition("=") + if flag in REQUIREMENT_OPTIONS: + if not has_value and i + 1 < len(tokens): + i += 1 + value = tokens[i] + names += [n for n in map(_index_name, value.split(",")) if n] + named_tool = named_tool or flag in TOOL_OPTIONS + elif flag in VALUE_OPTIONS and not has_value: + i += 1 + elif role == "command" or (role == "tool" and named_tool): + break + else: + name = _index_name(token.rstrip(".,;:!?)}\"'")) + if name: + names.append(name) + if role == "tool" or not every_positional: + break + i += 1 + return names + + +def _installs(snippet: str, every_positional: bool) -> list[str]: + names = [] + for pattern, role in INSTALLERS: + for match in pattern.finditer(snippet): + tokens = _tokens(snippet[match.end() :]) + names += _scan(tokens, role, every_positional) + return names + + +def _code_and_prose(text: str, markup: bool) -> tuple[list[str], str]: + """Split text into code snippets and the prose around them. + + Code is fenced blocks and, in markup, ``
`` elements (one command per
+ line), plus inline code spans and ```` elements, joined across a
+ soft wrap.
+ Prose is everything else with line breaks collapsed, so a wrapped sentence
+ reads as one line.
+ """
+ code = []
+ if markup:
+ for tag, inner in HTML_CODE.findall(text):
+ inner = html.unescape(TAG.sub("", inner))
+ code += inner.splitlines() if tag == "pre" else [" ".join(inner.split())]
+ text = html.unescape(TAG.sub(" ", text))
+ for _, block in FENCE.findall(text.replace("\\\n", " ")):
+ code += block.splitlines()
+ text = FENCE.sub("\n", text)
+ code += [" ".join(span.split()) for span in INLINE_CODE.findall(text)]
+ return code, " ".join(INLINE_CODE.sub(r"\1", text).split())
+
+
+def index_installs(text: str, markup: bool = False) -> list[str]:
+ """Every distribution an install command in ``text`` fetches by name."""
+ code, prose = _code_and_prose(text, markup)
+ names = [n for snippet in code for n in _installs(snippet, True)]
+ return names + _installs(prose, False)
+
+
+def _json_strings(value) -> list[str]:
+ if isinstance(value, str):
+ return [value]
+ if isinstance(value, dict):
+ value = list(value.values())
+ if isinstance(value, list):
+ return [s for item in value for s in _json_strings(item)]
+ return []
+
+
+def surface_installs(path: Path) -> list[str]:
+ """Every distribution ``path`` fetches from an index by name."""
+ text = path.read_text(encoding="utf-8")
+ if path.name.endswith("requirements.txt"):
+ # Each line of a requirements file is itself a requirement.
+ lines = (line.split("#", 1)[0].strip() for line in text.splitlines())
+ return [n for n in map(_index_name, lines) if n]
+ if path.suffix == ".json":
+ # Decode escapes so a quoted command in a note reads as written.
+ strings = _json_strings(json.loads(text))
+ return [n for s in strings for n in index_installs(s)]
+ if path.suffix in {".yml", ".yaml", ".sh"}:
+ return [n for line in text.splitlines() for n in _installs(line, True)]
+ return index_installs(text, markup=path.suffix in {".html", ".ts", ".tsx"})
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ "pip install policybench",
+ "$ pip3 install -U policybench",
+ "python -m pip install 'policybench[dev]>=2'",
+ "py -3.12 -m pip install policybench",
+ "uv pip install --system PolicyBench==2.0.0",
+ "uv tool install --python 3.12 policybench",
+ "uv add policybench",
+ "pipx install policybench",
+ "conda install -c conda-forge policybench",
+ "!pip install policybench",
+ "`pip install numpy policybench`",
+ '`pip install "policyengine-us>=2" policybench`',
+ "`pip install --no-binary :all: policybench`",
+ "Run pip install policybench to get started.",
+ "Install it with `pip install\npolicybench` and run it.",
+ "Install it with `pip\ninstall policybench`.",
+ "uvx policybench --help",
+ "uvx policybench@latest --help",
+ "uv tool run policybench --help",
+ "pipx run policybench",
+ "`uvx --with rich policybench --help`",
+ "`uvx -w policybench python`",
+ "`uv run --with numpy,policybench python`",
+ "`uvx --from policybench policybench --help`",
+ "uv add --optional dev policybench",
+ "uvx --exclude-newer 2026-10-01 policybench --help",
+ "Install it with pip install \u201cpolicybench\u201d.",
+ "PolicyBench is not on PyPI, so `pip install policybench` fails.",
+ "```bash\npip install \\\n policybench\n```",
+ ],
+)
+def test_detector_flags_index_installs_of_policybench(text):
+ assert DISTRIBUTION in index_installs(text)
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ f"pip install git+{REPO_URL}",
+ f"pip install git+{REPO_URL}.git@main",
+ f"pip install git+{REPO_URL}#egg=policybench",
+ f"uv tool install --python 3.12 git+{REPO_URL} # puts policybench on PATH",
+ f"uvx --from git+{REPO_URL} policybench --help",
+ f"pip install 'policybench @ git+{REPO_URL}'",
+ f"pip install policybench@git+{REPO_URL}",
+ 'pip install -e ".[dev]" # editable install of policybench',
+ "pip install -e policybench",
+ "pip install ./policybench",
+ "pip install dist/policybench-2.0.0-py3-none-any.whl",
+ "pip install policybench-tools",
+ "pip install uv, then clone policybench.",
+ "uv tool install puts policybench on your PATH.",
+ "A plain pip install of policybench fails because it is not on PyPI.",
+ "uvx and uv tool install both fetch policybench from GitHub.",
+ "`pip install --no-index --find-links dist policybench`",
+ "conda install -n policybench uv",
+ "uv sync --locked --extra dev",
+ "uv run policybench onboard",
+ "uv run python -m policybench.cli reference-outputs",
+ "policybench --help",
+ ],
+)
+def test_detector_passes_installs_from_source(text):
+ assert DISTRIBUTION not in index_installs(text)
+
+
+@pytest.mark.parametrize(
+ ("name", "content", "flagged"),
+ [
+ ("requirements.txt", "policybench>=2\n", True),
+ ("requirements.txt", "jupyter-book\nPolicyBench # the benchmark\n", True),
+ ("requirements.txt", f"git+{REPO_URL}\n-e .\n# policybench\n", False),
+ ("note.json", json.dumps({"body": 'Run "pip install policybench".'}), True),
+ ("note.json", json.dumps({"body": "```bash\nuvx policybench\n```"}), True),
+ ("note.json", json.dumps({"body": f"pip install git+{REPO_URL}"}), False),
+ (
+ "Page.tsx",
+ "\n uv tool install --python 3.12\n policybench\n",
+ True,
+ ),
+ (
+ "Page.tsx",
+ "\n Install with pip install\n policybench today.\n
",
+ True,
+ ),
+ ("page.html", 'pip install policybench', True),
+ ("page.html", "pip install uv\npolicybench --help
", False),
+ ("ci.yml", " - run: pip install policybench\n", True),
+ ("ci.yml", " - run: pip install uv\n", False),
+ ],
+)
+def test_detector_reads_each_surface_format(tmp_path, name, content, flagged):
+ path = tmp_path / name
+ path.write_text(content)
+ assert (DISTRIBUTION in surface_installs(path)) is flagged
+
+
+@pytest.mark.parametrize("pattern", SURFACES)
+def test_no_surface_installs_policybench_from_an_index(pattern):
+ paths = sorted(ROOT.glob(pattern))
+ assert paths, f"{pattern} matches no file; update SURFACES"
+ for path in paths:
+ assert DISTRIBUTION not in surface_installs(path), (
+ f"{path.relative_to(ROOT)} installs {DISTRIBUTION} from a package "
+ f"index by name, or quotes a command that does, but it is not "
+ f"published there; install from git+{REPO_URL} or a clone instead"
+ )
+
+
+def _github_source(token: str) -> bool:
+ source = re.sub(r"^[\w.-]+(\[[^\]]*\])?\s*@\s*", "", token)
+ return re.match(rf"git\+{re.escape(REPO_URL)}(\.git)?([@#/]|$)", source) is not None
+
+
+def test_readme_installs_policybench_from_github():
+ """The quick start installs PolicyBench itself from this repository (its
+ dependencies still come from PyPI) and shows ``policybench --help``."""
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
+ quick_start = readme.split("## Quick start", 1)[1].split("\n## ", 1)[0]
+ code, _ = _code_and_prose(quick_start, markup=False)
+ sources = [
+ token
+ for snippet in code
+ for pattern, _ in INSTALLERS[:3]
+ for match in pattern.finditer(snippet)
+ for token in _tokens(snippet[match.end() :])
+ ]
+ assert any(map(_github_source, sources)), code
+ assert "policybench --help" in code