Coverage for /Users/coordt/Documents/code/bump-my-version/bumpversion/scm.py: 44%

219 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-06-12 09:26 -0500

1"""Version control system management.""" 

2 

3import os 

4import re 

5import subprocess 

6from dataclasses import dataclass 

7from pathlib import Path 

8from tempfile import NamedTemporaryFile 

9from typing import TYPE_CHECKING, ClassVar, List, MutableMapping, Optional, Type, Union 

10 

11from bumpversion.ui import get_indented_logger 

12from bumpversion.utils import extract_regex_flags 

13 

14if TYPE_CHECKING: # pragma: no-coverage 14 ↛ 15line 14 didn't jump to line 15, because the condition on line 14 was never true

15 from bumpversion.config import Config 

16 

17from bumpversion.exceptions import BumpVersionError, DirtyWorkingDirectoryError, SignedTagsError 

18 

19logger = get_indented_logger(__name__) 

20 

21 

22@dataclass 

23class SCMInfo: 

24 """Information about the current source code manager and state.""" 

25 

26 tool: Optional[Type["SourceCodeManager"]] = None 

27 commit_sha: Optional[str] = None 

28 distance_to_latest_tag: int = 0 

29 current_version: Optional[str] = None 

30 branch_name: Optional[str] = None 

31 short_branch_name: Optional[str] = None 

32 dirty: Optional[bool] = None 

33 

34 def __str__(self): 

35 return self.__repr__() 

36 

37 def __repr__(self): 

38 tool_name = self.tool.__name__ if self.tool else "No SCM tool" 

39 return ( 

40 f"SCMInfo(tool={tool_name}, commit_sha={self.commit_sha}, " 

41 f"distance_to_latest_tag={self.distance_to_latest_tag}, current_version={self.current_version}, " 

42 f"dirty={self.dirty})" 

43 ) 

44 

45 

46class SourceCodeManager: 

47 """Base class for version control systems.""" 

48 

49 _TEST_USABLE_COMMAND: ClassVar[List[str]] = [] 

50 _COMMIT_COMMAND: ClassVar[List[str]] = [] 

51 _ALL_TAGS_COMMAND: ClassVar[List[str]] = [] 

52 

53 @classmethod 

54 def commit(cls, message: str, current_version: str, new_version: str, extra_args: Optional[list] = None) -> None: 

55 """Commit the changes.""" 

56 extra_args = extra_args or [] 

57 if not current_version: 

58 logger.warning("No current version given, using an empty string.") 

59 current_version = "" 

60 if not new_version: 

61 logger.warning("No new version given, using an empty string.") 

62 new_version = "" 

63 

64 with NamedTemporaryFile("wb", delete=False) as f: 

65 f.write(message.encode("utf-8")) 

66 

67 env = os.environ.copy() 

68 env["HGENCODING"] = "utf-8" 

69 env["BUMPVERSION_CURRENT_VERSION"] = current_version 

70 env["BUMPVERSION_NEW_VERSION"] = new_version 

71 

72 try: 

73 cmd = [*cls._COMMIT_COMMAND, f.name, *extra_args] 

74 subprocess.run(cmd, env=env, capture_output=True, check=True) # noqa: S603 

75 except (subprocess.CalledProcessError, TypeError) as exc: # pragma: no-coverage 

76 if isinstance(exc, TypeError): 

77 err_msg = f"Failed to run {cls._COMMIT_COMMAND}: {exc}" 

78 else: 

79 err_msg = f"Failed to run {exc.cmd}: return code {exc.returncode}, output: {exc.output}" 

80 logger.exception(err_msg) 

81 raise BumpVersionError(err_msg) from exc 

82 finally: 

83 os.unlink(f.name) 

84 

85 @classmethod 

86 def is_usable(cls) -> bool: 

87 """Is the VCS implementation usable.""" 

88 try: 

89 result = subprocess.run(cls._TEST_USABLE_COMMAND, check=True, capture_output=True) # noqa: S603 

