Module continuous_delivery_scripts.assert_news

Checks if valid news files are created for changes in the project.

Expand source code
#
# Copyright (C) 2020-2025 Arm Limited or its affiliates and Contributors. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
"""Checks if valid news files are created for changes in the project."""
import argparse
import logging
import pathlib
import re
import sys
from typing import Union, Optional, Iterable, Any, List

from continuous_delivery_scripts.utils.configuration import configuration, ConfigurationVariable
from continuous_delivery_scripts.utils.git_helpers import ProjectTempClone, LocalProjectRepository, GitWrapper
from continuous_delivery_scripts.utils.logging import log_exception, set_log_level
from continuous_delivery_scripts.utils.news_file import create_news_file

logger = logging.getLogger(__name__)

NEWS_FILE_NAME_REGEX = r"^[0-9]+.(misc|doc|removal|bugfix|feature|major)$"


class NewsFileValidator:
    """Verifies individual news files."""

    def __init__(self, absolute_path: Union[pathlib.Path, str]) -> None:
        """Creates a new instance of NewsFileValidator.

        Args:
            absolute_path: the absolute path to the news file
        """
        self._news_file_path = pathlib.Path(absolute_path)
        self._basename = self._news_file_path.name

    def validate_file_name(self) -> None:
        """Ensures the news file follows the naming rules."""
        if re.match(NEWS_FILE_NAME_REGEX, self._basename) is None:
            raise ValueError(
                f'Incorrect news file name "{self._basename}".'
                f' It doesn\'t match the following regex: "{NEWS_FILE_NAME_REGEX}".'
            )

    def validate_file_contents(self) -> None:
        """Ensures the news file is not empty and not longer than one line."""
        file_content = self._news_file_path.read_text()
        if file_content.strip() == "":
            raise ValueError(f'Empty news file "{self._basename}".')
        if len(file_content.splitlines()) > 1:
            raise ValueError(f'News file "{self._basename}" contains more than one line.')

    def validate(self) -> None:
        """Runs all validators."""
        logger.info(f"Verifying {self._basename}")
        self.validate_file_name()
        self.validate_file_contents()


def validate_news_file(absolute_path: Union[pathlib.Path, str]) -> None:
    """Applies NewsFileValidator validation logic to news file."""
    NewsFileValidator(absolute_path).validate()


def find_news_files(git: GitWrapper, root_dir: str, news_dir: str) -> List[str]:
    """Determines a list of all the news files which were added as part of the PR.

    Args:
        git: Instance of GitWrapper.
        root_dir: Root directory of the project.
        news_dir: Relative path to news directory.

    Returns:
        list: list of absolute paths to news files
    """
    files_changed = git.list_files_added_to_current_commit()
    # To speed up the process, we first look at files added to the current commit.
    # If no news files were added, then we check for addition on the branch.
    # Relies on the fact GitWrapper returns paths that are always relative
    # to the project root.
    added_news_files = [file_path for file_path in files_changed if file_path.startswith(news_dir)]
    if len(added_news_files) == 0:
        files_changed = git.list_files_added_on_current_branch()
        added_news_files = [file_path for file_path in files_changed if file_path.startswith(news_dir)]
    return [str(pathlib.Path(root_dir, file_path)) for file_path in added_news_files]


def validate_news_files(git: GitWrapper, root_dir: str, news_dir: str) -> None:
    """Checks that news files exist and pass validation checks.

    Args:
        git: Instance of GitWrapper.
        root_dir: Root directory of the project.
        news_dir: Relative path to news directory.
    """
    added_news_files = find_news_files(git=git, news_dir=news_dir, root_dir=root_dir)
    if not added_news_files:
        raise FileNotFoundError(f"PR must contain a news file in {news_dir}. See README.md.")
    for absolute_file_path in added_news_files:
        validate_news_file(absolute_file_path)


def _convert_to_string_iter(list: Optional[List[Any]]) -> Iterable[str]:
    if list is None:
        return []
    return [str(item) for item in list]


def generate_news_file(git: GitWrapper, news_dir: pathlib.Path) -> pathlib.Path:
    """Adds a news file if the branch corresponds to an dependency update.

    Args:
        git: Instance of GitWrapper.
        news_dir: path to news directory.
    """
    current_branch = str(git.get_current_branch())
    is_dependency_update, groups = git.is_current_branch_of_type(
        str(configuration.get_value(ConfigurationVariable.DEPENDENCY_UPDATE_BRANCH_PATTERN))
    )
    if not is_dependency_update:
        raise EnvironmentError(f"Branch {current_branch} must contain a news file.")
    if not configuration.get_value(ConfigurationVariable.AUTOGENERATE_NEWS_FILE_ON_DEPENDENCY_UPDATE):
        raise EnvironmentError(f"Branch {current_branch} must contain a news file.")

    list_groups = _convert_to_string_iter(groups)
    message = str(configuration.get_value(ConfigurationVariable.DEPENDENCY_UPDATE_NEWS_MESSAGE)).format(
        message=", ".join(list_groups)
    )
    logger.info(f"Generating a news file with content: {message}...")
    return create_news_file(
        str(news_dir),
        None,
        message,
        configuration.get_value(ConfigurationVariable.DEPENDENCY_UPDATE_NEWS_TYPE),
    )


