diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..0bc53ef --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## Summary + + + +## Changes + + + +- + +## Testing + + + +- [ ] Tests pass locally (`python -m pytest tests/ -v`) +- [ ] Pre-commit hooks pass (`pre-commit run --all-files`) + +## Related Issues + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..056f1bb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 + +updates: + # Python (pip) dependencies + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + labels: + - "dependencies" + commit-message: + prefix: "chore(deps)" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "ci" + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/auto-populate-pr.yml b/.github/workflows/auto-populate-pr.yml new file mode 100644 index 0000000..b4c2996 --- /dev/null +++ b/.github/workflows/auto-populate-pr.yml @@ -0,0 +1,109 @@ +name: Auto-populate PR Body + +on: + pull_request: + types: [opened] + +permissions: + pull-requests: write + +jobs: + populate-body: + name: Generate PR Body + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch base branch + run: git fetch origin ${{ github.event.pull_request.base.ref }} --depth=1 + + - name: Generate and update PR body + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + # Fetch the current PR body + CURRENT_BODY=$(gh pr view "$PR_NUMBER" --json body -q '.body') + + # Strip HTML comments, headers, whitespace, checklist items + STRIPPED=$(echo "$CURRENT_BODY" \ + | sed 's///g' \ + | sed '/^## /d' \ + | sed '/^- \[.\]/d' \ + | sed '/^-$/d' \ + | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \ + | sed '/^$/d') + + if [[ -n "$STRIPPED" ]]; then + echo "PR body already has custom content. Skipping auto-populate." + exit 0 + fi + + echo "PR body is empty or template-only. Generating content..." + + MERGE_BASE=$(git merge-base origin/"$BASE_REF" HEAD) + + # --- Collect commit messages --- + COMMITS=$(git log --pretty=format:"- %s" "$MERGE_BASE"..HEAD) + + # --- Collect changed files with stats --- + DIFF_STAT=$(git diff --stat "$MERGE_BASE"..HEAD) + FILE_LIST=$(git diff --name-only "$MERGE_BASE"..HEAD) + + # --- Categorize changes --- + TOTAL=$(echo "$FILE_LIST" | grep -c '.' || true) + SRC_CHANGES=$(echo "$FILE_LIST" | grep -c '^src/' || true) + TEST_CHANGES=$(echo "$FILE_LIST" | grep -c '^tests/' || true) + DOC_CHANGES=$(echo "$FILE_LIST" | grep -cE '^(.github/.*\.md|.*README)' || true) + CI_CHANGES=$(echo "$FILE_LIST" | grep -cE '^\.github/(workflows|dependabot)' || true) + + # --- Build summary line --- + SUMMARY_PARTS=() + [[ "$SRC_CHANGES" -gt 0 ]] && SUMMARY_PARTS+=("$SRC_CHANGES source file(s)") + [[ "$TEST_CHANGES" -gt 0 ]] && SUMMARY_PARTS+=("$TEST_CHANGES test file(s)") + [[ "$DOC_CHANGES" -gt 0 ]] && SUMMARY_PARTS+=("$DOC_CHANGES doc file(s)") + [[ "$CI_CHANGES" -gt 0 ]] && SUMMARY_PARTS+=("$CI_CHANGES CI file(s)") + + if [[ ${#SUMMARY_PARTS[@]} -gt 0 ]]; then + SUMMARY_LINE="This PR touches $(IFS=', '; echo "${SUMMARY_PARTS[*]}") across $TOTAL file(s) total." + else + SUMMARY_LINE="This PR modifies $TOTAL file(s)." + fi + + # --- Write PR body to a temp file --- + BODY_FILE=$(mktemp) + { + echo "## Summary" + echo "" + echo "$SUMMARY_LINE" + echo "" + echo "## Changes" + echo "" + echo "$COMMITS" + echo "" + echo "
" + echo "Diff stats" + echo "" + echo '```' + echo "$DIFF_STAT" + echo '```' + echo "" + echo "
" + echo "" + echo "## Testing" + echo "" + echo '- [ ] Tests pass locally (`python -m pytest tests/ -v`)' + echo '- [ ] Pre-commit hooks pass (`pre-commit run --all-files`)' + echo "" + echo "## Related Issues" + echo "" + echo "" + } > "$BODY_FILE" + + # Update the PR body from file + gh pr edit "$PR_NUMBER" --body-file "$BODY_FILE" + rm -f "$BODY_FILE" + echo "PR body has been auto-populated." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4c164fd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install pre-commit mypy bandit ruff + + - name: Cache pre-commit hooks + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: pre-commit- + + - name: Run pre-commit + run: pre-commit run --all-files --show-diff-on-failure + + - name: Lint check with ruff + run: ruff check src/ tests/ apps/ + + - name: Type check with mypy + run: mypy src/ apps/ + + - name: Security check with bandit + run: bandit -c pyproject.toml -r src/ apps/ + + test: + name: Test (Python ${{ matrix.python-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: lint + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Run tests + run: python -m pytest tests/ -v + + - name: Run tests with coverage + run: | + python -m pytest tests/ \ + -v \ + --cov=template_project \ + --cov-fail-under=90 \ + --cov-report=term-missing \ + --cov-report=xml diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..c8b5542 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,24 @@ +name: Dependency Review + +on: + pull_request: + paths: + - "requirements*.txt" + - "setup.py" + - "pyproject.toml" + +permissions: + contents: read + +jobs: + dependency-review: + name: Review Dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Dependency Review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + comment-summary-in-pr: always diff --git a/.github/workflows/pr-body.yml b/.github/workflows/pr-body.yml new file mode 100644 index 0000000..17c391e --- /dev/null +++ b/.github/workflows/pr-body.yml @@ -0,0 +1,54 @@ +name: PR Body Validation + +on: + pull_request: + # Runs on 'edited' and 'reopened' only — the 'opened' event is handled by + # the auto-populate workflow, which edits the body and triggers 'edited'. + types: [edited, reopened] + +jobs: + validate-body: + name: Validate PR Body + runs-on: ubuntu-22.04 + steps: + - name: Check PR body is not empty + run: | + BODY=$(cat <<'BODY_EOF' + ${{ github.event.pull_request.body }} + BODY_EOF + ) + + # Strip HTML comments, whitespace, and section headers + CLEANED=$(echo "$BODY" | sed 's///g; /^## /d; /^- \[ \]/d; s/^[[:space:]]*//; s/[[:space:]]*$//; /^$/d; /^-$/d') + + if [[ -z "$CLEANED" ]]; then + echo "::error::PR body is empty. Please provide a description of your changes." + echo "" + echo "A good PR description should include:" + echo " - What: A summary of the changes made" + echo " - Why: The motivation or issue being addressed" + echo " - How: Key implementation details (if non-obvious)" + echo " - Testing: How the changes were verified" + exit 1 + fi + + # Check minimum length (at least 20 characters of meaningful content) + CHAR_COUNT=${#CLEANED} + if [[ "$CHAR_COUNT" -lt 20 ]]; then + echo "::error::PR body is too short ($CHAR_COUNT chars). Please provide a meaningful description." + exit 1 + fi + + echo "PR body is present ($CHAR_COUNT chars)." + + - name: Check for placeholder text + run: | + BODY=$(cat <<'BODY_EOF' + ${{ github.event.pull_request.body }} + BODY_EOF + ) + + # Warn if common placeholder patterns are detected + if echo "$BODY" | grep -qiE '(TODO|FIXME|PLACEHOLDER|fill in|describe here|add description)'; then + echo "::warning::PR body may contain placeholder text. Please ensure all sections are filled in." + fi diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml new file mode 100644 index 0000000..5f20b81 --- /dev/null +++ b/.github/workflows/pr-policy.yml @@ -0,0 +1,63 @@ +name: PR Policy + +on: + pull_request: + types: [opened, edited, synchronize, labeled, unlabeled, reopened] + +jobs: + title-convention: + name: Validate PR Title + runs-on: ubuntu-latest + steps: + - name: Check conventional commit format + run: | + TITLE="${{ github.event.pull_request.title }}" + PATTERN="^(feat|fix|docs|doc|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?: .+" + if [[ ! "$TITLE" =~ $PATTERN ]]; then + echo "::error::PR title does not follow Conventional Commits format." + echo "" + echo "Expected format: (): " + echo " Types: feat, fix, docs, doc, style, refactor, perf, test, build, ci, chore, revert" + echo "" + echo "Examples:" + echo " feat: add decrement CLI command" + echo " fix(core): handle empty config file" + echo " docs: update installation instructions" + echo "" + echo "Got: '$TITLE'" + exit 1 + fi + echo "PR title follows Conventional Commits format." + + label-check: + name: Require Label + runs-on: ubuntu-latest + steps: + - name: Check for at least one label + run: | + LABEL_COUNT=$(echo '${{ toJson(github.event.pull_request.labels) }}' | jq 'length') + if [[ "$LABEL_COUNT" -eq 0 ]]; then + echo "::error::PR must have at least one label before merging." + echo "Consider adding a label such as: bug, enhancement, documentation, maintenance, etc." + exit 1 + fi + echo "PR has $LABEL_COUNT label(s)." + + branch-naming: + name: Validate Branch Name + runs-on: ubuntu-latest + steps: + - name: Check branch naming convention + run: | + BRANCH="${{ github.head_ref }}" + PATTERN="^(feature|fix|bugfix|hotfix|docs|chore|refactor|test|ci|release|claude)/" + if [[ ! "$BRANCH" =~ $PATTERN ]]; then + echo "::warning::Branch name '$BRANCH' does not follow the recommended naming convention." + echo "" + echo "Recommended format: /" + echo " Types: feature, fix, bugfix, hotfix, docs, chore, refactor, test, ci, release" + echo "" + echo "Examples:" + echo " feature/add-decrement-cli" + echo " fix/empty-config-handling" + fi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8b071b9..4a0792c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,6 @@ # - Python linting + formatting (Ruff) # - Typing checks (Mypy) # - Security checks (Bandit) -# - Docstring auto-formatting (Docformatter) # - Docs build smoke test # - Commit message enforcement # - Shell script linting (shfmt + shellcheck) @@ -44,14 +43,6 @@ repos: - id: ruff-format types_or: [python, pyi] - # ---------- DOCSTRING AUTO-FIX (Docformatter) ---------- - - repo: https://github.com/PyCQA/docformatter - rev: v1.7.7 - hooks: - - id: docformatter - args: [--in-place, --recursive, --wrap-summaries=88] - types_or: [python] - # ---------- TYPES (MYPY) ---------- - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.19.1 @@ -125,11 +116,7 @@ repos: rev: v1.38.0 hooks: - id: yamllint - args: - [ - -d, - "{extends: default, rules: {line-length: {max: 120}, document-start: disable}}", - ] + args: [--config-file=.yamllint] # ---------- CI / PRE-COMMIT.CI CONFIG ---------- ci: diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..efa8b92 --- /dev/null +++ b/.yamllint @@ -0,0 +1,34 @@ +# .yamllint +extends: default + +rules: + # GitHub Actions REQUIRE unquoted true/false for certain keys + truthy: + allowed-values: ["true", "false", "on", "off", "yes", "no"] + check-keys: false # Critical: don't validate GitHub Actions boolean keys + + # Realistic line lengths for CI workflows (URLs, commands) + line-length: + max: 160 + allow-non-breakable-words: true + allow-non-breakable-inline-mappings: true + + # Enforce newline at EOF (pre-commit already does this) + new-lines: + type: unix + + # Ignore GitHub Actions schema-specific constructs + comments-indentation: disable + indentation: + indent-sequences: consistent + +# Apply stricter rules to project YAML (configs), relaxed for workflows +yaml-files: + - '*.yaml' + - '*.yml' + +ignore: | + .git/ + __pycache__/ + .venv/ + venv/ diff --git a/apps/decrement_hydra.py b/apps/decrement_hydra.py index 3178380..fe7130e 100644 --- a/apps/decrement_hydra.py +++ b/apps/decrement_hydra.py @@ -1,26 +1,36 @@ """Sample app decrementing a given number using Hydra to handle CLI.""" import hydra +from omegaconf import DictConfig -from PROJECT_NAME.add import add +from template_project.add import add def decrement(x: int) -> int: - """Return the decrement of a given value. + """ + Return the decrement of a given value. Args: x (int): value to be decremented Returns: int: The decremented value 'x-1' + """ return add(x, -1) @hydra.main(version_base=None, config_path="configs", config_name="decrement") -def main(config): +def main(config: DictConfig) -> None: + """ + Decrement the given value from config and print the result. + + Args: + config: Hydra config object with 'value' attribute + + """ result = decrement(config.value) - print(result) + print(result) # noqa: T201 if __name__ == "__main__": diff --git a/apps/increment_fire.py b/apps/increment_fire.py index 832e28d..9c52e16 100644 --- a/apps/increment_fire.py +++ b/apps/increment_fire.py @@ -2,17 +2,19 @@ import fire -from PROJECT_NAME.add import add +from template_project.add import add def increment(x: int) -> int: - """Return the increment of a given value. + """ + Return the increment of a given value. Args: x (int): value to be incremented Returns: int: The incremented value 'x+1' + """ return add(x, 1) diff --git a/docker/Dockerfile b/docker/Dockerfile index 89506ea..5af147d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -15,4 +15,4 @@ RUN apt update && \ echo "$USERNAME ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers USER $USERNAME -WORKDIR /workspace/PROJECT_NAME +WORKDIR /workspace/template_project diff --git a/docker/compose.yaml b/docker/compose.yaml index 7042e1c..0450e94 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -1,7 +1,7 @@ services: - project_name: - image: project_name - container_name: project_name_container + template_project: + image: template_project + container_name: template_project_container build: context: . args: @@ -17,6 +17,6 @@ services: capabilities: [gpu] command: sleep infinity volumes: - - ..:/workspace/PROJECT_NAME + - ..:/workspace/template_project - ${HOST_SSH_FOLDER}:/home/${HOST_USERNAME}/.ssh:ro - ${HOST_GITCONFIG_FILE}:/home/${HOST_USERNAME}/.gitconfig:ro diff --git a/docker/create.sh b/docker/create.sh index 0a623b6..d8f50b8 100755 --- a/docker/create.sh +++ b/docker/create.sh @@ -12,4 +12,4 @@ source set_env_variables.sh docker compose up -d # Get an interactive bash session in the container -docker compose exec project_name bash +docker compose exec template_project bash diff --git a/docker/exec.sh b/docker/exec.sh index 2026e39..bda1519 100755 --- a/docker/exec.sh +++ b/docker/exec.sh @@ -9,4 +9,4 @@ cd "$(dirname "$0")" || exit source set_env_variables.sh # Get an interactive bash session -docker compose exec project_name bash +docker compose exec template_project bash diff --git a/pyproject.toml b/pyproject.toml index 8f6e425..572d89c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,30 @@ [tool.ruff] -# Core linting + formatting line-length = 88 -target-version = ["py39", "py310", "py311", "py312"] -fix = true -select = ["ALL"] +target-version = "py39" +src = ["src", "apps", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "B", "S", "D", "PLR"] extend-ignore = [ - "E203", # Conflicts with line break before ':' in slices (Black style) - "W503" # Line break before binary operator (Black style) + "E203", # Black-compatible slice formatting + "D203", # Google style: no blank line before class (keep D211) + "D212", # Google style: multi-line summary on second line (keep D213) ] -src = ["src", "apps", "tests"] -[tool.docformatter] -# Auto-format docstrings -wrap-summaries = 88 -pre-summary-newline = true -make-summary-multi-line = true -recursive = true +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "D100", "D101", "D102", # No docstring requirements in tests + "S101", # allow assert + "S602", # shell=True in subprocess (tests only) + "PLR2004", # Magic values in assertions are normal +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + [tool.mypy] # Type checking diff --git a/requirements.txt b/requirements.txt index 4f9c3aa..2292cab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,10 @@ +bandit fire hydra-core +mypy pre-commit<4 pytest +pytest-cov +pytest-mock +ruff +types-PyYAML diff --git a/setup.py b/setup.py index 6c701d8..49e5985 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,9 @@ +"""Docstring for setup.""" + from setuptools import find_packages, setup setup( - name="PROJECT_NAME", + name="template_project", version="0.0.0", description="", author="", diff --git a/src/PROJECT_NAME/__init__.py b/src/PROJECT_NAME/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/template_project/__init__.py b/src/template_project/__init__.py new file mode 100644 index 0000000..c10b7ed --- /dev/null +++ b/src/template_project/__init__.py @@ -0,0 +1 @@ +"""Docstring for template_project.""" diff --git a/src/PROJECT_NAME/add.py b/src/template_project/add.py similarity index 87% rename from src/PROJECT_NAME/add.py rename to src/template_project/add.py index aa39836..6124e82 100644 --- a/src/PROJECT_NAME/add.py +++ b/src/template_project/add.py @@ -2,7 +2,8 @@ def add(a: int, b: int) -> int: - """Return the sum of two numbers. + """ + Return the sum of two numbers. Adds two numbers using internally the '+' operator. @@ -12,6 +13,6 @@ def add(a: int, b: int) -> int: Returns: int: The value 'a+b' - """ + """ return a + b diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..40c103b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Docstring for apps/__init__.py.""" diff --git a/tests/apps/__init__.py b/tests/apps/__init__.py new file mode 100644 index 0000000..56fced9 --- /dev/null +++ b/tests/apps/__init__.py @@ -0,0 +1 @@ +"""Docstring for tests.apps.""" diff --git a/tests/apps/test_decrement_hydra.py b/tests/apps/test_decrement_hydra.py index 93720bb..1ffac58 100644 --- a/tests/apps/test_decrement_hydra.py +++ b/tests/apps/test_decrement_hydra.py @@ -1,3 +1,5 @@ +"""Tests for the decrement function.""" + import subprocess import tempfile from pathlib import Path @@ -5,9 +7,8 @@ apps_directory_path = Path(__file__).parents[2] / "apps" -def test_increment_fire(): +def test_increment_fire() -> None: """Test the decrement_hydra.py script.""" - with tempfile.TemporaryDirectory() as tmp_dir: command = ( f"python {apps_directory_path / 'decrement_hydra.py'} " diff --git a/tests/apps/test_increment_fire.py b/tests/apps/test_increment_fire.py index 48081b1..6555aa2 100644 --- a/tests/apps/test_increment_fire.py +++ b/tests/apps/test_increment_fire.py @@ -1,12 +1,13 @@ +"""Tests for the increment function.""" + import subprocess from pathlib import Path apps_directory_path = Path(__file__).parents[2] / "apps" -def test_increment_fire(): +def test_increment_fire() -> None: """Test the increment_fire.py script.""" - command = f"python {apps_directory_path / 'increment_fire.py'} --x 2" res = int(subprocess.check_output(command, shell=True)) assert res == 3 diff --git a/tests/test_add.py b/tests/test_add.py index 6bb8101..93be505 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -1,7 +1,8 @@ -from PROJECT_NAME.add import add +"""Tests for the add function.""" +from template_project.add import add -def test_add(): - """Test of the add module.""" +def test_add() -> None: + """Test of the add module.""" assert add(0, 1) == 1