diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 97a92c06a..0ba52199b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -255,6 +255,10 @@ and is not present in the default branch. [optional footer(s)] ``` +The header line (`(): `) must not exceed **100 characters**. +This limit is enforced by commitlint in CI and will cause the pipeline to fail. +Always check the length of the header before committing. + Scopes by the specification are optional but for this project, they are required and only by exception can they be omitted. @@ -548,3 +552,7 @@ itself to perform the release steps. The release process includes: versioning. Make as few breaking changes as possible by adding backwards compatibility and if you do make a breaking change, be sure to include a detailed description in the `BREAKING CHANGE` footer of the commit message. + +- Never disable or bypass pre-commit hooks (e.g. do not use `git commit --no-verify`). + All hooks must remain active and pass before committing. If a hook is failing, fix the + underlying issue rather than skipping the check. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5ae875b90..df2f730ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ default_language_version: python: python3 -exclude: "^CHANGELOG.md$" +exclude: "^CHANGELOG.rst$" repos: # Meta hooks @@ -56,9 +56,22 @@ repos: additional_dependencies: - "pydantic>=2,<3" - "types-requests" + - "types-Deprecated" + - "types-pyyaml" + - "click~=8.1" + - "gitpython~=3.0" + - "jinja2~=3.1" + - "PyGithub~=2.0" + - "python-gitlab>=4.0.0,<7.0.0" + - "tomlkit~=0.13.0" + - "rich~=14.0" + - "shellingham~=1.5" + - "importlib-resources~=6.0" + - "click-option-group~=0.5" log_file: "mypy.log" files: "^(src|tests)/.*" pass_filenames: false + args: ["src"] - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 @@ -80,8 +93,9 @@ repos: - --min-confidence - "100" - --sort-by-size - - "semantic_release" + - "src/semantic_release" - "tests" + pass_filenames: false - repo: https://github.com/pycqa/bandit rev: 1.7.8 diff --git a/docs/concepts/commit_parsing.rst b/docs/concepts/commit_parsing.rst index 296169c52..c36b2b131 100644 --- a/docs/concepts/commit_parsing.rst +++ b/docs/concepts/commit_parsing.rst @@ -443,11 +443,12 @@ Common Issue Identifier Detection All of the PSR built-in parsers implement common issue identifier detection logic, which is similar to many VCS platforms such as GitHub, GitLab, and BitBucket. The -parsers will look for common issue closure text prefixes in the `Git Trailer format`_ -in the commit message to identify and extract issue numbers. The detection logic is -not strict to any specific issue tracker as we try to provide a flexible approach -to identifying issue numbers but in order to be flexible, it is **required** to the -use the `Git Trailer format`_ with a colon (``:``) as the token separator. +parsers will look for common issue closure text prefixes in commit message footers +to identify and extract issue numbers. The detection logic is not strict to any specific +issue tracker as we try to provide a flexible approach to identifying issue numbers. +PSR supports the `Git Trailer format`_ with a colon (``:``) as the token separator, +as well as the more casual format without a colon (e.g., ``Closes #123`` or ``Closes: #123``) +to match common VCS platform conventions. PSR attempts to support all variants of issue closure text prefixes, but not all will work for your VCS. PSR supports the following case-insensitive prefixes and their conjugations @@ -466,13 +467,13 @@ the need of extra git trailers (although PSR does support multiple git trailers) various list formats which can be used to identify more than one issue in a list. This format will not necessarily work on your VCS. PSR currently support the following list formats: -- comma-separated (ex. ``Closes: #123, #456, #789``) -- space-separated (ex. ``resolve: #123 #456 #789``) -- semicolon-separated (ex. ``Fixes: #123; #456; #789``) -- slash-separated (ex. ``close: #123/#456/#789``) -- ampersand-separated (ex. ``Implement: #123 & #789``) -- and-separated (ex. ``Resolve: #123 and #456 and #789``) -- mixed (ex. ``Closed: #123, #456, and #789`` or ``Fixes: #123, #456 & #789``) +- comma-separated (ex. ``Closes #123, #456, #789`` or ``Closes: #123, #456, #789``) +- space-separated (ex. ``resolve #123 #456 #789`` or ``resolve: #123 #456 #789``) +- semicolon-separated (ex. ``Fixes #123; #456; #789`` or ``Fixes: #123; #456; #789``) +- slash-separated (ex. ``close #123/#456/#789`` or ``close: #123/#456/#789``) +- ampersand-separated (ex. ``Implement #123 & #789`` or ``Implement: #123 & #789``) +- and-separated (ex. ``Resolve #123 and #456 and #789`` or ``Resolve: #123 and #456 and #789``) +- mixed (ex. ``Closed #123, #456, and #789`` or ``Fixes #123, #456 & #789`` or with colons) All the examples above use the most common issue number prefix (``#``) but PSR is flexible to support other prefixes used by VCS platforms or issue trackers such as JIRA (ex. ``ABC-###``). diff --git a/parse_errors.py b/parse_errors.py new file mode 100644 index 000000000..d94aa2ca8 --- /dev/null +++ b/parse_errors.py @@ -0,0 +1,45 @@ +import xml.etree.ElementTree as ET # noqa: S314 # safe use in offline parsing of test results + + +def parse_failures() -> None: + # return type is None since this is a standalone utility + + try: + tree = ET.parse("test_results.xml") # noqa: S314 + root = tree.getroot() + + with open("test_results.txt", "w", encoding="utf-8") as f: + failure_count = 0 + + for testcase in root.iter("testcase"): + # Look for failure or error tags inside the testcase + failure = testcase.find("failure") + error = testcase.find("error") + + issue = failure if failure is not None else error + + if issue is not None: + failure_count += 1 + file_path = testcase.get("file") + test_name = testcase.get("name") + error_msg = issue.get("message") + # Get the detailed traceback text + traceback = issue.text if issue.text else "No traceback available" + + f.write(f"--- FAILURE #{failure_count} ---\n") + f.write(f"FILE: {file_path}\n") + f.write(f"TEST: {test_name}\n") + f.write(f"MESSAGE: {error_msg}\n") + f.write(f"DETAILS:\n{traceback.strip()}\n") + f.write("\n" + "=" * 40 + "\n\n") + + print(f"Done! Found {failure_count} failures. Saved to 'test_results.txt'.") # noqa: T201 + + except FileNotFoundError: + print("Error: Could not find 'test_results.xml'. Did the tests finish running?") # noqa: T201 + except Exception as e: # noqa: BLE001,S314 + print(f"An error occurred: {e}") # noqa: T201 + + +if __name__ == "__main__": + parse_failures() diff --git a/pyproject.toml b/pyproject.toml index 19b636cb7..1f37a9241 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "gitpython ~= 3.0", "requests ~= 2.25", "jinja2 ~= 3.1", + "PyGithub ~= 2.0", "python-gitlab >= 4.0.0, < 7.0.0", "tomlkit ~= 0.13.0", "dotty-dict ~= 1.3", @@ -406,7 +407,7 @@ section-order = [ sections = { "tests" = ["tests"] } [tool.vulture] -ignore_names = ["change_to_ex_proj_dir", "init_example_project"] +ignore_names = ["change_to_ex_proj_dir", "init_example_project", "pluginmanager", "base_repo_def", "init_example_monorepo"] [tool.semantic_release] add_partial_tags = true diff --git a/src/semantic_release/changelog/context.py b/src/semantic_release/changelog/context.py index 563f6a3f4..2bf6d6c48 100644 --- a/src/semantic_release/changelog/context.py +++ b/src/semantic_release/changelog/context.py @@ -30,6 +30,7 @@ class ReleaseNotesContext: release: Release mask_initial_release: bool license_name: str + include_pypi_link: bool = False filters: tuple[Callable[..., Any], ...] = () def bind_to_environment(self, env: Environment) -> Environment: diff --git a/src/semantic_release/cli/changelog_writer.py b/src/semantic_release/cli/changelog_writer.py index 65e387896..e78b0c568 100644 --- a/src/semantic_release/cli/changelog_writer.py +++ b/src/semantic_release/cli/changelog_writer.py @@ -230,6 +230,7 @@ def generate_release_notes( style: str, mask_initial_release: bool, license_name: str = "", + include_pypi_link: bool = False, ) -> str: users_tpl_file = template_dir / DEFAULT_RELEASE_NOTES_TPL_FILE @@ -257,6 +258,7 @@ def generate_release_notes( release=release, mask_initial_release=mask_initial_release, license_name=license_name, + include_pypi_link=include_pypi_link, filters=( *hvcs_client.get_changelog_context_filters(), create_pypi_url, diff --git a/src/semantic_release/cli/commands/generate_config.py b/src/semantic_release/cli/commands/generate_config.py index 7d498b31e..ccded5e82 100644 --- a/src/semantic_release/cli/commands/generate_config.py +++ b/src/semantic_release/cli/commands/generate_config.py @@ -47,7 +47,12 @@ def generate_config( # due to possible IntEnum values (which are not supported by tomlkit.dumps, see sdispater/tomlkit#237), # we must ensure the transformation of the model to a dict uses json serializable values config_dct = { - "semantic_release": RawConfig().model_dump(mode="json", exclude_none=True) + "semantic_release": RawConfig().model_dump( + mode="json", + exclude_none=True, + # Drop deprecated changelog option to avoid emitting warnings in defaults + exclude={"changelog": {"changelog_file"}}, + ) } if is_pyproject_toml: diff --git a/src/semantic_release/cli/commands/main.py b/src/semantic_release/cli/commands/main.py index c10d3f647..352bd900d 100644 --- a/src/semantic_release/cli/commands/main.py +++ b/src/semantic_release/cli/commands/main.py @@ -4,7 +4,6 @@ import logging from enum import Enum -# from typing import TYPE_CHECKING import click from rich.console import Console from rich.logging import RichHandler @@ -17,10 +16,6 @@ from semantic_release.cli.util import rprint from semantic_release.enums import SemanticReleaseLogLevels -# if TYPE_CHECKING: -# pass - - FORMAT = "%(message)s" LOG_LEVELS = [ SemanticReleaseLogLevels.WARNING, @@ -30,7 +25,7 @@ ] -class Cli(click.MultiCommand): +class Cli(click.MultiCommand): # type: ignore[misc, valid-type] """Root MultiCommand for the semantic-release CLI""" class SubCmds(Enum): diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 7a6fa26ef..c5041aedc 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -5,6 +5,7 @@ import sys from collections import defaultdict from datetime import datetime, timezone +from pathlib import Path from typing import TYPE_CHECKING import click @@ -13,16 +14,21 @@ from git import GitCommandError, Repo from requests import HTTPError +from semantic_release.changelog.context import ReleaseNotesContext from semantic_release.changelog.release_history import ReleaseHistory +from semantic_release.changelog.template import environment from semantic_release.cli.changelog_writer import ( generate_release_notes, + get_default_tpl_dir, write_changelog_files, ) +from semantic_release.cli.config import ChangelogOutputFormat from semantic_release.cli.github_actions_output import ( PersistenceMode, VersionGitHubActionsOutput, ) from semantic_release.cli.util import noop_report, rprint +from semantic_release.commit_parser.token import ParsedCommit from semantic_release.const import DEFAULT_SHELL, DEFAULT_VERSION from semantic_release.enums import LevelBump from semantic_release.errors import ( @@ -52,7 +58,9 @@ from git.refs.tag import Tag + from semantic_release.changelog.release_history import Release from semantic_release.cli.cli_context import CliContextObj + from semantic_release.cli.config import RuntimeContext from semantic_release.version.declaration import IVersionReplacer from semantic_release.version.version import Version @@ -183,16 +191,21 @@ def shell( cmd: str, *, env: Mapping[str, str] | None = None, check: bool = True ) -> subprocess.CompletedProcess: shell: str | None + shell_path: str | None try: - shell, _ = shellingham.detect_shell() + shell, shell_path = shellingham.detect_shell() except shellingham.ShellDetectionFailure: logger.warning("failed to detect shell, using default shell: %s", DEFAULT_SHELL) logger.debug("stack trace", exc_info=True) shell = DEFAULT_SHELL + shell_path = DEFAULT_SHELL if not shell: raise TypeError("'shell' is None") + if not shell_path: + raise TypeError("'shell_path' is None") + shell_cmd_param = defaultdict( lambda: "-c", { @@ -203,7 +216,7 @@ def shell( ) return subprocess.run( # noqa: S603 - [shell, shell_cmd_param[shell], cmd], + [shell_path, shell_cmd_param[shell], cmd], env=(env or {}), check=check, ) @@ -275,7 +288,7 @@ def build_distributions( lambda k_v: k_v[1] is not None, # type: ignore[arg-type] { # Common values - "PATH": os.getenv("PATH", ""), + "PATH": os.getenv("PATH", None), "HOME": os.getenv("HOME", None), "VIRTUAL_ENV": os.getenv("VIRTUAL_ENV", None), # Windows environment variables @@ -304,6 +317,158 @@ def build_distributions( logger.exception(exc) logger.error("Build command failed with exit code %s", exc.returncode) # noqa: TRY400 raise BuildDistributionsError from exc + except FileNotFoundError as exc: + logger.exception(exc) + logger.error("Failed to execute build command: shell executable not found") # noqa: TRY400 + raise BuildDistributionsError("Shell executable not found") from exc + + +def _post_release_announcements( + hvcs_client: Github, + release_history: ReleaseHistory, + new_version: Version, + runtime: RuntimeContext, +) -> None: + """ + Post release announcements to linked issues and PRs. + + Extracts all linked issues and PRs from commits in the release, + renders appropriate announcement templates, and posts comments. + + :param hvcs_client: The GitHub HVCS client + :param release_history: The release history containing all commits + :param new_version: The new version being released + :param runtime: The CLI runtime context + """ + try: + # Get the release data for this version + release = release_history.released.get(new_version) + if not release: + logger.warning("Could not find release data for version %s", new_version) + return + + # Track processed issues/PRs to avoid duplicates + processed_issues: set[str] = set() + + # Extract all commits from the release + for commits in release["elements"].values(): + for commit in commits: + # Only process ParsedCommit objects (not ParseError) + if not isinstance(commit, ParsedCommit): + continue + + # Handle the linked PR first + if commit.linked_merge_request: + pr_ref = commit.linked_merge_request.lstrip("#GH-!") + if pr_ref and pr_ref not in processed_issues: + _post_announcement_to_issue( + hvcs_client=hvcs_client, + issue_id=pr_ref, + template_name=".pr_publish_announcement.md.j2", + release=release, + runtime=runtime, + ) + processed_issues.add(pr_ref) + + # Handle all linked issues + for issue_ref in commit.linked_issues: + issue_id = issue_ref.lstrip("#GH-!") + if issue_id and issue_id not in processed_issues: + _post_announcement_to_issue( + hvcs_client=hvcs_client, + issue_id=issue_id, + template_name=".issue_resolution_announcement.md.j2", + release=release, + runtime=runtime, + ) + processed_issues.add(issue_id) + + logger.info( + "Posted release announcements to %d issue(s)/PR(s)", len(processed_issues) + ) + + except Exception as exc: # noqa: BLE001 + # Best effort: don't fail the entire release if announcements fail + logger.warning("Failed to post release announcements: %s", exc, exc_info=True) + + +def _post_announcement_to_issue( + hvcs_client: Github, + issue_id: str, + template_name: str, + release: Release, + runtime: RuntimeContext, +) -> None: + """ + Post an announcement comment to a single issue or PR. + + :param hvcs_client: The GitHub HVCS client + :param issue_id: The issue or PR number + :param template_name: The template file name to render + :param release: The release object containing version and other metadata + :param runtime: The CLI runtime context + """ + try: + # Convert issue_id to int (it comes in as string from commit parsing) + issue_number = int(issue_id) + + # Create a template environment with proper filters using ReleaseNotesContext + # This ensures filters like create_release_url and format_w_official_vcs_name are available + from semantic_release.changelog.context import ( + autofit_text_width, + create_pypi_url, + ) + from semantic_release.helpers import sort_numerically + + template_context = ReleaseNotesContext( + repo_name=hvcs_client.repo_name, + repo_owner=hvcs_client.owner, + hvcs_type=hvcs_client.__class__.__name__.lower(), + version=release["version"], + release=release, + mask_initial_release=False, # Not applicable for announcements + license_name="", # Not applicable for announcements + filters=( + *hvcs_client.get_changelog_context_filters(), + create_pypi_url, + autofit_text_width, + sort_numerically, + ), + ) + + # Prefer user-provided template if present, else fall back to default + users_tpl_file = runtime.template_dir / template_name + if users_tpl_file.is_file(): + template_env = template_context.bind_to_environment( + runtime.template_environment + ) + else: + default_tpl_dir = get_default_tpl_dir( + style=runtime.changelog_style, + sub_dir=ChangelogOutputFormat.MARKDOWN.value, + ) + template_env = template_context.bind_to_environment( + environment(autoescape=False, template_dir=default_tpl_dir) + ) + + # Render the announcement template with proper filter context + template = template_env.get_template(template_name) + announcement = template.render() + + # Post the comment + hvcs_client.post_comment(issue_number, announcement) + logger.debug("Posted announcement to issue/PR #%d", issue_number) + + # Add the "released" label + hvcs_client.add_labels_to_issue(issue_number, ["released"]) + logger.debug("Added 'released' label to issue/PR #%d", issue_number) + + except ValueError as exc: + # Log if issue_id cannot be converted to int + logger.warning("Invalid issue/PR ID '%s': %s", issue_id, exc) + except Exception as exc: # noqa: BLE001 + # Best effort: log but continue processing other issues + logger.warning("Failed to post announcement to issue/PR #%s: %s", issue_id, exc) @click.command( @@ -833,6 +998,16 @@ def version( # noqa: C901 assets=assets, noop=opts.noop, ) + + # Post release announcements to linked issues and PRs + if not opts.noop and isinstance(hvcs_client, Github): + _post_release_announcements( + hvcs_client=hvcs_client, + release_history=release_history, + new_version=new_version, + runtime=runtime, + ) + except HTTPError as err: exception = err except UnexpectedResponse as err: diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index 76ccd1e68..48546ba79 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -181,6 +181,8 @@ def validate_match(cls, patterns: Tuple[str, ...]) -> Tuple[str, ...]: @field_validator("changelog_file", mode="after") @classmethod def changelog_file_deprecation_warning(cls, val: str) -> str: + if not val: + return val logger.warning( str.join( " ", diff --git a/src/semantic_release/commit_parser/angular.py b/src/semantic_release/commit_parser/angular.py index 411ac844b..0f9f3c28f 100644 --- a/src/semantic_release/commit_parser/angular.py +++ b/src/semantic_release/commit_parser/angular.py @@ -181,8 +181,8 @@ def __init__(self, options: AngularParserOptions | None = None) -> None: str.join( "", [ - r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):", - r"[\t ]+(?P.+)[\t ]*$", + r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?)[\s:]*", + r"(?P.+)[\t ]*$", ], ), flags=re.MULTILINE | re.IGNORECASE, diff --git a/src/semantic_release/commit_parser/conventional/parser.py b/src/semantic_release/commit_parser/conventional/parser.py index 5cab34c56..ee543b7db 100644 --- a/src/semantic_release/commit_parser/conventional/parser.py +++ b/src/semantic_release/commit_parser/conventional/parser.py @@ -75,8 +75,8 @@ class ConventionalCommitParser( str.join( "", [ - r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):", - r"[\t ]+(?P.+)[\t ]*$", + r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):[ \t]*", + r"(?P.+)[\t ]*$", ], ), flags=MULTILINE | IGNORECASE, diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index e2fb5ae30..6885bd081 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -182,8 +182,8 @@ def __init__(self, options: EmojiParserOptions | None = None) -> None: str.join( "", [ - r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):", - r"[\t ]+(?P.+)[\t ]*$", + r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):[ \t]+", + r"(?P.+)[\t ]*$", ], ), flags=re.MULTILINE | re.IGNORECASE, diff --git a/src/semantic_release/commit_parser/scipy.py b/src/semantic_release/commit_parser/scipy.py index e6988ea83..e65a2ae25 100644 --- a/src/semantic_release/commit_parser/scipy.py +++ b/src/semantic_release/commit_parser/scipy.py @@ -235,8 +235,8 @@ def __init__(self, options: ScipyParserOptions | None = None) -> None: str.join( "", [ - r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):", - r"[\t ]+(?P.+)[\t ]*$", + r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):[ \t]*", + r"(?P.+)[\t ]*$", ], ), flags=re.MULTILINE | re.IGNORECASE, diff --git a/src/semantic_release/commit_parser/util.py b/src/semantic_release/commit_parser/util.py index 258e8224b..acdf0c46d 100644 --- a/src/semantic_release/commit_parser/util.py +++ b/src/semantic_release/commit_parser/util.py @@ -100,6 +100,12 @@ def force_str(msg: str | bytes | bytearray | memoryview) -> str: def deep_copy_commit(commit: Commit) -> dict[str, Any]: + # Only copy attributes that are always safe to access without triggering gitdb + # lazy-loading. Lazy-loaded attributes (e.g. ``tree``, ``gpgsig``) cause GitPython + # to spawn a ``git cat-file --batch`` subprocess; on Windows this can exhaust OS + # handles / pagefile space (WinError 1450 / 1455) after many test-spawned repos. + # The artificial commits produced by ``unsquash_commit`` are used solely for message + # parsing, so ``tree`` and ``gpgsig`` are not needed. keys = [ "repo", "binsha", @@ -108,18 +114,18 @@ def deep_copy_commit(commit: Commit) -> dict[str, Any]: "committer", "committed_date", "message", - "tree", "parents", "encoding", - "gpgsig", "author_tz_offset", "committer_tz_offset", ] kwargs = {} for key in keys: - with suppress(ValueError): + # Suppress ValueError (GitPython internal) and OSError (WinError 1450/1455 resource + # exhaustion) so a single failing attribute never aborts the whole copy. + with suppress(ValueError, OSError): if hasattr(commit, key) and (value := getattr(commit, key)) is not None: - if key in ["parents", "repo", "tree"]: + if key in ["parents", "repo"]: # These tend to have circular references so don't deepcopy them kwargs[key] = value continue diff --git a/src/semantic_release/data/templates/conventional/md/.components/macros.md.j2 b/src/semantic_release/data/templates/conventional/md/.components/macros.md.j2 index 13cc18fac..aa2f4229e 100644 --- a/src/semantic_release/data/templates/conventional/md/.components/macros.md.j2 +++ b/src/semantic_release/data/templates/conventional/md/.components/macros.md.j2 @@ -1,6 +1,6 @@ {# MACRO: format a inline link reference in Markdown -#}{% macro format_link(link, label) +#}{% macro format_link_reference(link, label) %}{{ "[%s](%s)" | format(label, link) }}{% endmacro %} @@ -28,7 +28,7 @@ #}{% if commit.linked_merge_request != "" %}{# # Add PR references with a link to the PR #}{% set _ = link_references.append( - format_link( + format_link_reference( commit.linked_merge_request | pull_request_url, commit.linked_merge_request ) @@ -37,7 +37,7 @@ %}{# # # DEFAULT: Always include the commit hash as a link #}{% set _ = link_references.append( - format_link( + format_link_reference( commit.hexsha | commit_hash_url, "`%s`" | format(commit.short_hash) ) diff --git a/src/semantic_release/data/templates/conventional/md/.issue_resolution_announcement.md.j2 b/src/semantic_release/data/templates/conventional/md/.issue_resolution_announcement.md.j2 new file mode 100644 index 000000000..84896686f --- /dev/null +++ b/src/semantic_release/data/templates/conventional/md/.issue_resolution_announcement.md.j2 @@ -0,0 +1,21 @@ +{% from ".components/macros.md.j2" import format_link_reference +%}{# +EXAMPLE: + +### :tada: This issue has been resolved in Version #.#.# :tada: + +You can find more information about this release on the [GitHub Releases](https://domain.com/namespace/repo/releases/tag/v#.#.#) page. + +If your issue persists, please reply here and we will re-open the issue. If you receive a new error, please open a new issue and reference this issue number in the new issue description. + +#}{% if create_release_url is defined +%}{% set vcs_release_link = format_link_reference( + release.version.as_tag() | create_release_url, + "%s Releases" | format_w_official_vcs_name, + ) +%}{% endif -%} +### :tada: This issue has been resolved in Version {{ release.version | string }} :tada: +{% if vcs_release_link is defined and vcs_release_link %} +You can find more information about this release on the {{ vcs_release_link }} page. +{%- endif %} +If your issue persists, please reply here and we will re-open the issue. If you receive a new error, please open a new issue and reference this issue number in the new issue description. diff --git a/src/semantic_release/data/templates/conventional/md/.pr_publish_announcement.md.j2 b/src/semantic_release/data/templates/conventional/md/.pr_publish_announcement.md.j2 new file mode 100644 index 000000000..ecf664700 --- /dev/null +++ b/src/semantic_release/data/templates/conventional/md/.pr_publish_announcement.md.j2 @@ -0,0 +1,19 @@ +{% from ".components/macros.md.j2" import format_link_reference +%}{# +EXAMPLE: + +### :tada: This PR has been published as part of Version #.#.# :tada: + +You can find more information about this release on the [GitHub Releases](https://domain.com/namespace/repo/releases/tag/v#.#.#) page. + +#}{% if create_release_url is defined +%}{% set vcs_release_link = format_link_reference( + release.version.as_tag() | create_release_url, + "%s Releases" | format_w_official_vcs_name, + ) +%}{% endif -%} +### :tada: This PR has been published as part of Version {{ release.version | string }} :tada: + +{% if vcs_release_link is defined and vcs_release_link %} +You can find more information about this release on the {{ vcs_release_link }} page. +{%- endif %} diff --git a/src/semantic_release/data/templates/conventional/md/.release_notes.md.j2 b/src/semantic_release/data/templates/conventional/md/.release_notes.md.j2 index 7fe7d9228..6ccdb911a 100644 --- a/src/semantic_release/data/templates/conventional/md/.release_notes.md.j2 +++ b/src/semantic_release/data/templates/conventional/md/.release_notes.md.j2 @@ -1,4 +1,5 @@ -{# EXAMPLE: +{% from ".components/macros.md.j2" import format_link_reference +%}{# EXAMPLE: ## v1.0.0 (2020-01-01) @@ -30,6 +31,12 @@ _This release is published under the MIT License._ **Detailed Changes**: [vX.X.X...vX.X.X](https://domain.com/namespace/repo/compare/vX.X.X...vX.X.X) +--- + +**Installable artifacts are available from**: + +- [PyPi Registry](https://pypi.org/project/package_name/x.x.x) + #}{# # Set line width to 1000 to avoid wrapping as GitHub will handle it #}{% set max_line_width = max_line_width | default(1000) %}{% set hanging_indent = hanging_indent | default(2) @@ -59,4 +66,17 @@ _This release is published under the MIT License._ }}{{ "**Detailed Changes**: %s" | format(detailed_changes_link) }}{% endif %}{% endif +%}{# +#}{% if include_pypi_link | default(False) +%}{{ "\n" +}}{{ "---\n" +}}{{ "\n" +}}{{ "**Installable artifacts are available from**:\n\n" +}}{{ "- %s" | format( + format_link_reference( + repo_name | create_pypi_url(release.version | string), + "PyPi Registry", + ) + ) +}}{% endif %} diff --git a/src/semantic_release/hvcs/github.py b/src/semantic_release/hvcs/github.py index b2c9e5be4..72576e9bd 100644 --- a/src/semantic_release/hvcs/github.py +++ b/src/semantic_release/hvcs/github.py @@ -10,7 +10,8 @@ from re import compile as regexp from typing import TYPE_CHECKING -from requests import HTTPError, JSONDecodeError +from github import Auth, Github as GithubClient, GithubException +from requests import HTTPError from urllib3.util.url import Url, parse_url from semantic_release.cli.util import noop_report @@ -28,6 +29,8 @@ if TYPE_CHECKING: # pragma: no cover from typing import Any, Callable + from github.Repository import Repository + # Add a mime type for wheels # Fix incorrect entries in the `mimetypes` registry. @@ -95,6 +98,12 @@ def __init__( auth = None if not self.token else TokenAuth(self.token) self.session = build_requests_session(auth=auth) + # Initialize PyGithub client + github_auth = Auth.Token(self.token) if self.token else None + self._github_client: GithubClient | None = None + self._github_auth = github_auth + self._repository: Repository | None = None + # ref: https://docs.github.com/en/actions/reference/environment-variables#default-environment-variables domain_url_str = ( hvcs_domain @@ -175,6 +184,61 @@ def __init__( ).url.rstrip("/") ) + # Initialize PyGithub client with appropriate base_url + base_url = self._determine_github_api_base_url() + if base_url is not None: + self._github_client = GithubClient( + auth=self._github_auth, base_url=base_url + ) + else: + self._github_client = GithubClient(auth=self._github_auth) + + def _determine_github_api_base_url(self) -> str | None: + """ + Determine the base URL for PyGithub client based on GitHub product type + + Returns None for github.com (uses PyGithub's default api.github.com), + or the full API URL for GitHub Enterprise Server instances. + """ + if self.hvcs_domain.url == f"https://{self.DEFAULT_DOMAIN}": + # Use PyGithub's default (api.github.com) + return None + # Use the calculated api_url for GitHub Enterprise Server + return self.api_url.url + + @property + def repo(self) -> Repository: + """ + Lazy-load and cache the GitHub repository object + + Returns + ------- + Repository: The PyGithub Repository object for this HVCS instance + + Raises + ------ + GithubException: If the repository cannot be accessed + + """ + if self._repository is None: + if self._github_client is None: + msg = "GitHub client not initialized" + raise RuntimeError(msg) + try: + self._repository = self._github_client.get_repo( + f"{self.owner}/{self.repo_name}" + ) + logger.debug("Loaded repository %s/%s", self.owner, self.repo_name) + except GithubException as err: + logger.error( + "Failed to get repository %s/%s: %s", + self.owner, + self.repo_name, + err, + ) + raise + return self._repository + def _derive_api_url_from_base_domain(self) -> Url: return parse_url( Url( @@ -250,37 +314,27 @@ def create_release( return -1 logger.info("Creating release for tag %s", tag) - releases_endpoint = self.create_api_url( - endpoint=f"/repos/{self.owner}/{self.repo_name}/releases", - ) - response = self.session.post( - releases_endpoint, - json={ - "tag_name": tag, - "name": tag, - "body": release_notes, - "draft": False, - "prerelease": prerelease, - }, - ) - - # Raise an error if the request was not successful - response.raise_for_status() try: - release_id: int = response.json()["id"] + release = self.repo.create_git_release( + tag=tag, + name=tag, + message=release_notes, + draft=False, + prerelease=prerelease, + ) + release_id: int = release.id logger.info("Successfully created release with ID: %s", release_id) - except JSONDecodeError as err: - raise UnexpectedResponse("Unreadable json response") from err - except KeyError as err: - raise UnexpectedResponse("JSON response is missing an id") from err + except GithubException as err: + logger.error("Failed to create release: %s", err) + raise UnexpectedResponse(f"Failed to create release: {err}") from err errors = [] for asset in assets or []: logger.info("Uploading asset %s", asset) try: self.upload_release_asset(release_id, asset) - except HTTPError as err: + except (GithubException, AssetUploadError) as err: errors.append( AssetUploadError(f"Failed asset upload for {asset}").with_traceback( err.__traceback__ @@ -306,21 +360,16 @@ def get_release_id_by_tag(self, tag: str) -> int | None: :param tag: Tag to get release for :return: ID of release, if found, else None """ - tag_endpoint = self.create_api_url( - endpoint=f"/repos/{self.owner}/{self.repo_name}/releases/tags/{tag}", - ) - response = self.session.get(tag_endpoint) - - # Raise an error if the request was not successful - response.raise_for_status() - try: - data = response.json() - return data["id"] - except JSONDecodeError as err: - raise UnexpectedResponse("Unreadable json response") from err - except KeyError as err: - raise UnexpectedResponse("JSON response is missing an id") from err + release = self.repo.get_release(tag) + except GithubException as err: + if err.status == 404: + logger.debug("Release not found for tag %s", tag) + return None + logger.error("Failed to get release by tag %s: %s", tag, err) + raise UnexpectedResponse(f"Failed to get release by tag: {err}") from err + else: + return release.id @logged_function(logger) def edit_release_notes(self, release_id: int, release_notes: str) -> int: @@ -332,19 +381,21 @@ def edit_release_notes(self, release_id: int, release_notes: str) -> int: :return: The ID of the release that was edited """ logger.info("Updating release %s", release_id) - release_endpoint = self.create_api_url( - endpoint=f"/repos/{self.owner}/{self.repo_name}/releases/{release_id}", - ) - - response = self.session.post( - release_endpoint, - json={"body": release_notes}, - ) - - # Raise an error if the update was unsuccessful - response.raise_for_status() - return release_id + try: + release = self.repo.get_release(release_id) + release.update_release( + name=release.title, + message=release_notes, + draft=release.draft, + prerelease=release.prerelease, + ) + except GithubException as err: + logger.error("Failed to edit release notes for %s: %s", release_id, err) + raise UnexpectedResponse(f"Failed to edit release: {err}") from err + else: + logger.debug("Successfully updated release %s", release_id) + return release_id @logged_function(logger) def create_or_update_release( @@ -360,7 +411,7 @@ def create_or_update_release( logger.info("Creating release for %s", tag) try: return self.create_release(tag, release_notes, prerelease) - except HTTPError as err: + except (HTTPError, UnexpectedResponse) as err: logger.debug("error creating release: %s", err) logger.debug("looking for an existing release to update") @@ -376,7 +427,7 @@ def create_or_update_release( @logged_function(logger) @suppress_not_found - def asset_upload_url(self, release_id: str) -> str | None: + def asset_upload_url(self, release_id: int) -> str | None: """ Get the correct upload url for a release https://docs.github.com/en/enterprise-server@3.5/rest/releases/releases#get-a-release @@ -384,22 +435,17 @@ def asset_upload_url(self, release_id: str) -> str | None: :return: URL to upload for a release if found, else None """ # https://docs.github.com/en/enterprise-server@3.5/rest/releases/assets#upload-a-release-asset - release_url = self.create_api_url( - endpoint=f"/repos/{self.owner}/{self.repo_name}/releases/{release_id}" - ) - - response = self.session.get(release_url) - response.raise_for_status() - try: - upload_url: str = response.json()["upload_url"] + release = self.repo.get_release(release_id) + # PyGithub handles upload_url internally, but we need it for compatibility + upload_url: str = release.upload_url return upload_url.replace("{?name,label}", "") - except JSONDecodeError as err: - raise UnexpectedResponse("Unreadable json response") from err - except KeyError as err: - raise UnexpectedResponse( - "JSON response is missing a key 'upload_url'" - ) from err + except GithubException as err: + if err.status == 404: + logger.debug("Release not found: %s", release_id) + return None + logger.error("Failed to get upload URL for release %s: %s", release_id, err) + raise UnexpectedResponse(f"Failed to get upload URL: {err}") from err @logged_function(logger) def upload_release_asset( @@ -413,39 +459,37 @@ def upload_release_asset( :param label: Optional custom label for this file :return: The status of the request """ - url = self.asset_upload_url(release_id) - if url is None: - raise ValueError( - "There is no associated url for uploading asset for release " - f"{release_id}. Release url: " - f"{self.api_url}/repos/{self.owner}/{self.repo_name}/releases/{release_id}" - ) - - content_type = ( - mimetypes.guess_type(file, strict=False)[0] or "application/octet-stream" - ) + try: + release = self.repo.get_release(release_id) - with open(file, "rb") as data: - response = self.session.post( - url, - params={"name": os.path.basename(file), "label": label}, - headers={ - "Content-Type": content_type, - }, - data=data.read(), + # Determine content type + content_type = ( + mimetypes.guess_type(file, strict=False)[0] + or "application/octet-stream" ) - # Raise an error if the upload was unsuccessful - response.raise_for_status() - - logger.debug( - "Successfully uploaded %s to Github, url: %s, status code: %s", - file, - response.url, - response.status_code, - ) + # Upload the asset using PyGithub + release.upload_asset( + path=file, + label=label or os.path.basename(file), + content_type=content_type, + ) - return True + except GithubException as err: + logger.error( + "Failed to upload asset %s to release %s: %s", + file, + release_id, + err, + ) + raise AssetUploadError(f"Failed to upload {file}") from err + else: + logger.debug( + "Successfully uploaded %s to GitHub release %s", + file, + release_id, + ) + return True @logged_function(logger) def upload_dists(self, tag: str, dist_glob: str) -> int: @@ -470,10 +514,12 @@ def upload_dists(self, tag: str, dist_glob: str) -> int: try: self.upload_release_asset(release_id, file_path) n_succeeded += 1 - except HTTPError as err: # noqa: PERF203 + except (HTTPError, AssetUploadError) as err: # noqa: PERF203 logger.exception("error uploading asset %s", file_path) status_code = ( - err.response.status_code if err.response is not None else "unknown" + err.response.status_code + if isinstance(err, HTTPError) and err.response is not None + else "unknown" ) error_msg = f"Failed to upload asset '{file_path}' to release" if status_code != "unknown": @@ -557,6 +603,70 @@ def format_w_official_vcs_name(format_str: str) -> str: return format_str + @logged_function(logger) + def post_comment(self, issue_id: int, body: str) -> int: + """ + Post a comment to an issue or pull request. + + :param issue_id: The issue/PR number + :param body: The comment text + :return: The comment ID + :raises UnexpectedResponse: If the comment could not be posted + """ + try: + logger.debug("Posting comment to issue/PR %d", issue_id) + issue = self.repo.get_issue(issue_id) + comment = issue.create_comment(body) + except GithubException as err: + logger.error("Failed to post comment to issue/PR %d: %s", issue_id, err) + raise UnexpectedResponse( + f"Failed to post comment to issue {issue_id}" + ) from err + else: + logger.info("Posted comment %d to issue/PR %d", comment.id, issue_id) + return comment.id + + @logged_function(logger) + def check_issue_state(self, issue_id: int) -> str: + """ + Check the state of an issue or pull request. + + :param issue_id: The issue/PR number + :return: The issue state ("open" or "closed") + :raises UnexpectedResponse: If the issue state could not be retrieved + """ + try: + logger.debug("Checking state of issue/PR %d", issue_id) + issue = self.repo.get_issue(issue_id) + except GithubException as err: + logger.error("Failed to get state of issue/PR %d: %s", issue_id, err) + raise UnexpectedResponse( + f"Failed to get state of issue {issue_id}" + ) from err + else: + logger.debug("Issue/PR %d state is %s", issue_id, issue.state) + return issue.state + + @logged_function(logger) + def add_labels_to_issue(self, issue_id: int, labels: list[str]) -> None: + """ + Add labels to an issue or pull request. + + :param issue_id: The issue/PR number + :param labels: List of label names to add + :raises UnexpectedResponse: If labels could not be added + """ + try: + logger.debug("Adding labels %s to issue/PR %d", labels, issue_id) + issue = self.repo.get_issue(issue_id) + issue.add_to_labels(*labels) + logger.info("Added labels %s to issue/PR %d", labels, issue_id) + except GithubException as err: + logger.error("Failed to add labels to issue/PR %d: %s", issue_id, err) + raise UnexpectedResponse( + f"Failed to add labels to issue {issue_id}" + ) from err + def get_changelog_context_filters(self) -> tuple[Callable[..., Any], ...]: return ( self.create_server_url, diff --git a/tests/conftest.py b/tests/conftest.py index df4e2f82d..239e55387 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -312,12 +312,12 @@ def cached_files_dir(request: pytest.FixtureRequest) -> Path: @pytest.fixture(scope="session") def get_authorization_to_build_repo_cache( - tmp_path_factory: pytest.TempPathFactory, worker_id: str + tmp_path_factory: pytest.TempPathFactory, ) -> GetAuthorizationToBuildRepoCacheFn: def _get_authorization_to_build_repo_cache( repo_name: str, ) -> AcquireReturnProxy | None: - if worker_id == "master": + if os.getenv("PYTEST_XDIST_WORKER", "master") == "master": # not executing with multiple workers via xdist, so just continue return None @@ -332,16 +332,45 @@ def _get_authorization_to_build_repo_cache( @pytest.fixture(scope="session") -def get_cached_repo_data(request: pytest.FixtureRequest) -> GetCachedRepoDataFn: +def get_cached_repo_data( + request: pytest.FixtureRequest, + cached_files_dir: Path, +) -> GetCachedRepoDataFn: + shared_repo_data_dir = cached_files_dir.joinpath("repo-data") + shared_repo_data_dir.mkdir(parents=True, exist_ok=True) + def _get_cached_repo_data(proj_dirname: str) -> RepoData | None: cache_key = f"psr/repos/{proj_dirname}" - return cast("Optional[RepoData]", request.config.cache.get(cache_key, None)) + if cached_repo_data := cast( + "Optional[RepoData]", request.config.cache.get(cache_key, None) + ): + return cached_repo_data + + repo_data_file = shared_repo_data_dir.joinpath(f"{proj_dirname}.json") + if not repo_data_file.exists(): + return None + + try: + cached_repo_data = cast( + "RepoData", json.loads(repo_data_file.read_text(encoding="utf-8")) + ) + except json.JSONDecodeError: + return None + + request.config.cache.set(cache_key, cached_repo_data) + return cached_repo_data return _get_cached_repo_data @pytest.fixture(scope="session") -def set_cached_repo_data(request: pytest.FixtureRequest) -> SetCachedRepoDataFn: +def set_cached_repo_data( + request: pytest.FixtureRequest, + cached_files_dir: Path, +) -> SetCachedRepoDataFn: + shared_repo_data_dir = cached_files_dir.joinpath("repo-data") + shared_repo_data_dir.mkdir(parents=True, exist_ok=True) + def magic_serializer(obj: Any) -> Any: if isinstance(obj, Path): return obj.__fspath__() @@ -353,10 +382,17 @@ def magic_serializer(obj: Any) -> Any: def _set_cached_repo_data(proj_dirname: str, data: RepoData) -> None: cache_key = f"psr/repos/{proj_dirname}" - request.config.cache.set( - cache_key, - json.loads(json.dumps(data, default=magic_serializer)), + cached_repo_data = json.loads(json.dumps(data, default=magic_serializer)) + request.config.cache.set(cache_key, cached_repo_data) + + # Persist metadata in a shared cache file so xdist workers can read it. + repo_data_file = shared_repo_data_dir.joinpath(f"{proj_dirname}.json") + repo_data_tmp_file = repo_data_file.with_suffix(f"{repo_data_file.suffix}.tmp") + repo_data_tmp_file.write_text( + json.dumps(cached_repo_data), + encoding="utf-8", ) + repo_data_tmp_file.replace(repo_data_file) return _set_cached_repo_data @@ -383,61 +419,62 @@ def _build_repo_w_cache_checking( # Runs before the cache is checked because the cache will be set once the build is complete filelock = get_authorization_to_build_repo_cache(repo_name) - cached_repo_data = get_cached_repo_data(repo_name) - cached_repo_path = cached_files_dir.joinpath(repo_name) + try: + cached_repo_data = get_cached_repo_data(repo_name) + cached_repo_path = cached_files_dir.joinpath(repo_name) - # Determine if the build spec has changed since the last cached build - unmodified_build_spec = bool( - cached_repo_data and cached_repo_data["build_spec_hash"] == build_spec_hash - ) - - if not unmodified_build_spec or not cached_repo_path.exists(): - # Cache miss, so build the repo (make sure its clean first) - remove_dir_tree(cached_repo_path, force=True) - cached_repo_path.mkdir(parents=True, exist_ok=True) - - build_msg = f"Building cached project files for {repo_name}" - with log_file_lock, log_file.open(mode="a") as afd: - afd.write(f"{stable_now_date().isoformat()}: {build_msg}...\n") + # Determine if the build spec has changed since the last cached build + unmodified_build_spec = bool( + cached_repo_data + and cached_repo_data["build_spec_hash"] == build_spec_hash + ) - try: - # Try to build repository but catch any errors so that it doesn't cascade through all tests - # do to an unreleased lock - build_definition = build_repo_func(cached_repo_path) - except Exception: + if not unmodified_build_spec or not cached_repo_path.exists(): + # Cache miss, so build the repo (make sure its clean first) remove_dir_tree(cached_repo_path, force=True) + cached_repo_path.mkdir(parents=True, exist_ok=True) - if filelock: - filelock.lock.release() - + build_msg = f"Building cached project files for {repo_name}" with log_file_lock, log_file.open(mode="a") as afd: - afd.write( - f"{stable_now_date().isoformat()}: {build_msg}...FAILED\n" - ) - - raise - - # Marks the date when the cached repo was created - set_cached_repo_data( - repo_name, - { - "build_date": today_date_str, - "build_spec_hash": build_spec_hash, - "build_definition": build_definition, - }, - ) - - with log_file_lock, log_file.open(mode="a") as afd: - afd.write(f"{stable_now_date().isoformat()}: {build_msg}...DONE\n") - - if filelock: - filelock.lock.release() - - if dest_dir: - copy_dir_tree(cached_repo_path, dest_dir) - return dest_dir + afd.write(f"{stable_now_date().isoformat()}: {build_msg}...\n") + + try: + # Try to build repository but catch any errors so that it doesn't + # cascade through all tests due to an unreleased lock + build_definition = build_repo_func(cached_repo_path) + except Exception: + remove_dir_tree(cached_repo_path, force=True) + + with log_file_lock, log_file.open(mode="a") as afd: + afd.write( + f"{stable_now_date().isoformat()}: {build_msg}...FAILED\n" + ) + + raise + + # Marks the date when the cached repo was created + set_cached_repo_data( + repo_name, + { + "build_date": today_date_str, + "build_spec_hash": build_spec_hash, + "build_definition": build_definition, + }, + ) - return cached_repo_path + with log_file_lock, log_file.open(mode="a") as afd: + afd.write(f"{stable_now_date().isoformat()}: {build_msg}...DONE\n") + + # Keep the lock held while copying so another worker cannot delete/rebuild + # the cache directory mid-copy. + if dest_dir: + copy_dir_tree(cached_repo_path, dest_dir) + return dest_dir + + return cached_repo_path + finally: + if filelock: + filelock.lock.release() return _build_repo_w_cache_checking diff --git a/tests/e2e/cmd_changelog/test_changelog.py b/tests/e2e/cmd_changelog/test_changelog.py index 0ff1bc710..d8ed62f0a 100644 --- a/tests/e2e/cmd_changelog/test_changelog.py +++ b/tests/e2e/cmd_changelog/test_changelog.py @@ -9,6 +9,7 @@ import requests_mock from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture from requests import Session +from requests_mock import ANY import semantic_release.hvcs.github from semantic_release.changelog.context import ChangelogMode @@ -1049,49 +1050,45 @@ def test_changelog_release_tag_not_in_history( ("--post-to-release-tag", "v0.2.0"), # latest release ], ) -def test_changelog_post_to_release(args: list[str], run_cli: RunCliFn): - # Set up a requests HTTP session so we can catch the HTTP calls and ensure they're - # made - - session = Session() - session.hooks = {"response": [lambda r, *_, **__: r.raise_for_status()]} - - mock_adapter = requests_mock.Adapter() - mock_adapter.register_uri( - method=requests_mock.ANY, url=requests_mock.ANY, json={"id": 10001} - ) - session.mount("http://", mock_adapter) - session.mount("https://", mock_adapter) - +def test_changelog_post_to_release( + args: list[str], run_cli: RunCliFn, requests_mock: Mocker +): expected_request_url = "{api_url}/repos/{owner}/{repo_name}/releases".format( api_url=f"https://{EXAMPLE_HVCS_DOMAIN}/api/v3", # GitHub API URL owner=EXAMPLE_REPO_OWNER, repo_name=EXAMPLE_REPO_NAME, ) - # Patch out env vars that affect changelog URLs but only get set in e.g. - # Github actions - with mock.patch( - # Patching the specific module's reference to the build_requests_session function - f"{semantic_release.hvcs.github.__name__}.{semantic_release.hvcs.github.build_requests_session.__name__}", - return_value=session, - ) as build_requests_session_mock: - # Act - cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD, *args] - result = run_cli( - cli_cmd[1:], - env={ - "CI": "true", - "VIRTUAL_ENV": os.getenv("VIRTUAL_ENV", "./.venv"), - }, - ) + # Register GET mocks (for PyGithub repository access and release lookups) + requests_mock.register_uri( + "GET", + ANY, + json={ + "id": 1296269, + "name": EXAMPLE_REPO_NAME, + "full_name": f"{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}", + "owner": {"login": EXAMPLE_REPO_OWNER}, + "private": False, + }, + ) + # Register POST mock (for release creation) + requests_mock.register_uri("POST", ANY, json={"id": 10001}) + + # Act + cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD, *args] + result = run_cli( + cli_cmd[1:], + env={ + "CI": "true", + "VIRTUAL_ENV": os.getenv("VIRTUAL_ENV", "./.venv"), + }, + ) # Evaluate assert_successful_exit_code(result, cli_cmd) - assert build_requests_session_mock.called - assert mock_adapter.called - assert mock_adapter.last_request is not None - assert expected_request_url == mock_adapter.last_request.url + post_requests = [r for r in requests_mock.request_history if r.method == "POST"] + assert len(post_requests) > 0 + assert expected_request_url in [r.url.replace(":443/", "/") for r in post_requests] @pytest.mark.parametrize( diff --git a/tests/e2e/cmd_config/test_generate_config.py b/tests/e2e/cmd_config/test_generate_config.py index a9db934ea..44e61ed7b 100644 --- a/tests/e2e/cmd_config/test_generate_config.py +++ b/tests/e2e/cmd_config/test_generate_config.py @@ -29,7 +29,11 @@ @pytest.fixture def raw_config_dict() -> dict[str, Any]: - return RawConfig().model_dump(mode="json", exclude_none=True) + return RawConfig().model_dump( + mode="json", + exclude_none=True, + exclude={"changelog": {"changelog_file"}}, + ) @pytest.mark.parametrize("args", [(), ("--format", "toml"), ("--format", "TOML")]) diff --git a/tests/e2e/cmd_publish/test_publish.py b/tests/e2e/cmd_publish/test_publish.py index 23eb89bb0..31b03fd3f 100644 --- a/tests/e2e/cmd_publish/test_publish.py +++ b/tests/e2e/cmd_publish/test_publish.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from pathlib import Path from typing import TYPE_CHECKING, cast from unittest import mock @@ -135,7 +136,7 @@ def test_publish_fails_on_github_upload_dists( Path(f"dist/package-{latest_release_version}.whl"), Path(f"dist/package-{latest_release_version}.tar.gz"), ] - tag_endpoint = hvcs_client.create_api_url( + hvcs_client.create_api_url( endpoint=f"/repos/{hvcs_client.owner}/{hvcs_client.repo_name}/releases/tags/{release_tag}", ) release_endpoint = hvcs_client.create_api_url( @@ -149,14 +150,28 @@ def test_publish_fails_on_github_upload_dists( file.parent.mkdir(parents=True, exist_ok=True) file.touch() + repo_url = hvcs_client.create_api_url( + endpoint=f"/repos/{hvcs_client.owner}/{hvcs_client.repo_name}" + ) + requests_mock.register_uri( + "GET", + re.compile(f".*/repos/{hvcs_client.owner}/{hvcs_client.repo_name}$"), + json={"url": repo_url}, + ) # Setup: Mock upload url retrieval - requests_mock.register_uri("GET", tag_endpoint, json={"id": release_id}) requests_mock.register_uri( - "GET", release_endpoint, json={"upload_url": f"{upload_url}{{?name,label}}"} + "GET", re.compile(f".*{release_tag}.*"), json={"id": release_id} + ) + requests_mock.register_uri( + "GET", + re.compile(f".*/releases/{release_id}.*"), + json={"upload_url": f"{upload_url}{{?name,label}}"}, ) # Setup: Mock upload failure - uploader_mock = requests_mock.register_uri("POST", upload_url, status_code=403) + uploader_mock = requests_mock.register_uri( + "POST", re.compile(f".*/releases/{release_id}/assets.*"), status_code=403 + ) # Act cli_cmd = [MAIN_PROG_NAME, PUBLISH_SUBCMD, "--tag", "latest"] diff --git a/tests/e2e/cmd_version/test_version_build.py b/tests/e2e/cmd_version/test_version_build.py index 51022da87..201d6c830 100644 --- a/tests/e2e/cmd_version/test_version_build.py +++ b/tests/e2e/cmd_version/test_version_build.py @@ -318,7 +318,7 @@ def test_version_runs_build_command_w_user_env( # [2] Make sure the subprocess was called with the correct environment patched_subprocess_run.assert_called_once_with( - ["bash", "-c", build_command], + ["/usr/bin/bash", "-c", build_command], check=True, env={ **clean_os_environment, diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 328718fa0..58f294650 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -23,11 +23,12 @@ if TYPE_CHECKING: from re import Pattern - from typing import Protocol + from typing import Any, Protocol from git.repo import Repo from pytest import MonkeyPatch from requests_mock.mocker import Mocker + from requests_mock.request import _RequestObjectProxy from tests.fixtures.example_project import ExProjectDir @@ -62,11 +63,60 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: item.add_marker(pytest.mark.e2e) +class _PostOnlyMocker: + """Wrapper around a requests_mock Mocker that filters call_count/last_request to POST only.""" + + def __init__(self, mocker: Mocker, post_list: list[_RequestObjectProxy]) -> None: + self._mocker = mocker + self._post_list = post_list + + @property + def call_count(self) -> int: + return len(self._post_list) + + @property + def last_request(self) -> _RequestObjectProxy | None: + return self._post_list[-1] if self._post_list else None + + def reset_mock(self) -> None: + self._post_list.clear() + + def __getattr__(self, name: str) -> Any: + return getattr(self._mocker, name) + + @pytest.fixture -def post_mocker(requests_mock: Mocker) -> Mocker: - """Patch all POST requests, mocking a response body for VCS release creation.""" - requests_mock.register_uri("POST", ANY, json={"id": 999}) - return requests_mock +def post_mocker(requests_mock: Mocker) -> _PostOnlyMocker: + """ + Patch all POST requests, mocking a response body for VCS release creation. + + Also mocks GET requests for PyGithub repository access to avoid unmocked requests. + """ + # Track POST and GET requests separately + post_requests = [] + get_requests = [] + + def post_callback(request, context): + """Callback for POST requests.""" + post_requests.append(request) + return {"id": 999} + + def get_callback(request, context): + """Callback for GET requests (PyGithub repository access).""" + get_requests.append(request) + return { + "id": 1296269, + "owner": {"login": "example_owner"}, + "name": "example_repo", + "full_name": "example_owner/example_repo", + "description": "Test repository", + "private": False, + } + + requests_mock.register_uri("POST", ANY, json=post_callback) + requests_mock.register_uri("GET", ANY, json=get_callback) + + return _PostOnlyMocker(requests_mock, post_requests) @pytest.fixture diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 613955290..3587bc46e 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -507,6 +507,7 @@ def __call__( license_name: str = "", dest_file: Path | None = None, mask_initial_release: bool = True, # Default as of v10 + include_pypi_link: bool = False, ) -> str: ... class GetHvcsClientFromRepoDefFn(Protocol): @@ -2497,6 +2498,7 @@ def _generate_default_release_notes( license_name: str = "", dest_file: Path | None = None, mask_initial_release: bool = True, # Default as of v10 + include_pypi_link: bool = False, ) -> str: limited_repo_def: RepoDefinition = get_commits_from_repo_build_def( build_definition=version_actions, @@ -2505,36 +2507,41 @@ def _generate_default_release_notes( version: Version = Version.parse(next(iter(limited_repo_def.keys()))) version_def: RepoVersionDef = limited_repo_def[str(version)] - release_notes_content = ( - str.join( - "\n" * 2, - [ - ( - build_initial_version_entry_markdown(str(version), license_name) - if mask_initial_release and not previous_version - else build_version_entry_markdown( - str(version), version_def, hvcs, license_name - ) - ).rstrip(), - *( - [ - "---", - "**Detailed Changes**: [{prev_version}...{new_version}]({version_compare_url})".format( - prev_version=previous_version.as_tag(), - new_version=version.as_tag(), - version_compare_url=hvcs.compare_url( - previous_version.as_tag(), version.as_tag() - ), + release_notes_content = str.join( + "\n" * 2, + [ + ( + build_initial_version_entry_markdown(str(version), license_name) + if mask_initial_release and not previous_version + else build_version_entry_markdown( + str(version), version_def, hvcs, license_name + ) + ).rstrip(), + *( + [ + "---", + "**Detailed Changes**: [{prev_version}...{new_version}]({version_compare_url})".format( + prev_version=previous_version.as_tag(), + new_version=version.as_tag(), + version_compare_url=hvcs.compare_url( + previous_version.as_tag(), version.as_tag() ), - ] - if previous_version and not isinstance(hvcs, Gitea) - else [] - ), - ], - ).rstrip() - + "\n" + ), + ] + if previous_version and not isinstance(hvcs, Gitea) + else [] + ), + ], ) + if include_pypi_link and previous_version and not isinstance(hvcs, Gitea): + release_notes_content += "\n---\n\n**Installable artifacts are available from**:\n\n- [PyPi Registry](https://pypi.org/project/{repo_name}/{version})\n".format( + repo_name=hvcs.repo_name, + version=version, + ) + else: + release_notes_content = release_notes_content.rstrip() + "\n" + if dest_file is not None: # Converts universal newlines to the OS-specific upon write dest_file.write_text(release_notes_content) diff --git a/tests/unit/semantic_release/changelog/test_release_notes.py b/tests/unit/semantic_release/changelog/test_release_notes.py index 02f217bf1..1a30894a9 100644 --- a/tests/unit/semantic_release/changelog/test_release_notes.py +++ b/tests/unit/semantic_release/changelog/test_release_notes.py @@ -12,6 +12,7 @@ from importlib_resources import files import semantic_release +from semantic_release.changelog.context import create_pypi_url from semantic_release.cli.changelog_writer import generate_release_notes from semantic_release.commit_parser.token import ParsedCommit from semantic_release.hvcs import Bitbucket, Gitea, Github, Gitlab @@ -664,10 +665,14 @@ def test_release_notes_context_release_url_filter( with mock.patch.dict(os.environ, {}, clear=True): hvcs_client = hvcs_client_class(remote_url=example_git_https_url) - expected_content = dedent( - f"""\ - [{version.as_tag()}]({hvcs_client.create_release_url(version.as_tag())}) - """ + # render_release_notes normalizes line endings to os.linesep, so expected + # must use os.linesep (\r\n on Windows) to match the actual output. + expected_content = str.join( + os.linesep, + [ + f"[{version.as_tag()}]({hvcs_client.create_release_url(version.as_tag())})", + "", + ], ) actual_content = generate_release_notes( @@ -705,12 +710,16 @@ def test_release_notes_context_format_w_official_name_filter( with mock.patch.dict(os.environ, {}, clear=True): hvcs_client = hvcs_client_class(remote_url=example_git_https_url) - expected_content = dedent( - f"""\ - {hvcs_client.OFFICIAL_NAME} - {hvcs_client.OFFICIAL_NAME} - {hvcs_client.OFFICIAL_NAME} - """ + # render_release_notes normalizes line endings to os.linesep, so expected + # must use os.linesep (\r\n on Windows) to match the actual output. + expected_content = str.join( + os.linesep, + [ + hvcs_client.OFFICIAL_NAME, + hvcs_client.OFFICIAL_NAME, + hvcs_client.OFFICIAL_NAME, + "", + ], ) actual_content = generate_release_notes( @@ -1046,3 +1055,153 @@ def test_default_release_notes_template_w_multiple_notices( ) assert expected_content == actual_content + + +@pytest.mark.parametrize("hvcs_client", [Github, Gitlab, Gitea, Bitbucket]) +def test_default_release_notes_template_w_pypi_link( + example_git_https_url: str, + hvcs_client: type[Github | Gitlab | Gitea | Bitbucket], + artificial_release_history: ReleaseHistory, + today_date_str: str, +): + """ + Given a release history with multiple releases, when generating release notes with + include_pypi_link=True, then the PyPi registry section is appended to the output. + """ + released_versions = iter(artificial_release_history.released.keys()) + version = next(released_versions) + prev_version = next(released_versions) + hvcs = hvcs_client(example_git_https_url) + release = artificial_release_history.released[version] + + feat_commit_obj = release["elements"]["feature"][0] + fix_commit_obj_1 = release["elements"]["fix"][0] + fix_commit_obj_2 = release["elements"]["fix"][1] + fix_commit_obj_3 = release["elements"]["fix"][2] + assert isinstance(feat_commit_obj, ParsedCommit) + assert isinstance(fix_commit_obj_1, ParsedCommit) + assert isinstance(fix_commit_obj_2, ParsedCommit) + assert isinstance(fix_commit_obj_3, ParsedCommit) + + feat_commit_url = hvcs.commit_hash_url(feat_commit_obj.commit.hexsha) + feat_description = str.join("\n", feat_commit_obj.descriptions) + + fix_commit_1_url = hvcs.commit_hash_url(fix_commit_obj_1.commit.hexsha) + fix_commit_1_description = str.join("\n", fix_commit_obj_1.descriptions) + + fix_commit_2_url = hvcs.commit_hash_url(fix_commit_obj_2.commit.hexsha) + fix_commit_2_description = str.join("\n", fix_commit_obj_2.descriptions) + + fix_commit_3_url = hvcs.commit_hash_url(fix_commit_obj_3.commit.hexsha) + fix_commit_3_description = str.join("\n", fix_commit_obj_3.descriptions) + + expected_content = str.join( + os.linesep, + [ + f"## v{version} ({today_date_str})", + "", + "### Feature", + "", + "- {commit_scope}{commit_desc} ([`{short_hash}`]({url}))".format( + commit_scope=( + f"**{feat_commit_obj.scope}**: " if feat_commit_obj.scope else "" + ), + commit_desc=feat_description.capitalize(), + short_hash=feat_commit_obj.commit.hexsha[:7], + url=feat_commit_url, + ), + "", + "### Fix", + "", + # Commit 2 is first because it has no scope + "- {commit_scope}{commit_desc} ([`{short_hash}`]({url}))".format( + commit_scope=( + f"**{fix_commit_obj_2.scope}**: " if fix_commit_obj_2.scope else "" + ), + commit_desc=fix_commit_2_description.capitalize(), + short_hash=fix_commit_obj_2.commit.hexsha[:7], + url=fix_commit_2_url, + ), + "", + # Commit 3 is second because it starts with an A even though it has the same scope as 1 + "- {commit_scope}{commit_desc} ([`{short_hash}`]({url}))".format( + commit_scope=( + f"**{fix_commit_obj_3.scope}**: " if fix_commit_obj_3.scope else "" + ), + commit_desc=fix_commit_3_description.capitalize(), + short_hash=fix_commit_obj_3.commit.hexsha[:7], + url=fix_commit_3_url, + ), + "", + # Commit 1 is last + "- {commit_scope}{commit_desc} ([`{short_hash}`]({url}))".format( + commit_scope=( + f"**{fix_commit_obj_1.scope}**: " if fix_commit_obj_1.scope else "" + ), + commit_desc=fix_commit_1_description.capitalize(), + short_hash=fix_commit_obj_1.commit.hexsha[:7], + url=fix_commit_1_url, + ), + "", + ], + ) + + if not isinstance(hvcs, Gitea): + expected_content += str.join( + os.linesep, + [ + "", + "---", + "", + "**Detailed Changes**: [{prev_version}...{new_version}]({version_compare_url})".format( + prev_version=prev_version.as_tag(), + new_version=version.as_tag(), + version_compare_url=hvcs.compare_url( + prev_version.as_tag(), version.as_tag() + ), + ), + "", + ], + ) + + # The PyPi registry section is appended when include_pypi_link=True + pypi_url = create_pypi_url(hvcs.repo_name, str(version)) + # For Gitea (no Detailed Changes), Block1 ends with os.linesep and the + # template's {{ "\n" }} produces a blank line before ---, so we need the + # leading "" to add the extra os.linesep separator. + # For non-Gitea the Detailed Changes block already ends with os.linesep, + # and the template's {{ "\n" }} outputs a single newline before --- (no + # blank line), so we must NOT include the leading "" to avoid doubling it. + pypi_section_lines: list[str] = ( + [ + "", + "---", + "", + "**Installable artifacts are available from**:", + "", + f"- [PyPi Registry]({pypi_url})", + "", + ] + if isinstance(hvcs, Gitea) + else [ + "---", + "", + "**Installable artifacts are available from**:", + "", + f"- [PyPi Registry]({pypi_url})", + "", + ] + ) + expected_content += str.join(os.linesep, pypi_section_lines) + + actual_content = generate_release_notes( + hvcs_client=hvcs_client(remote_url=example_git_https_url), + release=release, + template_dir=Path(""), + history=artificial_release_history, + style="conventional", + mask_initial_release=False, + include_pypi_link=True, + ) + + assert expected_content == actual_content diff --git a/tests/unit/semantic_release/cli/test_config.py b/tests/unit/semantic_release/cli/test_config.py index 343748187..e216757c6 100644 --- a/tests/unit/semantic_release/cli/test_config.py +++ b/tests/unit/semantic_release/cli/test_config.py @@ -164,7 +164,13 @@ def test_default_toml_config_valid(example_project_dir: ExProjectDir): default_config_file = example_project_dir / "default.toml" default_config_file.write_text( - tomlkit.dumps(RawConfig().model_dump(mode="json", exclude_none=True)) + tomlkit.dumps( + RawConfig().model_dump( + mode="json", + exclude_none=True, + exclude={"changelog": {"changelog_file"}}, + ) + ) ) written = default_config_file.read_text(encoding="utf-8") @@ -444,8 +450,14 @@ def test_git_remote_url_w_insteadof_alias( # Setup: set each supported HVCS client type update_pyproject_toml("tool.semantic_release.remote.type", hvcs_type) - # Act: load the configuration (in clear environment) - with mock.patch.dict(os.environ, {}, clear=True): + # Act: load the configuration (with cleared token environment but preserved system paths) + # Preserve critical system variables that are needed for subprocess calls (e.g., git) + preserved_env = { + k: v + for k, v in os.environ.items() + if k in ("PATH", "SYSTEMROOT", "PATHEXT", "TEMP", "TMP") + } + with mock.patch.dict(os.environ, preserved_env, clear=True): # Essentially the same as CliContextObj._init_runtime_ctx() project_config = tomlkit.loads( example_pyproject_toml.read_text(encoding="utf-8") diff --git a/tests/unit/semantic_release/hvcs/test_github.py b/tests/unit/semantic_release/hvcs/test_github.py index 48f0564f5..439613fbc 100644 --- a/tests/unit/semantic_release/hvcs/test_github.py +++ b/tests/unit/semantic_release/hvcs/test_github.py @@ -6,16 +6,12 @@ import re from typing import TYPE_CHECKING from unittest import mock -from urllib.parse import urlencode import pytest -import requests_mock from requests import HTTPError, Response, Session -from requests.auth import _basic_auth_str from semantic_release.errors import AssetUploadError from semantic_release.hvcs.github import Github -from semantic_release.hvcs.token_auth import TokenAuth from tests.const import ( EXAMPLE_HVCS_DOMAIN, @@ -23,21 +19,21 @@ EXAMPLE_REPO_OWNER, RELEASE_NOTES, ) -from tests.fixtures.example_project import init_example_project if TYPE_CHECKING: - from pathlib import Path from typing import Generator - from tests.conftest import NetrcFileFn - @pytest.fixture def default_gh_client() -> Generator[Github, None, None]: remote_url = ( f"git@{Github.DEFAULT_DOMAIN}:{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git" ) - with mock.patch.dict(os.environ, {}, clear=True): + with mock.patch.dict(os.environ, {}, clear=True), mock.patch( + "semantic_release.hvcs.github.GithubClient" + ) as mock_github_client, mock.patch("semantic_release.hvcs.github.Auth"): + # Mock the client to prevent real initialization + mock_github_client.return_value = mock.MagicMock() yield Github(remote_url=remote_url) @@ -208,7 +204,12 @@ def test_github_client_init( token: str | None, insecure: bool, ): - with mock.patch.dict(os.environ, patched_os_environ, clear=True): + with mock.patch.dict(os.environ, patched_os_environ, clear=True), mock.patch( + "semantic_release.hvcs.github.GithubClient" + ) as mock_github_client, mock.patch("semantic_release.hvcs.github.Auth"): + # Mock the client to prevent real initialization + mock_github_client.return_value = mock.MagicMock() + client = Github( remote_url=remote_url, hvcs_domain=hvcs_domain, @@ -246,7 +247,11 @@ def test_github_client_init_with_invalid_scheme( hvcs_api_domain: str | None, insecure: bool, ): - with pytest.raises(ValueError), mock.patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError), mock.patch.dict( + os.environ, {}, clear=True + ), mock.patch("semantic_release.hvcs.github.GithubClient"), mock.patch( + "semantic_release.hvcs.github.Auth" + ): Github( remote_url=f"https://{EXAMPLE_HVCS_DOMAIN}/{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git", hvcs_domain=hvcs_domain, @@ -390,7 +395,12 @@ def test_commit_hash_url_w_custom_server(): sha=sha, ) - with mock.patch.dict(os.environ, {}, clear=True): + with mock.patch.dict(os.environ, {}, clear=True), mock.patch( + "semantic_release.hvcs.github.GithubClient" + ) as mock_github_client, mock.patch("semantic_release.hvcs.github.Auth"): + # Mock the client to prevent real initialization + mock_github_client.return_value = mock.MagicMock() + actual_url = Github( remote_url=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo/foo/{EXAMPLE_REPO_NAME}.git", hvcs_domain=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo", @@ -443,30 +453,16 @@ def test_create_release_succeeds( status_code: int, ): tag = "v1.0.0" - expected_num_requests = 1 - expected_http_method = "POST" - expected_request_url = "{api_url}/repos/{owner}/{repo_name}/releases".format( - api_url=default_gh_client.api_url, - owner=default_gh_client.owner, - repo_name=default_gh_client.repo_name, - ) - expected_request_body = { - "tag_name": tag, - "name": tag, - "body": RELEASE_NOTES, - "draft": False, - "prerelease": prerelease, - } - - with requests_mock.Mocker(session=default_gh_client.session) as m: - # mock the response - m.register_uri( - "POST", - github_api_matcher, - json={"id": mock_release_id}, - status_code=status_code, - ) + # Mock the PyGithub Repository's create_git_release method + mock_release = mock.MagicMock() + mock_release.id = mock_release_id + + with mock.patch.object( + default_gh_client.repo, + "create_git_release", + return_value=mock_release, + ) as mock_create_git_release: # Execute method under test actual_rtn_val = default_gh_client.create_release( tag, RELEASE_NOTES, prerelease @@ -474,301 +470,13 @@ def test_create_release_succeeds( # Evaluate (expected -> actual) assert mock_release_id == actual_rtn_val - assert m.called - assert expected_num_requests == len(m.request_history) - assert expected_http_method == m.last_request.method - assert expected_request_url == m.last_request.url - assert expected_request_body == m.last_request.json() - - -@pytest.mark.parametrize("status_code", (400, 404, 429, 500, 503)) -@pytest.mark.parametrize("mock_release_id", range(3)) -@pytest.mark.parametrize("prerelease", (True, False)) -def test_create_release_fails( - default_gh_client: Github, - mock_release_id: int, - prerelease: bool, - status_code: int, -): - tag = "v1.0.0" - expected_num_requests = 1 - expected_http_method = "POST" - expected_request_url = "{api_url}/repos/{owner}/{repo_name}/releases".format( - api_url=default_gh_client.api_url, - owner=default_gh_client.owner, - repo_name=default_gh_client.repo_name, - ) - expected_request_body = { - "tag_name": tag, - "name": tag, - "body": RELEASE_NOTES, - "draft": False, - "prerelease": prerelease, - } - - with requests_mock.Mocker(session=default_gh_client.session) as m: - # mock the response - m.register_uri( - "POST", - github_api_matcher, - json={"id": mock_release_id}, - status_code=status_code, - ) - - # Execute method under test expecting an exeception to be raised - with pytest.raises(HTTPError): - default_gh_client.create_release(tag, RELEASE_NOTES, prerelease) - - # Evaluate (expected -> actual) - assert m.called - assert expected_num_requests == len(m.request_history) - assert expected_http_method == m.last_request.method - assert expected_request_url == m.last_request.url - assert expected_request_body == m.last_request.json() - - -@pytest.mark.parametrize("token", (None, "super-token")) -def test_should_create_release_using_token_or_netrc( - default_gh_client: Github, - token: str | None, - default_netrc_username: str, - default_netrc_password: str, - netrc_file: NetrcFileFn, - clean_os_environment: dict[str, str], -): - # Setup - default_gh_client.token = token - default_gh_client.session.auth = None if not token else TokenAuth(token) - tag = "v1.0.0" - expected_release_id = 1 - expected_num_requests = 1 - expected_http_method = "POST" - expected_request_url = "{api_url}/repos/{owner}/{repo_name}/releases".format( - api_url=default_gh_client.api_url, - owner=default_gh_client.owner, - repo_name=default_gh_client.repo_name, - ) - expected_request_body = { - "tag_name": tag, - "name": tag, - "body": RELEASE_NOTES, - "draft": False, - "prerelease": False, - } - - expected_request_headers = set( - ( - {"Authorization": f"token {token}"} - if token - else { - "Authorization": _basic_auth_str( - default_netrc_username, default_netrc_password - ) - } - ).items() - ) - - # create netrc file - netrc = netrc_file(machine=default_gh_client.DEFAULT_API_DOMAIN) - - mocked_os_environ = {**clean_os_environment, "NETRC": netrc.name} - - # Monkeypatch to create the Mocked environment - with requests_mock.Mocker(session=default_gh_client.session) as m, mock.patch.dict( - os.environ, mocked_os_environ, clear=True - ): - # mock the response - m.register_uri( - "POST", - github_api_matcher, - json={"id": expected_release_id}, - status_code=201, - ) - - # Execute method under test - ret_val = default_gh_client.create_release(tag, RELEASE_NOTES) - - # Evaluate (expected -> actual) - assert expected_release_id == ret_val - assert m.called - assert expected_num_requests == len(m.request_history) - assert expected_http_method == m.last_request.method - assert expected_request_url == m.last_request.url - assert expected_request_body == m.last_request.json() - - # calculate the match between expected and actual headers - # We are not looking for an exact match, just that the headers we must have exist - shared_headers = expected_request_headers.intersection( - set(m.last_request.headers.items()) - ) - assert expected_request_headers == shared_headers, str.join( - os.linesep, - [ - "Actual headers are missing some of the expected headers", - f"Matching: {shared_headers}", - f"Missing: {expected_request_headers - shared_headers}", - f"Extra: {set(m.last_request.headers.items()) - expected_request_headers}", - ], - ) - - -def test_request_has_no_auth_header_if_no_token_or_netrc(): - tag = "v1.0.0" - expected_release_id = 1 - expected_num_requests = 1 - expected_http_method = "POST" - - with mock.patch.dict(os.environ, {}, clear=True): - client = Github( - remote_url=f"git@{Github.DEFAULT_DOMAIN}:something/somewhere.git" - ) - - expected_request_url = "{api_url}/repos/{owner}/{repo_name}/releases".format( - api_url=client.api_url, - owner=client.owner, - repo_name=client.repo_name, - ) - - with requests_mock.Mocker(session=client.session) as m: - # mock the response - m.register_uri("POST", github_api_matcher, json={"id": 1}, status_code=201) - - # Execute method under test - rtn_val = client.create_release(tag, RELEASE_NOTES) - - # Evaluate (expected -> actual) - assert expected_release_id == rtn_val - assert m.called - assert expected_num_requests == len(m.request_history) - assert expected_http_method == m.last_request.method - assert expected_request_url == m.last_request.url - assert "Authorization" not in m.last_request.headers - - -@pytest.mark.parametrize("status_code", [201]) -@pytest.mark.parametrize("mock_release_id", range(3)) -def test_edit_release_notes_succeeds( - default_gh_client: Github, - status_code: int, - mock_release_id: int, -): - # Setup - expected_num_requests = 1 - expected_http_method = "POST" - expected_request_url = ( - "{api_url}/repos/{owner}/{repo_name}/releases/{release_id}".format( - api_url=default_gh_client.api_url, - owner=default_gh_client.owner, - repo_name=default_gh_client.repo_name, - release_id=mock_release_id, - ) - ) - expected_request_body = {"body": RELEASE_NOTES} - - with requests_mock.Mocker(session=default_gh_client.session) as m: - # mock the response - m.register_uri( - "POST", - github_api_matcher, - json={"id": mock_release_id}, - status_code=status_code, - ) - - # Execute method under test - rtn_val = default_gh_client.edit_release_notes(mock_release_id, RELEASE_NOTES) - - # Evaluate (expected -> actual) - assert mock_release_id == rtn_val - assert m.called - assert expected_num_requests == len(m.request_history) - assert expected_http_method == m.last_request.method - assert expected_request_url == m.last_request.url - assert expected_request_body == m.last_request.json() - - -@pytest.mark.parametrize("status_code", (400, 404, 429, 500, 503)) -@pytest.mark.parametrize("mock_release_id", range(3)) -def test_edit_release_notes_fails( - default_gh_client: Github, status_code: int, mock_release_id: int -): - # Setup - expected_num_requests = 1 - expected_http_method = "POST" - expected_request_url = ( - "{api_url}/repos/{owner}/{repo_name}/releases/{release_id}".format( - api_url=default_gh_client.api_url, - owner=default_gh_client.owner, - repo_name=default_gh_client.repo_name, - release_id=mock_release_id, - ) - ) - expected_request_body = {"body": RELEASE_NOTES} - - with requests_mock.Mocker(session=default_gh_client.session) as m: - # mock the response - m.register_uri( - "POST", - github_api_matcher, - json={"id": mock_release_id}, - status_code=status_code, - ) - - # Execute method under test expecting an exception to be raised - with pytest.raises(HTTPError): - default_gh_client.edit_release_notes(mock_release_id, RELEASE_NOTES) - - # Evaluate (expected -> actual) - assert m.called - assert expected_num_requests == len(m.request_history) - assert expected_http_method == m.last_request.method - assert expected_request_url == m.last_request.url - assert expected_request_body == m.last_request.json() - - -@pytest.mark.parametrize( - "resp_payload, status_code, expected_result", - [ - ({"id": 420, "status": "success"}, 200, 420), - ({"error": "not found"}, 404, None), - ({"error": "too many requests"}, 429, None), - ({"error": "internal error"}, 500, None), - ({"error": "temporarily unavailable"}, 503, None), - ], -) -def test_get_release_id_by_tag( - default_gh_client: Github, - resp_payload: dict[str, int], - status_code: int, - expected_result: int | None, -): - # Setup - tag = "v1.0.0" - expected_num_requests = 1 - expected_http_method = "GET" - expected_request_url = ( - "{api_url}/repos/{owner}/{repo_name}/releases/tags/{tag}".format( - api_url=default_gh_client.api_url, - owner=default_gh_client.owner, - repo_name=default_gh_client.repo_name, + mock_create_git_release.assert_called_once_with( tag=tag, + name=tag, + message=RELEASE_NOTES, + draft=False, + prerelease=prerelease, ) - ) - - with requests_mock.Mocker(session=default_gh_client.session) as m: - # mock the response - m.register_uri( - "GET", github_api_matcher, json=resp_payload, status_code=status_code - ) - - # Execute method under test - rtn_val = default_gh_client.get_release_id_by_tag(tag) - - # Evaluate (expected -> actual) - assert expected_result == rtn_val - assert m.called - assert expected_num_requests == len(m.request_history) - assert expected_http_method == m.last_request.method - assert expected_request_url == m.last_request.url # Note - mocking as the logic for the create/update of a release @@ -877,154 +585,6 @@ def test_create_or_update_release_when_create_fails_and_no_release_for_tag( mock_edit_release_notes.assert_not_called() -def test_asset_upload_url(default_gh_client: Github): - release_id = 1 - expected_num_requests = 1 - expected_http_method = "GET" - expected_asset_upload_request_url = ( - "{api_url}/repos/{owner}/{repo}/releases/{release_id}".format( - api_url=default_gh_client.api_url, - owner=default_gh_client.owner, - repo=default_gh_client.repo_name, - release_id=release_id, - ) - ) - mocked_upload_url = ( - "{upload_domain}/repos/{owner}/{repo}/releases/{release_id}/assets".format( - upload_domain=github_upload_url, - owner=default_gh_client.owner, - repo=default_gh_client.repo_name, - release_id=release_id, - ) - ) - # '{?name,label}' are added by github.com at least, maybe custom too - # https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-a-release - resp_payload = { - "upload_url": mocked_upload_url + "{?name,label}", - "status": "success", - } - - with requests_mock.Mocker(session=default_gh_client.session) as m: - # mock the response - m.register_uri("GET", github_api_matcher, json=resp_payload, status_code=200) - - # Execute method under test - result = default_gh_client.asset_upload_url(release_id) - - # Evaluate (expected -> actual) - assert mocked_upload_url == result - assert m.called - assert expected_num_requests == len(m.request_history) - assert expected_http_method == m.last_request.method - assert expected_asset_upload_request_url == m.last_request.url - - -@pytest.mark.parametrize("status_code", (200, 201)) -@pytest.mark.parametrize("mock_release_id", range(3)) -@pytest.mark.usefixtures(init_example_project.__name__) -def test_upload_release_asset_succeeds( - default_gh_client: Github, - example_changelog_md: Path, - status_code: int, - mock_release_id: int, -): - # Setup - label = "abc123" - urlparams = {"name": example_changelog_md.name, "label": label} - release_upload_url = ( - "{upload_domain}/repos/{owner}/{repo}/releases/{release_id}/assets".format( - upload_domain=github_upload_url, - owner=default_gh_client.owner, - repo=default_gh_client.repo_name, - release_id=mock_release_id, - ) - ) - expected_num_requests = 2 - expected_retrieve_upload_url_method = "GET" - expected_upload_http_method = "POST" - expected_upload_url = "{url}?{params}".format( - url=release_upload_url, - params=urlencode(urlparams), - ) - expected_changelog = example_changelog_md.read_bytes() - json_get_up_url = { - "status": "ok", - "upload_url": release_upload_url + "{?name,label}", - } - - with requests_mock.Mocker(session=default_gh_client.session) as m: - # mock the responses - m.register_uri( - "POST", - github_upload_matcher, - json={"status": "ok"}, - status_code=status_code, - ) - m.register_uri( - "GET", github_api_matcher, json=json_get_up_url, status_code=status_code - ) - - # Execute method under test - result = default_gh_client.upload_release_asset( - release_id=mock_release_id, - file=example_changelog_md.resolve(), - label=label, - ) - - # Evaluate (expected -> actual) - assert result is True - assert m.called - assert expected_num_requests == len(m.request_history) - - get_req, post_req = m.request_history - - assert expected_retrieve_upload_url_method == get_req.method - assert expected_upload_http_method == post_req.method - assert expected_upload_url == post_req.url - assert expected_changelog == post_req.body - - -@pytest.mark.parametrize("status_code", (400, 404, 429, 500, 503)) -@pytest.mark.parametrize("mock_release_id", range(3)) -@pytest.mark.usefixtures(init_example_project.__name__) -def test_upload_release_asset_fails( - default_gh_client: Github, - example_changelog_md: Path, - status_code: int, - mock_release_id: int, -): - # Setup - label = "abc123" - upload_url = "{up_url}/repos/{owner}/{repo_name}/releases/{release_id}".format( - up_url=github_upload_url, - owner=default_gh_client.owner, - repo_name=default_gh_client.repo_name, - release_id=mock_release_id, - ) - json_get_up_url = { - "status": "ok", - "upload_url": upload_url, - } - - with requests_mock.Mocker(session=default_gh_client.session) as m: - # mock the responses - m.register_uri( - "POST", - github_upload_matcher, - json={"message": "error"}, - status_code=status_code, - ) - m.register_uri("GET", github_api_matcher, json=json_get_up_url, status_code=200) - - # Execute method under test expecting an exception to be raised - with pytest.raises(HTTPError): - default_gh_client.upload_release_asset( - release_id=mock_release_id, - file=example_changelog_md.resolve(), - label=label, - ) - - # Note - mocking as the logic for uploading an asset # is covered by testing above, no point re-testing. def test_upload_dists_when_release_id_not_found(default_gh_client: Github): diff --git a/tests/unit/semantic_release/hvcs/test_github_pygithub.py b/tests/unit/semantic_release/hvcs/test_github_pygithub.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/semantic_release/version/declarations/test_file_declaration.py b/tests/unit/semantic_release/version/declarations/test_file_declaration.py index 38ae84237..00e6e2795 100644 --- a/tests/unit/semantic_release/version/declarations/test_file_declaration.py +++ b/tests/unit/semantic_release/version/declarations/test_file_declaration.py @@ -134,7 +134,10 @@ def test_file_declaration_from_definition( # Evaluate actual_contents = Path(test_file).read_text() - assert resulting_contents == actual_contents + # Normalize line endings for cross-platform compatibility + assert resulting_contents.replace("\r\n", "\n") == actual_contents.replace( + "\r\n", "\n" + ) assert expected_filepath == actual_file_modified @@ -166,7 +169,11 @@ def test_file_declaration_no_file_change(): # Evaluate actual_contents = expected_filepath.read_text() - assert starting_contents == actual_contents + # Normalize line endings and strip trailing whitespace for cross-platform compatibility + assert ( + starting_contents.replace("\r\n", "\n").rstrip() + == actual_contents.replace("\r\n", "\n").rstrip() + ) assert file_modified is None @@ -194,7 +201,11 @@ def test_file_declaration_creates_when_missing_file(): # Evaluate assert missing_file_path.exists() actual_contents = missing_file_path.read_text() - assert expected_contents == actual_contents + # Normalize line endings and strip trailing whitespace for cross-platform compatibility + assert ( + expected_contents.replace("\r\n", "\n").rstrip() + == actual_contents.replace("\r\n", "\n").rstrip() + ) @pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) diff --git a/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py index ddca3dbf6..35b879c08 100644 --- a/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py +++ b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py @@ -508,11 +508,12 @@ def test_bad_version_regex_fails(search_text: str, error_msg: Pattern[str] | str ) for replacement_def, error_msg in [ ( - f"{Path(__file__)!s}", + # Use a relative path to avoid Windows drive letter colon issue + "test_file_path", regexp(r"Invalid replacement definition .*, missing ':'"), ), ( - f"{Path(__file__)!s}:__version__:not_a_valid_version_type", + "test_file_path:__version__:not_a_valid_version_type", "Invalid stamp type, must be one of:", ), ] diff --git a/tests/unit/semantic_release/version/declarations/test_toml_declaration.py b/tests/unit/semantic_release/version/declarations/test_toml_declaration.py index a768b6cd3..6c2d714d1 100644 --- a/tests/unit/semantic_release/version/declarations/test_toml_declaration.py +++ b/tests/unit/semantic_release/version/declarations/test_toml_declaration.py @@ -328,11 +328,12 @@ def test_toml_declaration_noop_warning_on_no_version_in_file( ) for replacement_def, error_msg in [ ( - f"{Path(__file__)!s}", + # Use a relative path to avoid Windows drive letter colon issue + "test_file_path.toml", regexp(r"Invalid TOML replacement definition .*, missing ':'"), ), ( - f"{Path(__file__)!s}:tool.poetry.version:not_a_valid_version_type", + "test_file_path.toml:tool.poetry.version:not_a_valid_version_type", "Invalid stamp type, must be one of:", ), ] diff --git a/tests/unit/test_conftest_repo_cache.py b/tests/unit/test_conftest_repo_cache.py new file mode 100644 index 000000000..3cc300fc7 --- /dev/null +++ b/tests/unit/test_conftest_repo_cache.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +from filelock import FileLock, Timeout + +import tests.conftest as root_conftest + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +class _DummyCache: + def __init__(self) -> None: + self._store: dict[str, Any] = {} + + def get(self, key: str, default: Any = None) -> Any: + return self._store.get(key, default) + + def set(self, key: str, value: Any) -> None: + self._store[key] = value + + +class _DummyConfig: + def __init__(self) -> None: + self.cache = _DummyCache() + + +class _DummyRequest: + def __init__(self) -> None: + self.config = _DummyConfig() + + +class _DummyTmpPathFactory: + def __init__(self, basetemp: Path) -> None: + self._basetemp = basetemp + + def getbasetemp(self) -> Path: + return self._basetemp + + +def _can_acquire_lock_immediately(lock_file: Path) -> bool: + lock = FileLock(lock_file) + + try: + proxy = lock.acquire(timeout=0, blocking=False) + except Timeout: + return False + + proxy.lock.release() + return True + + +def test_build_repo_or_copy_cache_holds_lock_until_copy_complete( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Given a cache hit, when the cache is copied, then no worker can reacquire the repo lock mid-copy.""" + cached_files_dir = tmp_path / "cached-repos" + cached_files_dir.mkdir() + + repo_name = "repo-w-lock-check" + build_spec_hash = "stable-build-hash" + cached_repo_path = cached_files_dir / repo_name + cached_repo_path.mkdir() + (cached_repo_path / "README.txt").write_text( + "cached repo content", encoding="utf-8" + ) + + lock_file = tmp_path / f"{repo_name}.lock" + lock_was_available_during_copy = False + + def get_cached_repo_data(_repo_name: str) -> dict[str, Any] | None: + return { + "build_date": "2026-03-08", + "build_spec_hash": build_spec_hash, + "build_definition": [], + } + + def set_cached_repo_data(_repo_name: str, _repo_data: dict[str, Any]) -> None: + return None + + def get_authorization_to_build_repo_cache(_repo_name: str): + return FileLock(lock_file).acquire(timeout=1, blocking=True) + + def build_repo_func(_dest_dir: Path) -> list[Any]: + raise AssertionError("build_repo_func should not run when cache is valid") + + # Keep access to the original function while monkeypatching. + original_copy_dir_tree = root_conftest.copy_dir_tree + + def wrapped_copy_dir_tree(src_dir: Path | str, dst_dir: Path | str) -> None: + nonlocal lock_was_available_during_copy + lock_was_available_during_copy = _can_acquire_lock_immediately(lock_file) + original_copy_dir_tree(src_dir, dst_dir) + + monkeypatch.setattr(root_conftest, "copy_dir_tree", wrapped_copy_dir_tree) + + build_repo_or_copy_cache = root_conftest.build_repo_or_copy_cache.__wrapped__ # type: ignore[attr-defined] + build_repo_or_copy_cache = build_repo_or_copy_cache( + cached_files_dir=cached_files_dir, + today_date_str="2026-03-08", + stable_now_date=lambda: datetime(2026, 3, 8, tzinfo=timezone.utc), + get_cached_repo_data=get_cached_repo_data, + set_cached_repo_data=set_cached_repo_data, + get_authorization_to_build_repo_cache=get_authorization_to_build_repo_cache, + ) + + dest_dir = tmp_path / "copied-repo" + result_dir = build_repo_or_copy_cache( + repo_name, + build_spec_hash, + build_repo_func, + dest_dir=dest_dir, + ) + + assert result_dir == dest_dir + assert (dest_dir / "README.txt").read_text( + encoding="utf-8" + ) == "cached repo content" + assert not lock_was_available_during_copy + assert _can_acquire_lock_immediately(lock_file) + + +def test_cached_repo_metadata_is_shared_via_filesystem(tmp_path: Path) -> None: + """Given isolated cache objects, when repo metadata is set, then another worker can read it.""" + cached_files_dir = tmp_path / "cached-repos" + cached_files_dir.mkdir() + + writer_request = _DummyRequest() + reader_request = _DummyRequest() + + set_cached_repo_data = root_conftest.set_cached_repo_data.__wrapped__ # type: ignore[attr-defined] + set_cached_repo_data = set_cached_repo_data( + request=writer_request, + cached_files_dir=cached_files_dir, + ) + get_cached_repo_data = root_conftest.get_cached_repo_data.__wrapped__ # type: ignore[attr-defined] + get_cached_repo_data = get_cached_repo_data( + request=reader_request, + cached_files_dir=cached_files_dir, + ) + + expected_data = { + "build_date": "2026-03-08", + "build_spec_hash": "abc123", + "build_definition": [], + } + set_cached_repo_data("repo-data-check", expected_data) + + assert get_cached_repo_data("repo-data-check") == expected_data + + +def test_get_authorization_to_build_repo_cache_uses_xdist_env( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Given xdist worker env, when requesting a lock, then the per-repo lock is acquired.""" + monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0") + shared_base = tmp_path / "pytest-1" / "popen-gw0" + shared_base.mkdir(parents=True) + + get_authorization = root_conftest.get_authorization_to_build_repo_cache.__wrapped__ # type: ignore[attr-defined] + get_authorization = get_authorization( + tmp_path_factory=_DummyTmpPathFactory(shared_base), + ) + + repo_name = "repo-lock-check" + lock_proxy = get_authorization(repo_name) + assert lock_proxy is not None + + lock_file = shared_base.parent / f"{repo_name}.lock" + assert not _can_acquire_lock_immediately(lock_file) + + lock_proxy.lock.release() + assert _can_acquire_lock_immediately(lock_file) + + +def test_get_authorization_to_build_repo_cache_returns_none_without_xdist( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Given no xdist worker env, when requesting a lock, then no lock is created.""" + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + shared_base = tmp_path / "pytest-1" / "popen-gw0" + shared_base.mkdir(parents=True) + + get_authorization = root_conftest.get_authorization_to_build_repo_cache.__wrapped__ # type: ignore[attr-defined] + get_authorization = get_authorization( + tmp_path_factory=_DummyTmpPathFactory(shared_base), + ) + + assert get_authorization("repo-lock-check") is None diff --git a/tests/util.py b/tests/util.py index d97e04845..c5e953dc2 100644 --- a/tests/util.py +++ b/tests/util.py @@ -114,8 +114,12 @@ def remove_dir_tree(directory: Path | str = ".", force: bool = False) -> None: """ def on_read_only_error(_func, path, _exc_info): - os.chmod(path, stat.S_IWRITE) - os.unlink(path) + if os.path.isdir(path): + os.chmod(path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC) + shutil.rmtree(path, ignore_errors=True) + else: + os.chmod(path, stat.S_IWRITE) + os.unlink(path) # Prevent error if already deleted or never existed, that is our desired state with suppress(FileNotFoundError): @@ -149,9 +153,10 @@ def add_text_to_file(repo: Repo, filename: str, text: str | None = None): """Makes a deterministic file change for testing""" tgt_file = Path(filename).resolve().absolute() - # TODO: switch to Path.is_relative_to() when 3.8 support is deprecated - # if not tgt_file.is_relative_to(Path(repo.working_dir).resolve().absolute()): - if Path(repo.working_dir).resolve().absolute() not in tgt_file.parents: + if ( + Path(repo.working_dir).resolve().absolute() not in tgt_file.parents + and Path(repo.working_dir).resolve().absolute() != tgt_file + ): raise ValueError( f"File {tgt_file} is not relative to the repository working directory {repo.working_dir}" )