Skip to content

Package

tidy

The core git-tidy functions and classes that are utilized by the :ref:cli.

tidy.Commit

Commit(msg, schema, tag_match=None)

Bases: UserString

Parses a commit message into structured components.

It is assumed the commit message is formatted as YAML by the appropriate "git log" command (see CommitRange). If data is able to be parsed, attributes of the commit can be accessed as attributes of this object. For example, a type attribute in the schema is accessible as Commit().type.

If the commit cannot be parsed as valid YAML for unexpected reasons, is_parsed is False and only a limited amount of attributes are available.

Source code in tidy/core.py
def __init__(self, msg, schema, tag_match=None):
    msg = msg.strip()

    self._schema = schema
    self.data = msg
    self._tag_match = tag_match

    try:
        commit_data = yaml.safe_load(io.StringIO(msg))

        # Format commit attributes
        commit_data = {
            key: _format_commit_attr(key, value) for key, value in commit_data.items()
        }

        # Flatten attributes
        commit_data = {
            **{k: v for k, v in commit_data.items() if k != "trailers"},
            **commit_data["trailers"],
        }

        # Parse the commit data
        self.schema_data = schema.parse(commit_data)
        self._is_parsed = True
    except Exception as exc:
        # If the yaml data cannot be parsed, construct a special
        # formal dictionary object with an appropriate error
        match = re.match(r"sha: (?P<sha>[a-fA-F\d]+)\n", msg)
        sha = match.group("sha")
        errors = formaldict.Errors()
        errors.add(exceptions.CommitParseError(str(exc)))

        self.schema_data = formaldict.FormalDict(
            schema=schema,
            parsed={"sha": sha},
            data={"sha": sha},
            errors=errors,
        )
        self._is_parsed = False

is_parsed property

is_parsed

True if the commit has been parsed successfully.

If False, only the sha and msg attributes are available.

is_valid property

is_valid

True if the commit was successfully validated against the schema. If False, some attributes in the schema may be missing.

msg property

msg

The raw git commit message

tag property

tag

Returns a Tag that contains the commit

validation_errors property

validation_errors

Returns the schema formaldict.Errors that occurred during validation

tidy.CommitRange

CommitRange(range='', tag_match=None, before=None, after=None, reverse=False)

Bases: Commits

Represents a range of commits. The commit range can be filtered and grouped using all of the methods in Commits.

When doing git log, the user can provide a range (e.g. "origin/develop.."). Any range used in "git log" can be used as a range to the CommitRange object.

If the special :github/pr value is used as a range, the Github API is used to figure out the range based on a pull request opened from the current branch (if found).

Source code in tidy/core.py
def __init__(self, range="", tag_match=None, before=None, after=None, reverse=False):
    self._schema = _load_commit_schema()
    self._tag_match = tag_match
    self._before = before
    self._after = after
    self._reverse = reverse
    _check_git_version()

    # The special ":github/pr" range will do a range against the base
    # pull request branch
    if range == GITHUB_PR:
        range = _get_pull_request_range()

    # Ensure any remotes are fetched
    utils.shell("git --no-pager fetch -q")

    git_log_cmd = f"git --no-pager log {range} --no-merges"
    if before:
        git_log_cmd += f" --before={before}"
    if after:
        git_log_cmd += f" --after={after}"
    if reverse:
        git_log_cmd += " --reverse"

    git_yaml_logs = _git_log_as_yaml(git_log_cmd)

    self._range = range

    return super().__init__(
        [Commit(msg, self._schema, tag_match=self._tag_match) for msg in git_yaml_logs]
    )

tidy.Commits

Commits(commits)

Bases: Sequence

A filterable and groupable collection of commits

When a list of Commit objects is organized in this sequence, the "group", "filter", and "exclude" chainable methods can be used for various access patterns. These access patterns are typically used when writing git tidy log templates.

Source code in tidy/core.py
def __init__(self, commits):
    self._commits = commits

exclude

exclude(attr, value, match=False) -> Commits

Exclude commits by an attribute

Parameters:

Name Type Description Default
attr str

The name of the attribute on the Commit object.

required
value str | bool

The value to exclude by.

