From 3fb735d391ae8ebef809ce2322675638d9c3b4e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 01:26:34 +0000 Subject: [PATCH] Add CI gate enforcing PR contribution rules for pyhealth/ changes Any PR touching pyhealth/**/*.py must also update docs/ and examples/, keep added/modified lines free of ruff lint violations, and give new or modified top-level public classes/functions a '>>>' docstring example. --- .github/workflows/pr_contribution_rules.yml | 33 +++++ CONTRIBUTING.md | 18 +++ pyproject.toml | 10 ++ tools/check_pr_rules.py | 155 ++++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 .github/workflows/pr_contribution_rules.yml create mode 100644 tools/check_pr_rules.py diff --git a/.github/workflows/pr_contribution_rules.yml b/.github/workflows/pr_contribution_rules.yml new file mode 100644 index 000000000..63540da5a --- /dev/null +++ b/.github/workflows/pr_contribution_rules.yml @@ -0,0 +1,33 @@ +name: PR Contribution Rules + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + contribution-rules: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch PR base and head commits + run: | + git fetch origin ${{ github.event.pull_request.base.sha }} --depth=1 + git fetch origin ${{ github.event.pull_request.head.sha }} --depth=1 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install ruff + run: pip install 'ruff~=0.15' + + - name: Check PR contribution rules + run: | + python tools/check_pr_rules.py \ + --base ${{ github.event.pull_request.base.sha }} \ + --head ${{ github.event.pull_request.head.sha }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7aef2b3ae..5be4ccc82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,24 @@ is set to 88 characters. We follow the [Google style](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for docstrings. +## PR Contribution Rules (enforced in CI) + +Any pull request that modifies a file under `pyhealth/` must also: + +- Update at least one file under `docs/` and one file under `examples/`. +- Keep newly added/modified lines free of [ruff](https://docs.astral.sh/ruff/) + lint violations (`ruff check`, 88-char line length). Pre-existing lint + issues elsewhere in a touched file are not blocked. +- Give every new or modified top-level public class/function a `>>>` usage + example in its docstring, so it renders as example code in the API docs. + +These rules are checked by `.github/workflows/pr_contribution_rules.yml`, +which runs `tools/check_pr_rules.py`. You can run the same check locally: + +```bash +python tools/check_pr_rules.py --base --head HEAD +``` + ## Community We welcome you to join our community diff --git a/pyproject.toml b/pyproject.toml index 65e0e2757..b4626e649 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,9 @@ nlp = [ "rouge_score~=0.1.2", "nltk~=3.9.1", ] +lint = [ + "ruff~=0.15", +] [project.urls] Homepage = "https://github.com/sunlabuiuc/PyHealth" @@ -82,6 +85,13 @@ requires = ["hatchling"] build-backend = "hatchling.build" +### Ruff +# +[tool.ruff] +line-length = 88 +target-version = "py313" + + ### Hatchling # [tool.hatch.build.targets.wheel] diff --git a/tools/check_pr_rules.py b/tools/check_pr_rules.py new file mode 100644 index 000000000..1bfbcbd38 --- /dev/null +++ b/tools/check_pr_rules.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +CI gate enforcing PyHealth's PR contribution rules. + +Rules enforced whenever a PR touches pyhealth/**/*.py: + + 1. Docs/examples: the PR must also modify at least one file under + docs/** and one file under examples/**. + 2. Lint: lines added or modified in touched pyhealth/**/*.py files must + be free of ruff violations. Pre-existing violations elsewhere in a + touched file are not flagged. + 3. Docstring examples: new or modified top-level public classes/ + functions in pyhealth/**/*.py must include a '>>>' usage example in + their docstring. + +Usage: + python tools/check_pr_rules.py --base --head +""" +import argparse +import ast +import json +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def sh(*args): + return subprocess.run( + args, cwd=REPO_ROOT, capture_output=True, text=True, check=True + ).stdout + + +def changed_files(base, head): + out = sh("git", "diff", "--name-only", "--diff-filter=ACMR", f"{base}..{head}") + return [line.strip() for line in out.splitlines() if line.strip()] + + +def added_lines(base, head, path): + """Line numbers in `path` at `head` that were added or modified vs `base`.""" + out = sh("git", "diff", "--unified=0", f"{base}..{head}", "--", path) + lines = set() + for line in out.splitlines(): + if not line.startswith("@@"): + continue + plus = line.split("+")[1].split(" ")[0] + if "," in plus: + start, count = (int(x) for x in plus.split(",")) + else: + start, count = int(plus), 1 + lines |= set(range(start, start + count)) + return lines + + +def check_docs_examples(files): + if not any(f.startswith("pyhealth/") and f.endswith(".py") for f in files): + return [] + problems = [] + if not any(f.startswith("docs/") for f in files): + problems.append( + "PR modifies pyhealth/ source files but no file under docs/ " + "was updated." + ) + if not any(f.startswith("examples/") for f in files): + problems.append( + "PR modifies pyhealth/ source files but no file under " + "examples/ was updated." + ) + return problems + + +def check_lint(files, base, head): + py_files = [ + f + for f in files + if f.startswith("pyhealth/") and f.endswith(".py") and (REPO_ROOT / f).exists() + ] + if not py_files: + return [] + result = subprocess.run( + ["ruff", "check", "--output-format=json", *py_files], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + if not result.stdout.strip(): + return [] + problems = [] + for v in json.loads(result.stdout): + path = Path(v["filename"]).resolve().relative_to(REPO_ROOT).as_posix() + line = v["location"]["row"] + if line in added_lines(base, head, path): + problems.append(f"{path}:{line}: {v['code']} {v['message']}") + return problems + + +def check_docstring_examples(files, base, head): + problems = [] + for path in files: + if not (path.startswith("pyhealth/") and path.endswith(".py")): + continue + full = REPO_ROOT / path + if not full.exists(): + continue + added = added_lines(base, head, path) + if not added: + continue + try: + tree = ast.parse(full.read_text()) + except SyntaxError: + continue + for node in ast.iter_child_nodes(tree): + if not isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if node.name.startswith("_"): + continue + span = set(range(node.lineno, node.end_lineno + 1)) + if not span & added: + continue + doc = ast.get_docstring(node) + if not doc or ">>>" not in doc: + kind = "class" if isinstance(node, ast.ClassDef) else "function" + problems.append( + f"{path}:{node.lineno}: public {kind} '{node.name}' is " + "new/modified but its docstring has no '>>>' usage example." + ) + return problems + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True, help="base commit SHA") + parser.add_argument("--head", required=True, help="head commit SHA") + args = parser.parse_args() + + files = changed_files(args.base, args.head) + problems = ( + check_docs_examples(files) + + check_lint(files, args.base, args.head) + + check_docstring_examples(files, args.base, args.head) + ) + + if problems: + print("PR contribution rules failed:\n") + for p in problems: + print(f" - {p}") + print(f"\n{len(problems)} issue(s) found. See CONTRIBUTING.md for details.") + sys.exit(1) + + print("All PR contribution rules passed.") + + +if __name__ == "__main__": + main()