90 return result.returncode == 0 

91 except (FileNotFoundError, PermissionError, NotADirectoryError, subprocess.CalledProcessError): 

92 return False 

93 

94 @classmethod 

95 def assert_nondirty(cls) -> None: 

96 """Assert that the working directory is not dirty.""" 

97 raise NotImplementedError() 

98 

99 @classmethod 

100 def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo: 

101 """Return information about the latest tag.""" 

102 raise NotImplementedError() 

103 

104 @classmethod 

105 def add_path(cls, path: Union[str, Path]) -> None: 

106 """Add a path to the VCS.""" 

107 raise NotImplementedError() 

108 

109 @classmethod 

110 def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> None: 

111 """Create a tag of the new_version in VCS.""" 

112 raise NotImplementedError 

113 

114 @classmethod 

115 def get_all_tags(cls) -> List[str]: 

116 """Return all tags in VCS.""" 

117 try: 

118 result = subprocess.run(cls._ALL_TAGS_COMMAND, text=True, check=True, capture_output=True) # noqa: S603 

119 return result.stdout.splitlines() 

120 except (FileNotFoundError, PermissionError, NotADirectoryError, subprocess.CalledProcessError): 

121 return [] 

122 

123 @classmethod 

124 def get_version_from_tag(cls, tag: str, tag_name: str, parse_pattern: str) -> Optional[str]: 

125 """Return the version from a tag.""" 

126 version_pattern = parse_pattern.replace("\\\\", "\\") 

127 version_pattern, regex_flags = extract_regex_flags(version_pattern) 

128 rep = tag_name.replace("{new_version}", f"(?P<current_version>{version_pattern})") 

129 rep = f"{regex_flags}{rep}" 

130 tag_regex = re.compile(rep) 

131 return match["current_version"] if (match := tag_regex.match(tag)) else None 

132 

133 @classmethod 

134 def commit_to_scm( 

135 cls, 

136 files: List[Union[str, Path]], 

137 config: "Config", 

138 context: MutableMapping, 

139 extra_args: Optional[List[str]] = None, 

140 dry_run: bool = False, 

141 ) -> None: 

142 """Commit the files to the source code management system.""" 

143 if not cls.is_usable(): 

144 logger.error("SCM tool '%s' is unusable, unable to commit.", cls.__name__) 

145 return 

146 

147 if not config.commit: 

148 logger.info("Would not commit") 

149 return 

150 

151 do_commit = not dry_run 

152 logger.info( 

153 "%s %s commit", 

154 "Preparing" if do_commit else "Would prepare", 

155 cls.__name__, 

156 ) 

157 logger.indent() 

158 for path in files: 

159 logger.info( 

160 "%s changes in file '%s' to %s", 

161 "Adding" if do_commit else "Would add", 

162 path, 

163 cls.__name__, 

164 ) 

165 

166 if do_commit: 

167 cls.add_path(path) 

168 

169 commit_message = config.message.format(**context) 

170 

171 logger.info( 

172 "%s to %s with message '%s'", 

173 "Committing" if do_commit else "Would commit", 

174 cls.__name__, 

175 commit_message, 

176 ) 

177 if do_commit: 

178 cls.commit( 

179 message=commit_message, 

180 current_version=context["current_version"], 

181 new_version=context["new_version"], 

182 extra_args=extra_args, 

183 ) 

184 logger.dedent() 

185 

186 @classmethod 

187 def tag_in_scm(cls, config: "Config", context: MutableMapping, dry_run: bool = False) -> None: 

188 """Tag the current commit in the source code management system.""" 

189 if not config.tag: 

190 logger.info("Would not tag") 

191 return 

192 sign_tags = config.sign_tags 

193 tag_name = config.tag_name.format(**context) 

194 tag_message = config.tag_message.format(**context) 

195 existing_tags = cls.get_all_tags() 

196 do_tag = not dry_run 

197 

198 if tag_name in existing_tags: 

199 logger.warning("Tag '%s' already exists. Will not tag.", tag_name) 