required
match bool, default=False

Treat value as a regex pattern and match against it.

False

Returns:

Type Description
Commits

The excluded commits.

Source code in tidy/core.py
def exclude(self, attr, value, match=False) -> Commits:
    """Exclude commits by an attribute

    Args:
        attr (str): The name of the attribute on the `Commit` object.
        value (str|bool): The value to exclude by.
        match (bool, default=False): Treat ``value`` as a regex pattern and
            match against it.

    Returns:
        The excluded commits.
    """
    return Commits(
        [commit for commit in self if not _equals(getattr(commit, attr), value, match=match)]
    )

filter

filter(attr, value, match=False) -> Commits

Filter commits by an attribute

Parameters:

Name Type Description Default
attr str

The name of the attribute on the Commit object.

required
value str | bool

The value to filter by.

required
match bool, default=False

Treat value as a regex pattern and match against it.

False

Returns:

Type Description
Commits

The filtered commits.

Source code in tidy/core.py
def filter(self, attr, value, match=False) -> Commits:
    """Filter commits by an attribute

    Args:
        attr (str): The name of the attribute on the `Commit` object.
        value (str|bool): The value to filter by.
        match (bool, default=False): Treat ``value`` as a regex pattern and
            match against it.

    Returns:
        The filtered commits.
    """
    return Commits(
        [commit for commit in self if _equals(getattr(commit, attr), value, match=match)]
    )

group

group(
    attr,
    ascending_keys=False,
    descending_keys=False,
    none_key_first=False,
    none_key_last=False,
) -> dict[str, Commits]

Group commits by an attribute

Parameters:

Name Type Description Default
attr str

The attribute to group by.

required
ascending_keys bool, default=False

Sort the keys in ascending order.

False
descending_keys bool, default=False

Sort the keys in descending order.

False
none_key_first bool, default=False

Make the "None" key be first.

False
none_key_last bool, default=False

Make the "None" key be last.

False

Returns:

Type Description
dict[str, Commits]

A dictionary of Commits keyed on groups.

Source code in tidy/core.py
def group(
    self,
    attr,
    ascending_keys=False,
    descending_keys=False,
    none_key_first=False,
    none_key_last=False,
) -> dict[str, Commits]:
    """Group commits by an attribute

    Args:
        attr (str): The attribute to group by.
        ascending_keys (bool, default=False): Sort the keys in ascending
            order.
        descending_keys (bool, default=False): Sort the keys in descending
            order.
        none_key_first (bool, default=False): Make the "None" key be first.
        none_key_last (bool, default=False): Make the "None" key be last.

    Returns:
        A dictionary of `Commits` keyed on groups.
    """
    if any([ascending_keys, descending_keys]) and not any([none_key_first, none_key_last]):
        # If keys are sorted, default to making the "None" key last
        none_key_last = True

    # Get the natural ordering of the keys
    keys = list(
        collections.OrderedDict((getattr(commit, attr), True) for commit in self).keys()
    )

    # Re-sort the keys
    if any([ascending_keys, descending_keys]):
        sorted_keys = sorted((k for k in keys if k is not None), reverse=descending_keys)
        if None in keys:
            sorted_keys.append(None)

        keys = sorted_keys

    # Change the ordering of the "None" key
    if any([none_key_first, none_key_last]) and None in keys:
        keys.remove(None)
        keys.insert(0 if none_key_first else len(keys), None)

    return collections.OrderedDict((key, self.filter(attr, key)) for key in keys)

tidy.Tag

Tag(tag)

Bases: UserString

A git tag.

Source code in tidy/core.py
def __init__(self, tag):
    self.data = tag

date property

date: datetime

Parse the date of the tag

Returns:

Name Type Description
datetime datetime

The tag parsed as a datetime object.

from_sha classmethod

from_sha(sha, tag_match=None) -> Tag

Create a Tag object from a sha or return None if there is no associated tag

Returns:

Type Description
Tag

A constructed tag or None if no tags contain the commit.