def _commit_news_file(git: GitWrapper, news_file: pathlib.Path, local: bool) -> None:
    logger.info(f"Committing news file {str(news_file)}...")
    if not local:
        git.configure_for_github()
    git.add(news_file)
    git.commit("📰 Automatic changes ⚙ Adding news file")
    if not local:
        git.push()
        git.pull()


def main() -> None:
    """Asserts the new PR comprises at least one news file and it adheres to the required standard."""
    parser = argparse.ArgumentParser(description="Check correctly formatted news files exist on feature branch.")
    parser.add_argument("-b", "--current-branch", help="Name of the current branch", nargs="?")
    parser.add_argument("-l", "--local", action="store_true", help="perform checks directly on local repository")
    parser.add_argument("-v", "--verbose", action="count", default=0, help="Verbosity, by default errors are reported.")
    args = parser.parse_args()
    set_log_level(args.verbose)

    with (
        LocalProjectRepository()  # type: ignore
        if args.local
        else ProjectTempClone(desired_branch_name=args.current_branch)
    ) as git:
        if git.is_current_branch_feature():
            root_dir = configuration.get_value(ConfigurationVariable.PROJECT_ROOT)
            absolute_news_dir = configuration.get_value(ConfigurationVariable.NEWS_DIR)
            news_dir = str(pathlib.Path(absolute_news_dir).relative_to(root_dir))
            try:
                validate_news_files(git=git, news_dir=news_dir, root_dir=root_dir)
            except Exception as e:
                log_exception(logger, e)
                try:
                    news_file = generate_news_file(git, git.get_corresponding_path(pathlib.Path(news_dir)))
                    _commit_news_file(git, news_file, args.local)
                except Exception as e2:
                    log_exception(logger, e2)
                sys.exit(1)


if __name__ == "__main__":
    main()

Functions

def find_news_files(git: GitWrapper, root_dir: str, news_dir: str) ‑> List[str]

Determines a list of all the news files which were added as part of the PR.

Args

git
Instance of GitWrapper.
root_dir
Root directory of the project.
news_dir
Relative path to news directory.

Returns

list
list of absolute paths to news files
Expand source code
def find_news_files(git: GitWrapper, root_dir: str, news_dir: str) -> List[str]:
    """Determines a list of all the news files which were added as part of the PR.

    Args:
        git: Instance of GitWrapper.
        root_dir: Root directory of the project.
        news_dir: Relative path to news directory.

    Returns:
        list: list of absolute paths to news files
    """
    files_changed = git.list_files_added_to_current_commit()
    # To speed up the process, we first look at files added to the current commit.
    # If no news files were added, then we check for addition on the branch.
    # Relies on the fact GitWrapper returns paths that are always relative
    # to the project root.
    added_news_files = [file_path for file_path in files_changed if file_path.startswith(news_dir)]
    if len(added_news_files) == 0:
        files_changed = git.list_files_added_on_current_branch()
        added_news_files = [file_path for file_path in files_changed if file_path.startswith(news_dir)]
    return [str(pathlib.Path(root_dir, file_path)) for file_path in added_news_files]
def generate_news_file(git: GitWrapper, news_dir: pathlib.Path) ‑> pathlib.Path

Adds a news file if the branch corresponds to an dependency update.

Args

git
Instance of GitWrapper.
news_dir
path to news directory.
Expand source code
def generate_news_file(git: GitWrapper, news_dir: pathlib.Path) -> pathlib.Path:
    """Adds a news file if the branch corresponds to an dependency update.

    Args:
        git: Instance of GitWrapper.
        news_dir: path to news directory.
    """
    current_branch = str(git.get_current_branch())
    is_dependency_update, groups = git.is_current_branch_of_type(
        str(configuration.get_value(ConfigurationVariable.DEPENDENCY_UPDATE_BRANCH_PATTERN))
    )
    if not is_dependency_update:
        raise EnvironmentError(f"Branch {current_branch} must contain a news file.")
    if not configuration.get_value(ConfigurationVariable.AUTOGENERATE_NEWS_FILE_ON_DEPENDENCY_UPDATE):
        raise EnvironmentError(f"Branch {current_branch} must contain a news file.")

    list_groups = _convert_to_string_iter(groups)
    message = str(configuration.get_value(ConfigurationVariable.DEPENDENCY_UPDATE_NEWS_MESSAGE)).format(
        message=", ".join(list_groups)
    )
    logger.info(f"Generating a news file with content: {message}...")
    return create_news_file(
        str(news_dir),
        None,
        message,
        configuration.get_value(ConfigurationVariable.DEPENDENCY_UPDATE_NEWS_TYPE),
    )
def main() ‑> None

Asserts the new PR comprises at least one news file and it adheres to the required standard.

