Skip to content

Commit

Permalink
Merge pull request #219 from callowayproject/216-support-external-con…
Browse files Browse the repository at this point in the history
…fig-files

Fixes issues with external config files in git repos
  • Loading branch information
coordt authored Aug 6, 2024
2 parents efa87b6 + 890b692 commit 6f2448a
Show file tree
Hide file tree
Showing 11 changed files with 313 additions and 136 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ repos:
hooks:
- id: mypy
args: [--no-strict-optional, --ignore-missing-imports]
additional_dependencies: ["pydantic>2.0", "toml", "types-all"]
additional_dependencies: ["pydantic>2.0", "toml"]
- repo: https://github.com/jsh9/pydoclint
rev: 0.5.6
hooks:
Expand Down
4 changes: 2 additions & 2 deletions bumpversion/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import shlex
from pathlib import Path
from typing import TYPE_CHECKING, ChainMap, List, Optional
from typing import TYPE_CHECKING, List, MutableMapping, Optional

if TYPE_CHECKING: # pragma: no-coverage
from bumpversion.files import ConfiguredFile
Expand Down Expand Up @@ -117,7 +117,7 @@ def commit_and_tag(
config: Config,
config_file: Optional[Path],
configured_files: List["ConfiguredFile"],
ctx: ChainMap,
ctx: MutableMapping,
dry_run: bool = False,
) -> None:
"""
Expand Down
5 changes: 5 additions & 0 deletions bumpversion/config/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ def update_config_file(
logger.info("\n%sProcessing config file: %s", logger.indent_str, config_file)
logger.indent()
config_path = Path(config_file)

if config.scm_info.tool and not config.scm_info.path_in_repo(config_path):
logger.info("\n%sConfiguration file is outside of the repo. Not going to change.", logger.indent_str)
return

if config_path.suffix != ".toml":
logger.info("You must have a `.toml` suffix to update the config file: %s.", config_path)
return
Expand Down
164 changes: 109 additions & 55 deletions bumpversion/scm.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
from dataclasses import dataclass
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import TYPE_CHECKING, ClassVar, List, MutableMapping, Optional, Type, Union
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, MutableMapping, Optional, Type, Union

from bumpversion.ui import get_indented_logger
from bumpversion.utils import extract_regex_flags
from bumpversion.utils import extract_regex_flags, format_and_raise_error, run_command

if TYPE_CHECKING: # pragma: no-coverage
from bumpversion.config import Config

from bumpversion.exceptions import BumpVersionError, DirtyWorkingDirectoryError, SignedTagsError
from bumpversion.exceptions import DirtyWorkingDirectoryError, SignedTagsError

logger = get_indented_logger(__name__)

Expand All @@ -29,6 +29,7 @@ class SCMInfo:
current_version: Optional[str] = None
branch_name: Optional[str] = None
short_branch_name: Optional[str] = None
repository_root: Optional[Path] = None
dirty: Optional[bool] = None

def __str__(self):
Expand All @@ -37,11 +38,23 @@ def __str__(self):
def __repr__(self):
tool_name = self.tool.__name__ if self.tool else "No SCM tool"
return (
f"SCMInfo(tool={tool_name}, commit_sha={self.commit_sha}, "
f"distance_to_latest_tag={self.distance_to_latest_tag}, current_version={self.current_version}, "
f"SCMInfo(tool={tool_name}, "
f"commit_sha={self.commit_sha}, "
f"distance_to_latest_tag={self.distance_to_latest_tag}, "
f"current_version={self.current_version}, "
f"branch_name={self.branch_name}, "
f"short_branch_name={self.short_branch_name}, "
f"repository_root={self.repository_root}, "
f"dirty={self.dirty})"
)

def path_in_repo(self, path: Union[Path, str]) -> bool:
"""Return whether a path is inside this repository."""
if self.repository_root is None:
return True

return str(path).startswith(str(self.repository_root))


class SourceCodeManager:
"""Base class for version control systems."""
Expand Down Expand Up @@ -71,7 +84,7 @@ def commit(cls, message: str, current_version: str, new_version: str, extra_args

try:
cmd = [*cls._COMMIT_COMMAND, f.name, *extra_args]
subprocess.run(cmd, env=env, capture_output=True, check=True) # noqa: S603
run_command(cmd, env=env)
except (subprocess.CalledProcessError, TypeError) as exc: # pragma: no-coverage
cls.format_and_raise_error(exc)
finally:
Expand All @@ -80,20 +93,13 @@ def commit(cls, message: str, current_version: str, new_version: str, extra_args
@classmethod
def format_and_raise_error(cls, exc: Union[TypeError, subprocess.CalledProcessError]) -> None:
"""Format the error message from an exception and re-raise it as a BumpVersionError."""
if isinstance(exc, subprocess.CalledProcessError):
output = "\n".join([x for x in [exc.stdout.decode("utf8"), exc.stderr.decode("utf8")] if x])
cmd = " ".join(exc.cmd)
err_msg = f"Failed to run `{cmd}`: return code {exc.returncode}, output: {output}"
else: # pragma: no-coverage
err_msg = f"Failed to run {cls._COMMIT_COMMAND}: {exc}"
logger.exception(err_msg)
raise BumpVersionError(err_msg) from exc
format_and_raise_error(exc)

@classmethod
def is_usable(cls) -> bool:
"""Is the VCS implementation usable."""
try:
result = subprocess.run(cls._TEST_USABLE_COMMAND, check=True, capture_output=True) # noqa: S603
result = run_command(cls._TEST_USABLE_COMMAND)
return result.returncode == 0
except (FileNotFoundError, PermissionError, NotADirectoryError, subprocess.CalledProcessError):
return False
Expand Down Expand Up @@ -122,7 +128,7 @@ def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> No
def get_all_tags(cls) -> List[str]:
"""Return all tags in VCS."""
try:
result = subprocess.run(cls._ALL_TAGS_COMMAND, text=True, check=True, capture_output=True) # noqa: S603
result = run_command(cls._ALL_TAGS_COMMAND)
return result.stdout.splitlines()
except (FileNotFoundError, PermissionError, NotADirectoryError, subprocess.CalledProcessError):
return []
Expand Down Expand Up @@ -238,24 +244,48 @@ def assert_nondirty(cls) -> None:
"""Assert that the working directory is not dirty."""
lines = [
line.strip()
for line in subprocess.check_output(["git", "status", "--porcelain"]).splitlines() # noqa: S603, S607
if not line.strip().startswith(b"??")
for line in run_command(["git", "status", "--porcelain"]).stdout.splitlines()
if not line.strip().startswith("??")
]

if lines:
joined_lines = b"\n".join(lines).decode()
if joined_lines := "\n".join(lines):
raise DirtyWorkingDirectoryError(f"Git working directory is not clean:\n\n{joined_lines}")

@classmethod
def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:
"""Return information about the latest tag."""
if not cls.is_usable():
return SCMInfo()

info: Dict[str, Any] = {"tool": cls}

try:
# git-describe doesn't update the git-index, so we do that
subprocess.run(["git", "update-index", "--refresh", "-q"], capture_output=True) # noqa: S603, S607
run_command(["git", "update-index", "--refresh", "-q"])
except subprocess.CalledProcessError as e:
logger.debug("Error when running git update-index: %s", e.stderr)
return SCMInfo(tool=cls)

commit_info = cls._commit_info(parse_pattern, tag_name)
rev_info = cls._revision_info()
info.update(commit_info)
info.update(rev_info)

return SCMInfo(**info)

@classmethod
def _commit_info(cls, parse_pattern: str, tag_name: str) -> dict:
"""
Get the commit info for the repo.
Args:
parse_pattern: The regular expression pattern used to parse the version from the tag.
tag_name: The tag name format used to locate the latest tag.
Returns:
A dictionary containing information about the latest commit.
"""
tag_pattern = tag_name.replace("{new_version}", "*")
info = dict.fromkeys(["dirty", "commit_sha", "distance_to_latest_tag", "current_version"])
info["distance_to_latest_tag"] = 0
try:
# get info about the latest tag in git
git_cmd = [
Expand All @@ -267,44 +297,73 @@ def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:
"--abbrev=40",
f"--match={tag_pattern}",
]
result = subprocess.run(git_cmd, text=True, check=True, capture_output=True) # noqa: S603
result = run_command(git_cmd)
describe_out = result.stdout.strip().split("-")

git_cmd = ["git", "rev-parse", "--abbrev-ref", "HEAD"]
result = subprocess.run(git_cmd, text=True, check=True, capture_output=True) # noqa: S603
branch_name = result.stdout.strip()
short_branch_name = re.sub(r"([^a-zA-Z0-9]*)", "", branch_name).lower()[:20]
if describe_out[-1].strip() == "dirty":
info["dirty"] = True
describe_out.pop()
else:
info["dirty"] = False

info["commit_sha"] = describe_out.pop().lstrip("g")
info["distance_to_latest_tag"] = int(describe_out.pop())
version = cls.get_version_from_tag("-".join(describe_out), tag_name, parse_pattern)
info["current_version"] = version or "-".join(describe_out).lstrip("v")
except subprocess.CalledProcessError as e:
logger.debug("Error when running git describe: %s", e.stderr)
return SCMInfo(tool=cls)

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

if describe_out[-1].strip() == "dirty":
info.dirty = True
describe_out.pop()
else:
info.dirty = False
@classmethod
def _revision_info(cls) -> dict:
"""
Returns a dictionary containing revision information.
If an error occurs while running the git command, the dictionary values will be set to None.
Returns:
A dictionary with the following keys:
- branch_name: The name of the current branch.
- short_branch_name: A 20 lowercase characters of the branch name with special characters removed.
- repository_root: The root directory of the Git repository.
"""
info = dict.fromkeys(["branch_name", "short_branch_name", "repository_root"])

info.commit_sha = describe_out.pop().lstrip("g")
info.distance_to_latest_tag = int(describe_out.pop())
version = cls.get_version_from_tag("-".join(describe_out), tag_name, parse_pattern)
info.current_version = version or "-".join(describe_out).lstrip("v")
try:
git_cmd = ["git", "rev-parse", "--show-toplevel", "--abbrev-ref", "HEAD"]
result = run_command(git_cmd)
lines = [line.strip() for line in result.stdout.split("\n")]
repository_root = Path(lines[0])
branch_name = lines[1]
short_branch_name = re.sub(r"([^a-zA-Z0-9]*)", "", branch_name).lower()[:20]
info["branch_name"] = branch_name
info["short_branch_name"] = short_branch_name
info["repository_root"] = repository_root
except subprocess.CalledProcessError as e:
logger.debug("Error when running git rev-parse: %s", e.stderr)

return info

@classmethod
def add_path(cls, path: Union[str, Path]) -> None:
"""Add a path to the VCS."""
subprocess.check_output(["git", "add", "--update", str(path)]) # noqa: S603, S607
info = SCMInfo(**cls._revision_info())
if not info.path_in_repo(path):
return
cwd = Path.cwd()
temp_path = os.path.relpath(path, cwd)
try:
run_command(["git", "add", "--update", str(temp_path)])
except subprocess.CalledProcessError as e:
format_and_raise_error(e)

@classmethod
def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> None:
"""
Create a tag of the new_version in VCS.
If only name is given, bumpversion uses a lightweight tag.
Otherwise, it utilizes an annotated tag.
Otherwise, it uses an annotated tag.
Args:
name: The name of the tag
Expand All @@ -316,7 +375,7 @@ def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> No
command += ["--sign"]
if message:
command += ["--message", message]
subprocess.check_output(command) # noqa: S603
run_command(command)


