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

214 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-15 08:51 -0600

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 

15 from bumpversion.config import Config 

16 

17from bumpversion.exceptions import 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: Optional[int] = None 

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 

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

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

60 

61 env = os.environ.copy() 

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

63 env["BUMPVERSION_CURRENT_VERSION"] = current_version 

64 env["BUMPVERSION_NEW_VERSION"] = new_version 

65 

66 try: 

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

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

69 except subprocess.CalledProcessError as exc: # pragma: no-coverage 

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

71 logger.exception(err_msg) 

72 raise exc 

73 finally: 

74 os.unlink(f.name) 

75 

76 @classmethod 

77 def is_usable(cls) -> bool: 

78 """Is the VCS implementation usable.""" 

79 try: 

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

81 return result.returncode == 0 

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

83 return False 

84 

85 @classmethod 

86 def assert_nondirty(cls) -> None: 

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

88 raise NotImplementedError() 

89 

90 @classmethod 

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

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

93 raise NotImplementedError() 

94 

95 @classmethod 

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

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

98 raise NotImplementedError() 

99 

100 @classmethod 

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

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

103 raise NotImplementedError 

104 

105 @classmethod 

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

107 """Return all tags in VCS.""" 

108 try: 

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

110 return result.stdout.splitlines() 

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

112 return [] 

113 

114 @classmethod 

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

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

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

118 version_pattern, regex_flags = extract_regex_flags(version_pattern) 

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

120 rep = f"{regex_flags}{rep}" 

121 tag_regex = re.compile(rep) 

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

123 

124 @classmethod 

125 def commit_to_scm( 

126 cls, 

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

128 config: "Config", 

129 context: MutableMapping, 

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

131 dry_run: bool = False, 

132 ) -> None: 

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

134 if not cls.is_usable(): 

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

136 return 

137 

138 if not config.commit: 

139 logger.info("Would not commit") 

140 return 

141 

142 do_commit = not dry_run 

143 logger.info( 

144 "%s %s commit", 

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

146 cls.__name__, 

147 ) 

148 logger.indent() 

149 for path in files: 

150 logger.info( 

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

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

153 path, 

154 cls.__name__, 

155 ) 

156 

157 if do_commit: 

158 cls.add_path(path) 

159 

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

161 

162 logger.info( 

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

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

165 cls.__name__, 

166 commit_message, 

167 ) 

168 if do_commit: 

169 cls.commit( 

170 message=commit_message, 

171 current_version=context["current_version"], 

172 new_version=context["new_version"], 

173 extra_args=extra_args, 

174 ) 

175 logger.dedent() 

176 

177 @classmethod 

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

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

180 if not config.commit: 

181 logger.info("Would not tag since we are not committing") 

182 return 

183 if not config.tag: 

184 logger.info("Would not tag") 

185 return 

186 sign_tags = config.sign_tags 

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

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

189 existing_tags = cls.get_all_tags() 

190 do_tag = not dry_run 

191 

192 if tag_name in existing_tags: 

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

194 return 

195 

196 logger.info( 

197 "%s '%s' %s in %s and %s", 

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

199 tag_name, 

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

201 cls.__name__, 

202 "signing" if sign_tags else "not signing", 

203 ) 

204 if do_tag: 

205 cls.tag(tag_name, sign_tags, tag_message) 

206 

207 def __str__(self): 

208 return self.__repr__() 

209 

210 def __repr__(self): 

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

212 

213 

214class Git(SourceCodeManager): 

215 """Git implementation.""" 

216 

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

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

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

220 

221 @classmethod 

222 def assert_nondirty(cls) -> None: 

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

224 lines = [ 

225 line.strip() 

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

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

228 ] 

229 

230 if lines: 

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

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

233 

234 @classmethod 

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

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

237 try: 

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

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

240 except subprocess.CalledProcessError as e: 

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

242 return SCMInfo(tool=cls) 

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

244 try: 

245 # get info about the latest tag in git 

246 git_cmd = [ 

247 "git", 

248 "describe", 

249 "--dirty", 

250 "--tags", 

251 "--long", 

252 "--abbrev=40", 

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

254 ] 

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

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

257 

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

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

260 branch_name = result.stdout.strip() 

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

262 except subprocess.CalledProcessError as e: 

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

264 return SCMInfo(tool=cls) 

265 

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

267 

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

269 info.dirty = True 

270 describe_out.pop() 

271 else: 

272 info.dirty = False 

273 

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

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

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

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

278 

279 return info 

280 

281 @classmethod 

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

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

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

285 

286 @classmethod 

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

288 """ 

289 Create a tag of the new_version in VCS. 

290 

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

292 Otherwise, it utilizes an annotated tag. 

293 

294 Args: 

295 name: The name of the tag 

296 sign: True to sign the tag 

297 message: An optional message to annotate the tag. 

298 """ 

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

300 if sign: 

301 command += ["--sign"] 

302 if message: 

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

304 subprocess.check_output(command) # noqa: S603 

305 

306 

307class Mercurial(SourceCodeManager): 

308 """Mercurial implementation.""" 

309 

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

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

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

313 

314 @classmethod 

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

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

317 current_version = None 

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

319 result = subprocess.run( 

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

321 text=True, 

322 check=True, 

323 capture_output=True, 

324 ) 

325 result.check_returncode() 

326 if result.stdout: 

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

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

329 else: 

330 logger.debug("No tags found") 

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

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

333 

334 @classmethod 

335 def assert_nondirty(cls) -> None: 

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

337 lines = [ 

338 line.strip() 

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

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

341 ] 

342 

343 if lines: 

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

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

346 

347 @classmethod 

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

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

350 pass 

351 

352 @classmethod 

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

354 """ 

355 Create a tag of the new_version in VCS. 

356 

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

358 Otherwise, it utilizes an annotated tag. 

359 

360 Args: 

361 name: The name of the tag 

362 sign: True to sign the tag 

363 message: A optional message to annotate the tag. 

364 

365 Raises: 

366 SignedTagsError: If ``sign`` is ``True`` 

367 """ 

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

369 if sign: 

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

371 if message: 

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

373 subprocess.check_output(command) # noqa: S603 

374 

375 

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

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

378 if Git.is_usable(): 

379 return Git.latest_tag_info(tag_name, parse_pattern) 

380 elif Mercurial.is_usable(): 

381 return Mercurial.latest_tag_info(tag_name, parse_pattern) 

382 else: 

383 return SCMInfo()