Skip to content

Detail Package

detail

detail.Commit

Commit(data, tag_match=None)

Bases: UserDict

A commit object, parsed from a dictionary of formatted commit data. Allows one to easily see the tag.

Source code in detail/core.py
def __init__(self, data, tag_match=None):
    self.data = data
    self._tag_match = tag_match

tag property

tag

Returns a Tag that contains the commit

detail.Note

Note(data, *, path, schema, commit=None)

Bases: UserDict

A note object with an optional associated commit

Source code in detail/core.py
def __init__(self, data, *, path, schema, commit=None):
    self.data = data
    self.path = path
    self._schema = schema
    self.commit = commit
    self.schema_data = schema.parse(data)

is_valid property

is_valid

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

validation_errors property

validation_errors

Returns the schema formaldict.Errors that occurred during validation

detail.NoteRange

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

Bases: Notes

Represents a range of notes. The range can be filtered and grouped using all of the methods in Notes.

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 NoteRange 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 detail/core.py
def __init__(self, range="", tag_match=None, before=None, after=None, reverse=False):
    self._commit_schema = _load_commit_schema()
    self._tag_match = tag_match
    self._before = before
    self._after = after
    self._reverse = reverse

    # 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"

    self.commits = {commit["sha"]: commit for commit in _git_log(git_log_cmd)}
    note_log = _note_log(git_log_cmd)

    self._range = range
    schema = _load_note_schema()

    return super().__init__(
        [
            Note(
                data,
                path=path,
                schema=schema,
                commit=Commit(self.commits[sha], tag_match=tag_match),
            )
            for path, data, sha in note_log
            if data
        ]
    )

detail.Notes

Notes(notes)

Bases: Sequence

A filterable and groupable collection of notes

When a list of Note 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 log templates.

Source code in detail/core.py
def __init__(self, notes):
    self._notes = notes

exclude

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

Exclude notes by an attribute

Parameters:

Name Type Description Default
attr str

The name of the attribute on the Note 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
Notes

The excluded commits.

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

    Args:
        attr (str): The name of the attribute on the `Note` 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 Notes(
        [note for note in self if not _equals(getattr(note, attr), value, match=match)]
    )

filter

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

Filter notes by an attribute

Parameters:

Name Type Description Default
attr str

The name of the attribute on the Note 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
Notes

The filtered notes.

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

    Args:
        attr (str): The name of the attribute on the `Note` 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 notes.
    """
    return Notes([note for note in self if _equals(getattr(note, attr), value, match=match)])

group

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

Group notes 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
OrderedDict[str, Notes]

A dictionary of Notes keyed on groups.

Source code in detail/core.py
def group(
    self,
    attr,
    ascending_keys=False,
    descending_keys=False,
    none_key_first=False,
    none_key_last=False,
) -> collections.OrderedDict[str, Notes]:
    """Group notes 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 `Notes` 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(note, attr), True) for note 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)

detail.Tag

Tag(tag)

Bases: UserString

A git tag.

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

date property

Parse the date of the tag

Returns:

Type Description
Optional[datetime]

The tag parsed as a datetime object.

from_sha classmethod

from_sha(sha, tag_match=None) -> Optional[Tag]

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

Returns:

Type Description
Optional[Tag]

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

