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

1"""버전 관리 명령.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import re 

7import subprocess 

8from dataclasses import dataclass 

9from pathlib import Path 

10 

11from ..utils import ( 

12 ask_choice, 

13 ask_confirm, 

14 console, 

15 print_error, 

16 print_info, 

17 print_success, 

18 print_warning, 

19) 

20 

21 

22@dataclass 

23class Version: 

24 major: int 

25 minor: int 

26 patch: int 

27 prerelease: str | None = None 

28 

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 

34 

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 ) 

47 

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}") 

56 

57 

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을 찾을 수 없습니다") 

66 

67 

68def read_current_version(pyproject_path: Path) -> Version: 

69 """pyproject.toml에서 버전을 읽습니다.""" 

70 import tomllib 

71 

72 with open(pyproject_path, "rb") as f: 

73 data = tomllib.load(f) 

74 

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) 

85 

86 

87def get_current_version() -> Version: 

88 """pyproject.toml에서 현재 버전을 가져옵니다. 

89 

90 Returns: 

91 Version: 현재 버전 

92 

93 Raises: 

94 FileNotFoundError: pyproject.toml을 찾을 수 없는 경우 

95 """ 

96 pyproject_path = find_pyproject() 

97 return read_current_version(pyproject_path) 

98 

99 

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() 

104 

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 ) 

118 

119 with open(pyproject_path, "w", encoding="utf-8") as f: 

120 f.write(content) 

121 

122 

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) 

126 

127 

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 ) 

160 

161 

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 

169 

170 current = read_current_version(pyproject_path) 

171 console.print(f"\n[bold]현재 버전:[/bold] [cyan]{current}[/cyan]\n") 

172 

173 # Ask for bump type 

174 bump_type = ask_choice( 

175 "버전 업데이트 유형을 선택하세요", 

176 ["auto", "major", "minor", "patch", "show", "cancel"], 

177 default="auto", 

178 ) 

179 

180 if bump_type == "cancel": 

181 print_info("취소되었습니다.") 

182 return 0 

183 

184 if bump_type == "show": 

185 console.print(f"\n[bold green]현재 버전:[/bold green] {current}\n") 

186 return 0 

187 

188 # Auto mode 

189 if bump_type == "auto": 

190 from .auto_version import auto_bump 

191 

192 print_info("커밋 메시지를 분석하여 자동으로 버전을 결정합니다...") 

193 return auto_bump(dry_run=False, push=False, no_commit=False, no_tag=False) 

194 

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 ) 

201 

202 # Confirm 

203 if not ask_confirm("계속하시겠습니까?", default=True): 

204 print_info("취소되었습니다.") 

205 return 0 

206 

207 # Write version 

208 write_version(pyproject_path, new_version) 

209 print_success(f"{pyproject_path.name} 업데이트 완료") 

210 

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 

217 

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}") 

224 

225 if ask_confirm("Git 태그를 생성하시겠습니까?", default=True): 

226 run_git(["tag", f"v{new_version}"]) 

227 print_success(f"태그 생성 완료: v{new_version}") 

228 

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("태그 푸시 완료") 

234 

235 except subprocess.CalledProcessError as e: 

236 print_error(f"Git 작업 실패: {e.stderr}") 

237 return 1 

238 

239 return 0 

240 

241 

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() 

247 

248 # Auto mode 

249 if args.bump_type == "auto": 

250 from .auto_version import auto_bump 

251 

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 ) 

258 

259 try: 

260 pyproject_path = find_pyproject() 

261 except FileNotFoundError as e: 

262 print_error(str(e)) 

263 return 1 

264 

265 current = read_current_version(pyproject_path) 

266 

267 if args.bump_type == "show": 

268 console.print(f"[bold]현재 버전:[/bold] [cyan]{current}[/cyan]") 

269 return 0 

270 

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) 

280 

281 console.print( 

282 f"[yellow]버전 변경:[/yellow] {current} → [green]{new_version}[/green]" 

283 ) 

284 

285 # Write new version 

286 write_version(pyproject_path, new_version) 

287 print_success(f"{pyproject_path.name} 업데이트 완료") 

288 

289 # Git operations 

290 if not args.no_commit: 

291 try: 

292 # Check if git repo 

293 run_git(["rev-parse", "--git-dir"]) 

294 

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}") 

300 

301 # Create tag 

302 if not args.no_tag: 

303 run_git(["tag", f"v{new_version}"]) 

304 print_success(f"태그 생성 완료: v{new_version}") 

305 

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("태그 푸시 완료") 

313 

314 except subprocess.CalledProcessError as e: 

315 print_warning(f"Git 작업 실패: {e.stderr}") 

316 return 1 

317 

318 return 0