diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a400a93 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + groups: + development-dependencies: + dependency-type: development + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df55b32..4cba31b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,27 +5,35 @@ on: branches: [main] pull_request: +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: check: - runs-on: ubuntu-latest + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} cache: "pip" - cache-dependency-path: pyproject.toml + cache-dependency-path: requirements-dev.lock - - 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: Install locked development environment + run: make install-dev - name: Run all checks (lint + format check + mypy + pytest + coverage) run: make ci-check + + - name: Verify template initialization workflow + run: make test-integration diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de4ece1..250bf8a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -74,14 +74,14 @@ repos: name: "Ruff 포매터" # ========================================== - # 4. 자동 수정 결과를 같은 커밋에 stage + # 4. 자동 수정 검토 안내 + # index를 변경하지 않아 부분 staging을 보존한다. # (반드시 ruff/ruff-format 다음에 위치) - # stdlib만 사용하므로 어떤 python3든 OK. # ========================================== - repo: local hooks: - - id: stage-fixes - name: "자동 수정 파일 stage" + - id: review-fixes + name: "자동 수정 검토 안내" entry: python3 scripts/stage_fixes.py language: system stages: [pre-commit] @@ -116,7 +116,7 @@ repos: - id: mypy-push name: "mypy (push 전)" - entry: .venv/bin/python -m mypy src + entry: .venv/bin/python -m mypy src scripts language: system types: [python] stages: [pre-push] diff --git a/CLAUDE.md b/CLAUDE.md index ff169d9..15ddcca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Python 3.12 프로젝트 템플릿. Ruff(린터+포매터), pre-commit 훅, Commitizen(Conventional Commits)을 통한 자동화된 코드 품질 검사 시스템. +Python 3.12–3.13 프로젝트 템플릿. Ruff(린터+포매터), pre-commit 훅, Commitizen(Conventional Commits)을 통한 자동화된 코드 품질 검사 시스템. ## Commands @@ -27,6 +27,9 @@ make validate # CI용 검사 (수정 없이 검증만) make ci-check +# 임시 프로젝트에서 init → install-dev → ci-check 검증 +make test-integration + # 테스트 (pytest + coverage) make test @@ -45,14 +48,16 @@ make clean Ruff 직접 실행: ```bash -.venv/bin/ruff check src tests --fix -.venv/bin/ruff format src tests +.venv/bin/ruff check src tests scripts --fix +.venv/bin/ruff format src tests scripts ``` ## Architecture - `src/your_project/` — 소스 코드 패키지 (TODO: 프로젝트명으로 변경) -- `tests/` — 테스트 디렉토리 +- `tests/` — 단위 테스트와 템플릿 초기화 통합 테스트 +- `scripts/` — 프로젝트 초기화와 fail-only 자동 수정 검토 안내 +- `requirements-dev.lock` — Python 3.12–3.13 공통 개발 constraints - `pyproject.toml` — 프로젝트 메타데이터, Ruff 설정, Commitizen 설정 통합 - `.pre-commit-config.yaml` — Git 훅 (파일 검사, Ruff, Commitizen) - `Makefile` — 개발 자동화 명령어 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b4fc172 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Junghee Im + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 52689d4..897986a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # ============================================================================ # Makefile - Python 프로젝트 개발 자동화 -# Python 3.12+ 프로젝트용 +# Python 3.12–3.13 프로젝트용 # ============================================================================ # 사용법: # make help - 사용 가능한 명령어 목록 @@ -20,7 +20,7 @@ COLOR_BLUE := \033[34m COLOR_CYAN := \033[36m # 프로젝트 설정 -PYTHON := python3.12 +PYTHON ?= python3 VENV := .venv VENV_BIN := $(VENV)/bin PIP := $(VENV_BIN)/pip @@ -29,7 +29,11 @@ PYTHON_VENV := $(VENV_BIN)/python # 소스 디렉토리 SRC_DIR := src TEST_DIR := tests -ALL_DIRS := $(SRC_DIR) $(TEST_DIR) +SCRIPT_DIR := scripts +ALL_DIRS := $(SRC_DIR) $(TEST_DIR) $(SCRIPT_DIR) +MYPY_DIRS := $(SRC_DIR) $(SCRIPT_DIR) +COVERAGE_ARGS := --cov=$(SRC_DIR) --cov=$(SCRIPT_DIR) +DEV_CONSTRAINTS := requirements-dev.lock # 도구 실행 경로 RUFF := $(VENV_BIN)/ruff @@ -43,7 +47,8 @@ COMMITIZEN := $(VENV_BIN)/cz .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 quick-check full-check list-checks init + pre-commit run-all update-hooks quick-check full-check list-checks init \ + test-integration lock-dev # ============================================================================ # help - 사용 가능한 명령어 목록 @@ -61,6 +66,7 @@ help: @echo " make lint - 코드 품질 검사 (자동 수정)" @echo " make format - 코드 포매팅 (자동 정리)" @echo " make test - 테스트 실행 (pytest + coverage)" + @echo " make test-integration - 임시 프로젝트 초기화부터 CI 검사까지 검증" @echo " make typecheck - 타입 검사 (mypy)" @echo "" @echo "$(COLOR_GREEN)통합 명령어:$(COLOR_RESET)" @@ -82,6 +88,7 @@ help: @echo "" @echo "$(COLOR_GREEN)유지보수:$(COLOR_RESET)" @echo " make update-hooks - pre-commit 훅 업데이트" + @echo " make lock-dev - 개발 의존성 constraints 파일 재생성 (uv 필요)" @echo "" # ============================================================================ @@ -101,7 +108,7 @@ install-dev: @echo "$(COLOR_BOLD)$(COLOR_BLUE)개발 환경 설치 중...$(COLOR_RESET)" @test -d $(VENV) || $(PYTHON) -m venv $(VENV) @$(PIP) install --upgrade pip setuptools wheel - @$(PIP) install -e ".[dev]" + @$(PIP) install -c $(DEV_CONSTRAINTS) -e ".[dev]" @$(MAKE) install-hooks @echo "$(COLOR_GREEN)✓ 개발 환경 설치 완료$(COLOR_RESET)" @echo "$(COLOR_YELLOW)다음 명령어로 환경 활성화:$(COLOR_RESET)" @@ -175,8 +182,8 @@ 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 --cov=$(SRC_DIR) --cov-report=term-missing --no-header -q + @$(VENV_BIN)/mypy $(MYPY_DIRS) + @$(VENV_BIN)/pytest $(COVERAGE_ARGS) --cov-report=term-missing --no-header -q @echo "$(COLOR_GREEN)✓ CI 검사 통과$(COLOR_RESET)" # ============================================================================ @@ -184,15 +191,23 @@ ci-check: # ============================================================================ test: @echo "$(COLOR_BOLD)$(COLOR_BLUE)테스트 실행 중...$(COLOR_RESET)" - @$(VENV_BIN)/pytest --cov=$(SRC_DIR) --cov-report=term-missing + @$(VENV_BIN)/pytest $(COVERAGE_ARGS) --cov-report=term-missing @echo "$(COLOR_GREEN)✓ 테스트 완료$(COLOR_RESET)" +# ============================================================================ +# test-integration - 임시 복사본에서 init → install-dev → ci-check 검증 +# ============================================================================ +test-integration: + @echo "$(COLOR_BOLD)$(COLOR_BLUE)템플릿 초기화 통합 테스트 실행 중...$(COLOR_RESET)" + @$(VENV_BIN)/pytest -m integration --no-cov -q + @echo "$(COLOR_GREEN)✓ 초기화 통합 테스트 통과$(COLOR_RESET)" + # ============================================================================ # typecheck - 타입 검사 # ============================================================================ typecheck: @echo "$(COLOR_BOLD)$(COLOR_BLUE)타입 검사 중...$(COLOR_RESET)" - @$(VENV_BIN)/mypy $(SRC_DIR) + @$(VENV_BIN)/mypy $(MYPY_DIRS) @echo "$(COLOR_GREEN)✓ 타입 검사 완료$(COLOR_RESET)" # ============================================================================ @@ -243,6 +258,16 @@ update-hooks: @$(PRE_COMMIT) autoupdate @echo "$(COLOR_GREEN)✓ 훅 업데이트 완료$(COLOR_RESET)" +# ============================================================================ +# lock-dev - Python 3.12–3.13/전체 플랫폼용 개발 constraints 재생성 +# ============================================================================ +lock-dev: + @command -v uv >/dev/null || { echo "uv가 필요합니다: https://docs.astral.sh/uv/"; exit 1; } + @uv pip compile pyproject.toml --extra dev --universal \ + --no-emit-package your-project \ + --custom-compile-command "make lock-dev" \ + --output-file $(DEV_CONSTRAINTS) + # ============================================================================ # quick-check - commit 시뮬레이션 (pre-commit 단계 훅 전체) # ============================================================================ diff --git a/README.md b/README.md index f52ec29..0269de3 100644 --- a/README.md +++ b/README.md @@ -1,275 +1,132 @@ # Python Project Quality Template [![CI](https://github.com/bigmooon/python-template/actions/workflows/ci.yml/badge.svg)](https://github.com/bigmooon/python-template/actions/workflows/ci.yml) -![Python](https://img.shields.io/badge/Python-3.12-3776AB?logo=python&logoColor=white) +![Python](https://img.shields.io/badge/Python-3.12%20%7C%203.13-3776AB?logo=python&logoColor=white) ![Ruff](https://img.shields.io/badge/Ruff-0.8.4-D7FF64?logo=ruff&logoColor=261230) -![mypy](https://img.shields.io/badge/mypy-strict-2A6DB2) ![Coverage](https://img.shields.io/badge/coverage-gate%2080%25-brightgreen) -새 Python 프로젝트를 시작할 때마다 린터, 포매터, 타입 검사, 테스트, 커밋 규칙과 CI를 다시 연결하지 않도록 만든 **Python 3.12 프로젝트 템플릿**입니다. +Python 3.12–3.13 프로젝트를 시작할 때 패키지 구조, 품질 검사, Git hook과 CI를 함께 재사용할 수 있는 템플릿입니다. Ruff, mypy strict, pytest, coverage, pre-commit, Commitizen을 하나의 Make 워크플로우로 묶습니다. -커밋 전에는 변경 파일을 빠르게 확인하고, 푸시 전에는 테스트와 타입 검사를 실행하며, GitHub Actions에서는 자동 수정 없이 전체 품질 기준을 다시 검증합니다. +## 바로 시작하기 -## 프로젝트 개요 - -| 항목 | 내용 | -|---|---| -| 목적 | Python 프로젝트의 초기 품질 도구와 Git 워크플로를 재사용 가능한 형태로 표준화 | -| 대상 | 개인 프로젝트와 소규모 팀 프로젝트를 빠르게 시작하려는 Python 개발자 | -| 실행 환경 | Python 3.12, Git, Make, POSIX 환경(macOS/Linux) | -| 핵심 도구 | Ruff, mypy, pytest, coverage, pre-commit, Commitizen, GitHub Actions | -| 품질 기준 | Ruff 검사·포맷, mypy strict, 테스트 통과, 커버리지 80% 이상 | -| 초기화 방식 | `make init NAME=`으로 패키지명과 프로젝트 메타데이터 치환 | - -## 해결하려는 문제 - -프로젝트 초기에 품질 도구를 각각 설치하면 다음 문제가 반복됩니다. - -- 로컬 검사와 CI 명령이 달라 같은 코드가 한쪽에서만 실패함 -- 린트·타입·테스트 오류를 PR이나 배포 직전에 발견함 -- 자동 수정 과정에서 의도하지 않은 파일까지 함께 스테이징할 수 있음 -- 커밋 메시지 형식과 버전 증가 기준이 사람마다 달라짐 -- 새 저장소를 만들 때 설정 파일과 명령어를 매번 다시 작성함 - -이 템플릿은 검사 시점을 `commit → push → CI` 세 단계로 나누고, 설정과 실행 명령을 저장소 안에 함께 제공합니다. - -## 핵심 기능 - -| 영역 | 동작 | 도구 | -|---|---|---| -| 린트·포맷 | Python 안티패턴 검사, import 정렬, 자동 수정, 포맷 검사 | Ruff | -| 타입 안정성 | 소스 패키지 전체를 strict 모드로 검사 | mypy | -| 테스트 | smoke test 제공, CI에서 branch coverage 80% 기준 적용 | pytest, coverage | -| 파일 안전성 | 문법 오류, 충돌 마커, 디버그 구문, private key·AWS 자격증명 검사 | pre-commit hooks | -| 변경 크기 | 새로 추가한 1MB 초과 파일 차단 | pre-commit hooks | -| 커밋 규칙 | Conventional Commits 형식 검사와 버전 증가 | Commitizen | -| 자동화 | 로컬과 동일한 품질 검사를 push와 PR에서 재실행 | GitHub Actions | -| 프로젝트 초기화 | 패키지명·배포명·테스트·안내 문서 플레이스홀더 치환 | Python script, Make | - -## 품질 게이트 구조 - -```mermaid -flowchart LR - A[git commit] --> B[pre-commit] - B --> B1[파일·보안 검사] - B --> B2[Ruff lint·format] - B --> B3[수정 파일 재스테이징] - A --> C[commit-msg] - C --> C1[Commitizen 규칙] - - D[git push] --> E[pre-push] - E --> E1[pytest 빠른 실패] - E --> E2[mypy strict] - - F[push 또는 PR] --> G[GitHub Actions] - G --> G1[Ruff 검사·포맷 확인] - G --> G2[mypy strict] - G --> G3[pytest + coverage 80%] -``` - -### 단계별 책임 - -| 단계 | 목적 | 검사 범위 | -|---|---|---| -| pre-commit | 수초 안에 수정 가능한 문제 발견 | staged 파일, 파일 무결성, 보안 패턴, Ruff | -| commit-msg | 커밋 기록의 일관성 유지 | Conventional Commits 형식 | -| pre-push | 원격 전송 전 프로젝트 단위 회귀 확인 | 빠른 pytest, mypy | -| CI | 로컬 훅 우회 여부와 관계없이 최종 재검증 | Ruff, mypy, pytest, coverage gate | - -> GitHub Actions는 검사를 수행하지만, 실패한 코드의 `main` 병합을 실제로 차단하려면 저장소에서 branch protection과 required status check를 별도로 설정해야 합니다. - -## 설계 선택과 이유 - -### 빠른 검사는 앞에, 무거운 검사는 뒤에 - -커밋 단계에는 변경 파일 중심의 검사만 배치하고, 프로젝트 전체 테스트와 타입 검사는 push 단계에서 실행합니다. 커버리지 계산은 CI에만 두어 일상적인 커밋과 푸시의 대기 시간을 줄였습니다. - -### 로컬에서는 수정하고 CI에서는 검증만 수행 - -로컬 `make lint`와 pre-commit의 Ruff는 수정 가능한 문제를 자동으로 고칩니다. 반면 `make ci-check`는 `--no-fix`와 `format --check`를 사용해 CI가 코드를 바꾸지 않고 실패 원인만 보고하도록 구성했습니다. - -### 자동 수정 뒤에는 한 번 멈춤 - -Ruff가 staged 파일을 수정하면 첫 커밋은 실패합니다. `stage_fixes.py`는 pre-commit이 전달한 목록과 실제 staged 파일의 교집합만 다시 스테이징하고, 사용자는 변경 내용을 확인한 뒤 같은 커밋을 다시 실행합니다. `--all-files` 검사에서 작업 중인 다른 파일이 함께 추가되지 않도록 했습니다. +필요한 환경은 Git, Make, POSIX shell(macOS/Linux), 그리고 `python3`가 Python 3.12 또는 3.13을 가리키는 환경입니다. 기본 명령과 다른 실행 파일을 써야 하면 `PYTHON=python3.12`처럼 오버라이드하면 됩니다. ```bash -git commit -m "feat: add parser" -# Ruff가 코드를 수정하면 커밋 중단 +git clone https://github.com/bigmooon/python-template.git my-project +cd my-project -git diff --cached -git commit -m "feat: add parser" +make init NAME=my_project +make install-dev +make ci-check ``` -### 도구 버전 드리프트 방지 +`NAME`은 Python 패키지에 쓸 수 있는 `snake_case`를 권장합니다. `make init`은 다음 변경을 적용합니다. -패키지 의존성의 `ruff==0.8.4`와 pre-commit hook의 `rev: v0.8.4`를 맞춰 로컬 직접 실행과 Git hook의 결과 차이를 줄였습니다. GitHub Actions는 `make ci-check`를 호출해 저장소에 정의된 명령을 그대로 사용합니다. +- `src/your_project/`를 새 패키지명으로 이동 +- 배포 이름은 `kebab-case`, import 이름은 `snake_case`로 치환 +- `pyproject.toml`, 기본 테스트, README, Makefile, lock 파일의 플레이스홀더 갱신 -## 검증 결과 +`make install-dev`는 `.venv`를 만들고 `requirements-dev.lock`을 constraints로 사용해 개발 의존성을 설치한 뒤 pre-commit, commit-msg, pre-push hook을 설치합니다. -2026-10-10에 Python 3.12.12 환경에서 `make ci-check`를 실행해 다음 결과를 확인했습니다. +## 지원 범위와 검사 범위 -| 검사 | 결과 | -|---|:---:| -| Ruff lint | 통과 | -| Ruff format | 4개 Python 파일 통과 | -| mypy strict | 1개 소스 파일, 오류 0건 | -| pytest | 2개 테스트 통과 | -| branch coverage | 기준선 코드 100% | -| coverage gate | 요구 기준 80% 통과 | -| pre-commit | 파일·보안·Ruff·재스테이징 hook 통과 | -| pre-push | 빠른 pytest와 mypy hook 통과 | - -현재 100%는 템플릿에 포함된 3개 실행 구문의 기준선 결과입니다. 이 템플릿으로 만든 프로젝트의 향후 커버리지를 보장하는 수치가 아닙니다. - -최근 `main` CI 실행 결과는 [GitHub Actions](https://github.com/bigmooon/python-template/actions/workflows/ci.yml)에서 확인할 수 있습니다. +| 항목 | 현재 설정 | +|---|---| +| Python | `>=3.12,<3.14`; CI matrix는 3.12, 3.13 | +| 실행 환경 | macOS/Linux, Make, POSIX shell | +| Ruff | `src`, `tests`, `scripts`; CLI와 hook 모두 0.8.4 | +| mypy | `src`, `scripts`; strict mode | +| pytest/coverage | 단위 테스트, `src`+`scripts` branch coverage 80% gate | +| Commitizen | CLI와 commit-msg hook 모두 3.29.1 | +| CI runner | `ubuntu-24.04`, `permissions: contents: read` | +| 자동 의존성 갱신 | Dependabot의 pip, GitHub Actions 주간 검사 | +| 라이선스 | MIT `LICENSE`, PEP 639 SPDX `license = "MIT"` | -## 빠른 시작 +CI는 Python 3.12와 3.13 각각에서 constraints 파일로 환경을 설치하고 `make ci-check`와 `make test-integration`을 순서대로 실행합니다. -### 1. 요구사항 +## 사용 가능한 명령 -- Python 3.12 (`python3.12` 명령으로 실행 가능해야 함) -- Git -- Make와 POSIX shell(macOS 또는 Linux) +| 명령 | 동작 | +|---|---| +| `make init NAME=my_app` | 프로젝트와 패키지 플레이스홀더 치환 | +| `make install-dev` | 고정 개발 환경과 Git hook 설치 | +| `make lint` | `src`, `tests`, `scripts`에 Ruff 자동 수정 | +| `make format` | `src`, `tests`, `scripts` 포매팅 | +| `make typecheck` | `src`, `scripts` mypy strict 검사 | +| `make test` | 단위 테스트와 80% coverage gate | +| `make ci-check` | 자동 수정 없이 Ruff, mypy, 단위 테스트, coverage 검증 | +| `make test-integration` | 임시 Git 저장소에서 `init → install-dev → ci-check` 검증 | +| `make quick-check` | pre-commit 단계의 모든 hook 실행 | +| `make full-check` | pre-commit과 pre-push 단계 모두 실행 | +| `make commit` | Commitizen 대화형 Conventional Commit 생성 | +| `make lock-dev` | uv로 공통 개발 constraints 재생성 | +| `make update-hooks` | pre-commit repository revision 갱신 | -Makefile과 pre-commit 환경은 `python3.12` 실행 파일을 기준으로 구성되어 있습니다. +통합 테스트는 설치를 반복하므로 기본 pytest 실행에서는 제외됩니다. CI와 `make test-integration`이 이 테스트를 명시적으로 실행합니다. -### 2. 저장소 복제 및 프로젝트 초기화 +## Git hook과 부분 staging 보호 -```bash -git clone https://github.com/bigmooon/python-template.git my-project -cd my-project +pre-commit 단계는 파일·YAML·TOML·JSON 무결성, 충돌 마커, 디버그 구문, private key/AWS 자격 증명, 대용량 파일, Ruff lint/format을 검사합니다. commit-msg는 Commitizen, pre-push는 pytest와 mypy를 실행합니다. -# snake_case 패키지명 사용 -make init NAME=my_project -``` +Ruff가 파일을 수정하면 hook은 실패하고 커밋을 중단합니다. `stage_fixes.py`는 수정된 파일과 `git add -p` 안내를 보여주지만 index를 절대 변경하지 않습니다. hook 실행 중 자동으로 `git add`하면 pre-commit이 임시로 숨긴 unstaged hunk와 충돌해 사용자 변경을 복원하지 못할 수 있기 때문입니다. `git diff`와 `git diff --cached`를 확인한 뒤 원하는 hunk만 수동으로 stage하세요. -`make init`은 다음 작업을 수행합니다. +통합 테스트는 같은 줄에 staged·unstaged 변경이 동시에 있는 최악의 충돌 상황에서 실제 pre-commit 훅을 실행합니다. hook은 코드 1로 실패하고, staged 내용과 unstaged 내용이 각각 그대로 남아야 테스트가 통과합니다. -1. 입력값을 Python 패키지용 `snake_case`로 검증 -2. 배포 이름을 `kebab-case`로 변환 -3. `src/your_project/`를 새 패키지명으로 이동 -4. `pyproject.toml`, smoke test, 프로젝트 안내 문서의 플레이스홀더 치환 +## 의존성 고정과 갱신 -### 3. 개발 환경 설치 +`requirements-dev.lock`은 Python 3.12–3.13과 주요 플랫폼을 위한 universal constraints 파일입니다. 일반 사용자는 uv가 필요 없고, 의존성을 변경하는 유지보수자만 uv를 설치한 뒤 다음을 실행합니다. ```bash +make lock-dev make install-dev -``` - -이 명령은 `.venv`를 만들고 개발 의존성과 `pre-commit`, `commit-msg`, `pre-push` 훅을 설치합니다. - -### 4. 설치 결과 검증 - -```bash make ci-check +make test-integration ``` -## 일상적인 사용 흐름 - -```bash -# 코드 작성 후 로컬 전체 검증 -make validate +Ruff와 Commitizen을 올릴 때는 `pyproject.toml`의 CLI 버전과 `.pre-commit-config.yaml`의 hook revision을 함께 바꾸어야 합니다. Dependabot은 pip과 GitHub Actions를 주간으로 점검하지만 pre-commit repository revision은 `make update-hooks`로 관리합니다. -# Conventional Commit 작성 -git add src tests -git commit -m "feat(parser): add contract parser" +## 현재 검증 결과 -# pytest와 mypy가 자동 실행됨 -git push -``` +2026-10-10에 macOS, Python 3.12.12에서 이 저장소의 복사본으로 다음을 확인했습니다. 아래는 GitHub Actions의 원격 실행 결과가 아니라 로컬 실행 결과입니다. -필요하면 대화형 Commitizen 명령을 사용할 수 있습니다. +| 검증 | 결과 | +|---|---| +| `make ci-check` | 통과; Ruff 9개 Python 파일, mypy 3개 소스 파일 | +| 단위 테스트 | 17개 통과, 1개 integration marker 제외 | +| branch coverage | 85.39%; 기준 80% 통과 | +| `make full-check` | pre-commit·pre-push hook 모두 통과 | +| `make test-integration` | 임시 프로젝트 workflow와 실제 부분-staging hook 충돌 테스트 1개 통과 | -```bash -make commit -``` +Python 3.13은 GitHub Actions matrix에서 검증되도록 설정되었습니다. 저장소의 최신 원격 상태는 [GitHub Actions](https://github.com/bigmooon/python-template/actions/workflows/ci.yml)에서 확인하세요. -## 주요 명령어 +## GitHub 저장소에서 별도로 해야 할 일 -| 명령어 | 설명 | -|---|---| -| `make init NAME=my_app` | 프로젝트·패키지명 초기화 | -| `make install-dev` | 가상환경, 개발 의존성, Git hook 설치 | -| `make lint` | Ruff 검사와 자동 수정 | -| `make format` | Ruff 포매팅 적용 | -| `make test` | pytest와 coverage 실행 | -| `make typecheck` | mypy strict 검사 | -| `make validate` | lint, format, test, typecheck 전체 실행 | -| `make quick-check` | pre-commit 단계 시뮬레이션 | -| `make full-check` | pre-commit과 pre-push 단계 시뮬레이션 | -| `make ci-check` | 자동 수정 없는 CI 검사와 80% coverage gate | -| `make commit` | Commitizen 대화형 커밋 | -| `make bump-version` | Conventional Commit 기반 버전 증가와 태그 생성 | -| `make update-hooks` | pre-commit hook 버전 업데이트 | -| `make clean` | Python·검사 도구 캐시 삭제 | - -## Conventional Commits - -허용하는 커밋 타입은 다음과 같습니다. - -| 타입 | 용도 | -|---|---| -| `feat` | 기능 추가 | -| `fix` | 버그 수정 | -| `docs` | 문서 변경 | -| `refactor` | 동작 변경 없는 구조 개선 | -| `perf` | 성능 개선 | -| `chore` | 설정·유지보수 작업 | -| `revert` | 기존 변경 되돌리기 | +다음 항목은 코드 변경으로 적용되지 않으며, 저장소 관리자가 GitHub 설정에서 별도로 구성해야 합니다. -```text -feat(auth): add login endpoint -fix(parser): handle empty input -docs: update quick start -feat!: change public API -``` +1. **Template repository**: Settings → General에서 Template repository를 활성화합니다. +2. **Branch ruleset**: `main`을 대상으로 PR 필수, force push/삭제 제한, CI required status check를 설정합니다. +3. **Required checks**: matrix job의 `Python 3.12`와 `Python 3.13` 모두를 성공해야 merge할 수 있도록 선택합니다. 처음에는 workflow를 한 번 실행해야 check 이름이 선택 목록에 나타날 수 있습니다. -Commitizen 설정상 `feat`는 MINOR, `fix`와 `perf`는 PATCH 증가에 사용됩니다. 호환성을 깨는 변경은 `!` 문법으로 표시합니다. +CI workflow, read-only token permission, Dependabot 설정은 이 저장소에 파일로 포함됩니다. ruleset과 Template repository 플래그는 포함되지 않습니다. -## 저장소 구조 +## 구조 ```text . -├── .github/workflows/ci.yml # push·PR 품질 검사 -├── .pre-commit-config.yaml # pre-commit·commit-msg·pre-push hook +├── .github/ +│ ├── dependabot.yml +│ └── workflows/ci.yml ├── scripts/ -│ ├── init_project.py # 프로젝트명 초기화 -│ └── stage_fixes.py # 자동 수정 파일 재스테이징 -├── src/your_project/ # 교체될 Python 패키지 -├── tests/ # smoke test -├── Makefile # 개발 명령 진입점 -└── pyproject.toml # 패키지와 도구 설정 +│ ├── init_project.py +│ └── stage_fixes.py +├── src/your_project/ +├── tests/ +├── LICENSE +├── Makefile +├── pyproject.toml +└── requirements-dev.lock ``` -## 설정 변경 시 확인할 점 - -- Ruff를 올릴 때 `pyproject.toml`과 `.pre-commit-config.yaml`의 버전을 함께 변경합니다. -- 커버리지 기준은 `pyproject.toml`의 `[tool.coverage.report]`에서 조정합니다. -- 새 검사 명령을 추가하면 Makefile과 GitHub Actions 실행 흐름이 같은지 확인합니다. -- pre-push가 느려지면 느린 테스트를 별도 marker로 분리하고 빠른 회귀 테스트만 유지합니다. -- 로컬 hook은 `--no-verify`로 우회할 수 있으므로 중요한 저장소에서는 CI를 required check로 설정합니다. - -## 현재 범위와 제약 - -- Makefile과 `.venv/bin` 경로를 사용하므로 기본 지원 환경은 macOS와 Linux입니다. -- Python 3.12가 기본 검증 버전이며, 다른 버전은 별도 호환성 검증이 필요합니다. -- secret 검사는 private key와 AWS 자격증명 패턴을 확인하지만 전문 secret scanner를 대체하지 않습니다. -- 포함된 smoke test와 100% 기준선 커버리지는 실제 애플리케이션 품질을 의미하지 않습니다. -- GitHub 저장소는 현재 GitHub의 “Template repository” 옵션이 활성화되지 않아 `git clone` 방식으로 사용합니다. - -## 로드맵 - -- [x] Ruff lint·format 자동화 -- [x] mypy strict와 pytest coverage gate -- [x] pre-commit·commit-msg·pre-push 분리 -- [x] GitHub Actions CI -- [x] 패키지명 초기화 스크립트 -- [ ] 초기화 스크립트 단위 테스트 확대 -- [ ] Linux·macOS 설치 검증 자동화 -- [ ] Windows 지원 방식 결정 -- [ ] GitHub Template repository 옵션 활성화 -- [ ] `LICENSE` 파일 추가 - ## License -`pyproject.toml` 메타데이터에는 MIT로 선언되어 있습니다. 재사용 조건을 명확히 하려면 별도의 `LICENSE` 파일을 추가해야 합니다. +MIT. 전문은 [LICENSE](LICENSE)에 있습니다. diff --git a/pyproject.toml b/pyproject.toml index dc6493e..bf9c42e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,24 +1,29 @@ [build-system] -requires = ["setuptools>=68.0", "wheel"] +requires = ["setuptools>=77.0.3", "wheel"] build-backend = "setuptools.build_meta" [project] name = "your-project" # TODO: 프로젝트 이름으로 변경 version = "0.1.0" -description = "Python 3.12 자동화 검사 시스템" +description = "Reusable Python 3.12–3.13 project template with automated quality checks" readme = "README.md" -requires-python = ">=3.12" -license = {text = "MIT"} +requires-python = ">=3.12,<3.14" +license = "MIT" +license-files = ["LICENSE"] authors = [{name = "Junghee Im", email = "imjion.dev@gmail.com"}] keywords = ["automation", "lint", "format", "testing"] +classifiers = [ + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] dependencies = [] [project.optional-dependencies] dev = [ - "ruff==0.8.4", # .pre-commit-config.yaml의 ruff rev와 정확히 동일하게 핀 + "ruff==0.8.4", # .pre-commit-config.yaml의 ruff rev와 동일 "pre-commit>=3.8.0", - "commitizen>=3.29.0", + "commitizen==3.29.1", # .pre-commit-config.yaml의 Commitizen rev와 동일 "pytest>=8.0", "pytest-cov>=5.0", "mypy>=1.10", @@ -26,7 +31,7 @@ dev = [ [tool.ruff] target-version = "py312" -src = ["src", "tests"] +src = ["src", "tests", "scripts"] line-length = 88 [tool.ruff.lint] @@ -53,7 +58,7 @@ unfixable = [] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] -"tests/**/*.py" = ["S101", "T20"] +"tests/**/*.py" = ["S101", "S603", "S607", "T20"] # scripts/는 CLI 도구이므로 print/subprocess 사용 허용 "scripts/**/*.py" = ["T20", "S603", "S607"] @@ -70,12 +75,15 @@ skip-magic-trailing-comma = false [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] -addopts = "--strict-markers --strict-config -v" +addopts = "--strict-markers --strict-config -m 'not integration' -v" +markers = [ + "integration: creates a temporary project and exercises its Make workflow", +] # coverage는 [tool.coverage.*]에서 별도 관리. # Makefile의 test/ci-check 타겟이 명시적으로 --cov를 부착한다. [tool.coverage.run] -source = ["src"] +source = ["src", "scripts"] branch = true [tool.coverage.report] diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..899c6f2 --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,87 @@ +# This file was autogenerated by uv via the following command: +# make lock-dev +argcomplete==3.5.3 + # via commitizen +ast-serialize==0.12.1 + # via mypy +cfgv==3.5.0 + # via pre-commit +charset-normalizer==3.5.2 + # via commitizen +colorama==0.4.6 + # via + # commitizen + # pytest +commitizen==3.29.1 + # via your-project (pyproject.toml) +coverage==7.16.2 + # via pytest-cov +decli==0.6.3 + # via commitizen +distlib==0.4.3 + # via virtualenv +filelock==4.0.12 + # via + # python-discovery + # virtualenv +identify==2.6.20 + # via pre-commit +iniconfig==2.3.1 + # via pytest +jinja2==3.1.6 + # via commitizen +librt==0.16.0 ; platform_python_implementation != 'PyPy' + # via mypy +markupsafe==3.0.4 + # via jinja2 +mypy==2.4.0 + # via your-project (pyproject.toml) +mypy-extensions==1.1.0 + # via mypy +nodeenv==1.11.0 + # via pre-commit +packaging==26.3 + # via + # commitizen + # pytest + # virtualenv +pathspec==1.1.1 + # via mypy +platformdirs==4.12.4 + # via virtualenv +pluggy==1.6.0 + # via + # pytest + # pytest-cov +pre-commit==4.6.2 + # via your-project (pyproject.toml) +prompt-toolkit==3.0.53 + # via questionary +pygments==2.21.0 + # via pytest +pytest==9.1.1 + # via + # your-project (pyproject.toml) + # pytest-cov +pytest-cov==7.1.0 + # via your-project (pyproject.toml) +python-discovery==1.6.1 + # via virtualenv +pyyaml==6.0.3 + # via + # commitizen + # pre-commit +questionary==2.1.1 + # via commitizen +ruff==0.8.4 + # via your-project (pyproject.toml) +termcolor==2.5.0 + # via commitizen +tomlkit==0.15.1 + # via commitizen +typing-extensions==4.16.0 + # via mypy +virtualenv==21.14.6 + # via pre-commit +wcwidth==0.9.2 + # via prompt-toolkit diff --git a/scripts/init_project.py b/scripts/init_project.py index 73bcbae..ec29d54 100644 --- a/scripts/init_project.py +++ b/scripts/init_project.py @@ -49,12 +49,12 @@ def replace_in_file(path: Path, snake: str, kebab: str) -> None: path.write_text(new, encoding="utf-8") -def main(name: str) -> int: +def main(name: str, *, root: Path = ROOT) -> int: snake = to_snake(name) kebab = to_kebab(name) - src_old = ROOT / "src" / "your_project" - src_new = ROOT / "src" / snake + 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) @@ -62,11 +62,13 @@ def main(name: str) -> int: shutil.move(str(src_old), str(src_new)) targets = [ - ROOT / "pyproject.toml", + root / "pyproject.toml", src_new / "__init__.py", - ROOT / "tests" / "test_smoke.py", - ROOT / "CLAUDE.md", - ROOT / "README.md", + root / "tests" / "test_smoke.py", + root / "CLAUDE.md", + root / "README.md", + root / "Makefile", + root / "requirements-dev.lock", ] for f in targets: if f.exists(): diff --git a/scripts/stage_fixes.py b/scripts/stage_fixes.py index 86a4d9d..0ca1cd7 100644 --- a/scripts/stage_fixes.py +++ b/scripts/stage_fixes.py @@ -1,38 +1,53 @@ -"""pre-commit이 넘긴 파일 중 실제 staged 파일만 다시 git add. +"""Ruff가 수정한 staged 파일을 커밋하지 않도록 안내한다. -일반 commit에서는 pre-commit이 staged 파일을 넘기지만 ``--all-files``로 -실행하면 모든 tracked 파일을 넘긴다. 따라서 현재 index의 staged 파일을 -다시 조회하고 전달받은 목록과의 교집합만 ``git add``한다. +이 훅은 의도적으로 index를 변경하지 않는다. pre-commit은 부분 stage된 +파일의 unstaged hunk를 임시로 숨긴 뒤 훅을 실행한다. 이 중에 ``git add``로 +index를 바꾸면 자동 수정과 숨겨진 hunk가 같은 부분에서 충돌할 때 +사용자의 unstaged 변경을 복원하지 못할 수 있다. -``git add -A``/``git add -u``를 의도적으로 회피하여 -``git add -p`` 부분 stage 워크플로우의 unstaged hunk가 함께 끌려가는 -사고를 방지한다. +따라서 Ruff 후 staged 파일에 worktree 변경이 생기면 실패 메시지만 +출력한다. 사용자가 ``git diff``와 ``git diff --cached``를 확인한 뒤 +``git add -p``로 원하는 hunk만 다시 stage하는 것이 안전한 워크플로우다. """ from __future__ import annotations -from pathlib import Path import subprocess import sys -def main(files: list[str]) -> int: - if not files: - return 0 - +def git_paths(*args: str) -> set[str]: + """Git의 NUL 구분 파일 목록을 set으로 반환한다.""" result = subprocess.run( - ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], + ["git", *args, "-z"], check=True, capture_output=True, text=True, ) - staged = {f for f in result.stdout.split("\0") if f} - existing = [f for f in files if f in staged and Path(f).exists()] - if not existing: + return {path for path in result.stdout.split("\0") if path} + + +def files_requiring_review(files: list[str]) -> list[str]: + """Staged이면서 worktree에도 변경이 있는 전달 파일을 찾는다.""" + if not files: + return [] + staged = git_paths("diff", "--cached", "--name-only", "--diff-filter=ACMR") + modified = git_paths("diff", "--name-only", "--diff-filter=ACMR") + return sorted(set(files) & staged & modified) + + +def main(files: list[str]) -> int: + review = files_requiring_review(files) + if not review: return 0 - subprocess.run(["git", "add", "--", *existing], check=True) - return 0 + print( + "Ruff가 staged 파일을 수정했습니다. index는 변경하지 않았습니다.\n" + "다음 파일을 검토한 뒤 `git add -p`로 원하는 hunk만 stage하세요:\n" + + "\n".join(f" - {path}" for path in review), + file=sys.stderr, + ) + return 1 if __name__ == "__main__": diff --git a/tests/test_init_project.py b/tests/test_init_project.py new file mode 100644 index 0000000..b358595 --- /dev/null +++ b/tests/test_init_project.py @@ -0,0 +1,104 @@ +"""Unit tests for the template initialization script.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from scripts import init_project + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("my_app", "my_app"), + ("My-App", "my_app"), + ("my app", "my_app"), + ], +) +def test_to_snake_normalizes_supported_names(raw: str, expected: str) -> None: + assert init_project.to_snake(raw) == expected + + +@pytest.mark.parametrize("raw", ["123-app", "has.dot", "한글"]) +def test_to_snake_rejects_invalid_package_names(raw: str) -> None: + with pytest.raises(ValueError, match="유효한 Python 패키지명"): + init_project.to_snake(raw) + + +def test_to_kebab_normalizes_distribution_name() -> None: + assert init_project.to_kebab("My_project name") == "my-project-name" + + +def test_replace_in_file_updates_both_placeholders(tmp_path: Path) -> None: + target = tmp_path / "example.txt" + target.write_text("your-project imports your_project\n", encoding="utf-8") + + init_project.replace_in_file(target, "sample_app", "sample-app") + + assert target.read_text(encoding="utf-8") == ("sample-app imports sample_app\n") + + +def test_replace_in_file_leaves_unrelated_content_unchanged(tmp_path: Path) -> None: + target = tmp_path / "example.txt" + target.write_text("already initialized\n", encoding="utf-8") + + init_project.replace_in_file(target, "sample_app", "sample-app") + + assert target.read_text(encoding="utf-8") == "already initialized\n" + + +def test_main_renames_package_and_updates_template_files( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + package = tmp_path / "src" / "your_project" + package.mkdir(parents=True) + (package / "__init__.py").write_text('NAME = "your-project"\n', encoding="utf-8") + tests = tmp_path / "tests" + tests.mkdir() + (tests / "test_smoke.py").write_text("import your_project\n", encoding="utf-8") + for filename in ( + "pyproject.toml", + "CLAUDE.md", + "README.md", + "Makefile", + "requirements-dev.lock", + ): + (tmp_path / filename).write_text( + "your-project / your_project\n", encoding="utf-8" + ) + + assert init_project.main("Sample_App", root=tmp_path) == 0 + + renamed = tmp_path / "src" / "sample_app" + assert renamed.is_dir() + assert not package.exists() + assert "sample-app" in (renamed / "__init__.py").read_text(encoding="utf-8") + assert "import sample_app" in (tests / "test_smoke.py").read_text(encoding="utf-8") + assert "sample-app / sample_app" in (tmp_path / "README.md").read_text( + encoding="utf-8" + ) + assert "snake=sample_app, kebab=sample-app" in capsys.readouterr().out + + +def test_main_refuses_to_overwrite_existing_package( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / "src" / "your_project").mkdir(parents=True) + destination = tmp_path / "src" / "sample_app" + destination.mkdir() + + assert init_project.main("sample_app", root=tmp_path) == 1 + + assert "이미 존재합니다" in capsys.readouterr().err + assert (tmp_path / "src" / "your_project").is_dir() + assert destination.is_dir() + + +def test_main_is_safe_when_optional_template_files_are_missing(tmp_path: Path) -> None: + package = tmp_path / "src" / "your_project" + package.mkdir(parents=True) + + assert init_project.main("your_project", root=tmp_path) == 0 + + assert package.is_dir() diff --git a/tests/test_stage_fixes.py b/tests/test_stage_fixes.py new file mode 100644 index 0000000..1a2adc5 --- /dev/null +++ b/tests/test_stage_fixes.py @@ -0,0 +1,78 @@ +"""Unit tests for the fail-only formatter review hook.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess + +import pytest +from scripts import stage_fixes + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +def create_repository(tmp_path: Path) -> tuple[Path, Path]: + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init", "-q") + git(repo, "config", "user.name", "Template Tests") + git(repo, "config", "user.email", "tests@example.com") + target = repo / "sample.py" + target.write_text("value = 1\n", encoding="utf-8") + git(repo, "add", "sample.py") + git(repo, "commit", "-qm", "test: initial") + return repo, target + + +def test_main_reports_hook_fix_without_changing_index( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + repo, target = create_repository(tmp_path) + target.write_text("value=2\n", encoding="utf-8") + git(repo, "add", "sample.py") + target.write_text("value = 2\n", encoding="utf-8") + monkeypatch.chdir(repo) + + cached_before = git(repo, "show", ":sample.py") + assert stage_fixes.main(["sample.py"]) == 1 + + assert git(repo, "show", ":sample.py") == cached_before == "value=2\n" + assert target.read_text(encoding="utf-8") == "value = 2\n" + assert "git add -p" in capsys.readouterr().err + + +def test_main_preserves_partial_staging_on_same_hunk( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo, target = create_repository(tmp_path) + target.write_text("value=2\n", encoding="utf-8") + git(repo, "add", "sample.py") + target.write_text("value=3\n", encoding="utf-8") + monkeypatch.chdir(repo) + + assert stage_fixes.main(["sample.py"]) == 1 + + assert git(repo, "show", ":sample.py") == "value=2\n" + assert target.read_text(encoding="utf-8") == "value=3\n" + + +def test_main_ignores_empty_or_unrelated_file_lists( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo, target = create_repository(tmp_path) + target.write_text("value = 2\n", encoding="utf-8") + monkeypatch.chdir(repo) + + assert stage_fixes.main([]) == 0 + assert stage_fixes.main(["other.py"]) == 0 diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py new file mode 100644 index 0000000..1087a6f --- /dev/null +++ b/tests/test_template_integration.py @@ -0,0 +1,109 @@ +"""End-to-end test for creating a project from this template.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def run( + project: Path, + *command: str, + env: dict[str, str], + check: bool = True, + capture_output: bool = False, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=project, + env=env, + check=check, + capture_output=capture_output, + text=True, + timeout=300, + ) + + +@pytest.mark.integration +def test_initialized_project_workflow_and_partial_staging(tmp_path: Path) -> None: + project = tmp_path / "sample-project" + ignored = shutil.ignore_patterns( + ".git", + ".venv", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "__pycache__", + "*.egg-info", + ) + shutil.copytree(ROOT, project, ignore=ignored) + run(project, "git", "init", "-q", env=dict(os.environ)) + environment = {**os.environ, "PYTHON": sys.executable} + + run(project, "make", "init", "NAME=sample_project", env=environment) + run(project, "make", "install-dev", env=environment) + run(project, "make", "ci-check", env=environment) + + assert (project / "src" / "sample_project" / "__init__.py").is_file() + assert not (project / "src" / "your_project").exists() + + run(project, "git", "config", "user.name", "Template Integration", env=environment) + run( + project, + "git", + "config", + "user.email", + "integration@example.com", + env=environment, + ) + probe = project / "src" / "sample_project" / "hook_probe.py" + probe.write_text("value = 1\n", encoding="utf-8") + run(project, "git", "add", ".", env=environment) + run( + project, + "git", + "-c", + "core.hooksPath=/dev/null", + "commit", + "-qm", + "test: baseline", + env=environment, + ) + + probe.write_text("value=2\n", encoding="utf-8") + run(project, "git", "add", str(probe.relative_to(project)), env=environment) + probe.write_text("value=3\n", encoding="utf-8") + hook_environment = { + **environment, + "PRE_COMMIT_HOME": str(tmp_path / "pre-commit-cache"), + } + hook = run( + project, + str(project / ".venv" / "bin" / "pre-commit"), + "run", + "--hook-stage", + "pre-commit", + env=hook_environment, + check=False, + capture_output=True, + ) + + cached = run( + project, + "git", + "show", + ":src/sample_project/hook_probe.py", + env=environment, + capture_output=True, + ) + assert hook.returncode == 1, hook.stdout + hook.stderr + assert "index는 변경하지 않았습니다" in hook.stdout + hook.stderr + assert cached.stdout == "value=2\n" + assert probe.read_text(encoding="utf-8") == "value=3\n"