diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 610e9056c..dc2b13ef0 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -5,6 +5,7 @@ ## Features * #777: Improved VulnerabilityMatcher to handle packages with multiple vulnerabilities +* #517: Modified nox session `release:prepare` to report resolved security issues ## Refactoring diff --git a/doc/user_guide/features/creating_a_release.rst b/doc/user_guide/features/creating_a_release.rst index a55a2a7a5..12f2a64e4 100644 --- a/doc/user_guide/features/creating_a_release.rst +++ b/doc/user_guide/features/creating_a_release.rst @@ -17,15 +17,17 @@ Preparing a Release * Updates the version in the ``pyproject.toml`` and ``version.py`` * Moves the content of unreleased changes file ``unreleased.md`` to a versioned changes file ``changes_.md`` - * Adds a description of dependency changes to the versioned changes file: + * Describes additional changes in the versioned changes file by comparing + file ``poetry.lock`` to the latest Git tag: - * Only direct dependencies are described, no transitive dependencies - * Changes are detected by comparing the current content of file - ``poetry.lock`` to the latest Git tag. - * Updates the ``changelog.md`` list with the newly created versioned changes file + * Resolved vulnerabilities based on `Pip Audit`_. + * Updated direct dependencies, excluding transitive dependencies + * Updates file ``changelog.md`` to list the newly created versioned changes file * Commits the changes (can be skipped with ``--no-add``) * Pushes the changes and creates a PR (can be skipped with ``--no-pr``) +.. _Pip Audit: https://pypi.org/project/pip-audit/ + #. Merge your **Pull Request** to the **default branch** #. Trigger the release @@ -53,12 +55,12 @@ Preparing a Release Your ``PROJECT_CONFIG`` needs to have the flag ``create_major_version_tags=True``. -Updating Dependencies After Having Prepared the Release -+++++++++++++++++++++++++++++++++++++++++++++++++++++++ +Updating The Versioned Changes File ++++++++++++++++++++++++++++++++++++ -If you need to update some more dependencies after running the nox session -``release:prepare`` you can update them in the changelog by running the nox -session ``release:update``. +If you need to update some dependencies after running the nox session +``release:prepare`` you can update the versioned changes file by running the +nox session ``release:update``. What to do if the Release Failed? diff --git a/doc/user_guide/user_guide.rst b/doc/user_guide/user_guide.rst index 6106e019c..44f8af4d6 100644 --- a/doc/user_guide/user_guide.rst +++ b/doc/user_guide/user_guide.rst @@ -21,28 +21,30 @@ PTB simplifies keeping all of your projects up-to-date, secure, without bugs, us The PTB gains its name from employing a series of well-established tools to satisfy these goals: -* `Poetry`_ for packaging and managing dependencies -* `Nox`_ for using the tools via a common CLI +* `Bandit`_ for detecting security vulnerabilities * `Black`_ and `Ruff`_ for source code formatting -* `Pylint`_ / `Ruff` for linting * `Cookiecutter`_ for setting up new projects from a uniform template -* `Mypy`_ for static type checking * `Coverage`_ for measuring code coverage by tests -* `Bandit`_ for detecting security vulnerabilities -* `Sphinx`_ for generating the documentation +* `Mypy`_ for static type checking +* `Nox`_ for using the tools via a common CLI +* `Pip Audit`_ for known vulnerabilities in dependencies +* `Poetry`_ for packaging and managing dependencies +* `Pylint`_ / `Ruff` for linting * `Sonar`_ for reporting code quality based on the findings by other tools +* `Sphinx`_ for generating the documentation In rare cases you may need to disable a particular finding reported by one of these tools, see :ref:`ptb_troubleshooting`. -.. _Poetry: https://python-poetry.org -.. _Nox: https://nox.thea.codes/en/stable/ +.. _Bandit: https://bandit.readthedocs.io/en/latest/ .. _Black: https://black.readthedocs.io/en/stable/ -.. _Ruff: https://docs.astral.sh/ruff -.. _Pylint: https://pylint.readthedocs.io/en/stable/ .. _Cookiecutter: https://cookiecutter.readthedocs.io/en/stable/ -.. _Mypy: https://mypy.readthedocs.io/en/stable/ .. _Coverage: https://coverage.readthedocs.io/en/7.13.4/ -.. _Bandit: https://bandit.readthedocs.io/en/latest/ -.. _Sphinx: https://www.sphinx-doc.org/en/master +.. _Mypy: https://mypy.readthedocs.io/en/stable/ +.. _Nox: https://nox.thea.codes/en/stable/ +.. _Pip Audit: https://pypi.org/project/pip-audit/ +.. _Poetry: https://python-poetry.org +.. _Pylint: https://pylint.readthedocs.io/en/stable/ +.. _Ruff: https://docs.astral.sh/ruff .. _Sonar: https://docs.sonarsource.com/sonarqube-server +.. _Sphinx: https://www.sphinx-doc.org/en/master diff --git a/exasol/toolbox/nox/_release.py b/exasol/toolbox/nox/_release.py index 27d25647e..160729220 100644 --- a/exasol/toolbox/nox/_release.py +++ b/exasol/toolbox/nox/_release.py @@ -15,7 +15,7 @@ from exasol.toolbox.nox.plugin import NoxTasks from exasol.toolbox.util.dependencies.shared_models import PoetryFiles from exasol.toolbox.util.git import Git -from exasol.toolbox.util.release.changelog import Changelogs +from exasol.toolbox.util.release.changelog import Changelog from exasol.toolbox.util.version import ( ReleaseTypes, Version, @@ -64,8 +64,8 @@ def _update_project_version(session: Session, version: Version) -> Version: return version -def _get_changelogs(version: Version) -> Changelogs: - return Changelogs( +def _get_changelogs(version: Version) -> Changelog: + return Changelog( changes_path=PROJECT_CONFIG.documentation_path / "changes", root_path=PROJECT_CONFIG.root_path, version=version, diff --git a/exasol/toolbox/util/dependencies/audit.py b/exasol/toolbox/util/dependencies/audit.py index 1983fc4f1..26bc4fbd3 100644 --- a/exasol/toolbox/util/dependencies/audit.py +++ b/exasol/toolbox/util/dependencies/audit.py @@ -32,13 +32,22 @@ @dataclass class PipAuditException(Exception): + command: list[str] + cwd: Path + env: dict[str, str] returncode: int stdout: str stderr: str @classmethod - def from_subprocess(cls, proc: subprocess.CompletedProcess) -> PipAuditException: - return cls(proc.returncode, proc.stdout, proc.stderr) + def from_subprocess( + cls, + proc: subprocess.CompletedProcess, + command: list[str], + cwd: Path, + env: dict[str, str] | None = None, + ) -> PipAuditException: + return cls(command, cwd, env or {}, proc.returncode, proc.stdout, proc.stderr) class VulnerabilitySource(str, Enum): @@ -164,24 +173,29 @@ def audit_poetry_files(working_directory: Path) -> str: """ requirements_txt = "requirements.txt" + command = ["poetry", "export", "--format=requirements.txt"] output = subprocess.run( - ["poetry", "export", "--format=requirements.txt"], + command, capture_output=True, text=True, cwd=working_directory, ) # nosec if output.returncode != 0: - raise PipAuditException.from_subprocess(output) + raise PipAuditException.from_subprocess(output, command, cwd=working_directory) with tempfile.TemporaryDirectory() as path: tmpdir = Path(path) (tmpdir / requirements_txt).write_text(output.stdout) # CLI option `--disable-pip` skips dependency resolution in pip. The - # option can be used with hashed requirements files (which is the case - # here) to avoid `pip-audit` installing an isolated environment and - # speed up the audit significantly. - command = ["pip-audit", "--disable-pip", "-r", requirements_txt, "-f", "json"] + # option can be used with hashed requirements files to avoid + # `pip-audit` installing an isolated environment and speed up the + # audit significantly. + # + # In real use scenarios of the PTB we usually have hashed + # requirements. Unfortunately this is not the case for the example + # project created in the integration tests. + command = ["pip-audit", "-r", requirements_txt, "-f", "json"] output = subprocess.run( command, capture_output=True, @@ -195,7 +209,7 @@ def audit_poetry_files(working_directory: Path) -> str: # they both map to returncode = 1, so we have our own logic to raise errors # for the case of 2) and not 1). if not search(PIP_AUDIT_VULNERABILITY_PATTERN, output.stderr.strip()): - raise PipAuditException.from_subprocess(output) + raise PipAuditException.from_subprocess(output, command, cwd=tmpdir) return output.stdout diff --git a/exasol/toolbox/util/dependencies/track_vulnerabilities.py b/exasol/toolbox/util/dependencies/track_vulnerabilities.py index 8c9b7c647..04f4497ab 100644 --- a/exasol/toolbox/util/dependencies/track_vulnerabilities.py +++ b/exasol/toolbox/util/dependencies/track_vulnerabilities.py @@ -63,8 +63,6 @@ def report_resolved_vulnerabilities(self) -> str: if not (resolved := self.resolved_vulnerabilities): return "" header = cleandoc(""" - ## Fixed Vulnerabilities - This release fixes vulnerabilities by updating dependencies: | Dependency | Vulnerability | Affected | Fixed in | diff --git a/exasol/toolbox/util/release/changelog.py b/exasol/toolbox/util/release/changelog.py index d82c0d2cc..bd3da6cff 100644 --- a/exasol/toolbox/util/release/changelog.py +++ b/exasol/toolbox/util/release/changelog.py @@ -1,17 +1,24 @@ from __future__ import annotations -import re from collections import OrderedDict +from collections.abc import Generator from datetime import datetime +from enum import Enum from inspect import cleandoc from pathlib import Path +from exasol.toolbox.util.dependencies.audit import ( + get_vulnerabilities, + get_vulnerabilities_from_latest_tag, +) from exasol.toolbox.util.dependencies.poetry_dependencies import ( get_dependencies, get_dependencies_from_latest_tag, ) from exasol.toolbox.util.dependencies.shared_models import LatestTagNotFoundError from exasol.toolbox.util.dependencies.track_changes import DependencyChanges +from exasol.toolbox.util.dependencies.track_vulnerabilities import DependenciesAudit +from exasol.toolbox.util.release.markdown import Markdown from exasol.toolbox.util.version import Version UNRELEASED_INITIAL_CONTENT = cleandoc(""" @@ -20,10 +27,21 @@ ## Summary """) + "\n" -DEPENDENCY_UPDATES = "## Dependency Updates\n" +class Section(Enum): + FEATURES = "Features" + SECURITY = "Security Issues" + BUGFIXES = "Bugfixes" + DOCCUMENTATION = "Documentation" + REFACTORINGS = "Refactorings" + DEPENDENCIES = "Dependency Updates" + + @property + def title(self) -> str: + return f"## {self.value}" -class Changelogs: + +class Changelog: def __init__(self, changes_path: Path, root_path: Path, version: Version) -> None: """ Args: @@ -35,9 +53,10 @@ def __init__(self, changes_path: Path, root_path: Path, version: Version) -> Non """ self.version = version - self.unreleased_md: Path = changes_path / "unreleased.md" - self.versioned_changelog_md: Path = changes_path / f"changes_{version}.md" - self.changelog_md: Path = changes_path / "changelog.md" + self.unreleased: Path = changes_path / "unreleased.md" + self.versioned_changes: Path = changes_path / f"changes_{version}.md" + # Accepting attribute changelog duplicating the class name + self.changelog: Path = changes_path / "changelog.md" # NOSONAR self.root_path: Path = root_path def _create_new_unreleased(self): @@ -45,33 +64,9 @@ def _create_new_unreleased(self): Write a new unreleased changelog file. """ - self.unreleased_md.write_text(UNRELEASED_INITIAL_CONTENT) - - def _create_versioned_changelog(self, unreleased_content: str) -> None: - """ - Create a versioned changes file. + self.unreleased.write_text(UNRELEASED_INITIAL_CONTENT) - Args: - unreleased_content: the content of the (not yet versioned) changes - """ - - header = f"# {self.version} - {datetime.today().strftime('%Y-%m-%d')}" - dependency_changes = self._report_dependency_changes() - template = cleandoc(f"{header}\n\n{unreleased_content}\n{dependency_changes}") - self.versioned_changelog_md.write_text(template + "\n") - - def _extract_unreleased_notes(self) -> str: - """ - Extract (not yet versioned) changes from `unreleased.md`. - """ - - with self.unreleased_md.open(mode="r", encoding="utf-8") as f: - # skip header when reading in file, as contains # Unreleased - lines = f.readlines()[1:] - unreleased_content = cleandoc("".join(lines)) - return unreleased_content + "\n" - - def _dependency_changes(self) -> str: + def _dependency_sections(self) -> Generator[Markdown]: """ Return the dependency changes between the latest tag and the current version for use in the versioned changes file in markdown @@ -79,35 +74,29 @@ def _dependency_changes(self) -> str: """ try: - previous_dependencies_in_groups = get_dependencies_from_latest_tag( - root_path=self.root_path - ) + previous_groups = get_dependencies_from_latest_tag(root_path=self.root_path) except LatestTagNotFoundError: # In new projects, there is not a pre-existing tag, and all dependencies # are considered new. - previous_dependencies_in_groups = OrderedDict() - - current_dependencies_in_groups = get_dependencies( - working_directory=self.root_path - ) - - changes_by_group: list[str] = [] - # dict.keys() returns a set - all_groups = ( - previous_dependencies_in_groups.keys() - | current_dependencies_in_groups.keys() - ) + previous_groups = OrderedDict() + + current_groups = get_dependencies(working_directory=self.root_path) + all_groups = previous_groups.keys() | current_groups.keys() + for group in self._sort_groups(all_groups): - previous_dependencies = previous_dependencies_in_groups.get(group, {}) - current_dependencies = current_dependencies_in_groups.get(group, {}) - changes = DependencyChanges( - previous_dependencies=previous_dependencies, - current_dependencies=current_dependencies, - ).changes - if changes: - changes_str = "\n".join(str(change) for change in changes) - changes_by_group.append(f"\n### `{group}`\n\n{changes_str}\n") - return "".join(changes_by_group) + previous = previous_groups.get(group, {}) + current = current_groups.get(group, {}) + if changes := DependencyChanges( + previous_dependencies=previous, + current_dependencies=current, + ).changes: + items = "\n".join(str(change) for change in changes) + yield Markdown(f"### `{group}`", items=items) + + def _dependency_changes(self) -> Markdown | None: + if sections := list(self._dependency_sections()): + return Markdown(Section.DEPENDENCIES.title, children=sections) + return None @staticmethod def _sort_groups(groups: set[str]) -> list[str]: @@ -131,7 +120,7 @@ def _update_table_of_contents(self) -> None: to the relevant sections, and write the updated changelog.md again. """ updated_content = [] - with self.changelog_md.open(mode="r", encoding="utf-8") as f: + with self.changelog.open(mode="r", encoding="utf-8") as f: for line in f: updated_content.append(line) if line.startswith("* [unreleased]"): @@ -142,29 +131,45 @@ def _update_table_of_contents(self) -> None: updated_content.append(f"changes_{self.version}\n") updated_content_str = "".join(updated_content) - self.changelog_md.write_text(updated_content_str) + self.changelog.write_text(updated_content_str) def get_changed_files(self) -> list[Path]: - return [self.unreleased_md, self.versioned_changelog_md, self.changelog_md] - - def _report_dependency_changes(self) -> str: - if changes := self._dependency_changes(): - return f"{DEPENDENCY_UPDATES}{changes}" - return "" + return [self.unreleased, self.versioned_changes, self.changelog] - def update_latest(self) -> Changelogs: + def _resolved_vulnerabilities(self) -> Markdown | None: + try: + previous = get_vulnerabilities_from_latest_tag(self.root_path) + except LatestTagNotFoundError: + previous = [] + report = DependenciesAudit( + previous_vulnerabilities=previous, + current_vulnerabilities=get_vulnerabilities(self.root_path), + ).report_resolved_vulnerabilities() + if report: + return Markdown(Section.SECURITY.title, report) + else: + return None + + def _create_versioned_changes(self, initial_content: str) -> None: """ - Update the updated dependencies in the latest versioned changelog. + Create a versioned changes file. + + Args: + initial_content: the content of the (not yet versioned) changes """ - content = self.versioned_changelog_md.read_text() - flags = re.DOTALL | re.MULTILINE - stripped = re.sub(r"^{DEPENDENCY_UPDATES}.*", "", content, flags=flags) - dependency_changes = self._report_dependency_changes() - self.versioned_changelog_md.write_text(f"{stripped}\n{dependency_changes}") - return self + versioned = Markdown.from_text(initial_content) + versioned.title = f"# {self.version} - {datetime.today().strftime('%Y-%m-%d')}" + if dependency_changes := self._dependency_changes(): + versioned.replace_or_append_child(dependency_changes) + if resolved_vulnerabilities := self._resolved_vulnerabilities(): + if section := versioned.child(resolved_vulnerabilities.title): + section.intro = resolved_vulnerabilities.intro + else: + versioned.add_child(resolved_vulnerabilities) + self.versioned_changes.write_text(versioned.rendered) - def prepare_release(self) -> Changelogs: + def prepare_release(self) -> Changelog: """ Rotates the changelogs as is needed for a release. @@ -173,11 +178,19 @@ def prepare_release(self) -> Changelogs: 3. Updates the table of contents in the `changelog.md` with the new `changes_.md` """ - # create versioned changelog - unreleased_content = self._extract_unreleased_notes() - self._create_versioned_changelog(unreleased_content) + content = self.unreleased.read_text() + self._create_versioned_changes(content) # update other changelogs now that versioned changelog exists self._create_new_unreleased() self._update_table_of_contents() return self + + def update_latest(self) -> Changelog: + """ + Update the updated dependencies in the latest versioned changelog. + """ + + content = self.versioned_changes.read_text() + self._create_versioned_changes(content) + return self diff --git a/project-template/{{cookiecutter.repo_name}}/pyproject.toml b/project-template/{{cookiecutter.repo_name}}/pyproject.toml index 33dcc0710..846847f94 100644 --- a/project-template/{{cookiecutter.repo_name}}/pyproject.toml +++ b/project-template/{{cookiecutter.repo_name}}/pyproject.toml @@ -33,6 +33,9 @@ include = [ "exasol/toolbox/templates/**/*" ] +[tool.poetry.requires-plugins] +poetry-plugin-export = ">=1.8" + [poetry.urls] repository = "https://github.com/exasol/{{cookiecutter.repo_name}}" homepage = "https://github.com/exasol/{{cookiecutter.repo_name}}" diff --git a/test/integration/project-template/conftest.py b/test/integration/project-template/conftest.py index eadced1a5..979ee7e0b 100644 --- a/test/integration/project-template/conftest.py +++ b/test/integration/project-template/conftest.py @@ -1,3 +1,4 @@ +import logging import subprocess from pathlib import Path @@ -5,6 +6,8 @@ from noxconfig import PROJECT_CONFIG +LOG = logging.getLogger(__name__) + @pytest.fixture(scope="session", autouse=True) def cwd(tmp_path_factory): @@ -36,7 +39,6 @@ def new_project(cwd): capture_output=True, check=True, ) - return cwd / repo_name @@ -70,15 +72,37 @@ def run_command(poetry_path, git_path, new_project): """ def _run_command_fixture(command, **kwargs): + cwd = new_project + env = {"PATH": f"{Path(git_path).parent}:{Path(poetry_path).parent}"} defaults = { "capture_output": True, - "check": True, - "cwd": new_project, - "env": {"PATH": f"{Path(git_path).parent}:{Path(poetry_path).parent}"}, + "cwd": cwd, + "env": env, "text": True, } - config = {**defaults, **kwargs} - - return subprocess.run(command, **config) + config = {**defaults, **kwargs, "check": False} + p = subprocess.run(command, **config) + if p.returncode != 0: + + def text(stream) -> str: + return "" if stream is None else stream.strip() + + message = ( + f"subprocess.run() returned exit code: {p.returncode}" + f"\ncommand: {' '.join(command)}" + f"\nstdout: {text(p.stdout)}" + f"\nstderr: {text(p.stderr)}" + f"\ncwd: {cwd}" + f"\nenv: {env}" + ) + LOG.warning(message) + if kwargs.get("check", True): + raise subprocess.CalledProcessError( + p.returncode, + command, + output=p.stdout, + stderr=p.stderr, + ) + return p return _run_command_fixture diff --git a/test/unit/util/dependencies/track_vulnerabilities_test.py b/test/unit/util/dependencies/track_vulnerabilities_test.py index 4feeaec05..bff6eae71 100644 --- a/test/unit/util/dependencies/track_vulnerabilities_test.py +++ b/test/unit/util/dependencies/track_vulnerabilities_test.py @@ -29,15 +29,34 @@ def flipped_id_vulnerability(sample_vulnerability) -> Vulnerability: ) +PKG_DATA = {"name": "cryptography", "version": "46.0.6"} + +VULN_1 = Vulnerability( + package=PKG_DATA, + id="GHSA-m959-cc7f-wv43", + aliases=["CVE-2026-34073"], + fix_versions=["46.0.6"], + description="Dummy description", +) + +VULN_2 = Vulnerability( + package=PKG_DATA, + id="GHSA-p423-j2cm-9vmq", + aliases=["CVE-2026-39892"], + fix_versions=["46.0.7"], + description="Dummy description", +) + + class TestVulnerabilityMatcher: - def test_not_resolved(self, sample_vulnerability): + @staticmethod + def test_not_resolved(sample_vulnerability): vuln = sample_vulnerability.vulnerability matcher = VulnerabilityMatcher(current_vulnerabilities=[vuln]) assert not matcher.is_resolved(vuln) - def test_changed_id_not_resolved( - self, sample_vulnerability, flipped_id_vulnerability - ): + @staticmethod + def test_changed_id_not_resolved(sample_vulnerability, flipped_id_vulnerability): """ Simulate a vulnerability to be still present, but its ID having changed over time. @@ -51,38 +70,30 @@ def test_changed_id_not_resolved( ) assert not matcher.is_resolved(sample_vulnerability.vulnerability) - def test_resolved(self, sample_vulnerability): + @staticmethod + def test_resolved(sample_vulnerability): vuln = sample_vulnerability.vulnerability matcher = VulnerabilityMatcher(current_vulnerabilities=[]) assert matcher.is_resolved(vuln) - def test_no_resolution_same_package(self): + @staticmethod + @pytest.mark.parametrize( + "current_vulnerabilities", + [ + [VULN_1, VULN_2], + [VULN_2], + ], + ) + def test_no_resolution_same_package(current_vulnerabilities): """ - Scenario: 'cryptography' has two vulnerabilities. - One is resolved (removed from the current list), the other remains. + Two vulnerabilities in the same package 'cryptography', none of + them resolved. """ - pkg_data = {"name": "cryptography", "version": "46.0.6"} - - vuln_1 = Vulnerability( - package=pkg_data, - id="GHSA-m959-cc7f-wv43", - aliases=["CVE-2026-34073"], - fix_versions=["46.0.6"], - description="Dummy description", - ) - - vuln_2 = Vulnerability( - package=pkg_data, - id="GHSA-p423-j2cm-9vmq", - aliases=["CVE-2026-39892"], - fix_versions=["46.0.7"], - description="Dummy description", - ) - - matcher = VulnerabilityMatcher(current_vulnerabilities=[vuln_1, vuln_2]) - assert matcher.is_resolved(vuln_1) is False - assert matcher.is_resolved(vuln_2) is False + matcher = VulnerabilityMatcher(current_vulnerabilities=current_vulnerabilities) + for v in (VULN_1, VULN_2): + expected = not v in current_vulnerabilities + assert matcher.is_resolved(v) is expected class TestDependenciesAudit: @@ -100,7 +111,7 @@ def test_vulnerability_in_current_but_not_present(self, sample_vulnerability): # only care about "resolved" vulnerabilities, not new ones assert audit.resolved_vulnerabilities == [] - def test_resolved_vulnerabilities(self, sample_vulnerability): + def test_resolved_vulnerability(self, sample_vulnerability): audit = DependenciesAudit( previous_vulnerabilities=[sample_vulnerability.vulnerability], current_vulnerabilities=[], diff --git a/test/unit/util/release/changelog_test.py b/test/unit/util/release/changelog_test.py index f2c5c5de1..42d5f3bbe 100644 --- a/test/unit/util/release/changelog_test.py +++ b/test/unit/util/release/changelog_test.py @@ -1,4 +1,11 @@ +from __future__ import annotations + +from collections.abc import Callable from datetime import datetime +from enum import ( + Flag, + auto, +) from inspect import cleandoc from unittest.mock import Mock @@ -8,25 +15,32 @@ from exasol.toolbox.util.dependencies.shared_models import LatestTagNotFoundError from exasol.toolbox.util.release.changelog import ( UNRELEASED_INITIAL_CONTENT, - Changelogs, + Changelog, ) +from exasol.toolbox.util.release.markdown import Markdown from exasol.toolbox.util.version import Version +def _markdown(content: str) -> Markdown: + return Markdown.from_text(cleandoc(content)) + + class SampleContent: - changelog = "\n" + cleandoc(""" + unreleased = _markdown(""" + # Unreleased + ## Summary Summary of changes. - ## Added - * Added Awesome feature - - ## Changed - * Some behaviour + ## Features + * Added awesome feature - ## Fixed + ## Bugfixes * Fixed nasty bug - """) - changes = cleandoc(""" + + ## Refactorings + * Some refactoring + """).rendered + old_changelog = cleandoc(""" # Changelog * [unreleased](unreleased.md) @@ -40,7 +54,7 @@ class SampleContent: changes_0.1.0 ``` """) - altered_changes = cleandoc(""" + new_changelog = cleandoc(""" # Changelog * [unreleased](unreleased.md) @@ -58,137 +72,202 @@ class SampleContent: """) -def expected_changes_file_content(with_dependencies: bool = False): - header = cleandoc(f""" - # 1.0.0 - {datetime.today().strftime('%Y-%m-%d')} +class Expect(Flag): + NONE = auto() + VULNERABILITIES = auto() + VULNERABILITIES_INSERTED = auto() + DEPENDENCIES = auto() + + +def expected_changes( + unreleased: str = SampleContent.unreleased, + include: Expect = Expect.NONE, +) -> Markdown: + dependencies = _markdown(f""" + ## Dependency Updates + ### `main` + * Updated dependency `package1:0.0.1` to `0.1.0` + ### `dev` + * Added dependency `package2:0.2.0` + """) + + vulnerabilities = _markdown(""" + ## Security Issues + + This release fixes vulnerabilities by updating dependencies: + + | Dependency | Vulnerability | Affected | Fixed in | + |------------|---------------|----------|----------| + | jinja2 | CVE-2025-27516 | 3.1.5 | 3.1.6 | + """) + changes = Markdown.from_text(unreleased) + changes.title = f"# 1.0.0 - {datetime.today().strftime('%Y-%m-%d')}" + if Expect.VULNERABILITIES in include: + changes.add_child(vulnerabilities) + if Expect.VULNERABILITIES_INSERTED in include: + changes.child("## Security Issues").intro = vulnerabilities.intro + if Expect.DEPENDENCIES in include: + changes.replace_or_append_child(dependencies) + return changes - ## Summary - Summary of changes. +@pytest.fixture(scope="function") +def changelog(tmp_path) -> Changelog: + changes_path = tmp_path / "doc/changes" + changes_path.mkdir(parents=True) + return Changelog( + changes_path=changes_path, + root_path=tmp_path, + version=Version(major=1, minor=0, patch=0), + ) - ## Added - * Added Awesome feature - ## Changed - * Some behaviour +@pytest.fixture(scope="function") +def changelog_md(changelog): + changelog.changelog.write_text(SampleContent.old_changelog) - ## Fixed - * Fixed nasty bug - """) - dependencies = cleandoc(f""" - ## Dependency Updates - ### `main` +@pytest.fixture(scope="function") +def unreleased_md(changelog): + changelog.unreleased.write_text(SampleContent.unreleased) + - * Updated dependency `package1:0.0.1` to `0.1.0` +@pytest.fixture(scope="function") +def unreleased_with_security_issues(changelog) -> str: + security = _markdown(""" + ## Security Issues + * #123: Fixed vulnerability + """) + md = _markdown(SampleContent.unreleased).add_child(security) + changelog.unreleased.write_text(md.rendered) + return md.rendered - ### `dev` - * Added dependency `package2:0.2.0` - """) - return f"{header}\n\n{dependencies}\n" if with_dependencies else f"{header}\n" +DependencyChanges = tuple[Mock | dict, Mock | dict] | None +VulnerabilityChanges = tuple[Mock | dict, Mock | dict] | None @pytest.fixture(scope="function") -def changes_md(changelogs): - changelogs.changelog_md.write_text(SampleContent.changes) +def dependency_changes(previous_dependencies, dependencies) -> DependencyChanges: + return (previous_dependencies, dependencies) + + +@pytest.fixture +def mock_changelog( + monkeypatch, +) -> Callable[[DependencyChanges, VulnerabilityChanges], None]: + """ + Enable simulating pecific changes in dependencies or vulnerabilities + between the latest tag (last release) and the current release. + """ + + def mock( + dependencies: DependencyChanges = None, + vulnerabilities: VulnerabilityChanges = None, + ): + dependencies = dependencies or ({}, {}) + vulnerabilities = vulnerabilities or ([], []) + for func, value in ( + ("get_dependencies_from_latest_tag", dependencies[0]), + ("get_dependencies", dependencies[1]), + ("get_vulnerabilities_from_latest_tag", vulnerabilities[0]), + ("get_vulnerabilities", vulnerabilities[1]), + ): + mock = value if isinstance(value, Mock) else Mock(return_value=value) + monkeypatch.setattr(impl, func, mock) + + return mock @pytest.fixture(scope="function") -def unreleased_md(changelogs): - changelogs.unreleased_md.write_text( - UNRELEASED_INITIAL_CONTENT + SampleContent.changelog - ) +def mock_no_dependencies(mock_changelog): + mock_changelog() -def mock_changelog(monkeypatch, old_dependencies, new_dependencies): - for func, value in ( - ("get_dependencies_from_latest_tag", old_dependencies), - ("get_dependencies", new_dependencies), - ): - mock = value if isinstance(value, Mock) else Mock(return_value=value) - monkeypatch.setattr(impl, func, mock) +@pytest.fixture(scope="function") +def mock_dependencies(mock_changelog, dependency_changes): + mock_changelog(dependencies=dependency_changes) @pytest.fixture(scope="function") -def mock_dependencies(monkeypatch, previous_dependencies, dependencies): - mock_changelog(monkeypatch, previous_dependencies, dependencies) +def vulnerability_changes(sample_vulnerability) -> VulnerabilityChanges: + """ + Simulate resolved vulnerabilities. + """ + + previous = [sample_vulnerability.vulnerability] + current = [] + return (previous, current) @pytest.fixture(scope="function") -def mock_new_dependencies(monkeypatch, dependencies): - mock_changelog(monkeypatch, Mock(side_effect=LatestTagNotFoundError), dependencies) +def mock_vulnerabilties(mock_changelog, vulnerability_changes): + mock_changelog(vulnerabilities=vulnerability_changes) @pytest.fixture(scope="function") -def mock_no_dependencies(monkeypatch): - mock_changelog(monkeypatch, {}, {}) +def mock_dependencies_and_vulnerabilties( + mock_changelog, dependency_changes, vulnerability_changes +): + mock_changelog(dependency_changes, vulnerability_changes) @pytest.fixture(scope="function") -def changelogs(tmp_path) -> Changelogs: - changes_path = tmp_path / "doc/changes" - changes_path.mkdir(parents=True) - return Changelogs( - changes_path=changes_path, - root_path=tmp_path, - version=Version(major=1, minor=0, patch=0), +def mock_new_dependencies(mock_changelog, dependencies): + mock_changelog( + dependencies=(Mock(side_effect=LatestTagNotFoundError), dependencies) ) -class TestChangelogs: +class TestChangelog: """ - As some methods in the class `Changelogs` modify files, it is required that the - fixtures which create the sample files (changelog.md, unreleased.md, & changes_1.0.0.md) - reset per function and use `tmp_path`. By doing this, we ensure that the sample - are in their expected state for each test. + As some methods in the class `Changelog` modify files, it is required + that the fixtures which create the sample files (changelog.md, + unreleased.md, & changes_1.0.0.md) reset per function and use + `tmp_path`. By doing this, we ensure that the sample are in their expected + state for each test. """ @staticmethod - def test_create_new_unreleased(changelogs): - changelogs._create_new_unreleased() + def test_create_new_unreleased(changelog): + changelog._create_new_unreleased() - assert changelogs.unreleased_md.read_text() == UNRELEASED_INITIAL_CONTENT + assert changelog.unreleased.read_text() == UNRELEASED_INITIAL_CONTENT @staticmethod - def test_create_versioned_changelog(changelogs, mock_dependencies): - changelogs._create_versioned_changelog(SampleContent.changelog) - saved_text = changelogs.versioned_changelog_md.read_text() + def test_create_versioned_changes(changelog, mock_dependencies): + changelog._create_versioned_changes(SampleContent.unreleased) + saved_text = changelog.versioned_changes.read_text() assert "1.0.0" in saved_text - assert SampleContent.changelog in saved_text + unreleased_body = "\n".join(SampleContent.unreleased.splitlines()[1:]) + assert unreleased_body in saved_text @staticmethod - def test_extract_unreleased_notes(changelogs, unreleased_md): - actual = changelogs._extract_unreleased_notes() - expected = "## Summary\n" + SampleContent.changelog + "\n" - assert actual == expected - - @staticmethod - def test_dependency_changes(changelogs, mock_dependencies): - result = changelogs._dependency_changes() - assert result == ( - "\n" - "### `main`\n\n" - "* Updated dependency `package1:0.0.1` to `0.1.0`\n" - "\n" - "### `dev`\n\n" - "* Added dependency `package2:0.2.0`\n" - ) + def test_dependency_changes(changelog, mock_dependencies): + actual = changelog._dependency_changes() + expected = _markdown(""" + ## Dependency Updates + ### `main` + * Updated dependency `package1:0.0.1` to `0.1.0` + ### `dev` + * Added dependency `package2:0.2.0` + """) + assert expected == actual @staticmethod def test_dependency_changes_without_latest_version( - changelogs, mock_new_dependencies + changelog, mock_new_dependencies ): - result = changelogs._dependency_changes() - assert result == ( - "\n" - "### `main`\n\n" - "* Added dependency `package1:0.1.0`\n" - "\n" - "### `dev`\n\n" - "* Added dependency `package2:0.2.0`\n" - ) + actual = changelog._dependency_changes() + expected = _markdown(""" + ## Dependency Updates + ### `main` + * Added dependency `package1:0.1.0` + ### `dev` + * Added dependency `package2:0.2.0` + """) + assert expected == actual @staticmethod @pytest.mark.parametrize( @@ -202,47 +281,72 @@ def test_dependency_changes_without_latest_version( ), ], ) - def test_sort_groups(changelogs, groups, expected): - result = changelogs._sort_groups(groups) + def test_sort_groups(changelog, groups, expected): + result = changelog._sort_groups(groups) assert result == expected @staticmethod - def test_update_table_of_contents(changelogs, changes_md): - changelogs._update_table_of_contents() - - assert changelogs.changelog_md.read_text() == SampleContent.altered_changes + def test_update_table_of_contents(changelog, changelog_md): + changelog._update_table_of_contents() + assert changelog.changelog.read_text() == SampleContent.new_changelog @staticmethod - def test_prepare_release(changelogs, mock_dependencies, unreleased_md, changes_md): - changelogs.prepare_release() - assert changelogs.changelog_md.read_text() == SampleContent.altered_changes - assert changelogs.unreleased_md.read_text() == UNRELEASED_INITIAL_CONTENT - versioned = changelogs.versioned_changelog_md.read_text() - assert versioned == expected_changes_file_content(with_dependencies=True) + def test_prepare_release( + changelog, + mock_dependencies_and_vulnerabilties, + unreleased_md, + changelog_md, + ): + changelog.prepare_release() + assert changelog.changelog.read_text() == SampleContent.new_changelog + assert changelog.unreleased.read_text() == UNRELEASED_INITIAL_CONTENT + versioned = Markdown.read(changelog.versioned_changes) + expected = expected_changes( + include=Expect.DEPENDENCIES | Expect.VULNERABILITIES + ) + assert versioned == expected @staticmethod def test_update_latest( - monkeypatch, - mock_no_dependencies, - previous_dependencies, - dependencies, - changelogs, + mock_changelog, + dependency_changes, + vulnerability_changes, + changelog, unreleased_md, - changes_md, + changelog_md, ): - changelogs.prepare_release() - mock_changelog(monkeypatch, previous_dependencies, dependencies) - changelogs.update_latest() - versioned = changelogs.versioned_changelog_md.read_text() - assert versioned == expected_changes_file_content(with_dependencies=True) + mock_changelog() + changelog.prepare_release() + mock_changelog(dependency_changes, vulnerability_changes) + changelog.update_latest() + versioned = Markdown.read(changelog.versioned_changes) + expected = expected_changes( + include=Expect.DEPENDENCIES | Expect.VULNERABILITIES + ) + assert versioned == expected @staticmethod def test_prepare_release_with_no_dependencies( - changelogs, mock_no_dependencies, unreleased_md, changes_md + changelog, mock_no_dependencies, unreleased_md, changelog_md ): - changelogs.prepare_release() + changelog.prepare_release() + + assert changelog.changelog.read_text() == SampleContent.new_changelog + assert changelog.unreleased.read_text() == UNRELEASED_INITIAL_CONTENT + versioned = Markdown.read(changelog.versioned_changes) + assert versioned == expected_changes() - assert changelogs.changelog_md.read_text() == SampleContent.altered_changes - assert changelogs.unreleased_md.read_text() == UNRELEASED_INITIAL_CONTENT - versioned = changelogs.versioned_changelog_md.read_text() - assert versioned == expected_changes_file_content() + @staticmethod + def test_prepare_release_with_security_issues( + changelog, + mock_vulnerabilties, + unreleased_with_security_issues, + changelog_md, + ): + changelog.prepare_release() + versioned = Markdown.read(changelog.versioned_changes) + expected = expected_changes( + unreleased_with_security_issues, + include=Expect.VULNERABILITIES_INSERTED, + ) + assert versioned == expected