diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f2976d5dc..39cfa0204 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -168,8 +168,6 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: read - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} strategy: fail-fast: false matrix: ${{ fromJson(needs.build-matrix.outputs.matrix) }} diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 1c76e6060..92b44b712 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -1,5 +1,9 @@ # Unreleased +## Feature + +* #382: Added onto nox session `release:prepare` to append dependency changes between current and latest tag + ## Refactoring * #498: Centralized changelog code relevant for `release:trigger` & robustly tested diff --git a/exasol/toolbox/nox/_artifacts.py b/exasol/toolbox/nox/_artifacts.py index 1bab4cbf5..92e2f667a 100644 --- a/exasol/toolbox/nox/_artifacts.py +++ b/exasol/toolbox/nox/_artifacts.py @@ -7,10 +7,7 @@ import sys from collections.abc import Iterable from pathlib import Path -from typing import ( - Optional, - Union, -) +from typing import Optional import nox from nox import Session @@ -188,7 +185,7 @@ def _copy_artifacts(source: Path, dest: Path, files: Iterable[str]): def _prepare_coverage_xml( - session: Session, source: Path, cwd: Union[Path, None] = None + session: Session, source: Path, cwd: Optional[Path] = None ) -> None: """ Prepare the coverage XML for input into Sonar diff --git a/exasol/toolbox/nox/_dependencies.py b/exasol/toolbox/nox/_dependencies.py index 57ac71891..adf0167ef 100644 --- a/exasol/toolbox/nox/_dependencies.py +++ b/exasol/toolbox/nox/_dependencies.py @@ -9,8 +9,8 @@ from nox import Session from exasol.toolbox.util.dependencies.licenses import ( - licenses, - packages_to_markdown, + PackageLicenseReport, + get_licenses, ) from exasol.toolbox.util.dependencies.poetry_dependencies import get_dependencies @@ -85,8 +85,11 @@ def run(self, session: Session) -> None: def dependency_licenses(session: Session) -> None: """Return the packages with their licenses""" dependencies = get_dependencies(working_directory=Path()) - package_infos = licenses() - print(packages_to_markdown(dependencies=dependencies, packages=package_infos)) + licenses = get_licenses() + license_markdown = PackageLicenseReport( + dependencies=dependencies, licenses=licenses + ) + print(license_markdown.to_markdown()) @nox.session(name="dependency:audit", python=False) diff --git a/exasol/toolbox/nox/_release.py b/exasol/toolbox/nox/_release.py index 42482914e..c3bf1a11e 100644 --- a/exasol/toolbox/nox/_release.py +++ b/exasol/toolbox/nox/_release.py @@ -119,7 +119,9 @@ def prepare_release(session: Session) -> None: _ = _update_project_version(session, new_version) changelogs = Changelogs( - changes_path=PROJECT_CONFIG.doc / "changes", version=new_version + changes_path=PROJECT_CONFIG.doc / "changes", + root_path=PROJECT_CONFIG.root, + version=new_version, ) changelogs.update_changelogs_for_release() changed_files = changelogs.get_changed_files() diff --git a/exasol/toolbox/templates/github/workflows/checks.yml b/exasol/toolbox/templates/github/workflows/checks.yml index d4ae5cdf1..9e1a6efd4 100644 --- a/exasol/toolbox/templates/github/workflows/checks.yml +++ b/exasol/toolbox/templates/github/workflows/checks.yml @@ -166,8 +166,6 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: read - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} strategy: fail-fast: false matrix: ${{ fromJson(needs.build-matrix.outputs.matrix) }} diff --git a/exasol/toolbox/util/dependencies/licenses.py b/exasol/toolbox/util/dependencies/licenses.py index 25a3ac17c..53e219682 100644 --- a/exasol/toolbox/util/dependencies/licenses.py +++ b/exasol/toolbox/util/dependencies/licenses.py @@ -2,13 +2,18 @@ import subprocess import tempfile +from collections import OrderedDict +from dataclasses import dataclass from inspect import cleandoc from json import loads from typing import Optional from pydantic import field_validator -from exasol.toolbox.util.dependencies.shared_models import Package +from exasol.toolbox.util.dependencies.shared_models import ( + NormalizedPackageStr, + Package, +) LICENSE_MAPPING_TO_ABBREVIATION = { "BSD License": "BSD", @@ -88,20 +93,23 @@ def select_most_restrictive(licenses: list[str]) -> str: return LICENSE_MAPPING_TO_ABBREVIATION.get(_license, _license) -def _packages_from_json(json: str) -> list[PackageLicense]: +def _packages_from_json(json: str) -> dict[NormalizedPackageStr, PackageLicense]: packages = loads(json) - return [ - PackageLicense( - name=package["Name"], - package_link=package["URL"], - version=package["Version"], - license=package["License"], - ) + return { + package_license.normalized_name: package_license for package in packages - ] + if ( + package_license := PackageLicense( + name=package["Name"], + package_link=package["URL"], + version=package["Version"], + license=package["License"], + ) + ) + } -def licenses() -> list[PackageLicense]: +def get_licenses() -> dict[NormalizedPackageStr, PackageLicense]: with tempfile.NamedTemporaryFile() as file: subprocess.run( [ @@ -117,62 +125,51 @@ def licenses() -> list[PackageLicense]: return _packages_from_json(file.read().decode()) -def packages_to_markdown( - dependencies: dict[str, list], packages: list[PackageLicense] -) -> str: - def heading(): - return "# Dependencies\n" +@dataclass(frozen=True) +class PackageLicenseReport: + dependencies: OrderedDict[str, dict[NormalizedPackageStr, Package]] + licenses: dict[NormalizedPackageStr, PackageLicense] - def dependency( - group: str, - group_packages: list[Package], - packages: list[PackageLicense], - ) -> str: - def _header(_group: str): - _group = "".join([word.capitalize() for word in _group.strip().split()]) - text = f"## {_group} Dependencies\n" - text += "|Package|Version|License|\n" - text += "|---|---|---|\n" - return text - - def _rows( - _group_packages: list[Package], _packages: list[PackageLicense] - ) -> str: - text = "" - for package in _group_packages: - consistent = filter( - lambda elem: elem.normalized_name == package.normalized_name, - _packages, - ) - for content in consistent: - if content.package_link: - text += f"|[{content.name}]({content.package_link})" - else: - text += f"|{content.name}" - text += f"|{content.version}" - if content.license_link: - text += f"|[{content.license}]({content.license_link})|\n" - else: - text += f"|{content.license}|\n" - text += "\n" - return text - - _template = cleandoc( + @staticmethod + def _format_group_table_header(group: str) -> str: + return cleandoc( + f"""## `{group}` Dependencies + |Package|Version|License| + |---|---|---| """ - {header}{rows} - """ ) - return _template.format( - header=_header(group), rows=_rows(group_packages, packages) - ) - - template = cleandoc( - """ - {heading}{rows} - """ - ) - rows = "" - for group in dependencies: - rows += dependency(group, dependencies[group], packages) - return template.format(heading=heading(), rows=rows) + def _format_group_table( + self, group: str, group_package_names: set[NormalizedPackageStr] + ) -> str: + group_header = self._format_group_table_header(group=group) + + rows = [] + for package_name in sorted(group_package_names): + if license_info := self.licenses.get(package_name): + rows.append(self._format_table_row(license_info=license_info)) + + return f"""{group_header}\n{''.join(rows)}\n""" + + @staticmethod + def _format_table_row(license_info: PackageLicense) -> str: + row_package = f"{license_info.name}" + if license_info.package_link: + row_package = f"[{license_info.name}]({license_info.package_link})" + + row_license = f"{license_info.license}" + if license_info.license_link: + row_license = f"[{license_info.license}]({license_info.license_link})" + + return f"|{row_package}|{license_info.version}|{row_license}|\n" + + def to_markdown(self) -> str: + rows = [] + for group in self.dependencies: + group_package_names = set(self.dependencies[group].keys()) + rows.append( + self._format_group_table( + group=group, group_package_names=group_package_names + ) + ) + return cleandoc(f"""# Dependencies\n\n{''.join(rows)}""") diff --git a/exasol/toolbox/util/dependencies/poetry_dependencies.py b/exasol/toolbox/util/dependencies/poetry_dependencies.py index 768f1ffa4..00faadaab 100644 --- a/exasol/toolbox/util/dependencies/poetry_dependencies.py +++ b/exasol/toolbox/util/dependencies/poetry_dependencies.py @@ -2,6 +2,7 @@ import subprocess import tempfile +from collections import OrderedDict from pathlib import Path from typing import Optional @@ -12,7 +13,10 @@ ) from tomlkit import TOMLDocument -from exasol.toolbox.util.dependencies.shared_models import Package +from exasol.toolbox.util.dependencies.shared_models import ( + NormalizedPackageStr, + Package, +) from exasol.toolbox.util.git import Git @@ -92,16 +96,20 @@ def _extract_from_line(line: str) -> Optional[Package]: return None return Package(name=split_line[0], version=split_line[1]) - def _extract_from_poetry_show(self, output_text: str) -> list[Package]: - return [ - package + def _extract_from_poetry_show( + self, output_text: str + ) -> dict[NormalizedPackageStr, Package]: + return { + package.normalized_name: package for line in output_text.splitlines() if (package := self._extract_from_line(line)) - ] + } @property - def direct_dependencies(self) -> dict[str, list[Package]]: - dependencies = {} + def direct_dependencies( + self, + ) -> OrderedDict[str, dict[NormalizedPackageStr, Package]]: + dependencies = OrderedDict() for group in self.groups: command = ( "poetry", @@ -122,7 +130,7 @@ def direct_dependencies(self) -> dict[str, list[Package]]: return dependencies @property - def all_dependencies(self) -> dict[str, list[Package]]: + def all_dependencies(self) -> OrderedDict[str, dict[NormalizedPackageStr, Package]]: command = ("poetry", "show", "--no-truncate") output = subprocess.run( command, @@ -133,28 +141,33 @@ def all_dependencies(self) -> dict[str, list[Package]]: ) direct_dependencies = self.direct_dependencies.copy() - transitive_dependencies = [] + + transitive_dependencies = {} names_direct_dependencies = { - dep.name - for group_list in direct_dependencies.values() - for dep in group_list + package_name + for group_list in direct_dependencies + for package_name in group_list } for line in output.stdout.splitlines(): dep = self._extract_from_line(line=line) if dep and dep.name not in names_direct_dependencies: - transitive_dependencies.append(dep) + transitive_dependencies[dep.normalized_name] = dep return direct_dependencies | {TRANSITIVE_GROUP.name: transitive_dependencies} -def get_dependencies(working_directory: Path) -> dict[str, list[Package]]: +def get_dependencies( + working_directory: Path, +) -> OrderedDict[str, dict[NormalizedPackageStr, Package]]: poetry_dep = PoetryToml.load_from_toml(working_directory=working_directory) return PoetryDependencies( groups=poetry_dep.groups, working_directory=working_directory ).direct_dependencies -def get_dependencies_from_latest_tag() -> dict[str, list[Package]]: +def get_dependencies_from_latest_tag() -> ( + OrderedDict[str, dict[NormalizedPackageStr, Package]] +): latest_tag = Git.get_latest_tag() with tempfile.TemporaryDirectory() as path: tmpdir = Path(path) diff --git a/exasol/toolbox/util/dependencies/shared_models.py b/exasol/toolbox/util/dependencies/shared_models.py index f7e751458..a4df1e699 100644 --- a/exasol/toolbox/util/dependencies/shared_models.py +++ b/exasol/toolbox/util/dependencies/shared_models.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import Annotated +from typing import ( + Annotated, + NewType, +) from packaging.version import Version from pydantic import ( @@ -9,9 +12,15 @@ ConfigDict, ) +NormalizedPackageStr = NewType("NormalizedPackageStr", str) + VERSION_TYPE = Annotated[str, AfterValidator(lambda v: Version(v))] +def normalize_package_name(package_name: str) -> NormalizedPackageStr: + return NormalizedPackageStr(package_name.lower().replace("_", "-")) + + class Package(BaseModel): model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) @@ -19,5 +28,5 @@ class Package(BaseModel): version: VERSION_TYPE @property - def normalized_name(self) -> str: - return self.name.lower().replace("_", "-") + def normalized_name(self) -> NormalizedPackageStr: + return normalize_package_name(self.name) diff --git a/exasol/toolbox/util/dependencies/track_changes.py b/exasol/toolbox/util/dependencies/track_changes.py new file mode 100644 index 000000000..a684252a2 --- /dev/null +++ b/exasol/toolbox/util/dependencies/track_changes.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import Optional + +from packaging.version import Version +from pydantic import ( + BaseModel, + ConfigDict, +) + +from exasol.toolbox.util.dependencies.shared_models import ( + NormalizedPackageStr, + Package, +) + + +class DependencyChange(BaseModel): + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + name: str + + +class AddedDependency(DependencyChange): + version: Version + + def __str__(self) -> str: + return f"* Added dependency `{self.name}:{self.version}`" + + @classmethod + def from_package(cls, package: Package) -> AddedDependency: + return cls(name=package.name, version=package.version) + + +class RemovedDependency(DependencyChange): + version: Version + + def __str__(self) -> str: + return f"* Removed dependency `{self.name}:{self.version}`" + + @classmethod + def from_package(cls, package: Package) -> RemovedDependency: + return cls(name=package.name, version=package.version) + + +class UpdatedDependency(DependencyChange): + previous_version: Version + current_version: Version + + def __str__(self) -> str: + return ( + f"* Updated dependency `{self.name}:{self.previous_version}` " + f"to `{self.current_version}`" + ) + + @classmethod + def from_package( + cls, previous_package: Package, current_package: Package + ) -> UpdatedDependency: + return cls( + name=previous_package.name, + previous_version=previous_package.version, + current_version=current_package.version, + ) + + +class DependencyChanges(BaseModel): + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + previous_dependencies: dict[NormalizedPackageStr, Package] + current_dependencies: dict[NormalizedPackageStr, Package] + + def _categorize_change( + self, dependency_name: NormalizedPackageStr + ) -> Optional[DependencyChange]: + """ + Categorize dependency change as removed, added, or updated. + """ + previous_dependency = self.previous_dependencies.get(dependency_name) + current_dependency = self.current_dependencies.get(dependency_name) + if previous_dependency and not current_dependency: + return RemovedDependency.from_package(previous_dependency) + elif not previous_dependency and current_dependency: + return AddedDependency.from_package(current_dependency) + elif previous_dependency.version != current_dependency.version: # type: ignore + return UpdatedDependency.from_package( + previous_dependency, current_dependency # type: ignore + ) + # dependency was unchanged between versions + return None + + @property + def changes(self) -> list[DependencyChange]: + """ + Return dependency changes + """ + # dict.keys() returns a set converted into a list by `sorted()` + all_dependencies = sorted( + self.previous_dependencies.keys() | self.current_dependencies.keys() + ) + return [ + change_dependency + for dependency_name in all_dependencies + if (change_dependency := self._categorize_change(dependency_name)) + ] diff --git a/exasol/toolbox/util/release/changelog.py b/exasol/toolbox/util/release/changelog.py index b63e928e0..0c248730f 100644 --- a/exasol/toolbox/util/release/changelog.py +++ b/exasol/toolbox/util/release/changelog.py @@ -4,17 +4,32 @@ from inspect import cleandoc from pathlib import Path +from exasol.toolbox.util.dependencies.poetry_dependencies import ( + get_dependencies, + get_dependencies_from_latest_tag, +) +from exasol.toolbox.util.dependencies.track_changes import DependencyChanges from exasol.toolbox.util.version import Version UNRELEASED_INITIAL_CONTENT = "# Unreleased\n" class Changelogs: - def __init__(self, changes_path: Path, version: Version) -> None: + def __init__(self, changes_path: Path, root_path: Path, version: Version) -> None: + """ + Args: + changes_path: directory containing the changelog & changes files, e.g. `doc/changes/` + root_path: root directory of the current project, containing file + `pyproject.toml` + version: the version to be used in the versioned changes file and listed in + the `changelog.md`, which contains the index of the change log + """ + 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.root_path: Path = root_path def _create_new_unreleased(self): """ @@ -22,33 +37,74 @@ def _create_new_unreleased(self): """ self.unreleased_md.write_text(UNRELEASED_INITIAL_CONTENT) - def _create_versioned_changelog(self, content: str) -> None: + def _create_versioned_changelog(self, unreleased_content: str) -> None: """ - Create a changelog entry for a specific version. + Create a versioned changes file. Args: - content: The content of the changelog entry. - + unreleased_content: the content of the (not yet versioned) changes """ - template = cleandoc( - f""" - # {self.version} - {datetime.today().strftime("%Y-%m-%d")} + header = f"# {self.version} - {datetime.today().strftime('%Y-%m-%d')}" - {content} - """ - ) + dependency_content = "" + if dependency_changes := self._describe_dependency_changes(): + dependency_content = f"## Dependency Updates\n{dependency_changes}" + + template = cleandoc(f"{header}\n{unreleased_content}\n{dependency_content}") self.versioned_changelog_md.write_text(template) def _extract_unreleased_notes(self) -> str: """ - Extract release notes from `unreleased.md`. + 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)) - unreleased_content += "\n" - return unreleased_content + return unreleased_content + "\n" + + def _describe_dependency_changes(self) -> str: + """ + Describe the dependency changes between the latest tag and the current version + for use in the versioned changes file. + """ + previous_dependencies_in_groups = get_dependencies_from_latest_tag() + 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() + ) + 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{changes_str}\n") + return "".join(changes_by_group) + + @staticmethod + def _sort_groups(groups: set[str]) -> list[str]: + """ + Prepare a deterministic sorting for groups shown in the versioned changes file: + - `main` group should always be first + - remaining groups are sorted alphabetically + """ + main = "main" + if main not in groups: + # sorted converts set to list + return sorted(groups) + remaining_groups = groups - {main} + # sorted converts set to list + return [main] + sorted(remaining_groups) def _update_changelog_table_of_contents(self) -> None: """ @@ -76,7 +132,7 @@ def update_changelogs_for_release(self) -> None: """ Rotates the changelogs as is needed for a release. - 1. Moves the contents of the `unreleased.md` to the `changes_.md` + 1. Moves the contents from the `unreleased.md` to the `changes_.md` 2. Create a new file `unreleased.md` 3. Updates the table of contents in the `changelog.md` with the new `changes_.md` """ diff --git a/project-template/{{cookiecutter.repo_name}}/pyproject.toml b/project-template/{{cookiecutter.repo_name}}/pyproject.toml index 1cca979c9..15780e20a 100644 --- a/project-template/{{cookiecutter.repo_name}}/pyproject.toml +++ b/project-template/{{cookiecutter.repo_name}}/pyproject.toml @@ -50,7 +50,6 @@ force_grid_wrap = 2 [tool.pylint.master] fail-under = 5.0 -output-format = "colorized,json:.lint.json,text:.lint.txt" ignore = [] [tool.pylint.format] diff --git a/pyproject.toml b/pyproject.toml index dbbfe848e..e448400c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,6 @@ force_grid_wrap = 2 [tool.pylint.master] fail-under = 7.5 -output-format = "colorized,json:.lint.json,text:.lint.txt" [tool.pylint.format] max-line-length = 88 diff --git a/test/integration/project-template/nox_test.py b/test/integration/project-template/nox_test.py index 36895901d..d28852ee6 100644 --- a/test/integration/project-template/nox_test.py +++ b/test/integration/project-template/nox_test.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Optional class TestSpecificNoxTasks: @@ -14,8 +14,10 @@ class TestSpecificNoxTasks: both the relationships between nox tasks and ensuring that the project-template passes CI tests when a new project is created from it. """ + @staticmethod - def _command(poetry_path: str, task: str, add_ons: Union[list[str], None]=None) -> list[str]: + def _command(poetry_path: str, task: str, + add_ons: Optional[list[str]] = None) -> list[str]: base = [poetry_path, "run", "--", "nox", "-s", task] if add_ons: base = base + ["--"] + add_ons @@ -33,9 +35,10 @@ def test_artifact_validate(self, poetry_path, run_command): run_command(lint_code) lint_security = self._command(poetry_path, "lint:security") run_command(lint_security) - test_unit = self._command(poetry_path, "test:unit",["--coverage"]) + test_unit = self._command(poetry_path, "test:unit", ["--coverage"]) run_command(test_unit) - test_integration = self._command(poetry_path, "test:integration",["--coverage"]) + test_integration = self._command(poetry_path, "test:integration", + ["--coverage"]) run_command(test_integration) # `artifacts:copy` is skipped here. This step has the pre-requisite that files # were uploaded to & then downloaded from the GitHub run's artifacts. diff --git a/test/unit/util/conftest.py b/test/unit/util/conftest.py new file mode 100644 index 000000000..853123984 --- /dev/null +++ b/test/unit/util/conftest.py @@ -0,0 +1,31 @@ +from collections import OrderedDict + +import pytest + +from exasol.toolbox.util.dependencies.poetry_dependencies import PoetryGroup +from exasol.toolbox.util.dependencies.shared_models import Package + + +@pytest.fixture(scope="module") +def main_group(): + return PoetryGroup(name="main", toml_section="project.dependencies") + + +@pytest.fixture(scope="module") +def dev_group(): + return PoetryGroup(name="dev", toml_section="tool.poetry.group.dev.dependencies") + + +@pytest.fixture(scope="module") +def previous_dependencies(main_group, dev_group): + deps = OrderedDict() + deps[main_group.name] = {"package1": Package(name="package1", version="0.0.1")} + return deps + + +@pytest.fixture(scope="module") +def dependencies(main_group, dev_group): + deps = OrderedDict() + deps[main_group.name] = {"package1": Package(name="package1", version="0.1.0")} + deps[dev_group.name] = {"package2": Package(name="package2", version="0.2.0")} + return deps diff --git a/test/unit/util/dependencies/licenses_test.py b/test/unit/util/dependencies/licenses_test.py index bb3e33684..a20222fab 100644 --- a/test/unit/util/dependencies/licenses_test.py +++ b/test/unit/util/dependencies/licenses_test.py @@ -1,17 +1,14 @@ +from typing import Optional + import pytest from exasol.toolbox.util.dependencies.licenses import ( LICENSE_MAPPING_TO_URL, PackageLicense, + PackageLicenseReport, _normalize, _packages_from_json, - packages_to_markdown, ) -from exasol.toolbox.util.dependencies.poetry_dependencies import PoetryGroup -from exasol.toolbox.util.dependencies.shared_models import Package - -MAIN_GROUP = PoetryGroup(name="main", toml_section="project.dependencies") -DEV_GROUP = PoetryGroup(name="dev", toml_section="tool.poetry.group.dev.dependencies") class TestPackageLicense: @@ -62,97 +59,141 @@ def test_normalize(licenses, expected): assert actual == expected -@pytest.mark.parametrize( - "json,expected", - [ - ( - """ - [ - { - "License": "license1", - "Name": "name1", - "URL": "link1", - "Version": "0.1.0" - }, - { - "License": "license2", - "Name": "name2", - "URL": "UNKNOWN", - "Version": "0.2.0" - } - ] - """, - [ - PackageLicense( - name="name1", - version="0.1.0", - package_link="link1", - license="license1", - ), - PackageLicense( - name="name2", - version="0.2.0", - package_link=None, - license="license2", - ), - ], - ) - ], -) -def test_packages_from_json(json, expected): +def test_packages_from_json(): + json = """[ + { + "License": "license1", + "Name": "name1", + "URL": "link1", + "Version": "0.1.0" + }, + { + "License": "license2", + "Name": "name2", + "URL": "UNKNOWN", + "Version": "0.2.0" + } + ] + """ + actual = _packages_from_json(json) - assert actual == expected + assert actual == { + "name1": PackageLicense( + name="name1", + version="0.1.0", + package_link="link1", + license="license1", + ), + "name2": PackageLicense( + name="name2", + version="0.2.0", + package_link=None, + license="license2", + ), + } -@pytest.mark.parametrize( - "dependencies,packages", - [ - ( - { - MAIN_GROUP.name: [ - Package(name="package1", version="0.1.0"), - Package(name="package3", version="0.1.0"), - ], - DEV_GROUP.name: [Package(name="package2", version="0.2.0")], - }, - [ - PackageLicense( - name="package1", - package_link="package_link1", - version="0.1.0", - license="GPLv1", - ), - PackageLicense( - name="package2", - package_link="package_link2", - version="0.2.0", - license="GPLv2", - ), - PackageLicense( - name="package3", - package_link="UNKNOWN", - version="0.3.0", - license="license3", - ), - ], + +@pytest.fixture(scope="module") +def package_license_report(dependencies): + licenses = { + "package1": PackageLicense( + name="package1", + package_link="package_link1", + version="0.1.0", + license="GPLv1", + ), + "package2": PackageLicense( + name="package3", + package_link="UNKNOWN", + version="0.3.0", + license="license3", + ), + } + + return PackageLicenseReport(dependencies=dependencies, licenses=licenses) + + +class TestPackageLicenseReport: + @staticmethod + def test_format_group_table_header(package_license_report, main_group): + result = package_license_report._format_group_table_header( + group=main_group.name ) - ], -) -def test_packages_to_markdown(dependencies, packages): - actual = packages_to_markdown(dependencies, packages) - assert ( - actual - == """# Dependencies -## Main Dependencies -|Package|Version|License| -|---|---|---| -|[package1](package_link1)|0.1.0|[GPLv1](https://www.gnu.org/licenses/old-licenses/gpl-1.0.html)| -|package3|0.3.0|license3| - -## Dev Dependencies -|Package|Version|License| -|---|---|---| -|[package2](package_link2)|0.2.0|[GPLv2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html)| - -""" + + assert ( + result == "## `main` Dependencies\n|Package|Version|License|\n|---|---|---|" + ) + + @staticmethod + def test_format_group_table(package_license_report, dependencies, main_group): + group_package_names = set(dependencies[main_group.name].keys()) + + result = package_license_report._format_group_table( + group=main_group.name, group_package_names=group_package_names + ) + + assert result == ( + "## `main` Dependencies\n" + "|Package|Version|License|\n" + "|---|---|---|\n" + "|[package1](package_link1)|0.1.0|[GPLv1](https://www.gnu.org/licenses/old-licenses/gpl-1.0.html)|\n" + "\n" + ) + + @staticmethod + @pytest.mark.parametrize( + "package_link,license_link,expected", + [ + pytest.param( + "package_link2", + "GPLv2", + "|[package2](package_link2)|0.2.0|[GPLv2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html)|\n", + id="has-all-attributes", + ), + pytest.param( + None, + "GPLv2", + "|package2|0.2.0|[GPLv2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html)|\n", + id="no-package-link", + ), + pytest.param( + "package_link2", + "abcd", + "|[package2](package_link2)|0.2.0|abcd|\n", + id="no-associated-license-link", + ), + ], ) + def test_format_table_row( + package_license_report, + package_link: Optional[str], + license_link: Optional[str], + expected, + ): + _license = PackageLicense( + name="package2", + package_link=package_link, + version="0.2.0", + license=license_link, + ) + result = package_license_report._format_table_row(license_info=_license) + + assert result == expected + + @staticmethod + def test_to_markdown(package_license_report): + result = package_license_report.to_markdown() + assert result == ( + "# Dependencies\n" + "\n" + "## `main` Dependencies\n" + "|Package|Version|License|\n" + "|---|---|---|\n" + "|[package1](package_link1)|0.1.0|[GPLv1](https://www.gnu.org/licenses/old-licenses/gpl-1.0.html)|\n" + "\n" + "## `dev` Dependencies\n" + "|Package|Version|License|\n" + "|---|---|---|\n" + "|package3|0.3.0|license3|" + ) diff --git a/test/unit/util/dependencies/poetry_dependencies_test.py b/test/unit/util/dependencies/poetry_dependencies_test.py index b44be56cd..f38f8a77a 100644 --- a/test/unit/util/dependencies/poetry_dependencies_test.py +++ b/test/unit/util/dependencies/poetry_dependencies_test.py @@ -23,9 +23,9 @@ BLACK = Package(name="black", version="25.1.0") DIRECT_DEPENDENCIES = { - MAIN_GROUP.name: [PYLINT], - DEV_GROUP.name: [ISORT], - ANALYSIS_GROUP.name: [BLACK], + MAIN_GROUP.name: {PYLINT.name: PYLINT}, + DEV_GROUP.name: {ISORT.name: ISORT}, + ANALYSIS_GROUP.name: {BLACK.name: BLACK}, } diff --git a/test/unit/util/dependencies/track_changes_test.py b/test/unit/util/dependencies/track_changes_test.py new file mode 100644 index 000000000..bb1669466 --- /dev/null +++ b/test/unit/util/dependencies/track_changes_test.py @@ -0,0 +1,95 @@ +from exasol.toolbox.util.dependencies.shared_models import Package +from exasol.toolbox.util.dependencies.track_changes import ( + AddedDependency, + DependencyChanges, + RemovedDependency, + UpdatedDependency, +) + + +class SamplePackage: + name = "black" + version = "25.1.0" + + @property + def package(self) -> Package: + return Package(name=self.name, version=self.version) + + @property + def dependency_dict(self) -> dict[str, Package]: + return {self.name: self.package} + + +class TestDependencyChanges: + @staticmethod + def test_removed_dependency(): + changes = DependencyChanges( + previous_dependencies=SamplePackage().dependency_dict, + current_dependencies={}, + ) + + result = changes._categorize_change(SamplePackage.name) + + assert result == RemovedDependency.from_package(SamplePackage().package) + assert str(result) == "* Removed dependency `black:25.1.0`" + + @staticmethod + def test_added_dependency(): + changes = DependencyChanges( + previous_dependencies={}, + current_dependencies=SamplePackage().dependency_dict, + ) + + result = changes._categorize_change(SamplePackage.name) + + assert result == AddedDependency.from_package(SamplePackage().package) + assert str(result) == "* Added dependency `black:25.1.0`" + + @staticmethod + def test_updated_dependency(): + previous_package = Package(name=SamplePackage.name, version="24.1.0") + + changes = DependencyChanges( + previous_dependencies={SamplePackage.name: previous_package}, + current_dependencies=SamplePackage().dependency_dict, + ) + + result = changes._categorize_change(SamplePackage.name) + + assert result == UpdatedDependency.from_package( + previous_package=previous_package, current_package=SamplePackage().package + ) + assert str(result) == "* Updated dependency `black:24.1.0` to `25.1.0`" + + @staticmethod + def test_dependency_without_changes(): + changes = DependencyChanges( + previous_dependencies=SamplePackage().dependency_dict, + current_dependencies=SamplePackage().dependency_dict, + ) + + result = changes._categorize_change(SamplePackage.name) + + assert result is None + + @staticmethod + def test_changes_with_no_changed_dependencies(): + changes = DependencyChanges( + previous_dependencies=SamplePackage().dependency_dict, + current_dependencies=SamplePackage().dependency_dict, + ) + + assert changes.changes == [] + + @staticmethod + def test_changes_with_changed_dependencies(): + current_dependencies = SamplePackage().dependency_dict.copy() + added_package = Package(name="pylint", version="3.3.7") + current_dependencies[added_package.name] = added_package + + changes = DependencyChanges( + previous_dependencies=SamplePackage().dependency_dict, + current_dependencies=current_dependencies, + ) + + assert changes.changes == [AddedDependency.from_package(added_package)] diff --git a/test/unit/util/release/changelog_test.py b/test/unit/util/release/changelog_test.py index 5237d86cf..1c0002b60 100644 --- a/test/unit/util/release/changelog_test.py +++ b/test/unit/util/release/changelog_test.py @@ -1,4 +1,6 @@ +from datetime import datetime from inspect import cleandoc +from unittest import mock import pytest @@ -70,9 +72,35 @@ def unreleased_md(changelogs): ) +@pytest.fixture(scope="function") +def mock_dependencies(dependencies, previous_dependencies): + with mock.patch.multiple( + "exasol.toolbox.util.release.changelog", + get_dependencies_from_latest_tag=lambda: previous_dependencies, + get_dependencies=lambda working_directory: dependencies, + ): + yield + + +@pytest.fixture(scope="function") +def mock_no_dependencies(): + with mock.patch.multiple( + "exasol.toolbox.util.release.changelog", + get_dependencies_from_latest_tag=lambda: {}, + get_dependencies=lambda working_directory: {}, + ): + yield + + @pytest.fixture(scope="function") def changelogs(tmp_path) -> Changelogs: - return Changelogs(changes_path=tmp_path, version=Version(major=1, minor=0, patch=0)) + 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), + ) class TestChangelogs: @@ -90,7 +118,7 @@ def test_create_new_unreleased(changelogs): assert changelogs.unreleased_md.read_text() == UNRELEASED_INITIAL_CONTENT @staticmethod - def test_create_versioned_changelog(changelogs): + def test_create_versioned_changelog(changelogs, mock_dependencies): changelogs._create_versioned_changelog(SampleContent.changelog) saved_text = changelogs.versioned_changelog_md.read_text() @@ -103,6 +131,34 @@ def test_extract_unreleased_notes(changelogs, unreleased_md): assert result == SampleContent.changelog + "\n" + @staticmethod + def test_describe_dependency_changes(changelogs, mock_dependencies): + result = changelogs._describe_dependency_changes() + assert result == ( + "\n" + "### `main`\n" + "* Updated dependency `package1:0.0.1` to `0.1.0`\n" + "\n" + "### `dev`\n" + "* Added dependency `package2:0.2.0`\n" + ) + + @staticmethod + @pytest.mark.parametrize( + "groups,expected", + [ + pytest.param( + {"dev", "abcd", "main"}, ["main", "abcd", "dev"], id="with_main" + ), + pytest.param( + {"dev", "abcd", "bacd"}, ["abcd", "bacd", "dev"], id="without_main" + ), + ], + ) + def test_sort_groups(changelogs, groups, expected): + result = changelogs._sort_groups(groups) + assert result == expected + @staticmethod def test_update_changelog_table_of_contents(changelogs, changes_md): changelogs._update_changelog_table_of_contents() @@ -110,7 +166,9 @@ def test_update_changelog_table_of_contents(changelogs, changes_md): assert changelogs.changelog_md.read_text() == SampleContent.altered_changes @staticmethod - def test_update_changelogs_for_release(changelogs, unreleased_md, changes_md): + def test_update_changelogs_for_release( + changelogs, mock_dependencies, unreleased_md, changes_md + ): changelogs.update_changelogs_for_release() # changes.md @@ -119,5 +177,48 @@ def test_update_changelogs_for_release(changelogs, unreleased_md, changes_md): assert changelogs.unreleased_md.read_text() == UNRELEASED_INITIAL_CONTENT # versioned.md saved_text = changelogs.versioned_changelog_md.read_text() - assert "1.0.0" in saved_text - assert SampleContent.changelog in saved_text + assert saved_text == cleandoc( + f"""# 1.0.0 - {datetime.today().strftime('%Y-%m-%d')} + ## Added + * Added Awesome feature + + ## Changed + * Some behaviour + + ## Fixed + * Fixed nasty bug + + ## Dependency Updates + + ### `main` + * Updated dependency `package1:0.0.1` to `0.1.0` + + ### `dev` + * Added dependency `package2:0.2.0` + """ + ) + + @staticmethod + def test_update_changelogs_for_release_with_no_dependencies( + changelogs, mock_no_dependencies, unreleased_md, changes_md + ): + changelogs.update_changelogs_for_release() + + # changes.md + assert changelogs.changelog_md.read_text() == SampleContent.altered_changes + # unreleased.md + assert changelogs.unreleased_md.read_text() == UNRELEASED_INITIAL_CONTENT + # versioned.md + saved_text = changelogs.versioned_changelog_md.read_text() + assert saved_text == cleandoc( + f"""# 1.0.0 - {datetime.today().strftime('%Y-%m-%d')} + ## Added + * Added Awesome feature + + ## Changed + * Some behaviour + + ## Fixed + * Fixed nasty bug + """ + ) diff --git a/test/unit/util/version_test.py b/test/unit/util/version_test.py index 9a2a4f326..96455a7c7 100644 --- a/test/unit/util/version_test.py +++ b/test/unit/util/version_test.py @@ -24,7 +24,7 @@ def test_create_version_from_string(input, expected): @pytest.mark.parametrize( - "old_version,new_version,expected", + "previous_version,new_version,expected", [ (Version(1, 2, 3), Version(1, 2, 4), True), (Version(1, 2, 3), Version(1, 3, 3), True), @@ -34,8 +34,8 @@ def test_create_version_from_string(input, expected): (Version(1, 2, 3), Version(0, 3, 3), False), ], ) -def test_is_later_version(old_version, new_version, expected): - actual = new_version > old_version +def test_is_later_version(previous_version, new_version, expected): + actual = new_version > previous_version assert expected == actual