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

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

2

3 import os

4 import re

5 import subprocess

6 from dataclasses import dataclass

7 from pathlib import Path

8 from tempfile import NamedTemporaryFile

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

10

11 from bumpversion.ui import get_indented_logger

12 from bumpversion.utils import extract_regex_flags

13

14 if 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

17 from bumpversion.exceptions import DirtyWorkingDirectoryError, SignedTagsError

18

19 logger = get_indented_logger(__name__)

20

21

22@dataclass

23 class 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

46 class 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

214 class 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

307 class 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

376 def 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(): 378 ↛ 379line 378 didn't jump to line 379, because the condition on line 378 was never true

379 return Git.latest_tag_info(tag_name, parse_pattern)

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

381 return Mercurial.latest_tag_info(tag_name, parse_pattern)

382 else:

383 return SCMInfo()