Skip to content

Commit

Permalink
Merge pull request #228 from callowayproject/pre-post-bump-tasks
Browse files Browse the repository at this point in the history
Add script hooks
  • Loading branch information
coordt authored Aug 19, 2024
2 parents 508f87b + 04a98d0 commit 6735a61
Show file tree
Hide file tree
Showing 17 changed files with 622 additions and 27 deletions.
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
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)


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

0 comments on commit 6735a61

Please sign in to comment.