Source code in tidy/core.py
@classmethod
def from_sha(cls, sha, tag_match=None) -> Tag:
    """
    Create a Tag object from a sha or return None if there is no
    associated tag

    Returns:
        A constructed tag or ``None`` if no tags contain the commit.
    """
    describe_cmd = f"git describe {sha} --contains"
    if tag_match:
        describe_cmd += f" --match={tag_match}"

    rev = (
        utils.shell_stdout(describe_cmd, check=False, stderr=subprocess.PIPE)
        .replace("~", ":")
        .replace("^", ":")
    )
    return cls(rev.split(":")[0]) if rev else None

tidy.commit

commit(no_verify=False, allow_empty=False, defaults=None) -> CompletedProcess

Performs a tidy git commit.

Parameters:

Name Type Description Default
no_verify bool, default=False

True if ignoring pre-commit hooks

False
allow_empty bool, default=False

True if an empty commit should be allowed

False
defaults dict, default=None

Defaults to be used when prompting for commit attributes.

None

Returns:

Type Description
CompletedProcess

The result from running git commit. Returns the git pre-commit hook results if

CompletedProcess

failing during hook execution.

Source code in tidy/core.py
def commit(no_verify=False, allow_empty=False, defaults=None) -> subprocess.CompletedProcess:
    """
    Performs a tidy git commit.

    Args:
        no_verify (bool, default=False): True if ignoring
            pre-commit hooks
        allow_empty (bool, default=False): True if an empty
            commit should be allowed
        defaults (dict, default=None): Defaults to be used
            when prompting for commit attributes.

    Returns:
        The result from running git commit. Returns the git pre-commit hook results if
        failing during hook execution.
    """
    # Run pre-commit hooks manually so that the commit will fail
    # before prompting the user for information
    hooks_path = utils.shell_stdout("git rev-parse --git-path hooks")
    pre_commit_hook = os.path.join(hooks_path, "pre-commit")
    if not no_verify and os.path.exists(pre_commit_hook):
        result = utils.shell(pre_commit_hook, check=False)
        if result.returncode:
            return result

    # If there are no staged changes and we are not allowing empty
    # commits (the default git commit mode), short circuit and run
    # a failing git commit
    staged_changes = utils.shell_stdout("git diff --cached")
    if not staged_changes and not allow_empty:
        return utils.shell("git commit --no-verify", check=False)

    schema = _load_commit_schema(full=False)
    entry = schema.prompt(defaults=defaults)

    # Render the commit message from the validated entry
    commit_msg = ""
    if "summary" in entry:
        commit_msg += f'{entry["summary"].strip()}\n\n'
    if "description" in entry:
        commit_msg += f'{entry["description"].strip()}\n\n'

    for key, value in entry.items():
        if key not in ["summary", "description"]:
            key = key.capitalize().replace("_", "-").strip()
            commit_msg += f"{key}: {value.strip()}\n"

    commit_msg = commit_msg.strip()

    # Commit with git
    commit_cmd = "git commit --no-verify"
    if allow_empty:
        commit_cmd += " --allow-empty"
    with tempfile.NamedTemporaryFile(mode="w+") as commit_file:
        commit_file.write(commit_msg)
        commit_file.flush()

        return utils.shell(f"{commit_cmd} -F {commit_file.name}", check=False)

tidy.lint

lint(range='', any=False) -> tuple[bool, CommitRange]

Lint commits against an upstream (branch, sha, etc).

Parameters:

Name Type Description Default
range str, default=''

The git revision range against which linting happens. The special value of ":github/pr" can be used to lint against the remote branch of the pull request that is opened from the local branch. No range means linting will happen against all commits.

''
any bool, default=False

If True, linting will pass if at least one commit passes.

False

Raises:

Type Description
`NoGithubPullRequestFoundError`

When using :github/pr as the range and no pull requests are found.

`MultipleGithubPullRequestsFoundError`

When using :github/pr as the range and multiple pull requests are found.

Returns:

Type Description
tuple[bool, CommitRange]

A tuple of the lint result (True/False) and the associated CommitRange

