Coverage for /Users/OORDCOR/Documents/code/bump-my-version/bumpversion/scm.py: 57%
219 statements
« prev ^ index » next coverage.py v7.3.2, created at 2024-02-24 07:47 -0600
« prev ^ index » next coverage.py v7.3.2, created at 2024-02-24 07:47 -0600
1"""Version control system management."""
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
11from bumpversion.ui import get_indented_logger
12from bumpversion.utils import extract_regex_flags
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
17from bumpversion.exceptions import BumpVersionError, DirtyWorkingDirectoryError, SignedTagsError
19logger = get_indented_logger(__name__)
22@dataclass
23class SCMInfo:
24 """Information about the current source code manager and state."""
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
34 def __str__(self):
35 return self.__repr__()
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 )
46class SourceCodeManager:
47 """Base class for version control systems."""
49 _TEST_USABLE_COMMAND: ClassVar[List[str]] = []
50 _COMMIT_COMMAND: ClassVar[List[str]] = []
51 _ALL_TAGS_COMMAND: ClassVar[List[str]] = []
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 = ""
64 with NamedTemporaryFile("wb", delete=False) as f:
65 f.write(message.encode("utf-8"))
67 env = os.environ.copy()
68 env["HGENCODING"] = "utf-8"
69 env["BUMPVERSION_CURRENT_VERSION"] = current_version
70 env["BUMPVERSION_NEW_VERSION"] = new_version
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)
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
94 @classmethod
95 def assert_nondirty(cls) -> None:
96 """Assert that the working directory is not dirty."""
97 raise NotImplementedError()
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()
104 @classmethod
105 def add_path(cls, path: Union[str, Path]) -> None:
106 """Add a path to the VCS."""
107 raise NotImplementedError()
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
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 []
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
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(): 143 ↛ 144line 143 didn't jump to line 144, because the condition on line 143 was never true
144 logger.error("SCM tool '%s' is unusable, unable to commit.", cls.__name__)
145 return
147 if not config.commit: 147 ↛ 148line 147 didn't jump to line 148, because the condition on line 147 was never true
148 logger.info("Would not commit")
149 return
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 )
166 if do_commit: 166 ↛ 167line 166 didn't jump to line 167, because the condition on line 166 was never true
167 cls.add_path(path)
169 commit_message = config.message.format(**context)
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: 177 ↛ 178line 177 didn't jump to line 178, because the condition on line 177 was never true
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()
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: 189 ↛ 192line 189 didn't jump to line 192, because the condition on line 189 was never false
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
198 if tag_name in existing_tags:
199 logger.warning("Tag '%s' already exists. Will not tag.", tag_name)
200 return
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)
213 def __str__(self):
214 return self.__repr__()
216 def __repr__(self):
217 return f"{self.__class__.__name__}"
220class Git(SourceCodeManager):
221 """Git implementation."""
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"]
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 ]
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}")
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("-")
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)
272 info = SCMInfo(tool=cls, branch_name=branch_name, short_branch_name=short_branch_name)
274 if describe_out[-1].strip() == "dirty": 274 ↛ 275line 274 didn't jump to line 275, because the condition on line 274 was never true
275 info.dirty = True
276 describe_out.pop()
277 else:
278 info.dirty = False
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")
285 return info
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
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.
297 If only name is given, bumpversion uses a lightweight tag.
298 Otherwise, it utilizes an annotated tag.
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
313class Mercurial(SourceCodeManager):
314 """Mercurial implementation."""
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"']
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)
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 ]
349 if lines:
350 joined_lines = b"\n".join(lines).decode()
351 raise DirtyWorkingDirectoryError(f"Mercurial working directory is not clean:\n{joined_lines}")
353 @classmethod
354 def add_path(cls, path: Union[str, Path]) -> None:
355 """Add a path to the VCS."""
356 pass
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.
363 If only name is given, bumpversion uses a lightweight tag.
364 Otherwise, it utilizes an annotated tag.
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.
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
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()