Source code in detail/core.py
@classmethod
def from_sha(cls, sha, tag_match=None) -> Optional[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

detail.detail

detail(path: Optional[str] = None) -> Tuple[str, FormalDict]

Creates or updates a note

Parameters:

Name Type Description Default
path str, default=None

A path to an existing note. If provided, the note will be updated.

None

Returns:

Type Description
str

The result from running git commit. Returns the git pre-commit

FormalDict

hook results if failing during hook execution.

Source code in detail/core.py
def detail(path: Optional[str] = None) -> Tuple[str, formaldict.FormalDict]:
    """
    Creates or updates a note

    Arguments:
        path (str, default=None): A path to an existing note.
            If provided, the note will be updated.

    Returns:
        The result from running git commit. Returns the git pre-commit
        hook results if failing during hook execution.
    """
    defaults = {}
    if path:
        with open(path) as f:
            defaults = yaml.safe_load(f.read())
    else:
        now = dt.datetime.now(dt.timezone.utc)
        autopath = (
            utils.get_detail_note_root()
            / f'{now.strftime("%Y-%m-%d")}-{str(uuid.uuid4())[:6]}.yaml'
        )
        autopath.parent.mkdir(exist_ok=True, parents=True)
        path = str(autopath)

    schema = _load_note_schema()
    entry = schema.prompt(defaults=defaults)
    serialized = yaml.dump(entry.data, default_style="|")

    with open(path, "w") as f:
        f.write(serialized)

    return path, entry

detail.lint

lint(range='') -> Tuple[bool, Notes]

Lint notes against a range (branch, sha, etc).

Linting passes when either succeed:

  • No commits are in the range.
  • Commits are found, and all notes pass linting. At least one note must be in the commit range.

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.

''

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:

Name Type Description
tuple (bool, NoteRange)

A tuple of the lint result (True/False)

Notes

and the associated NoteRange

Source code in detail/core.py
def lint(range="") -> Tuple[bool, Notes]:
    """
    Lint notes against a range (branch, sha, etc).

    Linting passes when either succeed:

    - No commits are in the range.
    - Commits are found, and all notes pass linting. At least one
      note must be in the commit range.

    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.

    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:
        tuple(bool, NoteRange): A tuple of the lint result (True/False)
        and the associated `NoteRange`
    """
    notes = NoteRange(range=range)
    if not notes.commits:
        return True, notes
    elif not notes:
        return False, notes
    else:
        return not notes.filter("is_valid", False), notes

detail.log

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

Renders notes.

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 file nickname to use when rendering. Defaults to "default", which means .detail/log.tpl will be used to render. When used, the .detail/log_{{style}}.tpl file will be rendered.

'default'
template str, default=None

A template string to use when rendering. Supercedes any style provided.

None
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 log.

Source code in detail/core.py
def log(
    range="",
    style="default",
    template=None,
    tag_match=None,
    before=None,
    after=None,
    reverse=False,
    output=None,
) -> str:
    """
    Renders notes.

    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 file nickname to use when rendering.
            Defaults to "default", which means ``.detail/log.tpl`` will
            be used to render. When used, the ``.detail/log_{{style}}.tpl``
            file will be rendered.
        template (str, default=None): A template string to use when rendering.
            Supercedes any style provided.
        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 log.
    """
    notes = NoteRange(
        range=range,
        tag_match=tag_match,
        before=before,
        after=after,
        reverse=reverse,
    )

    if not template:
        env = jinja2.Environment(
            loader=jinja2.FileSystemLoader(utils.get_detail_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 template if the user didn't provide one
                template = jinja2.Template(DEFAULT_LOG_TEMPLATE, trim_blocks=True)
            else:
                raise
    else:
        template = jinja2.Template(template, trim_blocks=True)

    rendered = template.render(notes=notes, output=output, range=range)

    _output(path=output, value=rendered)

    return rendered

detail.exceptions

detail.exceptions.CommitParseError

Bases: Error

For representing errors when parsing commits

detail.exceptions.Error

Bases: Exception

The base error for all detail errors

detail.exceptions.GithubConfigurationError

Bases: Error

When not correctly set up for Github access

detail.exceptions.GithubPullRequestAPIError

Bases: Error

When an unexpected error happens with the Github pull request API

detail.exceptions.MultipleGithubPullRequestsFoundError

Bases: Error

When multiple Github pull requests have been opened

detail.exceptions.NoGithubPullRequestFoundError

Bases: Error

When no Github pull requests have been opened

detail.exceptions.SchemaError

Bases: Error

When an issue is found in the user-supplied schema