200 return 

201 

202 logger.info( 

203 "%s '%s' %s in %s and %s", 

204 "Tagging" if do_tag else "Would tag", 

205 tag_name, 

206 f"with message '{tag_message}'" if tag_message else "without message", 

207 cls.__name__, 

208 "signing" if sign_tags else "not signing", 

209 ) 

210 if do_tag: 

211 cls.tag(tag_name, sign_tags, tag_message) 

212 

213 def __str__(self): 

214 return self.__repr__() 

215 

216 def __repr__(self): 

217 return f"{self.__class__.__name__}" 

218 

219 

220class Git(SourceCodeManager): 

221 """Git implementation.""" 

222 

223 _TEST_USABLE_COMMAND: ClassVar[List[str]] = ["git", "rev-parse", "--git-dir"] 

224 _COMMIT_COMMAND: ClassVar[List[str]] = ["git", "commit", "-F"] 

225 _ALL_TAGS_COMMAND: ClassVar[List[str]] = ["git", "tag", "--list"] 

226 

227 @classmethod 

228 def assert_nondirty(cls) -> None: 

229 """Assert that the working directory is not dirty.""" 

230 lines = [ 

231 line.strip() 

232 for line in subprocess.check_output(["git", "status", "--porcelain"]).splitlines() # noqa: S603, S607 

233 if not line.strip().startswith(b"??") 

234 ] 

235 

236 if lines: 

237 joined_lines = b"\n".join(lines).decode() 

238 raise DirtyWorkingDirectoryError(f"Git working directory is not clean:\n\n{joined_lines}") 

239 

240 @classmethod 

241 def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo: 

242 """Return information about the latest tag.""" 

243 try: 

244 # git-describe doesn't update the git-index, so we do that 

245 subprocess.run(["git", "update-index", "--refresh", "-q"], capture_output=True) # noqa: S603, S607 

246 except subprocess.CalledProcessError as e: 

247 logger.debug("Error when running git update-index: %s", e.stderr) 

248 return SCMInfo(tool=cls) 

249 tag_pattern = tag_name.replace("{new_version}", "*") 

250 try: 

251 # get info about the latest tag in git 

252 git_cmd = [ 

253 "git", 

254 "describe", 

255 "--dirty", 

256 "--tags", 

257 "--long", 

258 "--abbrev=40", 

259 f"--match={tag_pattern}", 

260 ] 

261 result = subprocess.run(git_cmd, text=True, check=True, capture_output=True) # noqa: S603 

262 describe_out = result.stdout.strip().split("-") 

263 

264 git_cmd = ["git", "rev-parse", "--abbrev-ref", "HEAD"] 

265 result = subprocess.run(git_cmd, text=True, check=True, capture_output=True) # noqa: S603 

266 branch_name = result.stdout.strip() 

267 short_branch_name = re.sub(r"([^a-zA-Z0-9]*)", "", branch_name).lower()[:20] 

268 except subprocess.CalledProcessError as e: 

269 logger.debug("Error when running git describe: %s", e.stderr) 

270 return SCMInfo(tool=cls) 

271 

272 info = SCMInfo(tool=cls, branch_name=branch_name, short_branch_name=short_branch_name) 

273 

274 if describe_out[-1].strip() == "dirty": 

275 info.dirty = True 

276 describe_out.pop() 

277 else: 

278 info.dirty = False 

279 

280 info.commit_sha = describe_out.pop().lstrip("g") 

281 info.distance_to_latest_tag = int(describe_out.pop()) 

282 version = cls.get_version_from_tag(describe_out[-1], tag_name, parse_pattern) 

283 info.current_version = version or "-".join(describe_out).lstrip("v") 

284 

285 return info 

286 

287 @classmethod 

288 def add_path(cls, path: Union[str, Path]) -> None: 

289 """Add a path to the VCS.""" 

290 subprocess.check_output(["git", "add", "--update", str(path)]) # noqa: S603, S607 