Source code in tidy/core.py
def lint(range="", any=False) -> tuple[bool, CommitRange]:
    """
    Lint commits against an upstream (branch, sha, etc).

    Args:
        range (str, default=''): The git revision range against which linting
            happens. The special value of ":github/pr" can be used to lint
            against the remote branch of the pull request that is opened
            from the local branch. No range means linting will happen against
            all commits.
        any (bool, default=False): If True, linting will pass if at least
            one commit passes.

    Raises:
        `NoGithubPullRequestFoundError`: When using ``:github/pr`` as
            the range and no pull requests are found.
        `MultipleGithubPullRequestsFoundError`: When using ``:github/pr`` as
            the range and multiple pull requests are found.

    Returns:
        A tuple of the lint result (True/False) and the associated CommitRange
    """
    commits = CommitRange(range=range)
    if not any:
        return not commits.filter("is_valid", False), commits
    else:
        return bool(commits.filter("is_valid", True)), commits

tidy.log

log(
    range="",
    style="default",
    tag_match=None,
    before=None,
    after=None,
    reverse=False,
    output=None,
) -> str

Renders git logs using tidy rendering.

Parameters:

Name Type Description Default
range str, default=''

The git revision range over which logs are output. Using ":github/pr" as the range will use the base branch of an open github pull request as the range. No range will result in all commits being logged.

''
style str, default="default"

The template to use when rendering. Defaults to "default", which means .git-tidy/log.tpl will be used to render. When used, the .git-tidy/log_{{style}}.tpl file will be rendered.

'default'
tag_match str, default=None

A glob(7) pattern for matching tags when associating a tag with a commit in the log. Passed through to git describe --contains --matches when finding a tag.

None
before str, default=None

Only return commits before a specific date. Passed directly to git log --before.

None
after str, default=None

Only return commits after a specific date. Passed directly to git log --after.

None
reverse bool, default=False

Reverse ordering of results. Passed directly to git log --reverse.

False
output str | file

Path or file-like object to which the template is written. Using the special ":github/pr" output path will post the log as a comment on the pull request.

None

Raises:

Type Description
`NoGithubPullRequestFoundError`

When using :github/pr as the range and no pull requests are found.

`MultipleGithubPullRequestsFoundError`

When using :github/pr as the range and multiple pull requests are found.

Returns:

Type Description
str

The rendered tidy log.

Source code in tidy/core.py
def log(
    range="",
    style="default",
    tag_match=None,
    before=None,
    after=None,
    reverse=False,
    output=None,
) -> str:
    """
    Renders git logs using tidy rendering.

    Args:
        range (str, default=''): The git revision range over which logs are
            output. Using ":github/pr" as the range will use the base branch
            of an open github pull request as the range. No range will result
            in all commits being logged.
        style (str, default="default"): The template to use when rendering.
            Defaults to "default", which means ``.git-tidy/log.tpl`` will
            be used to render. When used, the ``.git-tidy/log_{{style}}.tpl``
            file will be rendered.
        tag_match (str, default=None): A glob(7) pattern for matching tags
            when associating a tag with a commit in the log. Passed through
            to ``git describe --contains --matches`` when finding a tag.
        before (str, default=None): Only return commits before a specific
            date. Passed directly to ``git log --before``.
        after (str, default=None): Only return commits after a specific
            date. Passed directly to ``git log --after``.
        reverse (bool, default=False): Reverse ordering of results. Passed
            directly to ``git log --reverse``.
        output (str|file): Path or file-like object to which the template is
            written. Using the special ":github/pr" output path will post the
            log as a comment on the pull request.

    Raises:
        `NoGithubPullRequestFoundError`: When using ``:github/pr`` as
            the range and no pull requests are found.
        `MultipleGithubPullRequestsFoundError`: When using ``:github/pr`` as
            the range and multiple pull requests are found.

    Returns:
        The rendered tidy log.
    """
    commits = CommitRange(
        range=range,
        tag_match=tag_match,
        before=before,
        after=after,
        reverse=reverse,
    )
    env = jinja2.Environment(
        loader=jinja2.FileSystemLoader(utils.get_tidy_file_root()),
        trim_blocks=True,
    )
    template_file = "log.tpl" if style == "default" else f"log_{style}.tpl"
    try:
        template = env.get_template(template_file)
    except jinja2.exceptions.TemplateNotFound:
        if style == "default":
            # Use the default tidy template if the user didn't provide one
            template = jinja2.Template(DEFAULT_LOG_TEMPLATE, trim_blocks=True)
        else:
            raise
    rendered = template.render(commits=commits, output=output, range=range)

    _output(path=output, value=rendered)

    return rendered