class Mercurial(SourceCodeManager):
Expand All @@ -331,32 +390,27 @@ def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:
"""Return information about the latest tag."""
current_version = None
re_pattern = tag_name.replace("{new_version}", ".*")
result = subprocess.run(
["hg", "log", "-r", f"tag('re:{re_pattern}')", "--template", "{latesttag}\n"], # noqa: S603, S607
text=True,
check=True,
capture_output=True,
)
result = run_command(["hg", "log", "-r", f"tag('re:{re_pattern}')", "--template", "{latesttag}\n"])
result.check_returncode()
if result.stdout:
tag_string = result.stdout.splitlines(keepends=False)[-1]
current_version = cls.get_version_from_tag(tag_string, tag_name, parse_pattern)
else:
logger.debug("No tags found")
is_dirty = len(subprocess.check_output(["hg", "status", "-mard"])) != 0 # noqa: S603, S607
is_dirty = len(run_command(["hg", "status", "-mard"]).stdout) != 0
return SCMInfo(tool=cls, current_version=current_version, dirty=is_dirty)

@classmethod
def assert_nondirty(cls) -> None:
"""Assert that the working directory is clean."""
lines = [
line.strip()
for line in subprocess.check_output(["hg", "status", "-mard"]).splitlines() # noqa: S603, S607
if not line.strip().startswith(b"??")
for line in run_command(["hg", "status", "-mard"]).stdout.splitlines()
if not line.strip().startswith("??")
]

