Skip to content

Commit 529ae78

Browse files
committed
Refactor release hint logic and add indented logger.
Refactored `ReleaseRule` to return structured results and updated related tests. Introduced `IndentedLoggerAdapter` for structured, indented logging and integrated it into the change log generation process. Enhanced CLI with verbosity options and improved logging outputs for better traceability.
1 parent cc9f7eb commit 529ae78

7 files changed

Lines changed: 368 additions & 17 deletions

File tree

action.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ branding:
2626
icon: "file-text"
2727
runs:
2828
using: "docker"
29-
image: "ghcr.io/callowayproject/generate-changelog:v0"
29+
# image: "ghcr.io/callowayproject/generate-changelog:v0"
30+
image: "Dockerfile"

generate_changelog/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from generate_changelog import __version__
1313
from generate_changelog.commits import get_context_from_tags
1414
from generate_changelog.configuration import DEFAULT_CONFIG_FILE_NAMES, Configuration, write_default_config
15+
from generate_changelog.indented_logger import setup_logging
1516
from generate_changelog.release_hint import suggest_release_type
1617

1718

@@ -57,6 +58,13 @@ def generate_config_callback(ctx: Context, param: Parameter, value: bool) -> Non
5758
@click.option("--output", "-o", type=click.Choice(["release-hint", "notes", "all"]), help="What output to generate.")
5859
@click.option("--skip-output-pipeline", is_flag=True, help="Do not execute the output pipeline in the configuration.")
5960
@click.option("--branch-override", "-b", help="Override the current branch for release hint decisions.")
61+
@click.option(
62+
"--verbose",
63+
"-v",
64+
count=True,
65+
required=False,
66+
help="Print verbose logging to stderr. Can specify several times for more verbosity.",
67+
)
6068
@click.version_option(version=__version__)
6169
def cli(
6270
config: Optional[Path],
@@ -65,6 +73,7 @@ def cli(
6573
output: Optional[str],
6674
skip_output_pipeline: bool,
6775
branch_override: Optional[str],
76+
verbose: int,
6877
) -> None:
6978
"""Generate a change log from git commits."""
7079
from generate_changelog import templating
@@ -73,6 +82,9 @@ def cli(
7382
echo_func = functools.partial(echo, quiet=bool(output))
7483
configuration = get_user_config(config, echo_func)
7584

85+
verbosity = verbose or configuration.verbosity
86+
setup_logging(verbosity)
87+
7688
repository = Repo(repo_path) if repo_path else Repo(search_parent_directories=True)
7789

7890
current_branch = repository.head if repository.head.is_detached else repository.active_branch

generate_changelog/configuration.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ class Configuration:
201201
group_by: list = field(default_factory=list)
202202
"""Group the commits within a version by these commit attributes."""
203203

204+
verbosity: int = 0
205+
"""Level of verbose logging output."""
206+
204207
#
205208
# Commit filtering
206209
#
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""A logger adapter that adds an indent to the beginning of each message."""
2+
3+
import logging
4+
import logging.config
5+
from contextvars import ContextVar
6+
from typing import Any, MutableMapping, Optional, Tuple, Union
7+
8+
import click
9+
from rich.console import Console, RenderableType
10+
from rich.padding import Padding
11+
from rich.text import Text
12+
13+
CURRENT_INDENT = ContextVar("current_indent", default=0)
14+
15+
16+
class IndentedLoggerAdapter(logging.LoggerAdapter):
17+
"""
18+
Logger adapter that adds an indent to the beginning of each message.
19+
20+
Parameters:
21+
logger: The logger to adapt.
22+
extra: Extra values to add to the logging context.
23+
depth: The number of `indent_char` to generate for each indent level.
24+
indent_char: The character or string to use for indenting.
25+
reset: `True` if the indent level should be reset to zero.
26+
"""
27+
28+
def __init__(
29+
self,
30+
logger: logging.Logger,
31+
extra: Optional[dict] = None,
32+
depth: int = 2,
33+
indent_char: str = " ",
34+
reset: bool = False,
35+
):
36+
super().__init__(logger, extra or {})
37+
self.console = Console(force_terminal=True)
38+
self._depth = depth
39+
self._indent_char = indent_char
40+
if reset:
41+
self.reset()
42+
43+
@property
44+
def current_indent(self) -> int:
45+
"""
46+
The current indent level.
47+
"""
48+
return CURRENT_INDENT.get()
49+
50+
def indent(self, amount: int = 1) -> None:
51+
"""
52+
Increase the indent level by `amount`.
53+
"""
54+
CURRENT_INDENT.set(CURRENT_INDENT.get() + amount)
55+
56+
def dedent(self, amount: int = 1) -> None:
57+
"""
58+
Decrease the indent level by `amount`.
59+
"""
60+
CURRENT_INDENT.set(max(0, CURRENT_INDENT.get() - amount))
61+
62+
def reset(self) -> None:
63+
"""
64+
Reset the indent level to zero.
65+
"""
66+
CURRENT_INDENT.set(0)
67+
68+
@property
69+
def indent_str(self) -> str:
70+
"""
71+
The indent string.
72+
"""
73+
return (self._indent_char * self._depth) * CURRENT_INDENT.get()
74+
75+
def process(
76+
self, msg: Union[str, RenderableType], kwargs: Optional[MutableMapping[str, Any]]
77+
) -> Tuple[str, MutableMapping[str, Any]]:
78+
"""
79+
Process the message and add the indent.
80+
81+
Args:
82+
msg: The logging message.
83+
kwargs: Keyword arguments passed to the logger.
84+
85+
Returns:
86+
A tuple containing the message and keyword arguments.
87+
"""
88+
if isinstance(msg, RenderableType):
89+
with self.console.capture() as capture:
90+
self.console.print(Padding(msg, (0, 0, 0, len(self.indent_str))))
91+
msg = Text.from_ansi(capture.get())
92+
else:
93+
msg = self.indent_str + msg
94+
95+
return msg, kwargs
96+
97+
98+
VERBOSITY = {
99+
0: (logging.WARNING, "%(message)s"),
100+
1: (logging.INFO, "%(message)s"),
101+
2: (logging.DEBUG, "%(message)s"),
102+
3: (logging.DEBUG, "%(message)s %(pathname)s:%(lineno)d"),
103+
}
104+
105+
106+
def get_indented_logger(name: str) -> IndentedLoggerAdapter:
107+
"""Get a logger with indentation."""
108+
return IndentedLoggerAdapter(logging.getLogger(name))
109+
110+
111+
def setup_logging(verbose: int = 0) -> None:
112+
"""Configure the logging."""
113+
logging.config.dictConfig(get_config(verbose))
114+
root_logger = get_indented_logger("")
115+
root_logger.setLevel(logging.WARNING)
116+
117+
118+
def get_config(verbose: int = 0) -> dict:
119+
"""Get the loggic config."""
120+
verbosity, log_format = VERBOSITY.get(verbose, VERBOSITY[3])
121+
return {
122+
"version": 1,
123+
"formatters": {
124+
"default": {
125+
"format": log_format,
126+
"datefmt": "[%X]",
127+
}
128+
},
129+
"handlers": {
130+
"default": {
131+
"class": "rich.logging.RichHandler",
132+
"markup": True,
133+
"rich_tracebacks": True,
134+
"show_level": False,
135+
"show_path": False,
136+
"show_time": False,
137+
"tracebacks_suppress": [click],
138+
"formatter": "default",
139+
}
140+
},
141+
"loggers": {
142+
"generate_changelog": {
143+
"handlers": ["default"],
144+
"level": verbosity,
145+
}
146+
},
147+
}

generate_changelog/release_hint.py

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
"""Methods for generating a release hint."""
22

3+
import copy
34
import fnmatch
45
import re
5-
from typing import List, Optional, Sequence, Union
6+
from dataclasses import dataclass
7+
from typing import List, Optional, Sequence, Set, Union
8+
9+
from rich.table import Table
10+
from rich.text import Text
611

712
from generate_changelog.configuration import RELEASE_TYPE_ORDER, Configuration
813
from generate_changelog.context import CommitContext, VersionContext
14+
from generate_changelog.indented_logger import get_indented_logger
15+
16+
logger = get_indented_logger(__name__)
917

1018

1119
class InvalidRuleError(Exception):
@@ -14,6 +22,24 @@ class InvalidRuleError(Exception):
1422
pass
1523

1624

25+
@dataclass
26+
class ReleaseRuleResult:
27+
"""The result of evaluating a release rule."""
28+
29+
matches_grouping: bool
30+
matches_path: bool
31+
matches_branch: bool
32+
result: str
33+
34+
@property
35+
def matches_all(self) -> bool:
36+
"""All the commit criteria were met."""
37+
return all([self.matches_grouping, self.matches_path, self.matches_branch])
38+
39+
def __str__(self) -> str:
40+
return self.result or ""
41+
42+
1743
class ReleaseRule:
1844
"""
1945
A commit evaluation rule for hinting at the level of change.
@@ -35,7 +61,7 @@ def __init__(
3561
branch: Optional[str] = None,
3662
):
3763
self.match_result = match_result
38-
self.no_match_result = no_match_result
64+
self.no_match_result = no_match_result or "no-release"
3965
self.grouping = grouping if grouping != "*" else None
4066
normalized_path = path if path != "*" else None
4167
if isinstance(normalized_path, str):
@@ -109,15 +135,22 @@ def matches_branch(self, current_branch: str) -> bool:
109135
"""
110136
return bool(re.match(self.branch, current_branch)) if self.branch else True
111137

112-
def __call__(self, commit: CommitContext, current_branch: str) -> Optional[str]:
138+
def __call__(self, commit: CommitContext, current_branch: str) -> ReleaseRuleResult:
113139
"""Evaluate the commit using this rule."""
114140
if not self.is_valid:
115141
raise InvalidRuleError()
116142

117143
matches_grouping = self.matches_grouping(commit)
118144
matches_path = self.matches_path(commit)
119145
matches_branch = self.matches_branch(current_branch)
120-
return self.match_result if all([matches_grouping, matches_path, matches_branch]) else self.no_match_result
146+
matches_all = all([matches_grouping, matches_path, matches_branch])
147+
result = self.match_result if matches_all else self.no_match_result
148+
return ReleaseRuleResult(
149+
matches_grouping=matches_grouping,
150+
matches_path=matches_path,
151+
matches_branch=matches_branch,
152+
result=result,
153+
)
121154

122155

123156
class RuleProcessor:
@@ -130,6 +163,7 @@ class RuleProcessor:
130163

131164
def __init__(self, rule_list: List[dict]):
132165
self.rules = [ReleaseRule(**kwargs) for kwargs in rule_list]
166+
self.results: List[ReleaseRuleResult] = []
133167

134168
def __call__(self, commit: CommitContext, current_branch: str) -> Optional[str]:
135169
"""
@@ -142,13 +176,28 @@ def __call__(self, commit: CommitContext, current_branch: str) -> Optional[str]:
142176
Returns:
143177
The release hint
144178
"""
145-
suggestions = {rule(commit, current_branch) for rule in self.rules}
179+
self.results = [rule(commit, current_branch) for rule in self.rules]
180+
suggestions: Set[str] = {str(result.result) for result in self.results}
146181
if unknown_suggestions := suggestions - set(RELEASE_TYPE_ORDER):
147182
return unknown_suggestions.pop() # Return a random value from the unknowns
148183

149184
sorted_suggestions = sorted(suggestions, key=lambda s: RELEASE_TYPE_ORDER.index(s))
150185
return sorted_suggestions[-1]
151186

187+
def rule_string(self) -> str:
188+
"""Return a string representation of the rules."""
189+
output: List[str] = []
190+
for i, rule in enumerate(self.rules):
191+
output.extend(
192+
(
193+
f"Rule: {i}",
194+
f" Grouping: {rule.grouping}",
195+
f" Path: {rule.path}",
196+
f" Branch: {rule.branch}",
197+
)
198+
)
199+
return "\n".join(output)
200+
152201

153202
def suggest_release_type(current_branch: str, version_contexts: List[VersionContext], config: Configuration) -> str:
154203
"""
@@ -160,20 +209,60 @@ def suggest_release_type(current_branch: str, version_contexts: List[VersionCont
160209
config: The current configuration
161210
162211
Returns:
163-
The type of release based on the rules, or ``no-release``
212+
The type of release based on the rules, or `no-release`
164213
"""
214+
logger.info("Processing commits to suggest release type...")
215+
logger.indent()
165216
rule_processor = RuleProcessor(rule_list=config.release_hint_rules)
217+
logger.debug(rule_processor.rule_string())
166218

167219
# If the latest release is not "unreleased", there is no need for a release
168220
if version_contexts[0].label != config.unreleased_label:
221+
logger.info(f"The latest release is {version_contexts[0].label}. No release is suggested.")
222+
logger.dedent()
169223
return "no-release"
170224

171225
suggestions = set()
226+
results = {}
172227
for commit_group in version_contexts[0].grouped_commits:
173228
suggestions |= {rule_processor(commit, current_branch) for commit in commit_group.commits}
229+
results["Grouping: " + " ".join(commit_group.grouping)] = copy.deepcopy(rule_processor.results)
230+
231+
print_table(results)
174232

175233
if not suggestions:
234+
logger.info("No suggestions found. No release is suggested.")
235+
logger.dedent()
176236
return "no-release"
177237

178238
sorted_suggestions = sorted(suggestions, key=lambda s: RELEASE_TYPE_ORDER.index(s))
179-
return sorted_suggestions[-1] or "no-release"
239+
result = sorted_suggestions[-1] or "no-release"
240+
logger.info("Suggested release type: %s", result)
241+
logger.dedent()
242+
return result
243+
244+
245+
def format_bool(bool_var: bool) -> Text:
246+
"""Format a boolean value as a string."""
247+
return Text("X", style="bold green") if bool_var else Text("-", style="bold red")
248+
249+
250+
def print_table(results: dict) -> None:
251+
"""Print the test results as a table."""
252+
for group_name, result_list in results.items():
253+
table = Table(title=group_name, title_justify="left")
254+
table.add_column("Rule", justify="center")
255+
table.add_column("Group", justify="center")
256+
table.add_column("Path", justify="center")
257+
table.add_column("Branch", justify="center")
258+
table.add_column("Result")
259+
260+
for i, result in enumerate(result_list):
261+
table.add_row(
262+
str(i),
263+
format_bool(result.matches_grouping),
264+
format_bool(result.matches_path),
265+
format_bool(result.matches_branch),
266+
result.result,
267+
)
268+
logger.debug(table)

0 commit comments

Comments
 (0)