291 

292 @classmethod 

293 def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> None: 

294 """ 

295 Create a tag of the new_version in VCS. 

296 

297 If only name is given, bumpversion uses a lightweight tag. 

298 Otherwise, it utilizes an annotated tag. 

299 

300 Args: 

301 name: The name of the tag 

302 sign: True to sign the tag 

303 message: An optional message to annotate the tag. 

304 """ 

305 command = ["git", "tag", name] 

306 if sign: 

307 command += ["--sign"] 

308 if message: 

309 command += ["--message", message] 

310 subprocess.check_output(command) # noqa: S603 

311 

312 

313class Mercurial(SourceCodeManager): 

314 """Mercurial implementation.""" 

315 

316 _TEST_USABLE_COMMAND: ClassVar[List[str]] = ["hg", "root"] 

317 _COMMIT_COMMAND: ClassVar[List[str]] = ["hg", "commit", "--logfile"] 

318 _ALL_TAGS_COMMAND: ClassVar[List[str]] = ["hg", "log", '--rev="tag()"', '--template="{tags}\n"'] 

319 

320 @classmethod 

321 def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo: 

322 """Return information about the latest tag.""" 

323 current_version = None 

324 re_pattern = tag_name.replace("{new_version}", ".*") 

325 result = subprocess.run( 

326 ["hg", "log", "-r", f"tag('re:{re_pattern}')", "--template", "{latesttag}\n"], # noqa: S603, S607 

327 text=True, 

328 check=True, 

329 capture_output=True, 

330 ) 

331 result.check_returncode() 

332 if result.stdout: 

333 tag_string = result.stdout.splitlines(keepends=False)[-1] 

334 current_version = cls.get_version_from_tag(tag_string, tag_name, parse_pattern) 

335 else: 

336 logger.debug("No tags found") 

337 is_dirty = len(subprocess.check_output(["hg", "status", "-mard"])) != 0 # noqa: S603, S607 

338 return SCMInfo(tool=cls, current_version=current_version, dirty=is_dirty) 

339 

340 @classmethod 

341 def assert_nondirty(cls) -> None: 

342 """Assert that the working directory is clean.""" 

343 lines = [ 

344 line.strip() 

345 for line in subprocess.check_output(["hg", "status", "-mard"]).splitlines() # noqa: S603, S607 

346 if not line.strip().startswith(b"??") 

347 ] 

348 

349 if lines: 

350 joined_lines = b"\n".join(lines).decode() 

351 raise DirtyWorkingDirectoryError(f"Mercurial working directory is not clean:\n{joined_lines}") 

352 

353 @classmethod 

354 def add_path(cls, path: Union[str, Path]) -> None: 

355 """Add a path to the VCS.""" 

356 pass 

357 

358 @classmethod 

359 def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> None: 

360 """ 

361 Create a tag of the new_version in VCS. 

362 

363 If only name is given, bumpversion uses a lightweight tag. 

364 Otherwise, it utilizes an annotated tag. 

365 

366 Args: 

367 name: The name of the tag 

368 sign: True to sign the tag 

369 message: A optional message to annotate the tag. 

370 

371 Raises: 

372 SignedTagsError: If ``sign`` is ``True`` 

373 """ 

374 command = ["hg", "tag", name] 

375 if sign: 

376 raise SignedTagsError("Mercurial does not support signed tags.") 

377 if message: 

378 command += ["--message", message] 

379 subprocess.check_output(command) # noqa: S603 

380 

381 

382def get_scm_info(tag_name: str, parse_pattern: str) -> SCMInfo: 

383 """Return a dict with the latest source code management info.""" 

384 if Git.is_usable(): 

385 return Git.latest_tag_info(tag_name, parse_pattern) 

386 elif Mercurial.is_usable(): 386 ↛ 387line 386 didn't jump to line 387, because the condition on line 386 was never true

387 return Mercurial.latest_tag_info(tag_name, parse_pattern) 

388 else: 

389 return SCMInfo()