tidy.squash

squash(ref, no_verify=False, allow_empty=False) -> CompletedProcess

Squashes all commits since the common ancestor of ref.

Parameters:

Name Type Description Default
ref str

The git reference to squash against. Every commit after the common ancestor of this reference will be squashed.

required
no_verify bool, default=False

True if ignoring pre-commit hooks

False
allow_empty bool, default=False

True if an empty commit should be allowed

False

Raises:

Type Description
`NoSquashableCommitsError`

When no commits can be squashed.

CalledProcessError

If the first git reset call unexpectedly fails

`NoGithubPullRequestFoundError`

When using :github/pr as the range and no pull requests are found.

`MultipleGithubPullRequestsFoundError`

When using :github/pr as the range and multiple pull requests are found.

Returns:

Type Description
CompletedProcess

The commit result. The commit result contains either a failed pre-commit hook result or a

CompletedProcess

successful/failed commit result.

Source code in tidy/core.py
def squash(ref, no_verify=False, allow_empty=False) -> subprocess.CompletedProcess:
    """
    Squashes all commits since the common ancestor of ref.

    Args:
        ref (str): The git reference to squash against. Every commit after
            the common ancestor of this reference will be squashed.
        no_verify (bool, default=False): True if ignoring
            pre-commit hooks
        allow_empty (bool, default=False): True if an empty
            commit should be allowed

    Raises:
        `NoSquashableCommitsError`: When no commits can be squashed.
        subprocess.CalledProcessError: If the first ``git reset`` call
            unexpectedly fails
        `NoGithubPullRequestFoundError`: When using ``:github/pr`` as
            the range and no pull requests are found.
        `MultipleGithubPullRequestsFoundError`: When using ``:github/pr`` as
            the range and multiple pull requests are found.

    Returns:
        The commit result. The commit result contains either a failed pre-commit hook result or a
        successful/failed commit result.
    """
    ref = github.get_pull_request_base() if ref == GITHUB_PR else ref
    range = f"{ref}.."

    commits = CommitRange(range=range)
    if not commits:
        raise exceptions.NoSquashableCommitsError("No commits to squash")

    # If there is a valid commit, use it as the default values for the
    # squashed commit message. Note that we look for the last valid commit
    valid_commits = commits.filter("is_valid", True)
    last_valid_commit = valid_commits[-1] if valid_commits else None

    defaults = last_valid_commit.schema_data if last_valid_commit else {}

    # Reset to the common ancestor of the ref point
    common_ancestor = utils.shell_stdout(f"git merge-base {ref} HEAD")
    utils.shell(f"git reset --soft {common_ancestor}")

    try:
        # Prompt for the new commit message. Reset back to the last point
        # if anything goes wrong
        commit_result = commit(allow_empty=allow_empty, no_verify=no_verify, defaults=defaults)
    except (Exception, KeyboardInterrupt):
        utils.shell("git reset ORIG_HEAD")
        raise

    if commit_result.returncode != 0:
        utils.shell("git reset ORIG_HEAD")

    return commit_result

tidy.exceptions

tidy.exceptions.CommitParseError

Bases: Error

For representing errors when parsing commits

tidy.exceptions.Error

Bases: Exception

The base error for all tidy errors

tidy.exceptions.GithubConfigurationError

Bases: Error

When not correctly set up for Github access

tidy.exceptions.GithubPullRequestAPIError

Bases: Error

When an unexpected error happens with the Github pull request API

tidy.exceptions.MultipleGithubPullRequestsFoundError

Bases: Error

When multiple Github pull requests have been opened

tidy.exceptions.NoGithubPullRequestFoundError

Bases: Error

When no Github pull requests have been opened

tidy.exceptions.NoSquashableCommitsError

Bases: Error

When no commits can be squashed

tidy.exceptions.SchemaError

Bases: Error

When an issue is found in the user-supplied schema