from __future__ import annotations
import os
from contextlib import suppress
from logging import getLogger
from pathlib import Path
from typing import TYPE_CHECKING
# NOTE: use backport with newer API than stdlib
from importlib_resources import files
import semantic_release
from semantic_release.changelog.context import (
ReleaseNotesContext,
autofit_text_width,
make_changelog_context,
)
from semantic_release.changelog.template import environment, recursive_render
from semantic_release.cli.config import ChangelogOutputFormat
from semantic_release.cli.const import (
DEFAULT_CHANGELOG_NAME_STEM,
DEFAULT_RELEASE_NOTES_TPL_FILE,
JINJA2_EXTENSION,
)
from semantic_release.cli.util import noop_report
from semantic_release.errors import InternalError
if TYPE_CHECKING:
from jinja2 import Environment
from semantic_release.changelog.context import ChangelogContext
from semantic_release.changelog.release_history import Release, ReleaseHistory
from semantic_release.cli.config import RuntimeContext
from semantic_release.hvcs._base import HvcsBase
log = getLogger(__name__)
[docs]
def get_default_tpl_dir(style: str, sub_dir: str | None = None) -> Path:
module_base_path = Path(str(files(semantic_release.__name__)))
default_templates_path = module_base_path.joinpath(
f"data/templates/{style}",
"" if sub_dir is None else sub_dir.strip("/"),
)
if default_templates_path.is_dir():
return default_templates_path
raise InternalError(
str.join(
" ",
[
"Default template directory not found at",
f"{default_templates_path}. Installation corrupted!",
],
)
)
[docs]
def render_default_changelog_file(
output_format: ChangelogOutputFormat,
changelog_context: ChangelogContext,
changelog_style: str,
) -> str:
tpl_dir = get_default_tpl_dir(style=changelog_style, sub_dir=output_format.value)
changelog_tpl_file = Path(DEFAULT_CHANGELOG_NAME_STEM).with_suffix(
str.join(".", ["", output_format.value, JINJA2_EXTENSION.lstrip(".")])
)
# Create a new environment as we don't want user's configuration as it might
# not match our default template structure
template_env = changelog_context.bind_to_environment(
environment(
autoescape=False,
newline_sequence="\n",
template_dir=tpl_dir,
)
)
# Using the proper enviroment with the changelog context, render the template
template = template_env.get_template(str(changelog_tpl_file))
changelog_content = template.render().rstrip()
# Normalize line endings to ensure universal newlines because that is what is expected
# of the content when we write it to a file. When using pathlib.Path.write_text(), it
# will automatically normalize the file to the OS. At this point after render, we may
# have mixed line endings because of the read_file() call of the previous changelog
# (which may be /r/n or /n)
return str.join(
"\n", [line.replace("\r", "") for line in changelog_content.split("\n")]
)
[docs]
def render_release_notes(
release_notes_template_file: str,
template_env: Environment,
) -> str:
# NOTE: release_notes_template_file must be a relative path to the template directory
# because jinja2's filtering and template loading filter is janky
template = template_env.get_template(release_notes_template_file)
release_notes = template.render().rstrip() + os.linesep
# Normalize line endings to match the current platform
return str.join(
os.linesep, [line.replace("\r", "") for line in release_notes.split("\n")]
)
[docs]
def apply_user_changelog_template_directory(
template_dir: Path,
environment: Environment,
destination_dir: Path,
noop: bool = False,
) -> list[str]:
if noop:
noop_report(
str.join(
" ",
[
"would have recursively rendered the template directory",
f"{template_dir!r} relative to {destination_dir!r}.",
"Paths which would be modified by this operation cannot be",
"determined in no-op mode.",
],
)
)
return []
return recursive_render(
template_dir, environment=environment, _root_dir=destination_dir
)
[docs]
def write_default_changelog(
changelog_file: Path,
destination_dir: Path,
output_format: ChangelogOutputFormat,
changelog_context: ChangelogContext,
changelog_style: str,
noop: bool = False,
) -> str:
if noop:
noop_report(
str.join(
" ",
[
"would have written your changelog to",
str(changelog_file.relative_to(destination_dir)),
],
)
)
return str(changelog_file)
changelog_text = render_default_changelog_file(
output_format=output_format,
changelog_context=changelog_context,
changelog_style=changelog_style,
)
# write_text() will automatically normalize newlines to the OS, so we just use an universal newline here
changelog_file.write_text(f"{changelog_text}\n", encoding="utf-8")
return str(changelog_file)
[docs]
def write_changelog_files(
runtime_ctx: RuntimeContext,
release_history: ReleaseHistory,
hvcs_client: HvcsBase,
noop: bool = False,
) -> list[str]:
project_dir = Path(runtime_ctx.repo_dir)
template_dir = runtime_ctx.template_dir
changelog_context = make_changelog_context(
hvcs_client=hvcs_client,
release_history=release_history,
mode=runtime_ctx.changelog_mode,
insertion_flag=runtime_ctx.changelog_insertion_flag,
prev_changelog_file=runtime_ctx.changelog_file,
)
user_templates = []
# Update known templates list if Directory exists and directory has actual files to render
if template_dir.is_dir():
user_templates.extend(
[
f
for f in template_dir.rglob("*")
if f.is_file() and f.suffix == JINJA2_EXTENSION
]
)
with suppress(ValueError):
# do not include a release notes override when considering number of changelog templates
user_templates.remove(template_dir / DEFAULT_RELEASE_NOTES_TPL_FILE)
# Render user templates if found
if len(user_templates) > 0:
return apply_user_changelog_template_directory(
template_dir=template_dir,
environment=changelog_context.bind_to_environment(
runtime_ctx.template_environment
),
destination_dir=project_dir,
noop=noop,
)
log.info("No contents found in %r, using default changelog template", template_dir)
return [
write_default_changelog(
changelog_file=runtime_ctx.changelog_file,
destination_dir=project_dir,
output_format=runtime_ctx.changelog_output_format,
changelog_context=changelog_context,
changelog_style=runtime_ctx.changelog_style,
noop=noop,
)
]
[docs]
def generate_release_notes(
hvcs_client: HvcsBase,
release: Release,
template_dir: Path,
history: ReleaseHistory,
style: str,
) -> str:
users_tpl_file = template_dir / DEFAULT_RELEASE_NOTES_TPL_FILE
# Determine if the user has a custom release notes template or we should use
# the default template directory with our default release notes template
tpl_dir = (
template_dir
if users_tpl_file.is_file()
else get_default_tpl_dir(
style=style, sub_dir=ChangelogOutputFormat.MARKDOWN.value
)
)
release_notes_tpl_file = (
users_tpl_file.name
if users_tpl_file.is_file()
else DEFAULT_RELEASE_NOTES_TPL_FILE
)
release_notes_env = ReleaseNotesContext(
repo_name=hvcs_client.repo_name,
repo_owner=hvcs_client.owner,
hvcs_type=hvcs_client.__class__.__name__.lower(),
version=release["version"],
release=release,
filters=(*hvcs_client.get_changelog_context_filters(), autofit_text_width),
).bind_to_environment(
# Use a new, non-configurable environment for release notes -
# not user-configurable at the moment
environment(autoescape=False, template_dir=tpl_dir)
)
# TODO: Remove in v10
release_notes_env.globals["context"] = {
"history": history,
}
release_notes_env.globals["ctx"] = {
"history": history,
}
return render_release_notes(
release_notes_template_file=release_notes_tpl_file,
template_env=release_notes_env,
)