diff --git a/src/semantic_release/cli/commands/main.py b/src/semantic_release/cli/commands/main.py index c7ab3897e..885898dfb 100644 --- a/src/semantic_release/cli/commands/main.py +++ b/src/semantic_release/cli/commands/main.py @@ -15,6 +15,7 @@ from semantic_release.cli.config import GlobalCommandLineOptions from semantic_release.cli.const import DEFAULT_CONFIG_FILE from semantic_release.cli.util import rprint +from semantic_release.enums import SemanticReleaseLogLevels # if TYPE_CHECKING: # pass @@ -108,7 +109,15 @@ def main( """ console = Console(stderr=True) - log_level = [logging.WARNING, logging.INFO, logging.DEBUG][verbosity] + log_levels = [ + SemanticReleaseLogLevels.WARNING, + SemanticReleaseLogLevels.INFO, + SemanticReleaseLogLevels.DEBUG, + SemanticReleaseLogLevels.SILLY, + ] + + log_level = log_levels[verbosity] + logging.basicConfig( level=log_level, format=FORMAT, @@ -123,7 +132,7 @@ def main( logger = logging.getLogger(__name__) logger.debug("logging level set to: %s", logging.getLevelName(log_level)) - if log_level == logging.DEBUG: + if log_level <= logging.DEBUG: globals.debug = True if noop: diff --git a/src/semantic_release/enums.py b/src/semantic_release/enums.py index 26c539dff..c4fdbac56 100644 --- a/src/semantic_release/enums.py +++ b/src/semantic_release/enums.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from enum import IntEnum, unique @@ -37,3 +38,32 @@ def from_string(cls, val: str) -> LevelBump: >>> LevelBump.from_string("minor") == LevelBump.MINOR """ return cls[val.upper().replace("-", "_")] + + +class SemanticReleaseLogLevels(IntEnum): + """IntEnum representing the log levels used by semantic-release.""" + + FATAL = logging.FATAL + CRITICAL = logging.CRITICAL + ERROR = logging.ERROR + WARNING = logging.WARNING + INFO = logging.INFO + DEBUG = logging.DEBUG + SILLY = 5 + + def __str__(self) -> str: + """ + Return the level name rather than 'SemanticReleaseLogLevels.' + E.g. + >>> str(SemanticReleaseLogLevels.DEBUG) + 'DEBUG' + >>> str(SemanticReleaseLogLevels.CRITICAL) + 'CRITICAL' + """ + return self.name.upper() + + +logging.addLevelName( + SemanticReleaseLogLevels.SILLY, + str(SemanticReleaseLogLevels.SILLY), +) diff --git a/src/semantic_release/version/algorithm.py b/src/semantic_release/version/algorithm.py index 1748e4212..10ca24bd5 100644 --- a/src/semantic_release/version/algorithm.py +++ b/src/semantic_release/version/algorithm.py @@ -1,20 +1,19 @@ from __future__ import annotations import logging -from queue import Queue +from contextlib import suppress +from queue import LifoQueue from typing import TYPE_CHECKING, Iterable from semantic_release.commit_parser import ParsedCommit from semantic_release.const import DEFAULT_VERSION -from semantic_release.enums import LevelBump -from semantic_release.errors import InvalidVersion, MissingMergeBaseError -from semantic_release.version.version import Version +from semantic_release.enums import LevelBump, SemanticReleaseLogLevels +from semantic_release.errors import InternalError, InvalidVersion if TYPE_CHECKING: # pragma: no cover - from git.objects.blob import Blob + from typing import Sequence + from git.objects.commit import Commit - from git.objects.tag import TagObject - from git.objects.tree import Tree from git.refs.tag import Tag from git.repo.base import Repo @@ -24,8 +23,10 @@ ParserOptions, ) from semantic_release.version.translator import VersionTranslator + from semantic_release.version.version import Version + -log = logging.getLogger(__name__) +logger = logging.getLogger(__name__) def tags_and_versions( @@ -44,99 +45,78 @@ def tags_and_versions( try: version = translator.from_tag(tag.name) except (NotImplementedError, InvalidVersion) as e: - log.warning( + logger.warning( "Couldn't parse tag %s as as Version: %s", tag.name, str(e), - exc_info=log.isEnabledFor(logging.DEBUG), + exc_info=logger.isEnabledFor(logging.DEBUG), ) continue if version: ts_and_vs.append((tag, version)) - log.info("found %s previous tags", len(ts_and_vs)) + logger.info("found %s previous tags", len(ts_and_vs)) return sorted(ts_and_vs, reverse=True, key=lambda v: v[1]) -def _bfs_for_latest_version_in_history( - merge_base: Commit | TagObject | Blob | Tree, - full_release_tags_and_versions: list[tuple[Tag, Version]], -) -> Version | None: - """ - Run a breadth-first search through the given `merge_base`'s parents, - looking for the most recent version corresponding to a commit in the - `merge_base`'s parents' history. If no commits in the history correspond - to a released version, return None - """ - tag_sha_2_version_lookup = { - tag.commit.hexsha: version for tag, version in full_release_tags_and_versions - } - - # Step 3. Latest full release version within the history of the current branch - # Breadth-first search the merge-base and its parent commits for one which matches - # the tag of the latest full release tag in history - def bfs(start_commit: Commit | TagObject | Blob | Tree) -> Version | None: - # Derived from Geeks for Geeks - # https://www.geeksforgeeks.org/python-program-for-breadth-first-search-or-bfs-for-a-graph/?ref=lbp - - # Create a queue for BFS - q: Queue[Commit | TagObject | Blob | Tree] = Queue() +def _traverse_graph_for_commits( + head_commit: Commit, + latest_release_tag_str: str = "", +) -> Sequence[Commit]: + # Depth-first search + def dfs(start_commit: Commit, stop_nodes: set[Commit]) -> Sequence[Commit]: + # Create a stack for DFS + stack: LifoQueue[Commit] = LifoQueue() # Create a set to store visited graph nodes (commit objects in this case) - visited: set[Commit | TagObject | Blob | Tree] = set() + visited: set[Commit] = set() - # Add the source node in the queue & mark as visited to start the search - q.put(start_commit) - visited.add(start_commit) + # Initialize the result + commits: list[Commit] = [] - # Initialize the result to None - result = None - - # Traverse the git history until it finds a version tag if one exists - while not q.empty(): - node = q.get() - visited.add(node) + # Add the source node in the queue to start the search + stack.put(start_commit) - log.debug("checking if commit %s matches any tags", node.hexsha) - version = tag_sha_2_version_lookup.get(node.hexsha, None) + # Traverse the git history capturing each commit found before it reaches a stop node + while not stack.empty(): + if (node := stack.get()) in visited or node in stop_nodes: + continue - if version is not None: - log.info( - "found latest version in branch history: %r (%s)", - str(version), - node.hexsha[:7], - ) - result = version - break - - log.debug("commit %s doesn't match any tags", node.hexsha) + visited.add(node) + commits.append(node) - # Add all parent commits to the queue if they haven't been visited + # Add all parent commits to the stack from left to right so that the rightmost is popped first + # as the left side is generally the merged into branch for parent in node.parents: - if parent in visited: - log.debug("parent commit %s already visited", node.hexsha) - continue - - log.debug("queuing parent commit %s", parent.hexsha) - q.put(parent) - - return result + logger.debug("queuing parent commit %s", parent.hexsha[:7]) + stack.put(parent) + + return commits + + # Run a Depth First Search to find all the commits since the last release + commits_since_last_release = dfs( + start_commit=head_commit, + stop_nodes=set( + head_commit.repo.iter_commits(latest_release_tag_str) + if latest_release_tag_str + else [] + ), + ) - # Run a Breadth First Search to find the latest version in the history - latest_version = bfs(merge_base) - if latest_version is not None: - log.info("the latest version in this branch's history is %s", latest_version) - else: - log.info("no version tags found in this branch's history") + log_msg = ( + f"Found {len(commits_since_last_release)} commits since the last release!" + if len(commits_since_last_release) > 0 + else "No commits found since the last release!" + ) + logger.info(log_msg) - return latest_version + return commits_since_last_release def _increment_version( latest_version: Version, latest_full_version: Version, - latest_full_version_in_history: Version, level_bump: LevelBump, prerelease: bool, prerelease_token: str, @@ -158,12 +138,18 @@ def _increment_version( is in this branch's history. """ local_vars = list(locals().items()) - log.debug("_increment_version: %s", ", ".join(f"{k} = {v}" for k, v in local_vars)) + logger.log( + SemanticReleaseLogLevels.SILLY, + "_increment_version: %s", + str.join(", ", [f"{k} = {v}" for k, v in local_vars]), + ) + + # Handle variations where the latest version is 0.x.x if latest_version.major == 0: if not allow_zero_version: # Set up default version to be 1.0.0 if currently 0.x.x which means a commented # breaking change is not required to bump to 1.0.0 - log.debug( + logger.debug( "Bumping major version as 0.x.x versions are disabled because of allow_zero_version=False" ) level_bump = LevelBump.MAJOR @@ -173,45 +159,43 @@ def _increment_version( # breaking changes should increment the minor digit # Correspondingly, we reduce the level that we increment the # version by. - log.debug( + logger.debug( "reducing version increment due to 0. version and major_on_zero=False" ) level_bump = min(level_bump, LevelBump.MINOR) + # Determine the difference between the latest version and the latest full release + diff_with_last_released_version = latest_version - latest_full_version + logger.debug( + "diff between the latest version %s and the latest full release version %s is: %s", + latest_version, + latest_full_version, + diff_with_last_released_version, + ) + + # Handle prerelease version bumps if prerelease: - log.debug("prerelease=true") - target_final_version = latest_full_version.finalize_version() - diff_with_last_released_version = ( - latest_version - latest_full_version_in_history - ) - log.debug( - "diff between the latest version %s and the latest full release version %s " - "is: %s", - latest_version, - latest_full_version_in_history, - diff_with_last_released_version, - ) # 6a i) if the level_bump > the level bump introduced by any prerelease tag # before e.g. 1.2.4-rc.3 -> 1.3.0-rc.1 if level_bump > diff_with_last_released_version: - log.debug( - "this release has a greater bump than any change since the last full " - "release, %s", - latest_full_version_in_history, + logger.debug( + "this release has a greater bump than any change since the last full release, %s", + latest_full_version, ) - return target_final_version.bump(level_bump).to_prerelease( - token=prerelease_token + return ( + latest_full_version.finalize_version() + .bump(level_bump) + .to_prerelease(token=prerelease_token) ) # 6a ii) if level_bump <= the level bump introduced by prerelease tag - log.debug( - "there has already been at least a %s release since the last full " - "release %s", + logger.debug( + "there has already been at least a %s release since the last full release %s", level_bump, - latest_full_version_in_history, + latest_full_version, ) - log.debug("this release will increment the prerelease revision") + logger.debug("this release will increment the prerelease revision") return latest_version.to_prerelease( token=prerelease_token, revision=( @@ -225,36 +209,25 @@ def _increment_version( # NOTE: These can actually be condensed down to the single line # 6b. i) if there's been a prerelease if latest_version.is_prerelease: - log.debug( + logger.debug( "prerelease=false and the latest version %s is a prerelease", latest_version ) - diff_with_last_released_version = ( - latest_version - latest_full_version_in_history - ) - log.debug( - "diff between the latest version %s and the latest full release version %s " - "is: %s", - latest_version, - latest_full_version_in_history, - diff_with_last_released_version, - ) if level_bump > diff_with_last_released_version: - log.debug( - "this release has a greater bump than any change since the last full " - "release, %s", - latest_full_version_in_history, + logger.debug( + "this release has a greater bump than any change since the last full release, %s", + latest_full_version, ) return latest_version.bump(level_bump).finalize_version() - log.debug( - "there has already been at least a %s release since the last full " - "release %s", + + logger.debug( + "there has already been at least a %s release since the last full release %s", level_bump, - latest_full_version_in_history, + latest_full_version, ) return latest_version.finalize_version() # 6b. ii) If there's been no prerelease - log.debug( + logger.debug( "prerelease=false and %s is not a prerelease; bumping with a %s release", latest_version, level_bump, @@ -274,181 +247,118 @@ def next_version( Evaluate the history within `repo`, and based on the tags and commits in the repo history, identify the next semantic version that should be applied to a release """ - # Step 1. All tags, sorted descending by semver ordering rules - all_git_tags_as_versions = tags_and_versions(repo.tags, translator) - all_full_release_tags_and_versions = list( - filter(lambda t_v: not t_v[1].is_prerelease, all_git_tags_as_versions) - ) - log.info( - "Found %s full releases (excluding prereleases)", - len(all_full_release_tags_and_versions), - ) - # Default initial version - latest_full_release_tag, latest_full_release_version = next( - iter(all_full_release_tags_and_versions), - (None, translator.from_string(DEFAULT_VERSION)), - ) - - # we can safely scan the extra commits on this - # branch if it's never been released, but we have no other - # guarantees that other branches exist - # Note the merge_base might be on our current branch, it's not - # necessarily the merge base of the current branch with `main` - other_ref = ( - repo.active_branch - if latest_full_release_tag is None - else latest_full_release_tag.name + # Since the translator is configured by the user, we can't guarantee that it will + # be able to parse the default version. So we first cast it to a tag using the default + # value and the users configured tag format, then parse it back to a version object + default_initial_version = translator.from_tag( + translator.str_to_tag(DEFAULT_VERSION) ) - - # Conditional log message to inform what was chosen as the comparison point - # to find the merge base of the current branch with the latest full release - log_msg = ( - str.join( - ", ", - [ - "No full releases have been made yet", - f"the default version to use is {latest_full_release_version}", - ], + if default_initial_version is None: + raise InternalError( + "Translator was unable to parse the embedded default version" ) - if latest_full_release_tag is None - else str.join( - ", ", - [ - f"The last full release was {latest_full_release_version}", - f"tagged as {latest_full_release_tag!r}", - ], - ) - ) - log.info(log_msg) - merge_bases = repo.merge_base(other_ref, repo.active_branch) - - if len(merge_bases) < 1: - raise MissingMergeBaseError( - f"Unable to find merge-base between {other_ref} and {repo.active_branch.name}" - ) - - if len(merge_bases) > 1: - raise NotImplementedError( - str.join( - " ", - [ - "This branch has more than one merge-base with the", - "latest version, which is not yet supported", - ], - ) - ) - - merge_base = merge_bases[0] + # Step 1. All tags, sorted descending by semver ordering rules + all_git_tags_as_versions = tags_and_versions(repo.tags, translator) - if merge_base is None: - str_tag_name = ( - "None" if latest_full_release_tag is None else latest_full_release_tag.name - ) - raise ValueError( - f"The merge_base found by merge_base({str_tag_name}, {repo.active_branch}) " - "is None" - ) + # Retrieve all commit hashes (regardless of merges) in the current branch's history from repo origin + commit_hash_set = { + commit.hexsha + for commit in _traverse_graph_for_commits(head_commit=repo.active_branch.commit) + } - latest_full_version_in_history = _bfs_for_latest_version_in_history( - merge_base=merge_base, - full_release_tags_and_versions=all_full_release_tags_and_versions, - ) - log.info( - "The last full version in this branch's history was %s", - latest_full_version_in_history, + # Filter all releases that are not found in the current branch's history + historic_versions: list[Version] = [] + for tag, version in all_git_tags_as_versions: + # TODO: move this to tags_and_versions() function? + # Ignore the error that is raised when tag points to a Blob or Tree object rather + # than a commit object (tags that point to tags that then point to commits are resolved automatically) + with suppress(ValueError): + if tag.commit.hexsha in commit_hash_set: + historic_versions.append(version) + + # Step 2. Get the latest final release version in the history of the current branch + # or fallback to the default 0.0.0 starting version value if none are found + latest_full_release_version = next( + filter( + lambda version: not version.is_prerelease, + historic_versions, + ), + default_initial_version, ) - commits_since_last_full_release = ( - repo.iter_commits() - if latest_full_version_in_history is None - else repo.iter_commits(f"{latest_full_version_in_history.as_tag()}...") + logger.info( + f"The last full version in this branch's history was {latest_full_release_version}" + if latest_full_release_version != default_initial_version + else "No full releases found in this branch's history" ) - # Step 4. Parse each commit since the last release and find any tags that have - # been added since then. - parsed_levels: set[LevelBump] = set() - latest_version = latest_full_version_in_history or Version( - 0, - 0, - 0, - prerelease_token=translator.prerelease_token, - tag_format=translator.tag_format, + # Step 3. Determine the latest release version in the history of the current branch + # If we the desired result is a prerelease, we must determine if there was any previous + # prerelease in the history of the current branch beyond the latest_full_release_version. + # Important to note that, we only consider prereleases that are of the same prerelease token + # as the basis of incrementing the prerelease revision. + # If we are not looking for a prerelease, this is the same as the last full release. + latest_version = ( + latest_full_release_version + if not prerelease + else next( + filter( + lambda version: all( + [ + version.is_prerelease, + version.prerelease_token == translator.prerelease_token, + version >= latest_full_release_version, + ] + ), + historic_versions, + ), + latest_full_release_version, # default + ) ) - # We only include pre-releases here if doing a prerelease. - # If it's not a prerelease, we need to include commits back - # to the last full version in consideration for what kind of - # bump to produce. However if we're doing a prerelease, we can - # include prereleases here to potentially consider a smaller portion - # of history (from a prerelease since the last full release, onwards) - - # Note that a side-effect of this is, if at some point the configuration - # for a particular branch pattern changes w.r.t. prerelease=True/False, - # the new kind of version will be produced from the commits already - # included in a prerelease since the last full release on the branch - tag_sha_2_version_lookup = { - tag.commit.hexsha: (tag, version) - for tag, version in all_git_tags_as_versions - if prerelease or not version.is_prerelease - } - - # N.B. these should be sorted so long as we iterate the commits in reverse order - for commit in commits_since_last_full_release: - parse_result = commit_parser.parse(commit) - if isinstance(parse_result, ParsedCommit): - log.debug( - "adding %s to the levels identified in commits_since_last_full_release", - parse_result.bump, - ) - parsed_levels.add(parse_result.bump) - - log.debug("checking if commit %s matches any tags", commit.hexsha) - t_v = tag_sha_2_version_lookup.get(commit.hexsha, None) + logger.info("The latest release in this branch's history was %s", latest_version) - if t_v is None: - # If we haven't found the latest prerelease on the branch, - # keep the loop going to look for it - log.debug("no tags correspond to commit %s", commit.hexsha) - continue + # Step 4. Walk the git tree to find all commits that have been made since the last release + commits_since_last_release = _traverse_graph_for_commits( + head_commit=repo.active_branch.commit, + latest_release_tag_str=( + # NOTE: the default_initial_version should not actually exist on the repository (ie v0.0.0) + # so we provide an empty tag string when there are no tags on the repository yet + latest_version.as_tag() if latest_version != default_initial_version else "" + ), + ) - # Unpack the tuple - tag, latest_version = t_v - log.debug( - "tag %r (%s) matches commit %s. the latest version is %s", - tag.name, - tag.commit.hexsha, - commit.hexsha, - latest_version, + # Step 5. Parse the commits to determine the bump level that should be applied + parsed_levels: set[LevelBump] = { + parsed_result.bump # type: ignore[union-attr] # too complex for type checkers + for parsed_result in filter( + lambda parsed_result: isinstance(parsed_result, ParsedCommit), + map(commit_parser.parse, commits_since_last_release), ) - break + } - log.debug( - "parsed the following distinct levels from the commits since the last release: " - "%s", + logger.debug( + "parsed the following distinct levels from the commits since the last release: %s", parsed_levels, ) + level_bump = max(parsed_levels, default=LevelBump.NO_RELEASE) - log.info("The type of the next release release is: %s", level_bump) - if level_bump is LevelBump.NO_RELEASE: # noqa: SIM102 - if latest_version.major != 0 or allow_zero_version: - log.info("No release will be made") - return latest_version + logger.info("The type of the next release release is: %s", level_bump) + + if all( + [ + level_bump is LevelBump.NO_RELEASE, + latest_version.major != 0 or allow_zero_version, + ] + ): + logger.info("No release will be made") + return latest_version return _increment_version( latest_version=latest_version, latest_full_version=latest_full_release_version, - latest_full_version_in_history=( - latest_full_version_in_history - or Version( - 0, - 0, - 0, - prerelease_token=translator.prerelease_token, - tag_format=translator.tag_format, - ) - ), level_bump=level_bump, prerelease=prerelease, prerelease_token=translator.prerelease_token, diff --git a/tests/e2e/cmd_version/bump_version/__init__.py b/tests/e2e/cmd_version/bump_version/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/cmd_version/bump_version/conftest.py b/tests/e2e/cmd_version/bump_version/conftest.py new file mode 100644 index 000000000..ecec668a9 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/conftest.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import os +import shutil +from re import IGNORECASE, compile as regexp +from typing import TYPE_CHECKING + +import pytest +from git import Repo + +if TYPE_CHECKING: + from pathlib import Path + from re import Pattern + from typing import Protocol + + from tests.fixtures.git_repo import BuildRepoFromDefinitionFn, RepoActionConfigure + + class GetSanitizedMdChangelogContentFn(Protocol): + def __call__(self, repo_dir: Path) -> str: ... + + class GetSanitizedRstChangelogContentFn(Protocol): + def __call__(self, repo_dir: Path) -> str: ... + + class InitMirrorRepo4RebuildFn(Protocol): + def __call__( + self, + mirror_repo_dir: Path, + configuration_step: RepoActionConfigure, + ) -> Path: ... + + +@pytest.fixture(scope="session") +def init_mirror_repo_for_rebuild( + default_changelog_md_template: Path, + default_changelog_rst_template: Path, + changelog_template_dir: Path, + build_repo_from_definition: BuildRepoFromDefinitionFn, +) -> InitMirrorRepo4RebuildFn: + def _init_mirror_repo_for_rebuild( + mirror_repo_dir: Path, + configuration_step: RepoActionConfigure, + ) -> Path: + # Create the mirror repo directory + mirror_repo_dir.mkdir(exist_ok=True, parents=True) + + # Initialize mirror repository + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=[configuration_step], + ) + + # Force custom changelog to be a copy of the default changelog (md and rst) + shutil.copytree( + src=default_changelog_md_template.parent, + dst=mirror_repo_dir / changelog_template_dir, + dirs_exist_ok=True, + ) + shutil.copytree( + src=default_changelog_rst_template.parent, + dst=mirror_repo_dir / changelog_template_dir, + dirs_exist_ok=True, + ) + + with Repo(mirror_repo_dir) as mirror_git_repo: + mirror_git_repo.git.add(str(changelog_template_dir)) + + return mirror_repo_dir + + return _init_mirror_repo_for_rebuild + + +@pytest.fixture(scope="session") +def long_hash_pattern() -> Pattern: + return regexp(r"\b([0-9a-f]{40})\b", IGNORECASE) + + +@pytest.fixture(scope="session") +def short_hash_pattern() -> Pattern: + return regexp(r"\b([0-9a-f]{7})\b", IGNORECASE) + + +@pytest.fixture(scope="session") +def get_sanitized_rst_changelog_content( + changelog_rst_file: Path, + default_rst_changelog_insertion_flag: str, + long_hash_pattern: Pattern, + short_hash_pattern: Pattern, +) -> GetSanitizedRstChangelogContentFn: + rst_short_hash_link_pattern = regexp(r"(_[0-9a-f]{7})\b", IGNORECASE) + + def _get_sanitized_rst_changelog_content(repo_dir: Path) -> str: + # TODO: v10 change -- default turns to update so this is not needed + # Because we are in init mode, the insertion flag is not present in the changelog + # we must take it out manually because our repo generation fixture includes it automatically + with (repo_dir / changelog_rst_file).open(newline=os.linesep) as rfd: + # use os.linesep here because the insertion flag is os-specific + # but convert the content to universal newlines for comparison + changelog_content = ( + rfd.read() + .replace(f"{default_rst_changelog_insertion_flag}{os.linesep}", "") + .replace("\r", "") + ) + + changelog_content = long_hash_pattern.sub("0" * 40, changelog_content) + changelog_content = short_hash_pattern.sub("0" * 7, changelog_content) + return rst_short_hash_link_pattern.sub(f'_{"0" * 7}', changelog_content) + + return _get_sanitized_rst_changelog_content + + +@pytest.fixture(scope="session") +def get_sanitized_md_changelog_content( + changelog_md_file: Path, + default_md_changelog_insertion_flag: str, + long_hash_pattern: Pattern, + short_hash_pattern: Pattern, +) -> GetSanitizedMdChangelogContentFn: + def _get_sanitized_md_changelog_content(repo_dir: Path) -> str: + # TODO: v10 change -- default turns to update so this is not needed + # Because we are in init mode, the insertion flag is not present in the changelog + # we must take it out manually because our repo generation fixture includes it automatically + with (repo_dir / changelog_md_file).open(newline=os.linesep) as rfd: + # use os.linesep here because the insertion flag is os-specific + # but convert the content to universal newlines for comparison + changelog_content = ( + rfd.read() + .replace(f"{default_md_changelog_insertion_flag}{os.linesep}", "") + .replace("\r", "") + ) + + changelog_content = long_hash_pattern.sub("0" * 40, changelog_content) + + return short_hash_pattern.sub("0" * 7, changelog_content) + + return _get_sanitized_md_changelog_content diff --git a/tests/e2e/cmd_version/bump_version/git_flow/__init__.py b/tests/e2e/cmd_version/bump_version/git_flow/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py new file mode 100644 index 000000000..281b97e71 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.git_flow import ( + repo_w_git_flow_angular_commits, + repo_w_git_flow_emoji_commits, + repo_w_git_flow_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_git_flow_angular_commits.__name__, + repo_w_git_flow_emoji_commits.__name__, + repo_w_git_flow_scipy_commits.__name__, + ], +) +def test_gitflow_repo_rebuild_1_channel( + repo_fixture_name: str, + cli_runner: CliRunner, + build_git_flow_repo_w_1_release_channels: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_git_flow_repo_w_1_release_channels( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py new file mode 100644 index 000000000..e61b640b6 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.git_flow import ( + repo_w_git_flow_w_alpha_prereleases_n_angular_commits, + repo_w_git_flow_w_alpha_prereleases_n_emoji_commits, + repo_w_git_flow_w_alpha_prereleases_n_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_git_flow_w_alpha_prereleases_n_angular_commits.__name__, + repo_w_git_flow_w_alpha_prereleases_n_emoji_commits.__name__, + repo_w_git_flow_w_alpha_prereleases_n_scipy_commits.__name__, + ], +) +def test_gitflow_repo_rebuild_2_channels( + repo_fixture_name: str, + cli_runner: CliRunner, + build_git_flow_repo_w_2_release_channels: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_git_flow_repo_w_2_release_channels( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py new file mode 100644 index 000000000..e815089f0 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.git_flow import ( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_emoji_commits, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits.__name__, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format.__name__, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_emoji_commits.__name__, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_scipy_commits.__name__, + ], +) +def test_gitflow_repo_rebuild_3_channels( + repo_fixture_name: str, + cli_runner: CliRunner, + build_git_flow_repo_w_3_release_channels: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_git_flow_repo_w_3_release_channels( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py new file mode 100644 index 000000000..d701e50ef --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.git_flow import ( + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_angular_commits, + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_emoji_commits, + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_angular_commits.__name__, + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_emoji_commits.__name__, + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_scipy_commits.__name__, + ], +) +def test_gitflow_repo_rebuild_4_channels( + repo_fixture_name: str, + cli_runner: CliRunner, + build_git_flow_repo_w_4_release_channels: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_git_flow_repo_w_4_release_channels( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/github_flow/__init__.py b/tests/e2e/cmd_version/bump_version/github_flow/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py new file mode 100644 index 000000000..796f0fcfe --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.github_flow import ( + repo_w_github_flow_w_default_release_channel_angular_commits, + repo_w_github_flow_w_default_release_channel_emoji_commits, + repo_w_github_flow_w_default_release_channel_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_github_flow_w_default_release_channel_angular_commits.__name__, + repo_w_github_flow_w_default_release_channel_emoji_commits.__name__, + repo_w_github_flow_w_default_release_channel_scipy_commits.__name__, + ], +) +def test_githubflow_repo_rebuild_1_channel( + repo_fixture_name: str, + cli_runner: CliRunner, + build_repo_w_github_flow_w_default_release_channel: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_w_github_flow_w_default_release_channel( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py new file mode 100644 index 000000000..4f50dd805 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.github_flow import ( + repo_w_github_flow_w_feature_release_channel_angular_commits, + repo_w_github_flow_w_feature_release_channel_emoji_commits, + repo_w_github_flow_w_feature_release_channel_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_github_flow_w_feature_release_channel_angular_commits.__name__, + repo_w_github_flow_w_feature_release_channel_emoji_commits.__name__, + repo_w_github_flow_w_feature_release_channel_scipy_commits.__name__, + ], +) +def test_githubflow_repo_rebuild_2_channels( + repo_fixture_name: str, + cli_runner: CliRunner, + build_repo_w_github_flow_w_feature_release_channel: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] # type: ignore[assignment] + ) + target_repo_definition = build_repo_w_github_flow_w_feature_release_channel( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/__init__.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py new file mode 100644 index 000000000..40f4a6fe8 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.trunk_based_dev import ( + repo_w_trunk_only_angular_commits, + repo_w_trunk_only_emoji_commits, + repo_w_trunk_only_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_trunk_only_angular_commits.__name__, + repo_w_trunk_only_emoji_commits.__name__, + repo_w_trunk_only_scipy_commits.__name__, + ], +) +def test_trunk_repo_rebuild_only_official_releases( + repo_fixture_name: str, + cli_runner: CliRunner, + build_trunk_only_repo_w_tags: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_trunk_only_repo_w_tags( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py new file mode 100644 index 000000000..cce770333 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.trunk_based_dev import ( + repo_w_trunk_only_n_prereleases_angular_commits, + repo_w_trunk_only_n_prereleases_emoji_commits, + repo_w_trunk_only_n_prereleases_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_trunk_only_n_prereleases_angular_commits.__name__, + repo_w_trunk_only_n_prereleases_emoji_commits.__name__, + repo_w_trunk_only_n_prereleases_scipy_commits.__name__, + ], +) +def test_trunk_repo_rebuild_w_prereleases( + repo_fixture_name: str, + cli_runner: CliRunner, + build_trunk_only_repo_w_prerelease_tags: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_trunk_only_repo_w_prerelease_tags( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + prerelease_args = ( + [ + "--as-prerelease", + "--prerelease-token", + ( + release_action_step["details"]["version"] + .split("-", maxsplit=1)[-1] + .split(".", maxsplit=1)[0] + ), + ] + if len(release_action_step["details"]["version"].split("-", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [ + MAIN_PROG_NAME, + "--strict", + VERSION_SUBCMD, + *build_metadata_args, + *prerelease_args, + ] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/fixtures/example_project.py b/tests/fixtures/example_project.py index 32da63155..83d0b6171 100644 --- a/tests/fixtures/example_project.py +++ b/tests/fixtures/example_project.py @@ -9,6 +9,9 @@ import pytest import tomlkit +# NOTE: use backport with newer API +from importlib_resources import files + import semantic_release from semantic_release.commit_parser import ( AngularCommitParser, @@ -30,13 +33,14 @@ EXAMPLE_SETUP_CFG_CONTENT, EXAMPLE_SETUP_PY_CONTENT, ) -from tests.util import copy_dir_tree +from tests.util import copy_dir_tree, temporary_working_directory if TYPE_CHECKING: from typing import Any, Protocol, Sequence from semantic_release.commit_parser import CommitParser from semantic_release.hvcs import HvcsBase + from semantic_release.version.version import Version from tests.conftest import ( BuildRepoOrCopyCacheFn, @@ -67,6 +71,9 @@ def __call__(self) -> type[CommitParser]: ... class UseReleaseNotesTemplateFn(Protocol): def __call__(self) -> None: ... + class UpdateVersionPyFileFn(Protocol): + def __call__(self, version: Version | str) -> None: ... + @pytest.fixture(scope="session") def deps_files_4_example_project() -> list[Path]: @@ -93,12 +100,14 @@ def build_spec_hash_4_example_project( @pytest.fixture(scope="session") def cached_example_project( build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + version_py_file: Path, pyproject_toml_file: Path, setup_cfg_file: Path, setup_py_file: Path, changelog_md_file: Path, changelog_rst_file: Path, build_spec_hash_4_example_project: str, + update_version_py_file: UpdateVersionPyFileFn, ) -> Path: """ Initializes the example project. DO NOT USE DIRECTLY @@ -108,12 +117,11 @@ def cached_example_project( def _build_project(cached_project_path: Path) -> Sequence[RepoActions]: # purposefully a relative path - example_dir = Path("src", EXAMPLE_PROJECT_NAME) - version_py = example_dir / "_version.py" + example_dir = version_py_file.parent gitignore_contents = dedent( f""" *.pyc - /src/**/{version_py.name} + /src/**/{version_py_file.name} """ ).lstrip() init_py_contents = dedent( @@ -128,15 +136,12 @@ def hello_world() -> None: print("Hello World") ''' ).lstrip() - version_py_contents = dedent( - f""" - __version__ = "{EXAMPLE_PROJECT_VERSION}" - """ - ).lstrip() + + with temporary_working_directory(cached_project_path): + update_version_py_file(EXAMPLE_PROJECT_VERSION) file_2_contents: list[tuple[str | Path, str]] = [ (example_dir / "__init__.py", init_py_contents), - (version_py, version_py_contents), (".gitignore", gitignore_contents), (pyproject_toml_file, EXAMPLE_PYPROJECT_TOML_CONTENT), (setup_cfg_file, EXAMPLE_SETUP_CFG_CONTENT), @@ -188,6 +193,11 @@ def example_project_with_release_notes_template( use_release_notes_template() +@pytest.fixture(scope="session") +def version_py_file() -> Path: + return Path("src", EXAMPLE_PROJECT_NAME, "_version.py") + + @pytest.fixture(scope="session") def pyproject_toml_file() -> Path: return Path("pyproject.toml") @@ -233,6 +243,30 @@ def default_rst_changelog_insertion_flag() -> str: return f"..{os.linesep} version list" +@pytest.fixture(scope="session") +def default_changelog_md_template() -> Path: + """Retrieve the semantic-release default changelog template file""" + return Path( + str( + files(semantic_release.__name__).joinpath( + Path("data", "templates", "angular", "md", "CHANGELOG.md.j2") + ) + ) + ).resolve() + + +@pytest.fixture(scope="session") +def default_changelog_rst_template() -> Path: + """Retrieve the semantic-release default changelog template file""" + return Path( + str( + files(semantic_release.__name__).joinpath( + Path("data", "templates", "angular", "rst", "CHANGELOG.rst.j2") + ) + ) + ).resolve() + + @pytest.fixture(scope="session") def get_wheel_file(dist_dir: Path) -> GetWheelFileFn: def _get_wheel_file(version_str: str) -> Path: @@ -346,6 +380,22 @@ def example_project_template_dir( return example_project_dir / changelog_template_dir +@pytest.fixture(scope="session") +def update_version_py_file(version_py_file: Path) -> UpdateVersionPyFileFn: + def _update_version_py_file(version: Version | str) -> None: + cwd_version_py = version_py_file.resolve() + cwd_version_py.parent.mkdir(parents=True, exist_ok=True) + cwd_version_py.write_text( + dedent( + f"""\ + __version__ = "{version}" + """ + ) + ) + + return _update_version_py_file + + @pytest.fixture(scope="session") def update_pyproject_toml(pyproject_toml_file: Path) -> UpdatePyprojectTomlFn: """Update the pyproject.toml file with the given content.""" diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 360016313..b5c872007 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -29,13 +29,14 @@ from tests.util import ( add_text_to_file, copy_dir_tree, - shortuid, temporary_working_directory, ) if TYPE_CHECKING: from typing import Generator, Literal, Protocol, Sequence, TypedDict, Union + from tests.fixtures.example_project import UpdateVersionPyFileFn + try: # Python 3.8 and 3.9 compatibility from typing_extensions import TypeAlias @@ -360,6 +361,14 @@ def __call__( RepoActionGitMerge, ] + class GetGitRepo4DirFn(Protocol): + def __call__(self, directory: Path | str) -> Repo: ... + + class SplitRepoActionsByReleaseTagsFn(Protocol): + def __call__( + self, repo_definition: Sequence[RepoActions], tag_format_str: str + ) -> dict[str, list[RepoActions]]: ... + @pytest.fixture(scope="session") def deps_files_4_example_git_project( @@ -450,7 +459,7 @@ def default_tag_format_str() -> str: @pytest.fixture(scope="session") def file_in_repo(): - return f"file-{shortuid()}.txt" + return "file.txt" @pytest.fixture(scope="session") @@ -758,6 +767,7 @@ def _create_squash_merge_commit( @pytest.fixture(scope="session") def create_release_tagged_commit( update_pyproject_toml: UpdatePyprojectTomlFn, + update_version_py_file: UpdateVersionPyFileFn, default_tag_format_str: str, stable_now_date: GetStableDateNowFn, ) -> CreateReleaseFn: @@ -775,6 +785,9 @@ def _mimic_semantic_release_commit( if curr_dt == commit_dt: sleep(1) # ensure commit timestamps are unique + # stamp version into version file + update_version_py_file(version) + # stamp version into pyproject.toml update_pyproject_toml("tool.poetry.version", version) @@ -928,15 +941,19 @@ def _build_configured_base_repo( # noqa: C901 update_pyproject_toml( # NOTE: must work in both bash and Powershell "tool.semantic_release.build_command", + # NOTE: we are trying to ensure a few non-file-path characters are removed, but this is not + # the equivalent of a cononcial version translator, so it may not work in all cases dedent( f"""\ mkdir -p "{build_result_file.parent}" - touch "{build_result_file}" + WHEEL_FILE="$(printf '%s' "{build_result_file}" | sed 's/+/./g')" + touch "$WHEEL_FILE" """ if sys.platform != "win32" else f"""\ mkdir {build_result_file.parent} > $null - New-Item -ItemType file -Path "{build_result_file}" -Force | Select-Object OriginalPath + $WHEEL_FILE = "{build_result_file}".Replace('+', '.') + New-Item -ItemType file -Path "$WHEEL_FILE" -Force | Select-Object OriginalPath """ ), ) @@ -1253,6 +1270,58 @@ def _get_commits( return _get_commits +@pytest.fixture(scope="session") +def split_repo_actions_by_release_tags( + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, +) -> SplitRepoActionsByReleaseTagsFn: + def _split_repo_actions_by_release_tags( + repo_definition: Sequence[RepoActions], + tag_format_str: str, + ) -> dict[str, list[RepoActions]]: + releasetags_2_steps: dict[str, list[RepoActions]] = { + "": [], + } + + # Create generator for next release tags + next_release_tag_gen = ( + tag_format_str.format(version=version) + for version in get_versions_from_repo_build_def(repo_definition) + ) + + # initialize the first release tag + curr_release_tag = next(next_release_tag_gen) + releasetags_2_steps[curr_release_tag] = [] + + # Loop through all actions and split them by release tags + for step in repo_definition: + if step["action"] == RepoActionStep.CONFIGURE: + releasetags_2_steps[""].append(step) + continue + + if step["action"] == RepoActionStep.WRITE_CHANGELOGS: + continue + + releasetags_2_steps[curr_release_tag].append(step) + + if step["action"] == RepoActionStep.RELEASE: + try: + curr_release_tag = next(next_release_tag_gen) + releasetags_2_steps[curr_release_tag] = [] + except StopIteration: + curr_release_tag = "Unreleased" + releasetags_2_steps[curr_release_tag] = [] + + if ( + "Unreleased" in releasetags_2_steps + and not releasetags_2_steps["Unreleased"] + ): + del releasetags_2_steps["Unreleased"] + + return releasetags_2_steps + + return _split_repo_actions_by_release_tags + + @pytest.fixture(scope="session") def simulate_default_changelog_creation( # noqa: C901 default_md_changelog_insertion_flag: str, @@ -1637,22 +1706,31 @@ def _mimic_semantic_release_default_changelog( @pytest.fixture -def example_project_git_repo( - example_project_dir: ExProjectDir, -) -> Generator[ExProjectGitRepoFn, None, None]: +def git_repo_for_directory() -> Generator[GetGitRepo4DirFn, None, None]: repos: list[Repo] = [] # Must be a callable function to ensure files exist before repo is opened - def _example_project_git_repo() -> Repo: - if not example_project_dir.exists(): - raise RuntimeError("Unable to find example git project!") + def _git_repo_4_dir(directory: Path | str) -> Repo: + if not Path(directory).exists(): + raise RuntimeError("Unable to find git project!") - repo = Repo(example_project_dir) + repo = Repo(directory) repos.append(repo) return repo try: - yield _example_project_git_repo + yield _git_repo_4_dir finally: for repo in repos: repo.close() + + +@pytest.fixture +def example_project_git_repo( + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, +) -> ExProjectGitRepoFn: + def _example_project_git_repo() -> Repo: + return git_repo_for_directory(example_project_dir) + + return _example_project_git_repo diff --git a/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py b/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py index 16524bf7e..75b614245 100644 --- a/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py +++ b/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py @@ -182,7 +182,7 @@ def _get_repo_from_defintion( "prerelease": False, }, "tool.semantic_release.allow_zero_version": True, - "tool.semantic_release.major_on_zero": False, + "tool.semantic_release.major_on_zero": True, **(extra_configs or {}), }, }, diff --git a/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py index b1b39eff0..52e8cdb36 100644 --- a/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py @@ -188,7 +188,7 @@ def _get_repo_from_defintion( "prerelease_token": "alpha", }, "tool.semantic_release.allow_zero_version": True, - "tool.semantic_release.major_on_zero": False, + "tool.semantic_release.major_on_zero": True, **(extra_configs or {}), }, }, diff --git a/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py index 2cbbdd2fa..32f22a7c6 100644 --- a/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py @@ -196,7 +196,7 @@ def _get_repo_from_defintion( "prerelease_token": "alpha", }, "tool.semantic_release.allow_zero_version": True, - "tool.semantic_release.major_on_zero": False, + "tool.semantic_release.major_on_zero": True, **(extra_configs or {}), }, }, diff --git a/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py index e12c01fc1..204a41ea3 100644 --- a/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py @@ -612,7 +612,8 @@ def _get_repo_from_defintion( ) # Add a new feature (another developer was working on) and create a release for it - new_version = f"1.1.0-rev.1+{FEAT_BRANCH_2_NAME}" + # Based on Semver standard, Build metadata is restricted to [A-Za-z0-9-] so we replace the '/' with a '-' + new_version = f"""1.1.0-rev.1+{FEAT_BRANCH_2_NAME.replace("/", '-')}""" repo_construction_steps.extend( [ *fast_forward_dev_branch_actions, diff --git a/tests/unit/semantic_release/version/test_algorithm.py b/tests/unit/semantic_release/version/test_algorithm.py index c48d89e22..431f7796b 100644 --- a/tests/unit/semantic_release/version/test_algorithm.py +++ b/tests/unit/semantic_release/version/test_algorithm.py @@ -1,32 +1,42 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest import mock + import pytest from git import Commit, Repo, TagReference from semantic_release.enums import LevelBump from semantic_release.version.algorithm import ( - _bfs_for_latest_version_in_history, _increment_version, + _traverse_graph_for_commits, tags_and_versions, ) from semantic_release.version.translator import VersionTranslator from semantic_release.version.version import Version +from tests.fixtures.repos import repo_w_initial_commit + +if TYPE_CHECKING: + from typing import Sequence + -def test_bfs_for_latest_version_in_history(): +@pytest.mark.usefixtures(repo_w_initial_commit.__name__) +def test_traverse_graph_for_commits(): # Setup fake git graph """ - * merge commit 6 (start) + * merge commit 6 (start) [3636363] |\ - | * commit 5 - | * commit 4 + | * commit 5 [3535353] + | * commit 4 [3434343] |/ - * commit 3 - * commit 2 - * commit 1 - * v1.0.0 + * commit 3 [3333333] + * commit 2 [3232323] + * commit 1 [3131313] + * v1.0.0 [3030303] """ repo = Repo() - expected_version = Version.parse("1.0.0") - v1_commit = Commit(repo, binsha=b"0" * 20) + v1_commit = Commit(repo, binsha=b"0" * 20, parents=[]) class TagReferenceOverride(TagReference): commit = v1_commit # mocking the commit property @@ -61,16 +71,36 @@ class TagReferenceOverride(TagReference): ], ) + commit_1 = trunk.parents[0].parents[0] + commit_2 = trunk.parents[0] + commit_3 = trunk + commit_4 = start_commit.parents[1].parents[0] + commit_5 = start_commit.parents[1] + commit_6 = start_commit + + expected_commit_order = [ + commit_6.hexsha, + commit_5.hexsha, + commit_4.hexsha, + commit_3.hexsha, + commit_2.hexsha, + commit_1.hexsha, + ] + # Execute - actual = _bfs_for_latest_version_in_history( - start_commit, - [ - (v1_tag, expected_version), - ], - ) + with mock.patch.object( + repo, repo.iter_commits.__name__, return_value=iter([v1_commit]) + ): + actual_commit_order = [ + commit.hexsha + for commit in _traverse_graph_for_commits( + head_commit=start_commit, + latest_release_tag_str=v1_tag.name, + ) + ] # Verify - assert expected_version == (actual or "") + assert expected_commit_order == actual_commit_order @pytest.mark.parametrize( @@ -114,7 +144,7 @@ class TagReferenceOverride(TagReference): ), ], ) -def test_sorted_repo_tags_and_versions(tags, sorted_tags): +def test_sorted_repo_tags_and_versions(tags: list[str], sorted_tags: list[str]): repo = Repo() translator = VersionTranslator() tagrefs = [repo.tag(tag) for tag in tags] @@ -178,7 +208,9 @@ def test_sorted_repo_tags_and_versions(tags, sorted_tags): ], ) def test_tags_and_versions_ignores_invalid_tags_as_versions( - tag_format, invalid_tags, valid_tags + tag_format: str, + invalid_tags: Sequence[str], + valid_tags: Sequence[str], ): repo = Repo() translator = VersionTranslator(tag_format=tag_format) @@ -188,197 +220,81 @@ def test_tags_and_versions_ignores_invalid_tags_as_versions( @pytest.mark.parametrize( - "latest_version, latest_full_version, latest_full_version_in_history, level_bump, " - "prerelease, prerelease_token, expected_version", + str.join( + ", ", + [ + "latest_version", + "latest_full_version", + "level_bump", + "prerelease", + "prerelease_token", + "expected_version", + ], + ), [ # NOTE: level_bump != LevelBump.NO_RELEASE, we return early in the # algorithm to discount this case - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.PRERELEASE_REVISION, - False, - "rc", - "1.0.0-rc.1", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.PRERELEASE_REVISION, - True, - "rc", - "1.0.0-rc.1", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.PATCH, - False, - "rc", - "1.0.1", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.PATCH, - True, - "rc", - "1.0.1-rc.1", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.MINOR, - False, - "rc", - "1.1.0", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.MINOR, - True, - "rc", - "1.1.0-rc.1", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.MAJOR, - False, - "rc", - "2.0.0", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.MAJOR, - True, - "rc", - "2.0.0-rc.1", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.PATCH, - False, - "rc", - "1.2.4", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.PATCH, - True, - "rc", - "1.2.4-rc.2", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.MINOR, - False, - "rc", - "1.3.0", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.MINOR, - True, - "rc", - "1.3.0-rc.1", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.MAJOR, - False, - "rc", - "2.0.0", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.MAJOR, - True, - "rc", - "2.0.0-rc.1", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.PATCH, - False, - "rc", - "2.0.0", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.PATCH, - True, - "rc", - "2.0.0-rc.2", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.MINOR, - False, - "rc", - "2.0.0", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.MINOR, - True, - "rc", - "2.0.0-rc.2", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.MAJOR, - False, - "rc", - "2.0.0", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.MAJOR, - True, - "rc", - "2.0.0-rc.2", - ), + *[ + ( + "1.0.0", + "1.0.0", + bump_level, + prerelease, + "rc", + expected_version, + ) + for bump_level, prerelease, expected_version in [ + (LevelBump.PRERELEASE_REVISION, False, "1.0.0-rc.1"), + (LevelBump.PRERELEASE_REVISION, True, "1.0.0-rc.1"), + (LevelBump.PATCH, False, "1.0.1"), + (LevelBump.PATCH, True, "1.0.1-rc.1"), + (LevelBump.MINOR, False, "1.1.0"), + (LevelBump.MINOR, True, "1.1.0-rc.1"), + (LevelBump.MAJOR, False, "2.0.0"), + (LevelBump.MAJOR, True, "2.0.0-rc.1"), + ] + ], + *[ + ( + "1.2.4-rc.1", + "1.2.3", + bump_level, + prerelease, + "rc", + expected_version, + ) + for bump_level, prerelease, expected_version in [ + (LevelBump.PATCH, False, "1.2.4"), + (LevelBump.PATCH, True, "1.2.4-rc.2"), + (LevelBump.MINOR, False, "1.3.0"), + (LevelBump.MINOR, True, "1.3.0-rc.1"), + (LevelBump.MAJOR, False, "2.0.0"), + (LevelBump.MAJOR, True, "2.0.0-rc.1"), + ] + ], + *[ + ( + "2.0.0-rc.1", + "1.22.0", + bump_level, + prerelease, + "rc", + expected_version, + ) + for bump_level, prerelease, expected_version in [ + (LevelBump.PATCH, False, "2.0.0"), + (LevelBump.PATCH, True, "2.0.0-rc.2"), + (LevelBump.MINOR, False, "2.0.0"), + (LevelBump.MINOR, True, "2.0.0-rc.2"), + (LevelBump.MAJOR, False, "2.0.0"), + (LevelBump.MAJOR, True, "2.0.0-rc.2"), + ] + ], ], ) def test_increment_version_no_major_on_zero( latest_version: str, latest_full_version: str, - latest_full_version_in_history: str, level_bump: LevelBump, prerelease: bool, prerelease_token: str, @@ -387,7 +303,6 @@ def test_increment_version_no_major_on_zero( actual = _increment_version( latest_version=Version.parse(latest_version), latest_full_version=Version.parse(latest_full_version), - latest_full_version_in_history=Version.parse(latest_full_version_in_history), level_bump=level_bump, prerelease=prerelease, prerelease_token=prerelease_token, diff --git a/tests/util.py b/tests/util.py index 194d1f377..97dc18b5c 100644 --- a/tests/util.py +++ b/tests/util.py @@ -151,9 +151,14 @@ def shortuid(length: int = 8) -> str: def add_text_to_file(repo: Repo, filename: str, text: str | None = None): - with open(f"{repo.working_tree_dir}/{filename}", "a+") as f: - f.write(text or f"default text {shortuid(12)}") - f.write(os.linesep) + """Makes a deterministic file change for testing""" + tgt_file = Path(repo.working_tree_dir or ".") / filename + tgt_file.parent.mkdir(parents=True, exist_ok=True) + file_contents = tgt_file.read_text() if tgt_file.exists() else "" + line_number = len(file_contents.splitlines()) + + file_contents += f"{line_number} {text or 'default text'}{os.linesep}" + tgt_file.write_text(file_contents, encoding="utf-8") repo.index.add(filename)