From af00a0981910717ecc51021cf913b4bdd2fb9067 Mon Sep 17 00:00:00 2001 From: "Junghee Im(Jion Im)" Date: Mon, 27 Apr 2026 10:30:28 +0900 Subject: [PATCH 01/10] chore(deps): pin ruff and refine tool config - Pin ruff==0.8.4 to match .pre-commit-config.yaml rev (drift prevention) - Add PERF and TRY rule sets (zero-noise perf and exception anti-patterns) - Remove redundant E501 ignore (Ruff formatter owns line length) - Add T20 to tests/ ignore (allow print in tests) - Add scripts/ ignore for T20/S603/S607 (CLI tools use print/subprocess) - Switch pytest addopts to --strict-markers --strict-config - Split coverage into [tool.coverage.run]/[tool.coverage.report] with fail_under=80 --- pyproject.toml | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 79e218d..dc6493e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [] [project.optional-dependencies] dev = [ - "ruff>=0.8.0", + "ruff==0.8.4", # .pre-commit-config.yaml의 ruff rev와 정확히 동일하게 핀 "pre-commit>=3.8.0", "commitizen>=3.29.0", "pytest>=8.0", @@ -44,14 +44,18 @@ select = [ "RUF", # Ruff 자체 규칙 "S", # flake8-bandit (보안) "T20", # flake8-print (print 감지) + "PERF", # perflint — 루프 성능 안티패턴 + "TRY", # tryceratops — 예외 처리 안티패턴 ] -ignore = ["E501"] +# E501(line length)은 Ruff formatter가 직접 관리하므로 ignore 불필요 fixable = ["ALL"] unfixable = [] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] -"tests/**/*.py" = ["S101"] +"tests/**/*.py" = ["S101", "T20"] +# scripts/는 CLI 도구이므로 print/subprocess 사용 허용 +"scripts/**/*.py" = ["T20", "S603", "S607"] [tool.ruff.lint.isort] known-first-party = ["your_project"] # TODO: 프로젝트 패키지명으로 변경 @@ -66,7 +70,22 @@ skip-magic-trailing-comma = false [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] -addopts = "--cov=src --cov-report=term-missing -v" +addopts = "--strict-markers --strict-config -v" +# coverage는 [tool.coverage.*]에서 별도 관리. +# Makefile의 test/ci-check 타겟이 명시적으로 --cov를 부착한다. + +[tool.coverage.run] +source = ["src"] +branch = true + +[tool.coverage.report] +fail_under = 80 +show_missing = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] [tool.mypy] python_version = "3.12" From e1096da7e1a10f55a708acd29fda65c24b459bcf Mon Sep 17 00:00:00 2001 From: "Junghee Im(Jion Im)" Date: Mon, 27 Apr 2026 10:30:47 +0900 Subject: [PATCH 02/10] feat(hooks): auto-stage Ruff fixes and add pre-push gate Solve the long-standing UX pain where Ruff auto-fixes weren't carried into the same commit, forcing users to git add + git commit twice. scripts/stage_fixes.py: - Re-stages ONLY the original staged file list (passed by pre-commit) - Avoids 'git add -u/-A' so partial 'git add -p' workflows stay safe .pre-commit-config.yaml: - New 'stage-fixes' local hook runs after ruff/ruff-format - New pre-push hooks: pytest -x --no-cov -q + mypy src (fast mode) - Add detect-aws-credentials + check-added-large-files (1MB) - Use .venv/bin/python for pre-push to match Makefile convention Makefile: - install-hooks now installs pre-commit + commit-msg + pre-push - New targets: quick-check (commit stage), full-check (push stage), list-checks (visibility), init NAME=... (project bootstrap) - ci-check/test now explicitly attach --cov for the 80% gate --- .pre-commit-config.yaml | 55 ++++++++++++++++++++++++++++++++++-- Makefile | 62 +++++++++++++++++++++++++++++++++++------ scripts/stage_fixes.py | 33 ++++++++++++++++++++++ 3 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 scripts/stage_fixes.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 84aa6bc..d88326b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,8 @@ default_language_version: python: python3.12 -default_stages: [pre-commit] # commit 단계에서만 실행 -fail_fast: false # 오류 발생 시 즉시 종료 +default_stages: [pre-commit] +fail_fast: false repos: # ========================================== @@ -45,6 +45,16 @@ repos: - id: detect-private-key name: "Private 키 탐지" + # AWS 자격증명 탐지 + - id: detect-aws-credentials + name: "AWS 자격증명 탐지" + args: [--allow-missing-credentials] + + # 대용량 파일 차단 (1MB) + - id: check-added-large-files + name: "대용량 파일 차단" + args: [--maxkb=1024] + # ========================================== # 2. Ruff Linter # ========================================== @@ -64,7 +74,21 @@ repos: name: "Ruff 포매터" # ========================================== - # 4. Commitizen + # 4. 자동 수정 결과를 같은 커밋에 stage + # (반드시 ruff/ruff-format 다음에 위치) + # stdlib만 사용하므로 어떤 python3든 OK. + # ========================================== + - repo: local + hooks: + - id: stage-fixes + name: "자동 수정 파일 stage" + entry: python3 scripts/stage_fixes.py + language: system + stages: [pre-commit] + pass_filenames: true + + # ========================================== + # 5. Commitizen (commit-msg 단계) # ========================================== - repo: https://github.com/commitizen-tools/commitizen rev: v3.29.1 @@ -72,3 +96,28 @@ repos: - id: commitizen name: "Commitizen 메시지 검증" stages: [commit-msg] + + # ========================================== + # 6. Pre-push 게이트 (테스트 + 타입체크) + # 빠른 모드 — 30s 이내로 --no-verify 우회 유혹 차단. + # 80% 커버리지 게이트는 CI에서만 적용. + # 프로젝트 venv 경로를 명시 (Makefile과 일관). + # ========================================== + - repo: local + hooks: + - id: pytest-push + name: "pytest (push 전, 빠른 모드)" + entry: .venv/bin/python -m pytest -x --no-cov -q + language: system + stages: [pre-push] + pass_filenames: false + always_run: true + + - id: mypy-push + name: "mypy (push 전)" + entry: .venv/bin/python -m mypy src + language: system + types: [python] + stages: [pre-push] + pass_filenames: false + always_run: true diff --git a/Makefile b/Makefile index fca66ae..52689d4 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,8 @@ COMMITIZEN := $(VENV_BIN)/cz .DEFAULT_GOAL := help .PHONY: help install install-dev install-hooks clean lint format \ - check ci-check validate test typecheck commit bump-version pre-commit run-all update-hooks + check ci-check validate test typecheck commit bump-version \ + pre-commit run-all update-hooks quick-check full-check list-checks init # ============================================================================ # help - 사용 가능한 명령어 목록 @@ -65,10 +66,16 @@ help: @echo "$(COLOR_GREEN)통합 명령어:$(COLOR_RESET)" @echo " make check - 코드 스타일 검사 (lint+format)" @echo " make validate - 커밋 전 전체 검증 (lint+format+test+typecheck)" - @echo " make ci-check - CI용 검사 (수정 없이 검증만)" + @echo " make ci-check - CI용 검사 (수정 없이 검증만, 80%+ coverage)" + @echo " make quick-check - commit 시뮬 (pre-commit 단계 훅)" + @echo " make full-check - push 시뮬 (pre-commit + pre-push 단계 훅)" + @echo " make list-checks - 정의된 모든 검사 목록 보기" @echo " make pre-commit - pre-commit 훅 수동 실행" @echo " make run-all - 모든 파일에 pre-commit 실행" @echo "" + @echo "$(COLOR_GREEN)프로젝트 초기화:$(COLOR_RESET)" + @echo " make init NAME=foo - your_project 플레이스홀더를 'foo'로 일괄 치환" + @echo "" @echo "$(COLOR_GREEN)Git 관련:$(COLOR_RESET)" @echo " make commit - 대화형 커밋 생성 (Conventional Commits)" @echo " make bump-version - 버전 자동 증가 및 태그 생성" @@ -105,9 +112,10 @@ install-dev: # ============================================================================ install-hooks: @echo "$(COLOR_BOLD)$(COLOR_BLUE)Git 훅 설치 중...$(COLOR_RESET)" - @$(PRE_COMMIT) install + @$(PRE_COMMIT) install --hook-type pre-commit @$(PRE_COMMIT) install --hook-type commit-msg - @echo "$(COLOR_GREEN)✓ Git 훅 설치 완료$(COLOR_RESET)" + @$(PRE_COMMIT) install --hook-type pre-push + @echo "$(COLOR_GREEN)✓ Git 훅 설치 완료 (pre-commit, commit-msg, pre-push)$(COLOR_RESET)" # ============================================================================ # clean - 임시 파일 및 캐시 삭제 @@ -161,22 +169,22 @@ validate: lint format test typecheck @echo "" # ============================================================================ -# ci-check - CI용 검사 (수정 없이 검증만) +# ci-check - CI용 검사 (수정 없이 검증만, coverage 80% 게이트) # ============================================================================ ci-check: @echo "$(COLOR_BOLD)$(COLOR_BLUE)CI 검사 중...$(COLOR_RESET)" @$(RUFF) check $(ALL_DIRS) --no-fix @$(RUFF) format $(ALL_DIRS) --check @$(VENV_BIN)/mypy $(SRC_DIR) - @$(VENV_BIN)/pytest --no-header -q + @$(VENV_BIN)/pytest --cov=$(SRC_DIR) --cov-report=term-missing --no-header -q @echo "$(COLOR_GREEN)✓ CI 검사 통과$(COLOR_RESET)" # ============================================================================ -# test - 테스트 실행 +# test - 테스트 실행 (coverage 포함, 80% 게이트) # ============================================================================ test: @echo "$(COLOR_BOLD)$(COLOR_BLUE)테스트 실행 중...$(COLOR_RESET)" - @$(VENV_BIN)/pytest + @$(VENV_BIN)/pytest --cov=$(SRC_DIR) --cov-report=term-missing @echo "$(COLOR_GREEN)✓ 테스트 완료$(COLOR_RESET)" # ============================================================================ @@ -235,6 +243,44 @@ update-hooks: @$(PRE_COMMIT) autoupdate @echo "$(COLOR_GREEN)✓ 훅 업데이트 완료$(COLOR_RESET)" +# ============================================================================ +# quick-check - commit 시뮬레이션 (pre-commit 단계 훅 전체) +# ============================================================================ +quick-check: + @echo "$(COLOR_BOLD)$(COLOR_BLUE)Quick check (commit 시뮬)...$(COLOR_RESET)" + @$(PRE_COMMIT) run --all-files --hook-stage pre-commit + @echo "$(COLOR_GREEN)✓ commit 단계 통과$(COLOR_RESET)" + +# ============================================================================ +# full-check - push 시뮬레이션 (pre-commit + pre-push 모두) +# ============================================================================ +full-check: + @echo "$(COLOR_BOLD)$(COLOR_BLUE)Full check (push 시뮬)...$(COLOR_RESET)" + @$(PRE_COMMIT) run --all-files --hook-stage pre-commit + @$(PRE_COMMIT) run --all-files --hook-stage pre-push + @echo "$(COLOR_GREEN)✓ commit + push 단계 모두 통과$(COLOR_RESET)" + +# ============================================================================ +# list-checks - 정의된 모든 검사를 한눈에 보기 +# ============================================================================ +list-checks: + @echo "$(COLOR_BOLD)$(COLOR_CYAN)정의된 pre-commit 훅 목록:$(COLOR_RESET)" + @$(PRE_COMMIT) run --all-files --verbose --hook-stage manual 2>/dev/null || true + @echo "" + @echo "$(COLOR_GREEN)단계별 훅 위치는 .pre-commit-config.yaml의 stages 필드 참고$(COLOR_RESET)" + +# ============================================================================ +# init - your_project 플레이스홀더를 새 이름으로 일괄 치환 +# 사용법: make init NAME=my_app +# ============================================================================ +init: +ifndef NAME + $(error NAME 변수가 필요합니다. 예: make init NAME=my_app) +endif + @echo "$(COLOR_BOLD)$(COLOR_BLUE)프로젝트 초기화: $(NAME)$(COLOR_RESET)" + @$(PYTHON) scripts/init_project.py $(NAME) + @echo "$(COLOR_GREEN)✓ 초기화 완료. 'make install-dev' 후 'make ci-check'로 검증하세요.$(COLOR_RESET)" + # ============================================================================ # 개발 워크플로우 예시 # ============================================================================ diff --git a/scripts/stage_fixes.py b/scripts/stage_fixes.py new file mode 100644 index 0000000..1209cd4 --- /dev/null +++ b/scripts/stage_fixes.py @@ -0,0 +1,33 @@ +"""pre-commit이 넘긴 원본 staged 파일 목록만 다시 git add. + +pre-commit 프레임워크는 훅에 "원래 staged였던 파일 목록"을 인자로 넘긴다 +(즉 ``git diff --cached --name-only --diff-filter=ACMR``). Ruff/Ruff-format +같은 자동수정 훅이 그 파일들을 수정한 뒤, 이 스크립트가 동일 목록만 +정확히 다시 ``git add``하여 자동수정 결과를 같은 커밋에 포함시킨다. + +``git add -A``/``git add -u``를 의도적으로 회피하여 +``git add -p`` 부분 stage 워크플로우의 unstaged hunk가 함께 끌려가는 +사고를 방지한다. +""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys + + +def main(files: list[str]) -> int: + if not files: + return 0 + + existing = [f for f in files if Path(f).exists()] + if not existing: + return 0 + + subprocess.run(["git", "add", "--", *existing], check=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) From 40825006df6a3b9aef8dee43c7af76e8cf528af7 Mon Sep 17 00:00:00 2001 From: "Junghee Im(Jion Im)" Date: Mon, 27 Apr 2026 10:31:03 +0900 Subject: [PATCH 03/10] feat(setup): add init script for project name substitution scripts/init_project.py replaces the manual 4-location placeholder substitution that previously required reading README's customization section. Cross-platform safe (stdlib only, no sed -i variant issues). - Validates the new name as a Python package identifier - Substitutes 'your-project' (kebab) and 'your_project' (snake) - Renames src/your_project/ to src// - Idempotent: safe to re-run Invoked via 'make init NAME=my_app' (Makefile target added previously) or directly: python scripts/init_project.py my_app --- scripts/init_project.py | 89 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 scripts/init_project.py diff --git a/scripts/init_project.py b/scripts/init_project.py new file mode 100644 index 0000000..31e095c --- /dev/null +++ b/scripts/init_project.py @@ -0,0 +1,89 @@ +"""your-project / your_project 플레이스홀더를 새 프로젝트명으로 일괄 치환. + +크로스플랫폼(macOS/Linux/Windows) 안전. ``sed -i`` 변종 차이를 피하려고 +Python stdlib만 사용한다. + +치환 규칙: +- ``your-project`` → kebab-case 이름 (PEP 503 정규화) +- ``your_project`` → snake_case 이름 (Python 패키지명) +- ``src/your_project/`` 디렉토리 → ``src//``로 이동 + +사용: + python scripts/init_project.py my_app + # 또는 Makefile 경유: + make init NAME=my_app +""" + +from __future__ import annotations + +from pathlib import Path +import re +import shutil +import sys + +ROOT = Path(__file__).resolve().parent.parent + + +def to_snake(name: str) -> str: + """패키지명용 snake_case로 정규화.""" + cleaned = re.sub(r"[-\s]+", "_", name).lower() + if not re.fullmatch(r"[a-z_][a-z0-9_]*", cleaned): + msg = ( + f"'{name}'은 유효한 Python 패키지명이 아닙니다 " + "(영문 소문자/숫자/언더스코어만 허용, 숫자로 시작 금지)" + ) + raise ValueError(msg) + return cleaned + + +def to_kebab(name: str) -> str: + """배포용 kebab-case로 정규화.""" + return re.sub(r"[_\s]+", "-", name).lower() + + +def replace_in_file(path: Path, snake: str, kebab: str) -> None: + text = path.read_text(encoding="utf-8") + new = text.replace("your-project", kebab).replace("your_project", snake) + if new != text: + path.write_text(new, encoding="utf-8") + + +def main(name: str) -> int: + snake = to_snake(name) + kebab = to_kebab(name) + + src_old = ROOT / "src" / "your_project" + src_new = ROOT / "src" / snake + if src_old.exists() and src_old != src_new: + if src_new.exists(): + print(f"오류: {src_new}가 이미 존재합니다.", file=sys.stderr) + return 1 + shutil.move(str(src_old), str(src_new)) + + targets = [ + ROOT / "pyproject.toml", + src_new / "__init__.py", + ] + for f in targets: + if f.exists(): + replace_in_file(f, snake, kebab) + + print( + f"✓ '{name}' (snake={snake}, kebab={kebab})로 초기화 완료.\n" + "다음 단계: make install-dev && make ci-check" + ) + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print( + "Usage: python scripts/init_project.py ", + file=sys.stderr, + ) + sys.exit(2) + try: + sys.exit(main(sys.argv[1])) + except ValueError as exc: + print(f"오류: {exc}", file=sys.stderr) + sys.exit(1) From 067a1b78f65db85c192e1ab409ba3485bf153713 Mon Sep 17 00:00:00 2001 From: "Junghee Im(Jion Im)" Date: Mon, 27 Apr 2026 10:31:16 +0900 Subject: [PATCH 04/10] chore(ci): add GitHub Actions workflow Remote safety net for cases where local hooks are bypassed. Runs make ci-check on every push to main and every pull_request, ensuring CI behavior matches local checks. - Python 3.12 matching pyproject.toml requires-python - pip cache keyed on pyproject.toml hash for faster reruns - Concurrency group cancels in-progress runs on the same ref --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d79ed93 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + cache-dependency-path: pyproject.toml + + - name: Install dev dependencies + run: pip install -e ".[dev]" + + - name: Run all checks (lint + format check + mypy + pytest + coverage) + run: make ci-check From 4b6a90820b511254bfe96cc92de7d2d733c13dab Mon Sep 17 00:00:00 2001 From: "Junghee Im(Jion Im)" Date: Mon, 27 Apr 2026 10:31:27 +0900 Subject: [PATCH 05/10] refactor(version): load __version__ via importlib.metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardcoded __version__ = '0.1.0' would drift after 'make bump-version' because commitizen only updates pyproject.toml. Read from package metadata at runtime instead — the standard pattern used by FastAPI, httpx, pip, and most well-maintained Python libraries. Falls back to '0.0.0' when the package isn't installed (source-only checkouts) so importing always succeeds. --- src/your_project/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/your_project/__init__.py b/src/your_project/__init__.py index 61e5102..e9c91e5 100644 --- a/src/your_project/__init__.py +++ b/src/your_project/__init__.py @@ -1,3 +1,9 @@ """your-project: Python 3.12 프로젝트 템플릿.""" -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("your-project") +except PackageNotFoundError: + # 패키지가 설치되지 않은 환경(예: 소스만 체크아웃) fallback + __version__ = "0.0.0" From 729fe565e5e55e05f4fd8594189c0439e4934630 Mon Sep 17 00:00:00 2001 From: "Junghee Im(Jion Im)" Date: Mon, 27 Apr 2026 10:31:38 +0900 Subject: [PATCH 06/10] chore(editor): add .editorconfig and VSCode workspace settings .editorconfig: cross-editor consistency for charset, EOL, indentation, and trailing whitespace. Tabs for Makefile, 2-space for YAML/TOML/JSON, 4-space for Python. .vscode/settings.json + extensions.json: opt-in workspace defaults. - Ruff as default Python formatter with format-on-save and fix-on-save - Pylance strict type checking aligned with mypy strict mode - pytest auto-discovery - Recommends ruff, python, mypy, even-better-toml, EditorConfig extensions .gitignore: unignore .vscode/settings.json and extensions.json specifically so the workspace defaults are tracked while personal settings (launch.json, tasks.json) stay ignored. --- .editorconfig | 22 ++++++++++++++++++++++ .gitignore | 6 ++++-- .vscode/extensions.json | 9 +++++++++ .vscode/settings.json | 15 +++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..17f81b9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.py] +indent_size = 4 +max_line_length = 88 + +[*.{yml,yaml,toml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore index 7877008..955acae 100644 --- a/.gitignore +++ b/.gitignore @@ -94,8 +94,10 @@ dmypy.json *.iml *.iws -# VSCode -.vscode/ +# VSCode (팀 공유 설정만 추적) +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json *.code-workspace # Vim diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..3583984 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "charliermarsh.ruff", + "ms-python.python", + "ms-python.mypy-type-checker", + "tamasfe.even-better-toml", + "EditorConfig.EditorConfig" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fcae568 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + } + }, + "python.analysis.typeCheckingMode": "strict", + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": ["tests"], + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true +} From e1dc7234fc1d7cb3c8203edbf81dab070889541e Mon Sep 17 00:00:00 2001 From: "Junghee Im(Jion Im)" Date: Mon, 27 Apr 2026 10:31:55 +0900 Subject: [PATCH 07/10] docs: rewrite README with new pipeline and troubleshooting Reflect the new commit/push checker architecture: - Two-stage hook pipeline diagram (commit-msg + pre-commit + pre-push) plus GitHub Actions CI showing each stage's responsibilities - 'Why might my first commit fail' section explaining the auto-stage flow - Daily workflow example - quick-check / full-check / list-checks command reference - Conventional Commits guide with the inline ! syntax for MAJOR bumps - Troubleshooting: slow pre-push, hook bypass, init misses, ruff drift - SSOT architecture diagram (.pre-commit-config.yaml as single source) --- README.md | 185 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 140 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 3ae581f..bcfa932 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,44 @@ # Python 3.12 Project Template -Automated code quality system for Python 3.12 projects. +Automated commit/push quality gate template for Python 3.12 projects. The +checking system itself is the deliverable — pre-commit and pre-push hooks +plus a CI workflow ensure broken or unformatted code never lands in main. ## Features -- **Ruff** — Linting + formatting in one tool +- **Ruff** — Linting + formatting in one tool, with auto-fix and **auto-stage** - **mypy** — Strict type checking -- **pytest** — Testing with coverage -- **pre-commit** — Automated checks on every commit +- **pytest + coverage** — Tests with 80%+ coverage gate (CI) +- **pre-commit + pre-push** — Two-stage hook pipeline - **Commitizen** — Conventional Commits enforcement +- **GitHub Actions CI** — Remote safety net for `--no-verify` bypass -### Git Hook Pipeline +### Two-Stage Hook Pipeline -```bash +``` git commit -m "feat: add user auth" -# Runs automatically: -# 1. File checks (EOF, whitespace, YAML/TOML/JSON, private keys) -# 2. Ruff linting (with auto-fix) -# 3. Ruff formatting -# 4. Commitizen message validation + ├─ pre-commit stage (fast, file-level) + │ ├─ End-of-file / trailing whitespace + │ ├─ YAML / TOML / JSON syntax check + │ ├─ Secret detection (private key, AWS credentials) + │ ├─ Large file block (>1MB) + │ ├─ Ruff lint (--fix) + │ ├─ Ruff format + │ └─ stage-fixes (auto git add the fixed files) + └─ commit-msg stage + └─ Commitizen message validation + +git push + └─ pre-push stage (project-wide) + ├─ pytest -x --no-cov -q (fast fail mode) + └─ mypy src + +GitHub Actions CI (push to main, PR) + └─ make ci-check + ├─ ruff check --no-fix + ├─ ruff format --check + ├─ mypy src + └─ pytest --cov (80% gate) ``` ## Prerequisites @@ -29,67 +49,142 @@ git commit -m "feat: add user auth" ## Getting Started ```bash -# Clone and setup +# 1. Clone git clone https://github.com/bigmooon/python-template.git cd python-template -# Install dev environment (venv + deps + git hooks) +# 2. Initialize with your project name +make init NAME=my_app + +# 3. Install dev environment (venv + deps + git hooks for commit/push/commit-msg) make install-dev ``` -## Usage +## Why might my first commit fail? -### Development Workflow +When Ruff auto-fixes code (e.g. import sort, quote style), pre-commit returns +a non-zero exit code so you can review the changes. The `stage-fixes` hook +**re-stages only the files originally staged** — protecting any partial +`git add -p` hunks you intentionally left unstaged. ```bash -# 1. After writing code -make check # lint + format (auto-fix) +git add src/foo.py +git commit -m "feat: add foo" +# ⚠️ Ruff fixed src/foo.py — first commit fails +git commit -m "feat: add foo" +# ✓ Second attempt passes (auto-fix already staged) +``` + +This is intentional — it gives you a chance to inspect the diff Ruff applied. + +## Usage -# 2. Before committing — full validation -make validate # lint + format + test + typecheck +### Daily Workflow -# 3. Commit -git add . -git commit -m "feat(auth): add login endpoint" +```bash +# Edit code → commit → push +git add src/foo.py +git commit -m "feat(foo): add bar" +git push +# pre-push runs pytest + mypy automatically +``` -# Or use interactive commit -make commit +### Manual checks before committing + +```bash +make quick-check # commit-stage hooks (lint + format + file checks) +make full-check # commit + push stages (adds pytest + mypy) +make ci-check # CI mode (no auto-fix, with coverage gate) +make list-checks # show all configured checks at a glance ``` -### All Commands +### Commands ```bash -make help # Show all commands +make help # all commands # Setup -make install # Production install -make install-dev # Dev environment (recommended) -make install-hooks # Git hooks only +make install-dev # venv + deps + git hooks (recommended) +make install-hooks # git hooks only (pre-commit + commit-msg + pre-push) +make init NAME=foo # rename placeholders to 'foo' -# Code Quality -make lint # Lint with auto-fix -make format # Format code -make test # Run tests (pytest + coverage) -make typecheck # Type check (mypy) +# Code quality +make lint # ruff check --fix +make format # ruff format +make typecheck # mypy src +make test # pytest with coverage # Combined make check # lint + format -make validate # lint + format + test + typecheck -make ci-check # CI mode (verify only, no auto-fix) +make validate # lint + format + typecheck + test +make ci-check # CI verification (--no-fix, coverage gate) +make quick-check # pre-commit stage only +make full-check # pre-commit + pre-push stages # Git -make commit # Interactive commit (Conventional Commits) -make bump-version # Auto bump version + tag +make commit # interactive commit (Conventional Commits) +make bump-version # auto bump version + tag # Maintenance -make clean # Clear caches -make update-hooks # Update pre-commit hooks +make clean # clear caches +make update-hooks # update pre-commit hook revisions ``` -## Customization +## Conventional Commits + +Allowed types: `feat`, `fix`, `docs`, `refactor`, `chore`, `revert`, `perf`. + +```bash +feat(auth): add login endpoint +fix(parser): handle empty input +``` + +**MAJOR version bumps**: use the inline `!` syntax — `feat!: rewrite API`. +The `BREAKING CHANGE:` footer is **not validated** by the commit-msg hook +(commitizen schema limitation), so the inline `!` is the reliable signal. + +## Troubleshooting + +### A pre-push check is too slow + +The `pre-push` hooks run `pytest -x --no-cov -q` and `mypy src`. If they +exceed ~30s, contributors may bypass with `--no-verify`. Mitigation: +- Mark slow tests with `@pytest.mark.slow` and skip them in pre-push +- The 80% coverage gate runs only in CI, so coverage isn't recomputed on push + +### `git push --no-verify` was used + +Local hooks can be bypassed. The GitHub Actions workflow +(`.github/workflows/ci.yml`) re-runs `make ci-check` on every push to `main` +and every PR — this is the authoritative gate. + +### `make init` didn't rename a reference + +`scripts/init_project.py` substitutes `your-project` (kebab) and `your_project` +(snake) in `pyproject.toml` and `src//__init__.py`. If you added new +files referencing the old name, run `grep -r your_project .` to find them. + +### Ruff and pre-commit ruff versions disagree + +`pyproject.toml` pins `ruff==0.8.4` exactly to match `.pre-commit-config.yaml`'s +`rev: v0.8.4`. When upgrading Ruff, **bump both in the same commit** to avoid +"local passes, CI fails" drift. + +## Architecture + +The check definitions live in `.pre-commit-config.yaml` (single source of +truth). The Makefile is a thin wrapper around `pre-commit run` plus a few +direct tool invocations for `make ci-check`. The CI workflow calls +`make ci-check` so local and CI behavior stay aligned. + +``` +.pre-commit-config.yaml ← SSOT for all check definitions + ↑ + Makefile (wrapper) + ↑ + .github/workflows/ci.yml (calls make ci-check) +``` -When using this template for a new project, update the following: +## License -1. `pyproject.toml` — `name`, `description`, `authors`, `keywords` -2. `pyproject.toml` — `known-first-party` in `[tool.ruff.lint.isort]` -3. Rename `src/your_project/` to match your package name +MIT From 4ac37d69c6cf50c8f98f9ce968adcf836bb5062d Mon Sep 17 00:00:00 2001 From: "Junghee Im(Jion Im)" Date: Mon, 27 Apr 2026 11:09:47 +0900 Subject: [PATCH 08/10] fix(tests): add smoke test so pre-push gate passes on empty templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pytest exits 5 (no tests collected) when the template is freshly cloned, which would block every fresh contributor from their first git push. The smoke test anchors the suite — users replace it as they add real tests. - tests/test_smoke.py: imports the package and asserts __version__ is set - src/your_project/__init__.py: mark PackageNotFoundError fallback with pragma: no cover (defensive code unreachable in installed environments) so the 80% coverage gate stays green --- src/your_project/__init__.py | 2 +- tests/test_smoke.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 tests/test_smoke.py diff --git a/src/your_project/__init__.py b/src/your_project/__init__.py index e9c91e5..ce6535b 100644 --- a/src/your_project/__init__.py +++ b/src/your_project/__init__.py @@ -4,6 +4,6 @@ try: __version__ = version("your-project") -except PackageNotFoundError: +except PackageNotFoundError: # pragma: no cover # 패키지가 설치되지 않은 환경(예: 소스만 체크아웃) fallback __version__ = "0.0.0" diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..28b65c6 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,19 @@ +"""Smoke test — anchors the test suite so pytest never returns exit code 5. + +Replace or expand this as your project grows. The template ships with one +test on purpose: pytest treats "0 tests collected" as a failure (exit 5), +which would block the pre-push gate on every fresh clone. +""" + +from __future__ import annotations + +import your_project + + +def test_package_imports() -> None: + assert your_project is not None + + +def test_version_is_a_string() -> None: + assert isinstance(your_project.__version__, str) + assert your_project.__version__ From cc2f9a70776340cd802979d279afa115e2678d9c Mon Sep 17 00:00:00 2001 From: "Junghee Im(Jion Im)" Date: Mon, 27 Apr 2026 12:11:15 +0900 Subject: [PATCH 09/10] fix(ci): create venv before make ci-check so .venv paths resolve Makefile's ci-check target uses .venv/bin/ruff and .venv/bin/pytest to match local convention. CI was installing into system Python directly, which left .venv/ absent and caused 'make: .venv/bin/ruff: No such file or directory'. Mirror local setup by creating .venv in CI as well. --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d79ed93..5db2bc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,11 @@ jobs: cache: "pip" cache-dependency-path: pyproject.toml - - name: Install dev dependencies - run: pip install -e ".[dev]" + - name: Create venv and install dev dependencies + run: | + python -m venv .venv + .venv/bin/pip install --upgrade pip + .venv/bin/pip install -e ".[dev]" - name: Run all checks (lint + format check + mypy + pytest + coverage) run: make ci-check From 2c32ccb59a0a14b1072631655efbbb695a5bb184 Mon Sep 17 00:00:00 2001 From: "Junghee Im(Jion Im)" Date: Mon, 27 Apr 2026 12:14:20 +0900 Subject: [PATCH 10/10] chore(ci): bump checkout/setup-python to v6 (Node.js 24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub deprecated Node.js 20 actions; v4 (checkout) and v5 (setup-python) will be forced to Node.js 24 by June 2026 and removed by September 2026. Move to v6 of both — input/output keys remain compatible (python-version, cache, cache-dependency-path). --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5db2bc8..df55b32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,9 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" cache: "pip"