Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add script hooks #228

Merged
merged 9 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bumpversion/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from pathlib import Path
from typing import TYPE_CHECKING, List, MutableMapping, Optional

from bumpversion.hooks import run_post_commit_hooks, run_pre_commit_hooks, run_setup_hooks

if TYPE_CHECKING: # pragma: no-coverage
from bumpversion.files import ConfiguredFile
from bumpversion.versioning.models import Version
Expand Down Expand Up @@ -75,10 +77,14 @@ def do_bump(
logger.indent()

ctx = get_context(config)

logger.info("Parsing current version '%s'", config.current_version)
logger.indent()
version = config.version_config.parse(config.current_version)
logger.dedent()

run_setup_hooks(config, version, dry_run)

next_version = get_next_version(version, config, version_part, new_version)
next_version_str = config.version_config.serialize(next_version, ctx)
logger.info("New version will be '%s'", next_version_str)
Expand Down Expand Up @@ -109,7 +115,13 @@ def do_bump(

ctx = get_context(config, version, next_version)
ctx["new_version"] = next_version_str

run_pre_commit_hooks(config, version, next_version, dry_run)

commit_and_tag(config, config_file, configured_files, ctx, dry_run)

run_post_commit_hooks(config, version, next_version, dry_run)

logger.info("Done.")


Expand Down
3 changes: 3 additions & 0 deletions bumpversion/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
"scm_info": None,
"parts": {},
"files": [],
"setup_hooks": [],
"pre_commit_hooks": [],
"post_commit_hooks": [],
}


Expand Down
3 changes: 3 additions & 0 deletions bumpversion/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ class Config(BaseSettings):
scm_info: Optional["SCMInfo"]
parts: Dict[str, VersionComponentSpec]
files: List[FileChange] = Field(default_factory=list)
setup_hooks: List[str] = Field(default_factory=list)
pre_commit_hooks: List[str] = Field(default_factory=list)
post_commit_hooks: List[str] = Field(default_factory=list)
included_paths: List[str] = Field(default_factory=list)
excluded_paths: List[str] = Field(default_factory=list)
model_config = SettingsConfigDict(env_prefix="bumpversion_")
Expand Down
138 changes: 138 additions & 0 deletions bumpversion/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Implementation of the hook interface."""

import datetime
import os
import subprocess

Check warning on line 5 in bumpversion/hooks.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

bumpversion/hooks.py#L5

Consider possible security implications associated with the subprocess module.
from typing import Dict, List, Optional

from bumpversion.config.models import Config
from bumpversion.ui import get_indented_logger
from bumpversion.versioning.models import Version

PREFIX = "BVHOOK_"

logger = get_indented_logger(__name__)


def run_command(script: str, environment: Optional[dict] = None) -> subprocess.CompletedProcess:
"""Runs command-line programs using the shell."""
if not isinstance(script, str):
raise TypeError(f"`script` must be a string, not {type(script)}")
if environment and not isinstance(environment, dict):
raise TypeError(f"`environment` must be a dict, not {type(environment)}")
return subprocess.run(script, env=environment, encoding="utf-8", shell=True, text=True, capture_output=True)

Check failure on line 23 in bumpversion/hooks.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

bumpversion/hooks.py#L23

subprocess call with shell=True identified, security issue.


def base_env(config: Config) -> Dict[str, str]:
"""Provide the base environment variables."""
return {
f"{PREFIX}NOW": datetime.datetime.now().isoformat(),
f"{PREFIX}UTCNOW": datetime.datetime.now(datetime.timezone.utc).isoformat(),
**os.environ,
**scm_env(config),
}


def scm_env(config: Config) -> Dict[str, str]:
"""Provide the scm environment variables."""
scm = config.scm_info
return {
f"{PREFIX}COMMIT_SHA": scm.commit_sha or "",
f"{PREFIX}DISTANCE_TO_LATEST_TAG": str(scm.distance_to_latest_tag) or "0",
f"{PREFIX}IS_DIRTY": str(scm.dirty),
f"{PREFIX}BRANCH_NAME": scm.branch_name or "",
f"{PREFIX}SHORT_BRANCH_NAME": scm.short_branch_name or "",
f"{PREFIX}CURRENT_VERSION": scm.current_version or "",
f"{PREFIX}CURRENT_TAG": scm.current_tag or "",
}


def version_env(version: Version, version_prefix: str) -> Dict[str, str]:
"""Provide the environment variables for each version component with a prefix."""
return {f"{PREFIX}{version_prefix}{part.upper()}": version[part].value for part in version}


def get_setup_hook_env(config: Config, current_version: Version) -> Dict[str, str]:
"""Provide the environment dictionary for `setup_hook`s."""
return {**base_env(config), **scm_env(config), **version_env(current_version, "CURRENT_")}


def get_pre_commit_hook_env(config: Config, current_version: Version, new_version: Version) -> Dict[str, str]:
"""Provide the environment dictionary for `pre_commit_hook`s."""
return {
**base_env(config),
**scm_env(config),
**version_env(current_version, "CURRENT_"),
**version_env(new_version, "NEW_"),
}


def get_post_commit_hook_env(config: Config, current_version: Version, new_version: Version) -> Dict[str, str]:
"""Provide the environment dictionary for `post_commit_hook`s."""
return {
**base_env(config),
**scm_env(config),
**version_env(current_version, "CURRENT_"),
**version_env(new_version, "NEW_"),
}


def run_hooks(hooks: List[str], env: Dict[str, str], dry_run: bool = False) -> None:
"""Run a list of command-line programs using the shell."""
logger.indent()
for script in hooks:
if dry_run:
logger.debug(f"Would run {script!r}")
continue
logger.debug(f"Running {script!r}")
logger.indent()
result = run_command(script, env)
logger.debug(result.stdout)
logger.debug(result.stderr)
logger.debug(f"Exited with {result.returncode}")
logger.indent()
logger.dedent()


def run_setup_hooks(config: Config, current_version: Version, dry_run: bool = False) -> None:
"""Run the setup hooks."""
env = get_setup_hook_env(config, current_version)
if config.setup_hooks:
running = "Would run" if dry_run else "Running"
logger.info(f"{running} setup hooks:")
else:
logger.info("No setup hooks defined")
return

run_hooks(config.setup_hooks, env, dry_run)


def run_pre_commit_hooks(
config: Config, current_version: Version, new_version: Version, dry_run: bool = False
) -> None:
"""Run the pre-commit hooks."""
env = get_pre_commit_hook_env(config, current_version, new_version)

if config.pre_commit_hooks:
running = "Would run" if dry_run else "Running"
logger.info(f"{running} pre-commit hooks:")
else:
logger.info("No pre-commit hooks defined")
return

run_hooks(config.pre_commit_hooks, env, dry_run)


def run_post_commit_hooks(
config: Config, current_version: Version, new_version: Version, dry_run: bool = False
) -> None:
"""Run the post-commit hooks."""
env = get_post_commit_hook_env(config, current_version, new_version)
if config.post_commit_hooks:
running = "Would run" if dry_run else "Running"
logger.info(f"{running} post-commit hooks:")
else:
logger.info("No post-commit hooks defined")
return

run_hooks(config.post_commit_hooks, env, dry_run)
5 changes: 4 additions & 1 deletion bumpversion/scm.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class SCMInfo:
commit_sha: Optional[str] = None
distance_to_latest_tag: int = 0
current_version: Optional[str] = None
current_tag: Optional[str] = None
branch_name: Optional[str] = None
short_branch_name: Optional[str] = None
repository_root: Optional[Path] = None
Expand All @@ -42,6 +43,7 @@ def __repr__(self):
f"commit_sha={self.commit_sha}, "
f"distance_to_latest_tag={self.distance_to_latest_tag}, "
f"current_version={self.current_version}, "
f"current_tag={self.current_tag}, "
f"branch_name={self.branch_name}, "
f"short_branch_name={self.short_branch_name}, "
f"repository_root={self.repository_root}, "
Expand Down Expand Up @@ -286,7 +288,7 @@ def _commit_info(cls, parse_pattern: str, tag_name: str) -> dict:
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 = dict.fromkeys(["dirty", "commit_sha", "distance_to_latest_tag", "current_version", "current_tag"])
info["distance_to_latest_tag"] = 0
try:
# get info about the latest tag in git
Expand All @@ -309,6 +311,7 @@ def _commit_info(cls, parse_pattern: str, tag_name: str) -> dict:

info["commit_sha"] = describe_out.pop().lstrip("g")
info["distance_to_latest_tag"] = int(describe_out.pop())
info["current_tag"] = "-".join(describe_out)
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:
Expand Down
139 changes: 139 additions & 0 deletions docs/reference/hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
title: Hooks
description: Details about writing and setting up hooks
icon:
date: 2024-08-15
comments: true
---
# Hooks

## Hook Suites

A _hook suite_ is a list of _hooks_ to run sequentially. A _hook_ is either an individual shell command or an executable script.

There are three hook suites: _setup, pre-commit,_ and _post-commit._ During the version increment process this is the order of operations:

1. Run _setup_ hooks
2. Increment version
3. Change files
4. Run _pre-commit_ hooks
5. Commit and tag
6. Run _post-commit_ hooks

!!! Note

Don't confuse the _pre-commit_ and _post-commit_ hook suites with Git pre- and post-commit hooks. Those hook suites are named for their adjacency to the commit and tag operation.


## Configuration

Configure each hook suite with the `setup_hooks`, `pre_commit_hooks`, or `post_commit_hooks` keys.

Each suite takes a list of strings. The strings may be individual commands:

```toml title="Calling individual commands"
[tool.bumpversion]
setup_hooks = [
"git config --global user.email \"[email protected]\"",
"git config --global user.name \"Testing Git\"",
"git --version",
"git config --list",
]
pre_commit_hooks = ["cat CHANGELOG.md"]
post_commit_hooks = ["echo Done"]
```

or the path to an executable script:

```toml title="Calling a shell script"
[tool.bumpversion]
setup_hooks = ["path/to/setup.sh"]
pre_commit_hooks = ["path/to/pre-commit.sh"]
post_commit_hooks = ["path/to/post-commit.sh"]
```

!!! Note

You can make a script executable using the following steps:

1. Add a [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) line to the top like `#!/bin/bash`
2. Run `chmod u+x path/to/script.sh` to set the executable bit

## Hook Environments

Each hook has these environment variables set when executed.

### Inherited environment

All environment variables set before bump-my-version was run are available.

### Date and time fields

::: field-list

`BVHOOK_NOW`
: The ISO-8601-formatted current local time without a time zone reference.

`BVHOOK_UTCNOW`
: The ISO-8601-formatted current local time in the UTC time zone.

### Source code management fields

!!! Note

These fields will only have values if the code is in a Git or Mercurial repository.

::: field-list

`BVHOOK_COMMIT_SHA`
: The latest commit reference.

`BHOOK_DISTANCE_TO_LATEST_TAG`
: The number of commits since the latest tag.

`BVHOOK_IS_DIRTY`
: A boolean indicating if the current repository has pending changes.

`BVHOOK_BRANCH_NAME`
: The current branch name.

`BVHOOK_SHORT_BRANCH_NAME`
: The current branch name, converted to lowercase, with non-alphanumeric characters removed and truncated to 20 characters. For example, `feature/MY-long_branch-name` would become `featuremylongbranchn`.


### Current version fields

::: field-list
`BVHOOK_CURRENT_VERSION`
: The current version serialized as a string

`BVHOOK_CURRENT_TAG`
: The current tag

`BVHOOK_CURRENT_<version component>`
: Each version component defined by the [version configuration parsing regular expression](configuration/global.md#parse). The default configuration would have `BVHOOK_CURRENT_MAJOR`, `BVHOOK_CURRENT_MINOR`, and `BVHOOK_CURRENT_PATCH` available.


### New version fields

!!! Note

These are not available in the _setup_ hook suite.

::: field-list
`BVHOOK_NEW_VERSION`
: The new version serialized as a string

`BVHOOK_NEW_TAG`
: The new tag

`BVHOOK_NEW_<version component>`
: Each version component defined by the [version configuration parsing regular expression](configuration/global.md#parse). The default configuration would have `BVHOOK_NEW_MAJOR`, `BVHOOK_NEW_MINOR`, and `BVHOOK_NEW_PATCH` available.

## Outputs

The `stdout` and `stderr` streams are echoed to the console if you pass the `-vv` option.

## Dry-runs

Bump my version does not execute any hooks during a dry run. With the verbose output option it will state which hooks it would have run.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ line-length = 119
select = ["E", "W", "F", "I", "N", "B", "BLE", "C", "D", "E", "F", "I", "N", "S", "T", "W", "RUF", "NPY", "PD", "PGH", "ANN", "C90", "PLC", "PLE", "PLW", "TCH"]
ignore = [
"ANN002", "ANN003", "ANN101", "ANN102", "ANN204", "ANN401",
"S101", "S104",
"S101", "S104", "S602",
"D105", "D106", "D107", "D200", "D212",
"PD011",
"PLW1510",
Expand Down
Loading
Loading