if lines:
joined_lines = b"\n".join(lines).decode()
joined_lines = "\n".join(lines)
raise DirtyWorkingDirectoryError(f"Mercurial working directory is not clean:\n{joined_lines}")

@classmethod
Expand All @@ -370,7 +424,7 @@ def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> No
Create a tag of the new_version in VCS.
If only name is given, bumpversion uses a lightweight tag.
Otherwise, it utilizes an annotated tag.
Otherwise, it uses an annotated tag.
Args:
name: The name of the tag
Expand All @@ -385,7 +439,7 @@ def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> No
raise SignedTagsError("Mercurial does not support signed tags.")
if message:
command += ["--message", message]
subprocess.check_output(command) # noqa: S603
run_command(command)


def get_scm_info(tag_name: str, parse_pattern: str) -> SCMInfo:
Expand Down
28 changes: 27 additions & 1 deletion bumpversion/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
"""General utilities."""

import string
from typing import Any, List, Tuple
import subprocess
from subprocess import CompletedProcess
from typing import Any, List, Optional, Tuple, Union

from bumpversion.exceptions import BumpVersionError
from bumpversion.ui import get_indented_logger

logger = get_indented_logger(__name__)


def extract_regex_flags(regex_pattern: str) -> Tuple[str, str]:
Expand Down Expand Up @@ -99,3 +106,22 @@ def set_nested_value(d: dict, value: Any, path: str) -> None:
raise ValueError(f"Path '{'.'.join(keys[:i+1])}' does not lead to a dictionary.")
else:
current_element = current_element[key]


def format_and_raise_error(exc: Union[TypeError, subprocess.CalledProcessError]) -> None:
"""Format the error message from an exception and re-raise it as a BumpVersionError."""
if isinstance(exc, subprocess.CalledProcessError):
output = "\n".join([x for x in [exc.stdout, exc.stderr] if x])
cmd = " ".join(exc.cmd)
err_msg = f"Failed to run `{cmd}`: return code {exc.returncode}, output: {output}"
else: # pragma: no-coverage
err_msg = f"Failed to run a command: {exc}"
logger.exception(err_msg)
raise BumpVersionError(err_msg) from exc


def run_command(command: list, env: Optional[dict] = None) -> CompletedProcess:
"""Run a shell command and return its output."""
result = subprocess.run(command, text=True, check=True, capture_output=True, env=env) # NOQA: S603
result.check_returncode()
return result
1 change: 1 addition & 0 deletions tests/fixtures/basic_cfg_expected.txt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
'current_version': None,
'dirty': None,
'distance_to_latest_tag': 0,
'repository_root': None,
'short_branch_name': None,
'tool': None},
'search': '{current_version}',
Expand Down
Loading

0 comments on commit 6f2448a

Please sign in to comment.