Expand source code
def main() -> None:
    """Asserts the new PR comprises at least one news file and it adheres to the required standard."""
    parser = argparse.ArgumentParser(description="Check correctly formatted news files exist on feature branch.")
    parser.add_argument("-b", "--current-branch", help="Name of the current branch", nargs="?")
    parser.add_argument("-l", "--local", action="store_true", help="perform checks directly on local repository")
    parser.add_argument("-v", "--verbose", action="count", default=0, help="Verbosity, by default errors are reported.")
    args = parser.parse_args()
    set_log_level(args.verbose)

    with (
        LocalProjectRepository()  # type: ignore
        if args.local
        else ProjectTempClone(desired_branch_name=args.current_branch)
    ) as git:
        if git.is_current_branch_feature():
            root_dir = configuration.get_value(ConfigurationVariable.PROJECT_ROOT)
            absolute_news_dir = configuration.get_value(ConfigurationVariable.NEWS_DIR)
            news_dir = str(pathlib.Path(absolute_news_dir).relative_to(root_dir))
            try:
                validate_news_files(git=git, news_dir=news_dir, root_dir=root_dir)
            except Exception as e:
                log_exception(logger, e)
                try:
                    news_file = generate_news_file(git, git.get_corresponding_path(pathlib.Path(news_dir)))
                    _commit_news_file(git, news_file, args.local)
                except Exception as e2:
                    log_exception(logger, e2)
                sys.exit(1)
def validate_news_file(absolute_path: Union[pathlib.Path, str]) ‑> None

Applies NewsFileValidator validation logic to news file.

Expand source code
def validate_news_file(absolute_path: Union[pathlib.Path, str]) -> None:
    """Applies NewsFileValidator validation logic to news file."""
    NewsFileValidator(absolute_path).validate()
def validate_news_files(git: GitWrapper, root_dir: str, news_dir: str) ‑> None

Checks that news files exist and pass validation checks.

Args

git
Instance of GitWrapper.
root_dir
Root directory of the project.
news_dir
Relative path to news directory.
Expand source code
def validate_news_files(git: GitWrapper, root_dir: str, news_dir: str) -> None:
    """Checks that news files exist and pass validation checks.

    Args:
        git: Instance of GitWrapper.
        root_dir: Root directory of the project.
        news_dir: Relative path to news directory.
    """
    added_news_files = find_news_files(git=git, news_dir=news_dir, root_dir=root_dir)
    if not added_news_files:
        raise FileNotFoundError(f"PR must contain a news file in {news_dir}. See README.md.")
    for absolute_file_path in added_news_files:
        validate_news_file(absolute_file_path)

Classes

class NewsFileValidator (absolute_path: Union[pathlib.Path, str])

Verifies individual news files.

Creates a new instance of NewsFileValidator.

Args

absolute_path
the absolute path to the news file
Expand source code
class NewsFileValidator:
    """Verifies individual news files."""

    def __init__(self, absolute_path: Union[pathlib.Path, str]) -> None:
        """Creates a new instance of NewsFileValidator.

        Args:
            absolute_path: the absolute path to the news file
        """
        self._news_file_path = pathlib.Path(absolute_path)
        self._basename = self._news_file_path.name

    def validate_file_name(self) -> None:
        """Ensures the news file follows the naming rules."""
        if re.match(NEWS_FILE_NAME_REGEX, self._basename) is None:
            raise ValueError(
                f'Incorrect news file name "{self._basename}".'
                f' It doesn\'t match the following regex: "{NEWS_FILE_NAME_REGEX}".'
            )

    def validate_file_contents(self) -> None:
        """Ensures the news file is not empty and not longer than one line."""
        file_content = self._news_file_path.read_text()
        if file_content.strip() == "":
            raise ValueError(f'Empty news file "{self._basename}".')
        if len(file_content.splitlines()) > 1:
            raise ValueError(f'News file "{self._basename}" contains more than one line.')

    def validate(self) -> None:
        """Runs all validators."""
        logger.info(f"Verifying {self._basename}")
        self.validate_file_name()
        self.validate_file_contents()

Methods

def validate(self) ‑> None

Runs all validators.

Expand source code
def validate(self) -> None:
    """Runs all validators."""
    logger.info(f"Verifying {self._basename}")
    self.validate_file_name()
    self.validate_file_contents()
def validate_file_contents(self) ‑> None

Ensures the news file is not empty and not longer than one line.

Expand source code
def validate_file_contents(self) -> None:
    """Ensures the news file is not empty and not longer than one line."""
    file_content = self._news_file_path.read_text()
    if file_content.strip() == "":
        raise ValueError(f'Empty news file "{self._basename}".')
    if len(file_content.splitlines()) > 1:
        raise ValueError(f'News file "{self._basename}" contains more than one line.')
def validate_file_name(self) ‑> None

Ensures the news file follows the naming rules.

Expand source code
def validate_file_name(self) -> None:
    """Ensures the news file follows the naming rules."""
    if re.match(NEWS_FILE_NAME_REGEX, self._basename) is None:
        raise ValueError(
            f'Incorrect news file name "{self._basename}".'
            f' It doesn\'t match the following regex: "{NEWS_FILE_NAME_REGEX}".'
        )