Coverage for src / mysingle / cli / core / version.py: 19%
168 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-02 00:58 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-02 00:58 +0900
1"""버전 관리 명령."""
3from __future__ import annotations
5import argparse
6import re
7import subprocess
8from dataclasses import dataclass
9from pathlib import Path
11from ..utils import (
12 ask_choice,
13 ask_confirm,
14 console,
15 print_error,
16 print_info,
17 print_success,
18 print_warning,
19)
22@dataclass
23class Version:
24 major: int
25 minor: int
26 patch: int
27 prerelease: str | None = None
29 def __str__(self) -> str:
30 base = f"{self.major}.{self.minor}.{self.patch}"
31 if self.prerelease: 31 ↛ 32line 31 didn't jump to line 32 because the condition on line 31 was never true
32 return f"{base}-{self.prerelease}"
33 return base
35 @classmethod
36 def parse(cls, s: str) -> "Version":
37 # Match version with optional prerelease (e.g., 2.0.0-alpha)
38 m = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.]+))?", s.strip())
39 if not m:
40 raise ValueError(f"Invalid version: {s}")
41 return cls(
42 int(m.group(1)),
43 int(m.group(2)),
44 int(m.group(3)),
45 m.group(4) if m.group(4) else None,
46 )
48 def bump(self, kind: str) -> "Version":
49 if kind == "major":
50 return Version(self.major + 1, 0, 0)
51 if kind == "minor":
52 return Version(self.major, self.minor + 1, 0)
53 if kind == "patch": 53 ↛ 55line 53 didn't jump to line 55 because the condition on line 53 was always true
54 return Version(self.major, self.minor, self.patch + 1)
55 raise ValueError(f"Unknown bump kind: {kind}")
58def find_pyproject() -> Path:
59 """현재 디렉토리 또는 상위 디렉토리에서 pyproject.toml을 찾습니다."""
60 current = Path.cwd()
61 for parent in [current, *current.parents]:
62 pyproject = parent / "pyproject.toml"
63 if pyproject.exists():
64 return pyproject
65 raise FileNotFoundError("pyproject.toml을 찾을 수 없습니다")
68def read_current_version(pyproject_path: Path) -> Version:
69 """pyproject.toml에서 버전을 읽습니다."""
70 import tomllib
72 with open(pyproject_path, "rb") as f:
73 data = tomllib.load(f)
75 try:
76 v = data["project"]["version"]
77 return Version.parse(v)
78 except KeyError:
79 with open(pyproject_path, "r", encoding="utf-8") as fr:
80 raw = fr.read()
81 m = re.search(r'(?m)^\s*version\s*=\s*"([^"]+)"\s*$', raw)
82 if m:
83 return Version.parse(m.group(1))
84 return Version(0, 0, 0)
87def get_current_version() -> Version:
88 """pyproject.toml에서 현재 버전을 가져옵니다.
90 Returns:
91 Version: 현재 버전
93 Raises:
94 FileNotFoundError: pyproject.toml을 찾을 수 없는 경우
95 """
96 pyproject_path = find_pyproject()
97 return read_current_version(pyproject_path)
100def write_version(pyproject_path: Path, new_version: Version) -> None:
101 """pyproject.toml에 새 버전을 작성합니다."""
102 with open(pyproject_path, "r", encoding="utf-8") as f:
103 content = f.read()
105 pattern = re.compile(r'(?m)^(\s*version\s*=\s*")([^"]+)(")\s*$')
106 if pattern.search(content):
107 content = pattern.sub(
108 lambda m: f"{m.group(1)}{new_version}{m.group(3)}", content
109 )
110 else:
111 # Insert after name line in [project] section
112 content = re.sub(
113 r'(?m)^(\s*name\s*=\s*".*"\s*\n)',
114 f'\\1version = "{new_version}"\n',
115 content,
116 count=1,
117 )
119 with open(pyproject_path, "w", encoding="utf-8") as f:
120 f.write(content)
123def run_git(args: list[str], check: bool = True) -> subprocess.CompletedProcess:
124 """Git 명령을 실행합니다."""
125 return subprocess.run(["git"] + args, check=check, capture_output=True, text=True)
128def setup_parser(parser: argparse.ArgumentParser) -> None:
129 """버전 명령 파서를 설정합니다."""
130 parser.add_argument(
131 "bump_type",
132 choices=["major", "minor", "patch", "show", "auto"],
133 nargs="?",
134 help="버전 범프 유형, 'show'로 현재 버전 표시, 또는 'auto'로 자동 분석",
135 )
136 parser.add_argument(
137 "--custom",
138 help="사용자 정의 버전 문자열 (예: 2.1.0)",
139 )
140 parser.add_argument(
141 "--no-commit",
142 action="store_true",
143 help="Git 커밋을 생성하지 않음",
144 )
145 parser.add_argument(
146 "--no-tag",
147 action="store_true",
148 help="Git 태그를 생성하지 않음",
149 )
150 parser.add_argument(
151 "--push",
152 action="store_true",
153 help="커밋과 태그를 origin에 푸시",
154 )
155 parser.add_argument(
156 "--dry-run",
157 action="store_true",
158 help="변경하지 않고 분석만 수행 (auto 모드에서만 사용)",
159 )
162def execute_interactive() -> int:
163 """대화형 모드로 버전 관리를 실행합니다."""
164 try:
165 pyproject_path = find_pyproject()
166 except FileNotFoundError as e:
167 print_error(str(e))
168 return 1
170 current = read_current_version(pyproject_path)
171 console.print(f"\n[bold]현재 버전:[/bold] [cyan]{current}[/cyan]\n")
173 # Ask for bump type
174 bump_type = ask_choice(
175 "버전 업데이트 유형을 선택하세요",
176 ["auto", "major", "minor", "patch", "show", "cancel"],
177 default="auto",
178 )
180 if bump_type == "cancel":
181 print_info("취소되었습니다.")
182 return 0
184 if bump_type == "show":
185 console.print(f"\n[bold green]현재 버전:[/bold green] {current}\n")
186 return 0
188 # Auto mode
189 if bump_type == "auto":
190 from .auto_version import auto_bump
192 print_info("커밋 메시지를 분석하여 자동으로 버전을 결정합니다...")
193 return auto_bump(dry_run=False, push=False, no_commit=False, no_tag=False)
195 # Manual bump
196 # Calculate new version
197 new_version = current.bump(bump_type)
198 console.print(
199 f"\n[yellow]버전 변경:[/yellow] {current} → [green]{new_version}[/green]\n"
200 )
202 # Confirm
203 if not ask_confirm("계속하시겠습니까?", default=True):
204 print_info("취소되었습니다.")
205 return 0
207 # Write version
208 write_version(pyproject_path, new_version)
209 print_success(f"{pyproject_path.name} 업데이트 완료")
211 # Ask for git operations
212 try:
213 run_git(["rev-parse", "--git-dir"])
214 has_git = True
215 except subprocess.CalledProcessError:
216 has_git = False
218 if has_git and ask_confirm("Git 커밋을 생성하시겠습니까?", default=True):
219 try:
220 run_git(["add", str(pyproject_path)])
221 msg = f"chore(release): v{new_version} (bump {bump_type})"
222 run_git(["commit", "-m", msg])
223 print_success(f"커밋 생성 완료: {msg}")
225 if ask_confirm("Git 태그를 생성하시겠습니까?", default=True):
226 run_git(["tag", f"v{new_version}"])
227 print_success(f"태그 생성 완료: v{new_version}")
229 if ask_confirm("origin에 푸시하시겠습니까?", default=False):
230 run_git(["push", "origin", "HEAD"])
231 print_success("커밋 푸시 완료")
232 run_git(["push", "origin", f"v{new_version}"])
233 print_success("태그 푸시 완료")
235 except subprocess.CalledProcessError as e:
236 print_error(f"Git 작업 실패: {e.stderr}")
237 return 1
239 return 0
242def execute(args: argparse.Namespace) -> int:
243 """버전 명령을 실행합니다."""
244 # If no bump_type specified, go interactive
245 if not args.bump_type:
246 return execute_interactive()
248 # Auto mode
249 if args.bump_type == "auto":
250 from .auto_version import auto_bump
252 return auto_bump(
253 dry_run=args.dry_run,
254 push=args.push,
255 no_commit=args.no_commit,
256 no_tag=args.no_tag,
257 )
259 try:
260 pyproject_path = find_pyproject()
261 except FileNotFoundError as e:
262 print_error(str(e))
263 return 1
265 current = read_current_version(pyproject_path)
267 if args.bump_type == "show":
268 console.print(f"[bold]현재 버전:[/bold] [cyan]{current}[/cyan]")
269 return 0
271 # Determine new version
272 if args.custom:
273 try:
274 new_version = Version.parse(args.custom)
275 except ValueError as e:
276 print_error(str(e))
277 return 1
278 else:
279 new_version = current.bump(args.bump_type)
281 console.print(
282 f"[yellow]버전 변경:[/yellow] {current} → [green]{new_version}[/green]"
283 )
285 # Write new version
286 write_version(pyproject_path, new_version)
287 print_success(f"{pyproject_path.name} 업데이트 완료")
289 # Git operations
290 if not args.no_commit:
291 try:
292 # Check if git repo
293 run_git(["rev-parse", "--git-dir"])
295 # Add and commit
296 run_git(["add", str(pyproject_path)])
297 msg = f"chore(release): v{new_version} (bump {args.bump_type})"
298 run_git(["commit", "-m", msg])
299 print_success(f"커밋 생성 완료: {msg}")
301 # Create tag
302 if not args.no_tag:
303 run_git(["tag", f"v{new_version}"])
304 print_success(f"태그 생성 완료: v{new_version}")
306 # Push
307 if args.push:
308 run_git(["push", "origin", "HEAD"])
309 print_success("커밋 푸시 완료")
310 if not args.no_tag:
311 run_git(["push", "origin", f"v{new_version}"])
312 print_success("태그 푸시 완료")
314 except subprocess.CalledProcessError as e:
315 print_warning(f"Git 작업 실패: {e.stderr}")
316 return 1
318 return 0